大道至简,事半功倍:MultiGet IO 并发在 ToplingDB 中的协程实现,以及在 MyTopling 中的落地应用

(一)背景

三年前,我用 Fiber(协程) 实现了 TerarkDB 中 MultiGet 的 IO 并发,因为 TerarkDB 分叉自 RocksDB 5.18,其 MultiGet 实现简单直接,所以我可以用 10 行代码就对其完成 Fiber(协程) 改造,并获得数量级的性能提升。

但是在 ToplingDB 中,为了充分借助社区力量,吸收社区成果,我们总是在 RocksDB 的最新版上展开工作,基本上每一两个月就会合并一次 RocksDB 上游代码。然而最近两三年,上游 RocksDB 对 MultiGet 进行了大规模的修改

1.针对每个 SST 的 MultiRead,在 FSRandomReadFile 中增加了 MultiRead 接口

   1.因为 MultiGet 中多个 Key 落到同一个 SST 的概率太低,从而对单个 SST 的 MultiRead 收益太小

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

3.MultiGet 的整个执行链路都进行了相应修改以支持 MultiRead 和 ReadAsync

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

   2.默认情况下 Coroutine 选项是关闭的(控制USE_COROUTINES)

   3.Coroutine 选项打开时,同时会打开 USE_FOLLY

   4.通过另一个宏 WITH_COROUTINES 来生成整个调用链路上的所有相关函数的异步版:

      1.TableCache::MultiGet 的异步版 MultiGetAsync

      2.Version::MultiGetFromSST 的异步版 MultiGetFromSSTAsync

      3.TableCache::MultiGet 的异步版 MultiGetAsync

      4.BlockBasedTable::RetrieveMultipleBlocks 的异步版 RetrieveMultipleBlocksAsync

4.在调用实际干活的 MultiGet 之前,还需要复杂的 Prepare 操作

   1.构造专门的 MultiGetContext 对象,调用链上的函数都增加 MultiGetContext::Range 参数

   2.MultiGet 增加了额外的参数 is_sorted,表示要 MultiGet 的多个 Key 是否已经排序,如果未排序,就要先进行排序

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

所有这些下来,相关的代码修改数以万行计,并且因为不必要的计算太多,对性能有较大影响,在 Cache 命中的情况下(不需要 IO),反而对性能有很大的负面影响。

BTW: 甚至于连 Linux kernel io_uring 的作者 Jens Axboe 也给 RocksDB 当外援

(二)ToplingDB 怎么办

ToplingDB 中有三种 SST:

Topling Fast Table(SST)              极速,一般常驻内存,并且仅用于 L0 和 L1

Topling Zip Table(SST)                使用可检索内存压缩算法,直接在压缩的数据上执行搜索。

压缩率和性能都远高于 RocksDB BlockBasedTable(不管它是用 zstd 还是 gzip/bzip)。

用于 L2 及更下层

Topling Auto Sort Table(SST)       允许输入的数据无序,用于 MyTopling(MySQL on ToplingDB) 中索引创建以及批量加载

在这三种 SST 中,只有 Topling Zip Table 需要 IO 异步(实现 IO 并发),如果也按照 RocksDB 那一套来实现,会有诸多问题:

1.如前所述,Cache 命中时,性能反而大幅降低

2.需要的代码修改太多,RocksDB 有全球顶级的强大的研发团队,即便是走在错误的道路上,也可以堆人,堆资源,硬是凭借大力出奇迹,而我们显然不能那样干

3.RocksDB 的这个异步机制仍在 Experiment 状态,不光稳定性存疑,而且处在不断的变化演进中,在它这个异步框架内实现,就要带着它这个包袱,它有 Bug,我们也遭殃,它改了接口,我们也得跟着改

按照我事半功倍的信条:改最少的代码,获最大的收益,这个收益,不仅仅是性能上的收益,还有代码的模块化、可读性、可维护性、可复用性……

所以,经过仔细思考与权衡,ToplingDB 的 MultiGet 还是得由我自己来亲自实现。

(三)实现方案

