幂等设计实战:接口防重复提交与分布式幂等

在分布式系统与高并发接口里,幂等设计是保证“同一请求多次执行结果一致”的核心能力。网络超时重试、消息队列重复投递、用户连点提交,都会让没有幂等保护的接口产生重复下单、重复扣款。本文用可落地的代码,讲清从前端防重复提交到后端分布式幂等的完整方案。

举个最常见的例子:用户在支付页点击“确认支付”,前端发出请求后网络抖动没收到响应,客户端便自动重试。如果服务端没有幂等保护,两次请求都可能“成功扣款”,银行卡被划走两笔。这类故障在日志里常表现为“同一订单出现两笔支付流水”,往往要等到事后对账才能发现,排查成本极高。

一、为什么一定要做幂等设计

“幂等”原本是数学概念: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 编排)做高可用。

幂等设计的本质,是承认“重复一定会发生”,并在架构层把它变成无害事件。把上面四套方案按场景组合,就能覆盖绝大多数高并发写接口的稳定性需求。

上一篇 fzf 实战:命令行模糊查找提速日常操作
下一篇 DuckDB 实战:嵌入式分析型数据库的本地 OLAP 新选择
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]