消息生产
生产过程
- Producer发送消息前,向NameServer发出获取Topic的路由信息的请求
- NameServer返回该Topic的路由表及Broker列表
- 通过特定的Queue选择算法选出要存储消息的队列
- 对消息做特殊处理
- Producer向Queue所在的Broker发送RPC请求,将消息发送到选择的队列里
路由表:Map数据结构,key为Topic名称,value为QueueData实例列表。一个QueueData对应 一个Broker中与key对应的Topic 里的所有的Queue。QueueData包含BrokerName
路由表在 NameServer 中的核心数据结构可以简化理解为一个 Map<String, TopicRouteData>
- Key:Topic 名称(表示该路由信息所属的消息主题)
- Value :
TopicRouteData
对象(包含该 Topic 在整个集群中的完整路由信息)
举个更直观的例子:如果 Topic OrderTopic
分布在 broker-1
、broker-2
两个 Broker 上,那么路由表中:
- Key 为
OrderTopic
- Value(
TopicRouteData
)中的QueueData
列表包含两个元素:
-
- 元素 1:
brokerName=broker-1
,记录broker-1
上该 Topic 的队列配置 - 元素 2:
brokerName=broker-2
,记录broker-2
上该 Topic 的队列配置
- 元素 1:
每个 QueueData
包含以下关键属性(核心字段):
属性名 | 数据类型 | 含义说明 |
brokerName |
String | 所属 Broker 的名称(唯一标识一个 Broker 节点或一组主从节点共享的逻辑名称) |
readQueueNums |
int | 该 Broker 上该 Topic 的读队列数量(消费者从这些队列拉取消息) |
writeQueueNums |
int | 该 Broker 上该 Topic 的写队列数量(生产者向这些队列发送消息) |
perm |
int | 队列权限标识(通过位运算表示,如:6=2+4 表示同时拥有读和写权限) |
topicSynFlag |
int | Topic 同步标识(用于主从复制场景,标识该 Topic 的队列是否需要同步到从节点) |
Broker列表:Map数据结构。key为BrokerName,value为BrokerData。BrokerData即为BrokerName相同的Broker主从集群
Queue选择算法
- 轮询算法
-
- 实现简单,能基本保证消息在队列间的均匀分布,避免单个队列负载过高
- 未考虑 Broker 实际负载(如网络延迟、处理能力差异),可能导致负载不均(例如某 Broker 性能较差但仍被分配等量消息)
- 最小投递延迟算法
-
- 能动态适应 Broker 性能变化,将更多消息分配到处理速度快、延迟低的 Broker / 队列,提升整体发送效率。
- 实现复杂,需要维护延迟统计并处理统计偏差(如网络抖动导致的瞬时延迟);首次发送时无历史数据,可能需要退化到轮询等基础算法
- 极端情况导致分配不均,有的消费者对应的队列消息过多,消费能力下降,导致消息堆积
消息的存储
RocketMQ中的消息存储在本地文件系统中,这些相关文件默认在当前用户主目录下的store目录中。
- abort:该文件在Broker启动后会自动创建,正常关闭Broker,该文件会自动消失。若在没有启动 Broker的情况下,发现这个文件是存在的,则说明之前Broker的关闭是非正常关闭。
- checkpoint:其中存储着commitlog、consumequeue、index文件的最后刷盘时间戳
- commitlog:其中存放着commitlog文件,而消息是写在commitlog文件中的
- config:存放着Broker运行期间的一些配置数据
- consumequeue:其中存放着consumequeue文件,队列就存放在这个目录中
- index:其中存放着消息索引文件indexFile
- lock:运行期间使用到的全局资源锁
commitlog
commitlog目录中存放很多mappedFile文件 ,当前Broker中所有的消息都是落盘到这些mappedFile文件中。
一个Broker中只有一个commitlog目录 ,broker中无论是什么类型的topic 都会被按顺序写入mappedFile。
mappedFile文件是顺序读写的文件,所以其访问效率很高。文件名由20位十进制数构成,表示 当前文件的第一条消息的起始位移偏移量。
mappedFile由多个消息单元构成。
consumequeue
consumequeue文件是commitlog的索引文件,可以根据consumequeue定位到具体的消息。
consumequeue文件名也由20位数字构成,表示当前文件的第一个索引条目的起始位移偏移量。与 mappedFile文件名不同的是,其后续文件名是固定的。因为consumequeue文件大小是固定不变的。
文件读写
消息写入:
- Broker根据queueId获取该消息对应索引条目要在consumequeue目录写入的偏移量
- 将queueId,queueOffset等数据与消息一起封装为消息单元
- 将消息单元写入commitlog,同时形成消息索引条目
- 将消息索引条目发放到consumequeue
消息拉取:
- Consumer获取准备消费的消息所在Queue的消费偏移量offset,计算出其要消费消息的消息offset
- Consumer向Broker发送拉取请求,请求中包含要拉取消息的Queue,消息offset以及消息Tag
- Broker计算消息在该consumequeue中的queueOffset(queueOffset = 消息offset * 索引条目字节数)
- 从该queueOffset出发向后寻找第一个指定Tag的索引条目
- 解析该索引条目,定位消息在commitlog中的commitlog offset
- 从对应的commitlog offset读取消息单元并发送给Consumer
indexFile
RocketMQ提供了根据Key查询消息的功能,索引数据是包含了key的消息被发送到Broker中被写入的,如果没有包含key则不写。
索引条目结构
每个Broker都包含一组indexFile,每个indexFile以其被创建时的时间戳命名。 每个indexFile文件由三部分构成:indexHeader,slots槽位,indexes索引数据。每个 indexFile文件中包含500w个slot槽。而每个slot槽又可能会挂载很多的index索引单元。
indexHeader固定40个字节,其中存放着如下数据:
- beginTimestamp:该indexFile中第一条消息的存储时间
- endTimestamp:该indexFile中最后一条消息存储时间
- beginPhyoffset:该indexFile中第一条消息在commitlog中的偏移量commitlog offset
- endPhyoffset:该indexFile中最后一条消息在commitlog中的偏移量commitlog offset
- hashSlotCount:已经填充有index的slot数量(并不是每个slot槽下都挂载有index索引单元,这里统计的是所有挂载了index索引单元的slot槽的数量)
- indexCount:该indexFile中包含的索引单元个数(统计出当前indexFile中所有slot槽下挂载的所有index索引单元的数量之和)
slots是固定的,但indexs索引数据是变化的,如果在每个slot后面存放其对应的索引数据,其所需的空间是无法确定的。因此统一将索引数据放到slots之后,那么用来存储索引数据的空间是无穷的。
为了便于理解,slots和indexs对应关系如图:
index索引单元默认20个字节,其中存放着以下四个属性:
- keyHash:消息中指定的业务key的hash值
- phyOffset:当前key对应的消息在commitlog中的偏移量commitlog offset
- timeDiff:当前key对应消息的存储时间与当前indexFile创建时间的时间差
- preIndexNo:当前slot下当前index索引单元的前一个index索引单元的indexNo
keyhash值 % 500w的结果即为slot槽位,然后将该slot值修改为该index索引单元的indexNo,根据这个indexNo可以计算出该index单元在indexFile中的位置。
indexNo是一个在indexFile中的流水号,从0开始依次递增。indexNo在index索引单元中是没有体现的,其是通过在indexes中依次数出来的。
在每个index索引单元中增加了preIndexNo,用于指定该slot中当前index索引单元的前一个index索引单元。而slot中始终存放的是其下最新的index索引单元的indexNo。
文件名
根据业务key进行查询时,查询条件除了key之外,还需要指定一个要查询的时间戳,表示要查询不大于该时间戳的最新的消息。
时间戳文件名可以简化查询,提高查询效率。
indexFile创建时机:
- 当第一条带key的消息发送来后,系统发现没有indexFile,此时会创建第一个indexFile文件
- 当一个indexFile中挂载的index索引单元数量超出2000w个时,会创建新的indexFile。当带key的消息发送到来后,系统会找到最新的indexFile, 并从其indexHeader的最后4字节中读取到 indexCount。若indexCount >= 2000w时,会创建新的indexFile。
查询流程
推拉消费模型
pull:
- Consumer主动从Broker中拉取消息
- 实时性低
- 拉去时间间隔由用户指定,若设置不当:间隔太短,空请求比 例会增加;间隔太长,消息的实时性太差
push:
- Broker收到数据后会主动推送给Consumer
- 实时性高
- 采用了发布-订阅模式,Consumer向其关联的queue注册了监听器, 一旦发现有新的消息到来就会触发回调的执行。
消费模式
广播模式:
相同Consumer Group的每个Consumer实例都接收同一个Topic的全量消息,每条消息都会被发送到Consumer Group中的每个Consumer。
消费模型为pull模型,每个consumer端各自保存自己的消费进度。
集群消费:
相同Consumer Group的每个Consumer实例平均分摊同一个Topic的消息,每条消息只会被发送到Consumer Group中的某个Consumer。
消费模型为push模型,消费进度保存在broker里,由consumer group中的consumer共享
Rebalance机制
将一个Topic下的多个Queue在同一个Consumer Group中多个Consumer之间重新分配的过程。
例如,⼀个Topic下5个队列,在只有1个消费者的情况下,这个消费者将负责消费这5个队列的消息。如果此时我们增加⼀个消费者,那么就可以给其中⼀个消费者分配2个队列,给另⼀个分配3个队列,从而提升消息的并行消费能力。
缺点:
- 消息暂停:
只有一个Consumer时,他负责全部的队列。当新增一个Consumer时,原来的不得不停止消费一些队列,然后将这些队列分给新的Consumer,这些停止的队列才能继续消费
- 消费重复:
Consumer消费新队列时是根据之前Consumer提交的消费进度offset 继续消费的,但offset提交默认为异步提交,就有可能导致新的Consumer接到消息但没有收到offset,因此重新消费部分信息
同步提交:consumer提交了其消费完毕的一批消息的offset给broker后,需要等待broker的成功 ACK。当收到ACK后,consumer才会继续获取并消费下一批消息。在等待ACK期间,consumer 是阻塞的。
异步提交:consumer提交了其消费完毕的一批消息的offset给broker后,不需要等待broker的成 功ACK。consumer可以直接获取并消费下一批消息。
- 消费突刺:
由于需要重复消费的消息过多,或者Rebalance暂停时间过长,导致消息挤压。在Rebalance释放瞬间需要消费很多消息。
Rebalance场景
Queue数量发生变化的场景:
- Broker扩容或缩容
- Broker升级运维
- Broker与NameServer间的网络异常
- Queue扩容或缩容
消费者数量发生变化的场景:
- Consumer Group扩容或缩容
- Consumer升级运维
- Consumer与NameServer间网络异常
Rebalance过程
在Broker中维护着多个Map集合:
- TopicConfigManager:key是topic名称,value是TopicConfig。TopicConfig中维护着该Topic中所 有Queue的数据。
- ConsumerManager:key是Consumser Group Id,value是ConsumerGroupInfo。 ConsumerGroupInfo中维护着该Group中所有Consumer实例数据。
- ConsumerOffsetManager:key为Topic与订阅该Topic的Group的组合,即topic@group, value是一个内层Map。内层Map的key为QueueId,内层Map的value为该Queue的消费进度 offset。
Broker 一旦发现消费者所订阅的Queue数量发生变化 ,或消费者组中消费者的数量发生变化 ,立即向Consumer Group中的每个实例发出Rebalance通知。
Consumer接到通知,采用Queue算法获取到对应的Queue,自主进行Rebalance。
Queue分配算法
平均分配策略
编辑
avg = QueueCount / ConsumerCount。整除的话直接平均分配;不能整除将多余的按Consumer的顺序依次分配
环形平均策略
不用事先计算,根据消费者的顺序,依次在由queue队列组成的环形图中逐个分配。
一致性hash策略
编辑
该算法会将consumer的hash值作为Node节点存放到hash环上,然后将queue的hash值也放到hash环上,通过顺时针方向,距离queue最近的那个consumer就是该queue要分配的consumer。
同机房策略
该算法会根据queue的部署机房位置和consumer的位置,过滤出当前consumer相同机房的queue。然后按照平均分配策略或环形平均策略对同机房queue进行分配。如果没有同机房queue,则按照平均分配策略或环形平均策略对所有queue进行分配。
对比
一致性hash算法复杂,分配效率低,分配的结果可能不均
但其 可以有效减少由于消费者组扩容或缩容所带来的大量的Rebalance。
可以看到,平均分配算法在扩容后会连带从头开始的Queue的分配,Rebalance的比例很高
但一致性hash无论扩容还是缩容,只会影响变化的Queue附近的Consumer的分配。
至少一次原则
RocketMQ规定每条消息必须要被成功消费一次。
Consumer在消费完消息后会向其消费进度记录器提交其消费消息的offset, offset被成功记录到记录器中,那么这条消费就被成功消费了。
对于广播模式,Consumer自身就是消费进度记录器
对于集群模式,Broker是消费进度记录器
订阅关系的一致性
订阅关系的一致性指的是,同一个消费者组下所有Consumer实例所订阅的Topic与Tag及对消息的处理逻辑必须完全一致。否则,消息消费的逻辑就会混乱,甚至导致消息丢失。
错误订阅关系
- 订阅了不同的topic
- 订阅了不同的tag
- 订阅了不同数量的topic
offset管理
消费进度offset是用来记录每个Queue的不同消费者组的消费进度
本地管理模式
消费模式为广播消费 ,消费进度offset被每个消费者自己管理
Consumer在广播消费模式下offset相关数据以json的形式持久化到Consumer本地磁盘文件中
远程管理模式
消费模式为集群消费,所有consumer共享offset,由Broker管理
Consumer在集群消费模式下offset相关数据以json的形式持久化到Broker磁盘文件中
Broker启动时会加载这个文件,并写入到一个双层Map(ConsumerOffsetManager) 。外层map的key 为topic@group,value为内层map。内层map的key为queueId,value为offset。当发生Rebalance时, 新的Consumer会从该Map中获取到相应的数据来继续消费。
当消费完一批消息后,Consumer会提交其消费进度offset给Broker,Broker在收到消费进度后会将其更新到那个双层Map(ConsumerOffsetManager)及consumerOffset.json文件中,然后向该Consumer进行ACK ,而ACK内容中包含三项数据:当前消费队列的最小offset(minOffset)、最大 offset(maxOffset)、及下次消费的起始offset(nextBeginOffset)。
重试队列:消费异常时,将消费异常的offset提交到Broker中的重试队列。系统在发生消息消费异常时会自动创建topic@group重试队列。
同步提交与异步提交
同步提交:从ack中获取 nextBeginOffset
异步提交:从Broker中直接获得 nextBeginOffset
消费幂等
概念
重复消费和消费一次的结果是一样的:f(x) = f(f(x))
消息重复的场景
- 发送消息时重复:
当一条消息已被成功发送到Broker并完成持久化,此时出现了网络闪断,从而导致Broker对Producer应答失败。 如果此时Producer意识到消息发送失败并尝试再次发送消息,消息重复。
- 消费时重复:
消息已投递到Consumer并完成业务处理,当Consumer给Broker反馈应答时网络闪断,Broker没有接收到消费成功响应。为了保证消息至少被消费一次的原则,Broker将在网络恢复后再次尝试投递之前已被处理过的消息。
- Rebalance时消息重复:
Rebalance时Consumer可能会收到曾经被消费过的消息
通用解决方案
- 幂等令牌:通常指唯一业务标识的字符串
- 唯一性处理:服务端采用一定的算法策略,保证同一个业务逻辑不会被重复执行成功多次
实现
将消息的Key设置为订单号,作为幂等处理的依据。
消费者收到消息时可以根据消息的Key即订单号来实现消费幂等
消息堆积与消费延迟
概念
如果Consumer的消费速度跟不上Producer的发送速度,MQ中未处理的消息会越来越多(进的多出的少),这部分消息就被称为堆积消息。消息出现堆积进而会造成消息的消费延迟。
- 业务上下游能力不匹配,且无法自行恢复
- 对消费实时性要求高
原因
Consumer 使用长轮询Pull模式消费消息时,分为以下两个阶段:
- 消息拉取:长轮询Pull模式拉取消息,吞吐量很好,不会成为瓶颈
- 消息消费:消费能力就完全依赖于消息的消费耗时和消费并发度。如果由于业务处理逻辑复杂等原因,导致处理单条消息的耗时较长,则整体的消息吞吐量肯定不会高,此时就会导致Consumer本地缓冲队列达到上限,停止从服务端拉取消息。造成消息堆积
因此,堆积与否取决于消费能力,消费能力由消费耗时和消费并发度决定。
保证消费耗时的合理性前提下考虑消费并发度
消费耗时
代码逻辑中可能会影响处理时长代码主要有两种类型: CPU内部计算型代码和外部I/O操作型代码。
内部计算耗时相对外部I/O操作来说几乎可以忽略,外部IO型代码是影响消息处理时长的主要原因所在。
外部IO操作型代码举例:
- 读写外部数据库,例如对远程MySQL的访问
- 读写外部缓存系统,例如对远程Redis的访问
消费并发度
消费者端的消费并发度由 单节点线程数(单个Consumer包含的线程数量) 和 节点数量(Consumer Group中Consumer的数量) 共同决定( 单节点线程数 * 节点 数量 )
优先调整单节点的线程数,若单机资源达到上限,则扩展节点数量
单机线程数计算:
理想环境下单节点的最优线程数计算模型为:C *(T1 + T2)/ T1。
- C:CPU内核数
- T1:CPU内部逻辑计算耗时
- T2:外部IO操作耗时
如何避免
梳理消息的消费耗时
- 消息消费逻辑的计算复杂度是否过高,代码是否存在无限循环和递归等缺陷。
- 消息消费逻辑中的I/O操作是否是必须的,能否用本地缓存等方案规避。
- 消费逻辑中的复杂耗时的操作是否可以做异步化处理。
设置消费并发度
- 逐步调大单个Consumer节点的线程数,并观测节点的系统指标,得到单个节点最优的消费线程数和消息吞吐量。
- 根据上下游链路的流量峰值计算出需要设置的节点数