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