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”就是天然幂等键。同一天同一个用户只能签到一次,这个组合天然唯一。如果接口设计不敏感,客户端传一个全局唯一的请求ID(UUID)也是常用手段。但请注意:用客户端生成的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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表有两个设计细节值得强调。一是唯一索引放在“业务类型+幂等键”的组合上,同一个幂等键在不同业务类型下可以独立存在,避免业务间互相干扰。二是预留了status字段和request/response快照,这些字段在排查问题时有奇效。我后面讲排查技巧时会再展开。
4.3 第三步:插入与业务事务绑定
核心逻辑分四步:
- 业务请求进来,先查幂等记录表。如果查到status=1,直接把保存的响应结果返回,这是“重试请求返回原结果”的标准做法。
- 没查到,则尝试插入幂等记录,status设为0。
- 插入成功,说明当前请求拿到了唯一执行权,继续执行业务逻辑(发放积分),然后更新幂等记录status=1。
- 插入失败,捕获唯一键冲突,说明有人抢先处理过了,再查一次记录返回状态。
这里有一个“查-插-改”三步走的时序问题,两个并发请求同时进来,第一步都查不到记录,然后都去插入,最终只有一个能插成功。数据库唯一索引天然拦下了并发,不需要额外加锁。但要注意的是,插入记录和执行业务必须在同一个数据库事务里,否则如果业务执行中途失败回滚了,幂等记录却已经插入成功且status=0,下一次请求看到status=0会陷入“处理中”的中间态。
@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 中间态与超时恢复策略
上面代码里status=0的“处理中”状态一旦出现,理论上需要有一条恢复路径。如果业务插入幂等记录后,应用进程突然崩溃,事务回滚,幂等记录原本也会跟着回滚,所以从严格意义上说事务回滚后不会留下status=0的记录。但有一种情况要注意:事务已经提交,幂等记录status=1已经写成功,但响应在网络上丢失了,调用方没收到结果于是重试。此时调用方再来请求,查到的就是status=1,直接返回快照即可,问题不大。
如果业务设计上不走同一事务,幂等记录表单独提交,那status=0的中间态就会真实存在。针对这种情况,需要加一个超时任务:定期扫描status=0且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”这个条件,务必确认驱动返回的语义。我在一个项目里排查了整整一天,最后发现是连接串没配useAffectedRows=true,导致明明更新成功了却走了重复逻辑分支。
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自动拦截重复请求。这个中间件能挡住绝大多数重放攻击和用户连点问题,虽然做不到业务语义级别的完美幂等,但作为第一道通用防线,性价比极高。先把通用的做了,再逐步把高频核心接口升级成业务语义幂等,这个演进路线比较适合多数团队。