在分布式系统与高并发接口里,幂等设计是保证“同一请求多次执行结果一致”的核心能力。网络超时重试、消息队列重复投递、用户连点提交,都会让没有幂等保护的接口产生重复下单、重复扣款。本文用可落地的代码,讲清从前端防重复提交到后端分布式幂等的完整方案。
举个最常见的例子:用户在支付页点击“确认支付”,前端发出请求后网络抖动没收到响应,客户端便自动重试。如果服务端没有幂等保护,两次请求都可能“成功扣款”,银行卡被划走两笔。这类故障在日志里常表现为“同一订单出现两笔支付流水”,往往要等到事后对账才能发现,排查成本极高。
一、为什么一定要做幂等设计
“幂等”原本是数学概念:f(f(x)) = f(x)。在接口语境下,意味着无论同一个请求被调用一次还是多次,对系统状态产生的影响必须完全相同。以下三类场景几乎必然触发重复:
- 网络重试:网关或客户端在超时后自动重发,服务端其实已经处理成功。
- 消息重复:MQ 在“至少一次”投递语义下,同一条消息可能被消费两次。
- 用户重复操作:支付页卡顿导致用户连点“提交订单”或“确认支付”。
需要强调:只读接口天然幂等,重复查询不会改变任何状态,无需特殊处理;幂等设计的精力应全部放在写接口(新增、修改、删除、扣减)上。把有限的防护资源聚焦在写链路,性价比最高。
如果接口只是“无脑 insert / 扣减余额”,重复请求就会变成真实的资损。因此幂等不是优化项,而是支付、下单、退款等写接口的必选项。更系统的缓存与并发防护思路,可参考 Redis 缓存设计:穿透、击穿、雪崩与最佳实践。
Token 机制的精妙之处,是把“是否重复”的判断前移到业务逻辑之前。哪怕后续数据库写入、远程调用、消息发送任一环节超时,只要 Token 已被消费,重试请求都会在入口被拦下,从根上杜绝重复进入核心流程。
二、单机防重复提交:Token 机制
2.1 前端只能辅助,不能兜底
按钮置灰、Loading 态、防抖节流,只能挡住“正常用户”的连点,挡不住脚本重放和重试。真正的防线必须在服务端。
2.2 后端 Token 消费
核心思路:进入表单页时先申请一个一次性 Token 并写入 Redis,提交时携带;服务端用 Lua 脚本保证“校验+删除”原子,避免并发下同一 Token 被两次放行。
// 1) 获取幂等 Token,写入 Redis 并设短期过期
@GetMapping("/idempotent/token")
public String getToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("idem:" + token, "1", 5, TimeUnit.MINUTES);
return token;
}
// 2) 提交时原子校验并消费(Lua 保证原子性)
public boolean checkAndDelete(String token) {
String script = "if redis.call('get', KEYS[1]) then "
+ "redis.call('del', KEYS[1]) return 1 else return 0 end";
Long r = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of("idem:" + token));
return r != null && r == 1L; // true=首次合法,false=重复/过期
}
关于 Redis 分布式锁的更完整写法(含 Redisson、看门狗续期),见 Redis 分布式锁与高级数据结构实战。
分布式环境下,单机的进程内存无法跨节点共享状态,因此必须借助外部存储(数据库或 Redis)来记录“这个请求是否已经处理过”。下面四种方案按可靠性与适用面从强到弱排列,实际项目通常是组合使用,而不是二选一。
三、分布式幂等:四种可落地方案
3.1 数据库唯一索引(最可靠)
对“写入型”幂等(下单、支付流水),给业务唯一键加唯一约束,重复写入会直接抛 DuplicateKeyException,捕获后返回“成功”即可。
-- 用业务唯一号做天然幂等拦截
ALTER TABLE orders ADD UNIQUE KEY uk_biz (biz_no);
INSERT INTO orders (biz_no, user_id, amount)
VALUES ('PAY-20261028-0001', 1001, 99.00);
-- 重复请求触发:ERROR 1062 (23000): Duplicate entry 'PAY-20261028-0001'
-- 业务层捕获后直接返回“该订单已处理”,对用户透明
3.2 Redis SET NX 互斥锁
对高频校验或防并发,用 SET key value NX EX:不存在才写入,天然互斥;处理完成或超时后自动释放,避免死锁。
# 不存在才写入,并返回 OK;已存在返回 nil
SET order_lock:PAY-20261028-0001 "1" NX EX 300
# OK → 首次进入,继续业务
# nil → 重复或并发占用,直接返回“处理中/已受理”
# 业务成功完成后主动释放(也可依赖 300s 过期兜底)
DEL order_lock:PAY-20261028-0001
3.3 状态机 + 乐观锁
对“状态推进型”操作(未支付→已支付),用带条件的 UPDATE:受影响行数为 0,说明已被处理,直接返回幂等成功。
-- 只有处于期望状态才推进,天然防重复
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE order_id = 10086
AND status = 'UNPAID'
AND version = 3;
-- 受影响行数 = 0 → 已被处理或状态不符,直接返回幂等成功
四、方案对比与选型
| 方案 | 适用场景 | 优点 | 注意点 |
|---|---|---|---|
| 唯一索引 | 写入型(下单/支付流水) | 数据库兜底,最可靠 | 需要稳定的业务唯一键 |
| Redis 锁 | 高频校验、防并发 | 性能高、可设超时 | 需保证原子与容灾 |
| 状态机 | 状态推进类 | 语义清晰、无额外存储 | 需设计合法状态流转 |
| Token | 表单/接口防重复提交 | 用户体验好 | 需前后端配合 |
五、常见误区
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 只在客户端禁按钮 | 接口仍可被重放 | 服务端必须校验 |
| “先查再插”判断 | 并发下仍会重复 | 用唯一索引或原子锁 |
| 锁不设超时 | 进程崩溃死锁 | 设 TTL + 兜底清理 |
| 忽略消息重复 | 消费端被处理两次 | 消费逻辑本身幂等 |
七、一个完整的下单幂等流程
把上述方案串起来,一个稳健的下单接口可以这样分层设计:① 进入下单页时,前端向后端申请幂等 Token 并随表单一起提交;② 后端用 Lua 原子校验并消费 Token,未通过直接返回“重复提交”;③ 落库时用订单号唯一索引兜底,万一 Token 被绕过也能靠数据库拦截;④ 调用支付网关前,用 Redis SET NX 对“订单号+支付渠道”加锁,防止并发重复支付;⑤ 状态推进一律用状态机 UPDATE 带条件,支付回调重复到达时直接幂等返回。五道关卡层层兜底,任何单点失效都不会导致资损。
这种“前端 Token + 数据库唯一索引 + Redis 锁 + 状态机”的组合,在电商、票务、充值等高频交易系统中几乎是标准实践。它的核心思想只有一个:不信任任何一次请求的“唯一性”,而是用多层外部状态把重复变成无害事件。当你的系统跑在多个节点上时,可结合 K8s 入门:Pod、Deployment、Service 做弹性伸缩,幂等设计正是多副本下保持一致性的前提。
八、落地清单
- 所有写接口先问一句:这个请求重复来一次会怎样?
- 防重复提交用 Token;写入型幂等优先唯一索引;状态推进用状态机。
- 锁必须有过期时间,且“获取—释放”尽量原子。
- 消费 MQ 的业务逻辑本身要幂等,不要依赖“恰好一次”。
- 在一致性与分片场景下,可结合 一致哈希实战 与容器化部署(Docker Compose 编排)做高可用。
幂等设计的本质,是承认“重复一定会发生”,并在架构层把它变成无害事件。把上面四套方案按场景组合,就能覆盖绝大多数高并发写接口的稳定性需求。




