06 存储:如何提升存储模块的性能和可靠性?
你好,我是文强。
上一节我们讲了消息队列存储模块的功能实现,今天我们来讲存储模块的性能优化。
存储模块的性能优化,核心要解决的其实就是两个问题:“写得快”和“读得快”。这两个问题如何解决呢?我们从四点和存储性能优化有关的基础理论讲起。
- 内存读写的效率高于硬盘读写
- 批量读写的效率高于单条读写
- 顺序读写的效率高于随机读写
- 数据复制次数越多,效率越低
提升写入操作的性能
上一节我们讲到,消息队列的数据最终是存储在文件中的,数据写入需要经过内存,最终才到硬盘,所以写入优化就得围绕 内存和硬盘 展开。写入性能的提高主要有缓存写、批量写、顺序写三个思路,这里对比来讲。
1. 缓存写和批量写
在计算机理论基础中,计算机多级存储模型的层级越高,代表速度越快(同时容量也越小,价格也越贵),也就是说写入速度从快到慢分别是:寄存器 > 缓存 > 主存 > 本地存储 > 远程存储。
所以基于理论1和2:
- 内存读写的效率高于硬盘读写
- 批量读写的效率高于单条读写
写入优化的主要思路之一是: 将数据写入到速度更快的内存中,等积攒了一批数据,再批量刷到硬盘中。
平时我们在一些技术文章看到的“数据先写入PageCache,再批量刷到硬盘”,说的就是这个思路。PageCache指操作系统的页缓存,简单理解就是内存,通过缓存读写数据可以避免直接对硬盘进行操作,从而提高性能。
具体来说就是把硬盘中的数据缓存到内存中,这样对硬盘的访问就变成了对内存的访问。然后再通过一定的策略,把缓存中的数据刷回到硬盘。一般情况下,内存数据是自动批量刷到硬盘的,这个逻辑对应用是透明的。
把缓存数据刷回到硬盘,一般有“按照空间占用比例”、“时间周期扫描”和“手动强制刷新”三种策略。操作系统内核提供了前两种处理策略,不需要应用程序感知。我们具体了解一下。
按空间占用比例刷新 是指当系统内存中的“脏”数据大于某个阈值时会将数据刷新到硬盘。操作系统提供了两个配置项。
- “脏”数据在内存中的占比(dirty_background_ratio)
- “脏”数据的绝对的字节数(dirty_background_bytes)
当这两个配置超过阈值,就会触发刷新操作。如果两者同时设置,则以绝对字节数为更高优先级。
按时间周期刷新 是指根据配置好的时间,周期性刷新数据到硬盘。主要通过脏页存活时间(dirty_expire_seconds) 和刷新周期(dirty_writeback_centisecs)两个参数来配置。两个配置默认都是1/100,也就说时间间隔为每秒100次,根据刷新周期的配置周期性执行刷新,刷新会检查脏页的存活时间是否超过配置的最大存活时间,如果是则刷入硬盘。
同时,操作系统也提供了第三种方法 程序手动强制刷新,你可以通过系统提供的sync()/msync()/fsync() 调用来强制刷新缓存。通过操作系统的参数配置,在Java 代码中,通过Java.NIO包中FileChannel提供的write()和 force()方法,实现写缓存和强制刷新缓存。代码参考:
通过 FileChannel 提供的write()方法写数据时,FileChannel 把数据写入到缓存就会返回成功,然后依赖操作系统的缓存更新策略,将数据刷新到硬盘。我们也可以在代码中调用FileChannel 提供的 force() 方法,把数据立即刷入刷盘中以免丢失。
基本所有的消息队列在写入时用的都是这个方案,比如 Kafka、RocketMQ、Pulsar 就是先写入缓存,然后依赖操作系统的策略刷新数据到硬盘。
消息队列一般会同时提供:是否同步刷盘、刷盘的时间周期、刷盘的空间比例三个配置项,让业务根据需要调整自己的刷新策略。从性能的角度看,异步刷新肯定是性能最高的,同步刷新是可靠性最高的。
2. 随机写和顺序写
写入操作的性能优化,我们还有最后一条基础理论没用上:顺序读写的效率高于随机读写。这里有两个问题很细节。
首先,你要理解顺序写和随机写是针对谁的?我们所说的都是针对硬盘的,是整个操作系统和硬盘的关系,而不是单文件和硬盘的关系。
明白了这一点,我们看下一个问题,“单文件顺序写入硬盘”和“多文件顺序写入硬盘”,从硬盘的角度看都是顺序读写吗?
单文件顺序写入硬盘很简单,硬盘控制器只需在连续的存储区域写入数据,对硬盘来讲,数据就是顺序写入的。
多文件顺序写入硬盘,系统中有很多文件同时写入,这个时候从硬盘的视角看,你会发现操作系统同时对多个不同的存储区域进行操作,硬盘控制器需要同时控制多个数据的写入,所以从硬盘的角度是随机写的。
这里的硬盘控制器只是一个抽象,它包含CPU调度、硬盘驱动、DMA设备、写入调度算法等具体底层实现。
所以,在消息队列中,实现随机写和顺序写的核心就是 数据存储结构的设计。
上节课我们讲过数据存储结构设计有两个思路:每个Partition/Queue单独一个存储文件,每台节点上所有Partition/Queue的数据都存储在同一个文件。
第一种方案,对单个文件来说读和写都是顺序的,性能最高,但当文件很多且都有读写,在硬盘层面就会退化为随机读写,性能会下降很多。第二种方案,因为只有一个文件,不存在文件过多的情况,写入层面一直都会是顺序的,性能一直很高。所以为了提高写的性能,我们最好使用第二种方案。
提升写入操作的可靠性
因为消息队列基本都采用数据先写入缓存、再写入硬盘的方案,所以有丢失数据的风险,比如数据还没刷新到硬盘中时,机器就异常重启了,数据就丢失了。
为了提高数据可靠性,在消息队列的存储模块中,一般会通过三种处理手段:同步刷盘、WAL预写日志、多副本备份,进一步提升数据的可靠性。
1. 同步刷盘
同步刷盘指每条数据都同步刷盘,等于回到了直接写硬盘的逻辑,一般通过写入数据后调用force()操作来完成数据刷盘。这种方案无法利用内存写入速度的优势,效率会降低很多。
一般消息队列都会开放这个配置项,默认批量刷盘,但有丢失数据的风险。如果业务需要修改为直接刷盘的策略来提高数据的可靠性,则会有一定的性能降低。至于如何提高同步刷盘的性能,我们在第18讲中还会展开细讲,敬请期待。
2. WAL
WAL(预写日志)指在写数据之前先写日志,当出现数据丢失时通过日志来恢复数据,避免数据丢失。
讲到这里,估计有同学有疑问了,WAL日志需要写入持久存储,业务数据也要写入缓存,多了一步,性能会不会降低呢?
没错,从理论来看,WAL机制肯定会比直接写入缓存中的性能低。但我们实际落地的时候往往可以通过一些手段来优化,降低影响,达到性能要求。
因为在消息队列中,消息数据的数据量是非常大的,我们不可能直接使用非常高性能的持久存储设备,成本太高。虽然WAL日志需要极高的写入性能,但是数据量一般很小,而且是可顺序存储的、可预测的(根据配置的缓存大小和更新策略可明确计算)。
所以 在实际落地中,我们可以采取WAL日志盘和实际数据盘分离的策略,提升WAL日志的写入速度。具体就是让WAL数据盘是高性能、低容量的数据盘,数据盘是性能较低、容量较大的数据盘,如果出现数据异常,就通过WAL日志进行数据恢复。这样,给WAL日志选择合适的设备,再加上并行读写等代码优化手段,性能损失就可控了,甚至可以忽略。
不过,这个方案也有缺点,在实际部署运维过程中,我们需要单独给WAL日志分配高性能的数据盘并进行相关处理配置,运维成本相对较高。
但强制刷盘、WAL预写日志这两种方案,都是指单机维度的可靠性保证。而我们在实际运维过程中,单机是不可靠的,都需要通过分布式的多副本存储来保证数据的高可靠,也就有了第三种方案。
3. 多副本的备份
多副本的备份就是将数据拷贝到多台节点,每台节点都写入到内存中,从而完成数据的可靠性存储。因为单机层面也是把数据写入到内存中就记录写入成功,单机层面也可能出现数据丢失,所以核心思路是同时在多台节点中缓存数据,只要不是多台节点同时重启,数据就可以恢复。
好处是可以在分布式存储的基础上做优化,通过多台缓存的手段来降低数据丢失的概率。但是如果所有节点在同一时刻重启,数据还是有可能丢失的,无法保证百分百的数据高可靠。
从消息队列业界的存储方案来看,方案一所有产品都会支持,方案二和方案三一般会选一种支持,Kakfa、RabbitMQ、RocketMQ用的是第三种,Pulsar用的是第二种。
接下来我们看如何提升读取操作的性能。
提升读取操作的性能
提高读取的性能主要有读热数据、顺序读、批量读、零拷贝四个思路。
1. 冷读和热读
经过写优化后,数据先经过内存缓存,再批量刷到硬盘上,而且根据消息队列的特性,数据都是单分区顺序读写的。所以我们在读取数据的时候,有冷读和热读两种场景。
热读是指消息数据本身还在缓存中,读取数据是从内存中获取,此时性能最高,不需要经过硬盘。冷读是指消息数据刷到硬盘中了,并且数据已经被换页换出缓存了,此时读取数据需要从硬盘读取。
Java 中使用代码实现读缓存很简单,只需要使用Java.NIO包中的FileChannel.read数据。
理想情况,肯定全部是热读最好,因为性能最高。但是在代码层面,我们是无法控制冷读或热读的,只能通过配置更大的内存,尽量保证缓存中保留更多的数据,从而提高热读的概率。
2. 顺序读、随机读、批量读
为了实现大吞吐,在消费的时候服务端都会支持批量读的能力。为了能尽快返回数据给客户端,服务端都会实现数据的预读机制。在读取数据的时候,也读取客户下一步可能会用的数据,预先加载到内存中,以便更快返回数据。
数据的预读分为两种:硬盘层面预读、应用程序的预读。
硬盘层面的预读,是在连续的地址空间中读取数据。 但具体实现,我们在程序中无法控制,这和数据目录存储结构设计有关。
之前讲了两种数据存储目录结构设计。方案一在读取的过程中,因为数据是连续存储的,数据预读非常方便,只要在硬盘上读取连续的数据块即可,不需要在程序上做逻辑处理,性能最高。
方案二需要根据分区上的数据索引,在具体存储文件的不同位置读取数据。数据可能是连续的,也可能是不连续的。这种情况下硬盘的预读就很有随机性,大部分情况下在硬盘看来就是随机读。性能比第一种方案低。
应用程序的预读就比较简单,一般通过程序中的逻辑关系,提前通过调度去硬盘读取数据(可能是连续的也可能是不连续的)。因为消息队列的数据是分区有序的,当读取到某条数据时,手动读取后面的一个批次的数据就可以了。这种方案需要程序去控制,比如read(0)时,要同时读read(1,10)的数据,相对繁琐,并且性能较低。
对比来看,理想情况下,肯定是硬盘层面的顺序预读的性能最高,所以针对读取操作,方案一更合适。
3. 零拷贝原理和使用方式
零拷贝这个词你肯定很熟悉,但具体是什么含义可能很难说上来。我们用一张流程图来理解,看从硬盘读取数据并发送给网卡的过程。
如上图所示,在正常读取数据的过程中,数据要经过五步,硬盘 -> ReadBuffer -> 应用程序 -> SocketBuffer -> 网卡设备,四次复制。因为数据在复制过程耗费资源和时间,会降低性能,所以优化流程最重要的是减少数据复制的次数和资源损耗。
值得注意的是,零拷贝指的是数据在内核空间和用户空间之间的拷贝次数,即图中的第2步和第3步。如果只有1和4两步,没有执行2和3的话,那么内核空间和用户空间之间的拷贝次数就是零,“零拷贝”的零指的是这个次数“零”,因此是零拷贝。
为了解决复制次数带来的性能损耗,“零拷贝”这个概念就被提出来了。 主要思路是通过减少数据复制次数、减少上下文(内核态和用户态)切换次数、通过DMA(直接内存)代替 CPU 完成数据读写,来解决复制和资源损耗的问题。
图中红色的线,就是通过零拷贝相关技术优化后的效果。
- 将数据复制链路缩短成了:硬盘 -> ReadBuffer -> 网卡设备,复制次数从四次减为两次。
- 用户空间和内核空间之间的数据复制需要进行上下文切换,优化完复制链路后,数据只在内核空间复制传输,就可以减少两次上下文切换。
- 通过DMA 来搬运数据,使数据复制不需要通过 CPU,释放CPU。
DMA 全称是直接内存存取。简单理解就是在IO设备和内存之间传递数据时,数据搬运工作全部交给 DMA 控制器,CPU不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务,从而释放CPU。
零拷贝主要用于在消费的时候提升性能,具体有两种实现方式: mmap+write 和 sendfile。
mmap是一种内存映射文件的方法,把文件或者其他对象映射到进程的地址空间,修改内存文件也会同步修改,这样就减少了一次数据拷贝。所以,我们不需要把数据拷贝到用户空间,修改后再回写到内核空间。
正常的“读取数据并发送”流程是通过read + write完成的,比如:
而操作系统层面的 read(),系统在调用的过程中,会把内核缓冲区的数据拷贝到用户的缓冲区里,为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。比如:
在Java代码中,实现mmap的方法很简单,使用Java NIO包的FileChannel的map方法即可。
FileChannel fc = f.getChannel();
MappedByteBuffer buf = fc.map(FileChannel.MapMode.READ_WRITE, 0, 200)
另外一种实现思路 sendfile,是指Linux内核提供的一个系统调用 sendfile() ,它可以将数据从一个文件描述符传输到另一个文件描述符。之前图中的红色线路就是通过senfile系统调用和DMA技术,将四次的数据复制次数变为了两次,提高了性能。
在 Java 中也可以使用零拷贝技术,主要是在 NIO FileChannel 类中。
- transferTo() 方法:可以将数据从 FileChannel 直接传输到另外一个 Channel。
- transferFrom() 方法:可以将数据从 Channel 传输到 FileChannel。
几乎所有的消息队列在消费时都使用了sendfile的调用,因为它配合DMA技术至少可以提升一倍的消费速度。
通过硬件和系统优化提升性能
讲到这里,我们谈的都是软件层面的优化。但在实际的运营部署中,性能的提升也得靠硬件的支持。接下来我们看如何提升硬件和系统的性能。
从硬件和系统优化提升性能的角度,主要可以通过提升硬件配置(如内存或硬盘)、配置多盘读写、配置硬盘阵列三个手段来提高集群的性能。
1. 提升硬件配置
为了提高热度的概率,直接配备更大的机器内存,性能提升最明显。
另外,消息队列是一款非常重视IO的组件,使用更快的硬盘IO设备,提高单机的吞吐能力,也能快速提升性能。硬盘类型很多,比如物理机部署下的机械盘、SSD、NVMe SSD,以及在云环境部署的各种规格的云盘。核心衡量指标主要有三个:IOPS、吞吐量、延时,这些指标越好,性能越高。
2. 配置多盘读写
系统层面,我们可以通过在机器上挂多块硬盘提升单机的硬盘吞吐能力。这种方案要内核支持这个机制,在部署的时候进行相关配置才能生效。
一般实现思路是在消息队列的内核支持多目录读写的能力,将不同的文件或者不同的数据段调度存放在不同硬盘设备对应的挂载目录中。此时在数据的写入和读取的过程中,就可以同时利用到多块盘的吞吐和存储。
3. 配置 RAID 和 LVM 硬盘阵列
多目录读写的问题是多块盘之间无法共享IO能力和存储空间,当遇到数据倾斜时,在单机层面会出现性能和容量瓶颈。Linux提供了RAID硬盘阵列和LVM 逻辑卷管理两种方式,通过串联多块盘的读写能力和容量,提升硬盘的性能和吞吐能力(具体的硬盘阵列构建你可以参考 这篇文章)。
总结
写入性能优化的核心是缓存写、批量写、顺序写。
内存的写性能比任何硬盘都高,通过先写内存、后批量刷新数据到硬盘,可以降低硬盘的写入次数,从而提升写入性能。在写缓存的过程中,要注意数据的可靠性,我们可以通过同步刷盘、副本同步、WAL机制等手段提高性能和可靠性。写入的时候,顺序写的性能比随机写的性能高,顺序写的核心是数据存储目录结构的设计。
读操作的性能提升,主要依赖热读、顺序读、批量读、零拷贝四种手段。
热读主要依赖可用内存的大小。批量读指一次IO操作尽可能读取更多的数据,避免多次的数据IO操作。数据预读分为硬盘预读和应用程序预读两类,主要思路都是提前读取数据缓存到内存中,提高热读的命中率。和写入一样,顺序读的核心也是数据存储目录结构的设计。
零拷贝主要用来提升消费的性能,它只是一个概念,不是一门具体的技术。核心思路是 减少复制和上下文切换的次数,通过DMA技术释放 CPU 的工作量,从而提升消费性能。底层的实现主要涉及mmap内存映射、sendfile系统调用、DMA直接内存读取三种技术手段。Java代码的实现,mmap和sendfile的操作都在NIO FileChannel类中,对应map、transferTo、transferFrom三个方法的使用。DMA技术在代码中无法控制,依赖于操作系统的实现。
另外,在硬件和操作系统层面,我们还可以通过提升硬件的规格和类型、配置多盘读写、配置RAID和LVM硬盘阵列三种手段来提高性能。
思考题
数据的批量写入,如果不用PageCache的缓存刷新机制,我们可以在应用程序中管理数据完成批量写入吗?如果可以怎么实现?优缺点是什么?
欢迎留言参与讨论,如果觉得今天的内容对你有帮助,也欢迎分享给身边的朋友一起学习。
上节课思考闭环
如果让你从头实现一个消息队列的存储模块,你的思考路径是什么?
1. 首先需要想有哪些数据需要存储?最主要的是不是消息数据?
2. 消息数据怎么存呢?按照Topic维度存还是分区维度存呢?
3. 如果是分区,那分区数据怎么存呢?每个分区一个文件,还是所有分区存到一个文件。得去看看业界都是怎么做的,这两个方案的优劣是什么。
4. 数据文件会很大,可能几个TB、几十个TB,这些数据是不是需要分段呢?是的话要怎么分段?
5. 如果数据分段了,要根据什么分段呢?分段后应该怎么定位到消息内容呢?
6. 消息队列的数据都需要清理,业界一般都有哪些清理机制,各自的优劣都是啥?这些清理都是怎么实现的呢?