我先交代一下背景。做后端开发的兄弟应该都有过这种经历:某个大促的凌晨,运营那边消息满天飞,说秒杀活动超卖了,库存瞬间变负数。你打开监控面板一看,高峰期下单接口的 QPS 快被打满了,应用日志里各种超时重试,数据库里库存字段值不对劲。这种事故我在前几年负责电商订单系统时,实打实踩过好几轮,最后用分布式锁把这问题压了下去,期间也踩了不少文档里根本不会写的坑。这篇文章直接把我当时的完整思路、落地代码、踩坑记录整理出来,给同样在处理库存超发问题的朋友一个可参考的副本。
先说清楚一个容易搞混的点:很多人以为超发是并发高导致的,其实并发只是催化剂。真正的原因是多个请求同时读到同一个库存值,然后在各自的内存空间里做判断和扣减,最后把结果写回去,后写的把先写的覆盖了。单机时代我们习惯用 synchronized 或者 JVM 内部的 Lock,但服务一拆多节点、部署多副本之后,每个 JVM 的锁管不到其他节点,这时候才轮到分布式锁上场。所以说到底,分布式锁解决的是跨进程互斥,是给“多个独立进程同时操作共享资源”这回事加一道门禁。
1. 超发问题是怎么来的,为什么单机锁挡不住
1.1 一次真实事故的完整复盘
大促当晚运营配置了一批限量 100 件的优惠券,页面一上线流量瞬间涌进来。我这边看到的异常是:数据库里优惠券库存字段变成了负数,最大负到 -7。查日志发现下单接口里用了类似这样的逻辑:
先查询当前库存,判断是否大于 0,大于 0 就执行 UPDATE 把库存减 1。这个判断和扣减之间隔着网络 IO、JVM 线程调度、数据库锁竞争,时间差足够让一百个并发请求都读到库存等于 1,然后各自通过判断,各自执行扣减,最后库存就被打成负数了。
这其实就是典型的 check-then-act 竞态条件。问题的关键不是 update 语句本身,而是“检查库存是否充足”和“扣减库存”这两步操作没有形成原子性。单机环境还能靠加锁把这两步锁住,多机部署之后就锁不住了。
1.2 为什么 synchronized 到多节点就失效了
synchronized 是 JVM 内置的监视器锁,它锁的是对象头里的 Mark Word,本质上是同一台机器上不同线程之间的协调机制。服务部署了三个节点,每个节点上有 200 个线程同时跑,这时候 synchronized 保证的是单个节点内互斥,三个节点之间完全隔离,跟没加锁一样。
还有一个很容易被忽略的点:即使只部署了单节点,如果应用里用了线程池异步处理、消息队列消费、定时任务调度,这些线程可能分散在不同 JVM 实例中,synchronized 同样管不到。我后来排查过一个诡异的问题:明明同一台机器上两个定时任务都加了 synchronized,结果两个任务还是同时执行了,最后发现它们分别跑在两个独立的 Spring 容器里——每个容器各有一个 JVM,锁自然是各锁各的。
所以要解决超发,必须有一个独立于所有业务节点之外的东西来做协调,让所有节点都认它、都听它的话。分布式的“分布”二字,指的就是这个协调者不在任何一个业务进程之内,而是一个所有进程都能访问的公共组件。
1.3 分布式锁解决的到底是哪一类问题
分布式锁本质上是一道分布式互斥机制:同一时刻只允许一个客户端持有锁,只有持锁的客户端能操作临界区资源,操作完成后主动释放锁,其他客户端只能等待或轮询。
套到超发场景里,锁保护的临界区就是那两行代码:检查库存、扣减库存。一旦这个区域变成互斥访问,同一时刻只有一个请求能进去,别的请求要么等它出来再进去,要么直接被拒掉,超发现象就从根本上消除。
需要提醒的是,分布式锁不是万能药。它适合保护“短时间、小粒度”的临界区。如果你的业务逻辑在锁内做了大量耗时的网络调用,比如远程接口、慢 SQL、批量文件处理,那么这把锁会拖垮整个系统的吞吐量。我见过一个团队把整个订单创建流程都塞进分布式锁里,结果 QPS 直接从 2000 掉到 50,数据库没超发,接口超时倒是刷屏了。这种场景应该思考怎么缩减临界区范围,而不是无脑加锁。
2. Redis 分布式锁的完整设计思路
2.1 从 SETNX 说起,每一步都是踩坑换来的
Redis 里最早实现分布式锁的方式是 SETNX 命令,全称是 SET if Not eXists,只在 key 不存在时设置成功,存在时返回失败。利用这个特性,可以让多个客户端竞争同一个 key,谁设置成功谁就获得锁。逻辑很直观,但真用起来漏洞不少。
初期我写的代码是这样的:先用 SETNX 去抢锁,抢到了就执行业务逻辑,最后 DEL 释放锁。跑了两天发现一个严重问题:如果业务逻辑抛异常了,或者服务进程直接崩溃了,DEL 代码压根执行不到,锁就会永远留在 Redis 里,后面的请求永远拿不到锁,系统直接瘫痪。
然后我想了个办法:给锁 key 设置过期时间。SETNX 成功之后再执行 EXPIRE,让锁在 30 秒后自动消失。可是这又引入了新的问题:SETNX 和 EXPIRE 是两条命令,不是原子的。如果 SETNX 成功之后,进程在 EXPIRE 执行前崩溃了,锁照样变成僵尸锁。
当时解决方式是写 Lua 脚本把 SETNX 和 EXPIRE 合并成一个原子操作发给 Redis 执行。后来发现这根本不用自己写,因为 Redis 官方早就考虑到了——SET 命令扩展了 NX 和 EX 参数,一行命令搞定:SET lock_key some_value NX EX 30。到这一步,加锁才算是有了一个相对完整的基础版本。
2.2 过期时间设多长,设短了业务没跑完怎么办
过期时间 TTL 的选择是分布式锁设计里最容易出问题的决策点。设短了,业务逻辑还没执行完锁就自动释放,后面的请求趁虚而入,临界区形同虚设。设长了,一旦持锁节点真的宕机,锁要很久才能被清除,系统整体可用性大大降低。
一个常见做法是看业务峰值耗时来估算:统计一下所有加锁保护的逻辑在 P99(99% 请求都能完成的时间)的水位,再乘以一个安全系数。比如库存扣减逻辑正常跑 50 毫秒,P99 是 200 毫秒,那么 TTL 设置 3 到 5 秒就挺充裕。但大促期间线上负载高、GC 暂停时间长、网络抖动频发,TTL 很容易被突破。
我自己更倾向于用实现成熟的客户端库的看门狗机制,而不是手工拍脑袋定一个固定值。Redisson 的 Watchdog 逻辑是这样的:如果锁的 TTL 没有显式指定,默认 30 秒;Redisson 会启动一个后台定时任务,每隔 TTL 的 1/3 时间(也就是约 10 秒)检查一下当前线程是否还持有锁,如果持有一边给锁续期 30 秒。这样只要业务线程还活着,锁就不会因为 TTL 太短被误删,业务跑完主动释放锁,后台任务自动取消。这套机制把“锁过期”这个老大难问题关进了笼子里。
2.3 释放锁为什么不能直接 DEL,必须用 Lua 脚本
很多初学者会忽视释放锁时的身份校验问题。直接 DEL 会有一个经典坑:A 线程持锁执行业务,因为某种原因业务耗时超过了 TTL,锁被 Redis 自动释放;B 线程抢到锁开始执行;这时候 A 线程业务终于跑完了,执行 DEL,把 B 线程的锁给删了;C 线程又趁虚而入拿到锁。结果就是同一时刻 B 和 C 同时进入临界区,超发问题换了个姿势又回来了。
解决思路是在锁的 value 里存一个只有当前线程/请求知道的唯一标识(通常用 UUID 加线程 ID),释放锁的时候先 GET 一下,判断 value 是否等于自己的标识,相等才 DEL。但这一步 GET 和 DEL 之间同样有竞态窗口,所以正确的姿势是用 Lua 脚本把比较和删除包成原子操作:
if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end这段脚本的意思是:只有当 key 里存的 value 等于我自己的唯一标识时,才删除这个 key。这样即使 A 线程的锁早就过期了,它来释放时发现 value 不是自己的,直接返回失败,不会误删 B 线程的锁。这个细节非常重要,我后来技术评审时只要看到有人直接写 DEL 释放锁,基本可以直接判断他对分布式锁的理解还停留在表面。
2.4 为什么我说 Redis 分布式锁要搭配业务兜底才稳妥
Redis 分布式锁有个无法回避的缺陷:Redis 主从架构不是强一致性的。A 线程在主节点上拿到了锁,主节点还没来得及把数据同步到从节点就宕机了,从节点晋升为主节点后,锁数据丢失,B 线程在新主节点上也能拿到锁,两个线程同时进入临界区。
这个问题的严重程度取决于你的业务容错能力。如果超发了可以接受事后人工补偿,比如人工核销、退款、补发,那 Redis 锁完全够用。如果超发一点就会造成资损且无法挽回,那就要考虑 Zookeeper 那种基于 ZAB 协议实现强一致的锁方案,或者引入 RedLock 算法。不过 RedLock 在业界争议很大,有些专家认为它并不能真正保证安全性,我在生产环境没有采用它,而是选择了“Redis 锁 + 数据库乐观锁兜底”的组合策略。后面讲代码实现时我会把这两层怎么配合一起说清楚。
3. 库存秒杀场景的代码落地全过程
3.1 先看一个不加锁的原始实现,理解问题是如何发生的
我拿一个典型的商品库存表来演示,表里只有一个商品、一个库存字段。不加任何锁的情况下,扣减库存的 Mapper 接口长这样:
@Update("UPDATE product_stock SET stock = stock - 1 WHERE id = #{productId}") int deductStock(@Param("productId") Long productId);Service 层逻辑是:
public Result<Boolean> createOrder(Long productId) { ProductStock stock = stockDao.selectById(productId); if (stock.getStock() <= 0) { return Result.fail("库存不足"); } stockDao.deductStock(productId); return Result.success(true); }这段代码就是我在文章开头复盘事故时提到的经典写法。先用 select 查一次库存,判断是否充足,再执行 update 扣减。并发高的时候,多个请求都 select 到了同一条库存记录,stock 都等于 1,都通过了 if 判断,都执行了扣减,库存就变成负数。
这里还有一个很多人没注意到的问题:即使把判断逻辑直接写进 SQL,比如UPDATE product_stock SET stock = stock - 1 WHERE id = #{productId} AND stock > 0,在单条语句层面确实是原子的,能够防止超扣。但它没有办法告诉业务层“这次到底有没有扣成”,还需要判断 update 返回的影响行数,如果为 0 说明库存不够。单纯靠这一招可以保住库存不为负,但如果我们后面还要串行执行其他业务逻辑,比如创建订单、发消息、写流水,这些操作依然需要一把全局锁来串行化,否则多个请求可能同时创建出超出库存的订单。
3.2 引入 Redisson 分布式锁,写一个可运行的完整示例
下面是我在生产环境使用过的代码,基于 Spring Boot 和 Redisson。Redisson 是 Java 生态里比较成熟的一个 Redis 客户端库,它把加锁、续期、释放等细节都封装好了,不像用 Jedis 或者 Lettuce 需要自己处理那么多边界情况。
先引入依赖:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.17.7</version> </dependency>然后在配置类里创建一个 RedissonClient 实例:
@Configuration public class RedissonConfig { @Bean public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://127.0.0.1:6379") .setDatabase(0) .setConnectionMinimumIdleSize(5) .setConnectionPoolSize(20); return Redisson.create(config); } }核心 Service 扣减逻辑:
@Service public class StockService { private static final String STOCK_LOCK_PREFIX = "stock:lock:"; @Autowired private RedissonClient redissonClient; @Autowired private StockDao stockDao; public Result<Boolean> createOrderWithLock(Long productId) { String lockKey = STOCK_LOCK_PREFIX + productId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(2, 10, TimeUnit.SECONDS); if (!locked) { return Result.fail("系统繁忙,请稍后重试"); } ProductStock stock = stockDao.selectById(productId); if (stock.getStock() <= 0) { return Result.fail("库存不足"); } boolean success = stockDao.deductStock(productId); if (!success) { return Result.fail("扣减失败"); } // 这里可以继续执行创建订单、写流水等业务 return Result.success(true); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail("系统异常"); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这里有几个细节值得细说:
tryLock 第一个参数是等待时间,意思是抢不到锁时最多等 2 秒,超过就放弃;第二个参数是锁的 TTL,单位是秒,但注意 Redisson 在显式传入 leaseTime 的时候不会启动看门狗,锁到期自动释放,你要自己评估业务时长是否够用;如果不传第二个参数,Redisson 才会启用看门狗自动续期。
我的建议是:对于库存扣减这种短平快的操作,显式传入 TTL 是够用的,因为逻辑本身只有一次查询和一次更新;但如果你在锁里调用了远程接口或者执行了批量操作,一定要用看门狗模式,也就是不传 leaseTime,让 Redisson 自动续期,否则 TTL 到期锁被释放,后续请求就会趁虚而入。
finally 块里我先用 isHeldByCurrentThread 判断一下当前线程是否还持有这把锁,再决定是否释放。这样可以避免一个极端场景:业务在锁内执行时间过长,锁已经自动过期了,后续代码走到 finally 时,如果直接 unlock,可能导致释放其他线程的锁。虽然 Redisson 的 unlock 内部有 value 校验,不会真的误删,但养成这个判断习惯能帮你写出更健壮的代码。
3.3 数据库层兜底:分布式锁 + 乐观锁双重保险
前面说了 Redis 分布式锁存在主从切换丢锁的隐患,为了把这个风险降到最低,我会在数据库层再加一道乐观锁兜底。具体做法是在库存表加一个 version 字段,每次更新时带上版本号,只有当版本号匹配时才更新成功:
@Update("UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE id = #{productId} AND version = #{version}") int deductStockWithVersion(@Param("productId") Long productId, @Param("version") Long version);Service 层在锁内读取库存时把 version 也查出来,执行更新时把 version 带进去,如果 update 返回值为 0,说明这条记录的版本号已经被其他事务修改了,本次扣减失败,直接返回库存不足或者稍后重试。
这两层叠加起来的效果是:分布式锁解决的是请求并发度高时的串行化问题,让绝大多数请求都能快速、有序地完成扣减;数据库乐观锁解决的是极端情况下锁失效时的最终一致性防线。即使某一次 Redis 锁出了问题,乐观锁也会拦截掉并发扣减,保证库存不会被打成负数。
我在压测环境里模拟过主节点宕机、业务流量持续的故障场景,纯 Redis 锁时超发量大约有 0.2% 的波动,加了乐观锁兜底后这个数字直接归零。代价是乐观锁在并发冲突高时会导致一部分 update 失败、需要重试或提示用户,但因为分布锁已经拦截了大部分并发,这个代价在实际生产中可以忽略不计。
3.4 锁粒度设计:锁商品还是锁订单,粗细怎么划分
分布式锁的粒度设计直接决定系统的并发能力和稳定性。拿库存系统来说,你有两种锁法:锁全局,比如stock:lock:all,所有商品的扣减共抢一把锁,实现最简单,但并发能力极低,一件商品的扣减会阻塞所有其他商品的扣减请求;按商品粒度锁,比如stock:lock:{productId},每个商品有自己独立的锁,互不干扰,并发能力大幅提升。
我的经验是尽量把锁粒度细化到业务的最小操作单元上。库存扣减的最小单元是单商品,所以锁 key 按 productId 生成是最合理的。如果你的业务里存在批量扣减,比如一次下单包含多个商品,那就得引入多把锁、控制加锁顺序,或者设计一个合并锁 key 的方案。比如把商品 ID 排序后拼接成一个字符串再加锁,这样可以避免多个请求互相持有对方等待的锁造成死锁。
另外锁的粒度也要考虑 Redis 集群的分片。如果你用的是 Redis Cluster,key 哈希槽是根据 key 字符串计算的,不同商品的锁 key 会分配到不同的分片节点上,天然分散了锁的读写压力。如果所有商品都用同一个 key 做锁,所有锁请求都会打到同一个分片上,这个分片会成为性能瓶颈,同时还可能导致整个集群数据倾斜。所以设计锁 key 时尽量让自己业务里的不同维度能自然分布。
4. 分布式锁实战中的坑与排查经验
4.1 锁过期导致业务漏出的真实案例和修复方案
有一次上线新活动后,我收到告警说某个商品的库存扣减量超过了配置的库存总量。查日志发现一个诡异的现象:扣减逻辑实际执行时间最长的一次达到了 12 秒,而锁的 TTL 只设了 5 秒。业务在锁还剩下的最后几秒里执行了一次耗时很长的 GC,日志显示 GC 暂停导致整个线程停顿了 7 秒,锁早就到期了,其他线程拿到锁又开始跑同一段逻辑。
那次事故后我把所有显式传了 TTL 的锁全部改成了 Redisson 的看门狗模式,同时加了一个业务超时监控。只要发现锁内执行时长超过 3 秒,就打点报警。改完之后类似的漏出问题再没出现过。
这里也分享一个判断技巧:如果你的业务逻辑里几乎没有外部依赖,本地纯内存计算和数据库单条更新,显式 TTL 就够了;如果涉及外部 HTTP 调用、消息队列发送、分布式事务,哪怕你说概率很低,也请务必用看门狗或者把 TTL 调到峰值耗时的 3 倍以上,别拿概率赌线上稳定性。
4.2 Redis 主从切换导致锁丢失的兜底策略
关于 Redis 主从切换丢锁,网上也有很多讨论,真实环境里确实会发生。有一次我们压测一个秒杀活动,专门做了故障演练:在流量高峰期手动把 Redis 主节点 kill 掉,让哨兵自动切换。结果监测到有大约 0.3% 的请求出现了两个线程同时进入临界区的情况,也就是超发。
那次演练让我意识到,纯靠 Redis 锁保底不现实,必须有多级防护。我采用的方案就是前面讲的数据库乐观锁兜底。如果你不想在代码里写乐观锁,也可以考虑让 Redis 走 RedLock 算法,在多个独立的 Redis 节点上分别加锁,超过半数成功才算加锁成功。但 RedLock 在业内争论很大,我自己的判断是工程上实现复杂、运维成本高、收益有限,不如数据库层兜底来得直接有效。
还有一个小技巧:给锁 key 的 value 加上当前节点的机器名和线程 ID,排查问题的时候通过 Redis 客户端查看 value,能直接定位到是哪个节点、哪个线程持锁,在故障复盘时非常有用。
4.3 锁内做远程调用的性能灾难与替代方案
我踩过的另一个大坑是把外部 RPC 调用放进了锁里。当时业务方要求下单成功后打电话通知客户,我图省事把通知逻辑直接写到了扣减库存的锁内,结果锁的持有时间从几毫秒变成了几百毫秒,系统吞吐量直接掉了一个量级。
后来的改造方案是锁内只做必要的库存预占和订单状态初始化,然后把后续的短信通知、优惠券发放、积分累计全部丢进消息队列异步消费。锁的服务时间回到几十毫秒水平,吞吐量恢复正常。这背后其实是一个更通用的原则:锁要短,跨界操作要出锁。凡是能在锁外做的,就不要拖到锁里;凡是能异步做的,就不要同步阻塞在临界区里。
另外要注意一个问题:锁内的 Redis 操作和业务数据库操作,不要在同一个事务里混用。因为 Redis 锁的释放和数据库事务提交之间天然存在先后顺序问题。如果先释放锁再提交事务,其他线程拿到锁后可能读到未提交的旧库存;如果先提交事务再释放锁,极端情况下锁一直持有会导致其他线程长时间阻塞。我常用的顺序是:业务操作完成后先提交事务,再在 finally 里释放锁。这样其他线程拿到锁时,看到的已经是新数据了。
4.4 常见问题速查表
| 症状 | 根本原因 | 处理办法 |
|---|---|---|
| 库存变成负数 | 并发请求同时通过库存判断 | 在扣减 SQL 里加 stock > 0 条件,配合分布式锁串行化 |
| 锁长期不释放,接口一直阻塞 | 业务抛异常后没有释放锁 | 用 try-finally 确保 unlock 执行,锁生命周期不要超过一次请求 |
| 请求量上来后 Redis 连接被打爆 | 锁的获取和释放频率过高,连接池太小 | 提高连接池大小,降低锁粒度,避免把远程调用放锁内 |
| 服务重启后锁还留在 Redis 里 | 没有设置过期时间 | 使用 SET NX EX 语法或 Redisson 的默认看门狗 |
| 锁偶尔失效,出现超发 | Redis 主从切换导致 key 丢失 | 数据库乐观锁兜底,或评估是否引入多节点 RedLock |
| 线程 A 释放了线程 B 的锁 | 释放前没有校验持有者身份 | 使用 Lua 脚本比较 value 后删除,不要直接 DEL |
| 锁内代码误删其他业务数据 | 锁 key 命名过于宽泛,比如全部商品共用一个锁 | 将锁 key 细化到资源维度,例如按 productId 拆分 |
| 秒杀接口吞吐量极低 | 锁内包含大量耗时操作 | 缩短临界区范围,耗时的操作改消息队列异步处理 |
4.5 选型建议与我的最终判断
如果你现在的系统规模还不大,单机 Redis 加 Redisson 完全够用,不需要为了分布式锁单独引入一套 Zookeeper 集群。只有当你的业务对一致性要求极高、且能接受 ZAB 协议带来的性能开销时,Zookeeper 临时顺序节点方案才值得考虑。Zookeeper 的锁语义清晰、无过期困扰、天然可重入,但是性能比 Redis 低了一个量级,而且运维组件越多,故障面越大。
从我个人的最终实践来看,稳定方案是:Redisson 分布式锁做第一道防线,数据库乐观锁做最终保底,锁 key 精确到商品维度,临界区只保留必保操作,释放锁时务必校验持有者身份。这套组合在多次大促和秒杀活动中经受住了考验,没有出现一次超发事故。
分享一个更实战的经验:上线前用压测工具模拟 500 并发同时抢 10 个库存商品,观察库存是否正确归零、订单是否没有超额生成、Redis 锁 key 是否全部释放。这个场景能一次性把文章里提到的绝大多数问题暴露出来。我在团队里要求每次涉及库存扣减的代码变更,都必须先跑通这个并发压测用例再提测。如果你正在被超发问题折磨,别急着自己造轮子,把上面这套方案跑一遍,很多问题会在压测阶段就露出马脚,而不是等到大促当夜再来开作战室复盘。