做了这么多年后端接口设计,有一个问题几乎每次评审都会被反复讨论,那就是幂等。很多人一上来就问"这个接口要不要做幂等",但真正要回答的不是要不要,而是你的业务能不能承受重复请求带来的后果。支付扣两次款、订单生成两条、库存多扣一件,这些都是重复请求惹的祸,而幂等性设计就是专门解决这类问题的。
接口的幂等性设计,核心就一句话:同一个请求不管被客户端重发多少次,服务端最终的业务结果必须保持一致。注意,这里说的不是"返回结果一模一样",而是"业务结果不能因为重复请求而发生变化"。比如用户连续点了三次支付按钮,用户的钱只能被扣一次,订单状态也只能从待支付变成已支付一次——至于接口每次都返回"支付成功",那完全没问题。
这篇文章我想把幂等性设计从原理到落地,结合我这些年踩过的坑,完整地梳理一遍。适合正在写后端接口、做微服务改造、接支付对账、写消息队列消费者的开发者。已经懂HTTP幂等语义的,可以直接跳到后面的方案部分;刚接触的,那就从基础一点点看。
1. 幂等性是什么,HTTP语义和业务语义必须分开看
1.1 HTTP方法自带的幂等语义
先从最基础的协议层说起。HTTP标准里,GET、PUT、DELETE天然具备幂等性,POST不具备。
- GET是查询操作,重复执行一百次,结果都一样,这是幂等的。
- PUT的语义是"把资源完全替换成请求体里的样子",你提交两次,最终资源还是那个状态,幂等。
- DELETE的语义是"删除这个资源",删一次和删两次,资源最终都不存在,幂等。
- POST的语义是"创建或追加一个新资源",每次执行都会产生新效果,不幂等。
但是这里有一个很容易掉进去的陷阱:协议层的幂等语义,只对"严格按照语义实现的接口"有效。如果你用POST去做更新操作,或者用PUT去做追加操作,那协议自带的幂等保护就完全失效了。我见过不少团队在接口设计文档里写"本接口采用POST,具备幂等性",这其实是概念混淆。
1.2 业务幂等才是真正的难点
协议层的幂等只是起点,真正麻烦的是业务幂等。举一个最典型的例子:下单支付。
用户在前端点了一次"确认支付",因为手机网络不好,请求超时了,客户端在用户不知情的情况下自动重试了一次。这时候服务端收到了两笔内容一模一样的支付请求。如果这个支付接口不做任何幂等处理,用户就被莫名其妙扣了两次钱。
这个场景里,HTTP方法大概率是POST,协议层不提供任何幂等保护。你必须靠业务设计来兜底。所以我在设计接口的时候,判定一个接口要不要做幂等,从不看它用的什么HTTP方法,只看它背后的业务操作是否是"重复执行会产生额外副作用"的那一类。
2. 哪些接口必须做幂等,哪些可以偷懒不做
2.1 必须做幂等的场景
这么多年项目做下来,我总结了几类必须做幂等的接口,基本上都属于"高价值、容易超发、状态敏感"的操作:
- 支付、退款、转账:资金操作,重复执行就是钱的问题,没什么好说的。
- 订单创建、订单状态流转:用户手滑多发一次请求,不能因此生成两个订单,或者把订单状态改乱。
- 消息队列消费:MQ为了保证消息不丢失,自带重试机制,生产者也会重发消息。消费者必须能识别出重复消息,否则一条订单消息就会被处理两遍。
- 优惠券领取、积分发放:这类资源往往有数量上限,重复领取会导致超发,运营那边对账会对不上。
- 文件上传、数据导入:重试会写入重复数据,后期清洗成本极高,而且往往不容易被发现。
2.2 可以不做或少做的接口
纯查询接口,只要没有副作用,天然幂等,不需要额外做任何处理。还有一个容易被忽略的情况:如果接口的每次请求都携带不同的业务参数,比如每次订单号都不同,那么重复请求本身就不会产生相同的业务效果,幂等的优先级可以往后放一放。
我见过一些团队,一上来就要求所有写接口统一接入幂等框架,结果生产环境里平白多了一堆Redis存储和锁开销,接口性能还受了影响。正确做法是先按业务风险评估,把钱、库存、订单、优惠这类接口优先纳入幂等治理,其他的按需处理。设计上做减法,往往比做加法更难。
3. 方案一:前端预申请Token
3.1 交互流程
Token方案是使用最广泛的一种,思路是:客户端在真正发起业务请求之前,先调用服务端的一个"申请令牌"接口,拿到一个全局唯一的Token,然后再把这个Token带在业务请求里发给服务端。服务端处理业务之前,先校验这个Token是否有效、是否已经被使用过。
完整流程是这样的:
- 客户端调用"申请令牌"接口,服务端生成一个UUID或者雪花ID作为Token,把Token存到Redis里并设置过期时间,然后返回给客户端。
- 客户端拿着这个Token,带着业务参数,调用真正的业务接口。业务参数的请求头里带上这个Token。
- 服务端业务接口先校验Token是否存在且未被使用。校验通过,继续执行后面的业务逻辑;校验不通过,直接返回"重复请求"。
- 业务处理成功后,删除这个Token。以后再拿着同一个Token来请求,就直接被挡在门外了。
3.2 这个方案解决了什么问题
这个方案的本质,是把"判断请求是否重复"这个动作前置到了客户端。第一次请求之前先占一个坑,占坑成功才有资格干活,干完活把坑填掉。即使用户在页面上疯狂点击提交按钮,同一个页面会话里也只会有一个有效的Token,后面的请求全部被拦截,业务层连执行的机会都没有。
但这里有一个非常隐蔽的坑:获取Token和发起业务请求是两个独立的HTTP调用。如果客户端在拿到Token之后、发起业务请求之前崩溃了,这个Token就永远没人消费了。所以Token必须设置过期时间,我一般设置15到30分钟。过期之后Token自动失效,客户端重新申请一个就行。如果忘记设置过期时间,Redis里会堆一堆永远没人用的Token,内存就这么慢慢被吃掉了。
3.3 注意事项
Token获取接口本身也要考虑高并发。正常情况下一个用户同时只会持有一个有效Token,但如果用户开了多个浏览器标签页,或者前端代码里做了并发预取,就可能出现同一个用户同时申请多个Token的情况。存储层用Redis是最合适的,天然支持过期时间,不需要额外写定时任务清理,实现成本最低。
4. 方案二:数据库唯一约束
4.1 核心原理
数据库唯一约束是另一种非常经典的幂等方案。思路非常简单,在业务表上加一个唯一索引,用某个能标识"同一笔业务"的字段作为唯一键。重复插入的时候,数据库会因为唯一键冲突,直接拒绝第二条数据的写入。
比如订单表里加一个"业务请求号"字段,并且建立唯一索引。客户端每次下单都带上同一个请求号,第一次插入成功,第二次插入直接触发唯一键冲突异常,事务回滚,接口返回"重复请求"。这个方案不依赖Redis、不依赖任何中间件,纯粹靠数据库本身的约束力。
4.2 从插入冲突到业务成功的转化
这里我踩过一个大坑,也是很多人栽跟头的地方:只做到了"阻止重复插入",却没有处理"第一个请求其实还没执行完"的情况。
想象一下这个时序:请求A已经进入事务,正在执行支付逻辑,事务还没提交。这时候重复请求B到达了,它尝试插入同一个唯一键。如果此时A已经插入了记录但尚未提交,B的插入会被数据库的行锁或者唯一索引阻塞等待。等A提交之后,B的插入仍然失败。这时候B应该返回什么?直接返回失败是不对的——A已经成功了,B这时候应该感知到"A成功",而不是"A失败"。如果B返回失败,客户端会继续重试,无意义地放大请求量。
所以正确的做法是:捕获到唯一键冲突异常之后,不急着返回,而是去查一次这笔业务的真实状态。如果已经是成功状态,就返回成功;如果还在处理中,就返回"处理中,请稍后查询";如果上一个请求最终失败了,才允许当前请求重试。
4.3 适用场景与局限
数据库唯一约束非常适合数据一致性要求极高、并发量不是特别大的场景。优点是简单可靠,不依赖外部组件,事务边界天然一致。缺点也很明显:如果"冲突后的状态回查"逻辑没写好,容易把重复请求误返回成失败,导致客户端不断重试,最后把日志、监控和数据库压力全部打爆。另外在高并发场景下,唯一索引冲突本身也会带来一定的数据库性能开销。
5. 方案三:基于Redis的原子操作
5.1 SETNX和分布式锁
Redis方案是现在互联网业务里用得最多的一种。它性能高、天然支持分布式、实现又简单。核心是利用Redis的原子操作SETNX,让多个并发请求里只有一个能"占坑"成功。
占坑的Key一般设计成业务维度,比如"order:pay:{orderNo}",Value可以存请求方传过来的请求ID,也可以存一个时间戳。多个请求同时到达时,只有一个能SETNX成功,其他请求拿不到锁,直接返回"重复请求"或者"处理中"。
5.2 一个完整的Redis幂等实现
我在项目里常用的实现是这样:
- 请求进来,先根据业务唯一键构造Redis Key。
- 执行SET key value NX EX 300,含义是"key不存在才设置,300秒后自动过期"。
- 设置成功,说明这是第一个请求,放行,继续执行业务逻辑。
- 设置失败,说明已经有人在处理了,直接返回"处理中"。
- 业务执行完毕,根据业务结果决定Key的去留。
这里有一个很多人容易忽略的细节:业务执行完之后,不要把Key无条件地立刻删除。删得太快,客户端如果因为响应超时立刻重试,还是会重新进来一个请求,而这个时候Key已经不存在了,新请求就绕过了幂等保护。比较稳妥的做法是,让Key自然地活到过期时间,也就是说把这个过期时间设置成业务处理最大耗时的数倍,而不是业务一结束就删。这样能挡住一大片重试流量。
5.3 谁负责删除Key,是要想清楚的问题
如果你的业务确实需要在处理完后立刻删除Key,那就必须想清楚"谁删、何时删、怎么防止误删"。我见过不少团队死在"谁删Key"这个问题上:业务线程执行完就删Key,但删除后客户端立刻重试,新请求因为没有Key的约束,又把业务执行了一遍,最终还是没挡住重复。
更稳的做法是:Key里存的Value是请求的唯一标识,删除Key之前先比对Value是不是和当前请求一致,一致才删。这里又涉及一个原子性问题——"比对"和"删除"是两个操作,不做原子保护的话,在并发场景下依然可能误删别人的Key。我常用的Lua脚本是这样的:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这个脚本解决了竞态条件下的误删问题,是Redis幂等实现里一个非常关键的细节。如果不做这个比对,请求A占坑后还没处理完,请求B因为某种原因也进入了临界区(比如Key被手动失效),B处理完删掉了Key,就可能把A的坑也删掉,造成连锁的重复处理。
6. 进阶:状态机与乐观锁辅助幂等
6.1 状态机让幂等有据可依
单纯的"挡重复请求"其实只是初级手段,真正优雅的幂等设计,是在业务模型上做文章。订单、支付、任务这类有明确生命周期的事物,应该用状态机来管理。
拿订单举例,订单状态无非是待支付、已支付、已取消、已退款这几个。状态机要做的,是规定合法的流转路径:待支付可以流转到已支付,已支付可以流转到已退款,但已支付绝对不能流转回待支付,已取消的订单更不能被重新支付。
当重复请求到达时,如果这个请求试图把订单从"已支付"改成"已支付",状态机判定这个流转不合法,直接返回当前状态,不执行任何写操作。这比单纯依赖"令牌挡请求"更彻底,因为即使外层的幂等机制意外失效了,状态机仍然是最后一道防线。我在实际项目里,凡是涉及资金或者订单状态变更的,都强制要求维护一张状态机流转表,并且把校验逻辑放在事务最前面。
6.2 乐观锁:版本号才是硬道理
结合数据库实现的时候,我强烈建议给关键业务表增加一个version字段。每次更新都带上旧的版本号作为条件:
UPDATE order_table SET status = 'PAID', version = version + 1 WHERE order_no = 'xxx' AND version = 1;如果更新的影响行数是0,说明version已经不匹配了——要么是请求已经过期,要么是已经被其他请求处理过了。这时候直接放弃本次更新,返回"重复操作"或者"请刷新最新状态"。这种方式叫乐观锁,是数据库层面的硬校验,不依赖Redis,也不依赖分布式锁组件,在"并发修改同一行数据"的场景下效果极好。
6.3 三层防护的完整形态
我比较推荐的生产级形态是三层防护,而不是迷信某一个单一机制:
第一层,Redis SETNX做请求级别的快速拦截,挡住绝大多数重复流量,这一层的成本最低、响应最快。 第二层,数据库唯一约束或者乐观锁,在极端并发或者Redis出现故障的时候兜底。 第三层,状态机校验,保证任何时候业务状态都不会被非法流转。
三层各司其职,任何一层失效,后面还有一层能兜住。这不是过度设计,而是资金类、订单类接口的基本配置。我见过线上事故,Redis集群抖动导致Key全部失效,靠数据库那一层兜住了,避免了资损。这种时候你才会觉得当初多写那几行代码是值得的。
7. 常见问题与排查技巧实录
7.1 重复请求还是穿透到了业务层
最典型的故障现象就是,明明做了幂等,重复请求还是穿透到了业务层。我排查这类问题的路径一般是这样:
- 先看负载均衡层,很多网关默认会对超时请求自动重试,网关重试一次、业务接口又防不住,重复就发生了。
- 再看RPC框架或者HTTP客户端配置的重试次数。像一些框架默认开启重试,服务端处理成功了但响应在网络上丢了,客户端就再试一次。
- 接着查Redis Key的过期时间是不是设置得太短,短到业务还没处理完,Key先没了。
- 最后查业务代码里删除Key的时机,是不是处理完立刻删了,删完客户端一重试就漏了。
我把这些现象整理成了一张速查表,排查的时候对着看非常方便:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 重复请求穿透到业务层 | Redis Key过期时间太短 | 检查过期时间是否覆盖业务最大耗时 |
| 请求正确处理但响应丢失 | 客户端超时重试 | 调整客户端超时时间和重试策略 |
| 唯一键冲突但查不到成功状态 | 第一个请求事务未提交 | 检查事务隔离级别和状态回查逻辑 |
| Key被误删导致重复 | 删除Key前未校验Value | 改用Lua脚本原子删除 |
| 高并发下Redis压力过大 | 大量请求同时打到同一个Key | 加本地缓存兜底或者前置限流 |
7.2 幂等记录如何清理
Redis里Tokey和锁Key可以靠过期时间自然清理,这个没问题。但数据库里的幂等记录表,如果不清理会无限膨胀,最终拖垮查询性能。我的做法是:在幂等记录表里加上请求时间字段,起一个定时任务,定期清理超过7天的记录。清理的时候要小心,只清理那些对应业务已经进入终态(成功或者最终失败)的记录,绝对不要清理"处理中"状态的记录——万一业务还在跑,你把幂等记录删了,重试就把业务又触发了一遍,那就是事故了。
7.3 分布式环境下的时钟问题
如果幂等Key的Value里存的是时间戳,而服务是多节点部署的,各节点之间的时钟可能存在偏差。尽量不要用本地时间作为唯一性判断的依据,而是用Redis自增ID、雪花ID这类不依赖时钟一致性的全局标识。另外,多节点部署下,同一个业务请求可能落到不同的节点,所以幂等判断逻辑不能放在本地内存里,必须放在所有节点都能访问到的地方,Redis就是最合适的选择。
8. 我对幂等设计的一些经验
8.1 幂等不能只靠一个组件
早几年的时候,我以为幂等就是加个Redis锁的事。后来线上出了一次事故,才彻底明白幂等是系统工程,要覆盖从客户端重试策略、网关转发、业务处理到数据落库的整条链路。任何一环没考虑到位,重复流量总能找到空隙。现在我做接口设计评审,一定会把整条链路拉出来过一遍,而不只是看控制器那一层代码。
8.2 接口文档里要把幂等规则写清楚
很多联调问题本质不是技术问题,而是约定问题。接口文档里应该明确写清楚:哪些字段是幂等键、重复请求会返回什么错误码、客户端在什么情况下应该重试、什么情况下不该重试。我见过太多因为文档没写清楚,下游调用方把"重复请求"的错误码当成系统异常,疯狂重试,最后把服务打挂的案例。文档里多写一行,联调阶段能省十倍的沟通成本。
8.3 最后分享一个小技巧
调试幂等接口的时候,我会在测试环境专门准备一个脚本,用同一个幂等Key并发发10个请求,然后看最终业务数据是不是只生效一次。这个脚本我每次接支付类需求都会跑一遍,顺便也验证一下Redis Key的清理逻辑是不是正确。别看它简单,比看一百行代码review都管用,因为并发场景下的幂等问题,静态代码review很难看出来,只有真实并发压一下才能暴露。