OK Log 姊妹篇html
在這個文檔中,咱們首先在頂層設計上描述這個系統。而後,咱們再引入約束和不變量來肯定問題域。咱們會一步步地提出一個具體的解決方案,描述框架中的關鍵組件和組件之間的行爲。golang
咱們有一個大且動態地生產者集,它們會生產大量的日誌記錄流。這些記錄應該可供消費者查找到的。web
+-----------+ P -> | | P -> | ? | -> C P -> | | +-----------+
生產者主要關心日誌被消費的速度儘量地快。若是這個速度沒有控制好,有一些策略能夠提供,包括:背壓策略(ps: 流速控制), 例如:事件日誌、緩衝和數據丟棄(例如:應用程序日誌)。在這些狀況下,接收日誌記錄流的組件須要優化順序寫操做。正則表達式
消費者主要關心儘快地響應用戶端的日誌查詢,保證儘量快的日誌持久化。由於咱們定義了查詢必須帶時間邊界條件,咱們要確保咱們能夠經過時間分隔數據文件,來解決grep
問題。因此存儲在磁盤上的最終數據格式,應該是一個按照時間劃分的數據文件格式,且這些文件內的數據是由全部生產者的日誌記錄流全局歸併獲得的。以下圖所示:算法
+-------------------+ P -> | R | P -> | R ? R R R | -> C P -> | R | +-------------------+
咱們有上千個有序的生產者。(一個生產者是由一個應用進程,和一個forward代理構成)。咱們的日誌系統有必要比要服務的生產系統小得多。所以咱們會有多個ingest節點,每一個ingest節點須要處理來自多個生產者的寫請求。api
咱們也想要服務於有大量日誌產生的生產系統。所以,咱們不會對數據量作還原性假設。咱們假設即便是最小工做集的日誌數據,對單個節點的存儲可能也是太大的。所以,消費者將必須經過查詢多個節點獲取結果。這意味着最終的時間分區的數據集將是分佈式的,而且是複製的。安全
producers --> forwarders --> ingester ---> **storage** <--- querying <--- consumer +---+ +---+ P -> F -> | I | | Q | --. P -> F -> | | +---+ | +---+ +---+ '-> +---+ ? | Q | ----> C P -> F -> | I | +---+ .-> P -> F -> | | +---+ | P -> F -> | | | Q | --' +---+ +---+
如今咱們引入分佈式,這意味着咱們必須解決協同問題。網絡
協同是分佈式系統的死亡之吻。(協同主要是解決分佈式數據的一致性問題)。咱們的日誌系統是無協同的。讓咱們看看每一個階段須要什麼。數據結構
生產者,更準確地說,forwarders,須要可以鏈接任何一個ingest節點,而且發送日誌記錄。這些日誌記錄直接持久化到ingester所在的磁盤上,並儘量地減小中間處理過程。若是ingester節點掛掉了,它的forwarders應該很是簡單地鏈接其餘ingester節點和恢復日誌傳輸。(根據系統配置,在傳輸期間,它們能夠提供背壓,緩衝和丟棄日誌記錄)言外之意,forwarders節點不須要知道哪一個ingest是ok的。任何ingester節點也必須是這樣。併發
有一個優化點是,高負載的ingesters節點能夠把負載(鏈接數)轉移到其餘的ingesters節點。有三種方式:、
例如:在一個特定時間內,不該該有許多ingesters節點拒絕鏈接。也就是說日誌系統不能同時有N個節點拒絕forwarders節點日誌傳輸請求。這個能夠在系統中進行參數配置。
consumers須要可以在沒有任什麼時候間分區和副本分配等條件的狀況下進行查詢。沒有這些已知條件,這意味着用戶的一個查詢老是要分散到每一個query節點上,而後聚合和去重。query節點可能會在任什麼時候刻掛掉,啓動或者所在磁盤數據空。所以查詢操做必須優雅地管理部分結果。
另外一個優化點是,consumers可以執行讀修復。一個查詢應該返回每個匹配的N個備份數據記錄,這個N是複製因子。任何日誌記錄少於N個備份都是須要讀修復的。一個新的日誌記錄段會被建立而且會複製到集羣中。更進一步地優化,獨立的進程可以執行時空範圍內的順序查詢,若是發現查詢結果存在不一致,能夠當即進行讀修復。
在ingest層和query層之間的數據傳輸也須要注意。理想狀況下,任何ingest節點應該可以把段傳送到任何查詢節點上。咱們必須優雅地從傳輸失敗中恢復。例如:在事務任何階段的網絡分區。
讓咱們如今觀察怎麼樣從ingest層把數據安全地傳送到query層。
ingesters節點從N個forwarders節點接收了N個獨立的日誌記錄流。每一個日誌記錄以帶有ULID的字符串開頭。每一個日誌記錄有一個合理精度的時間錯是很是重要的,它建立了一個全局有序,且惟一的ID。可是時鐘全局同步是不重要的,或者說記錄是嚴格線性增加的。若是在一個很小的時間窗口內日誌記錄同時到達出現了ID亂序,只要這個順序是穩定的,也沒有什麼大問題。
到達的日誌記錄被寫到一個活躍段中,在磁盤上這個活躍段是一個文件。
+---+ P -> F -> | I | -> Active: R R R... P -> F -> | | P -> F -> | | +---+
一旦這個段文件達到了B個字節,或者這個段活躍了S秒,那麼這個活躍段就會被flush到磁盤上。(ps: 時間限制或者size大小)
+---+ P -> F -> | I | -> Active: R R R... P -> F -> | | Flushed: R R R R R R R R R P -> F -> | | Flushed: R R R R R R R R +---+
這個ingester從每一個forwarder鏈接中順序消費日誌記錄。噹噹前的日誌記錄成功寫入到活躍的段中後,下一個日誌記錄將會被消費。而且這個活躍段在flush後當即同步複製備份。這是默認的持久化模式,暫定爲fast。
Producers選擇性地鏈接一個獨立的端口上,其處理程序將在寫入每一個記錄後同步活躍的段。者提供了更強的持久化,可是以犧牲吞吐量爲代價。這是一個獨立的耐用模式,暫時定爲持久化。(ps: 這段話翻譯有點怪怪的,下面是原文)
Producers can optionally connect to a separate port, whose handler will sync the active segment after each record is written. This provides stronger durability, at the expense of throughput. This is a separate durability mode, tentatively called durable.
第三個更高級的持久化模式,暫定爲混合模式。forwarders一次寫入整個段文件到ingester節點中。每個段文件只有在存儲節點成功複製後才能被確認。而後這個forwarder節點才能夠發送下一個完整的段。
ingesters節點提供了一個api,用於服務已flushed的段文件。
ps: 上面的ID是指:ingest節點的ID
段狀態由文件的擴展名控制,咱們利用文件系統進行原子重命名操做。這些狀態包括:.active、.flushed或者.pending, 而且每一個鏈接的forwarder節點每次只有一個活躍段。
+---+ P -> F -> | I | Active +---+ P -> F -> | | Active | Q | --. | | Flushed +---+ | +---+ +---+ '-> +---+ ? | Q | ----> C P -> F -> | I | Active +---+ .-> P -> F -> | | Active +---+ | P -> F -> | | Active | Q | --' | | Flushed +---+ | | Flushed +---+
觀察到,ingester節點是有狀態的,所以它們須要一個優雅地關閉進程。有三點:
這個ingesters節點充當一個隊列,將記錄緩衝到稱爲段的組中。雖然這些段有緩衝區保護,可是若是發生斷電故障,這內存中的段數據沒有寫入到磁盤文件中。因此咱們須要儘快地將段數據傳送到query層,存儲到磁盤文件中。在這裏,咱們從Prometheus的手冊中看到,咱們使用了拉模式。query節點從ingester節點中拉取已經flushed段,而不是ingester節點把flushed段推送到query節點上。這可以使這個設計模型提升其吞吐量。爲了接受一個更高的ingest速率,更加更多的ingest節點,用更快的磁盤。若是ingest節點正在備份,增長更多的查詢節點一共它們使用。
query節點消費分爲三個階段:
GET /next
, 從每個intest節點獲取最老的flushed段。(算法能夠是隨機選取、輪詢或者更復雜的算法,目前方案採用的是隨機選取)。query節點接收的段逐條讀取,而後再歸併到一個新的段文件中。這個過程是重複的,query節點從ingest層消費多個活躍段,而後歸併它們到一個新的段中。一旦這個新段達到B個字節或者S秒,這個活躍段將被寫入到磁盤文件上而後關閉。POST
方法發送這個段到N個隨機存儲節點的複製端點。一旦咱們把新段複製到了N個節點後,這個段就被確認複製完成。POST /commit
方法,提交來自全部ingest節點的原始段。若是這個新的段由於任何緣由複製失敗,這個query節點經過POST /failed
方法,把全部的原始段所有改成失敗狀態。不管哪一種狀況,這三個階段都完成了,這個query節點又能夠開始循環隨機獲取ingest節點的活躍段了。下面是query節點三個階段的事務圖:
Q1 I1 I2 I3 -- -- -- -- |-Next--->| | | |-Next------->| | |-Next----------->| |<-S1-----| | | |<-S2---------| | |<-S3-------------| | |--. | | S1∪S2∪S3 = S4 Q2 Q3 |<-' -- -- |-S4------------------>| | |-S4---------------------->| |<-OK------------------| | |<-OK----------------------| | | I1 I2 I3 | -- -- -- |-Commit->| | | |-Commit----->| | |-Commit--------->| |<-OK-----| | | |<-OK---------| | |<-OK-------------|
讓咱們如今考慮每個階段的失敗處理
若是一個ingest節點永久掛掉,在其上的全部段記錄都會丟失。爲了防止這種事情的發生,客戶端應該使用混合模式。在段文件被複制到存儲層以前,ingest節點都不會繼續寫操做。
若是一個存儲節點永久掛掉,只要有N-1個其餘節點存在都是安全的。可是必需要進行讀修復,把該節點丟失的全部段文件所有從新寫入到新的存儲節點上。一個特別的時空追蹤進行會執行這個修復操做。它理論上能夠從最開始進行讀修復,可是這是沒必要要的,它只須要修復掛掉的段文件就ok了。
全部的查詢都是帶時間邊界的,全部段都是按照時間順序寫入。可是增長一個索引對找個時間範圍內的匹配段也是很是必要的。無論查詢節點以任何理由寫入一個段,它都須要首先讀取這個段的第一個ULID和最後一個ULID。而後更新內存索引,使這個段攜帶時間邊界。在這裏,一個線段樹是一個很是好的數據結構。
另外一種方法是,把每個段文件命名爲FROM-TO,FROM表示該段中ULID的最小值,TO表示該段中ULID的最大值。而後給定一個帶時間邊界的查詢,返回全部與時間邊界有疊加的段文件列表。給定兩個範圍(A, B)和(C, D),若是A<=B, C<=D以及A<=C的話。(A, B)是查詢的時間邊界條件,(C, D)是一個給定的段文件。而後進行範圍疊加,若是B>=C的話,結果就是FROM C TO B的段結果
A--B B >= C? C--D yes A--B B >= C? C--D no A-----B B >= C? C-D yes A-B B >= C? C----D yes
這就給了咱們兩種方法帶時間邊界的查詢設計方法
合併有兩個目的:
在上面三個階段出現有失敗的狀況,例如:網絡故障(在分佈式協同裏,叫腦裂),會出現日誌記錄重複。可是段會按期透明地疊加。
在一個給定的查詢節點,考慮到三個段文件的疊加。以下圖所示:
t0 t1 +-------+ | | A | | +-------+ | | +---------+ | | | B | | | +---------+ | | +---------+ | | C | | +---------+
合併分爲三步:
t0 t1 +-------+-------+ | | | | D | E | | | | +-------+-------+
合併減小了查詢搜索段的數量。在理想狀況下,每次都會且只映射到一個段。這是經過減小讀數量來提升查詢性能。
觀察到合併能改善查詢性能,並且也不會影響正確性和空間利用率。在上述合併處理過程當中同時使用壓縮算法進行合併後的數據壓縮。合適的壓縮可使得日誌記錄段可以在磁盤保留更長的時間(ps: 由於可使用的空間更多了,磁盤也沒那麼快達到設置的上限),可是會消耗衡更多的CPU。它也可能會使UNIX/LINUX上的grep
服務沒法使用,可是這多是不重要的。
因爲日誌記錄是能夠單獨尋址的,所以查詢過程當中的日誌記錄去重會在每一個記錄上進行。映射到段的記錄能夠在每一個節點徹底獨立優化,無需協同。
合併的調度和耦合性也是一個很是重要的性能考慮點。在合併期間,單個合併groutine會按照順序執行每一個合併任務。它每秒最多進行一次合併。更多的性能分析和實際研究是很是必要的。
每一個查詢節點提供一個GET /query
的api服務。用戶可使用任意的query節點提供的查詢服務。系統受到用戶的查詢請求後,會在query層的每個節點上進行查詢。而後每一個節點返回響應的數據,在query層進行數據歸併和去重,並最終返回給用戶。
真正的查詢工做是由每一個查詢節點獨立完成的。這裏分爲三步:
這個pipeline是由不少的io.ReaderClosers
構建的,主要開銷在讀取操做。這個HTTP響應會返回給查詢節點,最後返回給用戶。
注意一點,這裏的每一個段reader都是一個goroutine,而且reading/filtering
是併發的。當前讀取段文件列表還進行goroutine數量的限制。(ps: 有多少個段文件,就會生成相應數量的goroutine)。這個是應該要優化的。
用戶查詢請求包括四個字段:
grep
來講,空字符串是匹配全部的記錄用戶查詢結果響應有如下幾個字段:
io.Reader
的數據對象 - 歸而且排序後的數據流StatsOnly能夠用來探索和迭代查詢,直到它被縮小到一個可用的結果集
下面是日誌管理系統的各個組件設計草案
\n
符號分割