rocketMQ相关
RocketMQ 主要由 Broker、NameServer、Producer 和 Consumer 组成的一个集群。
- NameServer:整个集群的注册中心和配置中心,管理集群的元数据。包括 Topic 信息和路由信息、Producer 和 Consumer 的客户端注册信息、Broker 的注册信息。
- Broker:负责接收消息的生产和消费请求,并进行消息的持久化和消息的读取。
- Producer:负责生产消息。
- Consumer:负责消费消息。
在实际生产和消费消息的过程中,NameServer 为生产者和消费者提供 Meta 数据,以确定消息该发往哪个 Broker 或者该从哪个 Broker 拉取消息。有了 Meta 数据后,生产者和消费者就可以直接和 Broker 交互了。这种点对点的交互方式最大限度降低了消息传递的中间环节,缩短了链路耗时。
网络模型
RocketMQ 使用 Netty 框架实现高性能的网络传输。
Netty 主要特点
- 具有统一的 API,用户无需关心 NIO 的编程模型和概念。通过 Netty 的 ChannelHandler 可以对通信框架进行灵活的定制和扩展。
- Netty 封装了网络编程涉及的基本功能:拆包解包、异常检测、零拷贝传输。
- Netty 解决了 NIO 的 Epoll Bug,避免空轮询导致 CPU 的 100% 利用率。
- 支持多种 Reactor 线程模型。
- 使用范围广泛,有较好的的开源社区支持。Hadoop、Spark、Dubbo 等项目都集成 Netty。
Netty 的高性能传输的体现
- 非阻塞 IO
- Ractor 线程模型
- 零拷贝。使用 FileChannel.transfer 避免在用户态和内核态之间的拷贝操作;通过 CompositeByteBuf 组合多个 ByteBuffer;通过 slice 获取 ByteBuffer 的切片;通过 wrapper 把普通 ByteBuffer 封装成 netty.ByteBuffer。
具体流程:
- eventLoopGroupBoss 作为 acceptor 负责接收客户端的连接请求
- eventLoopGroupSelector 负责 NIO 的读写操作
- NettyServerHandler 读取 IO 数据,并对消息头进行解析
- disatch 过程根据注册的消息 code 和 processsor 把不同的事件分发给不同的线程。由 processTable 维护(类型为 HashMap)
消息的生产
RocketMQ 支持三种消息发送方式:同步发送、异步发送和 One-Way 发送。One-Way 发送时客户端无法确定服务端消息是否投递成功,因此是不可靠的发送方式。
三种发送方式实现上的区别
- 同步发送:注册 ResponseFuture 到 responseTable,发送 Request 请求,并同步等待 Response 返回。
- 异步发送:注册 ResponseFuture 到 responseTable,发送 Request 请求,不需要同步等待 Response 返回,当 Response 返回后会调用注册的 Callback 方法,从而异步获取发送的结果。
- One-Way:发送 Request 请求,不需要等待 Response 返回,不需要触发 Callback 方法回调
流程说明
- Broker 通过 Netty 接收 RequestCode 为 SEND_MESSAGE 的请求,并把该请求交给 SendMessageProcessor 进行处理。
- SendMessageProcessor 先解析出 SEND_MESSAGE 报文中的消息头信息(Topic、queueId、producerGroup 等),并调用存储层进行处理。
- putMessage 中判断当前是否满足写入条件:Broker 状态为 running;Broker 为 master 节点;磁盘状态可写(磁盘满则无法写入);Topic 长度未超限;消息属性长度未超限;pageCache 未处于繁忙状态(pageCachebusy 的依据是 putMessage 写入 mmap 的耗时,如果耗时超过 1s,说明由于缺页导致页加载慢,此时认定 pageCache 繁忙,拒绝写入)。
- 从 MappedFileQueue 中选择已经预热过的 MappedFile。
- AppendMessageCallback 中执行消息的操作 doAppend,直接对 mmap 后的文件的 bytbuffer 进行写入操作。
Broker 端对写入性能的优化
自旋锁减少上下文切换
RocketMQ 的 CommitLog 为了避免并发写入,使用一个 PutMessageLock。PutMessageLock 有 2 个实现版本:PutMessageReentrantLock 和 PutMessageSpinLock。
PutMessageReentrantLock 是基于 java 的同步等待唤醒机制;PutMessageSpinLock 使用 Java 的 CAS 原语,通过自旋设值实现上锁和解锁。RocketMQ 默认使用 PutMessageSpinLock 以提高高并发写入时候的上锁解锁效率,并减少线程上下文切换次数。
MappedFile 预热和零拷贝机制
RocketMQ 消息写入对延时敏感,为了避免在写入消息时,CommitLog 文件尚未打开或者文件尚未加载到内存引起的 load 的开销,RocketMQ 实现了文件预热机制。
Linux 系统在写数据时候不会直接把数据写到磁盘上,而是写到磁盘对应的 PageCache 中,并把该页标记为脏页。当脏页累计到一定程度或者一定时间后再把数据 flush 到磁盘(当然在此期间如果系统掉电,会导致脏页数据丢失)。RocketMQ 实现文件预热的关键代码如下:
代码分析
- 对文件进行 mmap 映射。
- 对整个文件每隔一个 PAGE_SIZE 写入一个字节,如果是同步刷盘,每写入一个字节进行一次强制的刷盘。
- 调用 libc 的 mlock 函数,对文件所在的内存区域进行锁定。(系统调用 mlock 家族允许程序在物理内存上锁住它的部分或全部地址空间。这将阻止 Linux 将这个内存页调度到交换空间(swap space),即使该程序已有一段时间没有访问这段空间)。
同步和异步刷盘
RocketMQ 提供了同步刷盘和异步刷盘两种机制。默认使用异步刷盘机制。
当 CommitLog 在 putMessage() 中收到 MappedFile 成功追加消息到内存的结果后,便会调用 handleDiskFlush() 方法进行刷盘,将消息存储到文件中。handleDiskFlush() 便会根据两种刷盘策略,调用不同的刷盘服务。
抽象类 FlushCommitLogService 负责进行刷盘操作,该抽象类有 3 中实现:
- GroupCommitService:同步刷盘
- FlushRealTimeService:异步刷盘
- CommitRealTimeService:异步刷盘并且开启 TransientStorePool
每个实现类都是一个 ServiceThread 实现类。ServiceThread 可以看做是一个封装了基础功能的后台线程服务。有完整的生命周期管理,支持 start、shutdown、weakup、waitForRunning
同步刷盘流程
- 所有的 flush 操作都由 GroupCommitService 线程进行处理
- 当前接收消息的线程封装一个 GroupCommitRequest,并提交给 GroupCommitService 线程,然后当前线程进入一个 CountDownLatch 的等待
- 一旦有新任务进来 GroupCommitService 被立即唤醒,并调用 MappedFile.flush 进行刷盘。底层是调用 mappedByteBuffer.force ()
- flush 完成后唤醒等待中的接收消息线程。从而完成同步刷盘流程
异步刷盘流程
- RocketMQ 每隔 200ms 进行一次 flush 操作(把数据持久化到磁盘)
- 当有新的消息写入时候会主动唤醒 flush 线程进行刷盘
- 当前接收消息线程无须等待 flush 的结果。
消息消费
高性能的消息队列应该保证最快的消息周转效率:即发送方发送的一条消息被 Broker 处理完之后因该尽快地投递给消息的消费方。
消息存储结构
RocketMQ 的存储结构最大特点:
- 所有的消息写入转为顺序写(相比于 Kafka,RocketMQ 即使对于 1w+ 以上的 Topic 也能够应付自如)
- 读写文件分离。通过 ReputMessageService 服务生成 ConsumeQueue
结构说明
- ConsumeQueue 与 CommitLog 不同,采用定长存储结构,如下图所示。为了实现定长存储,ConsumeQueue 存储了消息 Tag 的 Hash Code,在进行 Broker 端消息过滤时,通过比较 Consumer 订阅 Tag 的 HashCode 和存储条目中的 Tag Hash Code 是否一致来决定是否消费消息。
- ReputMessageService 持续地读取 CommitLog 文件并生成 ConsumeQueue。
并行消费
- 并行消费的实现类为 ConsumeMessageConcurrentlyService。
- PullMessageService 内置一个 scheduledExecutorService 线程池,主要负责处理 PullRequest 请求,从 Broker 端拉取最新的消息返回给客户端。拉取到的消息会放入 MessageQueue 对应的 ProcessQueue。
- ConsumeMessageConcurrentlyService 把收到的消息封装成一个 ConsumeRequest,投递给内置的 consumeExecutor 独立线程池进行消费。
- ConsumeRequest 调用 MessageListener.consumeMessage 执行用户定义的消费逻辑,返回消费状态。
- 如果消费状态为 SUCCESS。则删除 ProcessQueue 中的消息,并提交 offset。
- 如果消费状态为 RECONSUME。则把消息发送到延时队列进行重试,并对当前失败的消息进行延迟处理。
串行消费
- 串行消费的实现类为 ConsumeMessageOrderlyService。
- PullMessageService 内置一个 scheduledExecutorService 线程池,主要负责处理 PullRequest 请求,从 Broker 端拉取最新的消息返回给客户端。拉取到的消息会放入 MessageQueue 对应的 ProcessQueue。
- ConsumeMessageOrderlyService 把收到的消息封装成一个 ConsumeRequest,投递给内置的 consumeExecutor 独立线程池进行消费。
- 消费时首先获取 MessageQueue 对应的 objectLock,保证当前进程内只有一个线程在处理对应的的 MessageQueue, 从 ProcessQueue 的 msgTreeMap 中按 offset 从低到高的顺序取消息,从而保证了消息的顺序性。
- ConsumeRequest 调用 MessageListener.consumeMessage 执行用户定义的消费逻辑,返回消费状态。
- 如果消费状态为 SUCCESS。则删除 ProcessQueue 中的消息,并提交 offset。
- 如果消费状态为 SUSPEND。判断是否达到最大重试次数,如果达到最大重试次数,就把消息投递到死信队列,继续下一条消费;否则消息重试次数 + 1,在延时一段时间后继续重试。
可见,串行消费如果某条消息一直无法消费成功会造成阻塞,严重时会引起消息堆积和关联业务异常。
Broker 端的 PullMessage 长连接实现
消息队列中的消息是由业务触发而产生的,如果使用周期性的轮询,不能保证每次都取到消息,且轮询的频率过快或者过慢都会对消息的延时有严重的影响。因此 RockMQ 在 Broker 端使用长连接的方式处理 PullMessage 请求。具体实现流程如下:
- PullRequest 请求中有个参数 brokerSuspendMaxTimeMillis,默认值为 15s,控制请求 hold 的时长。
- PullMessageProcessor 接收到 Request 后,解析参数,校验 Topic 的 Meta 信息和消费者的订阅关系。对于符合要求的请求,从存储中拉取消息。
- 如果拉取消息的结果为 PULL_NOT_FOUND,表示当前 MessageQueue 没有最新消息。
- 此时会封装一个 PullRequest 对象,并投递给 PullRequestHoldService 内部线程的 pullRequestTable 中。
- PullRequestHoldService 线程会周期性轮询 pullRequestTable,如果有新的消息或者 hold 时间超时 polling time,就会封装 Response 请求发给客户端。
- 另外 DefaultMessageStore 中定义了 messageArrivingListener,当产生新的 ConsumeQueue 记录时候,会触发 messageArrivingListener 回调,立即给客户端返回最新的消息。
长连接机制使得 RocketMQ 的网络利用率非常高效,并且最大限度地降低了消息拉取时的等待开销。实现了毫秒级的消息投递。
RocketMQ 的其他性能优化手段
关闭偏向锁
在 RocketMQ 的性能测试中,发现存在大量的 RevokeBias 停顿,偏向锁主要是消除无竞争情况下的同步原语以提高性能,但考虑到 RocketMQ 中该场景比较少,便通过 - XX:-UseBiasedLocking
关闭了偏向锁特性。
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次 CAS,但偏向锁只有初始化时需要一次 CAS。
偏向锁的使用场景有局限性,只适用于单个线程使用锁的场景,如果有其他线程竞争,则偏向锁会膨胀为轻量级锁。当出现大量 RevokeBias 引起的小停顿时,说明偏向锁意义不大,此时通过 - XX:-UseBiasedLocking
进行优化,因此 RocketMQ 的 JVM 参数中会默认加上 - XX:-UseBiasedLocking
。
写在最后
最后附上阿里中间件的延时性能对比。RocketMQ 在低延迟方面依然具有领先地位,如下图所示,RocketMQ 仅有少量 10~50ms 的毛刺延迟,Kafka 则有不少 500~1000ms 的毛刺
问5:如果Broker宕了,NameServer是怎么感知到的?
答:
Broker会定时(30s)向NameServer发送心跳
然后NameServer会定时(10s)运行一个任务,去检查一下各个Broker的最近一次心跳时间,如果某个Broker超过120s都没发送心跳了,那么就认为这个Broker已经挂掉了。
问6:Broker挂了,系统是怎么感知到的?
答:
主要是通过拉取NameServer上Broker的信息。
但是,因为Broker心跳、NameServer定时任务、生产者和消费者拉取Broker信息,这些操作都是周期性的,所以不会实时感知,所以存在发送消息和消费消息失败的情况,现在我们先知道,对于生产者而言,他是有一套容错机制的。
每个Broker向所有的NameServer上注册自己的信息,即每个NameServer上有所有的Broker信息。
Producer是随机选择还是使用什么规则选择NameSrv获取路由信息?
对NameSrv选择是随机的
问4:Master Broker突然挂了,这样会怎么样?
答:
RocketMQ 4.5版本之前,用Slave Broker同步数据,尽量保证数据不丢失,但是一旦Master故障了,Slave是没法自动切换成Master的。
所以在这种情况下,如果Master Broker宕机了,这时就得手动做一些运维操作,把Slave Broker重新修改一些配置,重启机器给调整为Master Broker,这是有点麻烦的,而且会导致中间一段时间不可用。
问5:基于Dledger实现RocketMQ高可用自动切换
RocketMQ 4.5之后支持了一种叫做Dledger机制,基于Raft协议实现的一个机制。
我们可以让一个Master Broker对应多个Slave Broker, 一旦Master Broker宕机了,在多个Slave中通过Dledger技术将一个Slave Broker选为新的Master Broker对外提供服务。
在生产环境中可以是用Dledger机制实现自动故障切换,只要10秒或者几十秒的时间就可以完成
NameServer是一个功能齐全的服务器,其角色类似Dubbo中的Zookeeper,但NameServer与Zookeeper相比更轻量。主要是因为每个NameServer节点互相之间是独立的,没有任何信息交互。
NameServer压力不会太大,平时主要开销是在维持心跳和提供Topic-Broker的关系数据
NameServer 被设计成几乎无状态的,可以横向扩展,节点之间相互之间无通信,通过部署多台机器来标记自己是一个伪集群
每个 Broker 在启动的时候会到 NameServer 注册,Producer 在发送消息前会根据 Topic 到 NameServer 获取到 Broker 的路由信息,Consumer 也会定时获取 Topic 的路由信息。
所以从功能上看NameServer应该是和 ZooKeeper 差不多,据说 RocketMQ 的早期版本确实是使用的 ZooKeeper ,后来改为了自己实现的 NameServer 。
Broker
Broker在RocketMQ中是进行处理Producer发送消息请求,Consumer消费消息的请求,并且进行消息的持久化,以及HA策略和服务端过滤,就是集群中很重的工作都是交给了Broker进行处理。
看Broker的源码的话会发现,他的初始化流程很冗长,会根据配置创建很多线程池主要用来发送消息、拉取消息、查询消息、客户端管理和消费者管理,也有很多定时任务,同时也注册了很多请求处理器,用来发送拉取消息查询消息的
RocketMQ 刷盘实现
Broker 在消息的存取时直接操作的是内存(内存映射文件),这可以提供系统的吞吐量,但是无法避免机器掉电时数据丢失,所以需要持久化到磁盘中。
刷盘的最终实现都是使用NIO中的 MappedByteBuffer.force() 将映射区的数据写入到磁盘,如果是同步刷盘的话,在Broker把消息写到CommitLog映射区后,就会等待写入完成。
异步而言,只是唤醒对应的线程,不保证执行的时机,流程如图所示。