還在用ELK?是時候了解一下輕量化日志服務Loki了
圖片來自 Pexels
本文基于我們對 Loki 的使用和理解,從它產(chǎn)生的背景、解決的問題、采用的方案、系統(tǒng)架構、實現(xiàn)邏輯等做一些剖析,希望對關注 Loki 的小伙伴們提供一些幫助。
在日常的系統(tǒng)可視化監(jiān)控過程中,當監(jiān)控探知到指標異常時,我們往往需要對問題的根因做出定位。
但監(jiān)控數(shù)據(jù)所暴露的信息是提前預設、高度提煉的,在信息量上存在著很大的不足,它需要結合能夠承載豐富信息的日志系統(tǒng)一起使用。
當監(jiān)控系統(tǒng)探知到異常告警,我們通常在 Dashboard 上根據(jù)異常指標所屬的集群、主機、實例、應用、時間等信息圈定問題的大致方向,然后跳轉到日志系統(tǒng)做更精細的查詢,獲取更豐富的信息來最終判斷問題根因。
在如上流程中,監(jiān)控系統(tǒng)和日志系統(tǒng)往往是獨立的,使用方式具有很大差異。比如監(jiān)控系統(tǒng) Prometheus 比較受歡迎,日志系統(tǒng)多采用 ES+Kibana 。
他們具有完全不同的概念、不同的搜索語法和界面,這不僅給使用者增加了學習成本,也使得在使用時需在兩套系統(tǒng)中頻繁做上下文切換,對問題的定位遲滯。
此外,日志系統(tǒng)多采用全文索引來支撐搜索服務,它需要為日志的原文建立反向索引,這會導致最終存儲數(shù)據(jù)相較原始內(nèi)容成倍增長,產(chǎn)生不可小覷的存儲成本。
并且,不管數(shù)據(jù)將來是否會被搜索,都會在寫入時因為索引操作而占用大量的計算資源,這對于日志這種寫多讀少的服務無疑也是一種計算資源的浪費。
Loki 則是為了應對上述問題而產(chǎn)生的解決方案,它的目標是打造能夠與監(jiān)控深度集成、成本極度低廉的日志系統(tǒng)。
Loki 日志方案
低使用成本
①數(shù)據(jù)模型
在數(shù)據(jù)模型上,Loki 參考了 Prometheus ,數(shù)據(jù)由標簽、時間戳、內(nèi)容組成,所有標簽相同的數(shù)據(jù)屬于同一日志流:
- 標簽,描述日志所屬集群、服務、主機、應用、類型等元信息, 用于后期搜索服務。
- 時間戳,日志的產(chǎn)生時間。
- 內(nèi)容,日志的原始內(nèi)容。
具有如下結構:
- {
- "stream": {
- "label1": "value1",
- "label1": "value2"
- }, # 標簽
- "values": [
- ["<timestamp nanoseconds>","log content"], # 時間戳,內(nèi)容
- ["<timestamp nanoseconds>","log content"]
- ]
- }
Loki 還支持多租戶,同一租戶下具有完全相同標簽的日志所組成的集合稱為一個日志流。
在日志的采集端使用和監(jiān)控時序數(shù)據(jù)一致的標簽,這樣在可以后續(xù)與監(jiān)控系統(tǒng)結合時使用相同的標簽,也為在 UI 界面中與監(jiān)控結合使用做快速上下文切換提供數(shù)據(jù)基礎。
LogQL:Loki 使用類似 Prometheus 的 PromQL 的查詢語句 logQL ,語法簡單并貼近社區(qū)使用習慣,降低用戶學習和使用成本。
語法例子如下:
- {file="debug.log""} |= "err"
流選擇器:{label1="value1", label2="value2"}, 通過標簽選擇日志流, 支持等、不等、匹配、不匹配等選擇方式。過濾器:|= "err",過濾日志內(nèi)容,支持包含、不包含、匹配、不匹配等過濾方式。
這種工作方式類似于 find+grep,find 找出文件,grep 從文件中逐行匹配:
- find . -name "debug.log" | grep err
logQL 除支持日志內(nèi)容查詢外,還支持對日志總量、頻率等聚合計算。
Grafana:在 Grafana 中原生支持 Loki 插件,將監(jiān)控和日志查詢集成在一起,在同一 UI 界面中可以對監(jiān)控數(shù)據(jù)和日志進行 side-by-side 的下鉆查詢探索,比使用不同系統(tǒng)反復進行切換更直觀、更便捷。
此外,在 Dashboard 中可以將監(jiān)控和日志查詢配置在一起,這樣可同時查看監(jiān)控數(shù)據(jù)走勢和日志內(nèi)容,為捕捉可能存在的問題提供更直觀的途徑。
低存儲成本
只索引與日志相關的元數(shù)據(jù)標簽,而日志內(nèi)容則以壓縮方式存儲于對象存儲中, 不做任何索引。
相較于 ES 這種全文索引的系統(tǒng),數(shù)據(jù)可在十倍量級上降低,加上使用對象存儲,最終存儲成本可降低數(shù)十倍甚至更低。
方案不解決復雜的存儲系統(tǒng)問題,而是直接應用現(xiàn)有成熟的分布式存儲系統(tǒng),比如 S3、GCS、Cassandra、BigTable 。
Loki 架構
整體上 Loki 采用了讀寫分離的架構,由多個模塊組成:
- Promtail、Fluent-bit、Fluentd、Rsyslog 等開源客戶端負責采集并上報日志。
- Distributor:日志寫入入口,將數(shù)據(jù)轉發(fā)到 Ingester。
- Ingester:日志的寫入服務,緩存并寫入日志內(nèi)容和索引到底層存儲。
- Querier:日志讀取服務,執(zhí)行搜索請求。
- QueryFrontend:日志讀取入口,分發(fā)讀取請求到 Querier 并返回結果。
- Cassandra/BigTable/DnyamoDB/S3/GCS:索引、日志內(nèi)容底層存儲。
- Cache:緩存,支持 Redis/Memcache/本地 Cache。
其主體結構如下圖所示:
Distributor:作為日志寫入的入口服務,其負責對上報數(shù)據(jù)進行解析、校驗與轉發(fā)。
它將接收到的上報數(shù)解析完成后會進行大小、條目、頻率、標簽、租戶等參數(shù)校驗,然后將合法數(shù)據(jù)轉發(fā)到 Ingester 服務,其在轉發(fā)之前最重要的任務是確保同一日志流的數(shù)據(jù)必須轉發(fā)到相同 Ingester 上,以確保數(shù)據(jù)的順序性。
Hash 環(huán):Distributor 采用一致性哈希與副本因子相結合的辦法來決定數(shù)據(jù)轉發(fā)到哪些 Ingester 上。
Ingester 在啟動后,會生成一系列的 32 位隨機數(shù)作為自己的 Token ,然后與這一組 Token 一起將自己注冊到 Hash 環(huán)中。
在選擇數(shù)據(jù)轉發(fā)目的地時,Distributor 根據(jù)日志的標簽和租戶 ID 生成 Hash,然后在 Hash 環(huán)中按 Token 的升序查找第一個大于這個 Hash 的 Token ,這個 Token 所對應的 Ingester 即為這條日志需要轉發(fā)的目的地。
如果設置了副本因子,順序的在之后的 Token 中查找不同的 Ingester 做為副本的目的地。
Hash 環(huán)可存儲于 etcd、consul 中。另外 Loki 使用 Memberlist 實現(xiàn)了集群內(nèi)部的 KV 存儲,如不想依賴 etcd 或 consul ,可采用此方案。
輸入輸出:Distributor 的輸入主要是以 HTTP 協(xié)議批量的方式接受上報日志,日志封裝格式支持 JSON 和 PB ,數(shù)據(jù)封裝結構:
- [
- {
- "stream": {
- "label1": "value1",
- "label1": "value2"
- },
- "values": [
- ["<timestamp nanoseconds>","log content"],
- ["<timestamp nanoseconds>","log content"]
- ]
- },
- ......
- ]
Distributor 以 grpc 方式向 ingester 發(fā)送數(shù)據(jù),數(shù)據(jù)封裝結構:
- {
- "streams": [
- {
- "labels": "{label1=value1, label2=value2}",
- "entries": [
- {"ts": <unix epoch in nanoseconds>, "line:":"<log line>" },
- {"ts": <unix epoch in nanoseconds>, "line:":"<log line>" },
- ]
- }
- ....
- ]
- }
①Ingester
作為 Loki 的寫入模塊,Ingester 主要任務是緩存并寫入數(shù)據(jù)到底層存儲。根據(jù)寫入數(shù)據(jù)在模塊中的生命周期,ingester 大體上分為校驗、緩存、存儲適配三層結構。
②校驗
Loki 有個重要的特性是它不整理數(shù)據(jù)亂序,要求同一日志流的數(shù)據(jù)必須嚴格遵守時間戳單調遞增順序寫入。
所以除對數(shù)據(jù)的長度、頻率等做校驗外,至關重要的是日志順序檢查。
Ingester 對每個日志流里每一條日志都會和上一條進行時間戳和內(nèi)容的對比,策略如下:
- 與上一條日志相比,本條日志時間戳更新,接收本條日志。
- 與上一條日志相比,時間戳相同內(nèi)容不同,接收本條日志。
- 與上一條日志相比,時間戳和內(nèi)容都相同,忽略本條日志。
- 與上一條日志相比,本條日志時間戳更老,返回亂序錯誤。
③緩存
日志在內(nèi)存中的緩存采用多層樹形結構對不同租戶、日志流做出隔離。同一日志流采用順序追加方式寫入分塊:
- Instances:以租戶的 userID 為鍵 Instance 為值的 Map 結構。
- Instance:一個租戶下所有日志流(stream)的容器。
- Streams:以日志流的指紋(streamFP)為鍵,Stream 為值的 Map 結構。
- Stream:一個日志流所有 Chunk 的容器。
- Chunks:Chunk 的列表。
- Chunk:持久存儲讀寫最小單元在內(nèi)存態(tài)的結構。
- Block:Chunk 的分塊,為已壓縮歸檔的數(shù)據(jù)。
- HeadBlock:尚在開放寫入的分塊。
- Entry:單條日志單元,包含時間戳(timestamp)和日志內(nèi)容(line)
整體結構如下:
Chunks:在向內(nèi)存寫入數(shù)據(jù)前,ingester 首先會根據(jù)租戶ID(userID)和由標簽計算的指紋(streamPF)定位到日志流(stream)及 Chunks。
Chunks 由按時間升序排列的 chunk 組成,最后一個 chunk 接收最新寫入的數(shù)據(jù),其他則等刷寫到底層存儲。
當最后一個 chunk 的存活時間或數(shù)據(jù)大小超過指定閾值時,Chunks 尾部追加新的 chunk 。
Chunk:Chunk 為 Loki 在底層存儲上讀寫的最小單元在內(nèi)存態(tài)下的結構。其由若干 block 組成,其中 headBlock 為正在開放寫入的 block ,而其他 Block 則已經(jīng)歸檔壓縮的數(shù)據(jù)。
Block:Block 為數(shù)據(jù)的壓縮單元,目的是為了在讀取操作那里避免因為每次解壓整個 Chunk 而浪費計算資源,因為很多情況下是讀取一個 chunk 的部分數(shù)據(jù)就滿足所需數(shù)據(jù)量而返回結果了。
Block 存儲的是日志的壓縮數(shù)據(jù),其結構為按時間順序的日志時間戳和原始內(nèi)容,壓縮可采用 gzip、snappy 、lz4 等方式。
HeadBlock:正在接收寫入的特殊 block ,它在滿足一定大小后會被壓縮歸檔為 Block ,然后新 headBlock 會被創(chuàng)建。
存儲適配:由于底層存儲要支持 S3、Cassandra、BigTable、DnyamoDB 等系統(tǒng),適配層將各種系統(tǒng)的讀寫操作抽象成統(tǒng)一接口,負責與他們進行數(shù)據(jù)交互。
④輸出
Loki 以 Chunk 為單位在存儲系統(tǒng)中讀寫數(shù)據(jù)。在持久存儲態(tài)下的 Chunk 具有如下結構:
- meta:封裝 chunk 所屬 stream 的指紋、租戶 ID,開始截止時間等元信息。
- data:封裝日志內(nèi)容,其中一些重要字段。
- encode 保存數(shù)據(jù)的壓縮方式。
- block-N bytes 保存一個 block 的日志數(shù)據(jù)。
- #blocks section byte offset 單元記錄 #block 單元的偏移量。
- #block 單元記錄一共有多少個 block。
- #entries 和 block-N bytes 一一對應,記錄每個 block 里有日式行數(shù)、時間起始點,blokc-N bytes 的開始位置和長度等元信息。
Chunk 數(shù)據(jù)的解析順序:
- 根據(jù)尾部的 #blocks section byte offset 單元得到 #block 單元的位置。
- 根據(jù) #block 單元記錄得出 chunk 里 block 數(shù)量。
- 從 #block 單元所在位置開始讀取所有 block 的 entries、mint、maxt、offset、len 等元信息。
- 順序的根據(jù)每個 block 元信息解析出 block 的數(shù)據(jù)。
⑤索引
Loki 只索引了標簽數(shù)據(jù),用于實現(xiàn)標簽→日志流→Chunk 的索引映射, 以分表形式在存儲層存儲。
表結構如下:
- CREATE TABLE IF NOT EXISTS Table_N (
- hash text,
- range blob,
- value blob,
- PRIMARY KEY (hash, range)
- )
Table_N,根據(jù)時間周期分表名;hash, 不同查詢類型時使用的索引;range,范圍查詢字段;value,日志標簽的值。
數(shù)據(jù)類型:Loki 保存了不同類型的索引數(shù)據(jù)用以實現(xiàn)不同映射場景,對于每種類型的映射數(shù)據(jù),Hash/Range/Value 三個字段的數(shù)據(jù)組成如下圖所示:
seriesID 為日志流 ID,shard 為分片,userID 為租戶 ID,labelName 為標簽名,labelValueHash 為標簽值 hash,chunkID 為 chunk 的 ID,chunkThrough 為 chunk 里最后一條數(shù)據(jù)的時間這些數(shù)據(jù)元素在映射過程中的作用在 Querier 環(huán)節(jié)的[查詢流程]((null))做詳細介紹。
上圖中三種顏色標識的索引類型從上到下分別為:
- 數(shù)據(jù)類型 1:用于根據(jù)用戶 ID 搜索查詢所有日志流的 ID。
- 數(shù)據(jù)類型 2:用于根據(jù)用戶 ID 和標簽查詢?nèi)罩玖鞯?ID。
- 數(shù)據(jù)類型 3:用于根據(jù)日志流 ID 查詢底層存儲 Chunk 的 ID。
除了采用分表外,Loki 還采用分桶、分片的方式優(yōu)化索引查詢速度。
分桶:
以天分割:bucketID = timestamp / secondsInDay。
以小時分割:bucketID = timestamp / secondsInHour。
分片:將不同日志流的索引分散到不同分片,shard = seriesID% 分片數(shù)。
Chunk 狀態(tài):Chunk 作為在 Ingester 中重要的數(shù)據(jù)單元,其在內(nèi)存中的生命周期內(nèi)分如下四種狀態(tài):
- Writing:正在寫入新數(shù)據(jù)。
- Waiting flush:停止寫入新數(shù)據(jù),等待寫入到存儲。
- Retain:已經(jīng)寫入存儲,等待銷毀。
- Destroy:已經(jīng)銷毀。
四種狀態(tài)之間的轉換以 writing→waiting flush→retain→destroy 順序進行。
狀態(tài)轉換時機:
- 協(xié)作觸發(fā):有新的數(shù)據(jù)寫入請求。
- 定時觸發(fā):刷寫周期觸發(fā)將 chunk 寫入存儲,回收周期觸發(fā)將 chunk 銷毀。
writing 轉為 waiting flush:chunk 初始狀態(tài)為 writing,標識正在接受數(shù)據(jù)的寫入,滿足如下條件則進入到等待刷寫狀態(tài):
- chunk 空間滿(協(xié)作觸發(fā))。
- chunk 的存活時間(首末兩條數(shù)據(jù)時間差)超過閾值 (定時觸發(fā))。
- chunk 的空閑時間(連續(xù)未寫入數(shù)據(jù)時長)超過設置 (定時觸發(fā))。
waiting flush 轉為 etain:Ingester 會定時的將等待刷寫的 chunk 寫到底層存儲,之后這些 chunk 會處于”retain“狀態(tài),這是因為 ingester 提供了對最新數(shù)據(jù)的搜索服務,需要在內(nèi)存里保留一段時間,retain 狀態(tài)則解耦了數(shù)據(jù)的刷寫時間以及在內(nèi)存中的保留時間,方便視不同選項優(yōu)化內(nèi)存配置。
destroy,被回收等待 GC 銷毀:總體上,Loki 由于針對日志的使用場景,采用了順序追加方式寫入,只索引元信息,極大程度上簡化了它的數(shù)據(jù)結構和處理邏輯,這也為 Ingester 能夠應對高速寫入提供了基礎。
Querier:查詢服務的執(zhí)行組件,其負責從底層存儲拉取數(shù)據(jù)并按照 LogQL 語言所描述的篩選條件過濾。它可以直接通過 API 提供查詢服務,也可以與 queryFrontend 結合使用實現(xiàn)分布式并發(fā)查詢。
⑥查詢類型
查詢類型如下:
- 范圍日志查詢
- 單日志查詢
- 統(tǒng)計查詢
- 元信息查詢
在這些查詢類型中,范圍日志查詢應用最為廣泛,所以下文只對范圍日志查詢做詳細介紹。
并發(fā)查詢:對于單個查詢請求,雖然可以直接調用 Querier 的 API 進行查詢,但很容易會由于大查詢導致 OOM,為應對此種問題 querier 與 queryFrontend 結合一起實現(xiàn)查詢分解與多 querier 并發(fā)執(zhí)行。
每個 querier 都與所有 queryFrontend 建立 grpc 雙向流式連接,實時從 queryFrontend 中獲取已經(jīng)分割的子查詢求,執(zhí)行后將結果發(fā)送回 queryFrontend。
具體如何分割查詢及在 querier 間調度子查詢將在 queryFrontend 環(huán)節(jié)介紹。
⑧查詢流程
先解析 logQL 指令,然后查詢?nèi)罩玖?ID 列表。
Loki 根據(jù)不同的標簽選擇器語法使用了不同的索引查詢邏輯,大體分為兩種:
=,或多值的正則匹配=~,工作過程如下:
以類似下 SQL 所描述的語義查詢出標簽選擇器里引用的每個標簽鍵值對所對應的日志流 ID(seriesID)的集合。
- SELECT * FROM Table_N WHERE hash=? AND range>=? AND value=labelValue
hash 為租戶 ID(userID)、分桶(bucketID)、標簽名(labelName)組合計算的哈希值;range 為標簽值(labelValue)計算的哈希值。
將根據(jù)標簽鍵值對所查詢的多個 seriesID 集合取并集或交集求最終集合。
比如,標簽選擇器{file="app.log", level=~"debug|error"}的工作過程如下:
- 查詢出 file="app.log",level="debug", level="error" 三個標簽鍵值所對應的 seriesID 集合,S1 、S2、S3。
- 根據(jù)三個集合計算最終 seriesID 集合 S = S1∩cap (S2∪S3)。
!=,=~,!~,工作過程如下:
以如下 SQL 所描述的語義查詢出標簽選擇器里引用的每個標簽所對應 seriesID 集合。
- SELECT * FROM Table_N WHERE hash = ?
hash 為租戶 ID(userID)、分桶(bucketID)、標簽名(labelName)。
根據(jù)標簽選擇語法對每個 seriesID 集合進行過濾。
將過濾后的集合進行并集、交集等操作求最終集合。
比如,{file~="mysql*", level!="error"} 的工作過程如下:
- 查詢出標簽“file”和標簽"level"對應的 seriesID 的集合,S1、S2。
- 求出 S1 中 file 的值匹配 mysql*的子集 SS1,S2 中 level 的值!="error"的子集 SS2。
- 計算最終 seriesID 集合 S = SS1∩SS2。
以如下 SQL 所描述的語義查詢出所有日志流所包含的 chunk 的 ID:
- SELECT * FROM Table_N Where hash = ?
hash 為分桶(bucketID)和日志流(seriesID)計算的哈希值。
根據(jù) chunkID 列表生成遍歷器來順序讀取日志行:遍歷器作為數(shù)據(jù)讀取的組件,其主要功能為從存儲系統(tǒng)中拉取 chunk 并從中讀取日志行。其采用多層樹形結構,自頂向下逐層遞歸觸發(fā)方式彈出數(shù)據(jù)。
具體結構如上圖所示:
- batch Iterator:以批量的方式從存儲中下載 chunk 原始數(shù)據(jù),并生成 iterator 樹。
- stream Iterator:多個 stream 數(shù)據(jù)的遍歷器,其采用堆排序確保多個 stream 之間數(shù)據(jù)的保序;
- chunks Iterator:多個 chunk 數(shù)據(jù)的遍歷器,同樣采用堆排序確保多個 chunk 之間保序及多副本之間的去重。
- blocks Iterator:多個 block 數(shù)據(jù)的遍歷器。
- block bytes Iterator:block 里日志行的遍歷器。
從 Ingester 查詢在內(nèi)存中尚未寫入到存儲中的數(shù)據(jù):由于 Ingester 是定時的將緩存數(shù)據(jù)寫入到存儲中,所以 Querier 在查詢時間范圍較新的數(shù)據(jù)時,還會通過 grpc 協(xié)議從每個 ingester 中查詢出內(nèi)存數(shù)據(jù)。
需要在 ingester 中查詢的時間范圍是可配置的,視 ingester 緩存數(shù)據(jù)時長而定。
上面是日志內(nèi)容查詢的主要流程。至于指標查詢的流程與其大同小異,只是增加了指標計算的遍歷器層用于從查詢出的日志計算指標數(shù)據(jù)。其他兩種則更為簡單,這里不再詳細展開。
QueryFrontend:Loki 對查詢采用了計算后置的方式,類似于在大量原始數(shù)據(jù)上做 grep,所以查詢勢必會消耗比較多的計算和內(nèi)存資源。
如果以單節(jié)點執(zhí)行一個查詢請求的話很容易因為大查詢造成 OOM、速度慢等性能瓶頸。
為解決此問題,Loki 采用了將單個查詢分解在多個 querier 上并發(fā)執(zhí)行方式,其中查詢請求的分解和調度則由 queryFrontend 完成。
queryFrontend 在 Loki 的整體架構上處于 querier 的前端,它作為數(shù)據(jù)讀取操作的入口服務,其主要的組件及工作流程如上圖所示:
- 分割 Request:將單個查詢分割成子查詢 subReq 的列表。
- Feeder:將子查詢順序注入到緩存隊列 Buf Queue。
- Runner:多個并發(fā)的運行器將 Buf Queue 中的查詢并注入到子查詢隊列,并等待返回查詢結果。
- Querier 通過 grpc 協(xié)議實時從子查詢隊列彈出子查詢,執(zhí)行后將結果返回給相應的 Runner。
- 所有子請求在 Runner 執(zhí)行完畢后匯總結果返回 API 響應。
⑨查詢分割
queryFrontend 按照固定時間跨度將查詢請求分割成多個子查詢。比如,一個查詢的時間范圍是 6 小時,分割跨度為 15 分鐘,則查詢會被分為 6*60/15=24 個子查詢。
⑩查詢調度
Feeder:Feeder 負責將分割好的子查詢逐一的寫入到緩存隊列 Buf Queue,以生產(chǎn)者/消費者模式與下游的 Runner 實現(xiàn)可控的子查詢并發(fā)。
Runner:從 Buf Queue 中競爭方式讀取子查詢并寫入到下游的請求隊列中,并處理來自 Querier 的返回結果。
Runner 的并發(fā)個數(shù)通過全局配置控制,避免因為一次分解過多子查詢而對 Querier 造成巨大的徒流量,影響其穩(wěn)定性。
子查詢隊列:隊列是一個二維結構,第一維存儲的是不同租戶的隊列,第二維存儲同一租戶子查詢列表,它們都是以 FIFO 的順序組織里面的元素的入隊出隊。
分配請求:queryFrontend 是以被動方式分配查詢請求,后端 Querier 與 queryFrontend 實時的通過 grpc 監(jiān)聽子查詢隊列,當有新請求時以如下順序在隊列中彈出下一個請求:
- 以循環(huán)的方式遍歷隊列中的租戶列表,尋找下一個有數(shù)據(jù)的租戶隊列。
- 彈出該租戶隊列中的最老的請求。
總結
Loki 作為一個正在快速發(fā)展的項目,最新版本已到 2.0,相較 1.6 增強了諸如日志解析、Ruler、Boltdb-shipper 等新功能,不過基本的模塊、架構、數(shù)據(jù)模型、工作原理上已處于穩(wěn)定狀態(tài)。
希望本文的這些嘗試性的剖析能夠能夠為大家提供一些幫助,如文中有理解錯誤之處,歡迎批評指正。
作者:張海軍
編輯:陶家龍
出處:轉載自公眾號京東智聯(lián)云開發(fā)者(ID:JDC_Developers)