AutoMQ 是一个建立在 S3 Stream 流存储库基础上的 Apache Kafka 云原生重塑解决方案。S3 Stream 利用云盘和对象存储,将对象存储作为主存储,将云盘作为缓冲区,实现了低延迟、高吞吐、“无限”容量和低成本的流式存储。
Delta WAL 作为 S3 Stream 的一部分,是 AutoMQ 的核心组件之一。它基于云盘,具有持久化、低延迟、高性能的特性,是 Main Storage(对象存储)上层的写入缓冲区。本文将重点介绍 Delta WAL 的实现原理。
Delta WAL 在 AutoMQ 中的主要职责是作为持久化写入缓冲区,配合 Log Cache 将写入的数据以 WAL 的形式在云盘上做高效的持久化。在云盘上持久化成功后才会返回客户端成功。而数据的读取则均会从内存中读取并返回客户端。
AutoMQ S3Stream 设计了冷热隔离的缓存 Log Cache(缓存新写入数据)和 Block Cache(缓存从对象存储中拉取的数据)。Log Cache 中的数据在 WAL 的数据没有上传到对象存储之前在内存中不会失效。如果从 Log Cache 无法读取到数据,则改为从 Block Cache 中读取数据。Block Cache 会通过预读、批量读等手段保证冷读时读取数据也尽量在内存命中,从而确保冷读时读取的性能。
Delta WAL 作为 S3Stream 中支持高性能持久化 WAL 的组件,主要用于将 Log Cache 中的数据高效地持久化到裸设备上。
Delta WAL 构建在云盘之上,绕过了文件系统,直接使用 Direct IO 对裸设备进行读写。这种设计选择有以下三个优势:
Kafka broker 在处理生产消息的请求时,会将数据写入 Page Cache,并异步地将数据写入磁盘;同样的,在处理消费请求时,如果数据不存在于 Page Cache 中,会从磁盘将数据读入 Page Cache。这种机制就会导致,当消费者追赶读(catch-up read)时,会把将从磁盘读取的数据放入 Page Cache,产生污染,影响实时的读写。而使用 Direct IO 进行读写时,绕过了 Page Cache,避免了这个问题,保证了实时读写与追赶读互不干扰。
在 AutoMQ 性能白皮书中,我们详细对比了 Kafka 与 AutoMQ 在追赶读时的性能表现。结果见下表:
从中可以看到,AutoMQ 很好地做到了读写隔离,在追赶读时,实时读写性能几乎不受影响;而 Kafka 在追赶读时,会导致发送消息延迟大幅增加,流量下跌严重。
绝大多数文件系统在读写时都会有一定的额外开销:比如文件系统的元数据操作、记录 Journal 等。这些操作会占用一部分磁盘的带宽与 IOPS,同时写入路径也会变得更长。而使用裸设备进行读写,避免了这些开销,写入延迟更低。下表对比了在文件系统与裸设备上进行写入的性能表现。可以看到,相较于文件系统,裸设备的写入延迟明显更低,性能更好。
注:基于 fio 测试,运行指令为 sudo fio -direct=1 -iodepth=4 -thread -rw=randwrite -ioengine=libaio -bs=4k -group_reporting -name=test -size=1G -numjobs=1 -filename={path}
当使用文件系统时,如果 OS Crash,在重启后,需要进行文件系统的检查与恢复,这个过程可能会非常耗时,与文件系统上的数据与元数据的大小成正比。
而使用裸设备时,不需要进行文件系统的检查与恢复,宕机后恢复更快。
Delta WAL 作为 S3 Stream 中的组件有如下设计目标:
Delta WAL 的源码可以在 s3stream 仓库中找到。接下来我们将从上至下介绍 Delta WAL 的具体实现。
Delta WAL 的接口定义在 WriteAheadLog.java。其中有如下几个主要的方法:
public interface WriteAheadLog {
AppendResult append(ByteBuf data) throws OverCapacityException;
interface AppendResult {
long recordOffset();
CompletableFuture<CallbackResult> future();
interface CallbackResult {
long flushedOffset();
}
}
CompletableFuture<Void> trim(long offset);
Iterator<RecoverResult> recover();
interface RecoverResult {
ByteBuf record();
long recordOffset();
}
}
值得说明的是,Delta WAL 中返回 offset 只是逻辑位点,而非实际在磁盘上的位置(物理位点)。这是由于前文提到的,Delta WAL 采用了轮转写入的模式,物理位点会在磁盘上循环,而逻辑位点则是单调递增的。
Delta WAL 中的主要数据结构有 WALHeader,RecordHeader 和 SlidingWindow,接下来将分别介绍它们。
WALHeader 是 Delta WAL 的头部信息,定义在 WALHeader.java。它包含了 Delta WAL 的一些元信息,包括:
RecordHeader 是 Delta WAL 中每条 record 的头部信息,定义在 SlidingWindowService.java。它包含了 Delta WAL 中每条 record 的一些元信息,包括:
SlidingWindow 是 Delta WAL 中用于写入的滑动窗口,定义在 SlidingWindowService.java。它用于分配每条 record 的写入位点,并控制 record 的写入。它由几个位点组成,如下图:
下面重点介绍一下 Delta WAL 的写入与恢复流程。
AutoMQ 在设计写入实现时充分考虑了云盘的计费项和底层实现的特性,以最大化性能和成本效益。以 AWS EBS GP3 为例,免费提供 3000 IOPS,因此 Delta WAL 的时间阈值默认为 1/3000 秒,以匹配免费 IOPS 额度,避免额外成本。此外,AutoMQ 引入了批大小阈值(默认为 256 KiB),避免发送过大的 Record 到云盘。云盘底层实现会将大于 256 KiB 的 Record 拆分成多个 256 KiB 的小数据块顺序写入持久化介质。
AutoMQ 的拆分操作确保云盘底层并行写入,提升写入性能。下图展示了 Delta WAL 具体的写入流程:
图中的 Start Offset 与 Next Offset 在上文已经介绍过,这里不再赘述。新引入的几个数据结构含义如下:
一次写入的流程如下:
在 Delta WAL 重启时,外部会调用 recover 方法,从最新的 trim 位点开始,恢复所有 record。恢复的流程如下:
值得说明的是,第 3 步中,之所以在遇到非法 record 后仍要继续尝试读取,是因为在滑动窗口中 Start Offset 与 Next Offset 之间的数据可能存在空洞,即,一部分 record 已经落盘,一部分 record 尚未落盘。在恢复时,需要尽可能地恢复已经落盘的 record,而不是直接跳过。
前面提到过,Delta WAL 底层没有依赖文件系统,而是直接使用 Direct IO 读写裸设备。在实现时,我们依赖了一个三方库 kdio,并对其进行了一点修改以适配 Java 9 中引入的 Modules 特性。它对 pread 与 pwrite 等系统调用进行了封装,提供了一些便利的方法,帮助我们直接读写裸设备。
下面介绍一下我们在使用 Direct IO 读写裸设备时积累的一些经验。
在使用 Direct IO 读写时,要求内存地址、IO 的偏移量及大小与以下几个值对齐,否则会写入失败:
为了保证 IO 的偏移量与大小对齐,我们对前文提到的 Block 进行了对齐处理,使其大小为 4 KiB 的整数倍,并将其写入磁盘时的偏移量也对齐到 4 KiB。这样做的好处是,在每次写入时,IO 偏移量都是对齐的,无需处理在某个扇区的中间写入的情况。同时由于 Block 有攒批的逻辑,Delta WAL 也仅作为缓冲区无需长期存储数据,因此对齐后产生的空洞带来的空间浪费是较小且可以接受的。
在实现的过程中,使用了以下几个方法来处理内存地址的对齐:
public static native int posix_memalign(PointerByReference memptr, NativeLong alignment, NativeLong size);
// following methods are from io.netty.util.internal.PlatformDependent
public static ByteBuffer directBuffer(long memoryAddress, int size);
public static long directBufferAddress(ByteBuffer buffer);
public static void freeDirectBuffer(ByteBuffer buffer);
a. directBuffer 用于将一个内存地址及大小封装为 ByteBuffer
b. directBufferAddress 用于获取 ByteBuffer 的内存地址,其被作为 pread 和 pwrite 的参数c. freeDirectBuffer 用于释放 ByteBuffer
将以上方法结合起来,我们就可以在 Java 中分配、使用、释放对齐的内存了。
与文件系统不同,裸设备的大小无法通过文件的元数据来获取,这就需要我们自己维护裸设备的大小。在初始化时,上层会指定 WAL 的大小,Delta WAL 会在初始化时获取裸设备的大小,并与指定的大小进行比较:如果裸设备的大小小于指定的大小,会抛出异常;如果裸设备的大小大于指定的大小,会将 WALHeader 中的 capacity 设置为指定的大小,且之后不可更改。这样做的好处是,可以保证 Delta WAL 的大小不绑定于裸设备的大小,避免裸设备大小的变化导致的问题。
在未来,我们还会支持动态变更 Delta WAL 的大小,以满足更多的场景。
为了验证 Delta WAL 的性能,我们进行了一些基准测试。测试环境如下:
测试代码详见仓库。测试时配置 IO 线程池大小为 4,目标写入吞吐为 120 MiB/s。测试结果如下:
*: 为 iostat 中的读数**: Stream WAL 中每个 record 还有 24 Bytes 的 header,这在测试时被减去了
可以看到
a. 写入吞吐接近 125 MiB/s(还有一小部分带宽用于写入写 header、4K 对齐等开销)。
b. 当 record 不过大时,可以基本跑满 3000 IOPS。
Delta WAL 作为 S3 Stream 的一部分,是 AutoMQ 的核心组件之一。它基于裸设备,避免了 Page Cache 污染,提高了写入性能,且宕机后恢复更快。在实现时,我们充分利用了云盘的 IOPS 与带宽,保证了 Delta WAL 的性能,进而保证了 AutoMQ 的低延迟、高吞吐。在未来,我们还会支持更多的特性,例如动态变更 Delta WAL 的大小,以满足更多的场景。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。