java
技术随笔
接口幂等性设计实战:支付回调、MQ 重复消费与六种防重方案
支付回调重复通知、消息队列重复投递、用户狂点“提交”按钮、RPC 框架自动重试——这些场景下如果接口不做幂等处理,轻则产生脏数据,重则重复扣款引发资损。本文讲清幂等的本质,并给出生产可用的六种方案与选型建议。
一、什么是幂等
一个操作无论执行一次还是多次,产生的结果与状态都完全一致,就称它具有幂等性。注意区分两个概念:
- 防重:第二次请求直接拒绝(如重复提交表单提示“请勿重复操作”);
- 幂等:第二次请求正常“消化”掉但不再重复生效(如支付回调第 2 次到达,业务侧直接返回成功)。
真正的接口幂等要应对的是:同一个业务请求被以不确定的次数重复执行。
二、哪些场景必须考虑幂等
| 场景 | 为什么可能重复 |
|---|---|
| 第三方支付回调 | 微信/支付宝会多次通知,直到你确认成功 |
| 消息队列消费 | MQ 至少一次投递 + 消费失败重投,天然可能重复 |
| RPC/HTTP 超时重试 | 客户端框架对超时请求自动重发,服务端可能已处理 |
| 表单提交 | 用户双击、弱网下重复点击 |
| 定时任务重跑 | 补偿任务与主任务可能处理同一批数据 |
三、幂等方案全家桶(从数据库到 Redis)
方案 1:数据库唯一约束(最硬核的兜底)
// 建唯一索引:同一笔业务流水号只能插入一次
// ALTER TABLE t_order ADD UNIQUE KEY uk_biz_no (biz_no);
OrderPO exist = orderDao.selectByBizNo(bizNo); // 先查
if (exist != null) { return SUCCESS; } // 已处理过:直接返回成功
try {
orderDao.insert(orderPO); // 首次插入
} catch (DuplicateKeyException e) {
// 并发下两个请求都查不到、都来插入:靠唯一索引兜住重复,重新查一次返回
return SUCCESS;
}
唯一索引能扛住任意并发,因为冲突由数据库保证。这是其他所有方案都不具备的“最后防线”,强烈建议每个核心写入表都设计一个 biz_no(业务流水号)唯一键。
方案 2:状态机校验(业务状态单向流转)
// 订单状态只能向前:待支付 -> 已支付 -> 已发货 -> 已完成
// 回调重复时状态已是“已支付”,update 语句的 where 条件让重复请求无效
int rows = orderMapper.updateState(
orderId,
/* newState */ PAYED,
/* expectOldState */ WAIT_PAY); // update ... where state = #{expectOldState}
if (rows == 0) {
// 说明状态已不是“待支付”,重复通知或并发更新,直接按成功返回
return SUCCESS;
}
方案 3:Redis SETNX + 过期时间(防重窗口 + 分布式锁兜底)
// 以“业务幂等键”为 key,抢到锁才允许继续,抢不到说明正在处理/已处理
String idemKey = "idem:pay:" + bizNo;
Boolean got = redisTemplate.opsForValue()
.setIfAbsent(idemKey, "1", 30, TimeUnit.SECONDS); // 注意设过期时间防死锁
if (!Boolean.TRUE.equals(got)) {
return SUCCESS; // 已有请求在处理,本次直接返回(或重试)
}
try {
doPayBusiness();
// 业务完成后可将 value 置为 done,便于查询;key 由过期时间兜底清理
} finally {
// 仅当业务失败需要允许重试时才主动删除;成功后保留直到过期
}
方案 4:token 令牌(“发一个、用一个”)
// ① 进入下单页时先向后端申请 token(写入 Redis,如有效 5 分钟)
String token = tokenService.acquire(uid); // Redis SET token-xxx 1 EX 300
// ② 提交时校验并“原子消费”:拿得到才允许执行(Lua 保证取+删不被打断)
String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) else return 0 end";
Long used = redisTemplate.execute(new DefaultRedisScript<Long>(lua, Long.class),
Collections.singletonList("token:" + token), token);
if (used == null || used == 0) {
throw new BizException("请勿重复提交");
}
// ③ 真正执行业务
方案 5:乐观锁(版本号 CAS)
// UPDATE t_account SET balance = balance - #{amount}, version = version + 1
// WHERE id = #{id} AND version = #{oldVersion}
// 影响行数为 0 说明版本已被别人改过,本次操作作废或重试
int rows = accountMapper.deductBalance(id, amount, oldVersion);
if (rows == 0) { throw new BizException("余额已被其他操作更新,请重试"); }
方案 6:消息队列消费者幂等(最常用组合)
// 消费者侧:以“业务唯一键”(如订单号/流水号)建幂等表或 Redis 标记,重复消息直接 ack
// 推荐做法:MQ 消息体里带业务流水号,消费时“先查后插+唯一键兜底”
public void onMessage(PayMsg msg) {
String bizNo = msg.getBizNo(); // 业务的天然幂等键
boolean first = consumeLogService.markDoing(bizNo); // 幂等表插记录,主键= bizNo
if (!first) {
log.warn("重复消息,直接确认: {}", bizNo);
return; // 重复消费:直接 ack 不再处理
}
try {
doBiz(msg);
consumeLogService.markDone(bizNo);
} catch (Exception e) {
consumeLogService.remove(bizNo); // 失败清除标记,允许重投重试
throw e;
}
}
四、方案选型对照表
| 方案 | 并发兜底能力 | 实现成本 | 适用场景 |
|---|---|---|---|
| 数据库唯一索引 | 最强(DB 级保证) | 低 | 一切核心写入表(首选) |
| 状态机 | 强(依赖 where 条件) | 低 | 有明确状态流转的业务(订单/审批) |
| Redis SETNX | 中(依赖 Redis) | 低 | 防重窗口、接口限流式防重 |
| token 令牌 | 中 | 低 | 表单/下单防重复提交 |
| 乐观锁 version | 强(CAS 语义) | 低 | 金额扣减、库存扣减等防超扣 |
| 幂等表/消息标记 | 强(配合唯一键) | 中 | MQ 消费者、异步任务 |
五、实战范式:支付回调幂等(综合运用)
// 完整链路:唯一索引(防并发) + 状态机(防乱序) + Redis(防重窗口) 三层配合
public PayResult handlePayCallback(PayNotify req) {
String bizNo = req.getBizNo();
// 第 1 层:查本地状态,已终态直接返回成功(天然幂等)
OrderPO order = orderDao.selectByBizNo(bizNo);
if (order == null || order.getState() == PAYED) {
return PayResult.SUCCESS; // 未见过 / 已支付:都算成功
}
// 第 2 层:Redis 分布式锁防并发同时入账
RLock lock = redisson.getLock("lock:pay:" + bizNo);
if (!lock.tryLock(5, TimeUnit.SECONDS)) return PayResult.RETRY;
try {
// 第 3 层:状态机 CAS 更新,天然幂等
int rows = orderMapper.payByState(order.getId(), order.getState(), PAYED);
if (rows == 1) {
balanceService.add(order.getUid(), order.getAmount()); // 只入账一次
}
return PayResult.SUCCESS;
} finally {
lock.unlock();
}
}
六、高频问题速答
- 幂等键怎么选? 选业务上天然唯一的字段(订单号、支付流水号、消息业务 ID),不要用每次都会变的随机值。
- Redis 防重和唯一索引冲突吗? 不冲突,Redis 挡大部分重复流量减轻 DB 压力,唯一索引兜底极端并发。
- 先写库还是先发 MQ? 先落库并标记发送状态,再投递,防止“消息发出去了库没写成”导致对端查不到。
- 幂等失败要不要补偿? 需要:定时任务扫描“已发送未完成”的中间态做对账补偿。
- GET 请求需要幂等吗? 查询天然幂等;但“GET 却干了写的事”的接口才需要额外防护。
一句话总结:幂等键要取自业务、唯一索引做兜底、Redis 挡重压、状态机防乱序——层层设防才能在高并发与网络抖动下保住核心数据。