新一代日誌型系統在 SOFAJRaft 中的應用

SOFAStack發表於2021-10-28


?

文|黃章衡(SOFAJRaft 專案組)

福州大學 19 級計算機系

研究方向|分散式中介軟體、分散式資料庫

Github 主頁|https://github.com/hzh0425

校對|馮家純(SOFAJRaft 開源社群負責人)

本文 9402 字 閱讀 18 分鐘

PART. 1 專案介紹

1.1 SOFAJRaft 介紹

SOFAJRaft 是一個基於 RAFT 一致性演算法的生產級高效能 Java 實現,支援 MULTI-RAFT-GROUP,適用於高負載低延遲的場景。使用 SOFAJRaft 你可以專注於自己的業務領域,由 SOFAJRaft 負責處理所有與 RAFT 相關的技術難題,並且 SOFAJRaft 非常易於使用,你可以通過幾個示例在很短的時間內掌握它。

Github 地址:

https://github.com/sofastack/sofa-jraft

img

1.2 任務要求

目標:當前 LogStorage 的實現,採用 index 與 data 分離的設計,我們將 key 和 value 的 offset 作為索引寫入 rocksdb,同時日誌條目(data)寫入 Segment Log。因為使用 SOFAJRaft 的使用者經常也使用了不同版本的 rocksdb,這就要求使用者不得不更換自己的 rocksdb 版本來適應 SOFAJRaft, 所以我們希望做一個改進:移除對 rocksdb 的依賴,構建出一個純 Java 實現的索引模組。

PART. 2 前置知識

Log Structured File Systems

如果學習過類似 Kafka 等訊息佇列的同學,對日誌型系統應該並不陌生。

如圖所示,我們可以在單機磁碟上儲存一些日誌型檔案,這些檔案中一般包含了舊檔案和新檔案的集合。區別在於 Active Data File 一般是對映到記憶體中的並且正在寫入的新檔案(基於 mmap 記憶體對映技術),而 Older Data File 是已經寫完了,並且都 Flush 到磁碟上的舊檔案,當一塊 Active File 寫完之後,就會將其關閉,並開啟一個新的 Active File 繼續寫。

img

並且每一次的寫入,每個 Log Entry 都會被 Append 到 Active File 的尾部,而 Active File 往往會用 mmap 記憶體對映技術,將檔案對映到 os Page Cache 裡,因此每一次的寫入都是記憶體順序寫,效能非常高。

終上所述,一塊 File 無非就是一些 Log Entry 的集合,如圖所示:

img

同時,僅僅將日誌寫入到 File 中還不夠,因為當需要搜尋日誌的時候,我們不可能順序遍歷每一塊檔案去搜尋,這樣效能就太差了。所以我們還需要構建這些檔案的 “目錄”,也即索引檔案。這裡的索引本質上也是一些檔案的集合,其儲存的索引項一般是固定大小的,並提供了 LogEntry 的元資訊,如:

- File_Id : 其對應的 LogEntry 儲存在哪一塊 File 中

- Value_sz : LogEntry 的資料大小

(注: LogEntry 是被序列化後, 以二進位制的方式儲存的)

- Value_pos: 儲存在對應 File 中的哪個位置開始

- 其他的可能還有 crc,時間戳等......

img

那麼依據索引檔案的特性,就能夠非常方便的查詢 IndexEntry。

- 日誌項 IndexEntry 是固定大小的

- IndexEntry 儲存了 LogEntry 的元資訊

- IndexEntry 具有單調遞增的特性

舉例,如果要查詢 LogIndex = 4 的日誌:

- 第一步,根據 LogIndex = 4,可以知道索引儲存的位置:IndexPos = IndexEntrySize * 4

- 第二步,根據 IndexPos,去索引檔案中,取出對應的索引項 IndexEntry

- 第三步,根據 IndexEntry 中的元資訊,如 File_Id、Pos 等,到對應的 Data File 中搜尋

- 第四步,找到對應的 LogEntry

img

記憶體對映技術 mmap

上文一直提到了一個技術:將檔案對映到記憶體中,在記憶體中寫 Active 檔案,這也是日誌型系統的一個關鍵技術,在 Unix/Linux 系統下讀寫檔案,一般有兩種方式。

