消息队列消费端幂等性实战:订单场景下重复消息不重复扣款的完整方案

电商小程序后端架构系列。消息队列的投递语义本质上是"至少一次"(at-least-once),重复消息是必然事件而不是小概率事件。本文以订单支付回调、库存扣减、优惠券核销三个真实场景为例,讲清消费端幂等的完整落地方案,包含可运行代码和3个线上踩坑复盘。

一、为什么消息一定会重复?

很多人以为重复消息是 MQ 出 bug 了,其实它是架构设计的必然结果。一条消息从 Broker 投递到消费者,完整流程是:Broker 推送 → 消费者拉取 → 执行业务逻辑 → 消费者返回 ACK → Broker 标记已消费。问题在于,ACK 之前的任何一步失败,Broker 都会重新投递

  • 消费者业务执行成功,但返回 ACK 时网络抖动,Broker 没收到 → 重投;
  • 消费者处理到一半进程重启 / 容器被重新调度 → 没来得及 ACK → 重投;
  • Broker 主从同步延迟,消费者从新主节点重新拉取 → 重投;
  • 生产端本身就发了两次(用户狂点支付按钮、定时任务重跑)。

所以结论很明确:消费端必须假设同一条消息会来 N 次,并且来 N 次的效果和来 1 次完全一样,这就是幂等。支付回调重复通知导致重复发货、库存消息重复消费导致超卖后又错扣、核销消息重复导致一张券用两次------这类事故在线上的代价都是真金白银。

二、三种幂等方案对比

方案 原理 优点 缺点 适用场景
唯一键去重表 业务唯一键插入去重表,插入成功才执行 强一致、可审计 多一次 DB 写入 资金、订单类核心链路
状态机约束 UPDATE 带前置状态条件,靠行锁兜底 无额外表、语义自然 要求业务有明确状态流转 订单状态、工单流转
Redis 去重 SETNX 占位,TTL 过期 性能最高 极端情况有窗口风险,只做前置拦截 高并发非核心链路

实战中这三者不是三选一,而是组合使用:Redis 做第一道快速拦截挡住大部分重复流量,数据库唯一约束 / 状态机做最终兜底保证强一致。下面逐个讲。

三、方案一:唯一消息键 + 去重表(核心链路首选)

3.1 表结构

