加了幂等表,为什么消息重试反而不执行了?聊聊 MQ 消费幂等的边界

上一篇我们聊到一个现象:

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

问题的关键在于:业务已经执行成功了,但 Offset 还没提交成功。只要这个窗口里发生宕机、重启、Rebalance,这条消息就可能重新被拉取。

所以很多人会很自然地想到一个方案:

那我加一张幂等表不就行了?

收到消息时先查幂等记录。

如果没处理过,就插入一条记录,然后执行业务。

如果处理过,就直接返回。

这个思路听起来非常合理。

但在真实业务里,它还有一个很隐蔽的问题:

幂等记录存在,不代表业务真的处理成功了。

如果没有处理好这个细节,幂等表不但挡住了重复消息,也可能把本来应该重试的消息一起挡掉。

最后你会看到一个更诡异的现象:

MQ 明明重试了,日志也显示"消息已处理过",但业务数据就是卡在中间态。


一、现场还原:订单进入分账中,但分账单没了

还是用支付成功后的分账链路举例。

用户支付成功后,交易系统发送一条 PAY_SUCCESS 消息。

分账服务消费消息后,正常要做几件事:

  1. 推进订单状态:PAID -> SHARING
  2. 创建分账单;
  3. 发送分账执行消息;
  4. 后续调用渠道分账接口。

为了防止 MQ 重复消费,系统加了一张消费记录表:

sql 复制代码
CREATE TABLE t_message_consume_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_id VARCHAR(128) NOT NULL,
    consumer_name VARCHAR(128) NOT NULL,
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_event_consumer (event_id, consumer_name)
);

消费代码大概是这样:

ini 复制代码
@KafkaListener(topics = "pay-success-topic", groupId = "profit-share-service")
public void consume(PaySuccessMessage message) {
    String eventId = message.getEventId();
    String consumerName = "profit-share-service";

    if (consumeRecordMapper.exists(eventId, consumerName)) {
        log.info("消息已处理过,忽略重复消息,eventId={}", eventId);
        return;
    }

    consumeRecordMapper.insert(eventId, consumerName);

    orderMapper.updateStatus(
            message.getOrderNo(),
            OrderStatus.PAID,
            OrderStatus.SHARING
    );

    ProfitShareOrder shareOrder = buildShareOrder(message);
    profitShareOrderMapper.insert(shareOrder);

    kafkaTemplate.send("share-execute-topic", buildShareExecuteMessage(shareOrder));

    log.info("支付成功消息处理完成,eventId={}", eventId);
}

这段代码看起来比之前安全多了。

它至少做了一件事:

同一个 eventId,同一个消费者,只允许处理一次。

但某次线上异常后,业务侧发现了一批奇怪的数据:

复制代码
订单状态:SHARING
分账单:不存在
消费记录:已存在
MQ 消息:已经重试过

从日志看,消息第二次进来时确实被消费到了。

但很快就打印了一句:

复制代码
消息已处理过,忽略重复消息

然后就没有然后了。

这时候问题就变成了:

幂等表明明是为了防止重复处理,为什么反而让失败消息没法重试了?


二、把时间线拉开看

这类问题不是出在"幂等表有没有加"。

而是出在:

幂等记录写入成功后,真正的业务动作还没有全部成功。

时间线大概是这样:

时间点 Consumer A MySQL 业务数据 消费记录
T1 收到支付成功消息 订单状态 = PAID 无记录
T2 插入消费记录 订单状态 = PAID 已存在
T3 更新订单状态为 SHARING 订单状态 = SHARING 已存在
T4 创建分账单时数据库异常 分账单不存在 已存在
T5 Consumer A 抛异常,消息等待重试 订单卡在 SHARING 已存在
T6 Consumer B 再次收到同一条消息 订单状态 = SHARING,分账单不存在 已存在
T7 Consumer B 发现消费记录存在,直接返回 数据没有修复 已存在

这里最危险的是 T2。

消费记录已经插进去了。

但业务还没真正做完。

后面不管消息重试多少次,都会被这条消费记录挡住。

这就是一种很典型的"半成功"问题:

幂等表记录的是"我开始处理过",但代码却把它当成了"我已经处理成功"。


三、根因:幂等记录不能只有"存在"和"不存在"

很多消费幂等设计最大的问题,就是把幂等记录做成了一个布尔值:

复制代码
存在 = 已处理
不存在 = 未处理

但业务处理过程不是瞬间完成的。

它可能经历:

复制代码
刚收到消息
正在处理
部分业务成功
部分业务失败
全部处理成功
处理失败等待重试

所以一条消费记录至少不应该只表达"有没有"。

它更应该表达:

这条消息处理到了什么阶段。

也就是说,消费记录本身也需要状态。

例如:

复制代码
PROCESSING:处理中
SUCCESS:处理成功
FAIL:处理失败

