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

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

相关推荐
文心快码BaiduComate1 小时前
提升组织级AI Coding质量:电商搜索项目实践
前端·后端·程序员
用户8356290780511 小时前
Python 操作 Word 修订跟踪(Track Changes)
后端·python
記億揺晃着的那天2 小时前
告别误操作!Spring Boot 多环境配置隔离与启动守卫实战
java·spring boot·后端·环境隔离
YuePeng2 小时前
凌晨 3 点告警群炸了,我用浏览器干了原本 XShell 才能干的事
后端·github
染翰2 小时前
Nacos 切换 Namespace 后配置不生效、占位符报错终极复盘
java·后端·spring·nacos
阿正的梦工坊3 小时前
【Rust】19-FFI、ABI 与跨语言边界设计
开发语言·后端·rust
fox_lht3 小时前
第十五章 函数式语言:迭代器和闭包
开发语言·后端·学习·算法·rust
码不停蹄的玄黓3 小时前
Spring Boot 实现过滤器(Filter)三种常用方式
java·spring boot·后端
悟空瞎说4 小时前
PM2 最全常用命令详解
后端