大道至簡(jiǎn),事半功倍:MultiGet IO 并發(fā)在 ToplingDB 中的協(xié)程實(shí)現(xiàn),以及在 MyTopling 中的落地應(yīng)用

(一)背景

三年前,我用 Fiber(協(xié)程)?實(shí)現(xiàn)了?TerarkDB 中 MultiGet 的 IO 并發(fā),因?yàn)?TerarkDB 分叉自 RocksDB 5.18,其 MultiGet 實(shí)現(xiàn)簡(jiǎn)單直接,所以我可以用 10 行代碼就對(duì)其完成?Fiber(協(xié)程) 改造,并獲得數(shù)量級(jí)的性能提升。

但是在?ToplingDB?中,為了充分借助社區(qū)力量,吸收社區(qū)成果,我們總是在 RocksDB 的最新版上展開工作,基本上每一兩個(gè)月就會(huì)合并一次 RocksDB 上游代碼。然而最近兩三年,上游 RocksDB 對(duì) MultiGet 進(jìn)行了大規(guī)模的修改

1.針對(duì)每個(gè) SST 的 MultiRead,在?FSRandomReadFile?中增加了 MultiRead 接口

? ?1.因?yàn)?MultiGet 中多個(gè) Key 落到同一個(gè) SST 的概率太低,從而對(duì)單個(gè) SST 的 MultiRead 收益太小

2.所以 RocksDB 又在?FSRandomReadFile?中增加了?ReadAsync?接口

3.MultiGet 的整個(gè)執(zhí)行鏈路都進(jìn)行了相應(yīng)修改以支持?MultiRead?和?ReadAsync

? ?1.其中用到了?folly::Coroutine和 C++20 的 Coroutine

? ?2.默認(rèn)情況下?Coroutine?選項(xiàng)是關(guān)閉的(控制USE_COROUTINES)

? ?3.Coroutine 選項(xiàng)打開時(shí),同時(shí)會(huì)打開?USE_FOLLY

? ?4.通過另一個(gè)宏?WITH_COROUTINES?來生成整個(gè)調(diào)用鏈路上的所有相關(guān)函數(shù)的異步版:

? ? ? 1.TableCache::MultiGet?的異步版?MultiGetAsync

? ? ? 2.Version::MultiGetFromSST?的異步版?MultiGetFromSSTAsync

? ? ? 3.TableCache::MultiGet?的異步版?MultiGetAsync

? ? ? 4.BlockBasedTable::RetrieveMultipleBlocks?的異步版?RetrieveMultipleBlocksAsync

4.在調(diào)用實(shí)際干活的 MultiGet 之前,還需要復(fù)雜的 Prepare 操作

? ?1.構(gòu)造專門的?MultiGetContext?對(duì)象,調(diào)用鏈上的函數(shù)都增加?MultiGetContext::Range?參數(shù)

? ?2.MultiGet 增加了額外的參數(shù) is_sorted,表示要 MultiGet 的多個(gè) Key 是否已經(jīng)排序,如果未排序,就要先進(jìn)行排序

5.就連不需要 IO 的 MemTable 也增加了 MultiGet 接口

所有這些下來,相關(guān)的代碼修改數(shù)以萬行計(jì),并且因?yàn)椴槐匾挠?jì)算太多,對(duì)性能有較大影響,在 Cache 命中的情況下(不需要 IO),反而對(duì)性能有很大的負(fù)面影響。

BTW: 甚至于連 Linux kernel io_uring 的作者?Jens Axboe?也給 RocksDB 當(dāng)外援

(二)ToplingDB?怎么辦

ToplingDB 中有三種 SST:

Topling Fast Table(SST)? ? ? ? ? ? ? 極速,一般常駐內(nèi)存,并且僅用于 L0 和 L1

Topling Zip Table(SST)? ? ? ? ? ? ? ? 使用可檢索內(nèi)存壓縮算法,直接在壓縮的數(shù)據(jù)上執(zhí)行搜索。

壓縮率和性能都遠(yuǎn)高于 RocksDB BlockBasedTable(不管它是用 zstd 還是 gzip/bzip)。

用于 L2 及更下層

Topling Auto Sort Table(SST)? ? ? ?允許輸入的數(shù)據(jù)無序,用于 MyTopling(MySQL on ToplingDB) 中索引創(chuàng)建以及批量加載

