外观
接口幂等性设计的5种方案:从防重复提交到分布式幂等实战
用户在支付页点了「确认支付」,页面卡了两秒,他又点了一次——于是两笔扣款请求同时到达。这不是段子,是每个后端工程师迟早会面对的线上事故。网络重试、消息重复投递、用户连击,都会让同一笔业务请求到达服务端不止一次;HTTP 语义里除 PUT/DELETE 外的多数方法本就不保证幂等,所以幂等设计是后端自己的必修课。本文梳理五种生产上经过验证的幂等方案,以及各自的适用边界。
方案一:数据库唯一索引——最简单也最可靠
为业务唯一键(订单号、流水号)建唯一索引,重复插入直接报错,这是所有方案里实现成本最低、可靠性最高的:
sql
ALTER TABLE `payment_order` ADD UNIQUE KEY `uk_out_trade_no` (`out_trade_no`);java
try {
paymentOrderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 命中唯一索引说明重复请求:返回第一次的处理结果或直接幂等成功
return paymentOrderMapper.selectByOutTradeNo(order.getOutTradeNo());
}要点是捕获重复键异常后返回原查询结果而不是报错给用户——对调用方而言,重复请求等价于「已经成功」,这才符合幂等语义。唯一索引的局限是只覆盖「插入」场景,且业务允许同一键多次操作(如多次充值)时不适用。
方案二:防重令牌——适配表单连击场景
前端先请求服务端领取一个一次性令牌,提交时携带令牌,服务端用「删除即消费」的原子操作校验:
java
// 提交接口:原子性地校验并消费令牌
public Result submit(String tokenId, OrderReq req) {
// DEL 是原子操作:只有第一次调用返回 1,后续并发调用都返回 0
Long del = redis.del("idem:token:" + tokenId);
if (del == 0) {
return Result.fail("请勿重复提交");
}
return doCreateOrder(req); // 只有抢到令牌的请求进入业务
}令牌方案防的是「入口重复」,适合下单、报名这类表单提交。注意两点:令牌必须一次性消费,用完即删,删除动作要在事务提交之前完成时序要谨慎——更严格的做法是把「校验令牌」放在数据库事务内,避免「令牌消费了但事务回滚」导致用户下次提交被拒。
方案三:分布式锁——兜底并发的临界区
以业务键为锁粒度,让同一业务键的并发请求串行执行,天然适用于「余额扣减」这类必须互斥的操作:
java
// 以订单号为锁粒度,setnx + 过期时间防死锁
String lockKey = "idem:lock:pay:" + req.getOutTradeNo();
boolean locked = redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
return Result.fail("处理中,请勿重复操作");
}
try {
// 临界区内先查状态再执行,状态机校验见方案四
return payService.pay(req);
} finally {
releaseLockSafely(lockKey, "1"); // 只释放自己持有的锁(Lua 原子校验)
}分布式锁防的是「同时进入」,但要注意它不等于幂等:请求 A 拿锁执行完释放锁后,迟到的重试请求 B 再拿锁还是会再执行一遍。所以锁通常要与状态校验组合使用,锁解决并发互斥,状态机解决重复执行。
方案四与方案五:状态机与乐观锁版本号
有生命周期状态的业务(订单、工单)最适合用状态机做幂等:每次流转都带上期望的前置状态,用条件更新实现「符合预期才变更」:
sql
-- 只有「待支付」状态才能流转到「已支付」,重复回调无法二次变更
UPDATE payment_order
SET status = 'PAID', paid_time = NOW()
WHERE out_trade_no = 'SO20260012' AND status = 'PENDING';
-- affected rows = 0 即为重复请求,直接幂等返回成功无状态流转的更新类接口则用乐观锁版本号:表加 version 字段,更新时 WHERE id = ? AND version = ? 并将 version 加一,重复或并发的旧版本请求更新行数为零,天然被拒。两种方案都把幂等下沉到了数据层,不依赖外部中间件,是最稳的收口手段。
选型决策与组合拳
| 方案 | 防什么 | 典型场景 | 局限 |
|---|---|---|---|
| 唯一索引 | 重复插入 | 下单、流水 | 仅插入场景 |
| 防重令牌 | 入口连击 | 表单提交 | 需前端配合 |
| 分布式锁 | 并发互斥 | 扣减余额 | 不防时序重试 |
| 状态机 | 重复流转 | 订单回调 | 需状态模型 |
| 乐观锁 | 并发覆盖 | 通用更新 | 冲突高时重试多 |
生产实践中通常是组合拳:入口层用令牌挡住连击,临界区用分布式锁互斥,数据层用状态机或唯一索引兜底。三层都过一遍,重复请求无论从哪个角度来,都只能在数据层留下一条记录。
实践清单
- 先画清业务键:每个写接口都要明确「业务唯一键是什么」。
- 插入类场景首选唯一索引,捕获重复键后幂等返回。
- 支付回调等状态流转接口一律走状态机条件更新。
- 锁的粒度到业务键、必须带过期时间、释放要用原子校验。
- 幂等方案要写进接口文档与联调用例,重复请求作为必测分支。
幂等的本质是把「只执行一次」的语义从客户端信任收回到服务端保障。设计接口时多想一步「这个请求来两次会怎样」,就能少一次凌晨的告警电话。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。