傳統檔案 IO 模型

一種標準的 IO 流程, 是 Open 一個檔案,然後使用 Read 系統呼叫讀取檔案的一部分或全部。這個 Read 過程是這樣的:核心將檔案中的資料從磁碟區域讀取到核心頁高速緩衝區,再從核心的高速緩衝區讀取到使用者程式的地址空間。這裡就涉及到了資料的兩次拷貝:磁碟->核心,核心->使用者態。

而且當存在多個程式同時讀取同一個檔案時,每一個程式中的地址空間都會儲存一份副本,這樣肯定不是最優方式的,造成了實體記憶體的浪費,看下圖:

img

記憶體對映技術

第二種方式就是使用記憶體對映的方式

具體操作方式是:Open 一個檔案,然後呼叫 mmap 系統呼叫,將檔案內容的全部或一部分直接對映到程式的地址空間(直接將使用者程式私有地址空間中的一塊區域與檔案物件建立對映關係),對映完成後,程式可以像訪問普通記憶體一樣做其他的操作,比如 memcpy 等等。mmap 並不會預先分配實體地址空間,它只是佔有程式的虛擬地址空間。

當第一個程式訪問核心中的緩衝區時,因為並沒有實際拷貝資料,這時 MMU 在地址對映表中是無法找到與地址空間相對應的實體地址的,也就是 MMU 失敗,就會觸發缺頁中斷。核心將檔案的這一頁資料讀入到核心高速緩衝區中,並更新程式的頁表,使頁表指向核心緩衝中 Page Cache 的這一頁。之後有其他的程式再次訪問這一頁的時候,該頁已經在記憶體中了,核心只需要將程式的頁表登記並且指向核心的頁高速緩衝區即可,如下圖所示:

對於容量較大的檔案來說(檔案大小一般需要限制在 1.5~2G 以下),採用 mmap 的方式其讀/寫的效率和效能都非常高。

img

當然,需要如果採用了 mmap 記憶體對映,此時呼叫 Write 並不是寫入磁碟,而是寫入 Page Cache 裡。因此,如果想讓寫入的資料儲存到硬碟上,我們還需要考慮在什麼時間點 Flush 最合適 (後文會講述)

img

PART. 3 架構設計

3.1 SOFAJRaft 原有日誌系統架構

下圖是 SOFAJRaft 原有日誌系統整體上的設計:

img

其中 LogManager 提供了和日誌相關的介面,如:

/*** Append log entry vector and wait until it's stable (NOT COMMITTED!)** @param entries log entries* @param done    callback*/void appendEntries(final Listentries, StableClosure done);/*** Get the log entry at index.** @param index the index of log entry* @return the log entry with {@code index}*/LogEntry getEntry(final long index);/*** Get the log term at index.** @param index the index of log entry* @return the term of log entry*/long getTerm(final long index);

實際上,當上層的 Node 呼叫這些方法時,LogManager 並不會直接處理,而是通過 OfferEvent( done, EventType ) 將事件釋出到高效能的併發佇列 Disruptor 中等待排程執行。

因此,可以把 LogManager 看做是一個 “門面”,提供了訪問日誌的介面,並通過 Disruptor 進行併發排程。

「注」: SOFAJRaft 中還有很多地方都基於 Disruptor 進行解耦,非同步回撥,並行排程, 如 SnapshotExecutor、NodeImpl 等,感興趣的小夥伴可以去社群一探究竟,對於學習 Java 併發程式設計有很大的益處 !

關於 Disruptor 併發佇列的介紹,可以看這裡:

https://tech.meituan.com/2016/11/18/disruptor.html

最後,實際儲存日誌的地方就是 LogManager 的呼叫物件,LogStorage。

而 LogStorage 也是一個介面:

/*** Append entries to log.*/boolean appendEntry(final LogEntry entry);/*** Append entries to log, return append success number.*/int appendEntries(final Listentries);/*** Delete logs from storage's head, [first_log_index, first_index_kept) will* be discarded.*/boolean truncatePrefix(final long firstIndexKept);/*** Delete uncommitted logs from storage's tail, (last_index_kept, last_log_index]* will be discarded.*/boolean truncateSuffix(final long lastIndexKept);

