分布式系统接口幂等九种实现方案:从重复提交到最终一致
分布式系统的接口幂等本质上不是“锁不锁”的问题而是“同一个请求被执行了多次业务结果是否仍然正确”的问题。我在真实项目里最常见的高发场景就两个一个是用户重复点击提交多创建了订单另一个是支付平台回调重试结果库存被扣了两次或者订单被更新了两遍。重复提交一旦真金白银地造成了重复扣款线上立刻就是事故没有二话可讲。这篇文章我围绕分布式系统中接口幂等性的九种实现方案展开把每种方案的设计逻辑、适用场景、典型代码和踩坑经验都梳理一遍。很适合正在做交易、订单、支付回调、库存扣减这类强一致场景的后端开发也适合准备系统梳理幂等知识的团队参考。代码示例以伪代码形式为主偏向思路落到你们自己的技术栈并不难。1. 先从“重复提交”说起弄懂幂等要解决什么问题1.1 重复请求到底是怎么来的很多刚接触分布式系统的同学有个误区以为重复提交就是用户手快多点了两下按钮。实际上线上环境的重复请求来源远比你想象的复杂前端按钮未做防抖、未禁用用户双击或连点提交。客户端发完请求后网络超时框架层自动重试拿着同一个请求体再发一次。API 网关配置了重试策略上游响应超时后自动转发到下游服务。消息队列普遍采用 at-least-once 投递语义消费者处理成功但未来得及提交消费位点消息重启后又投递一次。某些业务回调系统比如第三方支付回调、银行回调为了保证通知必达会间隔重试直到你明确返回成功。这几种场景叠加在分布式环境下最棘手的一点是请求可能被不同的机器节点分别处理每个节点看到的数据状态可能还不一样。你如果只在单机内存里放一个“请求是否已处理”的标记换一台机器就失效了。所以幂等设计必须在多个服务节点之间共享的存储上做判断比如数据库、Redis、ZooKeeper 这类中间件。1.2 幂等不是一种“锁”而是一种结果约束定义说人话一点一个接口执行一次和执行一百次对系统产生的业务影响是一样的这个接口就是幂等的。举例创建订单时同一个订单号第一次请求创建成功第二次请求不要报错也不要再插一条新订单而是返回同一个订单数据。支付回调时同一笔支付通知来了五次数据库里订单状态最终只从“待支付”变成“已支付”一次金额只入账一次。扣减库存时同一张业务单子重复调用库存不会因为重试而重复扣减。我经常看到有人把幂等和“并发控制”混为一谈。实际上锁是手段幂等是结果约束。你当然可以用悲观锁、乐观锁来实现幂等但也完全可以用数据库唯一索引、Redis 防重标记这类不用锁的方案来解决。理解这层区别后面选型才不容易迷糊。另外幂等判断必须是同一个请求。如果你的订单号相同但请求参数里商品数量不同这种算参数冲突不属于幂等应该处理的问题。所以做方案时要先明确“幂等键”的粒度是订单号、支付流水号、消息 ID还是用户 ID 业务场景 业务 ID 的组合。这一下子就决定了方案的可靠程度。2. 实现前先看清楚九种方案横向对比2.1 一套通用的幂等设计套路不管用哪种方案核心套路其实就三步用某个存储介质记录“这个请求的业务标识”。业务执行前判断标识是否已存在不存在就标记并执行已存在就说明是重复请求。重复请求要么直接返回上次成功结果要么直接拒绝。难点主要在第二步。因为你没法保证判断和业务执行是原子的。判断时没有记录刚记录完还没执行业务另一个节点也来判断又认为没有记录两边同时往下走就出问题了。所以真正稳妥的方案不是在“怎么做判断”上做花活而是在“存储约束”上下功夫。下面的九种方案其实都是围绕着这套思路展开的。2.2 九种方案总览表给一张我自己的对比表方案名称、依赖组件、核心思想、适用场景、局限一次说清楚编号方案核心机制适用场景主要局限1数据库唯一主键/唯一索引数据库唯一约束拦截重复插入订单创建、流水记录、消费记录有些场景没有天然唯一键需要加冗余列2乐观锁版本号字段更新时校验当前版本号库存扣减、金额变更只能处理更新型业务不能处理纯新增3悲观锁行锁SELECT ... FOR UPDATE 串行化低频高价值操作、金额处理并发性能差容易阻塞连接池4状态机控制按既定状态推进前置状态不对就拒绝订单状态流转、支付回调状态设计复杂跳状态场景难处理5Redis SETNX 幂等标记设置成功表示首次请求快速拦截重复点击Redis 故障或键过期会穿透到DB6Redis Lua 原子操作把判断和标记合并在一个脚本中执行高并发下单、防重标记脚本维护成本高仍需兜底7Token 令牌机制前端先申请令牌提交时消费令牌表单重复提交、秒杀下单需要额外一次令牌申请交互8分布式锁用锁保证同一业务同一时刻只有一个执行者多节点并发扣减锁过期会导致并发瞬间穿透9MQ 消费记录表用唯一消息ID写入本地消费记录表消息队列重复投递要有本地事务配合多一张表维护这张表不是让你选一种用到天荒地老。我在项目里做得最多的做法是“入口用缓存挡一波终点用数据库约束兜底”。比如下单接口Redis 幂等键先拦住前端连点的大量重复请求DB 层再加上一个唯一索引防止缓存过期或 Redis 异常的极端情况这样才是完整方案。3. 基于数据库的四种实现方案把最终防线先聊透3.1 方案一数据库唯一主键/唯一索引这是所有幂等方案里最可靠的一种。数据库的唯一约束是数据库自身保证的不管你多少台服务器同时来插入最终只有一条能成功天然就是分布式安全的。最常见的落地方式是在业务表里增加一个“幂等键”字段比如biz_no或order_id然后对它建唯一索引。代码大概是CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, UNIQUE KEY uk_order_no (order_no) );try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 说明是重复请求查一次原订单返回或直接返回“订单已存在” return orderMapper.selectByOrderNo(order.getOrderNo()); }这里有个非常容易踩的坑如果你的接口是新增一条主记录、同时还要插入 N 条子明细只对主表做唯一索引是不够的。因为两个重复请求可能都插了主记录一个成功一个失败失败的没关系可子表里可能已经各插了一半。对这种场景我强烈建议把主表和子表插入放进同一个数据库本地事务里由主表的唯一索引去引发事务回滚子表脏数据才能一并撤销。另一个经验不要随手把数据库主键当成幂等字段。自增 ID 每次插入都会生成新的根本拦不住重复。要用业务维度的唯一编码并且如果你担心外部传入的业务编码可能不唯一可以在服务端自己再拼接一个来源标识比如source_channel _ out_biz_no。3.2 方案二乐观锁版本号字段乐观锁适合“更新型”操作典型场景是库存扣减、余额变更。它的逻辑是更新数据前我读到当前版本号更新数据时SQL 条件里带上这个版本号如果别的请求已经把版本号改了我的更新行数就是 0说明本次操作无效。UPDATE t_inventory SET stock stock - #{buyNum}, version version 1 WHERE id #{inventoryId} AND version #{curVersion} AND stock #{buyNum};如果 update 返回的受影响行数为 0不一定就是重复请求也可能是库存不足。所以我的建议是返回 0 以后再查一次数据库当前状态区分“版本变化了”和“库存不足”这两种情况再决定返回“重复操作”还是“库存不够”。乐观锁本质上是把重复请求中的一个判定为失败如果第一个请求已经成功后面重复到达的请求会因为版本号不匹配而返回失败。从结果上看数据不会被重复扣减副作用被抑制住了这已经达到了幂等效果。但实际上你会发现如果业务上要求第二次重复请求也能返回“成功”那乐观锁还需要额外封装一层结果转换当 update 影响行数为 0 时去查记录当前状态如果已经是终态就返回成功或返回已有结果而不是直接抛错。3.3 方案三悲观行锁SELECT FOR UPDATE悲观锁的思路更直白先把这个订单或库存行锁住不让别的请求动它然后再做判断和更新。多节点场景下行锁是数据库层面生效的所以分布式环境也能用。BEGIN; SELECT * FROM t_order WHERE order_no #{orderNo} FOR UPDATE; -- 业务判断状态是否已经处理过 -- 如果未处理则执行更新 COMMIT;我为什么到现在仍会偶尔用悲观锁因为它能完美解决“先查后改”这个非原子操作。举例你做一笔退款需要先查出订单当前已退金额再累加上本次退款金额最后写回。如果两个节点同时查到同一行数据都认为可以退就都做累加结果必然错误。用悲观锁锁住订单行第二个请求只能等第一个事务提交后再去查询此时看到的已退金额已经包含前一次退款了不会再重复退。但使用它有严格前提第一必须在一个事务里第二查询条件必须走索引否则数据库可能锁整张表第三操作时间不能长否则会拖垮连接池。很多团队把悲观锁作为最后的并发防线但不会把它用在超高并发的秒杀扣库存场景。短事务、低频、高价值数据处理用它最踏实。3.4 方案四状态机控制状态机其实是“条件更新”的高级玩法。它不再依赖单独的版本号字段而是用业务状态字段本身作为更新条件。比如说订单状态只能从“待支付”变成“支付成功”支付回调来了以后执行UPDATE t_order SET status PAY_SUCCESS, pay_time NOW() WHERE order_no #{orderNo} AND status WAIT_PAY;只有当订单当前状态是“待支付”时这条 SQL 才会把状态改成“支付成功”。如果回调因为重试重复执行第二次执行时订单状态已经是“PAY_SUCCESS”更新行数是 0说明这单已经处理过了直接返回成功结果即可。这个方案可读性非常好因为它天然嵌入了业务规则。订单从创建到完结每个节点能往哪些状态跳是固定的待支付 → 支付成功支付成功 → 已发货已发货 → 已签收支付成功 → 退款中退款中 → 已退款突然来个乱七八糟的回调想把“已退款”改成“支付成功”底层 SQL 的条件状态不匹配请求根本不会生效。需要提醒的是状态机和并发控制结合使用效果更好。如果两个请求同时想把“待支付”改成不同状态SQL 条件都能匹配有可能都执行成功。解决方法是把状态字段和 version 字段同时作为条件比如WHERE status WAIT_PAY AND version 1。状态机负责业务规则约束版本号负责并发冲突检测两者不冲突。4. 基于 Redis 的缓存方案把重复请求拦在入口4.1 方案五Redis SETNX 幂等标记当业务量上来以后不能每个重复请求都打到数据库。用 Redis 做一个前置幂等判断是非常常见的做法。核心逻辑就是利用 SETNXset if not exists命令同一个业务键只有第一次能设置成功后面的请求会因为键已经存在而失败。// 伪代码gateway 或 service 入口处处理 Boolean first redisTemplate.opsForValue() .setIfAbsent(idempotent:order: orderNo, 1, Duration.ofMinutes(5)); if (first null || !first) { // 已处理过直接返回旧的业务结果 return orderService.queryByOrderNo(orderNo); } // 继续执行业务逻辑这里两个细节一定不能漏第一个是 TTL 必须设置。不然某个请求第一次设置成功后面业务处理失败你又没有清理这个键这个订单号永远无法再次提交用户只能干瞪眼。TTL 设置多少需要结合你的业务重试窗口判断。支付回调的重试时间窗口往往有数小时甚至一天缓存只放 5 分钟是不够的这种情况就得考虑键过期后仍会穿透到 DB由数据库兜底。第二个是“业务失败时清理键”和“业务成功时保留键”要分清楚。最合理的写法是先执行业务业务成功后设置幂等键并返回结果或者在事务内把键的状态标记为 SUCCESS。如果业务真的回滚了幂等标记也要清理否则用户无法重新提交同一个请求。这里又会引入顺序问题业务还没成功键就写上了后续请求会看到 key 而直接返回可业务一旦失败key 还留着就出问题了。所以纯用 Redis 做幂等很少能独立撑住强一致场景它更像是一个前置过滤器。4.2 方案六Redis Lua 原子操作有些场景你不仅需要判断“请求是否处理过”还要在判断的同时完成状态扭转比如把幂等键从 PROCESSING 改成 SUCCESS。如果这些操作分两步走两个请求可能在中间交叉导致结果错乱。这时候就需要 Lua 脚本把判断和修改合成一个原子操作。先看一个比较典型的“状态判断 状态转移”脚本local current redis.call(GET, KEYS[1]) if not current then return 0 -- 键不存在说明请求太早或者已经过期安全起见不处理 end if current ARGV[1] then -- 当前状态等于期望的前置状态可以执行转移 redis.call(SET, KEYS[1], ARGV[2], EX, ARGV[3]) return 1 end return -1 -- 状态不对可能是重复请求或非法流转Java 侧调用时传入订单号对应的 Redis 键、期望的前置状态、变更后的状态和 TTL。这样一来判断逻辑和更新逻辑全部在 Redis 服务端单线程执行天然没有并发问题。很多刚学 Redis 命令的同学会写“先 GET再 SET”这种代码。在单机演示时没问题但到了集群环境下两个并发请求很有可能 GET 到同样的旧值然后都继续处理。真正的原子操作应该交给 Lua 脚本或者使用 Redis 给的原生命令组合。这是我踩过最多坑的地方也是为什么我把 Lua 脚本单独作为一种方案列出来的原因。4.3 方案七Token 令牌机制Token 方案适合“防表单重复提交”的场景它跟 SETNX 的区别在于SETNX 使用的是业务键比如订单号这个值大多数是客户端传给服务端的Token 则是服务端提前发放客户端提交时必须带着提交后令牌立即失效。典型的流程是前端进入下单页时向后端申请一个 token。后端把 token 存到 Redis设置较短过期时间并返回给前端。前端点击提交时把 token 放在请求参数或 Header 中。后端收到请求后先尝试“消费”这个 token从 Redis 中删除并判断是否删除成功。只有在删除成功时才继续业务处理。第二次点击带着相同 token 来Redis 中已经没有这个 key 了请求直接拒绝。Redis 中删除并判断必须用 Lua 脚本保证原子性local token redis.call(GET, KEYS[1]) if token ARGV[1] then redis.call(DEL, KEYS[1]) return 1 end return 0用 GET 和 DEL 分开写的话两个请求可能都 GET 到同一个 token都认为有效然后都继续执行业务。这种令牌方案形同虚设。Token 方案有个隐蔽的坑业务处理失败以后token 已经消费掉了用户怎么重试我的处理办法是如果后端业务返回明确失败就把这个 token 重新设置回 Redis或者在响应里让用户重新获取令牌后再提交。不要默默让用户刷新页面重来那种体验很糟糕。4.4 方案八分布式锁分布式锁本质上和 SETNX 非常像但它追求的不是“只放一个请求进来”而是通过锁的方式把同一个业务键的并发执行者压缩成一个。Redis 分布式锁常见使用方式String lockKey lock:deduct:order: orderNo; String requestId UUID.randomUUID().toString(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(3)); if (!locked) { // 没抢到锁这里要结合业务决定是提示稍后重试还是直接返回重复 } try { // 拿到锁后执行业务逻辑 } finally { // 只有持有当前锁的请求才能删除锁防止误删其他请求的锁 String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(...); }很多人觉得分布式锁和 SETNX 幂等标记一样其实差别很大。幂等标记更像是“布告牌”我看到上面写了“在处理”就不往下做了分布式锁则是“排队入场”我拿到锁后继续执行没拿到锁的在门口等着或宣告失败。锁方案适合处理并发下的资源竞争比如两个请求同时要操作同一笔订单、扣减同一个库存记录。但经验之谈分布式锁做不了完整幂等。因为锁总有过期时间如果业务执行超过锁的过期时间锁自动释放了另一个相同请求进来又拿到锁还是可能重复处理。这就是为什么我特别喜欢在锁方案之外再配合一个 DB 唯一约束或者状态机做兜底。锁解决并发窗口数据库约束解决最终一致性。4.5 方案九MQ 消费记录表最后一种是消息队列场景里的经典幂等方案。前面说过MQ 普遍做不到 exactly-once即使像 Kafka、RocketMQ 这样的成熟消息中间件也只能在特定条件下做到近似精确一次现实中 at-least-once 才是常态。消费端宕机、网络抖动、位点提交失败都会导致同一条消息被再次投递。消费端幂等的核心做法是维护一张“消费记录表”。消息的全局唯一 ID 或业务唯一键作为消费记录表的主键每次消费前先插入一条消费记录插入成功才真正处理业务CREATE TABLE t_mq_consume_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL COMMENT 消息全局唯一ID, biz_id VARCHAR(64) NOT NULL COMMENT 业务唯一ID, consume_status VARCHAR(16) NOT NULL, consume_time DATETIME NOT NULL, UNIQUE KEY uk_msg_id (msg_id), UNIQUE KEY uk_biz_id (biz_id) );消费逻辑如下开启本地事务。尝试往t_mq_consume_log插入一条msg_id 本次消息ID的记录。如果插入冲突说明消息已经消费过直接 ACK 并跳过。如果插入成功继续执行业务更新。业务更新和消费记录插入在同一个数据库事务里提交然后向 MQ 返回 ACK。这个方案能成功本质上靠的还是数据库唯一约束。但它把“重复检测”做成了消息驱动场景的标准模板比直接在业务表上加幂等键更通用消费日志表独立存在业务表和消费记录表通过消息 ID 关联最终一致性也有保障。我实际做项目时通常不是只记录消息 ID还会记录业务 ID。因为有些错误消息虽然消息 ID 不同但业务 ID 相同比如同一个订单的退款消息被重推了两次消息 ID 变了业务 ID 没变。这种场景只对消息 ID 做唯一约束是拦不住的必须对业务 ID 也建唯一索引。5. 业务关键词怎么定决定了前面所有方案的效果这里单独拿出一节来说幂等键设计因为这个问题太常被忽略却是所有方案里最核心的变量。很多团队照着方案写代码上线以后发现还是重复了往回一查幂等键设计得不对。幂等键的确定原则是客户端同一次业务操作的重复请求必须携带同一个稳定无歧义的标识。举例来说用户一次下单动作前端生成的单据号是20250601001那么后续网关重试、前端连点、服务重试都应该带着这个单号。如果前端每次点击都重新生成一个新的 UUID就没有幂等性可言。幂等键一般由一个前缀和几个业务字段拼接而成常用格式是业务场景 用户标识 业务标识。比如下单order-create:{userId}:{clientRequestId}支付回调pay-callback:{paymentNo}退款申请refund-apply:{orderNo}:{refundNo}MQ 消费consumer:{topic}:{msgId}谨慎的做法是幂等键里带上租户或渠道信息。因为不同渠道的用户可能订单号规则不同A 渠道的订单号“1001”和 B 渠道订单号“1001”如果不加渠道前缀就会互相同步、被误判成重复请求。这种问题排查起来特别费劲而且通常都是上线后由业务方反馈才发现的非常尴尬。6. 九个方案放到真实场景里怎么组合选型6.1 典型场景速查拿我实际接触比较多的几个业务场景来演示一下怎么选。创建订单、秒杀下单这类“新增”型接口最适合的落地方案是“Token 或 Redis 幂等标记 数据库唯一索引”。第一次请求通过令牌校验后进入业务插入数据库唯一索引保证同一下单单号只能插入一次。前端连点和大规模重试被 Token 机制挡住极端情况下 Redis 故障后DB 唯一索引仍然能兜住最终的重复插入做到双保险。用户请求如果前面业务失败令牌被消费掉了我还会在业务失败时把幂等标记删除让用户重新用同一个单号发起或者提示前端重新获取 Token。对于支付回调这类第三方重试频繁的接口我的首选是“状态机 支付流水号唯一索引”。回调进来先按payment_no查支付流水如果流水已经终态直接返回成功给第三方。否则更新订单状态SQL 里强制前置状态是“待支付”。由于第三方平台对回调响应时间有要求这里不适合让回调线程长时间等待锁。状态机的条件更新是很短的一条 SQL性能可以接受而且天然具备重复保护。同时在支付流水表上建payment_no唯一索引就算业务代码状态机写漏了重复的回调流水也插不进去。库存扣减属于典型的非幂等更新但它还要兼顾高并发性能我不会用 Redis 分布式锁把所有扣减全串行化因为同一商品 SKU 的扣减一旦都排队热点商品的 TPS 会非常难看。这种场景更适合用“乐观锁版本号和stock num条件”的组合。SQL 中既判断版本号又判断库存充足更新行数为 0 时再回表查询给用户返回合理的提示。如果确实存在极高并发峰值下的超卖风险我才会考虑用 Redis 预扣减 异步对账的方案那就进入分布式事务范畴了复杂度再上一个级别。消息消费端幂等我的默认模板就是“消费记录表 本地事务”。这也是前面说的第九种方案。有些团队为了省事直接用 Redis SETNX 做消费幂等结果 Redis 主从切换丢键后消息重放照样重复记账。既然消息都打到数据库了再多写一张消费记录表并不算太重的成本换来的可靠性却高很多。真正常见问题是消费记录表所在的数据库和业务数据库不能跨库事务。跨库时有人用“先写业务再写消费表”这个顺序会导致业务执行成功、消费表没写入消息位点提交后后续机器重启又会重复执行业务。所以我一般强调尽量保证业务表和消费表在同一个本地事务中。确实有跨库需求时就必须引入可靠消息或本地消息表方案不能裸着硬写。6.2 组合应用时必须考虑最终防线不管选几种串联我最后都会问团队一个问题当 Redis 不可用、分布式锁恰好过期、幂等键设置失败、网关重试风暴突然到来你的数据库能不能保证不产生脏数据如果答案是不能这个幂等设计就有漏洞。缓存入口是有状态的而缓存不是强一致系统Redis 主从切换、网络分区、内存淘汰、键过期任何一环节都可能让前置保护失效。因此但凡涉及资金、库存、订单这类核心数据数据库的唯一约束或者条件更新必须作为兜底。前置缓存是“大部分时候的双保险”数据库约束才是“无论什么时候都不会松手的那道闸”。这个理念我建议直接写进团队的架构评审 checklist 里。评审幂等方案不要只听你说用了什么 Redis、什么 Lua、什么锁先问 DB 层设计了什么约束。没有 DB 兜底的方案本质上是靠运气在防重复。7. 实测路线上绕不开的坑和验收方法7.1 幂等接口上线前至少这样测很多团队上线前不做专门的幂等测试往往是线上出一次重复支付事故后才急匆匆补测试。幂等验证比想象中简单不需要复杂平台用接口测试工具就能做第一轮单并发重复测试。同一个请求体连续发送 10 次预期结果应该是第一次成功后面 9 次返回同一个结果或者提示重复提交。如果后 9 次里有任何一次又执行了业务插入或更新说明幂等没生效。第二轮多线程并发测试。用 50 个线程同时发送同一个业务幂等键的请求。这一轮比第一轮更能暴露问题。很多接口单次重复测试是正常的但并发一到就会挂因为多个请求同时通过判断同时进入业务逻辑。如果你的实现里没有数据库约束或原子操作这一轮大概率会翻车。第三轮加上“先失败后重试”的场景。第一次请求故意让下游服务返回超时但实际数据库已经写入成功第二次用同一个幂等键重新请求预期结果应该是返回第一次的成功状态而不是报“重复提交”让用户无法继续。这是幂等方案最容易忽略的分支。第四轮把 Redis 停掉再发一次重复请求。如果你的接口在缓存不可用时能退化为纯数据库校验并继续保持幂等说明你的最终防线是成立的。我用这几轮测试捞到过非常多问题有的接口幂等键没带全有的 Redis 判定重复后没查询原结果就直接报错了有的数据库唯一索引漏建。所以坦白说幂等不幂等不是代码 review 出来的是高压测试压出来的。7.2 集成过程中最容易踩的五个坑结合过往项目我整理了几个高频问题幂等键设置成固定常量。有同事直接把幂等 key 写成某个接口名比如idempotent:pay结果这个 key 一小时内只允许成功一次直接把所有用户的支付挡住。这种错误容易发生在“接口要做全局防重”的误解上。幂等键一定和用户、业务单强相关不能全接口共用。缓存 TTL 设置过短。Redis 幂等键过期后重复请求穿到 DB如果 DB 没有约束就重复成功。设置 TTL 时必须大于系统中同一请求可能重试的最长间隔。这个最长时间从哪里来网关超时重试次数加退避时间加上前端按钮可点击间隔再加上 MQ 重投延迟再加一点余量。算出来如果确实超过 24 小时就别依赖 Redis走 DB 唯一索引方案更稳。锁释放时误删别人的锁。这是分布式锁最经典的坑。A 线程锁 10 秒业务执行 12 秒锁自动过期了B 线程进来拿到锁开始执行A 执行完 finally 块把锁 DELETEB 的锁被误删C 线程又进来拿锁。所以释放锁之前一定要校验 value 是不是自己线程的 UUID再用 Lua 脚本完成“校验删除”。不要用先 GET 比较再 DEL 的方式。无条件信任更新结果。SQL 影响行数为 0并不必然等于重复请求也可能是前置状态不对。反正把 0 当成重复请求返回会造成调用方困惑。我的做法是影响行数为 0 时再查一次数据状态如果是终态就按“已处理过”处理否则返回真实失败原因。日志里看不到重复请求痕迹。我在重构一个下单接口时因为前端传的 requestId 每次点击都重新生成日志里看每个请求都像新请求无从分辨哪些是重复操作。建议在入口给每个请求打上业务幂等键的 trace 日志并记录 Redis 命中结果。事后排查时这些日志价值极高。否则线上出了重复扣款你连哪个环节放行的都查不出来。7.3 分布式环境下监控幂等效果的几个指标上线之后幂等做得好不好可以通过指标观测。最直观的是“幂等拦截率”也就是某接口因为幂等键重复被拦截的请求数占总请求数的比例。正常情况下这个比例通常很低如果突然飙升很可能不是用户重复点击而是网关重试策略出问题或者消息队列重复投递。此时要去查调用来源而不是只看业务代码。第二个指标是“幂等键冲突 TOP 排行”。把冲突最多的幂等键打点记录下来然后去分析为什么同一个 key 被高频重复提交。我曾经遇到过一个请求量特别大的场景TOP 幂等键全是同一个用户 ID 拼固定值的结果最后发现是用户点击一次系统内部就重试几十次属于上游 bug。这种问题如果没有监控很难在第一时间发现。第三标记“兜底生效”次数。也就是前置 Redis 没有拦截住最后被数据库唯一约束拦住的那部分请求。这个数字如果持续大于 0说明前置拦截存在漏洞需要修但它恰恰证明兜底设计是有效的。我一向把这个指标当成系统幂等设计质量的一个关键反馈不丢人反而很有价值。8. 几个值得沿用的小经验讲到这里九种方案和组合逻辑都过了一遍。最后说几条我在多次事故里沉淀下来的经验。幂等设计一定要在最开始做不要等业务跑出重复数据再补。重复数据一旦出现靠后续清洗脚本修复非常痛苦还要对账、溯源、甚至给用户赔礼道歉。很多功能排期时“防重复”常常被认为是一个很简单的小需求实际上它对设计细节的要求非常高。与其后期返工不如在表结构设计阶段就把唯一的业务键和状态机迁移规则划清楚代码实现反而水到渠成。不要迷信某一个组件的能力。Redis 很快但它是 AP 系统主从故障时可能丢数据数据库可靠但直接扛所有重复流量代价太大分布式锁能让业务串行但锁过期问题永远存在。真正落地的架构一定是多级保护。至少分两层缓存层负责拦流量数据库层负责保证最终一致。接口对外返回时尽量设计成“重复请求也能返回成功的业务结果”。除非业务明确要求严格拒绝否则用户再点一次后端返回“您刚才提交的订单已成功单号是 xxx”比提示“重复提交”体验好得多。很多支付平台对重试请求的处理就是这个套路相同请求返回相同成功结果调用方不需要额外判断。这一点设计理念建议在后端接口规范里直接定下来。篇幅有限但幂等这个话题可以延展的坑还有不少。如果你们团队正在做交易类系统我强烈建议找时间把这些方案一一落到代码里做个对比实验带着自己的业务场景去压测感受比读十篇文章都要深刻。