Curve 基於 Raft 的寫時延優化

語言: CN / TW / HK

1 背景

Curve( http://github.com/opencurve/... )是網易數帆自主設計研發的高性能、易運維、全場景支持的雲原生軟件定義存儲系統,旨滿足Ceph本身架構難以支撐的一些場景的需求,於2020年7月正式開源。當前由CurveBS和CurveFS兩個子項目構成,分別提供分佈式塊存儲和分佈式文件存儲兩種能力。其中CurveBS已經 成為開源雲原生數據庫PolarDB for PostgreSQL的分佈式共享存儲底座 ,支撐其存算分離架構。

在CurveBS的設計中,數據服務器ChunkServer數據一致性採用基於raft的分佈式一致性協議去實現的。

典型的基於raft一致性的寫Op實現如下圖所示:

以常見的三副本為例,其大致流程如下:

  1. 首先client 發送寫op(步驟1),寫op到達Leader後(如果沒有Leader,先會進行Leader選舉,寫Op總是先發送給Leader),Leader首先會接收寫Op,生成WAL(write ahead log),將WAL持久化到本地存儲引擎(步驟2), 並同時並行將WAL通過日誌發送rpc發送給兩個Follower(步驟3)。
  2. 兩個Follower在收到Leader的日誌請求後,將收到的日誌持久化到本地存儲引擎(步驟4)後,向Leader返回日誌寫入成功(步驟5)。
  3. 一般來説,Leader日誌總是會先完成落盤,此時再收到其他一個Follower的日誌成功的回覆後,即達成了大多數條件,就開始將寫Op提交到狀態機,並將寫Op寫入本地存儲引擎(步驟6)。
  4. 完成上述步驟後,即表示寫Op已經完成,可以向client返回寫成功(步驟7)。在稍晚一些時間,兩個Follower也將收到Leader日誌提交的消息,將寫Op應用到本地存儲引擎(步驟9)。

在目前CurveBS的實現中,寫Op是在raft apply 到本地存儲引擎(datastore)時,使用了基於O_DSYNC打開的sync寫的方式。實際上,在基於raft已經寫了日誌的情況下,寫Op不需要sync就可以安全的向client端返回,從而降低寫Op的時延,這就是本文所述的寫時延的優化的原理。

其中的代碼如下,在chunkfile的Open函數中使用了O_DSYNC的標誌。

CSErrorCode CSChunkFile::Open(bool createFile) {
    WriteLockGuard writeGuard(rwLock_);
    string chunkFilePath = path();
    // Create a new file, if the chunk file already exists, no need to create
    // The existence of chunk files may be caused by two situations:
    // 1. getchunk succeeded, but failed in stat or load metapage last time;
    // 2. Two write requests concurrently create new chunk files
    if (createFile
        && !lfs_->FileExists(chunkFilePath)
        && metaPage_.sn > 0) {
        std::unique_ptr<char[]> buf(new char[pageSize_]);
        memset(buf.get(), 0, pageSize_);
        metaPage_.version = FORMAT_VERSION_V2;
        metaPage_.encode(buf.get());

        int rc = chunkFilePool_->GetFile(chunkFilePath, buf.get(), true);
        // When creating files concurrently, the previous thread may have been
        // created successfully, then -EEXIST will be returned here. At this
        // point, you can continue to open the generated file
        // But the current operation of the same chunk is serial, this problem
        // will not occur
        if (rc != 0  && rc != -EEXIST) {
            LOG(ERROR) << "Error occured when create file."
                       << " filepath = " << chunkFilePath;
            return CSErrorCode::InternalError;
        }
    }
    int rc = lfs_->Open(chunkFilePath, O_RDWR|O_NOATIME|O_DSYNC);
    if (rc < 0) {
        LOG(ERROR) << "Error occured when opening file."
                   << " filepath = " << chunkFilePath;
        return CSErrorCode::InternalError;
    }
...
}

2 問題分析

先前之所以使用O_DSYNC,是考慮到raft的快照場景下,數據如果沒有落盤,一旦開始打快照,日誌也被Truncate掉的場景下,可能會丟數據,目前修改Apply寫不sync首先需要解決這個問題。

首先需要分析清楚Curve ChunkServer端打快照的過程,如下圖所示:

打快照過程的幾個關鍵點:

  1. 打快照這一過程是進StateMachine與讀寫Op的Apply在StateMachine排隊執行的;
  2. 快照所包含的last_applied_index在調用StateMachine執行保存快照之前,就已經保存了,也就是説執行快照的時候一定可以保證保存的last_applied_index已經被StateMachine執行過Apply了;
  3. 而如果修改StatusMachine的寫Op Apply去掉O_DSYNC,即不sync,那麼就會存在可能快照在truncate到last_applied_index,寫Op的Apply還沒真正sync到磁盤,這是我們需要解決的問題;

3 解決方案

解決方案有兩個:

3.1 方案一

  1. 既然打快照需要保證last_applied_index為止apply的寫Op必須Sync過,那麼最簡單的方式,就是在執行打快照時,執行一次Sync。這裏有3種方式,第一是對全盤進行一次FsSync。第二種方式,既然我們的打快照過程需要保存當前copyset中的所有chunk文件到快照元數據中,那麼我們天然就有當前快照的所有文件名列表,那麼我們可以在打快照時,對所有文件進行一次逐一Sync。第三種方式,鑑於一個複製組的chunk數量可能很多,而寫過的chunk數量可能不會很多,那麼可以在datastore執行寫op時,保存需要sync的chunkid列表,那麼在打快照時,只要sync上述列表中的chunk就可以了。
  2. 鑑於上述3種sync方式可能比較耗時,而且我們的打快照過程目前在狀態機中是“同步”的執行的,即打快照過程會阻塞IO,那麼可以考慮將打快照過程改為異步執行,同時這一修改也可減少打快照時對IO抖動的影響。

3.2 方案二

方案二則更為複雜,既然去掉O_DSYNC寫之後,我們目前不能保證last_applied_index為止的寫Op都被Sync了,那麼考慮將ApplyIndex拆分稱為兩個,即last_applied_index和last_synced_index。具體做法如下:

  1. 將last_applied_index拆分成兩個last_applied_index和last_synced_index,其中last_applied_index意義不變,增加last_synced_index,在執行一次全盤FsSync之後,將last_applied_index賦值給last_synced_index;
  2. 在前述打快照步驟中,將打快照前保存last_applied_index到快照元數據變更為last_synced_index,這樣即可保證在打快照時,快照包含的數據一定被sync過了;
  3. 我們需要一個後台線程定期去執行FsSync,通過定時器,定期執行Sync Task。執行過程可能是這樣的: 首先後台sync線程遍歷所有的狀態機,拿到當前的所有last_applied_index,執行FsSync,然後將上述last_applied_index賦值給對於狀態機的last_synced_index;

3.3 兩種方案的優缺點:

  1. 方案一改動較為簡單,只需要改動Curve代碼,不需要動braft的代碼,對braft框架是非侵入式的;方案二則較為複雜,需要改動braft代碼;
  2. 從快照執行性能來看,方案一會使得原有快照變慢,由於原有快照時同步的,因此最好在這次修改中改成異步執行快照;當然方案二也可以優化原有快照為異步,從而減少對IO的影響;

3.4 採取的方案:

  1. 採用方案一實現方式,原因是對braft的非侵入式修改,對於代碼的穩定性和對後續的兼容性都有好處。
  2. 至於對chunk的sync方式,採用方案一的第3種方式,即在datastore執行寫op時,保存需要sync的chunkid列表,同時在打快照時,sync上述列表中的chunkid,從而保證chunk全部落盤。這一做法避免頻繁的FsSync對全部所有chunkserver的造成IO的影響。此外,在執行上述sync時,採用批量sync的方式,並對sync的chunkid進行去重,進而減少實際sync的次數,從而減少對前台IO造成的影響。

4 POC

以下進行poc測試,測試在直接去掉O_DSYNC情況下,針對各種場景對IOPS,時延等是否有優化,每組測試至少測試兩次,取其中一組。

測試所用fio測試參數如下:

  • 4K隨機寫測試單卷IOPS:

    [global]
    rw=randwrite
    direct=1
    iodepth=128
    ioengine=libaio
    bsrange=4k-4k
    runtime=300
    group_reporting
    size=100G
    
    [disk01]
    filename=/dev/nbd0
  • 512K順序寫測單卷帶寬:

    [global]
    rw=write
    direct=1
    iodepth=128
    ioengine=libaio
    bsrange=512k-512k
    runtime=300
    group_reporting
    size=100G
     
     
    [disk01]
    filename=/dev/nbd0
  • 4K單深度隨機寫測試時延:
[global]
rw=randwrite
direct=1
iodepth=1
ioengine=libaio
bsrange=4k-4k
runtime=300
group_reporting
size=100G

[disk01]
filename=/dev/nbd0

集羣配置:

機器 roles disk
server1 client,mds,chunkserver ssd/hdd * 18
server2 mds,chunkserver ssd/hdd * 18
server3 mds,chunkserver ssd/hdd * 18

4.1 HDD對比測試結果

場景 優化前 優化後
單卷4K 隨機寫 IOPS=5928, BW=23.2MiB/s, lat=21587.15usec IOPS=6253, BW=24.4MiB/s, lat=20465.94usec
單卷512K順序寫 IOPS=550, BW=275MiB/s,lat=232.30msec IOPS=472, BW=236MiB/s,lat=271.14msec
單卷4K單深度隨機寫 IOPS=928, BW=3713KiB/s, lat=1074.32usec IOPS=936, BW=3745KiB/s, lat=1065.45usec

上述測試在RAID卡cache策略writeback下性能有略微提高,但是提升效果並不明顯,512K順序寫場景下甚至略有下降,並且還發現在去掉O_DSYNC後存在IO劇烈抖動的現象。

我們懷疑由於RAID卡緩存的關係,使得性能提升不太明顯,因此,我們又將RAID卡cache策略設置為writethough模式,繼續進行測試:

場景 優化前 優化後
單卷4K隨機寫 IOPS=993, BW=3974KiB/s,lat=128827.93usec IOPS=1202, BW=4811KiB/s, lat=106426.74usec
單卷單深度4K隨機寫 IOPS=21, BW=85.8KiB/s,lat=46.63msec IOPS=38, BW=154KiB/s,lat=26021.48usec

在RAID卡cache策略writethough模式下,性能提升較為明顯,單卷4K隨機寫大約有20%左右的提升。

4.2 SSD對比測試結果

SSD的測試在RAID直通模式(JBOD)下測試,性能對比如下:

場景 優化前 優化後
單卷4k隨機寫 bw=83571KB/s, iops=20892,lat=6124.95usec bw=178920KB/s, iops=44729,lat=2860.37usec
單卷512k順序寫 bw=140847KB/s, iops=275,lat=465.08msec bw=193975KB/s, iops=378,lat=337.72msec
單卷單深度4k隨機寫 bw=3247.3KB/s, iops=811,lat=1228.62usec bw=4308.8KB/s, iops=1077,lat=925.48usec

可以看到在上述場景下,測試效果有較大提升,4K隨機寫場景下IOPS幾乎提升了100%,512K順序寫也有較大提升,時延也有較大降低。

5 總結

上述優化適用於Curve塊存儲,基於RAFT分佈式一致性協議,可以減少RAFT狀態機應用到本地存儲引擎的一次立即落盤,從而減少Curve塊存儲的寫時延,提高Curve塊存儲的寫性能。在SSD場景下測試,性能有較大提升。對於HDD場景,由於通常啟用了RAID卡緩存的存在,效果並不明顯,因此我們提供了開關,在HDD場景可以選擇不啟用該優化。

本文作者:許超傑,網易數帆資深系統開發工程師