如果只靠"存在即返回",就会把下面这几种情况全部混在一起:

实际情况 是否应该直接返回
已经完整处理成功 可以返回
正在被另一个线程处理 可以等待、稍后重试,不能永远跳过
上一次处理失败 不应该直接返回,应该允许重试或补偿
上一次只处理了一半 不能直接返回,需要继续修复中间态

所以,幂等表不是一把简单的锁。

它更像是一张消息处理状态表。


四、先别急着建表,先想清楚哪些动作不能重复

在设计幂等之前,最容易犯的错误是:一上来就想技术方案。

比如:

复制代码
用 Redis 还是 MySQL?
用唯一索引还是幂等表?
用 eventId 还是 orderNo?
要不要加分布式锁?

这些问题都重要,但不是第一步。

第一步应该是:

这条消息重复来之后,到底哪些业务动作不能重复发生?

以支付成功分账为例,一条 PAY_SUCCESS 消息可能触发多个动作:

业务动作 能不能重复 更合适的幂等边界
推进订单状态 PAID -> SHARING 不能重复推进 order_no + 前置状态
创建分账单 不能重复创建 order_no + share_type
创建资金流水 不能重复入账 biz_no + flow_type
发送分账执行消息 可以重复发,但下游要幂等 share_order_no
调用渠道分账接口 不能重复执行 request_no

这张表说明一个问题:

幂等不应该只围绕 MQ 消息设计,而应该围绕业务动作设计。

eventId 可以标识一次消息事件,但它不能天然代表所有业务动作的成功结果。

比如同一条支付成功消息里,订单状态推进成功了,但分账单创建失败了。

这时你不能简单说:

eventId 已经处理过了,直接跳过。

因为从业务结果看,它并没有处理完整。


五、第一道防线:状态推进要有前置条件

对于订单状态,不要直接 update。

错误写法:

ini 复制代码
UPDATE t_order
SET status = 'SHARING'
WHERE order_no = #{orderNo}

这条 SQL 不关心订单当前是什么状态。

更稳的写法是:

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("订单状态已不是 PAID,orderNo={}", message.getOrderNo());
}

这一步解决的是:

只有订单还处于 PAID 状态时,才能推进到 SHARING

如果重复消息再次进来,订单已经是 SHARINGSHARE_SUCCESS,这条 SQL 就不会再生效。

但这里不能立刻写:

ini 复制代码
if (rows == 0) {
    return;
}

因为 rows == 0 有多种可能:

复制代码
订单已经完整处理成功了
订单正在处理中
订单卡在 SHARING 中间态
订单状态异常

所以,状态更新失败后,不能无脑返回。

至少要进一步判断当前状态,或者检查后续业务数据是否完整。

比如:

ini 复制代码
Order current = orderMapper.selectByOrderNo(message.getOrderNo());

if (OrderStatus.SHARE_SUCCESS.equals(current.getStatus())) {
    return;
}

if (OrderStatus.SHARING.equals(current.getStatus())) {
    log.info("订单处于分账中,继续检查分账单是否存在,orderNo={}", message.getOrderNo());
}

这一步很关键。

否则你会把"已经处理成功"和"卡在中间态"混为一谈。


六、第二道防线:业务单据必须有唯一约束

分账单这种业务单据,不能只靠 Java 代码先查再插。

比如:

ini 复制代码
ProfitShareOrder exists = profitShareOrderMapper.selectByOrderNo(orderNo);
if (exists == null) {
    profitShareOrderMapper.insert(shareOrder);
}

这段逻辑在并发下仍然有窗口:

时间点 Consumer A Consumer B MySQL
T1 查询分账单不存在 - 无分账单
T2 - 查询分账单不存在 无分账单
T3 插入 S001 - S001
T4 - 插入 S002 S001、S002

真正能兜底的是数据库唯一索引:

scss 复制代码
CREATE UNIQUE INDEX uk_order_share_type
ON t_profit_share_order(order_no, share_type);

这表示:

同一个订单,同一种分账类型,只允许存在一条分账单。

插入时可以这样处理:

matlab 复制代码
try {
    profitShareOrderMapper.insert(buildShareOrder(message));
} catch (DuplicateKeyException e) {
    log.info("分账单已存在,按幂等成功处理,orderNo={}", message.getOrderNo());
}

这里的重点不是"用异常控制流程优不优雅"。

而是:

这个业务规则必须被数据库兜住。

应用层可以提前判断,但最终防线应该放在唯一约束上。


七、消费记录应该怎么设计?

消费记录可以用,但不要只记录"处理过"。

更合理的结构应该至少包含状态和错误信息:

