標籤:

從零開始開發一個單機存儲引擎

1.VDL Logstore概述

如何設計存儲引擎,使得讀寫介面的性能足夠高,如何保證在機器宕機時,存儲引擎能夠將已存儲的數據恢復到一個一致性狀態。如何測試存儲引擎的正確性?本文將著重介紹一下VDL系統的日誌存儲引擎--Logstore的架構設計與核心流程實現,及為了保證Logstore的正確性,我們做了哪些工作;為了進一步提高Logstore的讀寫性能,我們又做了哪些工作。希望通過這篇文章,給大家介紹一下設計和開發一個存儲引擎的『前世今生』。

1.1 Logstore提供的功能

VDL中有兩種日誌形態,一種是raft日誌(以下稱為raft log),由raft演算法產生和使用,另一種是用戶形態的Log(以下稱為user log),由用戶產生和使用。Logstore作為VDL日誌存儲引擎,同時存儲著VDL的raft log 和user log。Logstore在設計中,將兩種Log形態組合成一個Log Entry。只是通過不同的頭部信息來區分。Logstore需要同時提供兩種不同形態的Log操作介面,主要有以下幾類:

  • 讀取,根據索引信息,讀取對應的Log。
  • 寫入,將用戶產生的Log,封裝成相應的user Log和Raft Log寫入到Logstore中。
  • 刪除,刪除用戶不再使用的Log,以文件為粒度,從最開始位置往後刪除。
  • 轉換,由Raft Log獲取對應的user Log。
  • 截斷,截斷一部分Log,主要是為了支持raft lib中刪除未達成一致的Log的功能。

2.Logstore的架構設計

2.1系統架構

Logstore由數據文件和索引文件組成,同時Logstore還會在內存中緩存最新的一段Log Entry,用於Raft lib能夠快速地從內存中讀取到最近Raft log,同時用戶也能夠快速讀取到最新存儲到Logstore中的user log。Logstore的組成如下圖所示:

  • segment: 用於存儲log的文件,大小固定(默認是512MB)。Segment文件從前到後代表著log的順序,Logstore通過追加的方式不斷將Log Entry寫入到segment中。Logstore只追加Log Entry到最後的Segment文件中,對於整個Logstore只有最後一個segment可讀可寫,其他Segment文件只讀。由於Segment文件大小固定,我們採用mmap函數方式對segment文件進行讀寫。
  • index: 用於存儲對應的segment中的log entry的元信息,例如:log entry在segment文件中的偏移,raft log index等。每個索引項大小固定。用於加速查找raft log和user log。
  • MemCache: 緩存最後一段log entry數據,保證VDL能夠從內存中讀取最新的一段log entry數據。

segment由一條一條的raft log entry組成,raft log的data部分存放的是user log。每個segment文件對應一個index文件,index file由index entry組成,index 文件中的索引項紀錄了對應raft log的位置和大小等信息。示意圖如下所示:

3. Logstore的核心流程實現

3.1 讀數據流程

Logstore讀數據分為兩種情況:

Read in MemCache,MemCache的元數據記錄了緩存的Log範圍信息,當讀取範圍剛好落在MemCache內時,則Logstore直接從MemCache中讀取Log並返回。

Read in Segment,當上層讀取的Log範圍未完全落在MemCache中時,則會從segment文件中讀取。Logstore記錄了每個segment的Log範圍元數據信息,先通過segment範圍元數據信息,定位到讀取的開始segment,然後在通過索引來定位具體的文件偏移。例如,讀取raft index 為10010-10019這段範圍的raft log,segment範圍如下圖所示:

根據segment的Log範圍元數據信息,我們可以知道此次讀取範圍開始位置和結束位置都在segment_2中,由於Raft log entry的長度是不固定的,如何定位讀取開始位置和結束位置的文件偏移呢?這時候就需要用到索引項,在Logstore中每個Log entry對應的索引項大小是固定的,索引項紀錄了該raft log entry在segment文件內的文件偏移。segment_2對應的index文件第一個索引項紀錄的是raft index為10001的raft log entry索引項,所以需要在index文件中超找raft log index範圍是:10010-10019,就非常簡單了。直接讀取index 文件的第10到第19範圍的索引項,然後根據索引項內的文件偏移到segment上讀取raft log。大概的流程如下圖所示:

3.2 寫數據流程

raft演算法要求寫入的raft log必須強制落盤後,才能返回成功。通過將log entry批量非同步寫入segment文件,並調用sync_file_range函數強制刷盤。為了提升寫入segment性能,segment文件創建時就預分配了512MB的磁碟空間,這種預分配文件空間的方式有助於提升寫性能。將索引信息寫入index文件是非同步寫完後就返回。同步寫segment,非同步寫index的方式降低了raft log寫耗時,但又不影響raft演算法的正確性。因為raft演算法是以segment中的數據作為參考標準的。

Logstore寫入流程如下圖所示:

3.3 數據恢複流程

Logstore必須要考慮到在VDL系統異常退出時,存儲的數據有可能出現不一致。例如在Logstore寫數據過程中,機器突然宕機。這時候就有可能只寫入了部分數據,在設計Logstore時就必須考慮到如何支持數據恢復操作,保證寫入Logstore的數據的一致性。

