RocketMQ 5.5.1:队列、顺序延时、事务回查与存储复制
Kafka 用一条日志分区的七个成功点讲过投递与确认;RabbitMQ 用三种确认讲过路由与死信。RocketMQ 的模型又不同:所有消息先顺序写进一份 CommitLog,再按队列建索引;事务消息用"半消息 + 本地事务 + 回查"解决"本地事务与发送不同步"的窗口。本页用本机真实 RocketMQ 5.5.1(NameServer + 单 Broker)跑通生产/消费、tag、延时、顺序、重试与事务六个场景,并把存储与复制的边界单独标清。
本页是 U59(#96)的 E03 权威正文:生产/消费模型、队列、重试/顺序/延时、事务回查、存储/复制恢复的 5.5.1 语义。事务消息的通用协议与 Outbox/CDC/XA/Saga 的取舍仍在Outbox、事务消息与最终一致性;Kafka 深度见 MQ 生产存储消费可靠性;RabbitMQ 的 exchange/confirm/DLX 见 RabbitMQ 路由、确认与恢复;重试退避方法论见重试、死信、延迟消息与消息积压。本页只讲 RocketMQ 的实现语义与运行证据。
与 Kafka、RabbitMQ 的模型差异
| 维度 | Kafka | RabbitMQ | RocketMQ |
|---|---|---|---|
| 存储 | 每分区一份日志 | 每队列独立存储 | 全局 CommitLog + 每队列 ConsumeQueue 索引 |
| 顺序单位 | 分区 | 队列 | MessageQueue |
| 消费单位 | 消费组 + 分区分配 | 队列 + 消费者 | 消费组 + 队列分配 |
| 路由 | 无(按 key 选分区) | exchange + binding | Topic + Tag 过滤 |
| 延时 | 无内建 | TTL + DLX 近似 | delayTimeLevel / 定时消息 |
| 事务 | 事务消息(EOS) | publisher confirm(非事务) | 半消息 + 回查 |
| 重试 | 重试 topic / 暂停分区 | nack + DLX | %RETRY% 队列 + %DLQ% |
这张表只做定位,不替代各课。RocketMQ 最独特的两点是:一份 CommitLog 承载所有队列(写放大低、但单机磁盘是共享瓶颈),以及事务回查(broker 主动问生产者"那笔本地事务到底成没成")。
版本、工程与观察入口
固定 Apache RocketMQ 5.5.1(官方发行包,仓库外缓存)、rocketmq-client 5.5.1(与服务端同版本)、BellSoft Liberica JDK 21.0.11+11-LTS、Maven 3.9.9、macOS 27.2 arm64。查询与运行日期 2026-10-29。
公开入口:
python3 labs/rocketmq-basics/run.py脚本启动本机 NameServer(9876)与单 Broker(10911,ASYNC_MASTER + ASYNC_FLUSH,存储目录临时),预创建实验主题,运行 6 个测试,结束后 mqshutdown 停止并确认端口释放。启动前检查 9876/10911,被占用即拒绝启动,不触碰其他 RocketMQ。完整命令、退出码与 11 条观察行见运行证据,工程边界见实验说明。
生产与消费模型
NameServer、Broker 与 Topic 路由
RocketMQ 的部署里 NameServer 是无状态路由表:Broker 启动后向所有 NameServer 注册 Topic 路由,客户端发消息前先问 NameServer"这个 Topic 在哪些 Broker、哪些队列",再直连 Broker。NameServer 之间不通信,所以多 NameServer 只是冗余,不是一致性集群。
这个模型意味着:Broker 挂了但 NameServer 还留着旧路由时,客户端会拿到失效地址。生产里靠客户端定时拉取路由(默认 30s)与 Broker 心跳续约来收敛;本实验单节点,未运行多 NameServer 与路由失效场景。
Topic 与 MessageQueue
一个 Topic 被切成多个 MessageQueue(默认 4 读 4 写)。队列是顺序与并行单位:
- 顺序:同一队列内 FIFO;
- 并行:不同队列可被不同消费者同时消费;
- 分配:集群消费下,队列在消费组内按策略分给消费者实例。
生产者默认轮询选队列,也可以自定义 MessageQueueSelector 把同一业务键固定到同一队列——这是"顺序消息"的实现基础。
集群消费与广播消费
| 模式 | 队列分配 | 位点存储 | 一条消息 |
|---|---|---|---|
| CLUSTERING(默认) | 组内分配,每个队列一个消费者 | Broker 端 | 组内只消费一次 |
| BROADCASTING | 每个消费者都收全部队列 | 客户端本地 | 每个消费者各消费一次 |
本实验全部用 CLUSTERING。广播模式未运行。
位点与业务提交
集群消费的位点存在 Broker(ConsumerOffsetManager),提交策略有定时/异步/同步;CONSUME_FROM_LAST_OFFSET、CONSUME_FROM_FIRST_OFFSET、CONSUME_FROM_TIMESTAMP 决定新消费组从哪开始。位点提交与业务提交不是一件事:位点先提交、业务后失败 = 丢消息;业务先提交、位点后提交崩溃 = 重投。这与 RabbitMQ 的 ack 时机问题同构,处理方式也一样:业务提交后再提交位点,并让业务幂等。
顺序、延时与重试
顺序消息
实验 orderedMessagesStayInQueueOrder:6 条消息用同一 selector 固定到 mqs.get(0)(第一个队列),顺序消费者收到 shard-0..shard-5 严格递增。
顺序的边界:
- 只保证同一队列内 FIFO;跨队列无全局顺序。
- 全局顺序 = 单队列(Topic 建 1 个队列),代价是并行度归零。
- 顺序消费失败时默认本地重试(阻塞该队列),不会立刻转重试队列——顺序与高可用需要权衡。
延时消息
实验 delayMessageIsInvisibleBeforeDeliverTime:消息设 delayTimeLevel=3(10s 等级),3 秒内不可见,实际延迟约 11.6s(含调度精度)。
delayTimeLevel 是固定等级(1s/5s/10s/30s/1m…18 级);5.x 另支持 deliverTimeMs 任意定时(需 Broker 开启定时消息能力),本实验未运行任意定时。延时消息不是"消费者 sleep"——它在 Broker 侧到点才进入可投递状态。
消费重试与死信
实验 failedConsumptionGoesToRetryQueue:消费者第一次返回 RECONSUME_LATER,消息进入 %RETRY%<consumerGroup> 重试队列,按等级递增间隔后重投;第二次返回 CONSUME_SUCCESS 结束。观察行里可见 retryQueue=%RETRY%...-retry-group 与 attempts≥2。
超过 maxReconsumeTimes(默认 16,实验设为 3)后进入 %DLQ%<consumerGroup> 死信队列。死信队列默认需要手动创建/订阅,本实验未触发 DLQ 路径(只验证了重试),这一点在未运行清单标注。
事务消息与回查
两个阶段与本地事务
事务消息解决的是"本地数据库事务"与"发消息"不能原子提交的问题。发送方调用 sendMessageInTransaction,客户端先发一条半消息(对消费者不可见),再执行本地事务,最后按结果提交或回滚:
public TransactionSendResult sendMessageInTransaction(final Message msg,
final TransactionListener localTransactionListener, final Object arg)
throws MQClientException {
TransactionListener transactionListener = getCheckListener();
if (null == localTransactionListener && null == transactionListener) {
throw new MQClientException("tranExecutor is null", null);
}
ensureNotDelayedForTransactional(msg);
Validators.checkMessage(msg, this.defaultMQProducer);
SendResult sendResult = null;
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_TRANSACTION_PREPARED, "true");
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_PRODUCER_GROUP, this.defaultMQProducer.getProducerGroup());
try {
sendResult = this.send(msg);
} catch (Exception e) {
throw new MQClientException("send message Exception", e);
}
LocalTransactionState localTransactionState = LocalTransactionState.UNKNOW;
Throwable localException = null;
switch (sendResult.getSendStatus()) {
case SEND_OK: {
try {
if (sendResult.getTransactionId() != null) {
msg.putUserProperty("__transactionId__", sendResult.getTransactionId());
}
String transactionId = msg.getProperty(MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX);
if (null != transactionId && !"".equals(transactionId)) {
msg.setTransactionId(transactionId);
}
if (null != localTransactionListener) {
localTransactionState = localTransactionListener.executeLocalTransaction(msg, arg);
} else {
log.debug("Used new transaction API");
localTransactionState = transactionListener.executeLocalTransaction(msg, arg);
}三段结构清楚:先 PROPERTY_TRANSACTION_PREPARED=true 标记半消息并发送,SEND_OK 后执行本地事务拿到 LocalTransactionState(COMMIT/ROLLBACK/UNKNOW),随后 endTransaction 把决议回报 Broker。
实验 transactionMessageCommitsAfterLocalTransaction 让本地事务恒返回 COMMIT_MESSAGE:半消息发出后提交,消费者收到 tx-commit-...。这条验证了"提交后才可见"的主路径。
回查:Broker 主动问生产者
如果提交/回滚的 RPC 丢了,或生产者进程崩溃,Broker 看到的半消息状态是 UNKNOW。这时 Broker 会回查(checkLocalTransaction)生产者:按事务 id 问"那笔本地事务到底成没成"。
回查的工程要求:
- 生产者必须持久记录本地事务结果,并能按事务 id 查询——回查不是重做事务,是查结果。
- 回查次数有限(默认 15 次,间隔递增),超过后按配置丢弃或投递。
- 事务消息不提供端到端 exactly-once:消费端仍需幂等(见重复消费、幂等与顺序消息)。
本实验注册了 checkLocalTransaction 但未触发真实回查路径(需要构造提交 RPC 丢失或半消息后进程崩溃),未运行项单列。
存储与复制恢复
CommitLog 与 ConsumeQueue
RocketMQ 的存储是两层:
| 结构 | 内容 | 写入 | 读取 |
|---|---|---|---|
| CommitLog | 所有消息的完整内容,单文件顺序追加(默认 1GB 一段) | 所有 Topic/队列混写 | 按物理偏移读 |
| ConsumeQueue | 每个队列的索引:消息偏移、大小、tag hash | 异步从 CommitLog 构建 | 先查索引再读 CommitLog |
这个设计把"写"变成全顺序追加(磁盘友好),代价是读放大:一条消息要读两次(索引 + 日志)。实验观察 storage=PASS commitlogFiles=2 consumeQueueTopics=6——2 个 CommitLog 文件(消息量小但分了段)、6 个 Topic 的 ConsumeQueue。
刷盘与复制
| 配置 | 语义 | 代价 |
|---|---|---|
| SYNC_FLUSH | Broker 确认前落盘 | 延迟高、吞吐低 |
| ASYNC_FLUSH | 先写 page cache,后台刷盘 | 崩溃丢最近消息 |
| SYNC_MASTER | 从节点确认后才算成功 | 主从延迟进入发送延迟 |
| ASYNC_MASTER | 主节点确认即成功 | 主挂丢未复制消息 |
本实验用 ASYNC_MASTER + ASYNC_FLUSH(单节点,无副本可同步)。这组配置下 Broker 崩溃可能丢最近消息、主挂无从可切——所以实验只证明"消息被接收并可消费",不证明持久性承诺。
恢复边界
- 主从切换(DLedger/Controller 模式)未运行;
- Broker 崩溃/断电后的 CommitLog 恢复未运行;
- 同步刷盘、同步复制的性能与承诺未运行。
"RocketMQ 不丢消息"是配置的函数,不是产品名:ASYNC_FLUSH + ASYNC_MASTER 的单节点就是可能丢。生产要按 RPO 选刷盘/复制组合,并为每种组合单独验证恢复。
观测、排障与恢复顺序
RocketMQ 出问题的排查顺序:
- 看 NameServer 路由:
mqadmin topicRoute -t <topic>,确认队列在哪些 Broker、读写队列数。 - 看消费进度:
mqadmin consumerProgress -g <group>,Diff Total是堆积量;OFFLINE说明消费者没连上。 - 看 Topic 状态:
mqadmin topicStatus -t <topic>,Min/Max Offset 与最后更新时间。 - 看重试与死信:
mqadmin topicList里%RETRY%/%DLQ%是否有积压。 - 看事务消息:
mqadmin transactionList/事务状态表,确认半消息是否卡在 UNKNOW。 - 看存储:
mqadmin brokerStatus、磁盘使用率、CommitLog 文件数与保留时间。
恢复顺序:先确认 NameServer 路由正确(否则重发也找不到 Broker),再确认消费者在线(否则堆积继续),再处理重试/死信(按原因分类),最后核对刷盘/复制承诺(重启后损失比例)。反过来做等于用新错误掩盖旧错误。
实验读法
python3 labs/rocketmq-basics/run.py 输出 11 条观察行:
| 观察行 | 含义 |
|---|---|
U59 clusterStart=PASS / clusterStop=PASS | NameServer + Broker 启停 |
U59 brokerVersion=PASS V5_5_1 | 服务端版本 |
U59 normal=PASS | 普通消息发送与集群消费 |
U59 tagFilter=PASS | Tag 过滤(订阅全量 + 本地按本轮 tag 过滤) |
U59 delay=PASS actualDelayMs≈11.6s | delayTimeLevel=3 的延时可见性 |
U59 ordered=PASS | 同队列 FIFO |
U59 retry=PASS attempts≥2 | RECONSUME_LATER 进入 %RETRY% 后重投成功 |
U59 transaction=PASS | 半消息 → 本地事务提交 → 消费者可见 |
U59 storage=PASS | CommitLog/ConsumeQueue 文件存在 |
U59 tests=PASS count=6 | 全部测试通过 |
闭卷验收
本页 24 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
未运行与不能由本页证明的
- 主从切换(DLedger/Controller 模式)、多 Broker 集群、跨机房复制未运行。
- Broker 崩溃/断电后的 CommitLog 恢复、ConsumeQueue 重建未运行。
- 同步刷盘(SYNC_FLUSH)与同步复制(SYNC_MASTER)的性能与承诺未运行。
- 事务回查的真实触发路径(提交 RPC 丢失、半消息后崩溃)未运行;只验证了 COMMIT 主路径。
- 死信
%DLQ%路径未触发(只验证%RETRY%);广播消费、批量消息、消息轨迹、ACL、管理控制台未运行。 - 任意定时消息(deliverTimeMs)未运行;只用 delayTimeLevel 固定等级。
- 教材完成不等于学习者掌握;未运行条件不得由"命令写好"或"测试通过"推出。