充分利用NVMe SSD特性提升OLTP存储引擎的性能(VLDB23)

What Modern NVMe Storage Can Do, And How To Exploit It: High-Performance I/O for High-Performance Storage Engines (VLDB 23)

现代NVMe存储可以做些什么,我们在存储引擎上如何更好利用高性能的 IO

摘要

​ 基于闪存的 NVMe SSD 不仅便宜还具有高额的吞吐量,在将多个SSD集成到一个服务器上后,可以实现每秒千万级别的IO操作。

​ 我们的实验表明,现存的 out-of-memory 数据库 和 存储系统并没有充分利用这种高性能的优势,在本篇工作中,我们通过优化IO存储引擎缩小了软硬件之间性能上的差距,在大于内存容量10倍的数据集中,我们系统达到了每秒百万的 TPC-C 事务。

1 介绍

关于闪存,近年来闪存SSD替代传统磁盘成为默认的存储介质

  • 传输协议方面,传统的SATA接口逐渐被 PCIe/NVMe 接口所取代;

  • 存储吞吐量方面,单个SSD可以实现百万级的随机IO操作;

  • 现代服务器逐渐支持每个插槽128个PCIe通道,可以轻松在全带宽下承载8个SSD;

  • 鉴于傲腾NVM商业上的停产,作者认为闪存是支持大型数据集经济且高效的方式;

现有数据库系统的存储性能差距

image-20240504095403757

图1是对几个 out-of-memory 数据库系统查找性能进行的实验,这些数据库分别通过内存缓冲池cache,LSM-Tree,B+树等数据结构进行索引查找,实验中采用了8块闪存SSD,每块支持每秒1.5M随机4KB读,因此服务器最高支持 12.5M IOPS。

但从实验结果来看,对闪存专门做了优化的 Leanstore 数据库也仅有每秒3.6M随机读,与系统最大支持的 IOPS 相去甚远。

本篇文章研究目标是如何缩短这一存储的性能差距,可以分解为以下几个研究问题:

1、NVMe SSD 阵列能否在实际工作中达到硬件规格中承诺的性能?

2、哪一种 IO API 在存储系统中最应该被使用:POSIX API,libaio,io_uring?

​ 是否有必要上 SPDK,基于内核旁路的 IO?

3、为了降低IO放大,存储引擎维护的页面大小选多少合适?

4、如何确保SSD吞吐量所需的并行性,同时多少IO并发请求能使系统性能最大化?

5、如何设计存储引擎,使得它支持千万级别的 IOPS?

6、IO操作应该交给专门的IO线程处理,还是每个工作线程自己处理性能更好?

2 What Modern NVMe Storage Can Do

2.1 SSD的可扩展性,根据第一个问题进行实验

image-20240504102519142

从图2可以看出,在接了8块SSD的服务器上测试后,总吞吐量能够达到预期的12.5M IOPS;

事务型工作负载通常是写密集型的,右图通过改变读数据的占比,得到服务器最佳的硬件性能。

2.2 页面大小的权衡

大多数数据库尽可能选择大的页面,比如:8 KB (PostgreSQL, SQL Server), 16 KB (MySQL, LeanStore), 32 KB (WiredTiger-MongoDB)

大页面带来的好处:

  • 性能提升,更多的数据可以在一次访存中被读写,减少了访存的次数
  • 减少了缓冲池需要管理的页面数量,减少了缓冲池管理的开销(元数据,索引)
  • 充分利用带宽

大页面带来的坏处:

  • 过大的page size可能会导致IO写放大:

​ 1、对于16KB页面,写入100B数据需要磁盘向内存完整传入整个页面,造成多余的160倍的额外IO读写
​ 2、对于4KB页面,写入100B数据需要磁盘向内存完整传入整个页面,造成多余的40倍的额外IO读写

​ 因此,用4KB页面比16KB页面要减少4倍的写放大。

image-20240504134517506

图3做了对比工作,当页面大小为4KB时,吞吐量最高,带宽充分利用,延迟最低。

另外,对于更小的页面,IOPS和延迟都差的原因在于,闪存翻译层的开销较高。

2.3 SSD的并行

SSD是可高度并行的设备,闪存的随机读延迟约为100us,比磁盘快100倍,比内存慢1000倍;

如果使用同步访问请求,将只有 10K IOPS,或 40MB/s

根据IO并发量的不同,做了图4的实验:

image-20240504135311421

想要获得良好的性能,至少同时1000个IO并发请求,每个SSD125个左右;

想要系统IO达到吞吐量的饱和,至少同时3000个IO并发请求。

2.4 IO接口

image-20240504135905548

  • POSIX API 阻塞式同步IO

pread,pwrite,用户线程发出IO请求,将请求交给内核,此时用户线程进入阻塞状态,被CPU移入阻塞队列,内核线程将判断数据是否从磁盘传输到内存,直到数据完成传输后,将数据传入用户空间,此时拷贝完成,唤醒用户线程。

  • libaio

允许一个系统调用提交多个 IO请求,允许应用程序发起异步I/O请求,然后继续执行其他任务,而不必等待I/O操作完成。异步I/O操作通过io_submit()函数提交I/O请求,然后通过io_getevents()函数获取完成的I/O事件。

  • io_uring

