2.3 防重复下单:分布式锁与幂等
大促那天晚上八点,运营在群里发了一张截图:同一个用户、同一批商品、间隔 300 毫秒,产生了 47 笔订单。原因是前端提交按钮没禁用,网络一抖用户疯狂点。这 47 笔里只有 1 笔是真实意愿,其余 46 笔全要手工处理。这一节讲我们怎么用分布式锁加幂等键堵住重复下单。
场景:重复下单的三个入口
重复下单不止前端狂点这一个入口。至少三个:用户快速点击提交(网络慢时最常见);前端做支付失败重试,重新走下单流程;上游系统调接口时超时重试,但第一次请求实际已经成功。前两个是用户行为,第三个是系统集成问题,且最难处理,因为重试的发起方根本不知道前一次成功了。
防重复的经典组合是「锁在前、幂等在后」。锁保证同一时刻只有一个请求在处理,幂等保证重复请求返回第一次的结果。两者缺一不可:只有锁,重试间隔超过锁超时就会穿透;只有幂等,瞬时并发会在数据库里撞车。
方案:Redis 锁加幂等键
方案分两层。第一层 Redis 分布式锁,锁的粒度按场景定:
| 场景 | 锁键 | 粒度理由 |
|---|---|---|
| 用户下单 | user:order:uid | 同一用户串行 |
| 商品库存 | stock:sku:商品Id | 粒度最细,并发最高 |
| 支付回调 | pay:orderNo | 回调重试幂等 |
| 整体兜底 | 幂等键 | 万能防线 |
第二层是幂等键。下单接口要求客户端传 idempotentKey,没有就拒绝。幂等键由业务方生成,规则是「同一时间窗口内同一业务意图的请求携带同一个键」。服务端对同一个键的重复请求返回第一次的响应结果。
锁与幂等的分工:锁保护短时间内的并发写入(毫秒级),幂等键保护任意时间的重复提交(分钟到小时级)。锁通常 3 到 5 秒就够,幂等键的存储设 24 小时过期。
代码:加锁与幂等检查
下单核心逻辑的骨架,先锁后查再插:
public CreateOrderResult createOrder(CreateOrderRequest req) {
String lockKey = "user:order:" + req.getUserId();
String token = UUID.randomUUID().toString();
try {
if (!redisLock.tryLock(lockKey, token, 5, TimeUnit.SECONDS)) {
throw new BizException("下单太频繁,请稍候");
}
Optional<Order> exist = orderDao.findByIdempotentKey(req.getIdempotentKey());
if (exist.isPresent()) {
return buildResult(exist.get()); // 幂等返回原单
}
return doCreateOrder(req);
} finally {
redisLock.unlock(lockKey, token);
}
}
两个细节决定这段代码能不能上线。一是锁必须带 token,解锁时判断持有者,防止超时后误删别人的锁;二是幂等查询依赖数据库唯一索引兜底,Redis 只是加速,真正的一致性由 MySQL 的唯一键保证(idempotentKey 字段加唯一索引,并发插入时必然有一个失败)。
踩坑:分布式锁的三个经典坑
第一个是锁超时误删。最早的解锁代码直接 del,一个事务执行慢超过锁的超时时间,锁被另一个线程拿到,原线程执行完直接删锁,把新线程的锁删掉了。改成带 token 的 Lua 脚本解锁后解决。锁超时时间也别设太短,下单链路有数据库事务,给到 5 秒比较稳。
第二个是唯一索引的兜底问题。高并发下 Redis 幂等查询全部 miss,请求一起涌向数据库,数据库唯一索引冲突报错。处理方式是捕获 DuplicateKeyException 后查一次返回原单,不能把冲突当真错误抛给用户。这个「先查后插失败再查」的模式在高并发表的 秒杀系统实战 里也有类似讨论。
第三个是锁的粒度。最初我们锁的是 userId,同用户的不同商品请求互相等待。改成用户级锁 + 商品级乐观锁(版本号)后吞吐量提升明显。锁粒度选择永远在「安全」与「吞吐」之间找平衡,可以参考 服务治理与限流 的保护思路。
另外,锁失效的极端情况(Redis 主从切换瞬间)会让两个请求同时拿到锁。我们的兜底是商品行的乐观锁版本号,数据库层再挡一道,这部分在下一章详细展开。
进阶实战:幂等键的生成与失效
幂等键的设计比看上去更讲究。键的生成规则决定了它能不能防住真实场景的重复。我们踩过一个坑:幂等键由「用户 ID + 商品 ID + 时间戳分钟」拼成,结果秒杀场景下用户 2 秒点两次,键相同防住了,但用户想买两件不同商品时也被错误幂等挡住。最后的规则是幂等键由前端在「进入结算页」时生成一次,整个下单流程不变;若用户重新进结算页则生成新键,重新开始。
| 幂等键来源 | 优点 | 风险 |
|---|---|---|
| 前端生成(进入结算页) | 贴近用户意图 | 前端 bug 导致键漂移 |
| 客户端缓存 | 离线可用 | 缓存清理后失效 |
| 服务端发牌 | 强可控 | 多一次交互 |
我们最终选前端生成加服务端校验的组合:前端拿不到键就不许下单,键格式带业务前缀便于排查。数据库的唯一索引是最终防线,并发插入DuplicateKeyException 转为查询返回。这套组合挡住了几乎所有重复场景,唯一没挡住的是极少数前端直接绕过结算页的异常路径,全部落在唯一索引兜底里。
锁与幂等的存储位置也有讲究。锁必须放 Redis 之类的内存存储,毫秒级的加解锁放数据库会把行锁排队拉长。幂等键存储则两份:Redis 加速查询(24 小时过期),MySQL 唯一索引永久兜底。Redis 只是快,MySQL 才是准。
速查清单
防重复下单的四道闸:
| 闸门 | 位置 | 作用 |
|---|---|---|
| 前端按钮禁用 | 客户端 | 体验层 |
| Redis 分布式锁 | 服务端 | 挡瞬时并发 |
| 幂等键查询 | 服务端 | 挡重复提交 |
| 唯一索引 | 数据库 | 最终防线 |
小结
防重复下单的核心是两道防线:分布式锁挡瞬时并发,幂等键挡重复提交,数据库唯一索引做最终兜底。任何一道单独都不够,组合起来才稳。下一篇讲分区表,数据量上来之后单库怎么治理。
常见误解:分布式锁的三个陷阱
第一个陷阱是「锁是万能的」,认为加了锁就高枕无忧。锁只是把并发串行化,它防不住重试、防不住业务校验缺失。真正的重复下单防护是锁、幂等键、唯一索引三层叠加,任何一层单独都有失效路径。我们给新同学的培训材料里专门有一句:锁挡住一瞬间,幂等挡住一辈子,索引挡住整个世界。
| 陷阱 | 失效场景 | 兜底层 |
|---|---|---|
| 锁是万能 | 重试跨过锁周期 | 幂等键 |
| 锁粒度越粗越安全 | 吞吐骤降 | 按场景选粒度 |
| 锁失败即业务失败 | 应拉高可用 | 调用方降级 |
第二个陷阱是「锁越粗越安全」。最早我们锁过用户、锁过订单、甚至锁过整张库存表,吞吐惨不忍睹。锁粒度的选择应当按竞争强度:用户级锁挡重复点击足够,商品级锁才有必要。粗锁是性能毒药,只在真正必要时使用。
第三个陷阱是「锁服务挂了业务就挂了」。Redis 故障时下单接口直接不可用是过度依赖。我们的做法是 Redis 锁失败时降级为数据库唯一索引兜底(请求照常下,冲突由数据库拒绝),代价只是一小部分重复请求失败重试,而不是全站不可用。基础设施降级策略在 服务治理 中有更系统的讨论,核心思想都是让业务对中间件故障有缓冲。
团队协作:重复下单问题的 quick 定位
重复单一旦发生,客服与运营都急着问「为什么」。我们做了一个小工具帮所有人自助查:输入订单号,工具返回这个订单的完整创建链路——请求来自哪个入口、idempotentKey 是什么、加锁是否成功、是否走了幂等返回。客服不用找开发,自己就能判断是用户重复点击还是系统异常。这个工具上线后,重复单相关的工单流转从两小时降到十分钟。
| 查询入口 | 适用问题 |
|---|---|
| 订单号 | 全链路追溯 |
| idempotentKey | 判断是否同一意图 |
| 用户 ID | 用户行为模式 |
另一个值得固化的实践是「重复单日报」。每天把前一天的重复单按入口分类:前端重复点击多少、渠道重试多少、未知多少。入口的分布直接指导下一阶段优化——前端点击占比高就做按钮防抖,渠道重试占比高就去梳理对接协议。数据让防御有的放矢,不再是凭感觉加锁。