在這三種 SST 中,只有 Topling Zip Table 需要 IO 異步(實(shí)現(xiàn) IO 并發(fā)),如果也按照 RocksDB 那一套來實(shí)現(xiàn),會(huì)有諸多問題:

1.如前所述,Cache 命中時(shí),性能反而大幅降低

2.需要的代碼修改太多,RocksDB 有全球頂級(jí)的強(qiáng)大的研發(fā)團(tuán)隊(duì),即便是走在錯(cuò)誤的道路上,也可以堆人,堆資源,硬是憑借大力出奇跡,而我們顯然不能那樣干

3.RocksDB 的這個(gè)異步機(jī)制仍在 Experiment 狀態(tài),不光穩(wěn)定性存疑,而且處在不斷的變化演進(jìn)中,在它這個(gè)異步框架內(nèi)實(shí)現(xiàn),就要帶著它這個(gè)包袱,它有 Bug,我們也遭殃,它改了接口,我們也得跟著改

按照我事半功倍的信條:改最少的代碼,獲最大的收益,這個(gè)收益,不僅僅是性能上的收益,還有代碼的模塊化、可讀性、可維護(hù)性、可復(fù)用性……

所以,經(jīng)過仔細(xì)思考與權(quán)衡,ToplingDB 的 MultiGet 還是得由我自己來親自實(shí)現(xiàn)。

(三)實(shí)現(xiàn)方案

協(xié)程分無棧協(xié)程和有棧協(xié)程,無棧協(xié)程理論上性能更好,但是一來需要編譯器支持,二來需要修改全鏈路代碼。

RocksDB 的 Async IO 實(shí)現(xiàn)其實(shí)是個(gè)有棧協(xié)程和無棧協(xié)程的混合體。

編譯器支持還好說,現(xiàn)在主流編譯器(gcc,clang,msvc)都支持 C++20 的協(xié)程,但是修改全鏈路代碼這是不能忍受的。

