一 简介

用户对超高并发、超大规模计算等需求推动了存储硬件技术的不断发展,存储集群的性能越来越好,延时也越来越低,对整体 IO 路径的性能要求也越来越高。在云硬盘场景中,IO 请求从生成到后端的存储集群再到返回之间的 IO 路径比较复杂,虚拟化 IO 路径尤其可能成为性能瓶颈,因为虚机内所有的 IO 都需要通过它下发给后端的存储系统。我们使用了 SPDK 来优化虚拟化 IO 路径,提出了开源未解决的 SDPK 热升级和在线迁移方案,并且在高性能云盘场景中成功应用,取得了不错的效果,RSSD 云硬盘最高可达 120 万 IOPS。本文主要分享我们在这方面的一些经验。

二 SPDK vhost 的基本原理

SPDK(Storage Performance Development Kit ) 提供了一组用于编写高性能、可伸缩、用户态存储应用程序的工具和库,基本组成分为用户态、轮询、异步、无锁 NVMe 驱动,提供了从用户空间应用程序直接访问 SSD 的零拷贝、高度并行的访问。

在虚拟化 IO 路径中,virtio 是比较常用的一种半虚拟化解决方案,而 virtio 底层是通过 vring 来通信,下面先介绍下 virtio vring 的基本原理,每个 virtio vring 主要包含了以下几个部分:

desc table 数组,该数组的大小等于设备的队列深度,一般为 128。数组中每一个元素表示一个 IO 请求,元素中会包含指针指向保存 IO 数据的内存地址、IO 的长度等基本信息。一般一个 IO 请求对应一个 desc 数组元素,当然也有 IO 涉及到多个内存页的,那么就需要多个 desc 连成链表来使用,未使用的 desc 元素会通过自身的 next 指针连接到 free_head 中,形成一个链表,以供后续使用。

available 数组,该数组是一个循环数组,每一项表示一个 desc 数组的索引,当处理 IO 请求时,从该数组里拿到一个索引就可以到 desc 数组里面找到对应的 IO 请求了。

used 数组,该数组与 avail 类似,只不过用来表示完成的 IO 请求。当一个 IO 请求处理完成时,该请求的 desc 数组索引就会保存在该数组中,而前端 virtio 驱动得到通知后就会扫描该数据判断是否有请求完成,如果完成就会回收该请求对应的 desc 数组项以便下个 IO 请求使用。

SPDK vhost 的原理比较简单,初始化时先由 qemu 的 vhost 驱动将以上 virtio vring 数组的信息发送给 SPDK,然后 SPDK 通过不停的轮寻 available 数组来判断是否有 IO 请求,有请求就处理,处理完后将索引添加到 used 数组中,并通过相应的 eventfd 通知 virtio 前端。

当 SPDK 收到一个 IO 请求时,只是指向该请求的指针,在处理时需要能直接访问这部分内存,而指针指向的地址是 qemu 地址空间的,显然不能直接使用,因此这里需要做一些转化。

在使用 SPDK 时虚机要使用大页内存,虚机在初始化时会将大页内存的信息发送给 SPDK,SPDK 会解析该信息并通过 mmap 映射同样的大页内存到自己的地址空间,这样就实现了内存的共享,所以当 SPDK 拿到 qemu 地址空间的指针时,通过计算偏移就可以很方便的将该指针转换到 SPDK 的地址空间。

由上述原理我们可以知道 SPDK vhost 通过共享大页内存的方式使得 IO 请求可以在两者之间快速传递这个过程中不需要做内存拷贝,完全是指针的传递,因此极大提升了 IO 路径的性能。

我们对比了原先使用的 qemu 云盘驱动的延时和使用了 SPDK vhost 之后的延时,为了单纯对比虚拟化 IO 路径的性能,我们采用了收到 IO 后直接返回的方式:

1.单队列(1 iodepth, 1 numjob)

qemu 网盘驱动延时:

SPDK vhost 延时:

可见在单队列情况下延时下降的非常明显,平均延时由原来的 130us 下降到了 7.3us。