sql 复制代码
CREATE TABLE t_message_consume_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_id VARCHAR(128) NOT NULL,
    consumer_name VARCHAR(128) NOT NULL,
    status VARCHAR(32) NOT NULL,
    retry_count INT NOT NULL DEFAULT 0,
    last_error VARCHAR(512),
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    UNIQUE KEY uk_event_consumer (event_id, consumer_name)
);

消费开始时,不是简单判断记录是否存在,而是判断状态。

可以按这个规则处理:

消费记录状态 处理策略
不存在 插入 PROCESSING,开始处理
SUCCESS 说明完整处理成功,可以直接返回
FAIL 允许重新处理,或交给补偿任务
PROCESSING 未超时 说明可能有其他线程正在处理,稍后重试
PROCESSING 已超时 可能是上次宕机遗留,可以尝试接管或进入补偿

伪代码可以这样写:

csharp 复制代码
ConsumeRecord record = consumeRecordService.getOrCreate(
        message.getEventId(),
        "profit-share-service"
);

if (record.isSuccess()) {
    return;
}

if (record.isProcessing() && !record.isTimeout()) {
    throw new RetryLaterException("消息正在处理中,稍后重试");
}

consumeRecordService.markProcessing(message.getEventId(), "profit-share-service");

try {
    doBusinessIdempotently(message);
    consumeRecordService.markSuccess(message.getEventId(), "profit-share-service");
} catch (Exception e) {
    consumeRecordService.markFail(message.getEventId(), "profit-share-service", e.getMessage());
    throw e;
}

这段代码的核心是:

只有 SUCCESS 才代表可以安全忽略重复消息。

其他状态都不能简单跳过。

尤其是 PROCESSINGFAIL,一定要设计清楚后续怎么处理。

否则幂等表就会变成"消息黑洞"。


八、一个更稳的处理流程

回到支付成功创建分账单这个场景。

我更倾向于把流程设计成这样:

markdown 复制代码
1. 收到 PAY_SUCCESS 消息
2. 获取或创建消费记录
3. 如果记录是 SUCCESS,直接返回
4. 如果记录是 PROCESSING 且未超时,稍后重试
5. 标记为 PROCESSING
6. 条件推进订单状态:PAID -> SHARING
7. 如果订单已经是 SHARING,不直接返回,继续检查分账单
8. 创建分账单,依靠 order_no + share_type 唯一索引兜底
9. 关键业务动作完成后,标记消费记录 SUCCESS
10. 失败时记录 FAIL,等待重试或补偿

对应代码大概是这样:

csharp 复制代码
@KafkaListener(topics = "pay-success-topic", groupId = "profit-share-service")
public void consume(PaySuccessMessage message) {
    String eventId = message.getEventId();
    String consumerName = "profit-share-service";

    ConsumeRecord record = consumeRecordService.getOrCreate(eventId, consumerName);

    if (record.isSuccess()) {
        log.info("消息已完整处理成功,忽略重复消息,eventId={}", eventId);
        return;
    }

    if (record.isProcessing() && !record.isTimeout()) {
        log.info("消息正在处理中,稍后重试,eventId={}", eventId);
        throw new RetryLaterException("message processing");
    }

    consumeRecordService.markProcessing(eventId, consumerName);

    try {
        int rows = orderMapper.updateStatus(
                message.getOrderNo(),
                OrderStatus.PAID,
                OrderStatus.SHARING
        );

        if (rows == 0) {
            Order current = orderMapper.selectByOrderNo(message.getOrderNo());
            if (OrderStatus.SHARE_SUCCESS.equals(current.getStatus())) {
                consumeRecordService.markSuccess(eventId, consumerName);
                return;
            }

            if (!OrderStatus.SHARING.equals(current.getStatus())) {
                throw new BusinessException("订单状态不允许创建分账单");
            }
        }

        try {
            profitShareOrderMapper.insert(buildShareOrder(message));
        } catch (DuplicateKeyException e) {
            log.info("分账单已存在,按幂等成功处理,orderNo={}", message.getOrderNo());
        }

        consumeRecordService.markSuccess(eventId, consumerName);
    } catch (Exception e) {
        consumeRecordService.markFail(eventId, consumerName, e.getMessage());
        throw e;
    }
}

这段代码不是为了展示某种标准答案。

它只是想表达几个原则:

ini 复制代码
只有 SUCCESS 才能直接忽略重复消息
PROCESSING 要考虑超时接管
FAIL 要允许重试或补偿
状态 rows = 0 不能无脑返回
业务单据必须靠唯一索引兜底

这样重复消息再进来时,不会重复创建分账单。

而上一次处理到一半失败时,也不会被幂等记录直接挡死。


九、外部调用是另一层幂等边界

如果消费消息后还要调用外部接口,比如渠道分账、退款、发券,就不能只考虑本地数据库幂等。

因为可能出现这样的情况:

时间点 动作
T1 本地创建分账单成功
T2 调用渠道分账接口
T3 渠道实际处理成功
T4 本地服务超时,没有拿到响应
T5 消息重试
T6 再次调用渠道分账接口

这时如果没有外部请求幂等,可能会造成重复分账。

所以这类请求必须带稳定的业务请求号:

ini 复制代码
requestNo = SHARE_{orderNo}_{shareType}

并且外部系统需要保证:

同一个 requestNo 重复请求,只执行一次,后续返回同一处理结果。

如果外部系统不支持幂等,本地就不能盲目重试执行类接口。

更稳的策略是:

复制代码
先落本地请求记录
调用失败或超时时,优先查外部结果
确认外部未处理后,再决定是否重试
无法确认时,进入人工或补偿流程

跨出本地数据库之后,幂等边界也必须跟着跨出去。


十、Redis 锁解决不了这个问题

有些人会想:

消费的时候加一把 Redis 分布式锁,不就不会重复了吗?

锁可以减少并发执行,但它不是幂等。

比如第一轮消费已经执行到一半,然后失败释放锁。

第二轮消息隔了 30 秒后重试,这时锁早就不存在了。

它照样会再次执行。

锁解决的是:

复制代码
同一时刻不要有两个线程一起处理

幂等解决的是:

复制代码
同一个业务动作重复执行时,结果仍然正确

这两个问题不一样。

所以在 MQ 消费场景里,分布式锁最多只能作为辅助,不能作为最后防线。

最后真正兜底的,还是这些东西:

复制代码
消费记录状态
状态条件更新
唯一索引
外部 requestNo
补偿任务

十一、这套方案的边界

这套设计能解决"重复消费"和"失败消息被幂等表挡死"的问题,但它也不是银弹。

1. 消费记录和业务操作不一定在一个事务里

如果消费记录更新成功,但业务事务提交失败,或者业务成功后消费记录标记成功失败,仍然可能出现不一致。

所以关键业务动作必须自身幂等,不能只依赖消费记录。

2. 中间态必须有补偿

比如:

复制代码
订单状态 = SHARING
分账单 = 不存在

或者:

复制代码
分账单已创建
分账执行消息没发出去

这类问题不能指望 MQ 重试全部解决。

需要补偿任务定期扫描异常状态,重新推进或标记失败。

3. 幂等键不能选错

幂等键太粗,会误伤正常业务。

比如只用 orderNo 做幂等,就可能把同一个订单的支付成功、退款成功、分账成功混在一起。

幂等键太细,又挡不住重复。

比如每次重试都生成新的 requestId,那就完全失去了幂等意义。

幂等键应该来自稳定的业务语义:

复制代码
eventId
eventType + bizNo
orderNo + shareType
refundNo
requestNo

4. 幂等记录不能无限增长

消费记录表会持续增长。

后面要考虑:

复制代码
按业务周期保留
定期归档
历史数据清理
关键字段索引

否则幂等表本身也可能变成性能瓶颈。


十二、总结

MQ 重复消费不可避免,业务幂等也不是简单加一张表就结束。

真正要小心的是:

幂等记录存在,不代表业务已经完整成功。

如果把"处理过"简单理解成"记录存在",就很容易出现:

复制代码
第一次处理到一半失败
第二次重试被幂等表挡住
业务永远卡在中间态

更稳的做法是:

复制代码
只有 SUCCESS 才能跳过
PROCESSING 要考虑超时
FAIL 要允许重试或补偿
状态推进要带前置条件
业务单据要靠唯一索引
外部调用要带 requestNo

幂等的目标不是让消息不重复。

而是让消息重复来时,业务不会重复生效;

消息失败重试时,业务也不会被错误地挡死。

相关推荐
地铁潜行者1 小时前
Kafka 只发了一条消息,为什么业务侧消费了两次?
后端
文心快码BaiduComate1 小时前
提升组织级AI Coding质量:电商搜索项目实践
前端·后端·程序员
用户8356290780511 小时前
Python 操作 Word 修订跟踪(Track Changes)
后端·python
摇滚侠2 小时前
SpringMVC 入门到实战 视图解析器 44-48
java·spring·maven·intellij-idea
記億揺晃着的那天2 小时前
告别误操作!Spring Boot 多环境配置隔离与启动守卫实战
java·spring boot·后端·环境隔离
我是唐青枫2 小时前
Java Spring Data JPA 实战指南:Repository 查询、分页与实体映射
java·开发语言
YuePeng2 小时前
凌晨 3 点告警群炸了,我用浏览器干了原本 XShell 才能干的事
后端·github
染翰2 小时前
Nacos 切换 Namespace 后配置不生效、占位符报错终极复盘
java·后端·spring·nacos
terry6002 小时前
2026图形验证码服务商横向测评|口碑、接入、安全选型全指南
java·大数据·人工智能·web安全·信息与通信·数据库架构