OK Log設計思路

cdh0805010118發表於2017-11-17

OK Log 姊妹篇

設計

在這個文件中,我們首先在頂層設計上描述這個系統。然後,我們再引入約束和不變數來確定問題域。我們會一步步地提出一個具體的解決方案,描述框架中的關鍵元件和元件之間的行為。

生產者與消費者

我們有一個大且動態地生產者集,它們會生產大量的日誌記錄流。這些記錄應該可供消費者查詢到的。

     +-----------+
P -> |           |
P -> |     ?     | -> C
P -> |           |
     +-----------+

生產者主要關心日誌被消費的速度儘可能地快。如果這個速度沒有控制好,有一些策略可以提供,包括:背壓策略(ps: 流速控制), 例如:事件日誌、緩衝和資料丟棄(例如:應用程式日誌)。在這些情況下,接收日誌記錄流的元件需要優化順序寫操作。

消費者主要關心儘快地響應使用者端的日誌查詢,保證儘可能快的日誌持久化。因為我們定義了查詢必須帶時間邊界條件,我們要確保我們可以通過時間分隔資料檔案,來解決grep問題。所以儲存在磁碟上的最終資料格式,應該是一個按照時間劃分的資料檔案格式,且這些檔案內的資料是由所有生產者的日誌記錄流全域性歸併得到的。如下圖所示:

     +-------------------+
P -> | R                 |
P -> | R     ?     R R R | -> C
P -> | R                 |
     +-------------------+

設計細節

我們有上千個有序的生產者。(一個生產者是由一個應用程式,和一個forward代理構成)。我們的日誌系統有必要比要服務的生產系統小得多。因此我們會有多個ingest節點,每個ingest節點需要處理來自多個生產者的寫請求。

我們也想要服務於有大量日誌產生的生產系統。因此,我們不會對資料量做還原性假設。我們假設即使是最小工作集的日誌資料,對單個節點的儲存可能也是太大的。因此,消費者將必須通過查詢多個節點獲取結果。這意味著最終的時間分割槽的資料集將是分散式的,並且是複製的。

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:不明白)

相關文章