
用户说只点了一次提交客服却看到两笔一模一样的订单。前端按钮确实做了防抖后端也查过“这个用户有没有下过单”可两笔订单还是都进了数据库。问题往往不是请求重复了而是系统把同一次“下单意图”当成了两次独立操作。1. “我只点了一次”请求却可能到了三次用户看到的是“点了一次”系统看到的却可能是一条很长的调用链用户点击 → 前端发出请求 → 网关转发 → 订单服务处理 → 调用支付或库存服务这条链上任何一段超时都可能触发重试。客户端等 3 秒没响应可能自动重发网关调用订单服务超时可能再发一次服务之间的 RPC 框架开启了重试也可能再发一次。用户那边只看到一个转圈的按钮后台却已经收到了好几份内容相同的请求。10:00:00 请求 A 到达开始创建订单 10:00:03 客户端等不及重发请求 B 10:00:04 请求 A 处理完成订单 1001 创建成功 10:00:05 请求 B 到达又创建了订单 1002这两笔订单的内容可能完全一样同一个用户、同一件商品、同一个金额、同一个收货地址。从数据库角度看它们就是两次合法的插入。前端的按钮防抖只能减少“用户手抖点两下”管不住客户端超时重试、网关重试和消息重投。真正的防线必须放在服务端而且不能只靠一次SELECT判断。图 1重复请求可能来自点击、超时重试或消息重投最后都要经过服务端的幂等判断和数据库唯一约束。2. 幂等不是“拦住第二次请求”很多人把幂等理解成“第二次请求直接拒绝”。这只说对了一半。幂等更准确的意思是同一个操作执行一次和执行多次业务结果相同。比如“把订单状态改成已支付”执行一次和三次最终状态都是已支付这就是幂等。“创建一笔订单”如果每次执行都插入一条新记录就不幂等只有让第二次执行返回第一次的结果或者什么都不做才叫幂等。所以幂等的目标不是让第二个请求消失而是让它不产生第二份业务结果。第二个请求可能仍然会到达服务器仍然会走一遍接口但它应该被识别出来然后返回一个合理的结果。这个结果可以是第一次已经成功直接返回第一次创建的订单第一次还在处理中返回“处理中请稍后查询”第一次失败了根据失败原因决定是否允许重试。只返回一句“重复提交”并不理想。客户端不知道第一次到底成功没有可能还会再试一次问题反而更复杂。3. 为什么“先查再插”挡不住并发最常见的错误写法是这样的publicOrdercreateOrder(CreateOrderCommandcmd){OrderexistingorderMapper.findByRequestId(cmd.getRequestId());if(existing!null){returnexisting;}OrderorderbuildOrder(cmd);orderMapper.insert(order);returnorder;}单线程下这段代码没问题。两个请求同时到达时情况就不一样了请求 A查询没找到 请求 B查询也没找到 请求 A插入订单 1001 请求 B插入订单 1002两个请求都在对方插入之前完成了查询所以都认为自己可以创建。这就是典型的“检查后执行”竞态。查询和插入之间有一段空档并发请求正好从这段空档里挤了进来。要解决它不能只靠应用层的if必须让数据库来保证“同一个键只能存在一条记录”。4. 幂等键应该由谁生成幂等键是识别“这是不是同一次操作”的凭证。它必须满足两个条件同一次业务意图的多次重试共用同一个键不同业务意图使用不同的键。比较常见的做法是让客户端生成POST /api/orders Idempotency-Key: 9f1c2d7e-4a3b-4c8d-9e10-2f6a7b8c9d01 Content-Type: application/json客户端在用户点击“提交订单”时生成一个 UUID之后不管是超时重发还是用户手动重试都复用这个键。只有用户明确开始了一笔新订单才生成新的键。如果让服务端在每次请求里生成键那就没有意义了每次重试都会得到一个全新的键系统仍然会把它们当成不同操作。如果键里带了时间戳或随机数也会出现同样的问题。键的内容要能区分业务范围。比如order:create:用户ID:客户端请求ID比单纯一个 UUID 更好排查。关键是同一个用户、同一次下单意图重试时键不能变。5. 最稳的落点数据库唯一约束不管前面用了 Redis 还是本地缓存做预检查最后都应该有一道数据库唯一约束。因为 Redis 可能过期、可能被清空应用也可能在两步之间崩溃只有数据库的唯一索引能对“最终只能有一条”负责。可以建一张幂等记录表CREATETABLEidempotency_record(idempotency_keyVARCHAR(64)PRIMARYKEY,statusVARCHAR(16)NOTNULL,order_idBIGINT,created_atDATETIMENOTNULL,updated_atDATETIMENOTNULL);处理流程可以写成这样TransactionalpublicOrderResultcreateOrder(CreateOrderCommandcmd,Stringkey){try{idempotencyMapper.insertProcessing(key);}catch(DuplicateKeyExceptione){IdempotencyRecordrecordidempotencyMapper.findByKey(key);if(SUCCESS.equals(record.getStatus())){returnorderService.getResult(record.getOrderId());}thrownewDuplicateRequestException(请求正在处理中);}OrderorderorderService.create(cmd);idempotencyMapper.markSuccess(key,order.getId());returnOrderResult.of(order);}这段代码的关键在第一步插入幂等记录时数据库主键或唯一索引会替我们做原子判断。第一个请求插入成功继续创建订单第二个请求插入时触发DuplicateKeyException于是去读第一条记录返回已有结果或“处理中”。这里有一个很重要的细节幂等记录和订单数据最好在同一个数据库事务里。如果订单插入成功、幂等记录没写进去应用又崩了下一次重试就找不到“已经创建过”的证据。反过来如果幂等记录写成功了、订单却没创建记录会卡在PROCESSING需要超时清理或状态补偿。同一个事务能保证“订单和幂等记录一起成功或一起失败”是更简单也更可靠的方案。如果订单创建还调用了外部支付、库存服务数据库事务管不到外部系统那就需要状态机和补偿任务不能指望一个本地事务解决所有问题。上面的写法是一个便于理解的 MySQL MyBatis 版本。如果你用的是 JPA 或 PostgreSQL唯一键冲突可能会把当前事务标记成只能回滚冲突之后继续查询会报错。这种情况下插入幂等记录要放到独立事务里例如REQUIRES_NEW冲突后先结束那个事务再查询已有记录。6. Redis 可以做预检查但不能当最后一道锁Redis 的SET key value NX EX很适合做快速防重。第一个请求设置成功后续请求设置失败就能在进入数据库之前挡掉大部分重复流量。但它不能单独承担幂等责任键可能过期业务还没处理完第二个请求就能重新进来Redis 可能被清空或主从切换丢数据应用在SET成功之后、数据库提交之前崩溃键还在但订单没创建锁释放得比数据库提交早另一个请求可能在提交前进入。所以 Redis 更适合做“前置减流”数据库唯一约束才是“最终裁决”。两层配合的逻辑是Redis 挡住绝大多数重复请求数据库保证极端情况下也不会出现两条记录。如果使用分布式锁还要注意锁的持有时间要覆盖业务关键阶段删除锁时要校验持有者不能用固定的值直接DEL。但这些细节只能降低并发窗口不能替代唯一约束。7. 状态流转也可以用条件更新做幂等不是所有幂等都发生在“创建”阶段。支付回调、订单状态变更、库存扣减也经常重复到达。以支付回调为例同一个支付成功通知可能被发送多次。比起先查询再判断更稳妥的方式是带上状态条件更新UPDATEordersSETstatusPAID,paid_atNOW()WHEREid#{orderId}ANDstatusPENDING;如果影响行数是 1说明这次更新真的把订单从待支付改成了已支付如果是 0说明订单已经不是待支付状态可能之前已经处理过了。此时读取当前状态返回“已处理”即可。这种写法把“判断”和“更新”合并成了一个原子操作不需要在应用层先查一遍再改。它特别适合状态机PENDING → PAID、PAID → SHIPPED这种单向流转重复执行不会把状态倒回去。8. 消息重复消费是同一类问题消息队列通常保证“至少投递一次”而不是“只投递一次”。消费者处理完消息、还没来得及确认消费者重启了消息就可能再次投递。处理方式和接口幂等一致给每条消息一个稳定的消息 ID在消费时先写去重表或依赖业务唯一键。业务处理和去重记录在同一个事务里提交提交成功后再确认消息。如果先确认消息再处理业务消费者在确认后崩溃消息就丢了如果先处理业务再确认重复投递时又可能重复处理。正确的顺序是业务和去重记录一起提交最后再确认。这样即使确认失败、消息重投第二次也会被去重记录挡住。9. 怎么验证幂等真的有效幂等设计不能只看代码“看起来对”要做几种并发和故障测试同一个幂等键两个线程同时请求最终只能有一条订单同一个幂等键第一次成功后再次请求返回同一笔订单第一次处理到一半应用重启重试后不会产生第二笔不同幂等键、相同业务内容是否应该被视为重复要看业务规则消息重复投递两次消费结果只生效一次。最后一种情况要特别注意如果只靠幂等键而客户端每次重试都生成新键系统仍然会重复创建。幂等键只能解决“同一次意图的重复”解决不了“两次真实的新意图”。业务上是否允许同一用户重复下单需要在业务规则里明确。总结重复提交的根源是网络和调用链都可能重试而业务代码把每次到达都当成了一次新的操作。前端的按钮防抖只能减少误点真正的幂等要落在服务端客户端为同一次业务意图生成稳定的幂等键服务端用数据库唯一约束做最终判断Redis 只能做前置减流。创建类操作靠幂等记录状态类操作靠条件更新消息消费则把去重记录和业务处理放在同一个事务里。目标不是阻止第二个请求到达而是让第二个请求不产生第二份结果。参考资料Stripe API: Idempotent requestsMicrosoft Azure Architecture Center: Idempotency