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

📄

文|黃章衡(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   :8589934592
Test 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 存儲了一塊文件的基本元信息:

// 第一個存儲元素的索引 : 對應圖中的 StartIndexd
private volatile long       FirstLogIndex      = BLANK_OFFSET_INDEX;

// 該文件的偏移量,對應圖中的 BaseOffset
private 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 files
private 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 完成了它的鏈路可視化之旅

img

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章