所以我們必須使用有棧協(xié)程,仍然延續(xù)之前 TerarkDB 的選擇:boost fiber(再加上我的改進(jìn)。

有棧協(xié)程理論上性能不如無棧協(xié)程,但是憑借優(yōu)良的實(shí)現(xiàn),其性能代價(jià)(協(xié)程切換)已經(jīng)低到大致等同于一個(gè)函數(shù)調(diào)用。但有棧協(xié)程最大的優(yōu)勢(shì)其實(shí)是幾近完美的兼容性:不需要編譯器支持,不需要修改現(xiàn)有代碼,甚至連現(xiàn)有二進(jìn)制庫都可以完全復(fù)用。

io 模型上,三年前使用的是 linux aio,現(xiàn)在自然要使用 io_uring,但是對(duì)外的函數(shù)接口沒變,依然是:

ssize_tfiber_aio_read(intfd,void*buf,size_tlen,off_toffset);

這個(gè)函數(shù)原型跟 posix pread 完全相同:

ssize_tpread(intfd,void*buf,size_tcount,off_toffset);

只要上層代碼開啟多個(gè) fiber 執(zhí)行 fiber_aio_read,就自動(dòng)獲得了 io 并發(fā)的能力,在?MultiGet 中

if(read_options.async_io)

gt_fiber_pool.update_fiber_count(read_options.async_queue_depth);

size_tmemtab_miss=0;

for(size_ti=0;i<num_keys;i++){

if(!ctx_vec[i].done){// was not found in MemTable, try get_in_sst

if(read_options.async_io)

gt_fiber_pool.push({TERARK_C_CALLBACK(get_in_sst),i});

else

get_in_sst(i);

memtab_miss++;

}

}

while(counting<memtab_miss)gt_fiber_pool.unchecked_yield();

關(guān)鍵代碼,在 fiber_pool 中執(zhí)行 get_in_sst(i):

gt_fiber_pool.push({TERARK_C_CALLBACK(get_in_sst),i});

get_in_sst?調(diào)用 Version::Get(這個(gè)函數(shù)有 14 個(gè)參數(shù),在 RocksDB 中是稀疏平常),完全復(fù)用了現(xiàn)有代碼,Version::Get 最終會(huì)調(diào)用到?TableReader::Get,例如 BlockBasedTable::Get,或者 ToplingZipTable::Get,在 ToplingZipTable 中:

staticconstbyte_t*// remove error check for simplicity

FiberAsyncRead(void*vself,size_toffset,size_tlen,valvec<byte_t>*buf){

buf->resize_no_init(len);

autoself=(ToplingZipTableReader*)vself;

if(autostats=self->stats_){

autot0=qtime::now();

fiber_aio_read(self->storeFD_,buf->data(),len,offset);

autot1=qtime::now();

stats->recordInHistogram(SST_READ_MICROS,t0.us(t1));

}else{

fiber_aio_read(self->storeFD_,buf->data(),len,offset);

}

returnbuf->data();

}

FiberAsyncRead 是個(gè)回調(diào)函數(shù),會(huì)被 topling-zip 的抽象存儲(chǔ)接口?BlobStore::fspread_record_append?調(diào)用。

(四)fiber_aio_read 實(shí)現(xiàn)原理

接下來,我們看 fiber_aio_read 是怎么使用 fiber 和 io uring 的,io uring 的原理和用法,有很多非常優(yōu)秀的介紹文章,所以這里我就不再嘮叨了。我們把關(guān)注點(diǎn)放在 fiber_aio_read 本身。

必須注意:只有在一個(gè)線程中的多個(gè) fiber 中調(diào)用 fiber_aio_read,才能起到預(yù)期的 IO 并發(fā)的效果,僅僅把 pread 改成 fiber_aio_read 是不行的!

fiber_aio_read 核心代碼(為簡(jiǎn)化起見,省略了錯(cuò)誤處理):

intptr_texec_io(intfd,void*buf,size_tlen,off_toffset,intcmd){

io_returnio_ret={nullptr,0,0,false};

io_uring_sqe*sqe;

while((sqe=io_uring_get_sqe(&ring))==nullptr)io_reap();

io_uring_prep_rw(cmd,sqe,fd,buf,len,offset);

io_uring_sqe_set_data(sqe,&io_ret);

tobe_submit++;

m_fy.unchecked_wait(&io_ret.fctx);

returnio_ret.len;

}

可以看到,在獲取到 sqe 并設(shè)置好內(nèi)容之后,就調(diào)用了?m_fy.unchecked_wait,這個(gè)函數(shù)的作用是掛起當(dāng)前 fiber,把當(dāng)前 fiber 的 context 指針放到 io_ret.fctx 中,其作用相當(dāng)于把當(dāng)前 fiber 放入 boost fiber 的等待隊(duì)列(但是代價(jià)更低,這個(gè)是我對(duì) boost fiber 的一個(gè)改進(jìn)),然后切換到下一個(gè) fiber?繼續(xù)執(zhí)行。這里的下一個(gè) fiber,就是線程的主 fiber,于是代碼回到 MultiGet 中:

gt_fiber_pool.push({TERARK_C_CALLBACK(get_in_sst),i});

的下一行,從而繼續(xù)把下一個(gè)?get_in_sst(i)?放到另一個(gè) fiber 中執(zhí)行,直到 fiber_pool 中的 fiber 數(shù)量上限,或者到達(dá) MultiGet 中的:

while(counting<memtab_miss)gt_fiber_pool.unchecked_yield();

這兩種情況下都會(huì)進(jìn)入 fiber_aio_read 中的另一段代碼(為簡(jiǎn)化起見,省略了錯(cuò)誤處理):

voidio_reap(){

if(tobe_submit>0){

intsubmitted=io_uring_submit_and_wait(&ring,io_reqnum?0:1);

tobe_submit-=submitted;

io_reqnum+=submitted;

}

while(io_reqnum){

io_uring_cqe*cqe=nullptr;

io_uring_wait_cqe(&ring,&cqe);

io_return*io_ret=(io_return*)

io_uring_cqe_get_data(cqe);

io_ret->len=cqe->res;

io_uring_cqe_seen(&ring,cqe);

io_reqnum--;

m_fy.unchecked_notify(&io_ret->fctx);

}

}

有個(gè)單獨(dú)的 fiber 執(zhí)行執(zhí)行 io_reap,這其中的 IO 并發(fā)來自于?io_uring_submit_and_wait,在其它 fiber (gt_fiber_pool.push 觸發(fā)的,調(diào)用 fiber_aio_read 的 fiber) 中每個(gè) fiber 已經(jīng)創(chuàng)建了一個(gè) sqe,到這里就通過io_uring_submit_and_wait?把那些 sqe 一次性全部提交,io uring 就在 linux 內(nèi)核中并發(fā)執(zhí)行這些 io!

io_uring_submit_and_wait 返回后,我們就開始收割 io 執(zhí)行的結(jié)果,對(duì)于每一個(gè) io 執(zhí)行的結(jié)果(我們自定義的io_return),我們切換回(m_fy.unchecked_notify(&io_ret->fctx))這個(gè) io 所在的 fiber 繼續(xù)執(zhí)行。

以這樣的方式,整個(gè)執(zhí)行鏈路上的代碼無需任何改動(dòng),具體到我們這個(gè) MultiGet 實(shí)現(xiàn),就是 get_in_sst(i) 調(diào)用的Version::Get,Version::Get?的現(xiàn)有代碼被我們完全復(fù)用了,沒有任何改動(dòng)!對(duì)比原版 RocksDB 的 MultiGet 實(shí)現(xiàn),需要的工程量,算上 fiber_aio_read 本身的實(shí)現(xiàn)和我對(duì) boost fiber 的改進(jìn),百分之一都不到!

(五)實(shí)測(cè)效果

我們先用 db_bench 進(jìn)行測(cè)試(將?ReadOptions::async_io?設(shè)為 true 或 false):

./db_bench -json sideplugin/rockside/sample-conf/lcompact_enterprise.yaml\

-benchmarks=multireadrandom -num100000000-reads20000\

-multiread_batched=true-multiread_check=false\

-multiread_async=true-multiread_async_qd=128-batch_size=128

db_bench 測(cè)試的數(shù)據(jù)是?10億條,數(shù)據(jù)總量?110G,用?-benchmarks=seqfill?準(zhǔn)備數(shù)據(jù),使用 ToplingZipTable 壓縮后為?16G。

對(duì) PageCache?全命中的測(cè)試,我們使用的是 64 核 768G內(nèi)存的物理機(jī),存儲(chǔ)是本地 NVMe XFS 文件系統(tǒng);

對(duì) PageCache?不命中的測(cè)試,我們使用了一臺(tái) 4 核 4G 的 VMware 虛擬機(jī),并且通過 NFS 訪問遠(yuǎn)端 NVMe XFS 文件系統(tǒng)。

測(cè)試結(jié)果如下:

PageCache 全命中PageCache 不命中

async_io = true3.845 micros/op

260067 ops/sec

0.077 seconds

19968 operations;

28.8 MB/s

(19968 of 19968 found)

51.138 micros/op

19554 ops/sec

1.021 seconds

19968 operations;

2.2 MB/s

(19968 of 19968 found)

async_io = false3.829 micros/op

261142 ops/sec

0.076 seconds

19968 operations;

28.9 MB/s

(19968 of 19968 found)

622.804 micros/op

1605 ops/sec

12.436 seconds

19968 operations;

0.2 MB/s

(19968 of 19968 found)

可以看出,在 PageCache 全命中的場(chǎng)景下,ToplingDB async_io 的 MultiGet 和同步 IO 幾乎沒有差別,而PageCache 不命中的情況下,相差了 11 倍!并且,在 PageCache 全命中的情況下,幾乎沒有性能退化(RocksDB 的 Async IO 實(shí)現(xiàn)在 Cache 全命中的情況下性能有不少退化,見下面截圖)。

再看下 RocksDB 的測(cè)試結(jié)果(摘自?RocksDB 官方提交記錄):


ToplingDB 的簡(jiǎn)單實(shí)現(xiàn),性能遠(yuǎn)高于 RocksDB 數(shù)以萬行的復(fù)雜實(shí)現(xiàn)!這就是大道至簡(jiǎn)事半功倍!

(六)應(yīng)用到 MyTopling MRR

MyTopling 是把 MySQL 架構(gòu)在 ToplingDB 之上的云原生數(shù)據(jù)庫,擁有 ToplingDB 的分布式 Compact,以及 CSPP 事務(wù)引擎、Topling Zip 可檢索內(nèi)存壓縮……,自然也能充分地利用 ToplingDB 的 MultiGet,這在 MySQL 的世界中叫做 MRR(Multi Range Read),簡(jiǎn)單說就是在一些 Query 中,需要訪問多條記錄,MySQL 執(zhí)行規(guī)劃就認(rèn)為,先拿到多條記錄(ROW)的主鍵,把這些主鍵一股腦下推到存儲(chǔ)引擎層,由存儲(chǔ)引擎來決定如何用盡可能高效的方式拿到這些記錄(ROW),最簡(jiǎn)單直接的 Query 就是:

select*fromSomeTablewhereidin(...../* 比如這里有 1000 個(gè) ID */);

如果存儲(chǔ)引擎不做任何優(yōu)化,一次一條地拿就可以了,例如對(duì)于 MEMORY 存儲(chǔ)引擎,因?yàn)椴粻可娴?IO,一次拿一條,簡(jiǎn)單直接。

