接口幂等性全链路设计实战:防重表、分布式锁与状态机方案
发布时间:2026/10/8 9:28:09 作者:尧图编辑部 阅读量:1,286

接口幂等性这个词面试时人人能聊几句真到线上出问题能快速定位并解决的人却不多。我自己就栽过一次一次支付回调触发了客户端超时重试同一笔支付结果被消费了两次库存扣了两遍订单状态还被后到的请求刷新成了异常。复盘会开得很安静因为每一步服务都返回了成功——问题恰恰出在没有一处做接口幂等性控制。要把接口幂等性做成一道真正的防线不是加个Redis锁那么简单得从HTTP入口、网关、业务服务、消息队列、数据库存储一直到重试补偿机制做一套全链路的设计。这篇文章我会把理论基础、常见实现、选型对比、全链路实战步骤以及三次线上踩坑的完整排查链路都摊开来讲适合正在做支付、订单、库存这类写密集型业务的同学参考。1. 先想清楚接口幂等性到底在防什么1.1 一次真实事故的完整还原那是业务高峰期支付网关回调我们的订单服务。支付渠道有自己的重试机制我们的网关配了超时重试服务端还接了一套消息队列异步处理任务也开启了自动重投。本来这条链路的每个环节单独看都很合理偏偏谁都没做幂等控制——同一笔支付成功的消息通过客户端超时重试网关重试MQ自动重投三条路径几乎同时到达了订单服务。结果就是同一笔订单被处理了三遍。库存扣了三次订单状态从已支付被后到的请求刷新成支付中下游对账直接崩掉。复盘的时候大家翻日志发现每个服务都正常返回了200链路没有任何报错。问题恰恰出在这里每个环节都在按单次请求的语义正确执行但整条链路没有去重的语义。这起事故让我彻底改变了一个认知重复请求不是小概率事件而是分布式系统里的常态。用户手抖点两下提交按钮、网络超时后的自动重试、网关超时后的重新转发、消息队列在消费失败后的重投每一条路径都在制造同一个业务请求的多个副本。接口幂等性要防的正是这些副本对业务数据的重复写入。1.2 幂等的定义以及和并发、一致性的边界接口幂等性的标准定义是一次请求和重复发起多次相同的请求最终对系统资源产生的影响是一致的。读操作天然幂等删除操作大部分幂等需要重点防御的是写操作——下单、支付、扣款、发券、转账这类接口每执行一次都会改变系统状态重复执行就会产生错误。很多人把幂等和并发混在一起聊其实它们是两回事。并发说的是多个不同请求同时到达解决方案是加锁、排队、乐观锁幂等说的是同一个请求被重复执行多次解决方案是去重、状态约束、唯一键。两者经常同时出现但设计思路完全不同。用电梯按钮来类比可能更好理解你连按三下电梯按钮电梯只响应一次这是幂等如果同一时间有几个人在不同楼层按下按钮电梯需要调度逻辑决定先响应谁这是并发控制。一致性则是前面两者的最终落点。无论是幂等还是并发控制最后都是为了保证数据库里落的数据是对的。把这三个概念拆开你在设计接口的时候才会想清楚这个接口需要的是去重还是排队还是两者都要。提示在做技术方案评审时我习惯先问一句这个接口的重复请求路径有哪些。如果答不上来幂等设计大概率是漏的。凡是存在客户端重试、网关重试、MQ重投、定时任务补偿其中任意一条路径的写接口都必须纳入幂等改造范围。2. 判断边界不是所有接口都需要同一套幂等方案2.1 三类写接口天然幂等、条件幂等、强制幂等很多团队一上来就给所有接口加防重表结果把简单系统搞得很臃肿。我的经验是把写接口分成三类分别对待分类典型接口重复执行的后果处理策略天然幂等按ID查询、按ID删除无影响或结果一致不做特殊处理条件幂等状态更新、库存预占依赖当前状态可能产生非预期覆盖加状态条件或版本号强制幂等下单、支付、扣款、发券会产生重复数据或资金损失必须做防重设计天然幂等类的接口不用多说。条件幂等类是最容易被忽略的比如更新订单状态这种操作如果直接UPDATE orders SET status PAID WHERE order_id ?重复执行两次结果一样看似幂等但如果存在两个并发任务同时更新同一笔订单后执行的会覆盖先执行的状态这时候就需要状态约束或者版本号。强制幂等类接口每一笔都是真金白银防重设计不仅要考虑业务正确性还要考虑性能、可审计性、数据清理。这类接口我会做防重表唯一索引状态机三重防护后面第4章会展开讲。2.2 幂等判定因子怎么选幂等设计的第一步是选定用什么来标识同一笔请求。这个标识在业界一般叫幂等键Idempotency Key它的组合规则决定了防重的准确性。最常见的错误是用用户ID、设备ID、IP这类因子作为幂等键。用户ID显然不行——同一个用户前后下单十次每次都算同一个幂等键的话后面的九次都会被拦截业务直接废掉。设备ID更不行一台手机上可能同时有多个订单在创建。正确做法是提取业务自身的唯一语义。比如下单接口userId 购物车批次号或前端生成的orderToken支付回调支付渠道 支付流水号发券接口活动ID 用户ID 批次号通用兜底网关或客户端生成的requestId这里有一个容易忽略的细节幂等键的生成时机。如果幂等键是后端在第一次请求时生成的那客户端重试时怎么知道用哪个键所以强一致的做法是幂等键由客户端或网关在请求入口生成随请求头透传。后端只负责校验和落库。如果接口没有对外暴露直接在服务内部取业务唯一字段比如orderId作为幂等键也完全可以。幂等键的判定还有一个原则宁可多包一层不要少包一层。也就是说幂等键的组合范围应该覆盖所有可能影响业务结果的参数。比如支付回调用渠道流水号如果漏了渠道两个不同渠道的同号流水就会互相干扰。注意幂等键的设计决定了防重的粒度。粒度太粗会误伤正常请求粒度太细会漏掉重复请求。上线前建议把所有写接口的幂等键列一个清单逐个评审覆盖范围。3. 主流幂等实现方案的原理拆解与选型对比3.1 数据库唯一约束最朴素也最可靠的兜底数据库唯一约束是最早出现的幂等方案核心思路很直白建一张专门的防重表把幂等键作为唯一索引业务处理前先往防重表插入一条记录。能插入成功说明这是第一次请求继续执行业务插入失败唯一键冲突说明请求已经处理过直接返回已处理的结果。防重表的建表语句大致是这样CREATE TABLE idempotent_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型ORDER/PAY/COUPON等, biz_key VARCHAR(128) NOT NULL COMMENT 幂等键由业务决定, request_id VARCHAR(64) NOT NULL COMMENT 请求唯一标识, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-处理中 1-成功 2-失败, result TEXT NULL COMMENT 首次请求的返回结果用于重复请求时直接返回, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_type, biz_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT幂等防重表;注意唯一索引要建在biz_type biz_key上而不是request_id上。因为request_id每次重试可能重新生成比如网关重试时造了新的requestId但业务幂等键是同一个。用业务幂等键才能拦住重复。数据库方案的可靠性在于唯一索引是数据库内核级别的约束只要插入成功并发场景下也只有一条能成功。它不依赖网络、不依赖中间件即使Redis整个挂掉依然能兜底。缺点也明显——每次请求都多一次数据库写入对高并发接口来说防重表会变成性能瓶颈。所以我的定位是数据库唯一约束永远作为最后一层兜底前面再快的方案都可以挂这一层不能倒。3.2 Redis令牌与分布式锁高性能场景的主力Redis方案性能好适合对响应时间敏感的业务。常见做法有两种一种是先取令牌再干活另一种是分布式锁直接锁请求。先取令牌的模式流程是这样的客户端请求前先调一个接口获取一个token本质上是一个随机字符串Redis以这个token为key存一份设置过期时间。真正执行业务时客户端把token带在请求头里服务端用SET token 1 EX 300 NX这样的命令消费令牌。NX的意思是不存在才设置如果设置失败说明token已被消费或已过期直接拒绝执行。这套模式的好处是令牌的发放和消费解耦适合预下单支付确认这类流程。坏处是多了一次网络请求而且如果客户端拿到令牌后弃用了Redis里会残留一堆过期前的无效key。分布式锁的模式更常见直接用幂等键作为锁的key执行业务前先加锁处理完再释放。核心命令是SET order_pay_12345 1 EX 300 NX但这里有一个经典的大坑释放锁的时候不能简单用DEL否则可能把别人刚获取到的锁删掉。正确的释放方式是用Lua脚本先比较value再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本要配合一个随机value使用每个请求加锁时生成自己的随机值释放时只删除属于自己的锁。不然在高并发下线程A的锁过期了线程B加锁成功此时A的处理逻辑跑完了一句DEL把B的锁删掉C就进来了等于锁完全失效。Redis方案的性能优势很明显单次操作都在毫秒级。但它依赖Redis的可用性而且一旦锁过期时间设置不当或者确实发生Redis宕机业务还是会穿透。所以Redis方案一定要配合数据库防重表一起用Redis是第一道闸数据库是最后一道网。3.3 状态机流转让业务状态自己阻止重复处理第三种方案是从业务状态层面做文章。很多业务天然带有状态流转的语义比如订单从待支付到已支付、再到已发货。既然状态是单向流转的那更新的时候只要带上前置状态条件重复请求自然会被拒掉。典型的SQL是这样的UPDATE orders SET status PAID, paid_time NOW(), trade_no 20240812123456 WHERE order_id 20240812001 AND status UNPAID;这条语句执行后如果影响行数为1说明支付成功且这是首次处理如果影响行数为0说明订单状态已经不是UNPAID了重复请求或者非法请求直接返回订单状态不支持该操作。状态机方案的精髓在于把幂等约束内聚到业务状态本身不需要额外的防重表也基本不需要Redis。上游重复投递多少次只要状态已经往前走了后面的请求都会撞在状态条件上。但状态机方案有一个适用前提业务必须能收敛到清晰的状态流转路径。如果业务状态可以回退比如取消的订单还能重新激活那就不适合单独用状态机做幂等需要配合版本号或者防重表。版本号方案本质上和状态机类似给业务表加一个version字段更新时WHERE version ?更新成功后version加一。谁先抢到版本号谁就成功后到的请求因为版本号不匹配影响行数为0。这属于乐观锁适合更新类操作的幂等与并发控制。3.4 三种方案的选型对比与组合思路三种方案各有侧重直接给结论维度数据库唯一约束Redis令牌/分布式锁状态机/版本号可靠性最高数据库内核保证中依赖Redis可用性高依赖业务状态约束性能低多一次DB写入高毫秒级中一次条件UPDATE运维成本需建表、清理、归档需关注过期时间、内存需梳理状态流转适用场景所有写接口的兜底支付/订单/扣款高并发抢购、秒杀、下单预热订单状态更新、任务状态流转真实项目里我不会只用其中一种。以我的标准模板来说对外部流量入口网关/Controller用Redis令牌做第一道过滤业务内部用状态机保证状态流转正确最后无论哪条路径穿透进来防重表的唯一索引都是最终的硬兜底。三层配合单层失败还有下一层接住。4. 全链路实战从入口到存储的幂等闭环落地4.1 入口层统一requestId的生成与透传全链路幂等设计的第一步是让每一个写请求从进入系统那一刻起就有一个全局唯一的身份标识。这个标识我习惯叫requestId在网关层统一生成也可以由客户端生成后放入请求头。生成规则上推荐使用雪花算法Snowflake或带业务前缀的UUID。雪花算法生成的是有序64位整数适合做数据库索引UUID完全随机适合分布式环境下不需要排序的场景。我个人的偏好是对外的请求用UUID因为生成简单、碰撞概率忽略不计对内的异步任务用雪花ID方便在日志里按时间范围排查。网关层要做的事情是读取请求头里的X-Request-Id如果没有就自动生成一个然后透传给下游所有服务同时把requestId写入统一日志上下文。这样做最大的好处是链路追踪——一个请求从网关进来无论经过多少服务、多少异步分支日志里都可以通过同一个requestId串起来。全链路幂等不能只在业务服务里做。网关本身也是一个重试节点很多网关框架在上游超时时会自动重试。所以网关层最好记录一下已经转发过的requestId集合,至少在短时间内能识别出相同的请求避免转发层自己造成重复。我这里用Nginx的lua脚本简单演示一下网关生成并透传requestId的逻辑-- lua脚本在access_by_lua阶段执行 if ngx.var.http_x_request_id nil or ngx.var.http_x_request_id then ngx.req.set_header(X-Request-Id, tostring(ngx.now() * 1000) .. - .. ngx.var.connection) end注意网关层的requestId透传需要所有下游服务都遵守优先取Header中的requestId取不到才自己生成的约定。否则一个请求到了业务服务又生成一个本地新ID日志就对不上了。4.2 业务层幂等注解与切面的落地实现在业务层做幂等最优雅的方式是注解切面把幂等逻辑从业务代码里抽离出来。我实际用的是一套自定义注解核心定义大致是这样Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface Idempotent { // 幂等键的SpEL表达式例如 #orderId String bizKey(); // 业务类型例如 order_pay String bizType(); // 防重窗口时间默认30分钟 long expireSeconds() default 1800; }配合一个AOP切面在方法执行前做防重校验。切面的核心流程我拆成五个步骤解析注解里的SpEL表达式从请求参数、上下文或Header中提取幂等键用bizType bizKey组装防重键先查Redis判断这个键是否已经处理完成防止并发请求重复进入事务查防重表尝试插入记录插入成功则继续执行插入冲突则说明已处理过直接返回上一次的结果或特定错误码方法执行成功后再更新防重表状态为成功并缓存结果。关键代码逻辑去掉细节保留骨架Aspect Component public class IdempotentAspect { Around(annotation(idempotent)) public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { String bizKey parseSpEL(idempotent.bizKey(), joinPoint); String lockKey idempotent.bizType() : bizKey; // 1. Redis分布式锁防止并发请求同时进入 String requestId UUID.randomUUID().toString(); if (!redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, idempotent.expireSeconds(), TimeUnit.SECONDS)) { // 已有请求在处理直接返回“重复提交” throw new BizException(REPEAT_REQUEST, 请求处理中请勿重复提交); } try { // 2. 防重表插入数据库唯一索引兜底 IdempotentRecord record new IdempotentRecord(); record.setBizType(idempotent.bizType()); record.setBizKey(bizKey); record.setRequestId(requestId); try { idempotentRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException(REPEAT_REQUEST, 该请求已处理过); } // 3. 执行业务方法 Object result joinPoint.proceed(); // 4. 更新防重记录状态为成功 idempotentRecordMapper.markSuccess(record.getId(), JSON.toJSONString(result)); return result; } finally { // 5. 释放Redis锁只删自己持有的锁 String luaScript if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(luaScript, Long.class), Collections.singletonList(lockKey), requestId); } } }这里有一个细节值得注意Redis锁和防重表为什么要同时上因为Redis锁过期时间再长也有失效的可能而防重表插入一旦成功唯一索引就是铁律。反过来如果只靠防重表并发请求同时插入时虽然最终只有一个成功但失败的请求拿不到已处理完成的结果只能抛异常。所以Redis锁负责给处理中的并发请求一个快速响应防重表负责兜底和记录结果两者各司其职。触发重复请求时的返回设计也很重要。我一般不在业务异常里处理而是统一返回一个特定响应码例如410 REPEAT_REQUEST配合首次请求的结果数据如果在防重表里缓存了结果的话。这样客户端就能明确知道这次请求没白来之前已经成功了而不是看到500误以为失败了再重试造成请求风暴。4.3 存储层防重表与业务表的事务一致性防重表兜底方案里最容易踩坑的是防重记录和业务更新不在同一个事务里。很多同学是先更新业务表再插入防重表。一旦业务更新成功后、防重记录写入前服务宕机或者接口异常这条业务数据就没有防重记录。重试请求过来防重表里没有记录业务又会执行一遍。正确的顺序是先插入防重记录再执行业务更新把两个操作放在同一个本地事务里。事务的边界大致这样Transactional(rollbackFor Exception.class) public PayResult processPay(String orderId, String tradeNo) { // 1. 先查防重表加了唯一索引插入前查一次能减少无效的插入冲突 IdempotentRecord exist idempotentRecordMapper.selectByBizKey(PAY, orderId); if (exist ! null) { return JSON.parseObject(exist.getResult(), PayResult.class); } // 2. 插入防重记录 idempotentRecordMapper.insert(record); // 3. 执行真正的业务更新 int rows orderMapper.payOrder(orderId, tradeNo); if (rows 0) { throw new BizException(ORDER_STATUS_ERROR, 订单状态不允许支付); } // 4. 更新防重记录的结果 idempotentRecordMapper.markSuccess(record.getId(), resultJson); return result; }注意第2步的插入和第3步的业务更新在同一个事务里。如果第3步失败事务回滚防重记录也一起回滚下一次请求还能重试。如果第3步成功但第4步失败事务也会回滚业务和防重记录一起消失——这一点很多人会忽略恰恰是它保证了不会出现业务成功了防重记录丢了的中间状态。防重表的数据量也是个问题。每一笔写请求都多一条记录几年下来可能几亿行。我的实践是按天或按月对防重表做分区然后定时归档超过30天具体窗口看业务重试周期的旧数据到一个冷表或直接清理。清理时机选在业务低峰期用分批DELETE的方式避免一次删太多锁住大量行。4.4 消息消费端MQ重投场景的幂等兜底很多幂等问题不是发生在HTTP接口而是发生在消息队列的消费端。MQ为了保证消息不丢失都会配置失败重试和重投机制。Kafka的at-least-once语义、RocketMQ的消费重试、RabbitMQ的manual ack requeue本质上都允许同一条消息被消费多次。如果消费逻辑不是幂等的重复消费就会污染数据。消费端幂等设计的核心是找到消息体里的业务幂等键用和HTTP接口类似的机制做去重。比如支付成功消息里一定有orderId和tradeNo下单消息里一定有orderToken。我实际用的方案是一个Redis防重 数据库唯一键的二级检查收到消息后先从Redis里SET msg_{bizKey} 1 EX 3600 NX如果设置失败直接ACK丢弃这条重复消息如果Redis设置成功执行真正的消费逻辑消费逻辑里所有写库操作都带上业务唯一键。比如防重表插入和HTTP接口共用同一张表或者业务表本身的唯一索引。这里用Redis做第一道检查好处是能拦截大部分重复消息减轻数据库压力。但要注意如果消费逻辑执行时间超过Redis key的过期时间下一个重试消息就会穿透Redis这时候必须靠数据库唯一索引兜底。所以消费端方案我推荐Redis 防重表双保险而不是只用Redis。还有一个点是消息消费的窗口期。业务上从订单创建到支付成功再到对账完成可能会有很长的链路重复消息可能隔几个小时才重投。所以防重表的清理窗口一定要覆盖整个业务链路的最长重试周期而不是固定按一天来算。我见过因为清理太早导致隔天重投的消息穿透防重表把订单状态改错的真实事故。5. 线上踩坑复盘三起真实问题的完整排查链路5.1 唯一索引失效重复订单怎么混进库的场景防重表明明建了uk_biz(biz_type, biz_key)联合唯一索引线上还是出现了两笔相同幂等键的订单。排查时我先查了防重表发现同一笔orderId有两条防重记录时间戳间隔只有几十毫秒。这说明两个并发请求都成功插入了防重表唯一索引没有拦住。再继续查下去问题出在代码提交顺序上当时有一个同事把防重记录插入写在了事务外面而且是先更新业务表、后插入防重表。两个并发请求同时通过了业务更新再分别去插入防重表此时谁也拦不住谁因为业务更新已经完成、防重插入已经是在事后补救了。这个坑的根因就是典型的事务边界和唯一索引的配合失效。唯一索引再强如果插入防重表和业务更新不在同一个事务里业务先执行、防重后记录就存在一个已经改了业务但还没记录防重的窗口任何重试和并发都能穿透。修复方式就是把防重插入和业务更新收进同一个Transactional方法里并且严格按先防重后业务的顺序执行。对于已经产生的脏数据写补偿脚本按业务键去重保留最早一条其余修正或删除。排查这类问题的思路值得记一下先看防重表里有没有记录再对比防重记录和业务数据的写入时间差最后看服务日志里有没有重启或异常中断的节点。防重表没记录或者时间差异常基本可以断定事务边界有问题。5.2 Redis锁提前过期业务没做完锁先没了场景一个支付确认接口用Redis锁做幂等锁的过期时间设为5秒。某个时段上游外部接口响应特别慢业务处理花了8秒。第一个请求的锁在第5秒过期第二个重试请求成功拿到锁也进入业务逻辑两个请求同时扣了用户余额。排查时我先看时间线第一笔和第二笔请求的到达时间差大概6秒正好卡在锁过期之后。再看代码锁的过期时间写死成了5秒没有结合业务耗时分析。这里有两个修复点。第一锁的过期时间要大于业务处理耗时的峰值我的经验是取P99耗时 x 2 缓冲时间至少20秒以上但也不能太长否则Redis里堆积大量key影响性能。第二更稳妥的做法是用带自动续期的锁方案比如Redisson的看门狗机制锁快到期时如果业务还在执行就自动续期避免锁提前失效。同样重要的是Redis锁失效后防重表要能接住。也就是说即使锁过期了第二个请求进入业务逻辑后它在防重表插入时撞上唯一索引一样得停下。锁负责拦住大部分并发防重表负责拦住漏网之鱼两层齐上才不会出资金事故。5.3 异步重试把已支付订单状态打回去了场景一笔订单用户已经支付成功状态正常更新为PAID。但几个小时之后一个支付超时自动关闭订单的异步重试任务迟到了直接执行了UPDATE orders SET status CLOSED WHERE order_id ?把已经支付的订单改成了关闭状态。用户支付单还在订单却显示已关闭对账立刻乱了。这个问题的根因不是缺少防重表而是状态更新没有带状态条件。异步任务只认orderId不关心订单当前处于什么状态结果就是迟到的任务覆盖了最新状态。修复方案就是前面讲过的状态机所有状态更新SQL必须携带前置状态条件。比如关闭订单必须写成UPDATE orders SET status CLOSED WHERE order_id ? AND status UNPAID影响行数为0就说明状态已经流转过了当前请求应该被丢弃或者走补偿流程。这类问题在排查时的特点是防重表和锁都正常但数据依然错了。所以一旦看到锁和防重都正常但状态被覆盖的现场优先怀疑所有异步任务和定时任务有没有做状态条件判断。幂等不只是挡住重复请求还要挡住过期的、乱序的请求对系统状态的覆盖。5.4 判断线上问题是幂等还是并发的排查方法线上出现重复数据时很多同学第一反应是加锁但有时候加锁反而掩盖了真正的问题。我自己总结了一套快速判断方法供参考看日志里的requestId同一个requestId重复出现多次说明是同一个请求的多次副本属于幂等缺失。多个不同的requestId同时操作同一份数据属于并发控制问题。看业务主键的时间戳如果两条重复数据的写入时间间隔很短毫秒级基本是并发穿透如果间隔几秒甚至几分钟大概率是重试或重投导致中间隔着网络超时或MQ重试周期。看防重表数据防重表里没有记录说明幂等链路压根没生效要么没接入要么事务边界有问题防重表有记录但业务表重复说明幂等键选错了或者状态更新逻辑有问题。复现手法抓一条线上真实请求的入参手工重放三次观察业务结果。如果三次都执行成功那就是幂等失效如果只有第一次成功、后面报重复说明幂等链路本身是通的问题出在并发时序上。这几招下来大部分线上重复数据问题都能在半小时内定位到具体环节。排查的时候保持一个心态不要急着加锁先搞清楚重复从哪条链路来的。最后再分享一个我现在坚持的习惯每个写接口上线前必须有重复请求演练这一项验收。拿压测工具或者脚本对同一笔订单并发打三到五次请求观察业务是否只生效一次。这个动作成本很低但能在上线前就把幂等链路的所有漏洞暴露出来。等到线上出了资金事故再补代价就完全不一样了。