天天看點

OK Log設計思路

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

生産者與消費者

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

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

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

設計細節

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

我們也想要服務于有大量日志産生的生産系統。是以,我們不會對資料量做還原性假設。我們假設即使是最小工作集的日志資料,對單個節點的存儲可能也是太大的。是以,消費者将必須通過查詢多個節點擷取結果。這意味着最終的時間分區的資料集将是分布式的,并且是複制的。

現在我們引入分布式,這意味着我們必須解決協同問題。

協同

協同是分布式系統的死亡之吻。(協同主要是解決分布式資料的一緻性問題)。我們的日志系統是無協同的。讓我們看看每個階段需要什麼。

生産者,更準确地說,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亂序,隻要這個順序是穩定的,也沒有什麼大問題。

到達的日志記錄被寫到一個活躍段中,在磁盤上這個活躍段是一個檔案。

一旦這個段檔案達到了B個位元組,或者這個段活躍了S秒,那麼這個活躍段就會被flush到磁盤上。(ps: 時間限制或者size大小)

這個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節點每次隻有一個活躍段。

觀察到,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節點三個階段的事務圖:

讓我們現在考慮每一個階段的失敗處理

對于第一個階段:讀階段失敗。挂起的段一直到逾時都處于閑置狀态。對于另一個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的段結果

這就給了我們兩種方法帶時間邊界的查詢設計方法

合并

合并有兩個目的:

記錄去重

段去疊加

在上面三個階段出現有失敗的情況,例如:網絡故障(在分布式協同裡,叫腦裂),會出現日志記錄重複。但是段會定期透明地疊加。

在一個給定的查詢節點,考慮到三個段檔案的疊加。如下圖所示:

合并分為三步:

首先在記憶體中把這些重疊的段歸并成一個新的聚合段

在歸并期間,通過ULID來進行日志記錄去重和丢棄

最後,合并再把新的聚合段分割成多個size的段,生成新的不重疊的段檔案清單

t0 t1

+-------+-------+

D

E