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

44次阅读

共计 7983 个字符,预计需要花费 20 分钟才能阅读完成。

(一)背景三年前,我用 Fiber(协程) 实现了 TerarkDB 中 MultiGet 的 IO 并发,因为 TerarkDB 分叉自 RocksDB 5.18,其 MultiGet 实现简略间接,所以我能够用 10 行代码就对其实现 Fiber(协程) 革新,并取得数量级的性能晋升。然而在 ToplingDB 中,为了充沛借助社区力量,排汇社区成绩,咱们总是在 RocksDB 的最新版上开展工作,基本上每一两个月就会合并一次 RocksDB 上游代码。然而最近两三年,上游 RocksDB 对 MultiGet 进行了大规模的批改:针对每个 SST 的 MultiRead,在 FSRandomReadFile 中减少了 MultiRead 接口因为 MultiGet 中多个 Key 落到同一个 SST 的概率太低,从而对单个 SST 的 MultiRead 收益太小所以 RocksDB 又在 FSRandomReadFile 中减少了 ReadAsync 接口 MultiGet 的整个执行链路都进行了相应批改以反对 MultiRead 和 ReadAsync 其中用到了 folly::Coroutine 和 C++20 的 Coroutine 默认状况下 Coroutine 选项是敞开的(管制宏 USE_COROUTINES)Coroutine 选项关上时,同时会关上 USE_FOLLY 通过另一个宏 WITH_COROUTINES 来生成整个调用链路上的所有相干函数的异步版:TableCache::MultiGet 的异步版 MultiGetAsyncVersion::MultiGetFromSST 的异步版 MultiGetFromSSTAsyncTableCache::MultiGet 的异步版 MultiGetAsyncBlockBasedTable::RetrieveMultipleBlocks 的异步版 RetrieveMultipleBlocksAsync 在调用理论干活的 MultiGet 之前,还须要简单的 Prepare 操作结构专门的 MultiGetContext 对象,调用链上的函数都减少 MultiGetContext::Range 参数 MultiGet 减少了额定的参数 is_sorted,示意要 MultiGet 的多个 Key 是否曾经排序,如果未排序,就要先进行排序就连不须要 IO 的 MemTable 也减少了 MultiGet 接口所有这些下来,相干的代码批改数以万行计,并且因为不必要的计算太多,对性能有较大影响,在 Cache 命中的状况下(不须要 IO),反而对性能有很大的负面影响。BTW: 甚至于连 Linux kernel io_uring 的作者 Jens Axboe 也给 RocksDB 当外援:

