
如果你真的上过生产环境大概率碰到过这类事支付平台回调连着重发三条通知用户手一抖双击了下单按钮或者MQ把同一条消息投递了两遍最后的结果往往是订单蹦出两单、余额被扣了两次运维和研发大半夜对着日志相互甩锅。这种问题的根源十有八九是接口没有做接口幂等性设计。接口幂等性这个概念后端面试年年考线上故障月月见。它的定义其实很简单同一个业务操作你执行一次和执行N次最终落库的数据结果必须完全一样。注意我说的是“业务结果”不是“响应内容”——重复请求的响应可以是失败、可以是成功、也可以是“你重复了”但数据不能被重复写坏。这篇文章我从原理讲到落地覆盖三套主流方案数据库唯一索引、Redis Token与分布式锁、状态机与乐观锁最后补一条从网关到存储的全链路幂等防线设计并附上我在实际项目里踩过的坑和测试思路。无论你是刚转后端的新人还是正在做接口改造的老手这都是一份可以直接参考的实战手册。1. 先搞清前提哪些接口需要幂等哪些不需要很多新人一开口就背HTTP方法语义GET是幂等的、PUT是幂等的、DELETE是幂等的POST不幂等。这句话在REST设计规范里算是对的但拿到真实业务里只能当参考不能当护身符。原因很简单HTTP方法语义描述的是“请求本身对资源的预期影响”而业务侧的幂等性描述的是“数据最终落库是否一致”。一个GET接口如果内部执行了扣库存、发短信这类副作用操作那它就一点都不幂等反过来一个POST接口只要在设计上做了去重处理也可以做到幂等。所以判断标准永远只有一个重复执行以后数据库里的关键数据会不会被多写、错写、覆盖成错误状态。1.1 HTTP语义只是参考GET也可能不幂等讨论幂等性绕不开HTTP方法的语义分类但我在实际项目里很少直接拿语义当决策依据。为什么因为绝大多数团队根本没有按照纯正的REST风格设计接口接口名和HTTP方法往往只是路由签名真正决定幂等性的永远是接口内部做的事情。举个很简单的例子一个GET/pick/coupon?userIdxxx接口如果内部逻辑是“如果用户有未领取的券则领取一张”这个GET被重放三次就会领到三张券它就是不幂等的。反之一个POST/order/status/update接口只要在内部加了“订单状态只有待支付才允许改成已支付”的约束重复调用并不会把已支付订单改出花样来它就是幂等的。所以在评审接口时我从来不看接口叫什么、用的什么HTTP方法而是顺着代码把数据库写操作全部列出来然后问自己一句这段代码重复执行两遍结果会和执行一遍完全一致吗只要答案里有任何一个“不一致”这个接口就必须纳入幂等设计的范围。1.2 三类高频场景重复提交、超时重试、MQ重复消费我翻了一圈自己参与过的项目真正需要幂等保护的无非三类场景。第一类是前端重复提交。移动端弱网环境下用户点了“立即支付”发现页面没反应下意识再点一次或者下单按钮未及时置灰双击直接发了两个请求。这类重复请求往往来自同一个客户端、同一个用户、几乎同时到达最典型的特征就是短时间内的并发量突增后端如果不做任何防护第一条请求和第二条请求都能正常进入业务逻辑。第二类是超时重试。服务端调用下游接口超时了出于“不丢请求”的考虑网关、RPC框架、HTTP客户端通常会自动重试一到三次。这些重试请求和前一个原始请求本质上是同一笔业务只是传输层不确定上一次是否真的处理成功了必须用幂等机制让重复请求无害化。RPC框架里的重试策略尤其坑像Dubbo、Feign默认的connect和read超时重试往往不会自动关闭服务端响应慢一点就可能连续收到三四次同样的请求。第三类是MQ重复消费也是我最头疼的一类。主流消息队列如Kafka、RocketMQ默认提供的是at-least-once语义也就是说消息生产时不丢、但消费时允许重复投递。一个订单创建事件被消费者捞到三次、执行了三次建单逻辑如果没有幂等保护重复订单就这么产生了。这种重复消费不像前端重复提交那样肉眼可见它的时间间隔可能是秒级也可能是分钟级隐蔽性很强。1.3 一条判断标准重复执行后数据会不会变脏那怎么快速判断一个接口到底要不要做幂等我给团队新人讲过一个很直接的判断方法把“再来一次”这四个字放到接口头上问自己如果我误把这个接口重放一遍用户数据会不会变脏。比如查询用户信息的接口再来一万遍也只是查询读的结果一样数据不变不需要幂等。比如修改用户昵称重复执行十遍最终昵称还是同一个值结果也一致属于天然幂等也可以不做额外设计。但创建订单、扣减余额、增加积分、发放优惠券这类操作每执行一次就会新增一条记录、扣一次钱、发一次券重复执行的数据影响完全不同必须纳入幂等设计范围。还有一个边界情况值得提醒有些接口看起来只是更新状态但更新动作里带着不可逆计算。比如“把订单金额调整为100元”重复执行没问题但“把订单金额在原价基础上加100元”再执行一次金额就变成200了这种相对计算也要警惕。判断的时候不能只看接口名字要顺着代码逻辑看它到底写了哪些库表、是否做了相对变更。2. 幂等方案的底层逻辑为什么裸写“先查后写”必翻车做幂等最朴素的想法是什么先把已处理的请求记录下来下次来一个请求就先去查一下“这笔单子处理过没有”查过了就拒绝没查过才继续执行。我早期设计防重功能时也这么干过代码大概是if (orderMapper.countByBizId(bizId) 0) { // 执行核心业务 orderMapper.insert(order); }单请求跑起来一点问题没有一旦用并发脚本压测立马原形毕露两个请求同一时刻都进到if语句里都查出来count为0于是都执行了插入重复数据照样落库。为什么因为“查询数量”和“插入数据”是两步独立操作中间存在时间差并发场景下这个时间差就是漏洞。你把校验前置做得再严谨只要写入和校验不是同一个原子动作就拦不住并发。2.1 一切问题的根源校验与写入之间的时间差“先查后写”这种思路的致命伤在于检查与写入之间存在一个时间窗口。这个窗口在单线程、低并发下几乎不可见但系统一旦进入多线程、多实例部署窗口就被放大了。我们想象一个具体的场景用户同时提交两个重复的创建订单请求请求A和请求B。A先一步执行了countByBizId查询发现没有记录在A正准备insert的那一刻B也执行了countByBizId查询发现也没有记录随后A插入成功B也插入成功。整个过程里每个请求单独看都是“正确的判断”但合在一起结果就错了。数据库层面没有做任何约束应用层的判断逻辑再严密也只是“空中楼阁”。这也是为什么我反复强调幂等方案设计的第一步不是考虑用什么中间件而是想清楚“是哪一步存在原子性缺口”。唯一索引之所以靠得住是因为IDEA将“判断是否存在”和“写入”压缩成了数据库的约束判断分布式锁之所以靠得住是因为锁本身保证了检查与写入之间的串行化。任何试图用“应用层先查再写”来实现幂等的方案本质上都是把可靠性押在一个易碎的时间差上。2.2 三种兜底思路约束、锁、标记既然核心矛盾在于“校验-写入”的原子性那所有成熟的幂等方案本质上就是三种思路的排列组合。第一种是约束也就是让数据库帮你去重。给业务表或者专门的幂等表加上唯一索引重复的插入在数据库层面直接失败不需要你写任何判断逻辑数据库就是最后的守门员。这是最硬核、最可靠、也是我最优先推荐的兜底手段。它的缺点是数据库表结构的改动要谨慎评审而且唯一索引冲突需要妥善处理不能直接抛一个生硬的系统异常。第二种是锁通过串行化请求来消灭并发。分布式锁、数据库悲观锁for update都算这一派。请求先抢到锁才能执行检查与写入抢不到的请求排队或者直接返回“处理中”。锁解决的是“同时刻多个请求互斥”的问题但对串行化之后顺序到达的重复请求还需要约束派的手段来识别。所以锁方案经常和幂等键配套使用锁负责并发互斥幂等键负责顺序去重。第三种是标记每个业务操作绑定一个唯一凭证幂等键/token。处理前先消费这个凭证消费过了就说明这笔操作已经处理过。标记方案最常见的形式是Redis SETNX和数据库唯一索引本质上都是“凭证只能被成功使用一次”。它是前两种思路在应用层的灵活变体选型空间最大也最容易出错。2.3 基于场景的方案选型对照表选型这事不能拍脑袋我常用的一张对照表供参考方案类别核心载体优点缺点最佳适用场景约束唯一索引数据库绝对可靠、无需额外组件表结构改动要谨慎、索引冲突需处理资金类、订单类必须要兜底的业务标记TokenRedis灵活、可设置有效期、减轻数据库压力依赖Redis可用性、过期时间要设计表单提交、瞬时并发防重的入口接口锁分布式锁Redis/ZK直接互斥、支持复杂业务需要设计锁粒度、有锁过期风险需要串行处理的并发敏感业务状态机业务表状态字段业务语义清晰、天然防重放只能用于状态流转明确的业务订单状态、审批流程、任务调度拿支付回调场景举例支付平台做了重试机制而且它本身不保证请求时序你收到三次回调、三次都告诉你“这笔订单已支付”。这种场景我首选“约束”即订单号在支付流水表上建唯一索引重复回调插入失败直接返回成功让支付平台别再重试。再叠加状态机订单状态只在“待支付”时才允许改成“已支付”两道防线同时生效。而像用户点击“领取优惠券”这种入口型接口用Redis Token防重就够了不必为它去动数据库表结构。3. 方案落地一数据库幂等表 唯一索引最硬核的兜底跑一遍最简单的落地。假设我要给“创建订单”接口做幂等最直接的办法是单独建一张幂等记录表CREATE TABLE idempotent_record ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, biz_type varchar(32) NOT NULL COMMENT 业务类型如CREATE_ORDER, biz_id varchar(64) NOT NULL COMMENT 业务幂等键如订单号、请求编号, request_body text COMMENT 请求体快照便于排查, response_body text COMMENT 首次处理的响应结果用于重复请求直接返回, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 处理状态0处理中 1成功 2失败, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_biz_type_bizid (biz_type, biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表的核心是(biz_type, biz_id)这一对唯一索引它们是整张表的灵魂。业务类型是为了防止不同接口之间撞键比如“创建订单”和“取消订单”可能都带着同一个外部订单号但分属不同业务幂等键则必须保证全局唯一。创建订单我推荐用“用户ID订单号”或者“上游请求流水号”拼接简单可靠不能用时间戳这种高重概率字段也不能用自增ID这种不同请求会一样的字段。3.1 幂等表/去重表设计字段怎么定除了核心的联合唯一索引幂等表里还有几个字段容易被忽视但它们在实际排查问题时会救你一命。第一个是request_body。把原始请求体快照记录下来线上遇到“用户说我没下单成功但库里有订单”这种投诉时你可以直接对比快照和实际落库数据判断是不是同一笔操作。第二个是response_body重复请求可以不用重新执行业务直接返回第一次处理的结果这是幂等方案的“甜点”之一——既挡了重复又让用户感知一致。第三个是status这是我在踩过坑之后才深刻理解的字段只做成功和失败两个状态往往不够“处理中”这个中间态必须保留否则会出现“第一次请求处理一半进程挂了第二次请求永远不知道结果”的尴尬局面。幂等键的生成规则也很重要我给新项目定过一个不成文的约定优先使用上游业务流水号比如支付流水、消息ID、前端生成的requestId如果上游没有可靠流水号就自己用“业务语义字段用户标识”构造例如userId:createOrder:202406010001。无论哪种都必须保证同一个业务操作在不同时刻、不同请求里生成的幂等键完全一致这是幂等生效的大前提。3.2 唯一索引insert的原子性核心代码有了这张表处理逻辑非常简洁先尝试往幂等表里插一条状态为“处理中”的记录insert成功的请求才有资格执行核心业务insert失败就说明之前已经处理过直接返回上一次的结果或者一个友好的重复提示。Transactional public OrderResult createOrder(CreateOrderRequest req) { IdempotentRecord record new IdempotentRecord(); record.setBizType(CREATE_ORDER); record.setBizId(req.getOrderNo()); record.setStatus(0); try { idempotentMapper.insert(record); } catch (DuplicateKeyException e) { // 唯一键冲突说明已处理过 return idempotentMapper.queryResult(req.getOrderNo()); } // 到这里说明当前请求拿到了“处理资格” OrderResult result doCreateOrder(req); idempotentMapper.updateStatusToSuccess(req.getOrderNo(), result); return result; }注意这段代码里insert本身就是原子判断数据库用唯一索引帮我们完成了“检查并写入”的合并动作并发再多也只有一个请求能insert成功其余全部抛异常。这就是我开头说的“约束”派思路——不做应用层的count查询把去重的可靠性交给数据库。很多人担心这里会锁表实际上InnoDB唯一索引冲突走的是行锁插入同样的key会发生锁等待或快速失败并不会把整张表锁住吞吐量在绝大多数业务场景下都够用。3.3 这个方案的三个坑我提前帮你踩过了这个方案看起来简单实战踩坑也不少我挑三个最典型的说。第一个坑是幂等记录和核心业务的一致性。上面代码里insert幂等记录和创建订单放在同一个事务里这个没问题但如果你图性能把它们拆成两个事务就会出现“核心业务失败、幂等记录却标成成功”的脏数据。我的建议是幂等记录和主业务的事务必须绑定要么一起成功一起回滚要么在业务失败时主动把幂等记录状态重置为失败并捕获异常后重新标记。第二个坑是状态字段别只做“成功”标记。只记录成功状态会导致一种尴尬场景第一次请求处理中进程挂了幂等记录永远停在“处理中”第二次请求过来看到有一条记录但不知道结果直接报“重复”用户永远拿不到订单。更好的做法是给“处理中”状态设置一个超时时间超时后允许带着相同幂等键的请求重新发起并覆盖处理。第三个坑是别让幂等表变成数据库热点。所有写操作都先插幂等表单表插入量一大就容易出现锁竞争。实践中可以按业务类型分表或者把幂等表拆到独立的Redis缓存加数据库分层先查RedisRedis没有再去数据库查把绝大多数查询压力挡在缓存层。Redis查不到时再回源数据库回源逻辑负责最终的幂等兜底。4. 方案落地二Token机制与Redis防重从原理到代码Token机制是Web前端防重复提交最经典的一套做法流程分四步第一步客户端在打开页面或者准备提交前先请求服务端发一个全局唯一的Token服务端用UUID或者雪花算法生成并把它存进Redis设置一个合理的过期时间。第二步客户端把Token带上发起真正的业务请求一般放在请求头Idempotency-Key或者表单隐藏域里。第三步服务端收到请求后先校验Token是否存在于Redis中如果不存在说明Token无效或者已经被使用过直接拒绝。第四步也是最关键的一步Token校验通过后业务逻辑处理之前必须先把这个Token从Redis中删除让后面再来的重复请求找不到Token而失败。为什么强调“先删除后处理”因为“校验通过-执行业务-删除Token”的流程里存在和“先查后写”一样的并发漏洞两个请求同时校验都发现Token还在于是都通过校验并发处理核心业务。用Redis的DEL操作先抢占Token抢占成功的才去处理业务才能保证“每个Token只能被使用一次”。这一点是最容易被忽略的细节。4.1 Token机制完整流程从申请到销毁我画过很多次Token机制的时序图但落到代码里其实只有两端逻辑申请Token的接口以及消费Token的业务入口。申请Token的接口非常简单生成UUID通过SET key EX 300写入Redis即可key的前缀可以带业务标识比如token:createOrder:uuid。消费Token的入口才是设计的重心。我推荐的做法是直接用DEL命令的返回值做判断DEL token:createOrder:uuid返回1说明这次请求成功抢占了Token有资格继续处理业务返回0说明Token已经被删除过本次请求直接判定为重复请求。这个方法比“先GET判断是否存在、再DEL删除”更安全和简洁因为DEL本身把校验和销毁合成了一个原子操作。这么做还有一个额外好处Token被删除后Redis里就不再残留数据无需依赖过期时间清理内存。但要注意如果业务处理过程很长Token会在执行开始前就被删除万一业务失败需要客户端重试客户端将拿不到原Token。这种场景下Token机制就不够用要把Token方案和业务状态回查结合起来或者干脆切换到幂等表方案。4.2 基于Redis的分布式锁防重SETNX的正确用法Token机制针对的是“携带同一个Token的重复提交”而分布式锁针对的是“同一个资源同时只能被一个请求处理”。以“同一账户同一时间只能下一个订单”为例用Redis做分布式锁防重的标准姿势是SET lock:order:userId_10001 ANY_VALUE NX EX 10这条命令的意思是只有当key不存在时才写入lock:order:userId_10001写入成功返回OK说明当前请求成功拿到锁写入失败返回nil说明已经有其他请求在处理直接返回“订单处理中请勿重复提交”。EX 10是锁的自动过期时间防止拿到锁的进程突然宕机出现死锁。释放锁不能简单DEL要先判断锁的持有者是不是当前线程防止“A线程的锁过期了、B线程拿到锁、A线程却把B的锁删了”这种错杀。判断和删除必须用同一个Lua脚本保证原子性if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end拿锁时Value设置为线程唯一标识释放时先比对再删除这套逻辑被称作“防误删锁”也是面试常被追问的点。我见过不少团队在分布式锁上写了两三步的Java代码来实现这个逻辑期间不加锁不搞原子操作结果锁释放错乱的概率并不低。4.3 Token和分布式锁到底用哪个我经常被问到“Token和分布式锁是不是一回事”答案是不完全一样。Token解决的是“这个凭证只能用一次”它天然支持重复请求无害化——同一个Token第二次来的时候已经被删了直接拒绝。分布式锁解决的是“同一时刻只有一个请求能干活”它本身不关心请求是不是同一个业务两个不同的请求如果都来抢锁也能做到互斥。举个场景你就明白了。用户重复点击“领取新人券”两次点击的请求体是一样的、Token也是一样的这是Token机制的典型场景。而一个订单同时被“用户取消”和“系统超时关闭”两个不同操作处理它们不是同一个Token但都想改订单状态这时候就得靠分布式锁来互斥避免两个操作同时改同一行数据。所以选型逻辑很简单如果重复请求“业务内容相同”用Token如果重复请求“业务内容可能不同但共享数据”用分布式锁。复杂一点的系统两者会同时用比如下单接口用Token挡住同一请求的重复提交同时在扣库存环节用分布式锁保护共享库存数据。两条防线管的是两类不同的重复风险。4.4 过期时间怎么定选型中的关键取舍Redis方案的另一个关键参数是过期时间。Token过期时间太短用户停留在页面填写表单的时间稍长一点Token就失效了提交时报“请刷新页面重试”体验很差过期时间太长大量Token在Redis里堆积白白占内存。我的经验是普通表单类接口设置10到30分钟和业务交互周期的上限对齐长流程业务如大额支付、合同签署可以设置1小时以上甚至在业务完成后显式删除Token避免占用。分布式锁的过期时间则要特别小心它的标准是“锁的过期时间必须大于业务处理的最大耗时”。如果业务平均要跑5秒你设置锁3秒过期极端情况下两个请求就会同时进临界区幂等直接被击穿。线上遇到过业务里调用外部接口超时锁过期后别的请求又进来了同一笔订单被处理了两次。所以设置锁过期时间前先评估业务的最大执行时长留足余量更稳妥的方案是给关键业务加“看门狗”续期机制定期给未过期但仍在处理的锁延长过期时间。5. 方案落地三状态机 乐观锁复杂业务的天然幂等前两套方案都有个共同特点为幂等额外建了一张表、引入了一堆逻辑。如果业务本身有清晰的状态流转其实有一条更轻巧的路——直接用状态机做幂等。最典型的就是订单状态待支付、已支付、已发货、已完成、已取消。如果业务规则规定“只有待支付状态的订单支付回调才能把它改成已支付”那么重复的支付回调根本改不动这个状态因为它第二次来的时候订单已经不是待支付了。这种设计是天然的幂等保护状态本身充当了“只允许从A状态流转到B状态一次”的约束条件。重复请求重复执行第一次完成了状态迁移后面的请求全部因为“当前状态不合法”被拒绝不需要额外查幂等表、不需要Redis。我自己的实战经验是凡是有显式状态机的业务一定要先把状态机定义清楚每个状态允许流转到哪些后续状态、哪些状态是终态、哪些流转需要外部触发。定义清楚之后幂等只是状态机设计正确带来的副产品而不是额外的工作量。5.1 状态机天然幂等的原理状态就是护城河状态机之所以能天然幂等是因为它把一个“可重复执行的操作”变成了“只有在特定前置条件下才生效的操作”。重复请求少了一个必要的前置条件自然就无法重复生效。举个例子一个订单的正常流转路径是“待支付 → 已支付 → 已发货 → 已完成”。支付回调的逻辑是“把待支付订单改成已支付”。如果A请求先到达订单从待支付变成已支付B请求重复回调后到达此时订单已经是已支付状态不再满足“待支付”的前提条件更新失败。整个过程中没有用到任何额外的存储只是把业务规则用数据库的状态字段表达出来了。这种方案特别适合那些天然具备“阶段推进”属性的业务订单、退款单、审批流、任务状态。做设计评审时我会先问产品经理要一份状态流转图只要状态图本身是清晰的单向前进型我就知道这个模块至少有了一半的幂等保障。反过来如果业务允许状态任意跳转、没有明确的流转约束状态机方案就用不上需要退回幂等表那一套。5.2 乐观锁版本号落地一条UPDATE搞定并发状态机判断天然并发了“先查状态-再改状态”同样存在检查与写入的间隙问题。解决方式是在UPDATE语句的条件里带上状态判断条件一次SQL完成“检查状态并更新”UPDATE order SET status PAID, pay_time NOW() WHERE order_no 202406010001 AND status PENDING;如果这个UPDATE影响行数为1说明当前请求成功把订单从待支付改成了已支付如果影响行数为0说明订单当前状态不是待支付要么已经支付过了要么已经取消本次操作直接结束。这整个判断过程被数据库在单条SQL内部原子完成不存在时间差漏洞。更通用的做法是加版本号字段version每次更新时WHERE version ?并把version version 1。这种CAS即比较并交换思想好比更衣室的储物柜你只能在自己对应编号的柜子里放东西如果别人已经占用了你的操作自然失败。乐观锁的优点是并发性能好、不需要锁住整行数据适合读多写少、冲突不频繁的业务。不过需要注意如果冲突频繁乐观锁会导致大量UPDATE影响行数为0的无效请求这时反而要考虑悲观锁串行化了。5.3 状态机唯一索引组合双保险怎么搭状态机和唯一索引不是二选一的关系我实际的项目里经常把二者叠加使用形成双保险。比如支付回调处理接口核心逻辑是先按订单号和支付流水号在支付流水表插入记录靠唯一索引挡住完全重复的流水插入成功后执行状态机更新把订单从待支付改为已支付两条防线各自负责一个维度的去重。唯一索引管的是“流水号级别的重复”状态机管的是“订单维度的状态合法”。就算流水唯一索引被绕过状态机也能兜住重复的支付结果就算状态机判断有并发漏洞唯一索引也能在数据库层拦住第二条流水。这种组合的代价是代码稍微多几行但换来的是资金、订单这类高价值数据几乎不可能出现重复入账的事故。双保险设计时还要注意一个细节两者的先后顺序不能乱。我习惯先写流水表、再做状态更新因为流水表插入失败可以直接返回成功省去一次状态查询反过来如果先改状态、再写流水可能出现状态已更新、流水还没写进程崩溃后两边数据对不上的情况。6. 从网关到数据库全链路幂等防线怎么搭接口幂等不是一个接口的事而是从用户点击到数据库落盘整条链路上的事。我通常把防线分成四层前端体验层、网关API层、服务层、存储层。前端防重做了请求期间按钮置灰、loading不可点击它的核心价值是减少无效请求的流量给后端减压网关层负责统一生成或透传幂等键让幂等键有规范服务层做Token校验、幂等表插入、状态机判断是整个幂等的业务主战场存储层靠数据库唯一索引做无法绕过的最终兜底。四层防线各司其职前端的防重在体验网关的ID在规范服务层的校验在业务数据库的约束在兜底。很少有一个方案能覆盖所有层全链路幂等的关键恰恰是“每一层都只解决自己该解决的问题”。6.1 分层职责前端、网关、服务、存储各干各的前端层是一个容易被忽略的环节。很多后端工程师觉得“前端防重不可靠所以不用做”这话对了一半。前端防重的确不能替代后端幂等因为它可以被脚本、多标签页绕过但它可以大幅降低无效请求的到达量。按钮置灰、loading状态、定时器防抖这些实现成本很低能挡住90%以上的用户手误和焦虑连点让后端不必处理那么多无意义的并发。所以我的建议是前端防重照做但永远默认它会被绕过。网关层要做的是“标准化的入口管理”。对外暴露的接口在网关统一判断请求头里是否带了幂等键如果没有网关直接生成一个UUID塞进header再转发对于需要用户幂等语义的接口网关可以基于userId接口路径业务参数摘要生成一个业务幂等键。这样做的好处是各个微服务不需要自己重复实现“如何生成幂等键”的逻辑只要约定“所有写请求必须带这个键”即可。服务层是最考验业务理解的一层。幂等键怎么校验、在哪个环节校验、校验失败返回什么都需要结合具体业务设计。存储层则简单直接关键业务表、流水表、幂等表都建好唯一索引把数据库当作最后一道无法关闭的大门。6.2 MQ消费端的幂等at-least-once里的去重细节消息队列的幂等是高频痛点。RocketMQ、Kafka在消费端都可能出现同一消息被重复投递消费者处理超时但未提交offset重平衡后又拉取到这条消息或者消费逻辑里调了外部接口超时重试导致同一条消息被处理两遍。我的处理思路是消费者拿到消息后用消息的唯一ID加业务幂等键去尝试做一次“消费凭证”登记。最简单的实现是利用Redis的SETNXSETNX order_msg_202406010001 1 EX 3600SETNX返回1说明这条消息第一次消费继续执行业务逻辑返回0说明之前已经消费过直接return。这里有两点容易踩坑一是消费业务失败时不能把凭证删掉重来因为删除后消息重投又会进来又会被判为“第一次”要同时处理清理逻辑或者标记失败状态二是SETNX的过期时间要大于可能的重试间隔否则消费者还在重试、凭证已经过期重复消息就会趁虚而入。如果希望更严谨可以使用数据库去重表替代Redis道理和幂等表完全一样消息ID在表上建唯一索引insert成功才消费。资金类、订单类的消息消费我倾向用数据库方案虽然比Redis慢但胜在不可丢、不会因为Redis故障而漏判。6.3 跨服务链路透传幂等键如何一传到底稍微复杂一点的系统里一个用户请求会跨多个服务网关调用订单服务订单服务调用支付网关支付网关回调支付服务支付服务又通知订单服务。这种情况下谁来做幂等我推荐的做法是整条链路统一使用一个幂等键。在网关层接收外部请求时如果请求头带了X-Idempotency-Key就沿用没带就自动生成一个UUID放在统一的上下文里向后透传。微服务框架里可以用拦截器把幂等键放入RPC调用的attachment或header就像一份贯穿全流程的车票每个下游都能看到同一个编号。这样不管请求在哪个环节被重试都有同一个键可以去重。但要注意全局幂等键解决的是“同一笔请求的重复提交”解决不了“不同请求但操作同一资源”的并发问题。所以在链路各环节还要用业务自身的键再做一层局部幂等。比如下单接口全局用同一个requestId做幂等扣库存环节还用商品ID做分布式锁两个维度各管一摊才能组成完整防线。7. 常见问题与排查技巧实录这里分享三个我在真实项目里栽过的跟头每个都对应一种典型的方案误用希望能帮你把坑绕过去。第一个坑Token方案“先校验再删除”在并发下失效。早期实现Token防重时按直觉写了“校验存在-删除-处理业务”压测发现偶发重复下单。原因就是两个请求同时透过校验、同时进入删除前的等待双双拿到处理资格。修复很简单改用DEL返回值的方案或者把校验和删除放进同一个Lua脚本让整个判断原子化。第二个坑Redis锁过期比业务快。业务里有一个退款接口处理过程中调外部银行接口平均耗时3到8秒而我把锁过期时间拍脑袋设成了5秒。结果连续碰到两个请求第一个请求锁过期被外部调用卡住第二个请求趁虚而入拿到了锁两个线程同时改同一笔退款单资金数据直接错乱。这个教训让我后来养成了一个习惯凡是涉及外部调用的临界区锁过期时间必须大于“业务最大耗时外部接口超时阈值”宁长勿短必要时上续期机制。第三个坑唯一索引冲突后没有正确兜底。数据库幂等表方案里insert发生DuplicateKeyException后我最初的处理方式是直接抛异常并给前端返回“系统繁忙”。结果有用户下单时第一次请求其实还没处理完就超时了第二次请求带着相同订单号过来被幂等表挡住返回“系统繁忙”但用户第一单其实已经创建成功造成了“业务已成功、用户却不知道”的体验问题。后来改成冲突时主动查一次幂等记录状态已成功就直接返回订单结果仍在处理中就返回“处理中”彻底解决了这个尴尬。7.1 我踩过的三个坑上面三个坑都属于方案实现的边界条件没处理好。我再补充一个容易被忽略的坑幂等键本身生成得不够稳定。有次排查重复数据发现同一个用户下的同一笔订单两个请求带的幂等键居然不一样原因是代码里把一个随机字符串拼进了幂等键。幂等键一旦不稳定再好的方案都白搭。排查幂等键生成问题的方法也很直接把所有模式的请求日志都收集起来按用户、订单号分组对比每组请求里的幂等键是否完全一致。只要发现前后两次请求幂等键不同问题一定在生成规则上而不是在后边的校验逻辑上。7.2 排查重复数据从哪几层下手最快线上如果真出了重复数据怎么快速判断到底是不是幂等设计失效我有一套固定的排查路径半小时内基本能定位方向。第一步看调用日志。用重复的业务单号订单号、流水号去全链路日志系统里搜确认重复请求是否真的到达了后端接口、来了几次、时间间隔多少。如果日志里只有一次那问题根本不在接口幂等而在业务代码内部自己跑了多次。第二步看幂等键是否一致。如果重复请求确实来了多次去看每次请求带的幂等键、Token、消息ID是不是同一个值。如果连幂等键都不一样那问题出在幂等键生成规则上比如生成时带了随机数或时间戳导致同一笔业务每次生成不同的键幂等自然形同虚设。第三步看幂等存储的状态。查幂等表记录是否存在、状态是什么查Redis里对应的Token或锁是否存在、为什么失效。如果是“处理中卡死”多半是进程异常退出后状态没有重置如果是“凭证提前过期”说明过期设计要重估如果是“根本查不到记录”说明幂等代码压根没走到。第四步复现并发场景。用jmeter或脚本并发重放同一个请求看是否稳定复现同时打开SQL日志观察是否有两条insert同时执行。并发问题很多是概率性问题压测时需要跑足够多的次数才能暴露。7.3 幂等测试设计上线前怎么验证最后补一段我在测试环节固定会做的设计。幂等测试不能只验证“重复请求返回什么”更关键的是验证“重复请求之后的数据是否一致”。我的幂等测试清单通常包含五类用例。第一类并发压测用相同参数和相同幂等键并发发起20个请求断言数据库中只产生一条记录。第二类时序重放等第一个请求处理完成后过几秒再重放同一个请求断言返回结果和首次一致、数据无变化。第三类中途失败重试在核心业务中人为制造异常让第一次请求失败之后带着相同幂等键再次请求断言幂等记录状态被正确更新业务最终能得到正确结果。第四类过期边界把Token或Redis锁的过期时间调到很小模拟过期后重复请求的行为。第五类状态机非法流转尝试把已支付订单再支付、已取消订单再发货断言被拒绝且状态不变。这些用例不用全部自动化但至少并发压测和失败重试这两类必须纳入持续集成的接口测试里因为它们最能暴露“校验-写入非原子”和“状态处理不严谨”这两类核心问题。写到这里整条从理论到全链路的幂等方案就讲完了。最后说点个人体会。我见过不少团队把幂等方案堆得很重分布式锁、Token中心、消息去重表、网关拦截全都要上结果排查一次问题要跨五个系统对账成本反而比不做幂等还高。我现在做设计时一般遵循三个原则优先用数据库唯一索引做兜底这是最笨也最可靠的一层能靠状态机表达的业务绝不多建一套幂等体系Redis防重只放在入口和短时效场景绝不把核心资金的可靠性押在缓存上。另外还有一个不算高级但非常有效的建议每次幂等改造上线前手动做一次故障演练停掉一个消费者进程再手动重投消息或者用脚本连续点五十次下单按钮亲眼看看库里到底会不会多出数据。幂等设计的最终标准不是方案有多么华丽而是任何一次重复请求都能变成可控、可观察、可恢复的结果。