本文主要介绍RocketMq在性能方面做的相关优化
经常会提到RocketMq,其性能多么多么好,那么各位有没想过,其性能为什么好?
先从RocketMq架构层面入手,先上一张架构图
RocketMQ 架构上由 Broker、NameServer、Producer 和 Consumer 组成,各个组件功能如下
先从生产者出发,生产者首先得把消息发送到Mq服务端(也就是Broker端),自然得先知道这条消息得往哪个Broker上发(毕竟消息要做持久化,提供堆积能力,不然要Broker干嘛),因此Producer需要从NameServer中获取Topic对应的路由元数据(元数据即指的是Topic对应的队列信息、Broker信息),那么这个Topic的路由信息是不是可以缓存呢?
对应到代码里的实现如下org.apache.rocketmq.client.impl.producer.DefaultMQProducerImpl#tryToFindTopicPublishInfo
TopicPublishInfo即为Topic对应的路由元数据信息
已经拿到了Topic的路由元数据了,就要开始往指定的Broker上发送消息了,那么发送方式上又有什么门道呢?是阻塞等待结果返回?
RocketMq中3种消息发送方式如下
如果消息很重要不能丢失,并且对响应时间要求较高,可以选择异步发送,吞吐量高于同步发送
如果消息不重要,允许丢失,则可以选择单向OneWay发送,吞吐量最高
不管选用何种消息发送方式,都涉及到消息在从Producer端传输到Broker端,Broker端再做持久化,假设某个Broker所处的机房出现了短暂的网络抖动或者某个Broker当前负载过高都有可能导致发往这个Broker的消息请求RT较高,RT较高时如果发送端仍源源不断往这个Broker发送消息,是不是耗时就增大了?此时把消息发往其他Broker是不是更好?
Mq在发送端引入了Broker故障转移机制,能够在某个Broker异常时,根据当次请求RT时间,预估出Broker的故障持续时间,在这段持续时间内暂时屏蔽该Broker,将消息发往其他Broker,是一种故障转移的实现思路
具体故障转移机制的实现,请参考之前写的文章RocketMQ-消息发送源码分析(三)消息重试及Broker故障延迟机制
Producer端采用故障转移机制,能够避免消息发送到故障的Broker中,防止消息RT增加,避免无用的重试
前面架构介绍里,已经提到过,NameServer是一个注册中心的角色,很多人可能不明白,为什么注册中心不使用Zookeeper这种现成的中间件,而要自己写一套注册中心呢?
RocketMq设计NameServer的亮点在于其每个NameServer节点之间没有像Zookeeper那样追求强一致性,啥意思?下面举个例子
如果采用Zookeeper这种强一致性的注册中心集群方案,当新增一个Broker时,Zk中Master节点中新增该Broker的注册路由信息,同时还要将该Broker信息同步给Zk集群中其他Follower节点,追求强一致性会对性能带来一定损耗
那么对应到NameServer中是怎么做的?
Broker消息服务器在启动时向所有 NameServer注册,写入自己的注册信息 。NameServer 与每台 Broker 服务器保持长连接,并间隔 30s 检 测 Broker是否存活,如果检测到 Broker宕机 , 则从路由注册表中将其移除,所以本质上同一时刻,NameServer服务器之间数据并不会完全相同。同时Broker宕机时,NameServer上仍然可能保留有该Broker注册信息,需等到30秒心跳检测时再做移除。Nameserver为什么要这么做呢?这是为了降低 NameServer实现的复杂性,在消息发送端提供容错机制来保证消息发送的高可用性(如果发送端正好从NameServer中获取到了一个宕机的Broker路由信息)
假设消息发送到了Broker端,Broker端主要负责消息的持久化,那么持久化上面又做了哪些性能优化呢?
RocketMq将所有的消息写入转为顺序写,所有topic的消息顺序写入一个commitLog存储文件。也就是说,仅仅将消息数据追加到文件的末尾,不是在文件的随机位置来修改数据。
上一张网上对于磁盘随机写、顺序写的性能对比图,可以看出顺序写有较大的性能提升(寻道之后磁头顺序读取信息的速度是很快的)
消息是并发到达Broker端的,那么如何保证所有消息顺序写入CommitLog文件呢,一个很自然的做法就是加锁,将消息写入过程通过锁变成串行执行,那么RocketMq中是如何加锁的呢?Synchronized?ReentrantLock?
mq中默认使用了自旋锁(循环CAS)来实现加锁功能,避免了高并发写入时候的上锁解锁效率,并减少线程上下文切换次数,为什么这么说呢?ReentrantLock本质上是基于AQS实现的等待、唤醒机制的锁,高并发下会发生频繁的上下文切换
获取锁了之后,自然是要将消息数据顺序的持久化到磁盘中,那么是如图所示直接写磁盘?
其实在Linux实现层面,并非是直接将数据写入磁盘的实现,而是写入Linux操作系统中的os page cache里,也就是仅仅写入内存中,并标记该page cache’为脏页,接下来由操作系统自己决定什么时候把os cache里的数据真的刷入(flush)到磁盘文件中。所以写磁盘的操作变为了写内存的操作,性能上得到了提升。
pageCache理论说完了,那么RocketMq中写入page cache怎么实现的?RocketMq通过mmap内存映射技术将磁盘中的commitLog文件映射到内存中
mmap是一种将文件映射到虚拟内存的技术,可以将文件在磁盘位置的地址和在虚拟内存中的虚拟地址通过映射对应起来,之后就可以在内存这块区域进行读写数据,而不必调用系统级别的read,wirte这些函数,从而提升IO操作性能
mq中单个 commitlog 文件,默认大小为 1G,而一个MappedFile即为内存映射中的封装的一个 CommitLog 文件,消息写入时会存在MappedFile的MappdByteBuffer中,在调用force()的时候将数据刷到磁盘中
文件预热也是针对上面内存映射中的MappedFile和磁盘中的CommotLog文件,即预先做好MappedFile和CommitLog的映射,避免在消息写入时进行磁盘文件的加载而引起性能损耗,mq中预热代码实现在org.apache.rocketmq.store.MappedFile#warmMappedFile
中,有兴趣的可以去研究下
消息写入pageCache后,自然是要进行刷盘操作的,RocketMq中提供了同步刷盘、异步刷盘2种刷盘策略,刷盘代码在org.apache.rocketmq.store.CommitLog#handleDiskFlush
中,实现如下
同步刷盘、异步刷盘对比如下:
同步刷盘:每次发送消息,消息都直接存储在 MappedFile 的 mappdByteBuffer,然后直接调用 force() 方法刷写到磁盘,等到 force 刷盘成功后,再返回给调用方(GroupCommitRequest#waitForFlush
)就是其同步调用的实现,本质上是使用CountdownLatch
来实现的同步等待及唤醒
异步刷盘
分为两种情况,是否开启堆外内存缓存池,具体配置参数:MessageStoreConfig#transientStorePoolEnable
1)transientStorePoolEnable=false(默认)
消息追加时,直接存入 MappedByteBuffer(pageCache) 中,然后定时 flush,这种很好理解,和同步刷盘相比,只是依赖定时器来定时将MappedByteBuffer中的消息数据刷入磁盘中,流程与同步刷盘差不多
2)transientStorePoolEnable = true
如果设置堆外缓冲池transientStorePoolEnable参数为true,就有点不一样了,之前提到过消息会写入MappedByteBuffer中,也就是pageCache中,而实际上读数据也是读的pageCache,如果读写并发较大,实际也有一定性能影响,mq通过将消息写到堆外内存中来提高性能,实现写消息、读消息读写分离。同时通过堆外缓冲池的使用,也能减少Java堆内存的使用,减少GC压力
开启该参数情况下,消息在追加时,先放入到堆外内存writeBuffer 中,然后定时 commit 到 FileChannel,然后定时flush,流程图如下
正常情况下,将磁盘文件中读取数据,并且将数据发送到socket中,是如图所示的一个流转情况
其中2,3两步是可以被优化的cpu拷贝过程,因为数据已经在内核态的buffer中了,如果能直接将内核态buffer中的数据直接发送到socket中,是可以避免2次cpu拷贝的,
所谓的零拷贝就是避免数据在内核空间缓存区和用户空间缓缓冲区之间的复制,避免掉2次cpu复制,释放cpu
Rocketmq中使用mmap+sendfile机制实现零拷贝
mmap通过虚拟内存映射,让多个虚拟地址指向同一个物理内存地址,用户空间的虚拟地址和内核空间的虚拟地址指向同一个物理内存地址,这样用户空间和内核空间共享同一个内存数据。这样DMA引擎从磁盘上加载的数据不需要在内核空间和用户空间进行复制,减少了一次cpu拷贝
sendfile,指的就是FileChannel.transferTo(long position, long count, WritableByteChannel target)
将数据从文件通道传输到了给定的可写字节通道
还有个问题,消费者读取pageCache的时候,是直接读取CommitLog映射过来的MappedFile文件么?
因为Mq在消息写入的时候是所有Topic顺序写入一个CommitLog文件的,如果读取的时候得根据指定Topic去读取CommitLog文件,则避免不了循环遍历文件,再获取Topic对应的消息数据,这样时间复杂度就到了O(n),显然是不能接受的。
那么有没办法将每个Topic对应的数据放到一个文件中呢?RocketMq中使用ReputMessageService 持续地读取 CommitLog 文件并生成 ConsumeQueue,消费者实际消费时会先读ConsumeQueue,再读CommitLog,这样就不需要去遍历Commitlog来获取topic下的消息,是一种读写分离的实现思路
通常消费端可以指定tag来过滤出业务需要的消息,如
consumer.subscribe("TopicTest1", "TagA || TagC || TagD");
那么这个过滤是在Broker服务端进行的,还是消费端进行的呢?再回顾下ConsumerQueue的存储结构,如图所示,每个消息是个定长的存储结构,CommitLog OffSet存储消息在实际磁盘文件CommitLog中的偏移量,Tag HashCode存储消息体中tag 的hash值(如果直接存储tag就不能达到定长的要求了)
mq中消息过滤的方式是Broker过滤+消费端过滤的组合实现,什么意思呢?
说了这么多过滤,和本文主体性能优化有啥关系?将大部分消息在Broker服务端过滤掉,避免了无效的消息发往消费端,这也是一种减少网络IO的优化思路
到了这里,就是消费端拉取消息的实现了,把Broker理解为一个存储消息的服务器,现在要消费者去服务器上获取指定topic的消息列表,你会怎么做?是让Broker和消费者建立连接,然后推消息?还是消费者死循环定时去Broker拉取消息?
RocketMq中使用长轮询机制实现消息拉取
长轮询什么意思呢,在消息服务端根据拉取偏移量去物理文件查找消息时没有找到,并不立即返回消息未找到,而是会将该线程挂起一段时间,然后再次重试。长连接机制使得 RocketMQ 的网络利用率非常高效,并且最大限度地降低了消息拉取时的等待开销。实现了毫秒级的消息投递。
上述提到的优化方案只是RocketMq中的一部分实现,还有其他方面优化,本人才疏学浅,不再一一细化
如下几点是mq中的其他一部分优化方案