电商小程序后端架构系列。消息队列的投递语义本质上是"至少一次"(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 只能做"优化"不能做"依据"------资金类操作最终一定要落到数据库唯一约束上。
六、生产端也要幂等
消费端做再多,生产端乱发也会放大问题。两个实践:
- 业务唯一键随消息带上 :支付回调消息的 key 用
支付流水号,订单事件用订单号+事件类型,不要让消费端自己拼 key,拼错规则就失去意义。 - 生产端发送也去重:用户双击提交、定时任务重跑时,用同一业务键在生产端做 SETNX 防重发,能从源头减少重复量。
七、小结
消费端幂等没有银弹,记住三句话:
- MQ 是 at-least-once,重复必然发生,幂等是必选项不是可选项;
- 核心链路用"去重表唯一约束 + 状态机条件更新"做强一致兜底,Redis 只做前置性能优化;
- 先查后改、只靠 Redis、占位后不处理僵尸记录,是三个最常见的翻车点。
订单、资金、库存这类场景,上线前用测试用例强制验证"同一条消息连续投递 3 次,业务结果与投递 1 次完全一致",把幂等测试纳入发布 checklist,比事后半夜抢修划算得多。