
1. 幂等的本质与需求拆解一开始看到“幂等的双倍快乐”这个说法很多没踩过线上事故的同学可能觉得这是句俏皮话。但干过几年后端、维护过支付或者订单系统的朋友看到这几个字心里多半会咯噔一下这不是梗这是用血泪换来的真理。幂等Idempotency翻译成大白话就是同一个操作不管你执行一次还是执行一万次最终对系统产生的效果是一样的不会因为重复执行而多扣钱、多发券、多建订单。分布式系统里网络是不可靠的请求超时、消息重试、用户手抖连点都是常态。如果你能保证接口幂等超时后重试就是安全的用户连点三下也是安全的消息队列重复投递照样安全。这种“怎么折腾都不会出乱子”的安全感加上“第一次顺利成功”的爽快感加在一起才是真正的双倍快乐。我一直认为幂等是后端开发里最值得花时间啃的一块硬骨头。原因很简单它不挑业务方向只要是涉及写操作、涉及资金或者涉及资源发放的系统幂等都是一道绕不开的关卡。这篇内容适合所有后端开发、架构设计相关的朋友阅读尤其是正在做订单、支付、优惠券、积分这一类强一致性业务的同学。哪怕你还在校招阶段搞懂幂等的原理和落地方式面试聊到分布式一致性时也会比别人扎实一截。很多人对幂等有一个认知误区觉得“这不就是加个唯一索引嘛”。确实唯一约束是常见的实现手段但幂等设计的真正难点在于理解你的业务里哪些操作天然具备幂等性哪些操作需要额外设计才能变得幂等以及幂等键怎么取、状态怎么流转、失败之后怎么恢复。这一篇我会从核心场景开始讲把落地细节和踩坑记录一并铺开。2. 从业务场景里看清幂等到底在保护什么光说理论容易飘先看几个最典型的场景你就明白幂等在设计上的分量了。2.1 支付下单最典型的“重复请求放大器”用户在下单页点了一次“提交订单”前端因为网络原因没有收到响应用户下意识又点了一次。如果下单接口不做幂等控制第二请求进来后会直接再创建一个新订单。结果就是用户只付了一笔钱后台却生成了两个订单。更麻烦的是如果这个下单操作同时还调了库存扣减两个订单会把库存扣两次哪怕用户只支付了其中一单另外一单也可能占用着库存没释放。支付回调是另一个重灾区。支付平台回调你的通知接口为了保证消息不丢支付平台自身会做多次重试有时候一小时之内回调七八次都很正常。每次回调如果你都去更新一遍订单状态、都去加一遍账户余额用户账户余额直接翻倍。这不是理论推演这是真实发生过的线上事故损失真要白纸黑字报上去的。2.2 消息队列重试看不见的重复消费用过消息队列的朋友都知道消息投递至少一次At Least Once是很多主流中间件的默认行为。基于至少一次投递消费者在业务处理成功之前如果发生报错、超时消息就会重新进入队列再次投递。你在消费者里做了一次“发放优惠券”操作代码里没有幂等保护重复消费一次就是给用户多发一张券。这个问题比接口幂等还要隐蔽因为从日志上看每一条消费记录都真实发生过定位问题的时候非常难复盘。2.3 防重与幂等的边界这里必须把两个概念掰开。防重通常指的是“同一个用户短时间内不要把同一个操作执行两次”它更多是一种体验保护比如按钮置灰、前端拦截背后往往是防抖或者简化版的去重标记。幂等则强调“同一个业务动作重复提交后结果保持一致”它不要求你必须拦截第二次请求而是在第二次请求到达时能识别出“这事儿已经做过了”然后返回第一次的既定结果。举个例子防重像是电梯里的关门按钮按一下亮灯再按也没什么后果它只管别让你重复按出问题。幂等则像是同一张电影票刷两次闸机第一次正常开门进入第二次再来刷闸机认得这张票已经验过了开不开门先不说但绝不让你凭同一张票再看一次电影。一张票对应一次入场这就是幂等的结果一致性。理解这个边界之后你就明白为什么光靠前端防重远远不够。就算你把按钮禁得死死的超时重试、网关重发、消息重投这些路径全部绕过了前端最终还是你的后端接口在兜底。3. 四条实现路径的原理与选型搞懂幂等要保护什么之后下一步是选实现方案。不同的业务场景、不同的并发规模、不同的数据存储方式对应方案完全不一样。我按自己实际工程里的使用频率排序给你逐一拆解。3.1 数据库唯一约束最朴素也最可靠核心思路很简单在数据库表上建一个唯一索引把业务里的“自然幂等键”作为唯一键往里插。第一个请求插入成功了后面的请求再插就会因为唯一键冲突被数据库挡下来业务方捕获这个冲突后按“已处理过”逻辑返回。举一个实际例子。你在做优惠券发放每一次发放都落一张记录表。建表时给业务流水号字段加唯一索引这个流水号是上游请求方传过来的幂等键。第一次请求插入成功用户拿到券第二次相同流水号再来插入失败但你能捕获到唯一约束冲突然后查出已有的发放记录返回给调用方。整个过程不需要锁不需要额外存储数据库自带的事务能力就帮你把并发抢插的问题解决了。这套方案最大的优点就是可靠。数据库的唯一约束是数据库内核层做保证的只要磁盘没坏、事务正常这个约束就一直在。对于中小型业务这是性价比最高的幂等实现方式。缺点是它要求你每次请求都得先写一次库如果幂等校验高频执行表会膨胀得比较快而且唯一索引的写放大问题在某些高吞吐场景下会比较扎眼。提示用这个方案时唯一键冲突需要单独捕获处理不要因为一条记录插不进去就抛500出去。把冲突转成“已存在”的语义返回给调用方时带上原有业务结果体验会好很多。3.2 状态机流转让业务流程自己拒绝重复状态机方案的核心思想是业务操作的本质是让数据从状态A流转到状态B如果当前状态已经处于B甚至更后面的状态说明这个操作早就完成过直接拒绝或者直接返回成功。订单系统是状态机幂等的教科书场景。一个订单从“待支付”到“已支付”再到“已发货”每一步流转都是由明确事件驱动的。支付回调进来的时候先查订单当前状态如果已经是“已支付”说明回调重复了直接返回成功即可不需要再执行任何余额变更、积分累计逻辑。状态机方案的关键在于状态流转必须是有方向的并且状态更新的SQL必须带上当前状态条件。实际落地时我习惯配合UPDATE的WHERE条件来保证并发安全类似这样UPDATE orders SET status PAID, paid_at NOW() WHERE order_id #{orderId} AND status PENDING;这条SQL只有一行被更新的情况下才说明订单确实是从待支付变成了已支付。如果更新的行数是0说明订单状态已经不在待支付阶段要么是已经被别的请求支付过了要么是订单本身状态异常两种情况都按“重复请求”处理。一个UPDATE语句把“状态校验”和“状态更新”合成了一个原子操作不需要额外加锁也不需要事务外再查一次再改一次那么费劲。3.3 Redis Token 机制适合前置拦截Token机制是另一种常见思路客户端在发起业务请求之前先向后端申请一个一次性令牌后端把这个令牌存在Redis里并设置短过期时间真正执行业务请求时客户端必须携带这个令牌后端拿到令牌后删除Redis中的对应key删除成功则放行业务处理删除失败说明令牌已用或已过期直接拒绝。这个方案解决的是“同一个操作不能重复发起多次”的问题尤其适合那些“前端提交动作特别容易连点”的场景。比如抢购页面、活动报名页面用户狂点提交按钮如果没有令牌保护瞬间就是好几个并发请求打到后端。有了令牌机制后几个请求里只有一个能成功拿到令牌执行其余请求拿不到令牌直接返回“处理中”之类的提示。Token方案的注意点集中在“删除令牌”这个动作的原子性上。在Redis里判断是否存在再删除这两步之间有并发窗口两个线程同时拿到同一个token就会双双通过校验。标准做法是用Lua脚本把“检查存在删除”合成一个原子操作if redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 endRedis的单线程模型保证了Lua脚本执行期间不会被其他命令插队所以这段脚本在并发场景下是安全的。但从分布式可靠性来看token方案有一个隐含前提Redis必须可用。Redis挂掉的时候令牌检查这个前置环节就瘫痪了所以生产环境里token机制通常用来做第一道防线业务核心的幂等兜底还是得靠数据库层再卡一道。3.4 分布式锁与乐观锁另一类幂等辅助分布式锁比如用Redis实现的互斥锁常用于防止多个节点并发执行同一个关键任务。它和幂等的关系是幂等要求“同一业务动作不重复生效”分布式锁能保证“同一时间只有一个节点在执行”这两者功能上有重叠但不是一回事。举个例子定时任务在多个实例部署时为了防止两个实例同时跑同一个对账任务你需要分布式锁互斥。但锁竞争通过之后A节点跑完任务宕机了B节点在锁过期之后重新执行任务任务内部的写操作如果没有幂等保护照样会产生重复数据。所以分布式锁解决的是并发互斥幂等解决的是重复结果分布式环境里二者经常搭配使用但互相替代不了。乐观锁则是基于版本号的更新方式。每次更新前先查出版本号UPDATE时把版本号作为条件更新成功则版本号递增。这个思路本质上和上面说的状态机更新异曲同工都是利用“受影响行数”来判断当前操作是否合法。乐观锁适合那些对数据一致性要求非常高、更新频率适中且冲突概率不高的场景。3.5 选型对照什么场景用什么方案这里我根据自己的工程经验整理一张选型表方便你结合实际业务直接套方案核心原理适用场景优点短板数据库唯一约束唯一索引拦截重复插入订单、流水、券码发放中低并发最可靠数据库原生保证表膨胀、写放大、依赖数据库状态机流转利用状态方向性拒绝重复执行订单状态变更、审批流、任务流轻量、天然契合业务需要梳理清楚状态机状态设计需严谨Redis Token一次性令牌原子删除前端防连点、抢购提交响应快、实现简单依赖Redis可用性兜底不足分布式锁互斥控制并发执行多实例定时任务、资源抢占控制并发能力强与数据幂等无直接关系主从延迟可能失效乐观锁版本号条件更新高并发更新、库存扣减无锁、性能好冲突频繁时重试成本高多数情况下我上线一个项目会至少叠两层Redis token做前置拦截挡住大部分重复请求数据库唯一索引或状态机做最终兜底。简单点说一个是挡在门口的保安一个是业务关口最后一个查验票据的检票员两道防线配合才能睡得着觉。4. 从0到1实战幂等方案的落地细节全记录理论聊完了下面进入实操环节。拿一个真实的模拟场景举例某积分商城做一个“每日签到领积分”功能用户每天只能签到一次领到固定的积分奖励。这个需求量级不大但幂等诉求很明确。我带着你从幂等键选取开始走一遍完整落地流程。4.1 第一步找对幂等键比方案更关键幂等方案不管选哪种首先要回答一个问题什么标识能代表“同一个操作”找错幂等键后面全白搭。签到场景里“日期用户ID”就是天然幂等键。同一天同一个用户只能签到一次这个组合天然唯一。如果接口设计不敏感客户端传一个全局唯一的请求IDUUID也是常用手段。但请注意用客户端生成的UUID做幂等键要面临客户端重复生成ID的风险。比如用户清了下缓存下次请求生成新的UUID后端就把它当成了新操作。这类风险大家权衡时一定要考虑清楚。我的经验是优先从业务语义里找幂等键。业务上能确定“同一主体同一周期内只能一次”的组合就直接用这个组合业务上没有天然键时再用全局唯一请求ID同时把请求方、用户标识、操作类型拼进去降低碰撞和伪造概率。4.2 第二步建一张幂等记录表为了通用性和排查方便我习惯单独建一张幂等记录表而不是把业务逻辑全塞进业务表。这张表的职责很简单记录“哪个幂等键在什么时间被处理过”。表结构大致这样CREATE TABLE idempotent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, idempotent_key VARCHAR(128) NOT NULL COMMENT 幂等键user_id date等, business_type VARCHAR(64) NOT NULL COMMENT 业务类型比如SIGN_IN, request_body TEXT COMMENT 首次请求快照便于排查, response_body TEXT COMMENT 首次响应快照, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1成功 2失败, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_idempotent_key (business_type, idempotent_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表有两个设计细节值得强调。一是唯一索引放在“业务类型幂等键”的组合上同一个幂等键在不同业务类型下可以独立存在避免业务间互相干扰。二是预留了status字段和request/response快照这些字段在排查问题时有奇效。我后面讲排查技巧时会再展开。4.3 第三步插入与业务事务绑定核心逻辑分四步业务请求进来先查幂等记录表。如果查到status1直接把保存的响应结果返回这是“重试请求返回原结果”的标准做法。没查到则尝试插入幂等记录status设为0。插入成功说明当前请求拿到了唯一执行权继续执行业务逻辑发放积分然后更新幂等记录status1。插入失败捕获唯一键冲突说明有人抢先处理过了再查一次记录返回状态。这里有一个“查-插-改”三步走的时序问题两个并发请求同时进来第一步都查不到记录然后都去插入最终只有一个能插成功。数据库唯一索引天然拦下了并发不需要额外加锁。但要注意的是插入记录和执行业务必须在同一个数据库事务里否则如果业务执行中途失败回滚了幂等记录却已经插入成功且status0下一次请求看到status0会陷入“处理中”的中间态。Transactional public SignResult signIn(Long userId) { String key buildIdempotentKey(userId); // 1. 尝试插入幂等记录 try { IdempotentRecord record new IdempotentRecord(); record.setIdempotentKey(key); record.setBusinessType(SIGN_IN); record.setStatus(0); idempotentRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 2. 唯一键冲突请求在并发下被抢先处理查状态返回 IdempotentRecord existing idempotentRecordMapper.selectByUniqueKey(SIGN_IN, key); if (existing.getStatus() 1) { return buildResultFromSnapshot(existing.getResponseBody()); } throw new BizException(签到正在处理中请勿重复提交); } // 3. 插入成功执行业务逻辑 int points 10; pointsService.addPoints(userId, points); // 4. 更新幂等记录为成功保存快照 String resp buildResponse(points); idempotentRecordMapper.updateStatusAndSnapshot(key, 1, , resp); return buildResult(points); }4.4 中间态与超时恢复策略上面代码里status0的“处理中”状态一旦出现理论上需要有一条恢复路径。如果业务插入幂等记录后应用进程突然崩溃事务回滚幂等记录原本也会跟着回滚所以从严格意义上说事务回滚后不会留下status0的记录。但有一种情况要注意事务已经提交幂等记录status1已经写成功但响应在网络上丢失了调用方没收到结果于是重试。此时调用方再来请求查到的就是status1直接返回快照即可问题不大。如果业务设计上不走同一事务幂等记录表单独提交那status0的中间态就会真实存在。针对这种情况需要加一个超时任务定期扫描status0且created_at超过N分钟未被更新的记录标记为失败或直接删除让用户重新发起。超时时间不宜设得过短要覆盖业务最长的处理时间否则容易把正常的慢请求误杀。4.5 过期清理与性能平衡幂等记录表是无脑增长的每一笔业务请求都会落一条记录。如果不清理一两年后这张表可能就是上亿级数据查询和唯一索引的维护成本越来越高。我的习惯是两种策略组合批量删除定期把超过90天的成功记录物理删除保留最近90天的数据用于排查问题。分区表按天或者按月做RANGE分区清理时直接DROP掉过期分区比DELETE一条条删快几个量级。数量级非常高的场景下也可以考虑把幂等记录从业务库挪到独立的Redis缓存里用Redis做短期比如24小时幂等判断超过期限的不再拦截。但要接受一个事实Redis的可靠性弱于数据库极端情况下Redis数据丢失会导致幂等失效所以关键业务还是把数据库记录兜底方案留着。5. 常见问题与排障实录那些年我们踩过的幂等坑最后这部分我把自己在实际开发和线上排障里遇到的高频问题整理成了一个速查清单。这些问题单看都简单但每一个背后都对应过真实的生产事故教训。5.1 为什么“查了再改”还是出了重复数据很多同学写代码时会在业务层先SELECT一下状态再根据状态决定是否执行更新。这个逻辑单独看没问题并发场景下就崩了两个请求同时SELECT都看到旧状态然后同时执行UPDATE结果更新了两次。这就是典型的“先查再改”竞态条件。正确的做法是把“判断条件”和“更新操作”合成一条SQL让数据库在原子层面做完状态校验和更新。上面提到的状态机UPDATE写法就是标准解。如果你用事务SELECT FOR UPDATE加锁来规避也能解决问题但锁等待的吞吐损耗明显更高能不用尽量不用。5.2 唯一索引防住了并发却拦不住“不同键同操作”有些业务代码把幂等键设计错了。比如用户提交订单你用“设备ID”做幂等键而不是“订单号”或“用户商品时间”那么换台设备就能重复提交。这不算并发问题是幂等键选取不严谨。排查这类问题时我会先问三个问题这个操作在业务语义上什么情况下算“重复”同一用户重复吗同一对象重复吗同一时间窗口重复吗把业务语义对清楚了幂等键才能对。泛泛的UUID虽然不会错但也容易把本应拦截的“重复”漏放过去因为每次UUID都不同。5.3 状态机更新行数为0不一定意味着幂等生效前面说了UPDATE受影响行数是0就当成重复请求。但有一种情况很特殊UPDATE条件里带status PENDING而订单当前状态确实是PENDING但因为值本身没变化MySQL默认返回的影响行数可能还是0。这个问题和MySQL驱动配置有关。useAffectedRows参数在JDBC连接串里设置时返回的是“实际被修改的行数”不设置或为false时返回的是“匹配的行数”。如果你的幂等判断依赖“行数0”这个条件务必确认驱动返回的语义。我在一个项目里排查了整整一天最后发现是连接串没配useAffectedRowstrue导致明明更新成功了却走了重复逻辑分支。5.4 Redis分布式锁的主从切换导致锁失效Redis主从架构下A节点拿到锁主节点宕机从节点顶上但还没同步完锁数据B请求在新的主节点上又拿到了同一个锁互斥就失效了。这个现象叫“主从延迟导致的锁失效”在极端故障下会发生。如果业务对互斥要求极其严格就别迷信单靠Redis锁做幂等。要么配合数据库唯一约束做兜底要么引入类似RedLock的算法但RedLock本身也有争议。实际上在我做过的系统里核心资金操作的互斥几乎都是用数据库行锁或者唯一索引控制的Redis锁只负责降低并发冲突概率提升用户体验。5.5 响应快照的价值最后分享一个所有方案里最容易被忽略的细节幂等记录表里存响应快照。当用户重复提交时正确做法不是简单返回“你已经操作过了”而是把第一次成功的结果原样返回。比如签到积分接口第一次返回“成功10积分”第二次重复请求时如果能返回同样的结构体前端逻辑就能统一处理不需要额外写分支。更关键的是出问题复盘时响应快照能让你看见第一次请求时业务到底返回了什么这对定位前端为什么没收到、用户为什么重复点击这一类问题极其有用。我一直认为响应快照不是可选项而是幂等方案里必须考虑的组件。没有快照的幂等兜底用户重复请求拿到一个语义不清的“重复”提示既影响体验又增加了排查难度。5.6 超时重试的链路设计要和幂等配套超时重试不是随便在请求失败后无脑重发。必须明确这个接口是否幂等如果幂等重试间隔可以短一些比如1秒、2秒、4秒退避如果不幂等重试就极其危险。我见过一个团队把所有失败请求都丢到重试队列结果恰好碰上下游接口无幂等保护重试风暴直接打崩了下游服务。所以团队内部定一条铁律任何允许重试的接口必须显式声明其幂等策略声明不了的不允许自动重试。这个约束可以写在接口文档规范里也可以做成框架层的注解强制开发者填写幂等键生成方式。5.7 常见坑速查表问题现象根因排查思路与解法用户重复提交产生了双订单接口无幂等或幂等键设计错误确认业务重复语义找到正确的幂等键加唯一约束兜底并发下双重发放积分先查再改的竞态窗口改成单条UPDATE带条件或唯一索引拦截同一请求重试后返回了不同结果幂等命中后直接返回写死的文案加响应快照走“有快照则返回快照”逻辑更新行数为0时误判为重复JDBC affectedRows 语义未配置检查连接参数useAffectedRows统一返回语义Redis锁或token在故障期失效依赖了主从同步弱一致核心业务务必用数据库层兜底幂等记录表无限膨胀缺少清理策略分区表定期DROP或引入Redis短期幂等层MQ重复消费始终偶发消费者缺少幂等处理消费逻辑套用同样的幂等模板捕获重复时提交消费进度我踩过其中一大半的坑。最深的感受是幂等设计不是上线前一个晚上能补出来的它必须在接口设计阶段就定好方案。等流量进来、事故发生后再想在数据库上临时加唯一索引往往已经晚了因为线上数据已经脏了补数据比防重复要痛苦得多。根据我个人的实际体会每次在新的项目里评估一个接口要不要做幂等我基本不看接口本身大小只看两件事这个接口会不会因为网络或重试被调用多次多次调用会不会产生不可预期的副作用。只要两个答案都是“会”就老老实实把幂等加上。这不是过度设计这是给未来的自己和运维的同事留一条活路。最后再分享一个小技巧。如果你时间紧张没法对每一个写接口做精细化幂等设计那就先从“对外暴露的写接口”抓起统一做一个幂等中间件基于请求头里的全局ID自动拦截重复请求。这个中间件能挡住绝大多数重放攻击和用户连点问题虽然做不到业务语义级别的完美幂等但作为第一道通用防线性价比极高。先把通用的做了再逐步把高频核心接口升级成业务语义幂等这个演进路线比较适合多数团队。