在原有體系中,其預設的實現類是 RocksDBLogStorage,並且採用了索引和日誌分離儲存的設計,索引儲存在 RocksDB 中,而日誌儲存在 SegmentFile 中。

img

如圖所示,RocksDBSegmentLogStorage 繼承了 RocksDBLogStorageRocksDBSegmentLogStorage 負責日誌的儲存 RocksDBLogStorage 負責索引的儲存。

3.2 專案任務分析

通過上文對原有日誌系統的描述,結合該專案的需求,可以知道本次任務我需要做的就是基於 Java 實現一個新的 LogStorage,並且能夠不依賴 RocksDB。實際上日誌和索引儲存在實現的過程中會有很大的相似之處。例如,檔案記憶體對映 mmap、檔案預分配、非同步刷盤等。因此我的任務不僅僅是做一個新的索引模組,還需要做到以下:

- 一套能夠被複用的檔案系統, 使得日誌和索引都能夠直接複用該檔案系統,實現各自的儲存

- 相容 SOFAJRaft 的儲存體系,實現一個新的 LogStorage,能夠被 LogManager 所呼叫

- 一套高效能的儲存系統,需要對原有的儲存系統在效能上有較大的提升

- 一套程式碼可讀性強的儲存系統,程式碼需要符合 SOFAJRaft 的規範

......

在本次任務中,我和導師在儲存架構的設計上進行了多次的討論與修改,最終設計出了一套完整的方案,能夠完美的契合以上的所有要求。

3.3 改進版的日誌系統

架構設計

下圖為改進版本的日誌系統,其中 DefaultLogStorage 為上文所述 LogStorage 的實現類。三大 DB 為邏輯上的儲存物件, 實際的資料儲存在由 FileManager 所管理的 AbstractFiles 中,此外 ServiceManager 中的 Service 起到輔助的效果,例如 FlushService 可以提供刷盤的作用。

img

為什麼需要三大 DB 來儲存資料呢? ConfDB 是幹什麼用的?

以下這幅圖可以很好的解釋三大 DB 的作用:

img

因為在 SOFAJraft 原有的儲存體系中,為了提高讀取 Configuration 型別的日誌的效能,會將 Configuration 型別的日誌和普通日誌分離儲存。因此,這裡我們需要一個 ConfDB 來儲存 Configuration 型別的日誌。

3.4 程式碼模組說明

程式碼主要分為四大模組:

img

- db 模組 (db 資料夾下)

- File 模組 (File 資料夾下)

- service 模組 (service 資料夾下)

- 工廠模組 (factory 資料夾下)

- DefaultLogStorage 就是上文所述的新的 LogStorage 實現類

3.5 效能測試

測試背景

- 作業系統:Window

- 寫入資料總大小:8G

- 記憶體:24G

- CPU:4 核 8 執行緒

- 測試程式碼:

#DefaultLogStorageBenchmark

資料展示

Log Number 代表總共寫入了 524288 條日誌

Log Size 代表每條日誌的大小為 16384

Total size 代表總共寫入了 8589934592 (8G) 大小的資料

寫入耗時 (45s)

讀取耗時 (5s)

Test write: Log number   :524288 Log Size     :16384 Cost time(s) :45 Total size   :8589934592  Test read: Log number   :524288 Log Size     :16384 Cost time(s) :5 Total size   :8589934592Test done!

PART. 4 系統亮點

### 4.1 日誌系統檔案管理

在 2.1 節中,我介紹了一個日誌系統的基本概念,回顧一下:

img

而本專案日誌檔案是如何管理的呢? 如圖所示,每一個 DB 的所有日誌檔案(IndexDB 對應 IndexFile, SegmentDB 對應 SegmentFile) 都由 File Manager 統一管理。

以 IndexDB 所使用的的 IndexFile 為例,假設每個 IndexFile 大小為 126,其中 fileHeader = 26 bytes,檔案能夠儲存十個索引項,每個索引項大小 10 bytes。

img

