作者:谢若衡-558|8 年中间件研发|消息队列方向 栈:RocketMQ 5.1.4(namesrv 2 + broker 3,16C64G)、Spring Boot 2.7 + rocketmq-client 5.1.4、Java 11、MySQL 8.0 兜底 场景:支付域「扣款成功 → 发对账事件」,producer 用 RocketMQ 事务消息保证「本地扣款记录 + 事件」原子可见
一、现象与背景:对账缺了 412 笔,但 lag、错误率、告警全绿
去年 11 月,我们对账系统发现某日 412 笔扣款记录没有对应的「对账事件」——库里扣款流水在、金额平,但下游清算链路静默断掉,T+1 才由人工对账捞出来。更反直觉的是:
- consumer lag 全程 <500,没有任何堆积告警;
- producer 侧错误日志为 0,
TransactionSendResult全部返回COMMIT_MESSAGE; - broker 的
store目录里,RMQ_SYS_TRANS_HALF_TOPIC对应的 commitlog 段当天多出约 310MB 写入,但没有任何一个业务 consumer 能看到这些消息。
根因:当天发布窗口内一个实例 JVM 挂死(死锁在业务锁上,进程活着、健康检查 200),恰好卡在「本地事务已提交、executeLocalTransaction 尚未返回」与「broker 回查请求打不进来」的缝隙里。412 笔就是这 23 分钟缝隙内的全量。
这篇文章把 RocketMQ 事务消息(half message)的两个隐藏窗口讲透:回滚不是对称的——「回查返回 UNKNOW 15 次」和「回查请求根本打不通」是两条完全不同的路径,超时时间差了 4 个数量级。
二、原理:half 消息到底存在哪里,两条回滚路径的机制差异
2.1 三步协议与队列映射
RocketMQ 事务消息的完整链路:
- producer 发 half 消息到内部 topic
RMQ_SYS_TRANS_HALF_TOPIC(业务 consumer 不可见); - producer 执行本地事务(写库),返回
COMMIT_MESSAGE/ROLLBACK_MESSAGE/UNKNOW; - 若返回 COMMIT,broker 把 half 消息在 commitlog 中标记并提交到真实 topic(对业务 consumer 可见);若 UNKNOW 或无响应,broker 走**回查(checkLocalTransaction)**路径。
关键点:half 消息的「提交」只是 commitlog 里的一个状态位翻转,消息体本身从未移动。这也是为什么 broker 侧能看到写入量(310MB),而任何业务监控都感知不到。
2.2 两条回滚路径(核心)
路径 A:回查有响应但回答 UNKNOW
- broker 首次回查间隔
transactionCheckInterval=60s(5.x 默认),之后每次重试间隔 1s; - 重试上限
transactionCheckMax=15次; - 15 次 UNKNOW 后 broker 执行回滚:half 消息被丢弃(commitlog 中标记 DELETED),全程最长约 76s(60s + 15×1s + 余量)。
路径 B:回查请求无响应(producer 进程挂死 / 网络分区 / checkLocalTransaction 线程池打满)
- broker 无法区分「UNKNOW」和「打不通」,只能等;
- 回滚条件是 half 消息创建时间超过
transactionTimeout,5.x 默认 6 小时(4.x 是 24 小时,很多人拿 4.x 文档套 5.x,这是第一个大坑); - 也就是说:进程挂死场景下,half 消息最长滞留 6 小时,期间既不提交也不回滚,对账窗口内下游完全无感知。
2.3 为什么 lag 监控天然看不见
half 消息在 RMQ_SYS_TRANS_HALF_TOPIC,这个 topic 不对任何业务 consumer group 暴露,consumer progress 命令查不到它。业务侧所有「lag 正常」的告警,对这条路径全部失效。这是事务消息方案与「本地消息表」方案最本质的监控差异——本地消息表的未发送行在你的库里,能 count;half 消息在 broker 的 commitlog 里,你得专门去查。
三、具体做法:监控、参数与兜底(可复现)
3.1 把「half 消息滞留时长」做成一级监控
# 查 half topic 的堆积(5.x,broker 端)
# 路径:$ROCKETMQ_HOME/brokers/<brokerId>/store/ 下对应 commitlog 段
# 更可靠的方式:用 broker 的 stats 接口或自建 exporter 抓取
# 关注指标:RMQ_SYS_TRANS_HALF_TOPIC 的 msgsNum 与 minStoreTimestamp
# 告警规则:当前时间 - minStoreTimestamp > 10min 即 P2
# 当前时间 - minStoreTimestamp > 60min 即 P1(说明回查链路可能已断)
落到 Prometheus 的一条告警(示意):
- alert: HalfMessageStuck
expr: time() * 1000 - rocketmq_half_topic_min_store_timestamp_ms > 10 * 60 * 1000
for: 2m
labels: {severity: P2}
这条告警是我们复盘后补的第一件事——它替代了「lag 正常 = 链路正常」的错误假设。
3.2 参数与代码约束
// producer 侧:给回查留一个确定性的本地状态机
// 本地事务表必须带 msgId(half 消息的 msgId 在 TransactionSendResult 里)
// 结构:(biz_id PK, msg_id, state IN ('PREPARED','COMMITTED','ROLLED_BACK'), gmt_create)
// checkLocalTransaction 的实现纪律:
// 1. 只查本地事务表,绝不调下游 RPC(超时会让路径 A 退化成路径 B)
// 2. 查不到记录 → 返回 ROLLBACK_MESSAGE(不是 UNKNOW!)
// 依据:「查不到」= 本地事务没执行 = 语义上就该回滚,返回 UNKNOW 白白浪费 15 次重试
// 3. 记录在 → 按 state 字段返回 COMMIT / ROLLBACK
// 4. 整个方法加 3s 超时熔断,超时兜底返回 UNKNOW
参数侧(5.x broker 配置):
| 参数 | 默认 | 我们的值 | 理由 |
|---|---|---|---|
| transactionCheckInterval | 60s | 30s | 缩短路径 A 首轮回查延迟,对账窗口更短 |
| transactionCheckMax | 15 | 15 | 保持默认,15 次 UNKNOW 已是明确信号 |
| transactionTimeout(half 消息过期) | 6h | 1h | 支付域对账 T+0,6h 的滞留窗口不可接受;1h 内必须人工介入 |
| producer checkLocalTransaction 线程池 | 1 | 8 + 队列 1024 | 默认单线程,一次回查风暴(如 3000 条 half)就能把队列打满,退化成路径 B |
3.3 发布纪律:禁止「先 kill 再等回查」
我们规定:任何 producer 实例的发布 / 重启,必须先 drain(停止接单 → 等待 in-flight 事务消息全部拿到 COMMIT/ROLLBACK 终态)→ 再 kill。用 arthas 确认:
# 确认无 in-flight 事务
ognl '#com.xxx.TransactionRegistry@inflight.size()'
# 返回 0 才允许 kill -15
四、踩过的坑 / 反直觉清单
transactionTimeout5.x 默认 6h,不是 4.x 文档里的 24h,更不是「分钟级」。我们最初按 4.x 心智设告警阈值 30min,结果路径 B 场景下告警根本不响——因为回滚发生在 6h 后,而 half 消息在 6h 前一直是「合法存在」的。- 回查线程池默认 1 线程。一次 broker 重启触发 3000+ half 消息集中回查,单线程 3s/次 的
checkLocalTransaction要跑 150 分钟——期间所有消息都处于「既没提交也没回滚」的悬置态。这条是最容易在生产踩到的,因为它只在 broker 重启 / 网络抖动时暴露。 - 「查不到本地记录」返回 UNKNOW 是错误写法。UNKNOW 语义是「我还不知道」,会触发 15 次重试;而「查不到」的语义明确就是回滚。白白多占 15s + 15 次回查资源。
- lag 监控对事务消息的 half 阶段完全失明。这是结构性盲区,不是配置问题——任何「看 lag 判断链路健康」的 SOP 在事务消息场景下都要补一条 half 滞留检查。
- 进程「活着但挂死」比进程死了更危险:健康检查 200、端口通、但
checkLocalTransaction打不进来 → 路径 B → 6h 悬置。进程死了反而快(namesrv 摘流 + 路径 B 的 6h 里有其他实例兜底)。
五、结论与可复用清单
- 结论:RocketMQ 事务消息的可靠性边界不在「协议设计」,而在回查链路的可用性。路径 A(UNKNOW 15 次回滚)是快的,路径 B(进程挂死等 6h)是慢的且对业务监控不可见——你的 SLA 取决于两条路径中更慢的那条。
- 可复用清单:
- 上线前给
RMQ_SYS_TRANS_HALF_TOPIC加 minStoreTimestamp 滞留告警(阈值 < transactionTimeout); transactionTimeout按业务对账窗口显式设置,不要吃默认值;- 回查线程池 ≥8,
checkLocalTransaction只查本地库 + 3s 熔断; - 「查不到本地记录」→ ROLLBACK,不是 UNKNOW;
- 发布 SOP 加「inflight 事务消息 = 0 才允许 kill」检查项;
- 监控大盘上把「half 滞留时长」与「consumer lag」并排展示,破除「lag 正常 = 链路正常」的默认假设。
- 上线前给
- 适用边界:本文基于 RocketMQ 5.1.4;4.x 的
transactionTimeout默认 24h、回查参数名有差异,套用前请先核对自己版本的 broker 配置默认值。