RocketMQ 入门到原理(五):特殊消息类型

这是 RocketMQ 系列的第五篇。前四篇围绕「一条消息的生命周期」走完了生产→存储→消费,本篇横向展开------那些普通消息满足不了的场景,RocketMQ 是怎么解决的。顺序消息、延迟消息、事务消息,每一种背后都是一套精巧的设计。

一、为什么需要特殊消息

前四篇讲的「普通消息」------轮询投递、立即可消费------已经能覆盖大多数业务。但有三类需求,普通消息搞不定:

  1. 有严格顺序要求:证券交易撮合,出价相同的单子必须按「先出价先成交」处理,消息乱序业务就出错。
  2. 要定时/延迟投递:下单 30 分钟未支付自动关闭,不能一下单就关,也不能靠轮询数据库去查。
  3. 要和本地事务绑定:支付成功后要同时更新订单、发货、加积分、清购物车,要么全成功、要么全不执行。

这三种需求,对应 RocketMQ 的三种特殊消息:顺序消息、延迟/定时消息、事务消息。下面逐个讲透。

二、顺序消息

1. 普通消息为什么乱序

先看普通消息为什么满足不了「先出价先成交」这类需求。问题出在两个环节都无序,而且这两个环节叠加在一起,即使消费者串行执行,消息顺序也是完全乱的。

生产端无序 :生产者按轮询策略投递(第二篇讲过),内部维护一个原子计数器,每发一条消息 计数器 % 队列数量 决定进哪个队列。同一支股票的两笔出价,计数器值不同,取模结果不同,就可能分别进了 MQ3 和 MQ4。消息一分散到不同队列,它们的相对顺序就失去了保障------因为不同队列是独立存储、独立被消费的,Broker 和消费者都不知道「MQ3 里的 msg-3 和 MQ4 里的 msg-9 谁先谁后」。

消费端无序 :消费者拉取消息后,并不是逐条串行处理,而是批量拉取 + 多线程并发消费 。具体来说,消费者从自己负责的多个队列里批量拉回一批消息,然后丢进一个线程池并行处理------线程池里有几个工作线程,就有几条消息同时被消费。哪条消息被哪个线程拿到、哪个线程先执行完,完全取决于 JVM 的线程调度,和消息的投递顺序没有任何关系。即使你把线程池大小设为 1(退化成串行),由于消息是从多个队列里混合拉取的,拉取顺序本身就取决于各队列的堆积情况,仍然无法保证全局有序。

再加上队列分布不均:假设 6 个队列、2 个消费者,A 负责 MQ1-3,B 负责 MQ4-6。出价者 A 的消息进了 MQ3,出价者 B 的消息进了 MQ4。如果 MQ3 前面堆积了消息,A 批量拉取时还没拉到出价者 A 的消息;而 MQ4 没堆积,B 一下就拉到了出价者 B 的消息------先出价的反而后消费,撮合规则被打破。

整个乱序链路见下图:

2. 解决思路:同时解决两端无序

要让同一支股票的出价严格按顺序消费,必须同时解决两个问题:

  • 生产端 :同一支股票的出价必须进同一个队列,且按出价顺序落盘。
  • 消费端 :同一个队列里的消息必须按顺序消费,不能并发。

3. 生产端:哈希路由 + 同步发送 + 分布式锁

第一步:哈希路由 。不再用轮询,改用 MessageQueueSelector,按股票代码做哈希,算出目标队列下标。同一支股票的出价永远进同一个队列------这个队列内部是顺序追加的(第三篇讲过),天然有序。

第二步:同步发送 。必须用同步发送(第二篇),不能用异步。原因不是网络顺序的问题,而是刷盘失败时的行为差异 :同步发送时,Broker 必须等消息真正落盘才返回 ack,如果落盘失败,生产者立刻知道、可以重试,顺序不会乱。但异步发送不一样------Broker 收到消息就返回 ack,生产者以为成功了继续发下一条,如果此时 Broker 后台刷盘失败(磁盘满了、IO 异常),这条消息就丢了,但生产者已经发了后续的消息。丢掉的这条消息的「位置」空了出来,后续消息的顺序就全乱了。同步发送把「落盘成功」作为发送成功的前提,从根本上杜绝了这种情况。

