OK Log設計思路

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節點經過gossip協議傳遞負載信息給其餘的ingesters節點,這些負載信息包括:鏈接數、IOps(I/O per second)等。
  • 而後高負載ingesters節點能夠拒絕新鏈接請求,這樣forwarders會重定向到其餘比較輕量級負載的ingesters節點上。
  • 滿負載的ingesters節點,若是須要的話,甚至能夠中斷已經存在的鏈接。可是這個要十分注意,避免錯誤的拒絕合理的服務請求。

例如:在一個特定時間內,不該該有許多ingesters節點拒絕鏈接。也就是說日誌系統不能同時有N個節點拒絕forwarders節點日誌傳輸請求。這個能夠在系統中進行參數配置。

consumers須要可以在沒有任什麼時候間分區和副本分配等條件的狀況下進行查詢。沒有這些已知條件,這意味着用戶的一個查詢老是要分散到每一個query節點上,而後聚合和去重。query節點可能會在任什麼時候刻掛掉,啓動或者所在磁盤數據空。所以查詢操做必須優雅地管理部分結果。

另外一個優化點是,consumers可以執行讀修復。一個查詢應該返回每個匹配的N個備份數據記錄,這個N是複製因子。任何日誌記錄少於N個備份都是須要讀修復的。一個新的日誌記錄段會被建立而且會複製到集羣中。更進一步地優化,獨立的進程可以執行時空範圍內的順序查詢,若是發現查詢結果存在不一致,能夠當即進行讀修復。

在ingest層和query層之間的數據傳輸也須要注意。理想狀況下,任何ingest節點應該可以把段傳送到任何查詢節點上。咱們必須優雅地從傳輸失敗中恢復。例如:在事務任何階段的網絡分區。

讓咱們如今觀察怎麼樣從ingest層把數據安全地傳送到query層。

ingest段

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的段文件。

  • Get /next ---- 返回最老的flushed段,並將其標記爲掛起
  • POST /commit?id=ID ---- 刪除一個掛起的段
  • POST /failed?id=ID ---- 返回一個已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節點是有狀態的,所以它們須要一個優雅地關閉進程。有三點:

  • 首先,它們應該中斷連接和關閉監聽者
  • 而後,它們應該等待全部flushed段被消費
  • 最後,它們才能夠完成關閉操做

消費段

這個ingesters節點充當一個隊列,將記錄緩衝到稱爲段的組中。雖然這些段有緩衝區保護,可是若是發生斷電故障,這內存中的段數據沒有寫入到磁盤文件中。因此咱們須要儘快地將段數據傳送到query層,存儲到磁盤文件中。在這裏,咱們從Prometheus的手冊中看到,咱們使用了拉模式。query節點從ingester節點中拉取已經flushed段,而不是ingester節點把flushed段推送到query節點上。這可以使這個設計模型提升其吞吐量。爲了接受一個更高的ingest速率,更加更多的ingest節點,用更快的磁盤。若是ingest節點正在備份,增長更多的查詢節點一共它們使用。

query節點消費分爲三個階段:

  • 第一個階段是讀階段。每個query節點按期地經過GET /next, 從每個intest節點獲取最老的flushed段。(算法能夠是隨機選取、輪詢或者更復雜的算法,目前方案採用的是隨機選取)。query節點接收的段逐條讀取,而後再歸併到一個新的段文件中。這個過程是重複的,query節點從ingest層消費多個活躍段,而後歸併它們到一個新的段中。一旦這個新段達到B個字節或者S秒,這個活躍段將被寫入到磁盤文件上而後關閉。
  • 第二個階段是複製階段。複製意味着寫這個新的段到N個獨立的query節點上。(N是複製因子)。這是咱們僅僅經過POST方法發送這個段到N個隨機存儲節點的複製端點。一旦咱們把新段複製到了N個節點後,這個段就被確認複製完成。
  • 第三個階段是提交階段。這個query節點經過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-------------|

讓咱們如今考慮每個階段的失敗處理

  • 對於第一個階段:讀階段失敗。掛起的段一直到超時都處於閒置狀態。對於另外一個query節點,ingest節點的活躍段是能夠獲取的。若是原來的query節點永遠掛掉了,這是沒有任何問題的。若是原始的query節點又活過來了,它有可能仍然會消費已經被其餘query節點消費和複製的段。在這種狀況下,重複的記錄將會寫入到query層,而且一個或者多個會提交失敗。若是這個發生了 ,這也ok:記錄超過了複製因子,可是它會在讀時刻去重,而且最終會從新合併。所以提交失敗應該被注意,可是也可以被安全地忽略。
  • 對於第二個階段:複製階段。錯誤的處理流程也是類似的。假設這個query節點沒有活過來,掛起的ingest段將會超時而且被其餘query節點重試。若是這個query節點活過來了,複製將會繼續進行而不會失敗,而且一個或者多個最終提交將將失敗
  • 對於第三個階段:commit階段。若是ingest節點等待query節點commit發生超時,則處在pending階段的一個或者多個ingest節點,會再次flushed到段中。和上面同樣,記錄將會重複,在讀取時進行數據去重,而後合併。

節點失敗

若是一個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    |
|     +---------+