但是對(duì)于 InnoDB 或 MyTopling 存儲(chǔ)引擎,幾乎必然會(huì)有 IO 操作,MRR 就提供了一個(gè)優(yōu)化 IO 的機(jī)會(huì)。在 MyTopling 中,自然就是 ToplingDB 的 MultiGet 了,我們用 sysbench 測(cè)試,通過設(shè)置適當(dāng)?shù)拿顓?shù),構(gòu)造了一些會(huì)產(chǎn)生 IO,并且觸發(fā) MRR 的 Query:

sysbench select_random_points run --tables=1 --table_size=200000000 \

? ? ? ? --mysql-user=***** --mysql-password=***** --mysql-host=***** \

? ? ? ? --mysql-port=3306 --mysql-db=***** --time=300 --threads=10 \

? ? ? ? --mysql_storage_engine='rocksdb default charset latin1' \

? ? ? ? --rand-type=uniform --random_points=256

用這個(gè) sysbench 測(cè)試,數(shù)據(jù)庫服務(wù)器統(tǒng)一使用?8 核 32G?的規(guī)格,對(duì)比了幾個(gè)(在阿里云上運(yùn)行的)主流 MySQL (變體)及不同存儲(chǔ)配置的指標(biāo):

QPS 對(duì)比線程數(shù)=4線程數(shù)=10QPS 對(duì)比線程數(shù)=4線程數(shù)=10