第三步:分布式锁(或单线程池) 。这一步容易被忽略------生产者通常是多节点并发部署 的,即使哈希到同一个队列,多个线程同时投递,CPU 调度 (线程 A 拿到时间片开始发送,但还没发完就被操作系统切换走,线程 B 拿到时间片先发了)、网络抖动(线程 A 的消息在网络中延迟,线程 B 的消息先到 Broker)都可能让后发出的消息先落盘。解决办法:

  • 单线程池投递:同一支股票的出价由一个线程串行投递,简单但吞吐受限。
  • 分布式锁 (推荐):用 Redisson 等工具,按股票代码加锁。同一支股票的出价必须拿到锁才能投递,其他股票的出价不受影响。锁的粒度按业务场景定------股票场景锁的是「股票代码」,订单场景锁的是「订单号」。

生产端有序投递的完整链路:

4. 消费端:按队列加锁 + 单线程消费

存储端不需要额外处理------队列内顺序追加,天然有序(第三篇)。问题在消费端:多线程并发消费会打乱顺序。

RocketMQ 的做法:按队列加锁,单线程消费队列内的消息

  • 每个队列同一时刻只允许一个消费者线程消费(客户端内部加锁)。
  • 不同队列之间仍然并行------消费 MQ1 的同时也能消费 MQ2,只要每个队列内部有序就行。

这里要解释一下为什么「队列间可以并发」而不破坏顺序。顺序消息保证的是同一支股票的出价有序 ,而同一支股票的出价通过哈希路由进了同一个队列 。不同队列里的消息属于不同的股票,它们之间本来就没有顺序关系------股票 A 的出价和股票 B 的出价谁先谁后,业务根本不关心。所以只要每个队列内部有序 ,全局来看同一支股票的出价就是有序的,不同队列之间完全可以并行消费,互不干扰。这就是「分区有序」的含义------不是全局所有消息有序,而是按分区(队列)维度各自有序。

这样既保证了顺序,又保留了队列级别的并发,吞吐不至于太低。

5. 顺序消息的代价

顺序消息不是免费的,有两个明显的代价:

  • 重试阻塞整个队列 :消费失败时,不能像普通消息那样丢进重试队列(%RETRY%)------因为重试队列是独立的 Topic,消息一进去就脱离了原来的顺序。所以顺序消息的重试是无限重试阻塞当前队列的后续消费,直到这条消息消费成功。TPS 会大幅下降,这是用性能换顺序的无奈之举。
  • 扩缩容破坏顺序 :队列数量一变,哈希结果全部重算------原来哈希到 MQ1 的股票,扩容后可能哈希到 MQ2,顺序链断了。所以顺序消息场景下,队列数要提前规划好,运行时尽量不变

三、定时/延迟消息

1. 典型场景

  • 延迟消息:电商下单后 30 分钟未支付自动关闭。不能一下单就关,也不能靠定时任务轮询数据库。
  • 定时消息:分布式调度场景,每天 5 点执行文件清理、每隔 2 分钟触发一次消息推送。传统基于数据库的定时调度在分布式下性能差、实现复杂。

两者本质原理一样,都是「消息不是立刻可见,到了指定时间才投递」,一起讲。

2. 实现原理:闹钟机制

核心思路类似闹钟:生产者指定一个「投递时间」,Broker 收到后不立刻投递给消费者 ,而是先存到一个内部的定时任务队列,由定时服务(ScheduleMessageService)扫描,到期后再投回原 Topic 的目标队列,消费者这才看得到。

时间怎么指定? 用毫秒级的 Unix 时间戳:

  • 定时消息 :指定一个绝对时间点。比如当前 17:30,希望 19:20 投递,定时时间就是 2022-06-09 19:20:00,转成时间戳 1654773600000
  • 延迟消息 :指定一个相对时长。比如当前 17:30,希望 1 小时后投递,换算成定时时刻就是 2022-06-09 18:30:00,转成时间戳 1654770600000

3. 投递流程