而 FileHeader 儲存了一塊檔案的基本元資訊:

// 第一個儲存元素的索引 : 對應圖中的 StartIndexdprivate volatile long       FirstLogIndex      = BLANK_OFFSET_INDEX;// 該檔案的偏移量,對應圖中的 BaseOffsetprivate long                FileFromOffset     = -1;

因此,FileManager 就能根據這兩個基本的元資訊,對所有的 File 進行統一的管理,這麼做有以下的好處:

- 統一的管理所有檔案

- 方便根據 LogIndex 查詢具體的日誌在哪個檔案中, 因為所有檔案都是根據 FirstLogIndex 排列的,很顯然在這裡可以基於二分演算法查詢:

int lo = 0, hi = this.files.size() - 1;while (lo <= hi) {   final int mid = (lo + hi) >>> 1;   final AbstractFile file = this.files.get(mid);   if (file.getLastLogIndex() < logIndex) {       lo = mid + 1;   } else if (file.getFirstLogIndex() > logIndex) {       hi = mid - 1;   } else {       return this.files.get(mid);   }}

- 方便 Flush 刷盤(4.2 節中會提到)

4.2 Group Commit - 組提交

在章節 2.2 中我們聊到,因為記憶體對映技術 mmap 的存在,Write 之後不能直接返回,還需要 Flush 才能保證資料被儲存到了磁碟上,但同時也不能直接寫回磁碟,因為磁碟 IO 的速度極慢,每寫一條日誌就 Flush 一次的話效能會很差。

因此,為了防止磁碟 '拖後腿',本專案引入了 Group commit 機制,Group commit 的思想是延遲 Flush,先儘可能多的寫入一批的日誌到 Page Cache 中,然後統一呼叫 Flush 減少刷盤的次數,如圖所示:

img

- LogManager 通過呼叫 appendEntries() 批量寫入日誌

- DefaultLogStorage 通過呼叫 DB 的介面寫入日誌

- DefaultLogStorage 註冊一個 FlushRequest 到對應 DB 的 FlushService 中,並阻塞等待,FlushRequest 包含了期望刷盤的位置 ExpectedFlushPosition。

