Kafka 只发了一条消息,为什么业务侧消费了两次?

在排查 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());
}

乍一看,这段代码没什么问题:

  1. 先查订单;
  2. 判断订单是否已支付;
  3. 创建分账单;
  4. 更新订单状态。

但某次发布后,业务同学反馈:同一个订单出现了两条待分账记录,金额一样,订单号一样,只是创建时间前后差了十几秒。

数据库里大概是这样:

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 消费要做幂等。

但"幂等"这个词太大了。

落到这个分账场景里,最少要先兜住两件事:

  1. 不能重复创建分账单;
  2. 不能重复推进订单状态。

六、第一道防线:业务唯一键,防止重复生成单据

这段代码最大的问题之一,是插入分账单之前只做了查询和状态判断。

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 负责把消息尽量可靠地送到消费者手里。

但消息最终会不会对业务产生重复影响,还是要靠业务系统自己兜住。

相关推荐
豆角焖肉2 小时前
SpringBoot 集成腾讯 AI 人脸识别
人工智能·spring boot·后端·人脸识别·腾讯 ai·base64 图片
付威20232 小时前
如果你想拥有自己的 Agent,为什么值得从 rpi 开始
后端
可乐ea3 小时前
2000 个智能体把 Agent 重写成 Rust:编排流水线拆解
开发语言·后端·rust·多智能体协作·ai编程助手·智能体编排·rust重写
用户72757483196503 小时前
02-变量、常量和字符串:len("Hello, 世界") 为什么不是 9?
后端
SamDeepThinking3 小时前
CyclicBarrier的底层实现
java·后端·面试
god_cvz3 小时前
第二章:6、对话记忆与引用标注
后端·ai编程
拖孩3 小时前
全程 AI 写的小程序,刚接上会员虚拟支付,上线第二天就有人真金白银买单了|双签名、定价、iOS 的坑我全踩了一遍
前端·后端·微信小程序
我是人✓3 小时前
SpringMVC异常处理与拦截器实战揭秘
java·开发语言·后端
仟里码3 小时前
九:goroutine(1.什么是协程)
后端
不是万能的豆学长3 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go