整个流程分四步:

  1. 生产者发送定时/延迟消息,消息里带上目标投递时间戳。
  2. Broker 收到后,不更新原 Topic 的 ConsumeQueue (消费者看不到),而是把消息投到内部的 SCHEDULE_TOPIC_XXXX 队列。
  3. 定时服务(ScheduleMessageService)不断扫描 这个内部队列------默认每秒扫描一次 ,发现某条消息的投递时间到了,就把它改写回原 Topic 的目标队列,更新 ConsumeQueue。
  4. 消费者拉取时,才能看到这条消息,正常消费。

4. 延迟等级与 5.0 的改进

4.x 版本 :只支持 18 个固定延迟等级,不支持任意时间。等级表如下:

等级 延迟时长 等级 延迟时长
1 1s 10 6m
2 5s 11 7m
3 10s 12 8m
4 30s 13 9m
5 1m 14 10m
6 2m 15 20m
7 3m 16 30m
8 4m 17 1h
9 5m 18 2h

第四篇讲重试时用的就是这套等级------重试从第 3 级(10s)开始逐级递增。

5.0 版本 :支持任意时间戳 的定时/延迟消息,不再受 18 个等级限制。但有一个约束:定时时长最大默认 24 小时,不允许超过这个数------太远的定时任务应该用专业的调度系统(如 XXL-Job),不该压在 MQ 上。

四、事务消息

1. 为什么需要事务消息

以电商支付场景为例:用户支付成功后,要同时做四件事------更新订单状态、新增物流记录、变更用户积分、清空购物车。这四件事要么全成功、要么全不执行。

传统方案有两个坑:

XA 分布式事务:把四个调用封装成一个大事务,结果一致性有保障。但多分支环境下资源锁定范围大、并发度低,下游分支越多性能越差。

普通消息 + 本地事务:把订单变更作为本地事务,剩下的通过普通消息通知下游。但两头很难一致:

  • 消息发成功了,订单没执行成功 → 需要回滚整个事务,但消息已经出去了。
  • 订单执行成功了,消息没发成功 → 下游收不到通知,需要额外补偿才能发现不一致。
  • 消息发送超时未知 → 不知道是该回滚订单还是提交订单变更。

2. 事务消息的核心思路

RocketMQ 的事务消息解决的是**「本地事务成功 消息一定投出」**这个一致性问题。它不保证消费端的结果------消费端的可靠性仍靠第四篇讲的幂等。

核心流程分四步,用一张图看清楚:

3. 分步讲解

第一步:发送半消息(Half Message)

生产者向 Broker 发送一条半消息 。Broker 持久化成功,返回 ack------但注意,此时没有更新 ConsumeQueue ,消息对消费者不可见。这条消息就叫「半消息」,意思是「只写了一半,还不确定要不要投递」。

第二步:执行本地事务

生产者收到 ack 后,开始执行自己的本地事务(比如更新订单状态为「已支付」)。本地事务有两种结果:成功 (Commit)或失败(Rollback)。

第三步:二次确认

生产者根据本地事务的结果,向 Broker 发送二次确认:

  • Commit :本地事务成功了,Broker 把半消息变为可见消息------更新 ConsumeQueue,消费者可以拉取消费了。
  • Rollback :本地事务失败了,Broker 删除这条半消息,不做任何操作。消息永远不会被消费,一致性得到保障。

第四步:事务回查(兜底机制)

第三步的二次确认可能丢失(网络波动),或者生产者返回了 Unknown (不确定本地事务状态)。这时 Broker 不能干等------它有一个事务回查机制

  • 经过固定时间后,Broker 向生产者集群中的任意一个实例发起消息回查。
  • 生产者收到回查后,检查本地事务的最终状态(比如查订单表,看这笔订单到底支付成功没有)。
  • 根据检查结果,再次提交 Commit 或 Rollback。
  • 回查有最大次数限制 (默认 15 次 ),每次间隔默认 60 秒 。超过最大次数仍未确认,Broker 默认回滚这条半消息。

4. 事务超时

还有一个兜底:半消息被发送到 Broker 后,如果在指定时间内 (事务超时时间,默认 1 小时 )Broker 始终无法确认提交或回滚状态,消息默认被回滚。防止半消息无限期挂在 Broker 上。

