(一)背景
三年前,我用 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 是在第一張圖右邊的:
