存储模块的优化主要是基于以下四点:

  • 内存读写的效率高于硬盘读写
  • 批量读写的效率高于单条读写
  • 顺序读写的效率高于随机读写
  • 数据复制次数越多,效率越低

提升写入操作的性能

数据需要先写入内存,然后才会落盘,所以写入操作的性能优化就要从内存和磁盘入手。写入性能的提高主要有缓存写、批量写、顺序写三个思路。

1. 缓存写和批量写

物理硬件的写入速度如下图:

image-20230719104418089

所以,写入优化的主要思路之一是:将数据写入到速度更快的内存中,等积攒了一批数据,再批量刷到硬盘中。

平时可以看到的一种说法,数据先写入 PageCache,再批量刷到硬盘,就是这种思路。PageCache 指操作系统的页缓存,简单理解就是内存,通过缓存读写数据可以避免直接对硬盘进行操作,从而提高性能。

把缓存数据刷回到硬盘,一般有“按照空间占用比例”、“时间周期扫描”和“手动强制刷新”三种策略。操作系统内核提供了前两种处理策略,不需要应用程序感知。

按空间占用比例刷新是指当系统内存中的“脏”数据大于某个阈值时会将数据刷新到硬盘。操作系统提供了两个配置项:

  • “脏”数据在内存中的占比(dirty_background_ratio)

  • “脏”数据的绝对的字节数(dirty_background_bytes)

按时间周期刷新是指根据配置好的时间,周期性刷新数据到硬盘。主要通过脏页存活时间(dirty_expire_seconds) 和刷新周期(dirty_writeback_centisecs)两个参数来配置。

两个配置默认都是 1/100,也就说时间间隔为每秒 100 次,根据刷新周期的配置周期性执行刷新,刷新会检查脏页的存活时间是否超过配置的最大存活时间,如果是则刷入硬盘。

同时,操作系统也提供了第三种方法程序手动强制刷新,你可以通过系统提供的 sync()/msync()/fsync() 调用来强制刷新缓存。

消息队列一般会同时提供:是否同步刷盘、刷盘的时间周期、刷盘的空间比例三个配置项,让业务根据需要调整自己的刷新策略。从性能的角度看,异步刷新肯定是性能最高的,同步刷新是可靠性最高的。

2. 随机写和顺序写

首先,随机写和顺序写都是针对硬盘的,是整个操作系统和硬盘的关系,而不是单文件和硬盘的关系。搞清楚这一点,就需要考虑单文件顺序写入硬盘多文件顺序写入硬盘,从硬盘角度看,他们都是顺序的吗?

单文件顺序写入硬盘很简单,硬盘控制器只需在连续的存储区域写入数据,对硬盘来讲,数据就是顺序写入的。

image-20230719105647355

多文件顺序写入硬盘,系统中有很多文件同时写入,这个时候从硬盘的视角看,你会发现操作系统同时对多个不同的存储区域进行操作,硬盘控制器需要同时控制多个数据的写入,所以从硬盘的角度是随机写的。

image-20230719105709022

所以,在消息队列中,实现随机写和顺序写的核心就是数据存储结构的设计

在上一篇博客中写过,数据存储结构设计有两个思路:每个 Partition/Queue 单独一个存储文件,每台节点上所有 Partition/Queue 的数据都存储在同一个文件。

第一种方案,对单个文件来说读和写都是顺序的,性能最高,但当文件很多且都有读写,在硬盘层面就会退化为随机读写,性能会下降很多。

第二种方案,因为只有一个文件,不存在文件过多的情况,写入层面一直都会是顺序的,性能一直很高。所以为了提高写的性能,可以采用第二种方案。

提升写入操作的可靠性

因为数据是先写入内存,然后刷到磁盘,那么没刷之前就有丢失的风险。

为了提高数据可靠性,在消息队列的存储模块中,一般会通过三种处理手段:同步刷盘、WAL 预写日志、多副本备份,进一步提升数据的可靠性。

1. 同步刷盘

同步刷盘指每条数据都同步刷盘,等于回到了直接写硬盘的逻辑,一般通过写入数据后调用 force() 操作来完成数据刷盘。这种办法相当于省略了内存那一步,直接写入磁盘,效率比较低。

image-20230719110348072

2. WAL

WAL(预写日志)指在写数据之前先写日志,当出现数据丢失时通过日志来恢复数据,避免数据丢失。但是WAL 日志需要写入持久存储,业务数据也要写入缓存,多了一步,性能会不会降低呢?

image-20230719110506254

从理论来看,WAL 机制肯定会比直接写入缓存中的性能低。但我们实际落地的时候往往可以通过一些手段来优化,降低影响,达到性能要求。

在消息队列中,消息的量很大,我们不可能采用性能很高的存储设备,但是日志的量比较小,而且可顺序存储。所以在实际落地中,我们可以采取 WAL 日志盘和实际数据盘分离的策略,提升 WAL 日志的写入速度

具体就是让 WAL 数据盘是高性能、低容量的数据盘,存储消息的数据盘是性能较低、容量较大的数据盘,如果出现数据异常,就通过 WAL 日志进行数据恢复。

3. 多副本的备份

多副本的备份就是将数据拷贝到多台节点,每台节点都写入到内存中,从而完成数据的可靠性存储。

image-20230719111128096

好处是可以在分布式存储的基础上做优化,通过多台缓存的手段来降低数据丢失的概率。但是如果所有节点在同一时刻重启,数据还是有可能丢失的,无法保证百分百的数据高可靠。