协程分无栈协程和有栈协程,无栈协程理论上性能更好,但是一来需要编译器支持,二来需要修改全链路代码

RocksDB 的 Async IO 实现其实是个有栈协程和无栈协程的混合体。

编译器支持还好说,现在主流编译器(gcc,clang,msvc)都支持 C++20 的协程,但是修改全链路代码这是不能忍受的。

所以我们必须使用有栈协程,仍然延续之前 TerarkDB 的选择:boost fiber(再加上我的改进

有栈协程理论上性能不如无栈协程,但是凭借优良的实现,其性能代价(协程切换)已经低到大致等同于一个函数调用。但有栈协程最大的优势其实是几近完美的兼容性:不需要编译器支持,不需要修改现有代码,甚至连现有二进制库都可以完全复用。

io 模型上,三年前使用的是 linux aio,现在自然要使用 io_uring,但是对外的函数接口没变,依然是:

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

这个函数原型跟 posix pread 完全相同:

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

只要上层代码开启多个 fiber 执行 fiber_aio_read,就自动获得了 io 并发的能力,在 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();

关键代码,在 fiber_pool 中执行 get_in_sst(i):

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

get_in_sst 调用 Version::Get(这个函数有 14 个参数,在 RocksDB 中是稀疏平常),完全复用了现有代码,Version::Get 最终会调用到 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 是个回调函数,会被 topling-zip 的抽象存储接口 BlobStore::fspread_record_append 调用。

(四)fiber_aio_read 实现原理

接下来,我们看 fiber_aio_read 是怎么使用 fiber 和 io uring 的,io uring 的原理和用法,有很多非常优秀的介绍文章,所以这里我就不再唠叨了。我们把关注点放在 fiber_aio_read 本身。

必须注意:只有在一个线程中的多个 fiber 中调用 fiber_aio_read,才能起到预期的 IO 并发的效果,仅仅把 pread 改成 fiber_aio_read 是不行的!

fiber_aio_read 核心代码(为简化起见,省略了错误处理):

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 并设置好内容之后,就调用了 m_fy.unchecked_wait,这个函数的作用是挂起当前 fiber,把当前 fiber 的 context 指针放到 io_ret.fctx 中,其作用相当于把当前 fiber 放入 boost fiber 的等待队列(但是代价更低,这个是我对 boost fiber 的一个改进),然后切换到下一个 fiber 继续执行。这里的下一个 fiber,就是线程的主 fiber,于是代码回到 MultiGet 中:

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

的下一行,从而继续把下一个 get_in_sst(i) 放到另一个 fiber 中执行,直到 fiber_pool 中的 fiber 数量上限,或者到达 MultiGet 中的:

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

这两种情况下都会进入 fiber_aio_read 中的另一段代码(为简化起见,省略了错误处理):

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);

}

}

有个单独的 fiber 执行执行 io_reap,这其中的 IO 并发来自于 io_uring_submit_and_wait,在其它 fiber (gt_fiber_pool.push 触发的,调用 fiber_aio_read 的 fiber) 中每个 fiber 已经创建了一个 sqe,到这里就通过io_uring_submit_and_wait 把那些 sqe 一次性全部提交,io uring 就在 linux 内核中并发执行这些 io!

io_uring_submit_and_wait 返回后,我们就开始收割 io 执行的结果,对于每一个 io 执行的结果(我们自定义的io_return),我们切换回(m_fy.unchecked_notify(&io_ret->fctx))这个 io 所在的 fiber 继续执行。

以这样的方式,整个执行链路上的代码无需任何改动,具体到我们这个 MultiGet 实现,就是 get_in_sst(i) 调用的Version::Get,Version::Get 的现有代码被我们完全复用了,没有任何改动!对比原版 RocksDB 的 MultiGet 实现,需要的工程量,算上 fiber_aio_read 本身的实现和我对 boost fiber 的改进,百分之一都不到!

(五)实测效果