在用户空间和内核空间分别设置了两个队列:提交队列SQE,完成队列CQE。

SQE用于用户线程向内核提交异步IO请求,CQE用于用户线程从内核接收完成的异步IO操作

io_uring与aio的区别在于:

1、libaio 仅支持 O_DIRECT 模式下的访存,io_uring 支持所有模式,同时也支持网络通信;

2、libaio一些内部实现仍采用阻塞式进行,比如元数据访存,设备不足时阻塞式等待;

3、libaio开销较高,需要一些额外的拷贝;

4、io_uring能够解决上面所有问题的同时,具有更强的性能和可扩展性。

  • SPDK 绕过内核的存取方式

SPDK库可以让用户空间线程直接将IO请求写入 NVMe请求队列,完全绕过系统内核

2.6 确定 leanstore IO性能下降的来源

1、并行度不够

leanstore使用的是传统的 POSIX API (O_DIRECT),工作线程使用同步IO操作处理缓冲区出现的页错误,每个请求都需要进行至少一次与内核的上下文切换,并在IO完成之前有阻塞。

2、过度订阅问题

当有大量的工作线程争夺剩余的CPU核心时,会导致非常高的上下文切换开销;

工作线程数量 > 系统最大支持的线程数时,调度延迟、资源竞争、上下文切换都会导致性能下降

3 如何设计 IO 优化的存储引擎

1、让系统每个模块充分利用并行性

总览

  • 每个工作线程可以同时接收数千级的并发请求,每个请求由工作线程内部的轻量级用户协程处理
  • 当请求涉及到I/O存取时,User Task 将换页请求发往I/O后端,当前协程被挂起 (await)

image-20240505160152527

3.2 如何在不引起线程超额订阅情况下,合理管理大量并发请求
  • 引入协程:如何在不引起线程超额订阅情况下,合理管理大量并发请求

image-20240506144340920

1、为避免超额订阅,工作线程的最大数量 = CPU物理核心数

但由于并发请求的数量是数以千计的,这里用到了新的思想,每个工作线程调度多个协程。

2、每个工作线程内部维护一个调度器,用于调度轻量级协程 User Task 的进行;

这里用的轻量级协程来自 Boost.Context 库,通过作者的测试,一个协程任务的切换,只需要20个CPU周期,而一个内核上下文切换需要几千个CPU周期。

3、这种非抢占式的协程调度意味着不能再使用同步阻塞式IO,因为它会阻塞整个工作线程。

因此,工作线程将使用非阻塞式异步IO接口,比如:libaio,io_uring,SPDK

当发生缺页时,IO请求将被提交到IO后端,并将当前协程的控制权交还给调度器,调度器会轮询并执行就绪的协程。

3.3 具体的执行流程例子

image-20240506154956824

​ 在原先设计的 Leanstore 存储引擎中,缺页时的页面替换,异步IO任务都交由后台线程完成,但缺点在于当工作负载发生变化的时候,我们很难知道后台线程的数量,难免会发生过度订阅,花更多的时间在切换上下文。

作者这里提出的观点是,由于协程和异步IO的加入,我们可以将这类后台线程放到前台,作为协程交给调度器调度,整个执行过程以图9为例:

  • 工作线程收到一个查询请求,需要通过遍历索引查找某项数据,于是创建协程的上下文,运行 user task1
  • 但在 task1 运行的过程中发现,这个页面不在内存中,这触发了缺页,这将生成一个IO请求,并将提交这个IO请求到IO后端,yield关键字会将当前协程挂起,并将控制权返还给调度器。
  • 调度器会轮询自己的任务队列,触发向SSD提交IO请求,页面替换任务,并交由IO控制器
  • 当有新的请求 user task2 到来时,若数据都已存在内存中,task2 将继续执行直到完成
  • 当 task1 的IO页面调度完成后,IO后端将调用 callback 函数通知调度器可以继续执行 task1
3.4 I/O模型的对比与选择
  • Dedicated I/O Threads Model

实现简单,在这种模型中,工作线程无法直接访问 SSD,而必须与处理 I/O 的专用 I/O 线程进行通信,这种专用I/O线程作为内核工作线程,处理大量I/O请求时需频繁切换上下文。

  • SSD Assignment Model

每个SSD分配给对应的工作线程,目的是改进缓存的局部性和减少轮询;

但系统在实际的运行过程中,每个工作线程需要与其他工作线程进行通信,以访问其所分配的 SSD,带来同步的开销。

  • All-to-All Model

每个工作线程都可以访问所有的SSD,线程之间不再需要传递I/O消息。

Leanstore 存储引擎原本的实现基于 Dedicated I/O Threads 模型,用的是传统的同步阻塞I/O

但为了更好的利用SSD并行性和异步I/O带来的优势,本文选择了 All-to-All 模型;

为了能更好反映 tradeoff,作者基于后两种I/O模型在两个异步I/O库上分别做了以下实验:

image-20240506193448614

