likes
comments
collection
share

关于RocketMQ的高性能设计,你真的了解吗?

作者站长头像
站长
· 阅读数 11

RocketMQ的高性能设计

谎言不会伤人,真相才是快刀。

关于RocketMQ的高性能设计,你真的了解吗?

哈喽大家好,我是际遇,今天继续跟大家聊一聊RocketMQ的高性能设计吧!

其实经过了很多年的阿里巴巴的双十一的验证,RocketMQ除了稳定以外,也保持着极高的性能(不然阿里早就不用啦)。这也是很多企业在技术选型的时候会把RocketMQ纳入考虑范围的原因。

先说结论,它的高性能设计主要体现在三个方面:

  • 数据存储设计
  • 动态伸缩能力
  • 消息实时投递

先说说数据存储设计吧,数据存储设计主要包括了RocketMQ的顺序写盘,消费队列的设计,消息跳跃读,数据零拷贝。

是不是看到这头都大了?

我刚开始看的时候也是觉得,这些术语看的我比上数学课都犯困,还学习?怎么学?

不急,我们还是拆开一点点来分解一下。

数据存储设计

RocketMQ的数据存储的核心主要由两个部分组成,CommitLog(数据存储文件)和ConsumeQueue(消费队列文件)。

简单来说,我们按步骤分解一下:

  1. Producer会先把消息发送到Broker服务器
  2. Broker会把所有收到的消息都存储在CommitLog文件中
  3. CommitLog文件会将消息转发到ConsumeQueue文件
  4. ConsumeQueue文件提供给各个Consumer消费

为了方便理解,画个图咱们了解一下:

关于RocketMQ的高性能设计,你真的了解吗?

顺序写盘

在说顺序写盘之前,我们先回顾一下子盘读写要经历的三个动作:

  • 寻道:磁头移动到指定磁道,需要找到数据在哪个物理位置,时间很长。
  • 旋转延迟:等待指定扇区旋转至磁头下,机械硬盘和每分钟多少转有关,时间很短。
  • 数据传输:数据通过系统总线从磁盘传输到内存,时间很短。

其中,磁盘读写最慢的动作是寻道,缩短寻道时间就能有效提升磁盘读写的速度,那最优的方式就是不用寻道啦。

  • 随机写:随机写会导致磁头不停的更换磁道,时间都花在寻道上了。
  • 顺序写:顺序写几乎不用换磁道,当然也不是完全不换,总之,其寻道时间在这里可以忽略不计。

前面我们说了CommitLog文件,它是负责存储消息数据的文件,所有Topic的消息都会先存在CommitLog文件中,消息数据写入CommitLog文件是加锁串行追加写入。

RocketMQ为了保证消息发送的高吞吐量,使用了单个文件存储所有的Topic的消息,也就是说,每个CommitLog文件存储了多个Topic的消息,这样虽然保证了消息存储的时候是完全的顺序写,但是又会给读取的时候带来困难。

那RocketMQ是怎么解决的呢?

RocketMQ的做法很简单,当消息到达CommitLog文件后,会通过异步线程几乎实时的将消息发送给消费队列文件(ConsumeQueue),每个CommitLog文件的默认大小是1GB,当写满后再写新的文件。

这样就导致了大量的I/O都顺序写在同一个CommitLog文件。重点来了,其文件名是按照该文件起始的总的字节偏移量offset命名,文件名固定的长度为20位,不足的前面补0,如:

  • 第一个文件起始偏移量是0,文件名为:00000000000000000000。
  • 第二个文件起始偏移量是1024 x 1024 x 1024 = 1073741824 (1 GB = 1073741824B),那第二个文件名为0000000001073741824。

这样加的好处是,在消费消息时能够根据偏移量offset来快速定位到消息存储在某个CommitLog文件,从而加快了检索的速度。

消费队列设计

其实前面我们已经发现了,消费Broker中的消息,本质上就是读取文件,但是问题在于消息数据文件中所有的Topic的消息是混合在一起的,但是消费消息的时候又是区分Topic来消费的。

这就导致如果消费消息的时候也读取CommitLog文件的话,会导致消费消息就变得性能差,吞吐量低。

那怎么解决的呢?

前面我们已经知道了,RocketMQ设计了ConsumeQueue来解决这个问题,它负责存储消费队列文件,在消息写入CommitLog文件的时候,通过异步线程转发到ConsumeQueue文件,然后提供给Consumer消费。

这里我们看之前的图应该就能知道,ConsumeQueue文件中并不存储具体的消息数据,只存储了CommitLog的offset偏移量,消息大小,消息Tag Hashcode。

每个Topic在某个Broker下是对应多个队列的,默认是四个,每一条记录的大小是20B,默认一个文件会存储30万个记录,文件名和CommitLog一样,也是按照字节偏移量来命名,文件名的默认长度是20位,不足补0。

  • 第一个文件起始偏移量是0,文件名是000000000000000000000,与CommitLog一样。
  • 第二个文件其实偏移量是20 x 30w = 6000000,第二个文件名是00000000000006000000。

如果是在集群的模式下,Broker会记录客户端对每个消费队列的消费偏移量,定位到ConsumeQueue里相应的记录,并通过CommitLog的Offset定位到CommitLog文件里的该条消息。

