用户说只点了一次提交,客服却看到两笔一模一样的订单。前端按钮确实做了防抖,后端也查过“这个用户有没有下过单”,可两笔订单还是都进了数据库。问题往往不是请求重复了,而是系统把同一次“下单意图”当成了两次独立操作。
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){Orderexisting=orderMapper.findByRequestId(cmd.getRequestId());if(existing!=null){returnexisting;}Orderorder=buildOrder(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){IdempotencyRecordrecord=idempotencyMapper.findByKey(key);if("SUCCESS".equals(record.getStatus())){returnorderService.getResult(record.getOrderId());}thrownewDuplicateRequestException("请求正在处理中");}Orderorder=orderService.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. 状态流转也可以用条件更新做幂等
不是所有幂等都发生在“创建”阶段。支付回调、订单状态变更、库存扣减,也经常重复到达。
以支付回调为例,同一个支付成功通知可能被发送多次。比起先查询再判断,更稳妥的方式是带上状态条件更新:
UPDATEordersSETstatus='PAID',paid_at=NOW()WHEREid=#{orderId}ANDstatus='PENDING';如果影响行数是 1,说明这次更新真的把订单从待支付改成了已支付;如果是 0,说明订单已经不是待支付状态,可能之前已经处理过了。此时读取当前状态,返回“已处理”即可。
这种写法把“判断”和“更新”合并成了一个原子操作,不需要在应用层先查一遍再改。它特别适合状态机:PENDING → PAID、PAID → SHIPPED这种单向流转,重复执行不会把状态倒回去。
8. 消息重复消费,是同一类问题
消息队列通常保证“至少投递一次”,而不是“只投递一次”。消费者处理完消息、还没来得及确认,消费者重启了,消息就可能再次投递。
处理方式和接口幂等一致:给每条消息一个稳定的消息 ID,在消费时先写去重表或依赖业务唯一键。业务处理和去重记录在同一个事务里提交,提交成功后再确认消息。
如果先确认消息再处理业务,消费者在确认后崩溃,消息就丢了;如果先处理业务再确认,重复投递时又可能重复处理。正确的顺序是:业务和去重记录一起提交,最后再确认。这样即使确认失败、消息重投,第二次也会被去重记录挡住。
9. 怎么验证幂等真的有效
幂等设计不能只看代码“看起来对”,要做几种并发和故障测试:
- 同一个幂等键,两个线程同时请求,最终只能有一条订单;
- 同一个幂等键,第一次成功后再次请求,返回同一笔订单;
- 第一次处理到一半应用重启,重试后不会产生第二笔;
- 不同幂等键、相同业务内容,是否应该被视为重复,要看业务规则;
- 消息重复投递两次,消费结果只生效一次。
最后一种情况要特别注意:如果只靠幂等键,而客户端每次重试都生成新键,系统仍然会重复创建。幂等键只能解决“同一次意图的重复”,解决不了“两次真实的新意图”。业务上是否允许同一用户重复下单,需要在业务规则里明确。
总结
重复提交的根源,是网络和调用链都可能重试,而业务代码把每次到达都当成了一次新的操作。前端的按钮防抖只能减少误点,真正的幂等要落在服务端:客户端为同一次业务意图生成稳定的幂等键,服务端用数据库唯一约束做最终判断,Redis 只能做前置减流。创建类操作靠幂等记录,状态类操作靠条件更新,消息消费则把去重记录和业务处理放在同一个事务里。目标不是阻止第二个请求到达,而是让第二个请求不产生第二份结果。
参考资料
- Stripe API: Idempotent requests
- Microsoft Azure Architecture Center: Idempotency