我们先用 db_bench 进行测试(将 ReadOptions::async_io 设为 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 测试的数据是 10亿条,数据总量 110G,用 -benchmarks=seqfill 准备数据,使用 ToplingZipTable 压缩后为 16G。

对 PageCache 全命中的测试,我们使用的是 64 核 768G内存的物理机,存储是本地 NVMe XFS 文件系统;

对 PageCache 不命中的测试,我们使用了一台 4 核 4G 的 VMware 虚拟机,并且通过 NFS 访问远端 NVMe XFS 文件系统。

测试结果如下:

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 全命中的场景下,ToplingDB async_io 的 MultiGet 和同步 IO 几乎没有差别,而PageCache 不命中的情况下,相差了 11 倍!并且,在 PageCache 全命中的情况下,几乎没有性能退化(RocksDB 的 Async IO 实现在 Cache 全命中的情况下性能有不少退化,见下面截图)。

再看下 RocksDB 的测试结果(摘自 RocksDB 官方提交记录):


ToplingDB 的简单实现,性能远高于 RocksDB 数以万行的复杂实现!这就是大道至简事半功倍

(六)应用到 MyTopling MRR

MyTopling 是把 MySQL 架构在 ToplingDB 之上的云原生数据库,拥有 ToplingDB 的分布式 Compact,以及 CSPP 事务引擎、Topling Zip 可检索内存压缩……,自然也能充分地利用 ToplingDB 的 MultiGet,这在 MySQL 的世界中叫做 MRR(Multi Range Read),简单说就是在一些 Query 中,需要访问多条记录,MySQL 执行规划就认为,先拿到多条记录(ROW)的主键,把这些主键一股脑下推到存储引擎层,由存储引擎来决定如何用尽可能高效的方式拿到这些记录(ROW),最简单直接的 Query 就是:

select*fromSomeTablewhereidin(...../* 比如这里有 1000 个 ID */);

如果存储引擎不做任何优化,一次一条地拿就可以了,例如对于 MEMORY 存储引擎,因为不牵涉到 IO,一次拿一条,简单直接。

但是对于 InnoDB 或 MyTopling 存储引擎,几乎必然会有 IO 操作,MRR 就提供了一个优化 IO 的机会。在 MyTopling 中,自然就是 ToplingDB 的 MultiGet 了,我们用 sysbench 测试,通过设置适当的命令参数,构造了一些会产生 IO,并且触发 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

用这个 sysbench 测试,数据库服务器统一使用 8 核 32G 的规格,对比了几个(在阿里云上运行的)主流 MySQL (变体)及不同存储配置的指标:

QPS 对比线程数=4线程数=10QPS 对比线程数=4线程数=10

网络存储 MyTopling132193本地 SSD MyTopling7501243

网络存储 MyRocks1729本地 SSD MyRocks162351

网络存储 InnoDB1434本地 SSD InnoDB158302

RDMA存储 PolarDB116285PolarDB 无本地 SSD

注:QPS 看着很低,但一次读 256 条,所以 QPS 乘以 256,才是每秒钟读的数据条数。

从中可以看到,不管是在网络存储上,还是在本地 SSD 文件系统上,MyTopling 优势特别突出。

跟 PolarDB 相比,MyTopling 使用的网络存储是阿里云 NAS,自然没法跟 PolarDB 的 RDMA 存储相比。4 线程时,MyTopling 相比 PolarDB 还勉强有微弱的优势(132 <=> 116),这纯粹是靠软件上的并发 IO 取胜的,到10 线程时,PolarDB 硬件上的优势就无法用软件取胜了!

但是本地 SSD 版,MyTopling 就是一枝独秀了!

用火焰图来看,一目了然:


我们先聚焦到 MySQL 对 MultiGet 的调用:


图中蓝框之外的部分是为 MRR 准备数据(ID主键),蓝框之内的,是线程主 fiber 中的 MultiGet,我们再聚焦:


MultiGet 中通过 fiber_pool.push 切换到 fiber_pool 中的 fiber 执行 get_in_sst,所以,这个栈中是看不到 get_in_sst 的,get_in_sst 是在第一张图右边的:


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,732评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,496评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,264评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,807评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,806评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,675评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,029评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,683评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,704评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,666评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,773评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,413评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,016评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,204评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,083评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,503评论 2 343

推荐阅读更多精彩内容