在Logstore中,只有最後一個segment文件可能出現數據不一致的可能。因為Logstore在寫滿一個segment文件後,會創建一個新的segment文件。在創建新的segment文件之前,Logstore通過sync系統調用讓最後的segment對應的index文件內容強制刷盤,並且最後一個segment文件寫入本身就是同步寫。通過這種機制保證了只有最後一個segment寫入的數據存在部分寫的可能。而在這之前的segment文件和index文件內容都是完整的。

有了上面的保證,數據恢復我們只需要考慮最後一個segment及其index文件中的數據是否完整。Logstore通過一個標識文件來標識系統是否正常退出,如果文件存在且裡面的標記為正常退出,Logstore就走正常啟動流程,否則,轉入數據恢複流程,Logstore數據恢複流程,主要操作如下圖所示:

4.Logstore的測試

為保證Logstore的正確性,我們對Logstore對外提供的介面函數及內部調用的核心函數都做了單元測試,通過gitlab+jenkins持續集成的方式,保證每次提交都會觸髮腳本將所有的單元測試重新運行一次,如果新增代碼或改動代碼,導致單元測試失敗,我們可以立刻發現。通過這種持續集成的方式,我們可以保證每次代碼提交的質量。

僅僅有單元測試還是不夠的,因為我們無法預測Logstore某個介面函數異常,對整個VDL系統造成什麼影響。所以,我們還對Logstore進行了異常測試,通過一個自研工具FIU,對Logstore中特定的函數注入各種異常條件,測試Logstore的在異常情況下,對系統的影響。我們在Logstore相關代碼中插入固定的異常代碼,然後通過FIU來觸發相應的異常點。這樣就可以讓Logstore走入指定的異常邏輯代碼。異常注入測試主要分為兩類:

  • 增加讀或寫延遲,Logstore向上層提供讀寫raft log和user log等操作。例如,讀取raft log增加3s的延遲、寫入user log增加1s-3s的隨機延遲。我們測試在這類異常場景下,對上層VDL會造成什麼影響,結果是否跟我們的預期一致。
  • 部分寫問題,機器突然宕機,有可能導致Logstore部分寫操作。也就是segment有可能只寫入了部分數據,或者index文件只寫入了部分數據。同樣,我們也是在寫入segment文件邏輯和index文件邏輯中增加異常點,利用FIU觸髮指定的異常邏輯。這樣就可以測試到在Logstore出現部分寫時,Logstore的數據恢複流程是否能夠正常工作,是否符合預期。

有了這類異常測試,我們可以提前去模擬線上有可能出現的異常場景,並修復可能存在的未知缺陷。保證VDL上線後更加穩定、可靠。並且添加異常各類異常測試用例是一個持續的過程,伴隨著VDL系統開發和演進的全過程。

5.Logstore的性能優化

為保證Logstore具有高性能的讀寫,在設計階段就考慮到了。比如通過文件空間預分配來提升寫性能,通過mmap方式讀日誌數據,提升讀性能。在代碼開發完成後,結合go pprof和火焰圖來定位Logstore的性能開銷較大的系統調用或代碼段,並做相應優化。性能優化方面的工作,比較有意義的幾點,可以分享一下:

  • 批量寫數據,不管是寫segment還是寫index文件,都是將數據先組合在一個內存空間中,然後批量寫入到磁碟。減少IO調用帶來的開銷。
  • index文件非同步刷盤,在前面的設計中,我們談到在segment rolling操作中,需要將index文件同步刷盤後,再創建新的segment文件。通過持續觀察發現,每次index文件刷盤都要消耗4ms-8ms的時間。寫入操作如果需要segment rolling時,這次的寫入延遲額外會增加4ms-8ms。Logstore的寫入就會出現抖動。經過分析,我們可以發現index文件同步刷盤所做的操作就是將index文件對應的內存臟頁更新到磁碟。如果我們能夠減少segment rolling操作時index文件對應的內存臟頁數量。就可以縮短index刷盤的耗時。我們採用的方式是每次寫index文件時,再調用sync_file_range操作非同步將index文件數據刷盤,這樣就可以分攤最後一次刷盤的壓力。經過優化後的index文件刷盤操作耗時縮短到200us-300us。使得整個Lostore的寫入耗時更加平滑。

    在核心函數調用中Logstore記錄相關metric信息,在Logstore上線後,通過日誌收集系統,收集metric信息到influxdb,然後通過grafana展示出來。有了grafana的直觀展示,我們可以監控到耗時比較長的系統調用,並做針對性地優化。目前關鍵的讀取和寫入操作都達到了預期的性能目標。

6.總結

本文介紹了Logstore在設計、開發、測試和性能優化等方面,我們所做的工作。希望能夠給讀者在設計和開發分散式存儲系統時,提供一定的參考思路。在後續演進中,我們希望結合業務場景,對數據做冷熱分離,進一步降低生產系統的成本。到時候有新的心得體會,我們繼續給大家分享。


推薦閱讀:

集群資源調度系統設計架構總結
素描單元化
Elasticell和Jepsen測試
利用DB實現分散式鎖的思路
閱讀筆記:PowerGraph: Distributed Graph-Parallel Computation on Natural Graphs

TAG:分散式系統 |