跳转至

消息存储读写性能的优化方法

你好,我是李玥。

我在京东开发消息队列 JoyQueue 过程中,花了非常多的时间和精力去优化消息的存储读写性能,并取得了不错的效果。

我总结了一些优化消息存储读写性能的方法,这些方法不仅仅适用于优化消息队列性能,同样也适用于采用与消息队列类似的存储结构的性能优化场景,例如数据库 BINLOG、复制状态机、大数据领域实时计算、监控和日志系统等场景。这节课我将这些优化方法分享给你。

为什么 WAL 如此重要?

我们知道,MQ 功能是为应用程序提供可靠的异步数据通信服务。在使用者看来,它是一个用于通信的数据管道。而从设计和实现者的角度来看,MQ 实际上是一个分布式的存储系统。

MQ 和 HDFS、Redis Cluster 或者是 Elastic Search 这些分布式存储系统的基本结构是一样的。数据被按照一定规则进行分片(Shard),每个分片包含多个物理副本(Replica),这些副本存储在集群多个数据节点(Data Node)上。

它们面临着同样的问题,诸如数据一致性、有状态节点的故障恢复、数据分片等,都使用相似的技术来解决这些问题。

和上面这些分布式存储系统相比,MQ 的区别在于,它的数据结构非常简单,就是一个由消息构成的队列。

数据的查询和更新方式也很简单:根据一个索引序号查询消息,只在尾部追加写入。MQ 的这个队列数据模型几乎就是一个天然的 WAL(Write Ahead Log),所以 MQ 可以在保证数据持久化的情况下,达到非常高的吞吐能力。

WAL 是一种最简单的数据模型,简单到无从考证到底是谁发明了它,或者说根本不需要发明,很多系统都已经在自觉或不自觉地使用 WAL。

WAL 就是一串有序的日志(Log),需要具备下面这些特征:

  • 尾部追加:日志只能在尾部追加写入。
  • 不可修改:日志写入后不允许再修改。
  • 线性有序:日志是串行写入,这样客观上保证了 WAL 的有序性。

这里面的所说的“日志”,概念上比我们通常理解的日志更加广泛,可以是一条 MQ 的消息、一条应用程序日志、一条 Git Commit Log 或者任意一段数据。

我们知道对于存储数据的硬盘来说,顺序读写性能要远远好于随机读写。目前常用的 SSD,顺序读写比随机读写要快 20 倍左右;在老式的 HDD 上,这个差距更是达到 100 倍以上!

WAL 线性有序的特性,使得对 WAL 的读写访问在绝大多数情况下,都是顺序读写。因此在磁盘上读写 WAL 非常快。相比常用的哈希表、各种树和其他大多数数据结构,WAL 的性能要高出一到两个数量级。

正是因为 WAL 具备高性能和有序性这两个重要的特性,WAL 的用途非常广泛。几乎所有的 MQ,都使用 WAL 作为存储引擎的数据结构。

除此之外,在分布式系统中,Paxos、Raft 等几乎所有的分布式一致性算法(Consensus algorithm),都依赖复制状态机(State machine replication)来解决一致性问题,而 WAL 是复制状态机的重要组成部分。

在数据库领域,无论是传统的 RDBMS 还是 NoSQL、NewSQL 这些新兴数据库都离不开 WAL。记录并持久化更新操作日志,用于故障恢复、数据备份和主从复制,几乎是所有数据库的标配功能,这个日志其实就是 WAL。

WAL 还是实现事务的关键数据结构。无论是数据库本地事务,还是各种分布式事务的实现,大多都离不开事务日志的支持。

大数据的流计算领域,其中处理的数据流,本质上也是 WAL。

要了解 WAL 更多的用途,以及对 WAL 更深入地理解,推荐你阅读这篇博文:这里,你也可以从 这里 找到它的中文译文。

研究如何高性能地读写 WAL,它的价值就不止于优化分布式消息队列。其中的一些优化经验和方法,在上面这些同样使用 WAL 的技术领域中大多也是可以适用的。

我在优化消息队列存储读写性能过程中,走过一些弯路,也总结了一些经验,在这里我尽量把这些从消息队列中抽象出来,总结成优化 WAL 读写性能的通用的方法,在这里分享。

不要迷信 mmap

WAL 是一个抽象的数据结构,和其他的数据结构一样,最终要通过操作系统的文件系统,以文件的方式保存在磁盘上。

一般来说 WAL 会按照顺序把数据写入到文件中,一个文件超过一定长度,再开启一个文件继续写。这样写入 WAL 的时候,对于磁盘来说就是典型的顺序写入,会有非常好的写入性能。

mmap 是 Linux 的内核提供的一个用于读写文件的系统调用(System Call)。它的作用是,将一个文件(或者文件中的一部分)映射到进程的内存地址空间上。映射关系建立之后,进程就可以通过读写这段映射的内存地址空间来读写文件,操作系统内核负责维护映射关系以及内存和磁盘文件的双向同步。

