幂等设计实战:从接口到消息消费的全链路防重方案

发布时间:2026/9/15 11:43:24
幂等设计实战:从接口到消息消费的全链路防重方案
凌晨一点我被电话吵醒。线上支付回调超时网关重试了三次结果同一笔订单在数据库里躺了两条记录库存也扣了两次。用户只付了一笔钱我却得连夜写脚本去冲正。这个事故让我重新把“幂等设计”四个字放到了架构优先级的最顶端。这次的项目复盘正好写到第20章主题就是幂等设计实战从接口到消息消费的全链路方案。下文记录的方案不是我读书读来的是线上踩坑踩出来的。欢迎对号入座也希望你不需要经历我这种“半夜被叫醒”的教训。1. 一次重复扣款事故逼我重新理解“幂等”1.1 事故现场还原当时业务场景很简单用户在App上下单调用支付渠道支付完成后渠道方回调我们系统的回调接口。回调超时或者渠道方自己重试都会导致同一个支付流水号被重复提交多次。那次事故的链条是这样的用户支付成功渠道方回调接口但由于网络抖动网关层超时。渠道方认为没送达开始重试重试了三次。三次请求几乎同时到达应用服务应用服务直接执行INSERT INTO orders ...。订单表中没有任何唯一约束于是同一个支付流水号插入了三条记录。库存服务也收到对应的扣减消息由于消息队列默认是“至少一次”投递库存也重复扣减了。等到我们发现的时候用户的账户里已经平白无故多了两笔未支付订单库存也少了。更麻烦的是当时并没有全链路唯一的请求ID想去重都不知道拿什么字段去重。1.2 幂等不是“不重复”而是“重复了也没事”很多人会把幂等理解成“请求不重复”。但实际上网络不可控我们永远无法保证请求只来一次。幂等真正要做的是同一个操作执行一次和执行多次产生的结果完全一致且不会因为重复执行产生副作用。最典型的例子就是电梯按钮。你按一次和按十次电梯都只会来一趟。ATM机取款也是你输入取款金额系统处理成功后就算重复提交也只会出一笔钱。所以做幂等设计的第一步是接受“重复”这件事一定会发生。只有接受了它才会在设计接口、表结构、消息消费逻辑时把防重当作第一公民来对待。1.3 幂等和防重其实是两件事我曾经也把“防重”和“幂等”混为一谈后来在排查问题时才意识到两者的层次完全不同防重在请求进来时用某种标识把重复请求拦截掉比如前端按钮置灰、Redis里放一个Token。它关注的是“这一次请求是不是重复的”。幂等即使防重失效重复请求穿透到了业务处理层业务系统依然能通过状态、唯一键等机制保证结果不变它关注的是“重复执行的结果是否一致”。防重是前端体验和网关层面的第一道防线但绝对不能成为唯一的防线。线上环境里超时重试、消息重放、人工脚本补数据随时可能绕过防重。我现在的习惯是防重做在入口幂等落在核心业务和存储层。接口层、数据库层、消息消费层每一层都要有独立的兜底方案。2. 接口层幂等设计三种常用武器怎么选接口层是外部请求进入系统的第一道关卡大部分业务接口都可以在这里完成幂等处理。但“加个Redis key”这种简单方案并不是万能的。我实际用过并且验证过的方案主要有三种Token模式、唯一请求号模式、状态机模式。2.1 Token模式先取号再办事Token模式适合“创建型”接口比如创建订单、提交表单、发起支付。流程是客户端先调用一个获取Token的接口服务端生成唯一Token并存入Redis设置有效时间。客户端提交表单时把Token放在Header或请求参数里。服务端收到请求后先尝试把该Token标记为已使用如果标记成功说明是第一次请求继续执行业务如果标记失败直接返回重复请求。实际用的时候我踩过一个坑有些同学直接用setIfAbsent(token, 1)来做标记但如果这个Token不是预先分配的而是客户端随便传的一个值第一次请求同样会“标记成功”导致Token校验形同虚设。所以Token模式必须配合“先取号、后办事”的流程服务端要对Token是否存在做校验。下面是我常用的写法// 获取幂等Token GetMapping(/idempotent/token) public String getToken() { String token UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue() .set(idem:token: token, 1, 30, TimeUnit.MINUTES); return token; } // 业务入口 PostMapping(/order) public Result createOrder(RequestHeader(Idempotent-Token) String token, RequestBody OrderReq req) { // 先校验Token是否由服务端生成 String key idem:token: token; if (Boolean.FALSE.equals(stringRedisTemplate.hasKey(key))) { return Result.fail(非法Token请重新获取); } // 删除Token原子操作删除成功说明是第一次请求 Boolean first stringRedisTemplate.delete(key); if (first null || !first) { return Result.fail(重复请求); } // 继续创建订单 return Result.ok(orderService.create(req)); }Token方案有个业务痛点是如果业务执行失败Token已经被删了客户端需要重新取Token再提交一次。这不一定不好反而能让用户感知到“刚才那次失败了我需要重新操作”。但如果是支付回调这种服务端到服务端的场景就不适合了因为回调方不会帮你重新取Token。2.2 唯一请求号把“这是第几次请求”交给请求方唯一请求号模式更通用尤其适合开放API接口也就是很多海外平台叫的Idempotency-Key机制。客户端每次请求时携带一个全局唯一的请求号服务端基于这个请求号做判重。判重可以在Redis里做核心是原子占位String key idem:req: requestId; Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(key, 1, 24, TimeUnit.HOURS); if (first null || !first) { // 返回上一次处理结果或者直接提示重复 return Result.fail(重复请求requestId requestId); } try { return Result.ok(process(req)); } catch (Exception e) { // 业务失败要释放占位否则重试会被直接拦截 stringRedisTemplate.delete(key); throw e; }这里必须注意只有业务执行成功幂等标记才能“永久保留”业务失败时应该删除标记允许同样的请求号重试。因为客户端拿到失败响应后通常会带着同一个请求号重试如果标记还在重试永远被挡在外面接口就变成了“一次失败永远失败”。另外在同一时间多个相同requestId并发到达时setIfAbsent要保证原子性。如果用“先查再写”的方式就存在竞态条件会让两个请求都进来。2.3 状态机让业务状态自己拒绝重复操作上面两种方案都是“外部标记”状态机则是把幂等逻辑融进业务模型。最经典的例子是订单状态待支付已支付已发货已完成已取消当支付回调再次进来时不能无条件把订单改成已支付而是必须满足“当前状态是待支付”才能更新。用SQL表达就是UPDATE orders SET status PAID, pay_time NOW() WHERE order_no #{orderNo} AND status WAIT_PAY如果影响行数为0说明订单当前已经不是待支付状态重复操作被状态机挡掉了。这种方法简单、可靠不依赖Redis也不依赖额外存储但前提是业务状态流转必须设计清楚。否则某个状态可以被多个前置状态进入状态机的幂等能力就会大打折扣。2.4 三种方案怎么组合我现在的选择逻辑很简单用户主动提交的表单Token模式兼顾防重和幂等体验最好。开放API、服务间调用、回调接口唯一请求号模式靠requestId或Idempotency-Key判重。核心业务状态变化尤其是钱和库存状态机兜底让状态字段本身保证不能重复流转。三种方案不是互斥的。一个复杂接口可能同时用唯一请求号和状态机先用requestId快速拦截重复请求再用状态更新条件保证极端并发下也不会覆盖已有状态。3. 数据库与缓存的幂等兜底接口层面拦不住时还有这一层接口层的幂等做得再好也架不住链路后段出现重试、消息重放、人工修复数据等情况。所以数据库和缓存这一层必须能独立兜底。3.1 唯一索引数据库帮你挡住重复没有比唯一索引更硬核的去重手段了。只要业务上有唯一的语义就应该在数据库里建唯一索引。比如订单号在订单表中唯一。支付流水号在支付记录表中唯一。消息ID在消费记录表中唯一。业务流水号在流水表中唯一。表结构可以这样设计CREATE TABLE payment_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, payment_no VARCHAR(64) NOT NULL COMMENT 业务支付流水号, order_no VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_payment_no (payment_no) ) ENGINEInnoDB;插入时如果遇到了重复的payment_no数据库会抛出DuplicateKeyException我们直接捕获并当作幂等处理即可try { paymentRecordDao.insert(record); } catch (DuplicateKeyException e) { // 重复插入说明已经处理过 log.warn(重复支付流水号: {}, record.getPaymentNo()); return; }注意唯一索引的字段一定要选业务上天然唯一的字段不能用自增ID。也别把唯一索引建在几个可空字段组合上因为数据库对NULL值的唯一性判断和预想的不一样很容易漏掉重复。3.2 乐观锁用更新条件挡住重复写乐观锁的核心是“版本号”或“状态条件”。版本号方式UPDATE inventory SET stock stock - #{count}, version version 1 WHERE sku_id #{skuId} AND version #{oldVersion}如果影响行数为0说明版本号已经变化当前更新是基于旧数据的直接重试或者返回失败。对于更偏向状态流转的场景则用状态条件比如前面提到的WHERE status WAIT_PAY。乐观锁的优点是不同并发请求彼此独立不会像悲观锁那样把数据库连接卡死。但要注意如果一次操作涉及多个字段或多个表要评估哪些字段参与版本控制不能随便更新一个不重要的字段来充当版本号。3.3 Redis原子操作高并发场景下的去重利器在高并发场景里比如秒杀、限时活动数据库同一行记录的并发更新很容易变成热点这时候Redis的原子操作就派上用场了。常用的是SETNX和Lua脚本。以库存扣减为例-- 扣减库存key: sku:stock:{skuId} local stock tonumber(redis.call(GET, KEYS[1]) or 0) local count tonumber(ARGV[1]) if stock count then return 0 end redis.call(DECRBY, KEYS[1], count) return 1Redis单线程执行命令Lua脚本具备原子性重复执行这个脚本不会导致超扣。但也要清醒地认识到Redis和数据库之间存在一致性问题。如果Redis扣了库存数据库事务回滚了就会出现Redis少了库存数据库没少。所以实际落地时我一般只把Redis当成“入口挡板”真正的库存扣减还是以数据库事务为准Redis操作失败时不允许请求继续。3.4 这一层的常见误区Redis键过期时间太短业务长事务可能会超过键的过期时间导致事务还没结束幂等键就消失了。核心场景建议先算好最长执行耗时再设置24小时以上的过期时间。只做缓存标记不做数据库落库缓存一旦丢失所有幂等标记就全没了。数据库唯一索引是最后底线。用更新后的结果做判断但更新条件没有把用户维度带进去比如只写WHERE order_no ?没有写AND user_id ?一旦有其他请求改了这段数据可能把别人的状态也覆盖了。4. 消息消费幂等重复消费不可怕可怕的是没有消费ID接口层做完幂等只是解决了“入口”问题。很多业务在接口层已经判重了但接口内部会发送MQ消息消息在异步消费时又可能出现重复消费。这不是偶然而是MQ的投递语义决定的。4.1 为什么消息一定会重复消费常用的消息队列比如Kafka、RocketMQ默认都提供“至少一次”投递语义也就是说消息可以被消费多次。原因有很多消费者在消费完消息、还没来得及提交位点offset时挂了恢复后从旧位点重新拉取。消费者处理成功但提交位点超时Broker认为消费失败重新投递。消费者加入/退出导致分组Rebalance消息被重新分配可能重复消费。如果消费逻辑不幂等重复消费的直接后果就是数据重复写入、库存重复扣减、状态被覆盖。而且这类问题往往比接口重复更隐蔽因为日志里看起来一切都是正常的。4.2 消费幂等的关键找到“消费操作”的业务唯一键消息消费幂等不能只依赖MQ自带的msgId。msgId只能说明“这条消息重复了”但无法判断“这条消息代表的业务操作是否已经执行过”。所以最终要去重的必须是业务ID。举个例子订单支付成功消息{ msgId: a7f3c2d0-xxxx, orderNo: ORD202501010001, payNo: PAY202501010001, payStatus: SUCCESS }这里的orderNo或payNo才是幂等键。即使msgId变了只要payNo相同消费端也要能识别出是同一笔支付。4.3 消费端双保险Redis标记 数据库唯一记录我常用的消费幂等方案是“Redis标记 数据库唯一记录”双保险消费时先按业务幂等键在Redis里setIfAbsent占位。占位成功继续处理业务。业务处理成功后向消费记录表插入一条唯一记录。如果处理失败删除Redis占位允许MQ重试。如果重复消费Redis占位失败直接返回即使Redis被穿透数据库唯一索引也能兜底。伪代码如下public void onMessage(OrderPaidMessage msg) { String idemKey idem:consume: msg.getPayNo(); try { Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(idemKey, 1, 24, TimeUnit.HOURS); if (first null || !first) { log.info(重复消费payNo{}, msg.getPayNo()); return; } // 业务处理更新订单状态 orderService.markPaid(msg.getOrderNo(), msg.getPayNo()); // 业务成功后落消费记录 consumeRecordDao.insert(new ConsumeRecord(msg.getMsgId(), msg.getPayNo())); } catch (DuplicateKeyException e) { // 数据库唯一索引兜底 log.warn(消费记录已存在忽略重复消息payNo{}, msg.getPayNo()); } catch (Exception e) { // 业务异常释放幂等标记让MQ重新投递 stringRedisTemplate.delete(idemKey); throw new RuntimeException(消费失败等待重试, e); } }注意这里有一个细节如果orderService.markPaid()已经成功但插入消费记录失败捕获到异常后会删除Redis占位MQ会重试看起来会重复执行业务。但因为订单状态已经变成PAIDmarkPaid里如果没有状态机条件就会出问题。所以消费端的业务处理一定要依赖状态机或唯一索引兜底不能只靠Redis。4.4 重试、死信和消息回放该怎么办重试MQ重试时消费端拿到的是同一条消息幂等键不变Redis标记通常还在能快速拦截。所以要给幂等标记设置足够长的过期时间最好大于最长重试周期。死信当消息重试到最大次数还是失败会进入死信队列。死信消息也会有重复消费的可能人工排查后重新投递时幂等键要保留。消息回放为了排查数据问题运维经常会把线上消息重新投递一遍。这种情况依赖Redis标记可能已经过期但数据库唯一索引依然有效这是我最看重兜底方案的原因。5. 全链路幂等贯穿一张ID串起接口、DB和MQ只有各层各自做幂等还不够。一个完整的业务操作往往要穿越HTTP接口、数据库、消息队列、下游服务如果每一层用的幂等键都不一样很容易在链路中间断掉。比如接口层用requestId去重到消息消费时却拿orderNo去重看起来都对但无法回答“这笔订单从头到尾是不是同一个幂等上下文”的问题。5.1 统一幂等ID的生成与透传我的做法是在链路入口生成一个全链路唯一的Idempotency-Key或TraceId然后通过HTTP Header、消息字段、数据库字段一直传递下去。以交易链路为例用户在App提交订单网关层为这个请求生成traceId同时接收客户端传来的requestId。交易服务在创建订单时用requestId做接口层判重同时生成业务订单号orderNo。orderNo就是订单表、支付记录表、消息体里面共用的业务幂等键。支付回调接口用paymentNo做幂等而paymentNo和orderNo在业务上是一一对应的。发送MQ消息时消息体里同时携带orderNo和paymentNo消费端用paymentNo做消费幂等。下游服务如果还需要继续发消息就把业务幂等键原样透传下去不能重新生成一个跟业务无关的ID。这样梳理下来你会发现一个业务请求在每一层可能表现出来的是不同的“号码”但它们之间必须有稳定的映射关系。否则就会出现接口层判重了但消息层重复消费后产生的数据和接口层的数据对不上。5.2 下单场景全链路推演我画过一张时序图给自己看用文字描述就是客户端POST /orders?requestIdabc123。网关透传requestId生成traceId用于日志检索。订单服务检查Redis中requestId是否已处理没有则继续。订单服务插入订单记录订单号ORD202501010001状态WAIT_PAY。订单服务发送OrderCreated消息消息体带orderNo。支付回调POST /payment/callback带paymentNoPAY202501010001对应orderNo。支付服务按paymentNo判重并校验订单状态必须为WAIT_PAY。支付服务更新订单为PAID发送OrderPaid消息消息体同时带paymentNo和orderNo。库存服务消费OrderPaid消息按paymentNo在消费记录表里插入唯一记录再扣减库存。这条链路上requestId负责入口防重orderNo负责业务身份paymentNo负责支付事件幂等。每一层都能回答“这个操作我是不是已经做过了”。5.3 分布式事务与最终一致性全链路幂等和分布式事务经常一起出现。当一次操作涉及订单库、库存库、支付服务等多个系统时我们不可能用本地事务把它们全部包住通常采用“本地消息表”或“事务消息”实现最终一致性。但很多人忽略了最终一致性的前提是每一个节点都幂等。如果A系统发出消息B系统消费时不幂等那不管事务协调器怎么重试最终数据都是乱的。反过来只要每个节点都是幂等的就算消息重发、任务重跑最终状态也能收敛到一致。所以我的观点是分布式事务框架解决的是“执行顺序”和“回滚协调”的问题幂等解决的是“重复执行”的问题。两者是搭档关系不是替代关系。5.4 设计原则能接受重复但必须结果一致做全链路幂等时有一个心态要摆正你不能保证所有请求只来一次但可以保证重复请求不会造成额外影响。与其花大力气写复杂的判重逻辑不如把核心业务的状态流转设计干净把数据库唯一约束建对把消息消费的幂等键定准。一些边界情况也要提前定好幂等键为空时怎么办直接拒绝还是生成一个我倾向直接拒绝因为空幂等键意味着无法保证幂等。重复请求返回什么有的系统返回200和“重复提交”提示有的直接返回409 Conflict。只要客户端能识别就行但服务端日志里一定要记录重复的幂等键和请求来源。幂等标记的保留时长至少等于业务最长事务时间加上业务补偿、人工重放需要的窗口我通常设置为24小时到7天。6. 复盘掉进过的坑与沉淀下来的经验最后这章聊聊这几年下来掉进去的坑。有些坑很小但足够让人在半夜爬起来看日志。6.1 五个高频坑坑点现象根因建议只在接口层做了幂等忘了消息消费端接口状态正常但库存/积分多扣接口判重后内部发消息没有设置消费幂等消费端必须用业务ID再次判重唯一索引建错字段重复数据依然插入成功用了自增ID或可空字段做唯一键选业务上天然唯一的字段Redis幂等键过期时间太短长事务后重试出现重复处理键过期占位失效设置24小时以上核心场景用DB兜底乐观锁更新条件漏掉业务维度状态被其他请求覆盖WHERE条件太宽条件必须包含业务主键和状态业务失败后没有释放幂等标记重试永远被挡用户永远失败把“请求占位”和“业务成功”混为一谈业务失败要删除标记允许重试6.2 落地顺序建议如果你现在要改造一个老系统我建议不要试图一次性全链路全覆盖可以按这个顺序推进先给核心表加唯一索引这是成本最低、收益最明显的兜底。再给外部写接口加幂等处理优先处理支付回调、订单创建这类接口。接着处理消息消费端找到所有消费逻辑里“重复执行会导致数据异常”的消息加上幂等。最后做统一ID透传和监控在网关生成traceId在统一日志和监控里能看到“哪个幂等键重复了几次”。这个过程其实也是梳理业务链路的过程。做完一轮之后你会发现很多以前不敢碰的异步逻辑现在都敢拆了。6.3 测试与压测怎么做幂等设计是否生效不能靠口头发誓要靠测试和压测来验证。我的做法是用接口自动化测试工具循环调用同一个requestId断言第二次响应的结果并检查数据库里只有一条记录。模拟消息重复投递把同一条MQ消息发送两次观察消费端日志和数据库。用并发压测工具同时发送N个相同请求确认没有并发穿透。做故障演练杀掉正在消费的进程等MQ重新投递后检查业务数据是否一致。这些测试看起来简单但只有真正跑过你才会发现并发场景下的竞态问题比如两个请求同时走到了setIfAbsent之前。6.4 一点个人体会从那次凌晨事故到现在我最大的变化是做任何涉及写操作的接口或消费逻辑都会先问一句“这个东西重复执行一次会怎样”如果答不上来那我一定不会把它放上线。幂等设计不是某一次重构的阶段性任务而是做分布式系统的基本功。很多方案看起来不性感甚至有点土比如唯一索引、状态机条件但它们比任何花哨的框架都可靠。我的经验是先把最土的手段用好再用Redis和分布式框架做优化千万别反过来。下一章我大概率会写“分布式事务与最终一致性”的实战记录很多东西其实是幂等的延伸。如果你也在做类似的事情欢迎一起交流踩坑心得。