合併分爲三步:

  • 首先在內存中把這些重疊的段歸併成一個新的聚合段。
  • 在歸併期間,經過ULID來進行日誌記錄去重和丟棄。
  • 最後,合併再把新的聚合段分割成多個size的段,生成新的不重疊的段文件列表
t0             t1
+-------+-------+
|       |       |
|   D   |   E   |
|       |       |
+-------+-------+

合併減小了查詢搜索段的數量。在理想狀況下,每次都會且只映射到一個段。這是經過減小讀數量來提升查詢性能。

觀察到合併能改善查詢性能,並且也不會影響正確性和空間利用率。在上述合併處理過程當中同時使用壓縮算法進行合併後的數據壓縮。合適的壓縮可使得日誌記錄段可以在磁盤保留更長的時間(ps: 由於可使用的空間更多了,磁盤也沒那麼快達到設置的上限),可是會消耗衡更多的CPU。它也可能會使UNIX/LINUX上的grep服務沒法使用,可是這多是不重要的。

因爲日誌記錄是能夠單獨尋址的,所以查詢過程當中的日誌記錄去重會在每一個記錄上進行。映射到段的記錄能夠在每一個節點徹底獨立優化,無需協同。

合併的調度和耦合性也是一個很是重要的性能考慮點。在合併期間,單個合併groutine會按照順序執行每一個合併任務。它每秒最多進行一次合併。更多的性能分析和實際研究是很是必要的。

查詢

每一個查詢節點提供一個GET /query的api服務。用戶可使用任意的query節點提供的查詢服務。系統受到用戶的查詢請求後,會在query層的每個節點上進行查詢。而後每一個節點返回響應的數據,在query層進行數據歸併和去重,並最終返回給用戶。

真正的查詢工做是由每一個查詢節點獨立完成的。這裏分爲三步:

  • 首先匹配查詢時間邊界條件的段文件被標記。(時間邊界條件匹配)
  • 對於第一步獲取的全部段,都有一個reader進行段文件查找匹配的日誌記錄,獲取日誌記錄列表
  • 最後對獲取到的日誌記錄列表經過歸併Reader進行歸併,排序,並返回給查詢節點。

這個pipeline是由不少的io.ReaderClosers構建的,主要開銷在讀取操做。這個HTTP響應會返回給查詢節點,最後返回給用戶。

注意一點,這裏的每一個段reader都是一個goroutine,而且reading/filtering是併發的。當前讀取段文件列表還進行goroutine數量的限制。(ps: 有多少個段文件,就會生成相應數量的goroutine)。這個是應該要優化的。

用戶查詢請求包括四個字段:

  • FROM, TO time.Time - 查詢的時間邊界
  • Q字符串 - 對於grep來講,空字符串是匹配全部的記錄
  • Regex布爾值 - 若是是true,則進行正則表達式匹配
  • StatsOnly布爾值 - 若是是true,只返回統計結果

用戶查詢結果響應有如下幾個字段:

  • NodeCount整型 - 查詢節點參與的數量
  • SegmentCount整型 - 參與讀的段文件數量
  • Size整型 - 響應結果中段文件的size
  • io.Reader的數據對象 - 歸而且排序後的數據流

StatsOnly能夠用來探索和迭代查詢,直到它被縮小到一個可用的結果集

組件模型

下面是日誌管理系統的各個組件設計草案

進程

forward
  • ./my_application | forward ingest.mycorp.local:7651
  • 應該接受多個ingest節點host:ports的段拉取
  • 應該包含DNS解析到單個實例的特性
  • 應該包含在鏈接斷掉後進行容錯的特性
  • 可以有選擇fast, durable和chunked寫的特性
  • Post-MVP: 更復雜的HTTP? forward/ingest協議;
ingest
  • 能夠接收來自多個forwarders節點的寫請求
  • 每條日誌記錄以\n符號分割
  • 每條日誌記錄的前綴必須是ULID開頭
  • 把日誌記錄追加到活躍段中
  • 當活躍段達到時間限制或者size時,須要flush到磁盤上
  • 爲存儲層的全部節點提供輪詢的段api服務
  • ingest節點之間經過Gossip協議共享負載統計數據
  • Post-MVP: 負載擴展/脫落;分段到存儲層的流傳輸
store
  • 輪詢ingest層的全部flush段
  • 把ingest段歸併到一塊兒
  • 複製歸併後的段到其餘存儲節點上
  • 爲客戶端提供查詢API服務
  • 在某個時刻執行合併操做
  • Post-MVP:來自ingest層的流式段合併;提供更高級的查詢條件

Libraries

Ingest日誌
  • 在ingest層的段Abstraction
  • 主要操做包括:建立活躍段,flush、pending標記,和提交
  • (I've got a reasonable prototype for this one) (ps: 不明白)
  • 請注意,這其實是一個磁盤備份隊列,有時間期限的持久化存儲
Store日誌
  • 在storage層的段Abstraction
  • 操做包括段收集、歸併、複製和合並
  • 注意這個是長期持久化存儲

集羣

  • 來之各個節點之間的信息的Abstraction
  • 大量的數據共享通訊是沒必要要的,只須要獲取節點身份和健康檢查信息就足夠了
  • HashiCorp's memberlist fits the bill (ps:不明白)
相關文章
相關標籤/搜索