资讯动态

接口幂等性设计全解析:从重复请求防护到Redis与状态机实践

发布时间:2026/10/15 1:11:42 来源:尧图企业网站定制
做了这么多年后端接口设计有一个问题几乎每次评审都会被反复讨论那就是幂等。很多人一上来就问这个接口要不要做幂等但真正要回答的不是要不要而是你的业务能不能承受重复请求带来的后果。支付扣两次款、订单生成两条、库存多扣一件这些都是重复请求惹的祸而幂等性设计就是专门解决这类问题的。接口的幂等性设计核心就一句话同一个请求不管被客户端重发多少次服务端最终的业务结果必须保持一致。注意这里说的不是返回结果一模一样而是业务结果不能因为重复请求而发生变化。比如用户连续点了三次支付按钮用户的钱只能被扣一次订单状态也只能从待支付变成已支付一次——至于接口每次都返回支付成功那完全没问题。这篇文章我想把幂等性设计从原理到落地结合我这些年踩过的坑完整地梳理一遍。适合正在写后端接口、做微服务改造、接支付对账、写消息队列消费者的开发者。已经懂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. 方案一前端预申请Token3.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很难看出来只有真实并发压一下才能暴露。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