sql 复制代码
CREATE TABLE `mq_consume_log` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `msg_key` varchar(64) NOT NULL COMMENT '业务唯一键,如 订单号+事件类型',
  `consumer_group` varchar(64) NOT NULL COMMENT '消费组名',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '0处理中 1成功 2失败',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_key_group` (`msg_key`, `consumer_group`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='MQ消费去重表';

关键点:唯一索引建在 (msg_key, consumer_group) 上。同一个消费组内重复消息会被唯一约束挡死;不同消费组各自独立消费互不影响。

3.2 消费端模板代码

java 复制代码
@RocketMQMessageListener(topic = "ORDER_PAY_RESULT", consumerGroup = "stock-deduct-group")
@Component
public class PayResultConsumer implements RocketMQListener<MessageExt> {

    @Resource
    private OrderService orderService;
    @Resource
    private MqConsumeLogMapper consumeLogMapper;

    @Override
    public void onMessage(MessageExt msg) {
        // msgKey 由生产端生成:订单号 + 事件,例如 "PAY_SUCCESS_2026090800123"
        String msgKey = msg.getKeys();
        String group = "stock-deduct-group";

        // 第一步:占位,唯一键冲突说明这条消息处理过或正在处理
        try {
            consumeLogMapper.insertProcessing(msgKey, group);
        } catch (DuplicateKeyException e) {
            // 查一下状态:已成功直接返回 ACK;处理中说明是并发重复,稍后重试
            MqConsumeLog exist = consumeLogMapper.selectByKey(msgKey, group);
            if (exist != null && exist.getStatus() == 1) {
                log.info("消息已成功消费,直接跳过: {}", msgKey);
                return;
            }
            log.warn("消息正在处理中,触发重试: {}", msgKey);
            throw new ConsumeRetryLaterException();
        }

        // 第二步:执行真正的业务
        try {
            PayResultEvent event = JSON.parseObject(new String(msg.getBody()), PayResultEvent.class);
            orderService.deductStockAndShip(event.getOrderNo());
            consumeLogMapper.markSuccess(msgKey, group);
        } catch (Exception e) {
            // 业务失败:标记失败并抛出,让 MQ 重投
            consumeLogMapper.markFail(msgKey, group);
            throw e;
        }
    }
}

3.3 踩坑点 1:去重记录插入成功但业务执行前进程挂了

这是上线后第一个月遇到的真实问题:消费者 insert 去重记录成功,还没执行业务就 OOM 重启。消息重投时唯一键冲突,但去重记录状态是"处理中",代码直接返回 ACK------结果业务永远没执行,订单卡在已支付未发货。

修复:状态为"处理中"时不能 ACK,要抛异常让 MQ 进重试;同时加一个定时任务扫描"处理中超过 5 分钟"的记录,把这类僵尸占位重置为失败,允许重新消费。

java 复制代码
// 定时兜底任务
@Scheduled(fixedDelay = 5 * 60 * 1000)
public void resetZombieRecords() {
    consumeLogMapper.resetProcessingBefore(LocalDateTime.now().minusMinutes(5));
}

四、方案二:状态机 + 条件更新(订单流转场景)

订单本身有状态机,幂等可以直接做在业务 UPDATE 里,连去重表都不用:

sql 复制代码
-- 只有"待发货"状态的订单才能被推进到"已发货"
UPDATE orders
SET status = 'SHIPPED', updated_at = NOW()
WHERE order_no = #{orderNo} AND status = 'WAIT_SHIP';

返回影响行数:等于 1 说明本次推进成功;等于 0 说明状态不对------要么已经处理过(重复消息),要么状态非法(乱序消息),直接 ACK 丢弃并告警。

4.1 踩坑点 2:先查后改的并发漏洞

最初有同事写成"先 SELECT 查状态,判断是 WAIT_SHIP 再 UPDATE"。两条重复消息并发到达时都查到 WAIT_SHIP,都执行了后续逻辑(扣库存、发券),UPDATE 虽然只有一条生效,但副作用已经执行了两次。

教训:判断和修改必须是同一条原子 SQL,靠数据库行锁串行化,任何"先查后改"在并发下都不可靠。 副作用操作(扣库存、加积分)要么放在条件 UPDATE 成功之后,要么本身也做幂等。

五、方案三:Redis 前置拦截(高并发场景)

大促峰值时,每个消息都写一次去重表会给数据库不小压力。用 Redis SETNX 做第一道闸:

java 复制代码
String redisKey = "mq:idem:" + msgKey;
// setIfAbsent = SETNX,value 用随机值,TTL 10 分钟
Boolean first = redisTemplate.opsForValue()
        .setIfAbsent(redisKey, msgId, Duration.ofMinutes(10));
if (Boolean.FALSE.equals(first)) {
    log.info("Redis判定重复,快速跳过: {}", msgKey);
    return;
}
// 通过 Redis 拦截后,依然要走数据库去重表/状态机兜底

5.1 踩坑点 3:只靠 Redis 的两个翻车现场

  • Redis Key 过期后消息重投:业务逻辑执行超过 TTL(比如下游接口慢),Key 过期了,重试消息穿透 Redis 又执行了一遍。所以 Redis TTL 要大于业务最大执行时间,且数据库兜底不能省。
  • Redis 主从切换丢 Key:主节点 SETNX 成功还没同步到从节点就宕机,新主上没有这个 Key,重复请求穿透。这也是为什么 Redis 只能做"优化"不能做"依据"------资金类操作最终一定要落到数据库唯一约束上。

六、生产端也要幂等

消费端做再多,生产端乱发也会放大问题。两个实践:

  1. 业务唯一键随消息带上 :支付回调消息的 key 用 支付流水号,订单事件用 订单号+事件类型,不要让消费端自己拼 key,拼错规则就失去意义。
  2. 生产端发送也去重:用户双击提交、定时任务重跑时,用同一业务键在生产端做 SETNX 防重发,能从源头减少重复量。

七、小结

消费端幂等没有银弹,记住三句话:

  • MQ 是 at-least-once,重复必然发生,幂等是必选项不是可选项;
  • 核心链路用"去重表唯一约束 + 状态机条件更新"做强一致兜底,Redis 只做前置性能优化;
  • 先查后改、只靠 Redis、占位后不处理僵尸记录,是三个最常见的翻车点。

订单、资金、库存这类场景,上线前用测试用例强制验证"同一条消息连续投递 3 次,业务结果与投递 1 次完全一致",把幂等测试纳入发布 checklist,比事后半夜抢修划算得多。

相关推荐
2601_949950637 小时前
个人在线刷题的工具
学习·考研·小程序·刷题·小程序推荐
QQ_216962909615 小时前
基于微服务架构的店铺管理系统的设计与实现
大数据·spring boot·后端·spring·微服务·小程序·架构
EatFan1 天前
Java接入支付宝 JSAPI 支付保姆教程(二):流程讲解与前后端代码讲解
前端·spring boot·后端·微信小程序·小程序·uni-app
m0_587383002 天前
折扣卡CPS系统源码的技术架构与实战开发指南
人工智能·小程序·数据挖掘·系统架构·需求分析
2501_915106322 天前
苹果App Store上架费用及流程全面解析
android·ios·小程序·https·uni-app·iphone·webview
T01156182 天前
全栈项目实战手记|艺培场馆课时预约小程序全项目开发历程完整复盘总结
运维·服务器·小程序
lhldsg3 天前
多商户团购系统源码:技术架构选型与二次开发实战指南
java·小程序·架构
2601_949950633 天前
一套小程序,解决资料整理、在线刷题和错题复盘
人工智能·小程序·刷题·练习·小程序推荐
白远山3 天前
智慧场馆解决方案小程序系统开发实战与架构设计指南
java·开发语言·小程序·架构