接口幂等性设计详解:幂等键、Token、唯一索引、去重表与支付回调
超时重试、用户双击、MQ 重投、网络抖动,都会让同一个业务动作到达服务端不止一次。幂等设计的目标是让”执行一次”和”执行多次”产生完全相同的业务效果。本文梳理重复请求的来源、天然幂等与需要设计的幂等,以及唯一约束、防重 Token、状态机、分布式锁等方案的适用边界与代码思路。
它解决什么问题
幂等(Idempotency)原本是数学概念:f(f(x)) = f(x),操作执行多次与执行一次效果相同。放到接口语义上,指同一个请求无论到达一次还是一百次,服务端业务状态的变化都一样——不要求返回值逐字节相同,但副作用必须只发生一次。
重复请求从哪里来?主要有四个源头:
- 超时重试:客户端、网关、RPC 框架在超时后自动重发,而超时恰恰可能是”服务端已处理成功但响应丢了”。
- 用户操作:双击提交按钮、刷新页面重复提交表单、App 断网恢复后重发请求。
- MQ 重复投递:主流消息队列只承诺至少一次(at-least-once)投递,消费者处理完但 ack 丢失时,消息会再次送达。
- 网络与中间件抖动:响应包丢失、负载均衡重发、第三方平台按策略多次回调(支付回调最典型)。
不处理幂等的代价,在资金、库存、订单场景是资损级事故:重复扣款、重复发货、库存超卖。更麻烦的是这类问题平时不暴露,压测和故障时才集中爆发。
核心原理
天然幂等与需要设计的幂等
先看操作本身的性质:
- 查询、按主键删除、
UPDATE SET status = 'PAID'这类赋值更新是天然幂等的,重复执行结果不变。 UPDATE SET balance = balance - 100这类增量更新、无条件 INSERT、调用外部接口都不是幂等的,重复一次副作用就叠加一次。
判断一个接口要不要做幂等,就看它是否包含非幂等副作用。包含,就必须引入”请求身份”的概念:给每次业务动作分配全局唯一标识(幂等键,可以是业务单号、交易流水号,或客户端生成的 Idempotency-Key),服务端凭它识别”这是不是同一件事”。所有幂等方案的内核都是同一句话:用唯一身份识别重复,用原子机制保证只执行一次。
“先查后做”的并发陷阱
最直觉也最错误的做法是:先 SELECT 查这笔单处理过没有,没处理过就执行。两个并发请求可能同时查到”未处理”,于是都执行——检查与执行之间的时间窗口就是重复的入口。所以幂等判断必须落到原子操作上:数据库唯一索引冲突、Redis 原子命令,或锁保护下的临界区。纯应用层的 if-else 判断在并发下必然失效。
深入机制
数据库唯一约束与去重表
给业务单号建唯一索引,插入冲突即重复:
CREATE TABLE pay_order ( |
try { |
去重表是同一思路的独立化:单建一张只含唯一键的处理记录表,先插去重表再办业务。关键点是把去重表插入和业务写入放进同一个事务——事务回滚则去重记录一起消失,后续请求还能正常重入;否则会出现”记录说办了、业务没办成”的死局。
防重 Token
表单类接口的经典方案。页面加载时服务端签发一次性 Token,存 Redis 并设短过期;提交时服务端校验并删除,验证通过才执行:
// Redis 6.2+ 原子地"取出并删除",同一 Token 只能被消费一次 |
先消费 Token 还是先办业务存在取舍:先办业务,Token 未删期间的重放会穿透;先消费 Token,业务失败后用户要重新获取。工程上推荐先原子消费、失败时明确提示重新发起,且资金类场景不能只靠 Token,必须有数据库唯一约束兜底。
状态机防重
订单、支付这类有生命周期的业务,用状态流转条件把”只允许执行一次”写进更新语句:
UPDATE pay_order |
状态机天然幂等:重复请求撞上 status = 'UNPAID' 不成立,影响行数为 0,无副作用。它还顺带解决了乱序问题——已关闭的订单不会被迟到的回调重新置为已支付。凡是有状态流转的业务,这是成本最低、最该先做的幂等层。
分布式锁串行化
对同一业务资源(如订单号)加分布式锁,把并发重试串成串行:
RLock lock = redissonClient.getLock("idempotent:" + orderNo); |
锁只负责”同一时刻只有一个请求在办”,不代表历史上没办过——进入临界区后仍要查业务状态或幂等记录。锁粒度按业务标识走,锁的租约时间按业务最长耗时的两到三倍设置。
MQ 消费幂等
MQ 只保证至少一次投递,消费端必须自己消化重复。标准做法是消费前用消息 ID 或业务流水号查/插消费记录表(或 Redis 的 SETNX),已消费直接 ack 跳过;未消费则在同一事务里完成”业务写入 + 记录消费位”。消费失败要把异常抛出让 MQ 重投,不能因为怕重复就吞掉异常——那是丢消息,比重复更糟。
工程实践
- 分层兜底:前端按钮置灰、防连点只是体验优化;服务端的唯一索引加状态机是底线;Token、分布式锁按场景叠加。任何单层都不能宣称”已解决幂等”。
- 幂等键设计:优先用业务天然唯一字段(订单号、交易流水号);没有时让客户端在发起流程前生成 UUID 作为 Idempotency-Key,服务端按它去重。
- 重复请求的响应要与首次一致:返回首次成功的结果而不是报错,否则上游会认为失败而继续重试。
- 验证靠测试:同一幂等键并发打 50 个请求只应产生一条流水;模拟超时重试、回调重发、MQ 重投后,订单状态、资金流水、库存数量交叉对账必须严丝合缝。
对比与权衡
| 方案 | 适用场景 | 优点 | 代价 / 风险 |
|---|---|---|---|
| 唯一索引 / 去重表 | 插入类(建单、流水) | 数据库层硬保证,最可靠 | 只适用插入;去重表需与业务同事务 |
| 防重 Token | 表单提交、页面操作 | 通用,对业务侵入小 | 依赖 Redis 可用性;两步交互;需配合兜底 |
| 状态机 | 有状态流转的业务(订单、支付) | 零额外存储,顺带防乱序 | 不适用无状态的增量操作 |
| 分布式锁 | 同一资源的并发更新 | 通用,可保护复杂临界区 | 性能开销、锁超时治理;锁内仍要查状态 |
| 乐观锁(版本号) | 并发更新同一条记录 | 无锁开销 | 冲突频繁时重试放大;需定义失败策略 |
选择优先级:能用业务唯一键加唯一索引的不要发明轮子;有状态流转先上状态机;需要串行化同一资源再加分布式锁;资金类场景多层叠加,不押宝单一机制。
面试视角
幂等和防重有什么区别? 防重是阻止重复请求到达或执行,偏拦截;幂等是允许重复请求到达,但保证副作用只发生一次,偏结果。工程上追求的是幂等,防重只是减轻幂等逻辑压力的辅助手段。
为什么”先查询再操作”不能保证幂等? 查询与执行之间存在时间窗口,两个并发请求可能同时读到”未处理”而都执行。判重必须落到原子机制上:唯一索引冲突、Redis 原子命令或锁内检查,应用层的先查后做在并发下必然失效。
MQ 消费怎么做幂等? 消费端用消息 ID 或业务流水号去重:先查消费记录,已消费直接 ack;未消费则业务写入与消费记录在同一事务提交。消费失败抛异常触发重投,靠消费端幂等消化重复,而不是吞异常造成丢消息。
Token 机制先删 Token 还是先执行业务? 推荐先原子消费(GETDEL 或 Lua)再执行业务:先执行业务则 Token 存续期间的重放会穿透。代价是业务失败后用户需重新获取 Token,且必须保留数据库唯一约束做最后兜底。
支付回调如何设计幂等? 以第三方交易流水号为幂等键建唯一索引;更新订单用状态机条件 UNPAID -> PAID;订单更新、资金流水、回调记录放进同一事务;重复回调直接返回成功,终止第三方继续重试。
小结
- 重复请求来自重试、双击、MQ 重投和网络抖动,不可能在上游杜绝,只能在服务端消化。
- 先区分操作是否天然幂等;非幂等操作必须引入幂等键,并把判重落到原子机制上,拒绝先查后做。
- 方案栈从底到上:唯一索引兜底、状态机防流转重复、Token 防表单重提、分布式锁串行化临界区、MQ 消费记录防重投。
- 重复请求要返回与首次一致的结果;上线前用并发压测、重试注入和数据对账验证,而不是只跑通正常路径。

