在排查 MQ 问题时,我最怕听到的一句话是:
"生产者明明只发了一条消息,为什么你们业务处理了两次?"
这句话听起来像是在怀疑 Kafka。
但大多数时候,Kafka 其实没那么冤,也没那么神秘。
真正出问题的地方,往往藏在一个很小的窗口里:
业务已经执行成功了,但 Offset 还没来得及提交。
只要这个窗口里发生宕机、重启、Rebalance,或者网络抖动,这条消息就可能重新被拉取一次。
于是,业务侧看起来就像"同一条消息被消费了两次"。
一、现场还原:同一个订单生成了两条分账单
假设有这样一条业务链路:
用户支付成功后,交易系统发送一条 Kafka 消息,分账服务消费这条消息,然后创建分账单。
消息内容大概是这样:
json
{
"eventId": "PAY_SUCCESS_202501010001",
"orderNo": "O202501010001",
"payAmount": 10000,
"eventType": "PAY_SUCCESS"
}
分账服务的消费逻辑也很常见:
scss
@KafkaListener(topics = "pay-success-topic", groupId = "profit-share-service")
public void consumePaySuccess(PaySuccessMessage message) {
log.info("收到支付成功消息,orderNo={}", message.getOrderNo());
Order order = orderMapper.selectByOrderNo(message.getOrderNo());
if (!OrderStatus.PAID.equals(order.getStatus())) {
return;
}
ProfitShareOrder shareOrder = new ProfitShareOrder();
shareOrder.setOrderNo(order.getOrderNo());
shareOrder.setAmount(order.getPayAmount());
shareOrder.setStatus(ShareStatus.INIT);
profitShareOrderMapper.insert(shareOrder);
orderMapper.updateStatus(order.getOrderNo(), OrderStatus.SHARING);
log.info("支付成功消息处理完成,orderNo={}", message.getOrderNo());
}
乍一看,这段代码没什么问题:
- 先查订单;
- 判断订单是否已支付;
- 创建分账单;
- 更新订单状态。
但某次发布后,业务同学反馈:同一个订单出现了两条待分账记录,金额一样,订单号一样,只是创建时间前后差了十几秒。
数据库里大概是这样:
ini
order_no = O202501010001, share_order_no = S001, amount = 10000
order_no = O202501010001, share_order_no = S002, amount = 10000
再看生产者日志,只打印了一次发送成功。
这时问题就变得很诡异:
Kafka 里明明只有一次业务事件,为什么分账单会生成两条?
二、先排除一个误区:不是两个消费者同时抢到了同一条消息
很多人看到"消费了两次",第一反应是:
是不是同一个 Consumer Group 里的两个消费者实例,同时消费了同一条消息?
正常情况下,不是。
在同一个 Consumer Group 内,一个 Partition 同一时刻只会分配给一个 Consumer 实例。
也就是说,如果一个 topic 的某个 partition 已经分配给 Consumer A,那么 Consumer B 不会在同一时刻也消费这个 partition 上的同一个 offset。
所以稳定状态下,不应该出现这种情况:
ini
Consumer A 正在消费 offset=100
Consumer B 也同时消费 offset=100
但这并不代表业务不会重复处理。
因为 Kafka 的一次消费,不是一个原子动作。
它至少包含三步:
rust
拉取消息 -> 执行业务逻辑 -> 提交 Offset
问题就出在第二步和第三步之间。
三、真正的危险窗口:业务成功了,Offset 没提交
我们把消费过程拆开看。
对于 Spring Kafka 这类常见用法来说,如果你没有手动提前提交 Offset,通常可以理解为:
Listener 方法正常执行完成后,再提交消费进度
于是,一次消费的顺序大概是:
markdown
1. 拉取消息
2. 执行业务代码
3. 方法执行完成
4. 提交 Offset
注意这里有一个非常关键的点:
业务数据库的提交,和 Kafka Offset 的提交,不在同一个事务里。
也就是说,下面这种情况完全可能发生:
sql
分账单已经插入成功
订单状态也已经更新成功
但 Kafka Offset 还没有提交成功
这时如果消费者实例刚好重启,或者发生 Rebalance,Kafka 并不知道你的业务已经处理完了。
它只知道:
上一次提交成功的 Offset 还停留在 99。
所以当新的消费者接管这个 partition 后,它会继续从 offset=100 开始拉取。
这条消息就又来了。
四、把时间线拉开看
假设支付成功消息所在的位置是 offset=100。
| 时间点 | Consumer A | Kafka 记录的 Offset | MySQL 业务数据 |
|---|---|---|---|
| T1 | 拉取 offset=100 的消息 | 已提交到 99 | 还没有分账单 |
| T2 | 查询订单,状态是 PAID | 已提交到 99 | 订单已支付 |
| T3 | 插入分账单 S001 | 已提交到 99 | S001 已落库 |
| T4 | 更新订单状态为 SHARING | 已提交到 99 | 订单进入分账中 |
| T5 | Consumer A 重启 / 宕机 / 触发 Rebalance | 仍然是 99 | 业务已经执行完 |
| T6 | Consumer B 接管 partition | 从 offset=100 继续消费 | S001 已存在 |
| T7 | Consumer B 再次执行同一段业务代码 | 准备提交 100 | 又插入 S002 |
| T8 | Offset 提交成功 | 已提交到 100 | 出现两条分账单 |
这个表里最关键的不是 T7,而是 T5。
在 T5 之前,业务已经成功了。
但从 Kafka 的视角看,这条消息还没有被确认消费。
所以它重新投递,并不奇怪。
五、根因:Kafka 只能记录消费进度,不能替业务保证只执行一次
这个问题的本质不是"Kafka 为什么重复投递"。
而是:
Kafka 的 Offset 只能表示消息消费到哪里了,不能表示你的业务影响是否已经成功落库。
Kafka 能帮你维护消费进度,但它不知道你在消费时做了什么。
你可能只是打印了一行日志。
也可能做了这些动作:
创建分账单
扣减库存
增加账户余额
发放优惠券
调用退款接口
发送短信通知
这些动作一旦执行成功,就已经对业务世界产生了影响。
而 Kafka Offset 的提交,只是另一个系统里的进度标记。
两者不是一回事。
所以在常规业务系统里,我们应该接受一个现实:
Kafka 消息被重复拉取是正常风险,业务逻辑必须能承受重复执行。
这也是为什么很多人说 MQ 消费要做幂等。
但"幂等"这个词太大了。
落到这个分账场景里,最少要先兜住两件事:
- 不能重复创建分账单;
- 不能重复推进订单状态。
六、第一道防线:业务唯一键,防止重复生成单据
这段代码最大的问题之一,是插入分账单之前只做了查询和状态判断。
ini
Order order = orderMapper.selectByOrderNo(message.getOrderNo());
if (!OrderStatus.PAID.equals(order.getStatus())) {
return;
}
profitShareOrderMapper.insert(shareOrder);
这在单线程下没问题。
但在重复消费、并发消费、服务重试的情况下,单纯靠 Java 判断不可靠。
真正应该兜底的是数据库唯一约束。
比如分账单表里加唯一索引:
scss
CREATE UNIQUE INDEX uk_order_no_share_type
ON t_profit_share_order(order_no, share_type);
这样即使同一条消息又执行了一次,第二次插入也会被数据库挡住。
代码可以变成:
kotlin
try {
profitShareOrderMapper.insert(shareOrder);
} catch (DuplicateKeyException e) {
log.warn("分账单已存在,忽略重复消息,orderNo={}", message.getOrderNo());
return;
}
这不是为了优雅。
而是因为"同一个订单不能创建两条同类型分账单"本来就是业务规则。
既然是业务规则,就不应该只写在 Java if 里,也应该写进数据库约束里。
七、第二道防线:状态推进必须带前置条件
除了分账单重复插入,状态更新也不能直接写成:
ini
UPDATE t_order
SET status = 'SHARING'
WHERE order_no = #{orderNo}
这种写法的问题是:不管订单当前是什么状态,它都会被更新成 SHARING。
更稳的做法是带上前置状态:
ini
UPDATE t_order
SET status = 'SHARING'
WHERE order_no = #{orderNo}
AND status = 'PAID'
然后根据影响行数判断是否推进成功:
ini
int rows = orderMapper.updateStatus(
message.getOrderNo(),
OrderStatus.PAID,
OrderStatus.SHARING
);
if (rows == 0) {
log.info("订单状态已变化,忽略本次消息,orderNo={}", message.getOrderNo());
return;
}
这样做的意义是:
只有订单还处于 PAID 状态时,才能被推进到 SHARING。
如果第一轮消费已经把订单推进到了 SHARING,那么第二轮重复消费再来时,这条 SQL 的影响行数就是 0。
业务自然不会继续往下走。
这类写法比"先查状态,再 update"更稳,因为判断和更新在同一条 SQL 里完成,减少了并发窗口。
八、这两个防线应该怎么组合
对于这个场景,我更倾向于这样处理:
markdown
1. 收到支付成功消息
2. 条件更新订单状态:PAID -> SHARING
3. 如果 rows = 0,说明订单已经被处理过,直接返回
4. 插入分账单,依靠 order_no + share_type 唯一索引兜底
5. 后续执行分账任务
6. Listener 正常结束后提交 Offset
示意代码:
c
@KafkaListener(topics = "pay-success-topic", groupId = "profit-share-service")
public void consumePaySuccess(PaySuccessMessage message) {
log.info("收到支付成功消息,orderNo={}, eventId={}",
message.getOrderNo(), message.getEventId());
int rows = orderMapper.updateStatus(
message.getOrderNo(),
OrderStatus.PAID,
OrderStatus.SHARING
);
if (rows == 0) {
log.info("订单状态已不是 PAID,忽略重复消息,orderNo={}", message.getOrderNo());
return;
}
try {
ProfitShareOrder shareOrder = buildShareOrder(message);
profitShareOrderMapper.insert(shareOrder);
} catch (DuplicateKeyException e) {
log.warn("分账单已存在,忽略重复创建,orderNo={}", message.getOrderNo());
return;
}
log.info("支付成功消息处理完成,orderNo={}", message.getOrderNo());
}
这里有两个关键点:
第一,订单状态推进用条件更新兜住。
第二,分账单创建用唯一索引兜住。
即使消息重复投递,也不会重复推进状态,更不会重复生成业务单据。
九、这套方案的边界
上面的方案能解决"重复消费导致重复生成分账单"这个问题,但它不是 MQ 幂等的全部。
它仍然有几个边界。
1. 如果一条消息会触发多个动作,单靠唯一索引不够
比如支付成功后,不只是创建分账单,还要:
发积分
发优惠券
通知商家
写资金流水
触发结算任务
这时每个动作都要有自己的幂等边界。
不能因为分账单挡住了重复插入,就认为整条消息幂等了。
2. 如果中间状态失败,需要补偿
比如订单状态已经从 PAID 更新为 SHARING,但插入分账单失败了。
这时消息如果直接失败重试,下一次进来发现订单已经不是 PAID,可能会直接返回,导致分账单永远没有创建。
所以真实生产里,还要设计中间态和补偿逻辑。
比如:
订单状态 SHARING
但分账单不存在
这种数据就应该被补偿任务扫描出来,重新创建分账单或标记异常。
3. 如果涉及外部接口,还需要 requestNo
如果消费消息后要调用第三方分账接口,只靠本地唯一索引也不够。
因为可能出现:
第三方调用成功
本地更新失败
消费者宕机
消息重新消费
再次调用第三方
这时外部请求必须带业务请求号,让第三方也能识别重复请求。
这已经是下一层问题了,可以单独展开。
十、再回到最开始的问题
Kafka 只发了一条消息,为什么业务侧消费了两次?
更准确地说,可能不是 Kafka "凭空制造了第二条消息"。
而是:
第一轮消费已经把业务做完了,但 Offset 没提交成功。
Kafka 认为这条消息还没被确认,于是重新交给消费者处理。
业务代码没有幂等兜底,于是第二次消费又产生了一次业务影响。
所以问题不在于"Kafka 为什么不保证我只处理一次"。
而在于:
只要业务动作和 Offset 提交不是一个原子事务,重复消费就是必须面对的正常风险。
对于核心业务来说,不能把"不会重复消费"当成前提。
更稳的做法是:
用状态条件更新控制状态推进
用唯一索引控制业务单据创建
用业务请求号控制外部调用
用补偿任务修复中间态
Kafka 负责把消息尽量可靠地送到消费者手里。
但消息最终会不会对业务产生重复影响,还是要靠业务系统自己兜住。