private boolean waitForFlush(final AbstractDB logDB, final long exceptedLogPosition,                            final long exceptedIndexPosition) {   try {       final FlushRequest logRequest = FlushRequest.buildRequest(exceptedLogPosition);       final FlushRequest indexRequest = FlushRequest.buildRequest(exceptedIndexPosition);       // 註冊 FlushRequest       logDB.registerFlushRequest(logRequest);       this.indexDB.registerFlushRequest(indexRequest);   // 阻塞等待喚醒       final int timeout = this.storeOptions.getWaitingFlushTimeout();       CompletableFuture.allOf(logRequest.getFuture(), indexRequest.getFuture()).get(timeout, TimeUnit.MILLISECONDS);   } catch (final Exception e) {       LOG.error(.....);       return false;   }}

- FlushService 刷到 expectedFlushPosition 後,通過 doWakeupConsumer() 喚醒阻塞等待的 DefaultLogStorage

while (!isStopped()) {   // 阻塞等待刷盤請求   while ((size = this.requestQueue.blockingDrainTo(this.tempQueue, QUEUE_SIZE, WAITING_TIME,       TimeUnit.MILLISECONDS)) == 0) {       if (isStopped()) {           break;       }   }   if (size > 0) {       .......       // 執行刷盤       doFlush(maxPosition);       // 喚醒 DefaultLogStorage       doWakeupConsumer();       .....   }}

那麼 FlushService 到底是如何配合 FileManager 進行刷盤的呢? 或者應該問 FlushService 是如何找到對應的檔案進行刷盤?

實際上在 FileManager 維護了一個變數 FlushedPosition,就代表了當前刷盤的位置。從 4.1 節中我們瞭解到 FileManager 中每一塊 File 的 FileHeader 都記載了當前 File 的 BaseOffset。因此,我們只需要根據 FlushedPosition,查詢其當前在哪一塊 File 的區間裡,便可找到對應的檔案,例如:

當前 FlushPosition = 130,便可以知道當前刷到了第二塊檔案。

img

4.3 檔案預分配

當日志系統寫滿一個檔案,想要開啟一個新檔案時,往往是一個比較耗時的過程。所謂檔案預分配,就是事先通過 mmap 對映一些空檔案存在容器中,當下一次想要 Append 一條 Log 並且前一個檔案用完了,我們就可以直接到這個容器裡面取一個空檔案,在這個專案中直接使用即可。有一個後臺的執行緒 AllocateFileService 在這個 Allocator 中,我採用的是典型的生產者消費者模式,即用了 ReentrantLock + Condition 實現了檔案預分配。

// Pre-allocated filesprivate final ArrayDequeblankFiles = new ArrayDeque<>();private final Lock                        allocateLock      private final Condition                   fullCond          private final Condition                   emptyCond

其中 fullCond 用於代表當前的容器是否滿了,emptyCond 代表當前容器是否為空。

private void doAllocateAbstractFileInLock() throws InterruptedException {   this.allocateLock.lock();   try {     // 如果容器滿了, 則阻塞等待, 直到被喚醒       while (this.blankAbstractFiles.size() >= this.storeOptions.getPreAllocateFileCount()) {           this.fullCond.await();       }       // 分配檔案       doAllocateAbstractFile0();    // 容器不為空, 喚醒阻塞的消費者       this.emptyCond.signal();   } finally {       this.allocateLock.unlock();   }}public AbstractFile takeEmptyFile() throws Exception {   this.allocateLock.lock();   try {       // 如果容器為空, 當前消費者阻塞等待       while (this.blankAbstractFiles.isEmpty()) {           this.emptyCond.await();       }       final AllocatedResult result = this.blankAbstractFiles.pollFirst();       // 喚醒生產者       this.fullCond.signal();         return result.abstractFile;   } finally {       this.allocateLock.unlock();   }}

4.4 檔案預熱

在 2.2 節中介紹 mmap 時,我們知道 mmap 系統呼叫後作業系統並不會直接分配實體記憶體空間,只有在第一次訪問某個 page 的時候,發出缺頁中斷 OS 才會分配。可以想象如果一個檔案大小為 1G,一個 page 4KB,那麼得缺頁中斷大概 100 萬次才能對映完一個檔案,所以這裡也需要進行優化。

當 AllocateFileService 預分配一個檔案的時候,會同時呼叫兩個系統:

- Madvise()簡單來說建議作業系統預讀該檔案,作業系統可能會採納該意見

- Mlock()將程式使用的部分或者全部的地址空間鎖定在實體記憶體中,防止被作業系統回收

對於 SOFAJRaft 這種場景來說,追求的是訊息讀寫低延遲,那麼肯定希望儘可能地多使用實體記憶體,提高資料讀寫訪問的操作效率。

- 收穫 -

在這個過程中我慢慢學習到了一個專案的常規流程:

- 首先,仔細打磨立項方案,深入考慮方案是否可行。

- 其次,專案過程中多和導師溝通,儘快發現問題。本次專案也遇到過一些我無法解決的問題,家純老師非常耐心的幫我找出問題所在,萬分感謝!

- 最後,應該注重程式碼的每一個細節,包括命名、註釋。

正如家純老師在結項點評中提到的,"What really makes xxx stand out is attention to low-level details "。

在今後的專案開發中,我會更加註意程式碼的細節,以追求程式碼優美併兼顧效能為目標。

後續,我計劃為 SOFAJRaft 專案作出更多的貢獻,期望於早日晉升成為社群 Committer。也將會藉助 SOFAStack 社群的優秀專案,不斷深入探索雲原生!

- 鳴謝 -

首先很幸運能參與本次開源之夏的活動,感謝馮家純導師對我的耐心指導和幫助 !

感謝開源軟體供應鏈點亮計劃和 SOFAStack 社群給予我的這次機會 !

*本週推薦閱讀*

SOFAJRaft 在同程旅遊中的實踐

下一個 Kubernetes 前沿:多叢集管理

基於 RAFT 的生產級高效能 Java 實現 - SOFAJRaft 系列內容合輯

終於!SOFATracer 完成了它的鏈路視覺化之旅

相關文章