網(wǎng)絡(luò)存儲(chǔ) MyTopling132193本地 SSD MyTopling7501243

網(wǎng)絡(luò)存儲(chǔ) MyRocks1729本地 SSD MyRocks162351

網(wǎng)絡(luò)存儲(chǔ) InnoDB1434本地 SSD InnoDB158302

RDMA存儲(chǔ) PolarDB116285PolarDB 無本地 SSD

注:QPS 看著很低,但一次讀 256 條,所以 QPS 乘以 256,才是每秒鐘讀的數(shù)據(jù)條數(shù)。

從中可以看到,不管是在網(wǎng)絡(luò)存儲(chǔ)上,還是在本地 SSD 文件系統(tǒng)上,MyTopling 優(yōu)勢(shì)特別突出。

跟 PolarDB 相比,MyTopling 使用的網(wǎng)絡(luò)存儲(chǔ)是阿里云 NAS,自然沒法跟 PolarDB 的 RDMA 存儲(chǔ)相比。4 線程時(shí),MyTopling 相比 PolarDB 還勉強(qiáng)有微弱的優(yōu)勢(shì)(132 <=> 116),這純粹是靠軟件上的并發(fā) IO 取勝的,到10 線程時(shí),PolarDB 硬件上的優(yōu)勢(shì)就無法用軟件取勝了!

但是本地 SSD 版,MyTopling 就是一枝獨(dú)秀了!

用火焰圖來看,一目了然:


我們先聚焦到 MySQL 對(duì) MultiGet 的調(diào)用:


圖中藍(lán)框之外的部分是為 MRR 準(zhǔn)備數(shù)據(jù)(ID主鍵),藍(lán)框之內(nèi)的,是線程主 fiber 中的 MultiGet,我們?cè)倬劢梗?/p>


MultiGet 中通過 fiber_pool.push 切換到 fiber_pool 中的 fiber 執(zhí)行 get_in_sst,所以,這個(gè)棧中是看不到 get_in_sst 的,get_in_sst 是在第一張圖右邊的:


?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容