mmap 和传统的 IO 方式相比,读写过程中减少了一次内存数据拷贝,通常被认为具有更好的读写性能。很多 IO 密集型的系统,也都会使用 mmap 来读写文件。

JoyQueue 的存储引擎最开始也是使用 mmap 来实现的,然而性能测试显示,实际的性能表现和理论预期有不小的差距。

我又做了一个 1GB 文件顺序读写的测试,对比了传统 IO(read 和 write 系统调用)和 mmap 两种实现方式,在我的 MacBook Pro 上,传统 IO 比 mmap 快了近 30%。为了排除个体差异,我又在生产服务器上做了同样的测试,测试结果也是差不多的。

是什么原因导致了这样的结果呢?

查阅相关论文后,我发现在 Efficient Memory Mapped File I/O for In-Memory File Systems 这篇论文中,作者做了类似的测试,测试结果仍然是传统读写方式更快。

下图是论文作者放出的测试数据:

论文中给出了详细的原因分析,简单的说就是,mmap 相比于传统读写,会有一些额外的开销,包括:缺页中断、TLB miss 和 PTE 创建这些软件开销。

绝大多数情况下,这些开销相对于磁盘 IO 的开销来说,几乎可以忽略不计。但是在顺序读写和高性能存储这种场景下,磁盘 IO 的开销要小很多,这个时候 mmap 的额外开销就被相对放大了。

从下面这张图中可以看到在不同硬件环境下,mmap 软件开销的占比情况:

目前国内大多数数据中心,网络和服务器配置接近上图中的第二种情况:NVMe SSD & 10Gb NIC,mmap 额外的的开销占到了约 50%。

mmap 软件开销的详细分析,你可以参考论文,不再赘述。论文还给出了一种 Async Map-ahead 的方法来优化顺序读,但因为并不适用于 JoyQueue 的使用场景,我们并没有采用。

由此我们得到一个重要的经验:在高性能存储上顺序读写 WAL,传统 IO(read/write)的性能要优于 mmap。

要达到最佳的读写性能,除了选择合适的系统调用以外,还有一个重要的因素就是:每次读写的数据大小。

频繁地在磁盘上读写小数据,性能很差。所以,很多编程语言内置的 IO 库都会自带一个 Buffer。使用 Buffer 虽然会带来一次额外的内存拷贝,但可以减少磁盘读写次数,提升读写性能。

JoyQueue 没有设计专门的 Buffer,而是将 Buffer 与缓存合二为一,我们来看一下是如何实现的。

内存管理

JoyQueue 采用 Java 语言开发,为了避免在高吞吐量情况下出现 Full GC,JoyQueue 使用了堆外内存来缓存消息。接下来我分享一下 JoyQueue 对于堆外内存管理的一些实用策略。

使用堆外内存,相当于绕过了 Java 的自动内存管理机制,所以这些策略对于使用 C++ 等这些非自动内存管理语言编写的系统,同样是适用的。

考虑到 WAL 在写入磁盘过程中,数据一定要经过堆外内存,所以用堆外内存实现一个读写缓存是自然的选择。JoyQueue 在堆外为每个 WAL 文件,申请一块儿和文件大小相同的堆外内存,作为文件的读写缓存,我们称为缓存页。

写入的过程如下图所示:

数据首先从 Socket Buffer 拷贝到堆内存,存放在 BrokerMessage 的实例,也就是一个 Java 对象中。所有的协议解析、数据转换等逻辑都在堆内存中完成。

处理完的消息从堆内存拷贝到堆外内存,追加写入到 WAL 最后一个文件对应的缓存页中,到这里 WAL 的写入已经完成。
缓存中的脏数据由 Flush 线程异步写入 PageCache,再由操作系统择机异步同步到磁盘的文件中。

堆外内存作为读写缓存,有效解决了高吞吐量下的 Full GC 问题。虽然数据读写时仍然要经过堆内存,但是有了堆外缓存,使得数据在堆内存停留的时间非常短,一般每个 BrokerMessage 实例的生命周期在几个微妙(us)级别。

数据在堆内存中停留的时间越短,两次 Young GC 之间,单位堆内存空间能支撑吞吐量越高。

用个形象的比喻就是,对于一个桌数固定的饭店来说,每桌客人用餐的时间越短,翻台率也就越高,饭店单位时间内能接待的客人就越多。

借助于这个缓存并结合 WAL 的特性,JoyQueue 实现了近乎无锁的纯内存读写,将读写的时间开销降至最低,这也是高性能读写的关键因素之一。

WAL 要求尾部追加线性写入,因此 JoyQueue 为每个 WAL 绑定一个写线程,单线程写入。

单线程写入无需考虑并发场景下的数据一致性问题,因此不需要加锁,但是对写入时延非常敏感。因为我们给每个文件设置了一个写缓存,每次写入不需要 IO 等待,所需的时间只是两次内存拷贝的时间,所以即使是单线程写入,仍然可以做到非常高的 QPS。