2.多队列(128 iodepth,1 numjob)

qemu 网盘驱动延时:

SPDK vhost 延时:

多队列时 IO 延时一般会比单队列更大些,可见在多队列场景下平均延时也由 3341us 下降为 1090us,下降为原来的三分之一。

三 SPDK 热升级

在我们刚开始使用 SPDK 时,发现 SPDK 缺少一重要功能——热升级。我们使用 SPDK 并基于 SPDK 开发自定义的 bdev 设备肯定会涉及到版本升级,并且也不能 100% 保证 SPDK 进程不会 crash 掉,因此一旦后端 SPDK 重启或者 crash,前端 qemu 里 IO 就会卡住,即使 SPDK 重启后也无法恢复。

我们仔细研究了 SPDK 的初始化过程发现,在 SPDK vhost 启动初期,qemu 会下发一些配置信息,而 SPDK 重启后这些配置信息都丢失了,那么这是否意味着只要 SPDK 重启后重新下发这些配置信息就能使 SPDK 正常工作呢?我们尝试在 qemu 中添加了自动重连的机制,并且一旦自动重连完成,就会按照初始化的顺序再次下发这些配置信息。开发完成后,初步测试发现确实能够自动恢复,但随着更严格的压测发现只有在 SPDK 正常退出时才能恢复,而 SPDK crash 退出后 IO 还是会卡住无法恢复。从现象上看应该是部分 IO 没有被处理,所以 qemu 端虚机一直在等待这些 IO 返回导致的。

通过深入研究 virtio vring 的机制我们发现在 SPDK 正常退出时,会保证所有的 IO 都已经处理完成并返回了才退出,也就是所在的 virtio vring 中是干净的。而在意外 crash 时是不能做这个保证的,意外 crash 时 virtio vring 中还有部分 IO 是没有被处理的,所以在 SPDK 恢复后需要扫描 virtio vring 将未处理的请求下发下去。这个问题的复杂之处在于,virtio vring 中的请求是按顺序下发处理的,但实际完成的时候并不是按照下发的顺序的。

假设在 virtio vring 的 available ring 中有 6 个 IO,索引号为 1,2,3,4,5,6,SPDK 按顺序的依次得到这个几个 IO,并同时下发给设备处理,但实际可能请求 1 和 4 已经完成,并返回了成功了,如下图所示,而 2,3,5,6 都还没有完成。这个时候如果 crash,重启后需要将 2,3,5,6 这个四个 IO 重新下发处理,而 1 和 4 是不能再次处理的,因为已经处理完成返回了,对应的内存也可能已经被释放。也就是说我们无法通过简单的扫描 available ring 来判断哪些 IO 需要重新下发,我们需要有一块内存来记录 virtio vring 中各个请求的状态,当重启后能够按照该内存中记录的状态来决定哪些 IO 是需要重新下发处理的,而且这块内存不能因 SPDK 重启而丢失,那么显然使用 qemu 进程的内存是最合适的。所以我们在 qemu 中针对每个 virtio vring 申请一块共享内存,在初始化时发送给 SPDK,SPDK 在处理 IO 时会在该内存中记录每个 virtio vring 请求的状态,并在意外 crash 恢复后能利用该信息找出需要重新下发的请求。

四 SPDK 在线迁移

SPDK vhost 所提供的虚拟化 IO 路径性能非常好,那么我们有没有可能使用该 IO 路径来代替原有的虚拟化 IO 路径呢?我们做了一些调研,SPDK 在部分功能上并没有现有的 qemu IO 路径完善,其中尤为重要的是在线迁移功能,该功能的缺失是我们使用 SPDK vhost 代替原有 IO 路径的最大障碍。

SPDK 在设计时更多是为网络存储准备的,所以支持设备状态的迁移,但并不支持设备上数据的在线迁移。而 qemu 本身是支持在线迁移的,包括设备状态和设备上的数据的在线迁移,但在使用 vhost 模式时是不支持在线迁移的。主要原因是使用了 vhost 之后 qemu 只控制了设备的控制链路,而设备的数据链路已经托管给了后端的 SPDK,也就是说 qemu 没有设备的数据流 IO 路径所以并不知道一个设备那些部分被写入了。