从消息队列业界的存储方案来看,方案一所有产品都会支持,方案二和方案三一般会选一种支持,Kakfa、RabbitMQ、RocketMQ 用的是第三种,Pulsar 用的是第二种。

提升读取操作的性能

提高读取的性能主要有读热数据、顺序读、批量读、零拷贝四个思路。

1. 冷读和热读

热读是指消息数据本身还在缓存中,读取数据是从内存中获取,此时性能最高,不需要经过硬盘。冷读是指消息数据刷到硬盘中了,并且数据已经被换页换出缓存了,此时读取数据需要从硬盘读取。

image-20230719111256980

理想情况,肯定全部是热读最好,因为性能最高。但是在代码层面,我们是无法控制冷读或热读的,只能通过配置更大的内存,尽量保证缓存中保留更多的数据,从而提高热读的概率。

2. 顺序读、随机读、批量读

为了实现大吞吐,在消费的时候服务端都会支持批量读的能力。为了能尽快返回数据给客户端,服务端都会实现数据的预读机制。在读取数据的时候,也读取客户下一步可能会用的数据,预先加载到内存中,以便更快返回数据。

数据的预读分为两种:硬盘层面预读、应用程序的预读。

硬盘层面的预读,是在连续的地址空间中读取数据。但具体实现,我们在程序中无法控制,这和数据目录存储结构设计有关。

image-20230719111635117

上图是两种数据存储结构的设计,第一种由于每个分区一个文件,读取一个分区时数据都是连续的,预读很方便,只要在硬盘上读取连续的数据块即可。而第二种设计方案,需要根据分区的索引,在具体存储文件的不同位置进行读取,预读有很大的随机成分,效率不如第一种。

3. 零拷贝原理和使用方式

image-20230719111938059

如上图所示,在正常读取数据的过程中,数据要经过五步,硬盘 -> ReadBuffer -> 应用程序 -> SocketBuffer -> 网卡设备,四次复制。因为数据在复制过程耗费资源和时间,会降低性能,所以优化流程最重要的是减少数据复制的次数和资源损耗。

零拷贝指的是数据在内核空间用户空间之间的拷贝次数,即图中的第 2 步和第 3 步。

如果只有 1 和 4 两步,没有执行 2 和 3 的话,那么内核空间和用户空间之间的拷贝次数就是零,“零拷贝”的零指的是这个次数“零”,因此是零拷贝。

主要思路是通过减少数据复制次数、减少上下文(内核态和用户态)切换次数、通过 DMA(直接内存)代替 CPU 完成数据读写,来解决复制和资源损耗的问题。

image-20230719112126032

红色的线即为零拷贝优化后的结果。

优化后,数据链路赋值变为了硬盘 -> ReadBuffer -> 网卡设备,从4次变为了2次,而且减少用户态和核心态的切换,并且使用DMA来搬运数据,释放了CPU。

零拷贝主要用于在消费的时候提升性能,具体有两种实现方式:mmap+write 和 sendfile

mmap 是一种内存映射文件的方法,把文件或者其他对象映射到进程的地址空间,修改内存文件也会同步修改,这样就减少了一次数据拷贝。所以,我们不需要把数据拷贝到用户空间,修改后再回写到内核空间。

image-20230719112520577
1
2
3
4
5
6
7
# 正常的“读取数据并发送”流程是通过 read + write 完成的
read(file, tmp_buf, len);
write(socket, tmp_buf, len);

# 而操作系统层面的 read(),系统在调用的过程中,会把内核缓冲区的数据拷贝到用户的缓冲区里,为了减少这一步开销,我们可以用 mmap() # 替换 read() 系统调用函数。
buf = mmap(file, len);
write(sockfd, buf, len);
1
2
3
// 使用 Java NIO 包的 FileChannel 的 map 方法
FileChannel fc = f.getChannel();
MappedByteBuffer buf = fc.map(FileChannel.MapMode.READ_WRITE, 0, 200)

在 Java 中也可以使用零拷贝技术,主要是在 NIO FileChannel 类中。

  • transferTo() 方法:可以将数据从 FileChannel 直接传输到另外一个 Channel。

  • transferFrom() 方法:可以将数据从 Channel 传输到 FileChannel。

通过硬件和系统优化提升性能

从硬件和系统优化提升性能的角度,主要可以通过提升硬件配置(如内存或硬盘)、配置多盘读写、配置硬盘阵列三个手段来提高集群的性能。

1. 提升硬件配置

2. 配置多盘读写

这种方案要内核支持这个机制,在部署的时候进行相关配置才能生效。

image-20230719112927310

一般实现思路是在消息队列的内核支持多目录读写的能力,将不同的文件或者不同的数据段调度存放在不同硬盘设备对应的挂载目录中。此时在数据的写入和读取的过程中,就可以同时利用到多块盘的吞吐和存储。

3. 配置 RAID 和 LVM 硬盘阵列

多目录读写的问题是多块盘之间无法共享 IO 能力和存储空间,当遇到数据倾斜时,在单机层面会出现性能和容量瓶颈。Linux 提供了 RAID 硬盘阵列和 LVM 逻辑卷管理两种方式,通过串联多块盘的读写能力和容量,提升硬盘的性能和吞吐能力。

image-20230719113032364

参考

《深入拆解消息队列 47 讲》