ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

幂等设计实战:从API到MQ消息消费的全链路防重方案 这一章要聊的幂等设计真的不是“加个唯一索引”那么简单。我碰到过最典型的场景是支付回调因为超时被客户端重试了三次结果生成订单的服务被调了三次优惠券也发出去三张财务对账怎么都对不上。后来排查半天发现接口层、消息消费层、异步任务层各管各的每个环节都以为上游会兜底结果谁都没兜住。所以这一章我打算直接从实战角度把从API接口到MQ消息消费这条链路上的幂等方案完整串一遍。你如果正在做支付、订单、库存这类强一致业务或者接了不少第三方回调、消息队列异步任务这篇文章基本能帮你省掉大半的踩坑时间。我尽量把代码、表结构、踩坑点都直接铺开便于你在自己的项目里照着改。1. 幂等问题的本质先搞清楚做的是防重还是幂等1.1 从一次重复支付回调说起先还原一下现场。用户下单之后跳转支付支付平台回调我们后端接口这个回调因为网络抖动支付平台在几十秒内重试了三次。第一次请求到了正常处理第二次请求到的时候订单已经变成“已支付”了第三次也一样。如果接口没有做幂等处理第二次请求可能会把订单状态再刷一遍如果里面还有“发优惠券”“加积分”这类逻辑就会被重复执行。一次事故下来用户实际付了一次款但业务系统里记了三笔奖励。这个案例特别典型因为它涉及两个层面的问题接口层能不能识别“同一个请求”业务层能不能容忍“重复执行但不改变结果”如果接口层只做了简单的token校验而业务层的状态更新不是原子操作还是会出问题。所以幂等方案从来不是单点的事它要求接口层、业务层、存储层协同配合层层兜底。1.2 幂等、防重、一致性到底是什么关系先区分三个容易混淆的概念。防重指的是“同一请求只允许成功一次后续请求直接拒绝”比如用户提交订单时点了两次按钮第二次应该被拦截幂等指的是“重复执行不会改变结果”比如支付回调重复调用第一次执行成功第二次直接返回成功即可但不重复发奖励。一致性则是更上层的目标要求数据在多次干扰下依然正确。实际项目中防重通常靠token或者短时分布式锁实现属于入口拦截幂等一般靠唯一键、状态机、版本号实现属于业务处理层兜底。两者不是替代关系而是叠加使用的。我之前在项目里就是先在前端做按钮置灰后端接口再做token校验业务层再用唯一键兜底三层都做完才敢说这个接口“稳了”。1.3 到底哪些场景必须上幂等从个人经验来看只要满足以下任意一条就应该认真设计幂等外部系统会重试的支付回调、短信回调、第三方webhook客户端可能重复提交的下单、领取、报名MQ消费端属于“至少一次”语义的Kafka消费者宕机重平衡时会重新投递消息内部定时任务可能重叠执行的。反过来像纯查询接口、幂等写操作比如insert时使用相同的唯一主键天然不会产生副作用就不必过度设计。我见过不少人一听到幂等就上Redis锁结果锁覆盖的范围不对反而把性能拖垮了。其实先判断场景再选方案比一上来就写一堆代码更实用。这个判断标准我建议贴在团队wiki里这个操作失败后重试会不会产生重复数据会不会重复扣减会不会发重复通知只要有一个“会”就必须做幂等。2. 接口层幂等三种主流方案与取舍2.1 TokenRedis 预生成凭证一次请求一个令牌这类方案适合“先领令牌再执行业务”的流程典型场景就是用户提交订单。前端在进入提交页面时先向后端请求一个幂等token后端生成UUID并写入Redis设置合理的过期时间比如30分钟用户提交订单时必须带上这个token后端收到请求后在Redis里删除该token删除成功说明是首次请求继续执行下单逻辑删除失败说明token不存在或已被使用直接拒绝。这个方案的关键在于“校验并删除”必须是一个原子操作。我见过有人先get再delete结果两个并发请求都拿到了旧token然后都认为自己是第一个导致重复下单。正确的做法是直接用Redis的del命令单命令本身就是原子性的如果想带业务校验就用Lua脚本来完成。还有一个容易被忽略的细节token不能只做接口层校验还必须和业务数据绑定。我习惯把token设计成“bizKey 随机串”bizKey是业务唯一标识比如用户ID订单类型这样即使token被人为篡改也能在删除前校验业务的合法性。// 获取幂等Token public String acquireToken(String bizKey) { String token UUID.randomUUID().toString().replace(-, ); String key idem:token: bizKey : token; stringRedisTemplate.opsForValue().set(key, 1, Duration.ofMinutes(30)); return token; } // 提交时校验并消费Token public boolean checkAndConsume(String bizKey, String token) { String key idem:token: bizKey : token; // del成功返回true失败返回false单命令是原子的 Boolean deleted stringRedisTemplate.delete(key); return Boolean.TRUE.equals(deleted); }这套方案优势是简单直观对原业务侵入小缺点是要求客户端必须配合先取token如果遇到第三方回调这类“无法预取token”的场景就不太适用。2.2 数据库唯一键兜底最硬核的防重手段Token方案能挡住绝大多数重复请求但扛不住一种极端情况两个请求同时通过了token校验然后同时进入业务代码都去执行insert。这个时候只能靠数据库层的唯一约束来兜底。这也是我强烈建议每个核心业务表都必须设计“业务唯一键”的原因。以支付记录表为例我们可以给“商户订单号支付渠道流水号”建联合唯一索引。当支付回调来了处理器先尝试插入支付记录如果能插入成功说明这是首次通知继续更新订单状态如果插入时抛出DuplicateKeyException说明这个支付回调已经处理过了直接返回成功就行。这样即使前面任何一层都失效了数据库也能把重复的数据拒之门外。CREATE TABLE pay_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL COMMENT 商户订单号, pay_trade_no VARCHAR(64) NOT NULL COMMENT 支付渠道流水号, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待支付 1-已支付 2-已退款, amount DECIMAL(12,2) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_pay (order_no, pay_trade_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;很多人对“数据库唯一索引”有个误解觉得它只能防insert不能防update。实际上对于“扣库存”“加积分”这类update操作也可以用“状态位条件更新”来实现幂等。比如更新语句里带上WHERE status 0只有第一次能把状态从0改成1后续update影响行数为0自然就不会重复执行。这种方案在后面讲状态机的时候会详细展开。2.3 状态机推进乐观锁让业务本身具备幂等性有些业务天然适合用状态机来保证幂等典型的就是订单状态流转。订单从“待支付”到“已支付”再到“已发货”每个状态只能往既定方向推进不允许回退。如果回调重复通知第二次试图把“已支付”改成“已支付”的时候SQL里带了AND status 待支付结果影响行数为0代码就能判断出“重复请求不需要处理”。这种方式比起前面的方案最大的优势是不依赖外部存储不需要额外维护token或者去重表直接把幂等约束写进了业务规则里。但代价是状态机的设计需要非常严谨每个状态之间的流转关系都要理清楚。我建议在项目初期就把订单状态枚举和流转矩阵画出来挂在设计文档里否则后面加需求时很容易出现状态跳变把整个状态机搞乱。// 伪代码示例支付成功回调处理 int rows orderMapper.updateStatusByCondition( orderNo, OrderStatus.PAID, OrderStatus.UNPAID // 期望当前状态 ); if (rows 0) { // 没有更新成功说明状态不是未支付可能是重复回调也可能是非法流转 // 需要再查一次订单状态决定是返回成功还是抛出异常 Order order orderMapper.selectByOrderNo(orderNo); if (order.getStatus() OrderStatus.PAID) { return 重复回调直接返回成功; } throw new IllegalStateException(订单状态异常); }状态机方案还可以和版本号乐观锁组合使用。给业务表加一个version字段每次更新都带上WHERE version #{oldVersion}更新成功后把version1。这样即使两个并发请求同时读到同一个版本最后也只有一个能更新成功。这个方案在防止“丢失更新”上效果立竿见影。2.4 实战对比怎么选、怎么混搭把这三种方案放在一起看没有哪个是绝对最优的。TokenRedis适合有交互界面的新增类操作用户体验好能提前拦截数据库唯一键适合有明确业务单号的场景比如订单号、支付流水号、申请单号成本最低但要求表结构提前设计好状态机/乐观锁适合更新类操作能让业务规则清晰但需要分析状态流转。我的习惯是策略混搭接口入口用Token或分布式锁做第一层拦截核心业务表用唯一键做第二层兜底涉及状态流转的再用状态机做第三层保障。三层都过了这个接口才算真正安全。特别是涉及资金、积分、库存的接口宁可在数据库层多做几个索引也不要省掉唯一键。3. 消息消费层的幂等重复消费才是常态3.1 为什么消息一定会重复接口层的重复请求主要靠重试或超时触发但消息队列的重复消费几乎是必然的。以Kafka为例消费者处理完一条消息后如果还没来得及提交offset就宕机了Kafka会把这个分区重新分配给其他消费者新的消费者会从之前提交的offset位置重新消费那条“处理完但没提交offset”的消息就会被再消费一次。RabbitMQ的自动ack模式也有类似问题消费者收到消息、处理过程中崩溃消息会被重新投递。所以在设计消息消费逻辑时千万不要抱着“消息只会被消费一次”的幻想。一定要假设每条消息都可能被消费多次而且消费顺序可能是乱的甚至同一业务可能有不同的事件重复到达。消费端幂等就是在这个前提下保证“重复消费不产生重复数据、不重复发奖、不重复扣款”。3.2 方案一消费侧去重表简单可靠这个方法是我最常用的基本逻辑是消息里面带一个业务幂等键消费者收到消息后先尝试往“消息消费记录表”里插入一条记录如果插入成功继续处理业务如果插入冲突唯一键冲突说明这条消息之前已经消费过了直接返回成功并提交offset。去重表的设计有两个关键点。第一幂等键要选业务键而不是messageId。因为同一个业务可能对应多条消息比如“下单”会触发“扣库存”“发优惠券”“生成物流单”等多个事件如果用messageId做去重只能保证同一条消息不重复消费但没法保证同一个业务操作不被重复执行。正确的做法是让每条消息在业务层带上bizType bizId比如order:123456这样所有跟这个订单相关的事件都能共用同一个去重键。CREATE TABLE msg_consume_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_key VARCHAR(128) NOT NULL COMMENT 业务幂等键例如 order:123456, msg_id VARCHAR(64) NOT NULL COMMENT 原始消息ID用于排障, consume_status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中 1-已完成 2-处理失败, consume_time DATETIME DEFAULT NULL, remark VARCHAR(255) DEFAULT NULL, UNIQUE KEY uk_biz_key (biz_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第二去重记录的插入和业务逻辑要在同一个本地事务里。先插入去重记录再执行业务最后更新处理状态整个过程中如果业务抛异常事务回滚去重记录也跟着回滚下次消息重试时还能再插入这样才能保证“失败可重试、成功不重复”。我见过有人把插入去重记录放在业务事务外面结果业务执行失败但去重记录已经提交了后序重试就被直接挡住业务永远无法成功。3.3 方案二业务表状态字段天然去重如果业务本身有明确的处理状态比如“对账文件处理记录”表里有FILE_STATUS字段刚开始是INIT处理成功改成SUCCESS。消息消费者在处理前先执行一条更新语句UPDATE file_record SET status PROCESSING WHERE id #{fileId} AND status INIT。如果影响行数为1说明当前是首次处理可以继续往下走如果影响行数为0说明已经处理过直接跳过。这种方案本质上跟接口层的状态机一致只是应用到了消息消费场景。它的好处是省去了一张去重表坏处是要保证所有消息都能找到对应的业务记录。如果消息里带的业务ID在业务表里根本不存在那这条更新语句永远影响0行这时要区分“重复消息”和“非法消息”。我一般会在更新0行之后再查一次业务表如果记录存在且状态是最终状态就按重复消息处理如果记录不存在就按非法消息告警。3.4 方案三Redis Lua脚本做原子检查如果对性能要求比较高不想每次消费都去操作数据库可以借助Redis的SETNX命令实现轻量级去重。核心思路是以业务幂等键作为Redis的key消费前执行SETNX key value如果返回1说明第一次消费继续执行业务如果返回0说明已经消费过直接跳过。为了防止Redis key永久残留需要设置过期时间建议设为业务处理最大耗时的2到3倍。这里有个坑必须提醒如果执行业务的时间超过了key的过期时间那么第二次消费时key已经过期了会被当成“第一次消费”再次执行。所以这种方式只适合处理耗时可控、不涉及资金操作的短任务。对于耗时长的任务我更建议用数据库去重表兜底或者在使用Lua脚本时把过期时间设得足够长同时监控业务耗时。-- KEYS[1] 幂等键 -- ARGV[1] 业务执行者标识可选 -- ARGV[2] 过期时间秒 local exists redis.call(setnx, KEYS[1], ARGV[1]) if exists 1 then redis.call(expire, KEYS[1], ARGV[2]) return 1 end return 03.5 消费侧幂等的关键细节ack时机与本地事务消息消费的重复消费问题除了幂等处理本身还有一个非常隐蔽的坑本地事务和消息ack的时序。我踩过的场景是这样的消费方法里先执行了业务操作然后手动ack了消息结果在ack之后又有一段“发短信”的逻辑抛异常了。RabbitMQ认为消息已经被ack不会再重投但短信其实没发出去业务数据却已经变了。这个问题的本质是“业务操作”和“消息确认”没有在同一个原子操作里完成。解决办法有三种一是把业务操作和消息去重放在同一个本地事务里事务提交成功后再手动ack二是用“事务后事件”的思路在业务事务提交成功后异步发送确认信号三是把不必要的副作用逻辑尽量前移或者精简减少ack之后的善后操作。总之消息消费端的核心原则是宁可多消费一次也不要少执行一次。重复消费有幂等保护少执行一次就只能靠对账补偿去捞了。4. 打通全链路从接口到消息的统一幂等上下文4.1 requestId如何在请求链路中透传前面的内容分别讲了接口层和消息层的幂等方案但实际生产环境里这两层往往是一条链路上的不同环节比如用户调接口下单 - 接口内发送MQ消息 - 消息消费者扣减库存 - 扣减成功后调用第三方发货接口。如果每一层都各建一套幂等标识链条一长就非常难排查因为你没法快速判断“MQ里这条消息是不是对应接口层那个请求”。所以我的做法是全链路统一使用requestId也叫traceId作为幂等上下文的主键。用户在接入层发起请求时网关或者框架层生成一个全局唯一的requestId放进HTTP Header里习惯用X-Request-Id然后通过Feign拦截器、HTTP Client拦截器逐层传递。当业务代码需要发送MQ消息时把requestId塞进消息头的trace_id字段消费者从消息头里取出这个值作为当前消费逻辑的幂等前缀。这样任何一条消息出了问题都能沿着requestId回溯到最初的那个HTTP请求。// Feign 请求拦截器示例 public class FeignRequestInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { String requestId IdemContext.getRequestId(); if (StringUtils.hasText(requestId)) { template.header(X-Request-Id, requestId); } } }同时要注意requestId不能一股脑当作所有场景的幂等键。如果一个请求内部需要在本地事务里插入多条记录或者发送多条不同业务类型的消息单一的requestId会导致它们互相冲突。我的处理方式是“requestId定位请求链路bizKey定位业务操作”比如requestId:order:123456表示“订单操作”requestId:refund:123456表示“退款操作”。这样既保留了链路的可追踪性又保证了不同业务操作的幂等键互不干扰。4.2 统一幂等框架的设计思路当项目里需要上幂等的接口越来越多时每个接口都手写一遍“查Redis、删Token、捕获唯一键冲突”会非常冗余还容易遗漏异常分支。我建议抽一个轻量级的幂等组件通过注解 切面的方式统一处理。核心设计大致是这样自定义一个Idempotent注解里面可以配置幂等键的SpEL表达式、幂等存储类型Redis还是数据库、过期时间切面在方法执行前解析注解上的SpEL表达式从入参里取出幂等键然后根据存储类型执行“检查并占位”业务方法执行成功后更新幂等状态为“已完成”如果业务抛异常释放占位允许后续重试。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { // SpEL表达式例如 #order.orderNo String key(); // 幂等存储REDIS / DB IdempotentType type() default IdempotentType.REDIS; // 过期时间 long expireSeconds() default 3600; }搭建这个框架时有几个细节值得注意。第一幂等检查不要直接包在整个大事务的外层否则一个长事务会把Redis锁或者数据库锁持有很久并发高的时候就是灾难。我建议只对“核心写操作”做幂等控制查询和预校验放在外面。第二切面里要把“重复请求”和“业务异常”区分清楚重复请求返回的应该是“重复提交”提示而不是把异常堆栈抛给调用方。第三切面本身不应该吞掉业务异常否则调用方以为成功了实际上业务没执行这个比重复执行更麻烦。4.3 异步场景下幂等标记如何延续异步场景是幂等最容易断层的地方。举个例子接口层通过token拦截了重复提交订单创建成功然后往MQ发消息接下来的消息消费者如果不知道订单号的存在只拿messageId做去重那这条消息被重复投递时消费者还是会把“扣库存”执行两次。要让幂等延续到异步链路核心是“业务标识跟着消息走”。生产者在发送消息时必须把能唯一定位这次业务操作的字段订单号、退款单号、任务单号放进消息体里消费者处理前优先从消息体里提取这个业务标识而不是自己去生成一个新的随机ID。另外一个实用经验是消费者收到消息后不要立刻做完整业务逻辑而是先做“幂等预检”如果发现这条消息对应的业务操作已经完成直接返回成功并提交offset如果正在处理中可以做延迟重试避免并发重复执行同一任务。我自己在项目里维护了一张“异步任务状态表”字段包括task_id、biz_type、biz_id、task_status、execute_count、last_execute_time。所有异步消息的消费者都先查这张表如果task_status是SUCCESS直接跳过如果是RUNNING且已经跑了很久就告警人工介入如果是FAILED允许重新执行。这张表既充当了幂等去重层也充当了异步任务的心跳监控一举两得。5. 高频踩坑与排查实录5.1 并发相同请求把库存打成负数这是最典型的“查询再更新”导致的并发问题。很多人写的扣库存逻辑都是先select stock from product where id ?判断stock大于0再update product set stock stock - 1 where id ?。如果两个请求同时查到stock1然后同时执行update库存就变成了-1。正确的做法是直接更新并携带条件update product set stock stock - 1 where id ? and stock 0然后判断影响行数如果为0说明库存不足拒绝请求。这样数据库层面天然保证了操作的原子性不需要额外加分布式锁。如果还要做防重可以在扣减前先查“订单号是否已经扣过库存”再配合唯一索引兜底。这个案例也说明一个道理好的幂等设计不一定是复杂的有时候一条带条件的SQL就能解决90%的问题。5.2 本地事务还没提交消息就把任务重复执行了这个坑我印象非常深。有一次我写了一个流程在订单创建的本地事务里把订单数据更新完然后立刻往MQ发一条“创建物流单”的消息。结果消费者响应非常快几乎在事务提交前就收到了消息然后去业务表查订单数据查出来的是旧状态逻辑直接异常退出。更隐蔽的是事务回滚后消息已经发出去了消费者重试还是查不到新数据就形成了一条“永远失败”的脏消息。解决思路有几种。如果使用本地事务MQ可以把消息发送放在事务提交后的TransactionSynchronizationManager.registerSynchronization回调里确保事务提交成功后再发消息或者用RocketMQ这类支持事务消息的中间件把“本地业务操作”和“消息发送”一起提交。如果暂时改不了中间件也可以在生产端对消息做延迟投递给消费者留出处理时间。但从根治角度看还是推荐“本地事务成功后再发消息”这个原则。5.3 值判断无法处理“部分成功”再讲一个逻辑层面的坑。有些人做幂等的方式是方法一开始查询业务表发现状态是“已完成”就直接返回成功。这种方式能挡住重复请求但扛不住一种情况上一次执行时业务数据已经更新成“已完成”但后续的“发短信”“发通知”等副操作还没执行完整个任务就因为异常中断了。此时重复请求来了发现状态是“已完成”直接返回成功结果“发短信”就永远没执行。这就是“部分成功”的问题。面对这种情况不能只看最终状态就判断“已完成”而要设计成“目标状态副作用状态”双重判断。比如订单表里有order_status还有一张side_effect_log表记录短信、通知等副操作的执行情况。只有当主业务状态为“已完成”且所有副操作状态都是“已完成”时才允许直接返回成功否则需要走重试或者对账补偿。这一点在需要对接外部系统的场景里特别重要因为外部调用可能没有事务无法跟着本地事务一起回滚。5.4 排查工具与方法幂等问题最难受的地方在于复现困难因为重复请求可能只出现在特定时序下。我的排查经验是先把链路追踪开起来每个请求的requestId要贯穿日志、Redis key、数据库记录这样出现问题时能根据requestId把整条链路拉出来。其次要看消息中间件的重试日志许多重复消费问题都能在重试日志里发现端倪比如消费者处理时间超时、offset提交失败、消费者频繁rebalance。最后是看数据库的唯一键冲突日志这类异常往往非常频繁但容易被忽略建议在捕获DuplicateKeyException的地方打上warn级别日志并且带上业务关键ID方便排查。针对性能问题我还会写一个小脚本定期扫描Redis里的幂等key数量和过期时间分布如果发现某些业务key的数量增长过快多半是幂等键设计得太细了比如把用户每次点击都作为幂等键或者key没有设置过期时间。这种问题早点发现能省很多事。最后再分享一个我个人非常坚持的体会幂等键千万不要自己现造随机数优先用业务上天然唯一的字段比如订单号、支付流水号、退款单号。因为随机数只在“这一次请求”内有效而业务字段能在“这个业务”的整个生命周期里持续识别重复。等你在对接支付、干跑对账、接各种第三方回调时就会明白这个原则能帮你躲开大部分“看似幂等实际不幂等”的坑。后来我接手新项目第一件事就是让团队把所有核心表的主键、唯一键、业务单号梳理一遍确定好每个关键路径的幂等键规则后面接消息队列和异步任务时基本上就是套模板的事。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进