
高并发下如何保证接口幂等性实战方案全解析先抛一个场景你在电商系统里提交订单前端按钮被用户手滑连点了两下或者订单服务在返回响应时网络抖动了一下客户端自动重试了一次结果生成了两笔订单、扣了两次库存。再严重一点支付回调因为消息中间件重试机制被投递了三次账户余额被扣减了三遍。这不是代码逻辑写错了而是接口天然不具备幂等性——同一个操作执行一次和执行N次系统状态发生了不同的变化。接口幂等性Idempotency解决的核心问题就是这一句同一个请求无论到达多少次最终落库的业务结果必须和第一次执行时完全一致。它和并发安全不是一回事并发安全关心的是多个不同请求同时访问同一份数据时的正确性幂等性关心的是同一个请求重复出现时的结果一致性。但在高并发场景下这两个问题常常纠缠在一起出现因为一旦你引入分布式锁、消息队列、异步重试这些常规手段重复请求的到达路径就变得异常复杂重试次数、超时时间、补偿任务都会成为幂等性的攻击面。这篇文章围绕高并发场景下接口幂等性的落地展开把几种主流的实现方案拆开讲清楚包括数据库唯一索引、token机制、分布式锁与状态机结合等。不会只讲理论每部分都会给出可以直接抄走的建表语句、核心代码逻辑和压测参数也会把我自己在实际项目里踩过的坑一并写出来。无论你是后端开发、架构设计人员还是在做支付、电商、开放平台这类对幂等性有硬性要求的业务这篇都有参考价值。1. 先搞明白幂等性到底防的是哪类问题1.1 从两次“成功”的支付说起支付系统是幂等性需求最强烈的场景没有之一。假设用户在收银台点击“确认支付”前端调用后端下单支付接口后端将支付单状态从“待支付”更新为“支付中”然后返回给前端一个支付跳转链接。这时如果后端在返回响应时出现超时前端不知道请求是否成功用户又点了一次“确认支付”第二个请求同样走到了“待支付”到“支付中”的状态更新逻辑。此时两个请求都认为自己拿到了最新的支付单状态都试图去更新数据库最后可能出现支付流水表里插入了两条记录或者两次扣款请求被打到三方支付渠道。更隐蔽的一种情况后端支付回调接口收到三方支付平台的异步通知处理完业务后正常返回“SUCCESS”但这个返回响应在网络上滞留了三方平台没收到确认按策略再次推送同一笔回调。如果回调接口没有幂等防护入库的流水表又没有唯一约束这笔支付流水就会被插入第二次关联的订单状态、库存扣减、积分发放都会被重复触发。幂等性需要防住的本质上是这些“同一业务动作的非预期重复执行”。它不要求你在代码里判断“这个请求是不是上次那个”而是要求在数据层面或者流程层面形成一种机制第一个请求执行成功了后面的重复请求要么被识别并直接返回第一次的结果要么在数据层被“挡”住不会产生第二次业务动作。1.2 幂等与并发安全不是一回事但都会同时出现很多人容易把幂等性跟并发安全混在一起。举个例子用户A发起请求给账户加余额用户B同时发起请求扣减同一个账户的余额。这种场景下你需要处理的是并发安全也就是用锁、版本号、CAS等手段保证两个不同的请求对同一数据的操作不会互相覆盖。而幂等性场景里请求内容是“相同”的同一笔订单的支付、同一条消息的消费、同一个用户对同一条数据的提交。它要防的是重复不是冲突。但高并发下这两个问题往往在同一段代码里被同时触发。因为请求重复到达时往往伴随着时序上的不确定——两个重复请求可能几乎同时到达第一个还没提交事务第二个已经开始读取数据了。你会发现单纯防重还不够还得保证并发执行的两个相同请求不会同时通过校验。这也就引出了后面要讲的几种方案它们在设计上都会同时考虑幂等和并发两层问题。1.3 影响幂等性的常见位置清单从我接触过的项目来看容易出现幂等性问题的位置高度集中在几类场景里你在做设计评审的时候可以照着清单过一遍前端重复提交用户连续点击提交按钮移动端弱网下的自动重试。服务间重试RPC框架自带的超时重试机制比如Dubbo、Feign、Spring Retry。消息队列重投Kafka、RocketMQ在消费失败或ack超时时会自动重投消息。定时任务补偿分布式调度框架下同一个任务被多个节点执行或者任务失败后扫表补偿补偿逻辑本身重复执行。三方平台回调支付、短信、风控等外部系统的异步通知几乎都有失败重发机制。这些位置有一个共同特点请求来源不可控到达次数不可预知。所以幂等方案必须放在业务入口的最前端或者数据写入的最底层形成一道“不管你重复多少次最终结果都一样”的屏障。2. 方案一数据库唯一索引——最简单直接的“硬约束”2.1 原理与适用场景数据库唯一索引是幂等性方案里成本最低、理解成本也最低的一种。它的核心思路在业务表中建立一个与“业务重复标识”绑定的唯一索引利用数据库本身的唯一约束来拒绝重复插入。比如支付流水表每次支付回调处理时先按业务订单号插入一条流水记录。如果订单号在表里已经存在唯一索引直接报错业务代码捕获这个冲突就知道这个回调已经处理过了直接返回成功不再执行后续的订单状态更新、库存扣减等操作。建表示例CREATE TABLE pay_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_order_no VARCHAR(64) NOT NULL COMMENT 业务订单号, pay_trade_no VARCHAR(64) NOT NULL COMMENT 三方支付流水号, pay_status TINYINT NOT NULL COMMENT 支付状态, amount DECIMAL(12,2) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_order_no (biz_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里的关键是uk_biz_order_no这个唯一索引。当第一个请求插入成功后第二个相同订单号的请求再插入时数据库会因为唯一键冲突抛异常业务逻辑捕获异常后return整个过程没有并发问题因为数据库的约束本身就是原子的。Java侧的伪代码如下public void handlePayCallback(PayCallbackRequest request) { try { PayFlow flow new PayFlow(); flow.setBizOrderNo(request.getOrderNo()); flow.setPayTradeNo(request.getTradeNo()); flow.setPayStatus(1); flow.setAmount(request.getAmount()); payFlowMapper.insert(flow); // 插入成功说明是第一次处理 doBusinessProcess(request); } catch (DuplicateKeyException e) { // 捕获唯一键冲突说明是重复请求直接返回成功 log.info(duplicate callback, orderNo{}, request.getOrderNo()); } }注意一点这里的“业务重复标识”要选准。像支付回调订单号是天然的业务标识像消息消费消息ID就是业务标识像接口防重可能需要根据请求参数动态生成一个MD5作为标识。标识选得不对会导致两个不同业务被误判为相同或者同一个业务被拆成多条都会出问题。2.2 落地时不可忽略的细节第一个细节唯一索引的粒度必须和业务幂等粒度保持一致。如果你的幂等范围是“同一个用户在同一个活动下只能领取一次优惠券”那么唯一索引应该建在(user_id, activity_id)上而不是单独建user_id否则用户参与不同活动的请求会被误拦截。反过来如果粒度太细比如把请求参数的全部字段都hash进去那么同一订单前后两次回调因为某个可选字段值的细微差异而导致hash不一致唯一索引就拦不住了。所以字段选取要保守选那些稳定不变、全网唯一的业务编号。第二个细节插入和业务处理要放在同一个本地事务里。如果先insert成功提交事务然后再执行业务处理中间出了异常这个事务回滚了但后续重试的请求会继续尝试插入依然能保证只有一次成功。但如果你把insert放在事务外面先插入成功业务处理失败事务回滚后这条流水记录还在后续的重复请求永远会被幂等拦截——业务却根本没执行成功。这就属于“假幂等”数据说这事成功了实际业务是失败的。所以在写代码时把insert和业务处理放进同一个Transactional方法里要么一起成功要么一起回滚。第三个细节DuplicateKeyException的捕获范围要精确。很多框架抛出来的异常是嵌套的比如Spring的DataIntegrityViolationExceptionMyBatis的PersistenceException里面可能包着MySQL的DuplicateEntryException。直接catch一个具体的异常类型可能会漏掉包装层抛出的其他类型。我的建议是catch一个宽泛的数据访问异常然后用根异常的原因链去判断是不是唯一键冲突或者干脆用“先查再插”的方式兜底虽然性能稍差但逻辑上不容易出错。2.3 这个方案的短板在哪唯一索引方案最大的局限性它只能防“新增”操作防不了“更新”操作。订单状态从“待支付”改到“支付成功”这是一次update如果重复回调到来第二次update即使执行了也不会报错它会把状态再改一遍结果虽然一样但相关连带动作比如发送短信通知、给账户加积分可能会被重复触发。为了解决这个连带动作重复触发的问题业界常用组合拳用一张唯一键表做“执行凭证”先插入凭证插入成功才执行后续所有动作。这张凭证表可以看作一个分布式锁同时具备了“挡重复”的能力比单独对业务表加唯一索引更灵活因为凭证表和业务表的更新相互独立后续的业务逻辑无论怎么演进都不会拆掉这层幂等屏障。这个思路和接下来的token机制本质上是一样的只是实现载体不同。3. 方案二token机制——分布式场景下的“通行证”3.1 请求前发令牌、请求时验令牌存Redis还是存库token机制的应用场景最常见的就是前端提交类接口创建订单、发布内容、提交审核表单等。它的交互流程是客户端在提交前先向后端申请一个token后端生成一个唯一令牌并存储起来返回给客户端客户端提交表单时把这个token放在请求头或者请求体里后端收到提交请求后先校验token是否存在且有效校验通过就立即删除token然后执行业务逻辑。删除token这个动作是整个机制里最关键的环节——保证了token只能被成功消费一次后续的重复请求因为token不存在直接校验失败。存储载体通常有两种选择Redis和数据库。Redis方案// 生成token String token UUID.randomUUID().toString().replace(-, ); stringRedisTemplate.opsForValue().set(bizKey token, 1, 10, TimeUnit.MINUTES); return token; // 校验并删除token String key bizKey token; Boolean flag stringRedisTemplate.execute(new DefaultRedisScriptLong() { // Lua脚本原子操作判断存在则删除返回1不存在则返回0 }, Arrays.asList(key), 1); if (flag ! null flag.longValue() 1L) { // 校验通过继续执行业务 }这个Lua脚本很关键因为“判断token存在”和“删除token”必须是原子操作。如果你先get再delete两个请求同时到达时可能都get到了同一个token然后都通过了校验这个token就被消费了两次。用Lua脚本把“存在则删除”这个逻辑原子化才能保证只有一个请求能成功获取到token。数据库方案建一张token表主键或者唯一索引就是token本身。消费时执行一条删除语句根据返回的影响行数判断是否删除成功。DELETE FROM idempotent_token WHERE token #{token};如果影响行数是1说明这个token是第一次被消费如果是0说明token不存在或被消费过了。这条delete语句本身就是原子的不需要额外的锁性能也够快。两种载体怎么选如果Redis基础设施稳定毫无疑问首选Redis性能高还能给token设置过期时间省得清理历史token数据。如果没有Redis或者业务方希望所有幂等凭证都可追溯、可查询、可对账可以用数据库表但要注意定期清理过期token数据防止表无限制膨胀。3.2 完整时序与注意事项token机制的完整时序我画一个文字版的流程方便你对照落地客户端调用POST /api/idempotent/token获取token。服务端生成token保存到Redis带过期时间返回给客户端。客户端携带token调用真正的业务接口比如POST /api/order/create。服务端先执行token校验逻辑Lua脚本判断存在则删除。token校验通过执行业务逻辑返回成功。客户端重复提交同一请求比如双击按钮、重试此时token已被删除校验失败返回重复提交提示。这个流程里有两个容易被忽略的细节。第一个token的获取和消费不要耦合进同一个接口。如果“获取token”和“业务提交”在一个事务里提交失败时token的回滚状态会变得很难管理。分开设计token独立获取业务侧独立消费职责清晰排查问题时也方便直接看token的存续状态。第二个token校验失败时的响应要明确。不能简单返回一个500或者其他通用错误码要把“重复提交”这个语义传达给前端。前端拿到这个错误码时可以提示用户“请勿重复提交”或者直接禁用提交按钮。如果返回的是一堆堆栈信息或者一个不明确的系统错误用户体验会很差而且前端也无法做针对性的交互处理。还有一个在实践中很重要的点token机制的幂等范围是“一次完整业务流程”。客户端要发起下单、回填收货地址、确认支付等多个连续操作时每个操作都单独申请token不要复用同一个token。否则一个token被第一个操作消费后第二个操作就无法获取到有效的幂等凭证。3.3 token方案的边界要清楚token机制不是万能的。它解决的问题是“同一个客户端、同一次业务提交的重复点击”。对于“服务端逻辑重试”和“消息重复投递”token机制并不能完全覆盖因为这两类场景下客户端并不会重新从token接口获取新的token而是服务端或中间件系统自动把同一个请求重新投递一次。如果业务请求已经到了服务端内部你再回头去查token发现token还在因为第一次请求已经消费过了这个逻辑就走不通了。所以我的判断标准是面向外部直接交互的接口比如Web、App、H5的提交接口用token机制很合适既保护了用户重复点击又能提供明确的交互反馈面向系统间调用、消息消费、回调通知这类场景更适合用数据库唯一索引、状态机或者分布式锁来保证幂等。在实际项目中很多团队跑不赢线上故障的原因就是把token机制用在了消息消费的监听器上。消息重投时第一次投递消费完了token第二次投递再来时token就没了消费被直接丢弃业务数据压根没落库等到对账时才发现少了一大批数据。这个坑我踩过后来统一改成“消费前先查状态用状态机推进消息携带的偏移量做唯一约束”才彻底解决。4. 方案三分布式锁与状态机——保底与防重并行4.1 分布式锁怎么配合幂等使用分布式锁本身并不是幂等方案它解决的是互斥问题。但在高并发场景下分布式锁往往是幂等方案能够正确运行的底层保障。回到前面2.1节的场景两个重复的支付回调同时到达如果只用唯一索引数据库会自动拒绝第二个插入这边没问题。但如果业务逻辑复杂需要“先读订单状态再判断是否处理过再更新状态”那你就不得不用锁来保证“读-判-写”这个动作的原子性。典型实现是Redis分布式锁String lockKey lock:order: orderNo; String requestId UUID.randomUUID().toString(); boolean locked false; try { // 加锁设置过期时间防止死锁 locked stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (!locked) { // 获取锁失败说明另一个请求正在处理直接返回 return handling; } // 执行幂等校验和业务处理 Order order orderMapper.selectByOrderNo(orderNo); if (order.getStatus() PAY_SUCCESS) { // 已经处理过直接返回 return success; } doProcess(order); } finally { if (locked) { // 释放锁时校验是同一个请求持有的锁 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; stringRedisTemplate.execute(new DefaultRedisScriptLong(script, Long.class), Arrays.asList(lockKey), requestId); } }这里有两处细节值得展开。一是加锁时必须设置过期时间。如果不设置或者设置太短一旦业务执行时间超过锁的持有时间锁自动过期释放另一个重复请求就能拿到锁两个请求就会同时进入业务逻辑。业务执行慢是常态比如调用三方支付接口超时、查库慢、缓存没命中导致走全表扫描这个时间是不可控的。过期时间建议根据业务P99耗时来设置一般是预估正常处理时长的5~10倍同时加上一个看门狗机制定期续期避免大事务执行到一半锁就过期了。如果团队里没有统一封装Redisson这样的框架建议把过期时间设长一点宁可锁多占一会儿也不要因为锁提前失效而出事。二是释放锁时必须校验持有者。上面的finally块里用requestId做了一次校验只有持有锁的线程才能释放锁。如果不校验就简单调用del命令删除锁可能出现一种极端情况请求A的锁过期了请求B拿到了同一把锁此时请求A业务执行完直接del锁把请求B线程持有的锁删掉了请求C趁虚而入三个请求同时进入临界区。用requestId标识持有者能避免这种误删。4.2 状态机为什么能天然防重状态机在幂等场景里的价值经常被低估。订单有“待支付”、“支付中”、“支付成功”、“已发货”、“已完成”等状态每一次业务操作都会推动状态流转。当你为状态流转规定好“必须从状态A流转到状态B”那么重复请求到达时如果当前状态已经不是A这次流转就是非法的直接拒绝。做一个最简单的示例支付回调要把订单从“待支付”改为“支付成功”。SQL可以写成UPDATE orders SET status 支付成功 WHERE order_id #{orderId} AND status 待支付;这行SQL只有影响行数为1时才说明状态流转成功。如果返回0说明订单状态已经不是“待支付”了可能是已经处理过也可能是被别的分支流程改过此时直接忽略即可。这种做法既不需要锁也不需要额外查一次订单状态一条update语句就完成了“判断更新”两个动作在数据库层面天然保证了幂等。这就是状态机方案的优势简单、可靠、无锁。但它有一个前提——业务本身的状态流转必须是单向前进的不能回退。如果一个状态可以被反复修改比如用户的备注、资料的昵称这类操作就不能用状态机方案因为它的状态是“可覆盖”的直接update就行幂等性天然成立同一份数据改成同一个值执行多少次结果都一样。状态机方案的注意点是不能用“覆盖式”写法。很多程序员写更新逻辑喜欢先查出一份完整对象把所有字段都set上去再执行一条update这种写法会破坏状态机的前置条件约束。正确写法是把你的状态字段单独拿出来作为where条件进行前置判断。其他字段的更新必须发生在状态字段更新成功之后保证业务流程的一致性。4.3 锁工具选型的一些建议如果你的系统已经引入了分布式锁框架建议优先使用现成的能力而不是自己手写Redis加锁脚本。Redisson就是一个很常用的库它自带的RLock支持看门狗自动续期加锁、释放的语义都做得比较成熟直接通过Lockable注解或者RLock API就能接入比自己维护Lua脚本省心得多。ZooKeeper的分布式锁虽然性能不如Redis但可靠性更好适合对一致性要求极高的场景。不过要泼一盆冷水分布式锁不是用来扛住所有并发请求的它只是保护临界区的“安全门”。如果你判断系统最大QPS在1万但临界区里每次要执行100ms那么用锁以后系统只能扛住约10 QPS的有效处理能力其余请求全部阻塞或者排队。这不是你扩大锁池、增加Redis节点就能解决的——本质上是临界区的处理能力被锁串行化了。所以设计上要尽量减少锁内部的耗时把耗时的外部调用、远程IO尽量移出锁范围锁内部只做状态判断和快速写入。5. 方案对比与选型没有银弹只有取舍5.1 不同场景下的推荐搭配做了这么多方案拆解最后落到选型上。我根据自己的项目经验把常见场景和推荐方案整理成了下面这张对照表可以当作一个起步参照场景特征首选方案辅助方案说明前端表单提交、防重复点击token机制Redis数据库唯一索引对外交互最友好能反馈明确语义支付回调、异步通知数据库唯一索引业务流水号状态机配合数据层挡重最可靠消息队列消费消费记录表唯一索引分布式锁消息ID作为幂等键订单状态流转状态机update where status分布式锁天然防重性能最好上下游系统间调用请求幂等键header数据库唯一索引调用方生成幂等键服务端做校验对账补偿任务分布式锁 唯一索引状态机防止多个节点重复扫表这条表格里的核心思想只有一个把“防重屏障”放到离数据最近的那一层同时兼顾对外交互的语义清晰度。越是外部直接交互的场景越需要显式的token机制来传递“重复提交”语义越是内部消息、回调的场景越依赖数据库层面的硬约束来做保底。5.2 幂等判断标识怎么生成很多幂等方案失效不是因为方案本身有问题而是幂等标识选错了。我在实际项目里见过把用户ID当作幂等键的导致用户创建完第一笔订单后第二笔订单怎么也创建不进去也见过把订单时间戳当作幂等键的同一个订单两次重复回调因为时间戳精度不同生成不同键重复请求照样穿透。一个合格的幂等标识要满足几个条件全局唯一、业务稳定、不随重试变化。常见的选择有订单号、流水号、交易号这类业务系统自己生成的全局ID。消息ID消息队列自带的消息唯一标识用于消费场景。外部系统提供的通知ID比如支付平台回调里的event_id、transaction_id。由调用方在请求头中主动传入的幂等键比如Idempotency-Key通常是一个UUID。最不推荐的做法是把整个请求体序列化后做hash或者把用户ID、业务类型、时间戳简单拼接。前者性能差且不稳定字段顺序微调就会生成完全不同的hash后者极易出现相同用户下的不同业务互相“串”幂等键。5.3 被忽略的响应设计幂等方案并不只是后端的事。在服务端识别到重复请求时返回的响应体至少应该包含一个明确的错误码比如DUPLICATE_REQUEST或者REPEATED_SUBMIT让调用方能够针对性处理。对于前端来说收到这个错误码应该直接提示“您已提交成功请勿重复操作”而不是展示一个让人摸不着头脑的“系统繁忙”。对于后端系统间调用重复请求的响应应该跟首次请求的响应保持一致甚至可以缓存首次响应的结果在幂等校验通过后直接返回缓存避免上游拿到不同的返回结构而解析失败。这一点在开放平台接口设计中尤其重要。第三方开发者接入你的接口时如果重复请求拿到一个含糊的错误他们往往无法区分是“请求参数错了”还是“重复提交”只能继续重试反而加重了你的系统压力。你可以在幂等校验不通过时返回一个标准的幂等错误Code并附带第一次请求的处理时间方便调用方对账。6. 高并发实测中的常见问题与排查实录6.1 网络超时导致的重试风暴我在一个电商项目里遇到过一种现象某次大促活动中下单接口的P99耗时突然从200ms飙升到3秒以上紧接着监控面板上看到下单接口的重试次数暴增数据库的订单表里出现大量重复订单。排查下来根因是网络抖动导致部分请求超时RPC框架触发了自动重试而这些重试请求没有幂等保护全部穿到了数据库层。当时订单表里已经建了订单号唯一索引按说重试请求应该被唯一索引挡住的。但问题就出在“先查询再插入”的代码逻辑上订单服务收到重试请求后先根据订单号去查订单表发现没有数据因为第一次请求还在网络传输的队列里没有落库于是走插入逻辑而插入时又因为没有捕获唯一键冲突异常整个事务回滚订单表还是空的。更混乱的是第一次请求和重试请求几乎同时到达都查不到数据都尝试插入最终两个请求都因为唯一键冲突回滚了订单彻底没创建成功。这个事故暴露了一个核心问题幂等保护不仅要能拦住重复请求还要能在重复请求和首次请求“并发到达”时保证有一个请求能成功执行。只靠“查一下再插”是不够的因为“先查后插”本身就有时间窗口。后来我们把下单逻辑改成“幂等键先插入业务数据后更新”的模式先根据订单号往幂等表里插入一条记录插入成功的那个请求才被允许继续干活插入失败的说明已经有其他请求在途直接返回重试提示或者等待几毫秒后查一次最终结果再返回。6.2 唯一键冲突被当成系统异常还有一个高频问题就是很多团队把唯一键冲突当成了未知数据库异常直接抛给全局异常处理器返回一个500或者一个“系统繁忙”的错误。这在压测时尤其容易出现因为压测工具不会去关注响应体的业务码只看HTTP状态码所以一旦唯一键冲突被当成500压测报告里就会出现大量报错很多人误以为系统有bug或者性能瓶颈排查半天发现是幂等冲突。这种问题本质上是“业务可预期异常”和“系统未知异常”没有区分开。唯一键冲突在幂等闭环里其实是一条预期内的分支应当在业务代码里捕获并转化为明确的业务响应而不是让它冒泡到全局异常层。我的处理习惯是这样所有的重复请求都要在接口文档里明确标注“400 DUPLICATE_REQUEST”语义和后端的业务异常、系统异常分开。这样监控系统看到的错误码分布才真正反映业务状态排障时一眼就能识别出哪些是幂等拦截、哪些是真实系统异常。6.3 分布式锁过期带来的重复执行我再分享一个很容易被忽略的场景。订单超时关单的定时任务用的是Redisson的分布式锁锁的过期时间设定为30秒。正常来说关单逻辑执行很快几十毫秒就结束了锁完全够用。但有一次数据库主库发生了慢查询关单逻辑单次执行耗时超过了40秒锁自动过期释放。这时另一个节点被调度到同一个关单任务发现锁已经可以获取了就取到了锁并开始执行。可是第一个节点的关单逻辑还没结束它只是持有锁的“许可证”过期了业务线程还在跑。两个节点同时执行同一个订单的关单操作重复修改订单状态、重复释放库存、重复发送短信。这类问题的解法不是加长锁过期时间这么简单因为业务耗时的波动是客观存在的。更可靠的做法是在业务逻辑内部对每笔订单的操作仍然做一次状态机校验比如关单只允许从“待支付”流转到“已关闭”即使分布式锁失效了状态机依然能挡住重复操作。分布式锁是性能优化手段状态机才是最终的幂等保证。只有两者叠加才能在高并发边缘场景里不出事。6.4 从压测数据看方案的性能边界为了让你对业务选型有个直观的参考我把之前压测的一组数据贴出来。测试环境是4台8C16G的应用节点单库MySQLRedis单节点模拟场景是支付回调接口QPS从500逐步叠加到5000。纯唯一索引方案回调接口在QPS 3000以内P99稳定在80ms左右主要耗时在数据库插入和索引更新。唯一索引 Redis token校验方案在QPS 2000左右性能开始明显下降因为多了一次Redis网络往返而且Redis单节点在高并发下存在连接等待。状态机update方案update ... where status性能最优QPS 5000时P99仍然在50ms左右因为只执行一次数据库更新没有额外的检查操作。这说明一个结论如果在满足业务场景的前提下能用一条状态机update语句解决就别引入额外的Redis依赖。如果确实需要token机制记得把连接池配置调好避免Redis连接成为瓶颈。7. 最后分享几点我自己沉淀下来的经验7.1 幂等设计要前置不要在故障后补幂等设计最理想的介入时机是在接口设计阶段。每定义一个对外接口先问一句这个接口被重复调用会怎么样如果会出问题就必须设计幂等方案。要是项目已经上线了再补往往意味着要改数据库、改接口协议、改调用方的重试策略改动成本比一开始就做高出数倍。我在评审接口文档时会强制检查一项接口文档里必须有“幂等性说明”小节写清楚该接口是否支持幂等、幂等键是什么、重复请求会返回什么错误码。没有这一项接口不允许进入开发阶段。这个习惯在一开始执行时会增加一些工作量但长期来看能避免大量线上事故值回票价。7.2 日志和监控是幂等问题的第一排障工具幂等方案运行时日志设计一定要显式区分“首次请求”和“重复请求”。每次幂等拦截时至少要记录一个业务幂等键和拦截原因比如reject duplicate callback, orderNoxxx, causeunique_key_conflict。如果日志里直接省略幂等键排查时你会一头雾水不知道是哪条业务数据被拦了。监控上建议单独建一个“幂等拦截次数”指标按接口维度统计。正常情况下这个指标在线上的量级应该相对稳定如果某个接口的这个指标突然飙升说明调用方可能出现了重试风暴或者是重复推送的bug。我经常用的做法是给这个指标单独配一条告警规则涨幅超过前一天的2倍就触发通知这样很多幂等相关的线上问题都能在用户感知之前暴露出来。7.3 兜底手段不能少对账机制要留着即使幂等方案做得再完善也不要指望它能挡住所有极端情况。三个系统之间的数据一致性依赖的往往是最后一层兜底对账机制。我负责支付系统时每天凌晨会跑一个对账任务把支付流水表、三方支付账单、订单表三方的数据做一次交叉比对。如果发现某笔订单本地状态是“支付成功”但三方账单里没有对应记录或者状态不一致就会触发告警并进入人工排查流程。对账机制的存在不是要求幂等方案不犯任何错而是在幂等方案漏防时给系统提供一个最终一致的收敛手段。把对账当作兜底把幂等当作常态手段两者组合使用系统才能在复杂的高并发环境里真正稳定下来。这套组合拳是我做了多年后端以后觉得最稳妥的防御姿态也建议你在自己的系统里尽早配上。