消息跳跃读取

RocketMQ中还是用了操作系统中的Page Cache机制(一种缓存机制), RocketMQ读取消息依赖操作系统的PageCache,PageCache命中率越高,那么RocketMQ的读取性能就越高,操作系统会尽量预读取数据,使应用直接访问磁盘的概率变低。

消息队列文件的读取流程:

  • 检查要读取的数据是否在上次预读取的Cache中。
  • 如果没有命中Cache,操作系统从磁盘中读取对应的数据页,并将该数据页之后的连续几页一起读取到Cache中,在将应用需要的数据返回,这种方式就叫做跳跃读取。
  • 如果命中的Cache,上次缓存的数据有效,操作系统认为在顺序读盘,则继续扩大混存的数据范围,将之前缓存的数据页之后的几页数据再读取到Cache中。

这里我们小小的扩展一下,在计算机中,CPU,RAM(内存),DISK(硬盘)的速度是不相同的,相信大家都知道的,按照速度高低排列一下:CPU>RAM>DISK。

还有一点是,它们之间的速度和容量差距是指数级别的,为了折中一下,就有了在CPU和RAM之间使用CPU Cache来提高访存速度,在RAM和DISK之间使用Page Cache提高系统对文件的访问速度。

数据零拷贝

这个相信各位也应该有所耳闻了,在网络通信的过程中呢,通常情况下对文件的读写要多经历一次数据拷贝,例如写文件数据要从用户态拷贝到内核态,再由内核态写入物理文件。

所谓的零拷贝指的就是用户态与内核态之间不存在拷贝。

在Java 的NIO中会提到相同的东西,到时候细说一下。

RocketMQ中的文件读写主要通过Java NIO中的 MappedByteBuffer来进行文件映射。利用了Java NIO中的FIleChannel模型,可以直接将物理文件映射到缓冲区的PageCache,少了一次数据拷贝的过程,提高了读写的速度。

动态伸缩能力

所谓动态伸缩呢,我们可以想象一下类似双11和618的时候,我们需要增加服务器(伸)来应对暴增的流量,但并不是所有的时候都有这么大的流量,所以当流量回归到正常水平的时候,为了避免服务器的资源(成本)浪费,此时就需要减少服务器(缩)。

我们从两个方面来说一下:

  • 消息队列扩容/缩容:一个Consumer实例同时消费多个消息队列中的消息。如果一个Topic的消息量特别大,但是Broker集群的水位压力还是很低,就可以对该Topic进行扩容,Topic的消息队列数跟消费速度是成正比的。消息队列的数可以在创建Topic的时候指定,也可在运行中修改。相反,就可以缩容。
  • Broker集群扩容/缩容:同样的, 如果一个Topic的消息量特别大,但是Broker集群的水位很高,此时就需要对Broker机器扩容,扩容的方式也很简单,直接加机器部署Broker就行了。新的Broker启动后会向NameServer注册,Producer和Consumer通过NameServer发现新的Broker并更新路由信息,反之亦然。

消息实时投递

消息的高性能除了以上说到的,还体现在消息发送到存储之后能否立即被客户端消费,这就涉及到了消息的实时投递,Consumer消费消息的实时性与获取消息的方式有很大的关系。

我们知道,任何一款消息中间件都会有两种获取消息的方式,Push推模式和Pull拉模式。这两种模式呢,不能是谁好谁不好,只是他们适用于不同的场景:

  • Push推模式:

    • 优点:Consumer能实时的接收到新的消息数据。
    • 缺点:Consumer消费不过来,缓冲区溢出。而且一个Topic往往对应多个ConsumerGroup,服务端一条消息会产生多次推送,给服务端造成不小的压力。
  • Pull拉模式:

    • 优点:可以根据消费速度选择合适的时机拉去消息消费。
    • 缺点:拉取的间隔时间不好控制。

其实这两种获取消息方式的缺点都很明显,单一的方式很难应对复杂的消费场景。

所以呢,RocketMQ提供了一种推/拉结合的长轮询机制来平衡这哥俩的缺点。

长轮询本质上其实是对普通Pull模式的优化,所以呢,还是以Consumer轮询的方式主动发送拉取请求到服务端Broker,Broker检测到有新的消息就返回给Consumer,如果没有就不返回,挂起当前请求缓存到本地,Broker有个线程去检查挂起请求,等到新消息产生时再返回Consumer。

对了,平常我们使用的DefaulMQPushConsumer的实现就是推拉结合的。

说白了,也就是消费者会不断地问Broker:“有新的消息可以给我消费了没?”

然后Broker要么回答:“有了!马上发给你。”

要么一直不说话,等到有新消息了再回答你。

后记

本来之前想着RocketMQ的高性能设计不需要太长的篇幅就可以讲完的,没想到整理了一下,有点小多。

那就后续再写RokcetMQ的高可用部分吧。

今天就不emo了,emo不起来了。

本来想写一点自己怎么入坑的这个行业,写了快一万字了,才刚到一半,有点小累。

好啦,今天就到这里啦!

欲知后事如何,且听下回分解。