账户原来有 100 元,消费者收到一条入账 25 元的消息。MySQL 已经提交,余额变成了 125 元,监听器却返回了 RECONSUME_LATER。等 RocketMQ 把消息重新投过来,同一段入账代码又执行了一遍,余额到了 150 元。
消息重投时,消费者要先知道这笔钱有没有入账。把消费记录和余额更新放在同一个 InnoDB 事务里,再给消费记录加上事件唯一键,同样的重投只会留下第一次入账,余额仍是 125 元。
先把两种失败放在同一套环境里
实验用 Temurin JDK 21.0.13、RocketMQ 5.3.1(NameServer、Broker 和经典 Java 客户端)、MySQL 8.4.13 / InnoDB,JDBC 驱动是 Connector/J 8.4.0。消费者用 DefaultMQPushConsumer,配置集群消费、并发监听,单次回调只处理一条消息。四组各有一个独立账户,初始余额都是 10000 分。生产者对每组只发送一次,消息金额都是 2500 分。
两张表都用自增的 id 作主键。对照组只约束这个主键,同一个 event_id 可以插入两行。另一张表给 event_id 也加了唯一键。
CREATE TABLE received_without_unique (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(64) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE received_with_unique LIKE received_without_unique;
ALTER TABLE received_with_unique ADD UNIQUE KEY uq_event_id (event_id);
每次回调都先插入一条消费记录,再修改余额。代码在事务结束后用另一个连接查消费记录和余额,日志里的 credits 和 balance_cents 是数据库已提交的状态。RocketMQ 的 reconsume=0 是首次投递,reconsume=1 是第一次重投。
提交前回滚:重投还能完成第一次入账
第一次回调执行完两条写入后主动回滚,再返回 RECONSUME_LATER。下面是两组各自的首次消费和重投日志。
recv case=before_no_key event=payment-before_no_key msgId=AC150005000111524711444C453C0000 reconsume=0 action=ROLLBACK_BEFORE_COMMIT credits=0 balance_cents=10000 return=RECONSUME_LATER
recv case=before_no_key event=payment-before_no_key msgId=AC150005000111524711444C453C0000 reconsume=1 action=COMMIT credits=1 balance_cents=12500 return=CONSUME_SUCCESS
recv case=before_unique event=payment-before_unique msgId=AC150005000111524711444C991D0001 reconsume=0 action=ROLLBACK_BEFORE_COMMIT credits=0 balance_cents=10000 return=RECONSUME_LATER
recv case=before_unique event=payment-before_unique msgId=AC150005000111524711444C991D0001 reconsume=1 action=COMMIT credits=1 balance_cents=12500 return=CONSUME_SUCCESS
两组回滚后查到的余额都是 100 元,消费记录也都是 0 条。重投时,用同一个事件 ID 插入消费记录仍然成功,余额随后变成 125 元。去重记录要跟着余额更新一起回滚。否则下一次收到同一事件时,消费者可能误以为已经入账。
提交后重投:两张表的结果拉开了差距
第二轮把失败点放在 COMMIT 之后。第一次回调已经更新余额,但仍主动返回 RECONSUME_LATER,由 Broker 重新投递。
recv case=after_no_key event=payment-after_no_key msgId=AC150005000111524711444CA5310002 reconsume=0 action=COMMIT_THEN_FORCE_RETRY credits=1 balance_cents=12500 return=RECONSUME_LATER
recv case=after_no_key event=payment-after_no_key msgId=AC150005000111524711444CA5310002 reconsume=1 action=COMMIT credits=2 balance_cents=15000 return=CONSUME_SUCCESS
recv case=after_unique event=payment-after_unique msgId=AC150005000111524711444CB1680003 reconsume=0 action=COMMIT_THEN_FORCE_RETRY credits=1 balance_cents=12500 return=RECONSUME_LATER
recv case=after_unique event=payment-after_unique msgId=AC150005000111524711444CB1680003 reconsume=1 action=DUPLICATE_1062 credits=1 balance_cents=12500 return=CONSUME_SUCCESS
两组第一次投递都把余额加到了 125 元。差别出在 reconsume=1。对照组又插入一条消费记录,再加了 25 元。有唯一键的那组在 INSERT 时就收到 MySQL 错误 1062,随即回滚这次事务。原先提交的那笔入账还在,监听器返回 CONSUME_SUCCESS。
实验结束后,在 MySQL 执行下面的汇总查询。每条已提交的消费记录对应一次同事务内的余额更新。
WITH receipts AS (
SELECT event_id FROM received_without_unique
UNION ALL
SELECT event_id FROM received_with_unique
)
SELECT a.scenario, COUNT(r.event_id) AS committed_credits, a.balance_cents
FROM account AS a
LEFT JOIN receipts AS r ON r.event_id = CONCAT('payment-', a.scenario)
GROUP BY a.scenario, a.balance_cents
ORDER BY FIELD(a.scenario, 'before_no_key', 'before_unique', 'after_no_key', 'after_unique');
+---------------+-------------------+---------------+
| scenario | committed_credits | balance_cents |
+---------------+-------------------+---------------+
| before_no_key | 1 | 12500 |
| before_unique | 1 | 12500 |
| after_no_key | 2 | 15000 |
| after_unique | 1 | 12500 |
+---------------+-------------------+---------------+
把去重判断放在入账之前
用同一个 JDBC 连接执行两条 SQL。关闭自动提交后,先插入消费记录,插入成功再更新余额,最后一起提交。代码只在这次 INSERT 上判断重复键。余额更新遇到异常时,外层会回滚并让消息重试。这里的 scenario.table() 只会选中实验的两张固定表。
try (var insert = conn.prepareStatement(
"INSERT INTO " + scenario.table() + " (event_id) VALUES (?)")) {
insert.setString(1, scenario.eventId());
insert.executeUpdate();
} catch (SQLException error) {
// Only this INSERT can mean the event's receipt already exists.
if (scenario.unique() && error.getErrorCode() == 1062
&& "23000".equals(error.getSQLState())) {
conn.rollback();
return "DUPLICATE_1062";
}
throw error;
}
try (var update = conn.prepareStatement(
"UPDATE account SET balance_cents = balance_cents + ? WHERE scenario = ?")) {
update.setInt(1, cents);
update.setString(2, scenario.name());
if (update.executeUpdate() != 1) {
throw new SQLException("expected one account row");
}
}
if (attempt == 0 && scenario.fault() == Fault.BEFORE_COMMIT) {
conn.rollback();
return "ROLLBACK_BEFORE_COMMIT";
}
conn.commit();
生产者把付款事件的 event_id 放进消息 Key,同一事件重试时沿用这个 ID。一张订单可能有多个事件。用订单号去重,后续的合法事件也可能被跳过。如果同一张消费记录表服务多个独立处理器,可以按各自的业务语义设计 (consumer_name, event_id) 这样的复合唯一键。例子里的消费记录表除了自增主键,只有 event_id 这一项业务唯一约束。如果表里还有其他唯一约束,捕获 1062 时要先判断撞的是哪一个键。
如果先写 Redis 的“已处理”标记,再提交 MySQL,MySQL 一旦回滚,重投就可能被留下的缓存标记挡住。这个例子只用 MySQL 判定入账是否已经完成。给外部支付接口发请求之类的副作用,需要按那个接口自己的幂等协议设计。
排查从事件 ID 开始
先查同一业务事件的消费记录和账务流水,确认第一次写入到底有没有提交。再看消费者日志里的事件 ID、Message ID、重投次数和返回状态,把这些记录按先后排在一起。如果有两次已提交的入账,顺着事务代码检查唯一约束是否设在正确的事件字段上,以及去重记录和业务写入是不是一起提交。如果只看到重投,先确认余额是否真的变过。
这次用经典并发消费回调主动触发重试,验证了单库事务中的入账次数。5.3.1 经典客户端在集群模式下会把失败的消息送回 Broker,实验没有注入真实进程崩溃或网络丢包。部署中的重试间隔还要按对应 Broker 配置核对。 RocketMQ 5.3.1 并发消费源码
- 对照课程中的监听器初始化和消费返回值。 Spring Cloud Alibaba 实战第 44 讲 · RocketMQ 消费重试
- 查事务和唯一键的数据库行为。 MySQL 事务隔离实测 · MySQL 8.4 事务语句 · MySQL 8.4 主键与唯一键约束
完整消费者日志 · MySQL 原始输出 · 下载复现工程