由于本文基于多个SSD设备,需要一个RAID的软件解决方案

Linux md raid(Multiple Device RAID)是 Linux 内核提供的软件 RAID 解决方案,但它存在一个硬限制:the Linux md raid seems to have a hard limit at around 15 GB/s.

因此,本文实现了一个 RAID0 级别的数据条带化抽象:

在图13的消融实验中,可以看到一个较高的吞吐量的提升。

RAID0:无容错设计的条带硬盘阵列,以条带形式将RAID组的数据均匀分布在各个SSD中

image-20240507103929648

3.5 CPU部分的小优化

主要是页面替换方面,通过引入乐观父节点指针减少树的遍历次数节约CPU时间

leanstore 管理页面的方式:用B+树同时管理内存和磁盘中的页面,将页面分为 hot, cooling, cold 三类

在原本的leanstore baseline中,页面驱逐分为两个阶段:

  • 第一阶段,随机页面被选取,并放入 cooling 队列
  • 第二阶段,从 cooling 队列取出页面进行替换,替换后的页面标记为 cold

本文的优化在于,当某个内部节点的所有叶节点被 unswizzled 后,它应立即被放入 cooling 队列,但由于原先的实现B+树没有从叶节点指向父节点的指针,只能从根节点遍历,浪费大量的CPU周期(测出大约占10%CPU时间),而如果盲目对每个节点加入指向其父节点指针,会带来一笔空间上的开销。作者用了乐观父节点指针方法减少了树的遍历,但这里我个人认为算是小优化。

对于内部节点,只有它所有的叶节点都被 unswizzled 后,它才允许被替换

image-20240509185950088

4 实验部分

  • 实验1:比较系统之间的性能,其中 leanstore 的存储引擎采取了本文提到的若干 I/O 优化

image-20240507110859632

  • Ablation Study. 对比各项优化对系统整体性能提升的影响
  • baseline 在相同负载情况下,事务吞吐量和随机查找性能较低,但带宽很高,这是由于 baseline 默认页面大小为 16KB

实验2,消融实验,对比各个优化对系统整体性能提升的影响

baseline在相同负载情况下,事务吞吐量和随机查找性能较低,但带宽很高,这是由于baseline默认页面大小为 16KB

(可以理解为在相同并发请求下,带宽是有限制的)

  • 4KB在TPCC负载下有比较大的TPS提升,是由于减少了写放大
  • CPU的小优化,提升不算大,页面替换时占用的CPU周期缩短约5%~10%
  • 作者自己设计的RAID0解决方案,没有了linux md raid的硬限制,有一定的提升
  • 协程级别的task,避免了过度订阅,减少了内核上下文切换所需的CPU周期
  • SPDK,异步IO,绕过内核直接在用户空间与SSD进行读写

image-20240507110939127

  • 实验3:性能测试,随着数据集(仓库数量)的增加,事务吞吐量和带宽的变化

image-20240507113705657

  • 实验4:实现了多种不同异步I/O接口,比较它们在CPU核数增加时的性能影响

image-20240507113739751

  • 实验5:对比不同异步I/O接口的 CPU 利用率

image-20240509095706091

  • 实验6:测量事务延迟,在 TPC-C 负载下,由于每个事务平均需要4个同步读操作和3个页面写入,这导致了更高的读尾延迟,因为它们会被写操作暂停

image-20240509100312393

总结

​ 本篇文章最大的贡献在于做了很多对比实验,对TP数据库的存储引擎设计与优化提供了很多参考性的建议

1、一些重要概念:

  • 闪存SSD, 普通SSD
  • PCIe/NVMe接口,SATA接口

2、关于 O_direct,直接io,即非缓冲io

为什么有的系统需要内核缓冲io,有的系统需要直接io?

  • 文件系统,网络服务等需要内核缓冲io(不加io_direct),由于内核缓冲区可以减少磁盘IO的次数,相当于利用了数据的局部性;
  • 数据库等应用需要 io_direct,数据库需要自己的内存缓冲区管理数据,不经过内核缓冲区可以减少数据复制的次数,提高性能。

leanstore 运行 spdk

1
2
3
4
sudo ./frontend/tpcc --ssd_path="traddr=0000:03:00.0" --ioengine=spdk --nopp=true --partition_bits=12 --tpcc_warehouse_count=5 --run_for_seconds=10 --dram_gib=1 --worker_tasks=32 --async_batch_size=32 --optimistic_parent_pointer=1 --xmerge=1 --contention_split=1 --worker_threads=1 --pp_threads=1


sudo ./frontend/tpcc --ssd_path="traddr=0000:03:00.0" --ioengine=spdk --nopp=true --partition_bits=12 --tpcc_warehouse_count=10 --run_for_seconds=20 --dram_gib=2 --worker_tasks=32 --async_batch_size=32 --optimistic_parent_pointer=1 --xmerge=1 --contention_split=1 --worker_threads=1 --pp_threads=1

充分利用NVMe SSD特性提升OLTP存储引擎的性能(VLDB23)
https://yanghy233.github.io/2024/05/09/nvme-storage/
作者
yanghy
发布于
2024年5月9日
许可协议