在考察了现有的 qemu 在线迁移功能后,我们觉着这个技术难点并不是不能解决的,因此我们决定在 qemu 里开发一套针对 vhost 存储设备的在线迁移功能。

块设备的在线迁移的原理比较简单,可以分为两个步骤,第一个步骤将全盘数据从头到尾拷贝到目标虚机,因为拷贝过程时间较长,肯定会发生已经拷贝的数据又被再次写入的情况,这个步骤中那些再次被写脏的数据块会在 bitmap 中被置位,留给第二个步骤来处理,步骤二中通过 bitmap 来找到那些剩余的脏数据块,将这些脏数据块发送到目标端,最后会 block 住所有的 IO,然后将剩余的一点脏数据块同步到目标端迁移就完成了。

SPDK 的在线迁移原理上于上面是相同的,复杂之处在于 qemu 没有数据的流 IO 路径,所以我们在 qemu 中开发了一套驱动可以用来实现迁移专用的数据流 IO 路径,并且通过共享内存加进程间互斥的方式在 qemu 和 SPDK 之间创建了一块 bitmap 用来保存块设备的脏页数量。考虑到 SPDK 是独立的进程可能会出现意外 crash 的情况,因此我们给使用的 pthread mutex 加上了 PTHREAD_MUTEX_ROBUST 特性来防止意外 crash 后死锁的情况发生,整体架构如下图所示:

五 SPDK IO uring 体验

IO uring 是内核中比较新的技术,在上游内核 5.1 以上才合入,该技术主要是通过用户态和内核态共享内存的方式来优化现有的 aio 系列系统调用,使得提交 IO 不需要每次都进行系统调用,这样减少了系统调用的开销,从而提供了更高的性能。

SPDK 在最新发布的 19.04 版本已经包含了支持 uring 的 bdev,但该功能只是添加了代码,并没有开放出来,当然我们可以通过修改 SPDK 代码来体验该功能。

首先新版本 SPDK 中只是包含了 io uring 的代码甚至默认都没有开放编译,我们需要做些修改:

1.安装最新的 liburing 库,同时修改 spdk 的 config 文件打开 io uring 的编译;

2.参考其他 bdev 的实现,添加针对 io uring 设备的 rpc 调用,使得我们可以像创建其他 bdev 设备那样创建出 io uring 的设备;

3.最新的 liburing 已经将 io_uring_get_completion 调用改成了 io_uring_peek_cqe,并需要配合 io_uring_cqe_seen 使用,所以我们也要调整下 SPDK 中 io uring 的代码实现,避免编译时出现找不到 io_uring_get_completion 函数的错误:

4.使用修改 open 调用,使用 O_SYNC 模式打开文件,确保我们在数据写入返回时就落地了,并且比调用 fdatasync 效率更高,我们对 aio bdev 也做了同样的修改,同时添加读写模式:

经过上述修改 spdk io uring 设备就可以成功创建出来了,我们做下性能的对比:

使用 aio bdev 的时候:

使用 io uring bdev 的时候:

可见在最高性能和延时上 io uring 都有不错的优势,IOPS 提升了约 20%,延迟降低约 10%。这个结果其实受到了底层硬件设备最大性能的限制,还未达到 io uring 的上限。

六 总结

SPDK 技术的应用使得虚拟化 IO 路径的性能提升不再存在瓶颈,也促使 UCloud 高性能云盘产品可以更好的发挥出后端存储的性能。当然一项技术的应用并没有那么顺利,我们在使用 SPDK 的过程中也遇到了许多问题,除了上述分享的还有一些 bug 修复等我们也都已经提交给了 SPDK 社区,SPDK 作为一个快速发展迭代的项目,每个版本都会给我们带来惊喜,里面也有很多有意思的功能等待我们发掘并进一步运用到云盘及其它产品性能的提升上。


↙↙↙阅读原文可查看相关链接,并与作者交流