事故:用户收到两笔重复的退款
五一后第一天,客服转来一个投诉:同一笔订单退了两次款。查 MQ 消费日志,发现退款消息被消费了两次。RocketMQ 的「至少一次」投递语义意味着重复消费必然发生,幂等没做好的锅,得我们自己背。
排查:为什么恰好重复
消息体里其实带了唯一的 refundId,但旧代码直接 INSERT 退款记录,并发两消费者拿到同一条消息,都过了校验就都写了库。根本问题:消费侧没有任何去重手段,且「查重」和「落库」不是原子的,两个线程同时查都查不到,就同时插入。
方案一:数据库唯一索引去重(最稳)
给退款记录表加唯一索引,靠数据库兜底,重复插入直接抛 DuplicateKeyException:
ALTER TABLE refund_record ADD UNIQUE KEY uk_refund (refund_id);
@Transactional
public void consume(RefundMsg msg) {
try {
refundMapper.insert(toRecord(msg));
doRefund(msg);
} catch (DuplicateKeyException e) {
log.warn("重复退款消息,已忽略 refundId={}", msg.getRefundId());
}
}
这是最可靠的,因为唯一索引的约束是数据库层面的原子操作,不依赖应用层时序。
方案二:状态机幂等
退款本身有状态流转:INIT → PROCESSING → SUCCESS。消费前先 SELECT ... FOR UPDATE 拿到行锁,只有当前状态合法才推进:
Refund r = mapper.selectForUpdate(msg.getRefundId());
if (r.getStatus() != RefundStatus.INIT) return; // 已处理,直接跳过
r.setStatus(PROCESSING);
mapper.update(r);
doRefund(msg);
状态机的好处是能防住「重复处理 + 状态错乱」两类问题,适合流程长的业务。
方案三:Redis 去重窗口
对于消费速度极快、不想每次查库的场景,用 Redis 的 SETNX 做短期去重,过期时间覆盖消息重投窗口(比如 24 小时):
String key = "refund:dedup:" + msg.getRefundId();
Boolean ok = redis.set(key, "1", SetOptions.builder().nx().ex(86400).build());
if (Boolean.FALSE.equals(ok)) return; // 已消费过
doRefund(msg);
注意这是窗口去重,不是永久去重——Redis 键过期后理论上还能重复消费,所以只适合「重复都发生在短时间内」的场景,或作为唯一索引之外的第一道快速拦截。
小结
幂等设计没有万能解。强一致选唯一索引 + 异常捕获;有状态流转用状态机;高吞吐低延迟用 Redis 窗口做前置拦截,但一定要配合库里去重才保险。我们最后落地是「Redis 快速拦截 + 唯一索引兜底」双保险,重复退款再没出现过。