5. 事务消息的边界

最后强调一点:事务消息只保障「本地事务」和「消息投递」的一致性。它不管消费端的结果------消费者消费失败、消费超时,事务消息不负责。消费端的可靠性,仍然要靠第四篇讲的幂等消费、重试机制来兜底。

对一致性要求极高的场景(比如金融类核心账务),事务消息的最终一致性可能不够,需要用传统的 XA 方案。这是取舍,不是缺陷。

五、消息过滤

1. Tag 过滤

这个前面其实已经讲过了(第四篇),这里收拢一下完整链路:

  • Broker 端粗筛:ConsumeQueue 索引里存了 Tag 的 HashCode(第三篇的 20 字节结构),Broker 拉取消息时先用 HashCode 粗筛,把明显不匹配的过滤掉。
  • 客户端精筛:拉回来的消息,客户端再做精确的 Tag 匹配,只把符合订阅关系的消息交给业务。

两层过滤配合,既减少了网络传输(Broker 端就过滤掉一批),又保证了准确性(客户端精筛兜底)。

2. SQL92 过滤

Tag 过滤只能做「等于」匹配,如果业务需要更复杂的条件------比如「金额大于 100 且来源是 APP 的消息」------就要用 SQL92 过滤

SQL92 过滤允许消费者在订阅时写 SQL 表达式,按消息的属性字段做条件筛选。比如:

sql 复制代码
amount > 100 AND source = 'APP'

Broker 端会解析这个表达式,拉取消息时按属性过滤。

代价:SQL92 过滤比 Tag 过滤重------Broker 需要解析 SQL、读取消息属性、逐条计算,CPU 开销更大。所以只在 Tag 过滤不够用时才上 SQL92,能不用就不用。

3. 两者取舍

Tag 过滤 SQL92 过滤
匹配方式 等于 表达式(>、<、AND、OR...)
过滤位置 Broker 端 HashCode 粗筛 + 客户端精筛 Broker 端按属性计算
性能 轻,几乎无额外开销 重,Broker 需要解析 SQL
适用场景 简单分类(如按业务类型分) 复杂条件(如按金额范围、来源组合筛选)

小结

这一篇讲了三种特殊消息和消息过滤:顺序消息 用「哈希路由 + 同步发送 + 分布式锁」解决生产端无序,用「按队列加锁 + 单线程消费」解决消费端无序,代价是重试阻塞和扩缩容破坏顺序;定时/延迟消息 用「SCHEDULE_TOPIC 中转 + 定时服务扫描」实现闹钟式投递,4.x 只有 18 个固定等级,5.0 支持任意时间戳(最大 24h);事务消息 用「半消息 + 本地事务 + 二次确认 + 事务回查」保障本地事务与消息投递的一致性,回查默认 15 次、间隔 60s、超时 1h 默认回滚;消息过滤有 Tag(轻)和 SQL92(重)两种,按需选用。

下一篇是系列的收官篇------可靠性全景,把第一篇的「四大问题」(消息丢失、重复消费、消息堆积、顺序问题)逐一收口,输出一张完整的可靠性 checklist。

相关推荐
第五页的你1 小时前
SpringBoot 源码理解
后端
RISCV_Explorer1 小时前
RISC-V处理器性能优化:从指令集到微架构的协同设计
后端·risc-v
她的男孩1 小时前
数据库改了配置,说好的 30 秒自动刷新根本没跑:那条 @Scheduled 是注释状态
java·后端·架构
君顾11 小时前
西安24小时自助健身房系统软件开发实战:需求分析与技术落地指南
java·开发语言·健身房
Dawson Zhu1 小时前
大模型 Agent 记忆系统五大技术路线解析与工程选型指南
人工智能·语言模型·架构·aigc·agi
右耳朵猫AI2 小时前
Rust周刊2026W38 | mold重写Rust、认证级Rust裸机、Slint 1.18发布、lint提速3133倍
后端·rust·系统编程
Dawson Zhu2 小时前
Palantir Foundry 架构深度解析:数据、本体与AI的三层协同
人工智能·语言模型·架构·aigc·agi