我们实测,单个 WAL 单线程 32 字节的小数据写入,QPS 可以达到每秒钟 170 万次左右。当然,虽然采用了单线程写入,JoyQueue 对外提供的仍然是并发写接口。并发写请求按照自然顺序被写入到一个内存队列中,然后再由写入线程消费这个内存队列单线程写入。

一个文件写满之后,这个文件被关闭不再可写,对应的缓存页并不会被立即逐出内存,而是转变为只读缓存继续提供读服务。因为 WAL 的不可变性,对缓存页的并发读操作,即使不加锁也是安全的。

这样,基于这个简单的缓存页,实现了近乎无锁的并发内存读写。

为什么说是“近乎”无锁呢?实际上为了保证安全性,在每个 WAL 上还是使用了一把读写锁。因为物理内存的限制,文件对应的缓存页有可能会被换出内存,所以无法保证在读写时,对应文件的缓存页一定存在,访问缓存页(包括读写)与换出缓存页这两组操作,需要使用一把读写锁保证安全性。在读写缓存页时加读锁,换入换出缓存页时加写锁。

我们对缓存页采用简单加权的 LRU 置换策略,在普通的 LRU 基础上,为 WAL 最后一个正在写入的页适当增加了权重。鉴于大部分 WAL 具有热尾效应,即绝大部分的数据读取都发生在刚刚写入的 WAL 的尾部,这种加权 LRU 的缓存命中率非常高。

JoyQueue 线上使用的服务器大多配置了 128GB 内存,我们一般配置 60G 左右的堆外内存用于存放缓存页,这种情况下支撑各种业务进行生产消费,平均的缓存命中率约为 99.6% 左右。

在这种缓存命中率下,锁竞争的概率近乎于零,锁带来的性能损耗可以忽略不计,所以我们称为“近乎无锁”的并发读写。

小结

WAL 是一种非常简单却用途广泛的数据结构,它具有尾部追加、不可修改和线性有序的特性。WAL 的用途非常广泛:可以用于解决分布式系统一致性问题,在数据库中被用于故障恢复、数据备份和主从复制,还在事务、MQ 和流计算等领域起着不可替代的作用。

通常情况下,mmap 的 IO 性能要优于传统 IO,但是在高性能存储(SSD)上顺序读写 WAL,传统 IO 的性能要比 mmap 快 30% 左右。所以在读写入 WAL 时,使用带 Buffer 的传统 IO 方式是性能最佳的选择。

在内存管理方面,JoyQueue 在堆外内存中为 WAL 的每个文件定义了一个对应的缓存页。使用堆外缓存页有效地降低了数据在堆内存的停留时间,极大程度上避免高吞吐情况下 JVM 的 Full GC 问题。

文件与缓存页一一对应的结构,使得 WAL 存储系统结构更简单,基于这个简单的存储结构,JoyQueue 实现了近乎无锁的并发读写。

思考题

请列举一下,你了解的哪些开源软件使用了 WAL?你开发过的系统中,哪些场景使用了 WAL?

欢迎你在评论区留言,文末我会给出参考答案。

思考题答案

以下是使用了 WAL 机制的主流开源软件:

  • PostgreSQL:使用 WAL 确保数据库事务的持久性和一致性。
  • SQLite:称为“journal”的 WAL 机制。
  • MySQL/InnoDB:称为“redo log”的 WAL 实现。
  • MongoDB:通过 oplog 实现 WAL 机制。
  • Redis:AOF(Append Only File)本质上是一种 WAL 实现。
  • etcd:使用 WAL 确保操作的持久性。
  • RocksDB:Facebook 开发的 LSM 树数据库,LSM 使用 WAL 实现崩溃恢复能力,其本身存储结构也是一个分层存储的 WAL。
  • Elasticsearch:使用 translog 作为 WAL 机制。
  • InfluxDB:使用 WAL 缓存写入操作。
  • Prometheus:使用 WAL 记录最新的数据点。

上节课思考题答案

关于 上节课 我提出的问题,参考答案如下:

RocketMQ 的高可用架构在控制面和数据面都有着完整的设计。

在控制面上,RocketMQ 采用 Name Server 集群来提供服务发现和路由功能,每个 Name Server 都节点都保存了集群完整的元数据,都可以独立提供控制面完整服务,这样集群中只要还有一个 Name Server 节点存活就可以提供控制面服务。

在数据面上,RocketMQ 采用主从架构来保证消息的可靠性和服务的可用性。每个 Broker 分为 Master 和 Slave 两个角色,

Master 负责读写请求,Slave 负责数据备份。当 Master 发生故障时,Slave 可以继续提供消息消费服务,保证了服务的连续性。

在数据可靠性方面,RocketMQ 支持同步复制和异步复制两种模式。同步复制模式下,只有消息同时写入 Master 和 Slave 后才返回成功,保证了数据零丢失(RPO=0),但会带来一定的性能开销。异步复制模式则在性能和可靠性之间取得平衡,虽然可能在发生故障时丢失少量数据,但提供了更好的性能表现。

精选留言(1)
  • 码哥字节 👍(0) 💬(0)

    Redis:AOF(Append Only File) 先写内存,再写日志

    2025-02-07