(二)ToplingDB 怎么办 ToplingDB 中有三种 SST:Topling Fast Table(SST)极速,个别常驻内存,并且仅用于 L0 和 L1Topling Zip Table(SST)应用可检索内存压缩算法,间接在压缩的数据上执行搜寻。压缩率和性能都远高于 RocksDB BlockBasedTable(不论它是用 zstd 还是 lz4/snappy/gzip/bzip)。用于 L2 及更上层 Topling Auto Sort Table(SST)容许输出的数据无序,用于 MyTopling(MySQL on ToplingDB) 中索引创立以及批量加载在这三种 SST 中,只有 Topling Zip Table 须要 IO 异步 (实现 IO 并发),如果也依照 RocksDB 那一套来实现,会有诸多问题:如前所述,Cache 命中时,性能反而大幅升高须要的代码批改太多,RocksDB 有寰球顶级的弱小的研发团队,即使是走在谬误的路线上,也能够堆人,堆资源,硬是凭借鼎力出奇观,而咱们显然不能那样干 RocksDB 的这个异步机制仍在 Experiment 状态,不光稳定性存疑,而且处在一直的变动演进中,在它这个异步框架内实现,就要带着它这个包袱,它有 Bug,咱们也遭殃,它改了接口,咱们也得跟着改依照我事倍功半的信条:改起码的代码,获最大的收益,这个收益,不仅仅是性能上的收益,还有代码的模块化、可读性、可维护性、可复用性……所以,通过认真思考与衡量,ToplingDB 的 MultiGet 还是得由我本人来亲自实现。(三)实现计划协程分无栈协程和有栈协程,无栈协程实践上性能更好,然而一来须要编译器反对,二来须要批改全链路代码。RocksDB 的 Async IO 实现其实是个有栈协程和无栈协程的混合体。编译器反对还好说,当初支流编译器(gcc,clang,msvc) 都反对 C++20 的协程,然而批改全链路代码这是不能忍耐的。所以咱们必须应用有栈协程,依然连续之前 TerarkDB 的抉择:boost fiber(再加上我的改良)。有栈协程实践上性能不如无栈协程,然而凭借低劣的实现,其性能代价(协程切换)曾经低到大抵等同于一个函数调用。但有栈协程最大的劣势其实是几近完满的兼容性:不须要编译器反对,不须要批改现有代码,甚至连现有二进制库都能够齐全复用。io 模型上,三年前应用的是 linux aio,当初天然要应用 io_uring,然而对外的函数接口没变,仍然是:ssize_t fiber_aio_read(int fd, void* buf, size_t len, off_t offset);
这个函数原型跟 posix pread 完全相同:ssize_t pread(int fd, void *buf, size_t count, off_t offset);
只有下层代码开启多个 fiber 执行 fiber_aio_read,就主动取得了 io 并发的能力,在 MultiGet 中:if (read_options.async_io) {
gt_fiber_pool.update_fiber_count(read_options.async_queue_depth);
size_t memtab_miss = 0;
for (size_t i = 0; i < num_keys; i++) {

if (!ctx_vec[i].is_done())
  gt_fiber_pool.push({TERARK_C_CALLBACK(get_in_sst), i}), memtab_miss++;

}
while (get_in_sst_cnt < memtab_miss) gt_fiber_pool.unchecked_yield();
} else {
for (size_t i = 0; i < num_keys; i++)

if (!ctx_vec[i].is_done()) get_in_sst(i);

}
要害代码,在 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 中:static const byte_t* // remove error check for simplicity
FiberAsyncRead(void vself, size_t offset, size_t len, valvec<byte_t> buf) {
buf->resize_no_init(len);
auto self = (ToplingZipTableReader*)vself;
if (auto stats = self->stats_) {

auto t0 = qtime::now();
fiber_aio_read(self->storeFD_, buf->data(), len, offset);
auto t1 = qtime::now();
stats->recordInHistogram(SST_READ_MICROS, t0.us(t1));

} else {

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

}
return buf->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_t exec_io(int fd, void* buf, size_t len, off_t offset, int cmd) {

io_return io_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);
return io_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 (get_in_sst_cnt < memtab_miss) gt_fiber_pool.unchecked_yield();
这两种状况下都会进入 fiber_aio_read 中的另一段代码(为简化起见,省略了错误处理):void io_reap() {

if (tobe_submit > 0) {int submitted = 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 -num 100000000 -reads 20000 \
       -multiread_batched=true -multiread_check=false \
       -multiread_async=true -multiread_async_qd=128 -batch_size=128db_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/op260067 ops/sec0.077 seconds19968 operations;28.8 MB/s(19968 of 19968 found)51.138 micros/op19554 ops/sec1.021 seconds19968 operations;2.2 MB/s(19968 of 19968 found)async_io = false3.829 micros/op261142 ops/sec0.076 seconds19968 operations;28.9 MB/s(19968 of 19968 found)622.804 micros/op1605 ops/sec12.436 seconds19968 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 MRRMyTopling 是把 MySQL 架构在 ToplingDB 之上的云原生数据库,领有 ToplingDB 的分布式 Compact,以及 CSPP 事务引擎、Topling Zip 可检索内存压缩……,天然也能充沛地利用 ToplingDB 的 MultiGet,这在 MySQL 的世界中叫做 MRR(Multi Range Read),简略说就是在一些 Query 中,须要拜访多条记录,MySQL 执行布局就认为,先拿到多条记录 (ROW) 的主键,把这些主键一股脑下推到存储引擎层,由存储引擎来决定如何用尽可能高效的形式拿到这些记录(ROW),最简略间接的 Query 就是:select from SomeTable where id in (….. / 比方这里有 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 InnoDB158302RDMA 存储 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 是在第一张图左边的:

正文完
 0