1. 问题现场:为什么单机锁在集群下必然塌方
分布式锁这个词,在优惠券秒杀这个场景里不是炫技用的,是被逼出来的。这个点评类练手项目的秒杀模块,最早跑在单机上,一个synchronized就能把一人一单压得死死的,压测几千次也没出过超领;等到把服务实例从 1 台扩到 2 台,前端挂上负载均衡再跑同一套脚本,问题立刻炸出来:券库存被扣成负数,同一个账号能领到两张优惠券,订单表里出现重复记录。这里面的核心矛盾非常朴素——synchronized锁的是 JVM 进程内的一块内存,两台机器就是两个 JVM,两把锁互不相识,谁也管不住谁。所以优惠券秒杀一旦进入多实例部署,就必然要从“进程内互斥”升级到“跨进程互斥”,也就是分布式锁。
1.1 超卖与超领的真实触发路径
很多人以为超卖是“并发太高”,其实不是。真正的触发路径是:线程 A 和线程 B 几乎同一时刻读到了同一份库存,比如都读到stock = 1,两边都判断“有库存”,然后各自执行stock - 1,最终数据库里剩-1。这个过程跟并发量大小没有绝对关系,只要两个请求能在“读”和“写”之间形成交错,1 个库存卖 2 次就会发生。一人一单是同一套逻辑的变种:两个线程同时查“这个用户有没有下过单”,都查到“没有”,于是都去插入订单,唯一性就这样被击穿了。
我在自己的环境里做过一个很直观的对照实验:把synchronized去掉,用 JMeter 开 200 个线程、循环 5 次去打秒杀接口,稳定复现超卖,库存扣到负数;把synchronized加回去、单实例部署,200 线程跑完库存正好归零,一张不多一张不少。这个对照的价值在于,它把“锁到底在防什么”这件事变成了可观测现象,而不是一句抽象的“保证线程安全”。
还有一个容易被忽略的细节:超卖往往不是均匀发生的,它高度集中在整点开抢后的头几百毫秒。因为那一刻大量请求同时到达,读-写交错概率最高;等到库存快见底时,反而因为大部分请求被“库存不足”挡在门外,交错机会变少了。所以排查这类问题时,看平均 QPS 意义不大,要看峰值瞬间的并发密度。
1.2 synchronized 失效的机械原理
把synchronized (userId.toString().intern())这行代码拆开看,它做了两件事:第一,intern()从字符串常量池里拿一个全局唯一的字符串对象;第二,synchronized对这个对象加监视器锁。注意,常量池是 JVM 级别的,监视器锁也是 JVM 级别的,两者都在进程内部。当请求打到实例 A,锁在 A 的 JVM 里;打到实例 B,锁在 B 的 JVM 里,两个 JVM 之间没有任何通信机制,锁自然就形同虚设。
这里还有个更隐蔽的坑:intern()在单机下“看起来能用”是有运气成分的。字符串常量池在不同 JDK 版本里的实现位置不一样,某些情况下intern()返回的对象行为并不完全等同于“全局唯一”,一旦拿到的不是同一个对象,同机内的锁也会失效。所以这行代码本身就是个“能跑但不该依赖”的写法,它在单机环境下靠巧合工作,在集群环境下则百分之百失效。
我在第一次做集群验证时犯过一个错:只起了两个实例,但前端请求全被负载均衡的会话保持打到了同一台机器上,跑出来的结果是“没问题”。当时差点误判成synchronized够用。后来把会话保持关掉、改成轮询,问题立刻暴露。这个教训很值钱——验证分布式问题,先确认你的流量真的分散到了多个节点上,否则你验证的是单机。
1.3 分布式锁的三条硬指标
在选择具体方案之前,得先明确一把合格的分布式锁要满足什么。我自己的判断标准是三条,缺一条都不算过关。
第一是互斥性,任意时刻只能有一个客户端持有锁,这是底线,做不到就不用谈了。第二是防死锁,持锁的客户端崩溃或者网络断开后,锁必须能自动释放,不能永久卡住,否则整个业务线就瘫了。第三是解铃还须系铃人,A 加的锁只能 A 来解,不能让 B 把 A 的锁删掉——这条看着简单,实现起来恰恰是最容易翻车的地方。
除了这三条,还有两个工程上必须考虑的软指标:可重入和阻塞等待。可重入的意思是同一个线程重复加同一把锁不会把自己锁死;阻塞等待的意思是拿不到锁时可以选择等一会儿再试,而不是立刻失败。这两点如果全靠自己写,工作量会迅速膨胀,这也是后面转向成熟组件的原因。
注意:互斥性、防死锁、锁归属这三条,后面每换一次实现方案都要重新对照检查一遍。很多线上事故不是方案选错,而是换了实现之后某一条悄悄不满足了,比如为了省事给锁设了固定过期时间却没做续期,防死锁满足了,业务却没跑完。
2. 方案演进:从手写 setnx 到 Redisson
明确了指标,接下来就是选型。这个过程我走了三步:先用 Redis 的setnx手写一版,理解原理;然后用 Lua 脚本修补缺陷;最后换成 Redisson 做工程化托底。三步走完,才对“为什么大家都用 Redisson”有体感——不看前两步,用 Redisson 就只是抄配置,出了问题完全无从下手。
2.1 手写版的最小可用实现与两个致命缺陷
最原始的写法是这样:用setnx尝试占位,成功就代表拿到锁,然后给这个 key 设一个过期时间,业务执行完再del掉。逻辑上能跑通,但有两个致命缺陷。
第一个缺陷是原子性断裂。setnx和expire是两条命令,如果setnx成功之后、expire执行之前,客户端恰好宕机或者网络抖动,这个 key 就永久存在了,锁再也释放不掉,整个秒杀接口从此卡死。这个概率不高,但只要发生一次就是 P0 级事故。
第二个缺陷是误删别人的锁。假设线程 A 拿到锁,业务执行时间超过了预设的过期时间,锁自动释放;线程 B 此时拿到锁开始执行;线程 A 执行完了,执行del把 key 删了——删掉的是 B 的锁。这时候线程 C 又能拿到锁,B 和 C 同时执行,互斥性直接归零。这个场景在压测时特别容易撞上,因为压测会把响应时间拉长,锁超时的概率被放大。
修第一个缺陷,把setnx+expire换成一条set key value NX EX 30命令就够了,Redis 保证单条命令的原子性。修第二个缺陷,就得在删除前先判断“这个锁是不是我的”,而“判断 + 删除”这两步又必须是原子的,于是就必须上 Lua。
2.2 Lua 把“判断+删除”焊成一步
Redis 执行 Lua 脚本时是单线程且不可打断的,脚本里的多条命令要么全执行完,要么全不执行,中间不会被其他客户端插队。这就是它能把“判断归属”和“删除”焊死的原因。手写版的解锁脚本大致长这样:
-- KEYS[1] = 锁的 key,ARGV[1] = 当前客户端标识 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end加锁的时候,value 不能写死一个"1",必须写能唯一标识当前持有者的东西,通常是UUID + 线程ID拼起来的字符串。这一点很多人省事写死成固定值,结果锁归属判断形同虚设,等于没做。
光有 Lua 还不够,手写方案还有个绕不开的麻烦:业务执行时间不确定。你没法预知这次扣库存要跑 50 毫秒还是 5 秒,过期时间设短了业务没跑完锁就没了,设长了万一客户端崩溃,锁要等很久才释放,中间这段时间所有请求全被挡在外面。想做“业务没跑完就自动续期”,就得再起一个定时任务去刷新过期时间,同时还要处理续期任务和业务线程的生命周期同步——代码量一下子就上去了,而且极容易写出 bug。
2.3 为什么最终选了 Redisson
走到这一步,其实已经不是在选“要不要用现成组件”,而是在选“要不要自己维护一套带续期、带重入、带阻塞等待的锁框架”。答案很明显。Redisson 在这个场景下提供的价值,我总结成三点:
一是开箱即用的可重入锁,它用 Hash 结构记录“哪个线程持有了几次”,重入逻辑天然成立,不用自己维护计数器。二是看门狗自动续期,只要你不显式指定租期,它会在后台按固定频率给锁续命,业务跑多久锁就活多久,彻底绕开“过期时间设多少”的玄学问题。三是阻塞等待与超时控制,tryLock支持等待时间参数,拿不到锁可以让线程等一下再试,而不是直接给用户返回失败,体验上平滑很多。
代价是引入了一个额外依赖,并且需要理解它的默认行为——尤其是看门狗在什么条件下才启动,这一点坑了太多人,后面单独讲。
| 方案 | 互斥 | 防死锁 | 锁归属安全 | 可重入 | 自动续期 | 落地成本 |
|---|---|---|---|---|---|---|
synchronized | 仅单机 | 天然支持 | 天然安全 | 支持 | 不需要 | 极低 |
setnx+expire | 支持 | 有原子性风险 | 不安全 | 不支持 | 无 | 低 |
set NX EX+ Lua 解锁 | 支持 | 支持 | 安全 | 需自己实现 | 无 | 中 |
| Redisson | 支持 | 支持 | 安全 | 支持 | 支持 | 低(依赖引入) |
提示:表格里“落地成本”那一列,指的是写完之后能不能长期维护的成本,不是第一次跑通的成本。手写 Lua 版本第一次跑通很快,但后续每加一个业务场景都要重新推演一遍边界,隐性成本很高。
3. Redisson 加锁全链路拆解
用 Redisson 最怕的就是“配好了能跑,但不知道为什么能跑”。这一节把加锁、续期、解锁三段拆开看,理解了这三段,后面排查问题基本靠看日志就能定位。
3.1 锁的数据结构:Hash 而不是 String
手写版用一个 String 存锁,value 是持有者标识。Redisson 不一样,它用 Hash:key 是锁的名字,field 是“客户端UUID + 线程ID”,value 是重入次数。这个设计的直接收益就是可重入——同一个线程第二次进来,hexists判断到自己已经持有,直接把计数+1,不会阻塞自己。
为什么非得用 Hash?因为 String 结构只能表达“谁持有”,表达不了“持有几次”。可重入必须知道层数,释放的时候也要按层数递减,减到 0 才真正删 key。这个结构在小项目里可能用不上,但只要业务里出现“加锁方法内部又调用了另一个需要加同一把锁的方法”,可重入就是刚需。
我在改造时就踩过一次:把锁加在了扣减库存的方法上,结果创建订单的方法内部又调了一次扣减,同一线程两次加锁——如果用的是不可重入的实现,这里会直接自己把自己锁死,接口表现为无限等待直到超时。Redisson 靠 Hash 计数避开了这个问题,但这也提醒我,锁的边界要设计清楚,可重入是兜底而不是设计目标,过度重入往往意味着锁的粒度切得不对。
3.2 加锁脚本逐行读
Redisson 加锁的核心是一段 Lua,简化后的逻辑是这样:
-- KEYS[1] = 锁 key -- ARGV[1] = 租期(毫秒) -- ARGV[2] = 持有者标识(UUID:线程ID) if ((redis.call('exists', KEYS[1]) == 0) or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);第一行判断是核心:锁不存在,或者锁存在但持有者就是自己,两种情况都允许继续,执行hincrby把计数加一,然后重设过期时间。注意是每次都重设过期时间,这就是可重入场景下锁不会提前过期的原因。
如果两个条件都不满足,说明锁被别人占着,脚本返回当前锁的剩余存活时间(pttl)。这个返回值很关键——客户端拿到它之后,不会傻等,而是根据这个时间做订阅等待,等锁释放的消息再重新竞争。这比“循环重试 + sleep”高效得多,也不会给 Redis 带来无谓的压力。
我在读这段脚本时最有收获的一点是:它没有用setnx。setnx只能表达“不存在则设置”,表达不了“不存在或者是我自己则设置”,而后者正是可重入的前提。很多人手写锁时卡在可重入上,根子就在这里。
3.3 看门狗续期的触发条件与时间常数
看门狗是 Redisson 最容易被误解的机制。最常见的误解是“只要用 Redisson 就有自动续期”,这是错的。准确条件是:调用tryLock时没有显式传入租期,也就是leaseTime为-1。如果你写了tryLock(10, 30, TimeUnit.SECONDS),第二个参数 30 秒是硬租期,看门狗直接不启动,锁到点就释放,不管业务跑完没有。
默认的时间常数是:锁的初始租期 30 秒(由lockWatchdogTimeout配置项控制),续期周期是租期的三分之一,也就是 10 秒。也就是说,持锁期间每 10 秒左右,后台会有一个任务检查这把锁还在不在、是不是自己持有,如果是,就把过期时间重新刷回 30 秒。这样只要客户端进程还活着,锁就一直有效;一旦进程挂掉,续期任务消失,最多 30 秒后锁自动释放。
这个机制解决了“业务时长不确定”的问题,但代价是客户端宕机后锁最多要等 30 秒才释放。在秒杀这种场景下,30 秒足以堆积大量请求。所以我的实践是:能预估业务时长的场景,显式传租期;实在估不准的,才交给看门狗。秒杀扣库存这种操作,正常耗时在几十毫秒量级,我把租期设成 3 到 5 秒,既覆盖了绝大多数情况,又能在异常时快速释放锁。
还有一点要提醒:看门狗的续期任务跑在 Redisson 内部的定时线程池里,如果这个线程池被阻塞或者线程数不够,续期就会延迟甚至失败,锁可能在业务没跑完时就过期了。所以不要在持锁期间做长时间的阻塞调用,比如同步 HTTP 请求、大批量数据库操作,这类操作要么挪到锁外面,要么把租期设得足够宽。
3.4 解锁脚本与并发唤醒
解锁的 Lua 逻辑是加锁的镜像:
-- KEYS[1] = 锁 key,KEYS[2] = 释放通知频道 -- ARGV[1] = 通知消息 -- ARGV[2] = 租期 -- ARGV[3] = 持有者标识 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end;第一段判断是安全阀门:如果当前线程根本没持有这把锁,hexists返回 0,脚本直接返回空,什么也不做。这就是“解铃还须系铃人”的落地——不会误删别人的锁。如果调用unlock时发现锁压根不是自己的,Redisson 会在客户端抛一个IllegalMonitorStateException,这个异常很有价值,它往往意味着你的加锁和解锁不在同一个线程上,或者锁已经因为超时被释放了。
第二段是重入计数递减,减完还大于 0 说明外层还有持有,只续期不删除。减到 0 才真正del,然后往一个频道发一条释放消息。最后那个publish就是给等待中的线程发信号,让它们别傻等超时,立刻来抢。这套“订阅 + 通知”机制比轮询优雅得多,也解释了为什么 Redisson 在锁竞争激烈时性能依然可接受。
4. 落地改造:优惠券秒杀接入分布式锁
原理讲完,进入改造环节。这一节的内容是我在项目里实际跑通的版本,参数和写法可以直接复用,但要注意结合自己的业务量级调整。
4.1 依赖、配置与 Bean 装配
先引依赖,版本自己按工程统一管理,不建议散着写死:
<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.x.x</version> </dependency>配置走application.yml,最小化配置长这样:
spring: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 3000ms然后在配置类里声明一个RedissonClientBean。如果用的是上面那个 starter,很多情况下会自动装配,但秒杀这种场景我建议手动声明,把关键参数显式写出来,出问题时一眼能看到配置:
@Configuration public class RedissonConfig { @Value("${spring.redis.host}") private String host; @Value("${spring.redis.port}") private int port; @Bean(destroyMethod = "shutdown") public RedissonClient redissonClient() { Config config = new Config(); config.useSingleServer() .setAddress("redis://" + host + ":" + port) .setConnectionMinimumIdleSize(8) .setConnectionPoolSize(32) .setTimeout(3000); // 看门狗默认租期,单位毫秒 config.setLockWatchdogTimeout(30000); return Redisson.create(config); } }连接池那两个参数值得说一下。秒杀场景瞬时并发很高,但每个请求持锁时间极短,connectionPoolSize设到 32 左右基本够用;如果设得太小,会出现大量线程在等连接而不是等锁,表现出来的现象是接口慢但 CPU 不高,很容易误判。setTimeout是命令超时,别设太短,网络抖动时容易抛超时异常,锁状态反而更乱。
4.2 业务代码改造与事务顺序
业务层改造的核心是把原来synchronized包裹的那段逻辑,换成RLock包裹。写法上有个必须注意的顺序问题:加锁必须在事务之外。
原因很直白:Spring 的声明式事务是通过 AOP 代理织入的,事务的开启和提交发生在方法调用的最外层。如果锁加在事务方法内部,执行顺序就变成“开启事务 → 加锁 → 业务 → 解锁 → 提交事务”。解锁之后、事务提交之前,另一个线程就能拿到锁进来,此时它读到的还是未提交前的旧数据,一人一单照样被击穿。
我的处理办法是把锁加在 Controller 层,或者单独拆一个不带事务的外层方法去调内层带事务的方法:
@RestController @RequestMapping("/voucher-order") public class VoucherOrderController { @Resource private IVoucherOrderService voucherOrderService; @Resource private RedissonClient redissonClient; @PostMapping("/seckill/{id}") public Result seckill(@PathVariable("id") Long voucherId) { Long userId = UserHolder.getUser().getId(); // 锁粒度:券 + 用户,保证同一用户对同一张券串行 String lockKey = "lock:seckill:" + voucherId + ":" + userId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { // 等待 1 秒,租期 5 秒:秒杀操作耗时短,超时说明异常 locked = lock.tryLock(1, 5, TimeUnit.SECONDS); if (!locked) { return Result.fail("您点得太快了,请稍后再试"); } return voucherOrderService.seckillVoucher(voucherId, userId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail("系统繁忙"); } finally { // 必须是当前线程持有才解锁,防止误杀 if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这里有几个细节是我反复调过的。tryLock的第一个参数是等待时间,我设 1 秒——秒杀接口整体响应时间应该控制在几百毫秒,如果等锁超过 1 秒,说明系统已经很吃力了,与其让用户干等,不如直接返回友好提示。第二个参数是租期 5 秒,比正常业务耗时高一个数量级,够用且不会因为异常而长时间锁死。
finally里那两个判断也不能省。locked变量防止“加锁失败却去解锁”抛异常;isHeldByCurrentThread()防止锁已经超时释放、被别的线程拿走后,这边再去解一次,虽然 Redisson 内部有归属校验不会误删,但提前判断能避免抛IllegalMonitorStateException污染日志。
4.3 锁粒度怎么定
锁粒度是这套方案里对性能影响最大的决策,没有之一。我试过三种粒度,结论很明确:
| 锁粒度 | 示例 key | 互斥范围 | 吞吐 | 适用场景 |
|---|---|---|---|---|
| 全局锁 | lock:seckill | 所有券所有用户 | 极低 | 只适合日访问个位数的场景 |
| 券级锁 | lock:seckill:{voucherId} | 同一张券所有用户 | 中 | 券库存很少、需要严格串行 |
| 券+用户锁 | lock:seckill:{voucherId}:{userId} | 同一用户同一张券 | 高 | 一人一单场景首选 |
全局锁的问题是显而易见的:100 张不同的券抢购时会互相阻塞,明明没有资源竞争却要排队,毫无道理。券级锁好一些,但它把同一张券的所有用户都串起来了,一张热门券可能有上万人抢,全部排队,吞吐上不去。
券+用户锁是我最终选的方案,它把互斥范围收窄到“同一个用户对同一张券”,不同用户之间完全不阻塞。这个粒度成立的前提是:库存超卖由 Redis 的 Lua 脚本保证原子性,而不是靠锁。锁只负责“一人一单”这一层业务约束。这个分工很重要,理清楚了锁的范围才能收窄;理不清就只能上全局锁,性能白白损失。
注意:锁粒度收窄之后,一定要确认底层扣减逻辑本身是原子的。锁只解决“同一用户重复下单”,解决不了“不同用户同时扣同一份库存”。两层防护是叠加关系,不是替代关系。
4.4 压测与结果核对
改造完必须验证,而且验证方式要能真正暴露并发问题。我的做法是准备 1000 个测试账号,用压测工具配置 200 并发、循环执行,打同一张库存 100 的券,跑完之后核对三件事:库存是否为 0、订单数是否为 100、是否存在重复的“用户+券”组合。
第一次跑出来的结果是库存 0、订单 100,看起来完美。但我去查重复组合时发现了 3 条重复记录——原因是某些请求在 Redis 预检阶段通过了,落库时因为并发插入导致重复。这个问题的根子不在锁,在于我当时把锁加在了错误的位置,锁和事务的顺序反了。调整成 4.2 节那种外层加锁的结构之后,重复记录归零。
第二轮压测我把租期从 5 秒改成 1 秒,故意制造“业务没跑完锁就过期”的情况,观察系统的反应。结果如预期:出现少量IllegalMonitorStateException,订单数倒是没错,但日志被污染得很严重。这个实验的价值是让我确认了——租期不能拍脑袋定,要留足余量,并且配合监控。后来我在解锁前加了一行日志,记录持锁耗时,跑一段时间之后按 P99 定租期,比凭感觉靠谱得多。
还有一个核对项容易被漏掉:缓存与数据库的最终一致性。秒杀成功后库存先在 Redis 里扣,异步落库,压测结束要等异步队列消费完再比对,否则会看到“Redis 库存 0、数据库库存 5”的假异常,白折腾半天。
5. 影响范围:哪些接口要锁,哪些别乱加
分布式锁不是免费的,一次加锁至少两次网络往返,还要占用 Redis 连接和内存。项目里我见过有人图省事,给所有写接口都套一把锁,结果 QPS 掉了一半,收益几乎为零。所以这一节聊聊边界。
5.1 需要应用层互斥的场景清单
判断标准其实就一条:同一个业务实体在极短时间内被重复操作,且重复操作会破坏业务规则。按这个标准,项目里真正需要锁的地方并不多:
- 优惠券秒杀的一人一单校验,必须锁,这是典型场景。
- 用户余额扣减,必须锁,或者用数据库乐观锁 + 版本号替代。
- 订单状态流转(比如从“待支付”改成“已支付”),建议锁,防止重复回调把状态改乱。
- 库存回滚、退款这类补偿操作,建议锁,避免同一笔单子被补偿两次。
反过来,那些“读多写少”或者“本身幂等”的接口,加锁纯属浪费。比如商品详情查询、券列表查询,加锁只会把并发能力砍掉一大截,什么也保护不了。
5.2 不该加锁的场景与替代思路
有些场景看起来需要锁,实际上有更轻的解法。比如“防止用户重复提交表单”,与其上分布式锁,不如用 token 机制:进入页面时发一个一次性 token,提交时校验并删除,天然幂等,成本比锁低得多。再比如“统计当日领券人数”,这种计数类操作直接用 Redis 的incr就够,本身原子,根本不需要外层锁。
还有一种情况是“数据库唯一索引能解决,却非要用锁解决”。一人一单这件事,最稳的兜底其实是给订单表加一个(user_id, voucher_id)的联合唯一索引。锁是第一道防线,唯一索引是最后一道。我试过把锁去掉、只留唯一索引,功能上没问题,但会有大量插入异常需要捕获处理,对用户体验不友好。所以最终方案是锁在前、唯一索引在后,两层叠加,既保证体验又保证数据绝对正确。
| 场景 | 是否加锁 | 更优解 |
|---|---|---|
| 秒杀一人一单 | 是 | 锁 + 唯一索引双保险 |
| 表单重复提交 | 否 | 一次性 token |
| 计数统计 | 否 | Redisincr |
| 订单状态流转 | 是 | 锁 + 状态机校验 |
| 商品详情查询 | 否 | 本地缓存 + Redis 缓存 |
5.3 主从架构下锁的可靠性缺口
这一点必须单独说,因为它决定了你对这套方案的信任边界。Redisson 的锁是写在单个 Redis 节点上的。如果 Redis 是主从架构,加锁成功的那一刻,数据可能还没同步到从节点;此时主节点宕机,从节点升主,新主上没有这把锁——另一个线程立刻就能拿到同一把锁,互斥性被打破。
这个窗口很小,但确实存在。业界的应对思路大致两类:一类是向多个独立主节点同时加锁,多数成功才算成功,代价是实现复杂、性能下降明显;另一类是承认这个窗口存在,在业务层做兜底,靠唯一索引和幂等设计保证即使锁失效,数据也不会错。
我在这个项目里的取舍是第二种。原因很现实:秒杀业务的数据正确性最终由数据库约束保证,锁只是提升体验、减少异常的手段,不是唯一防线。为了堵一个极小概率的窗口去上多节点加锁,复杂度和运维成本都不划算。但如果换成资金类业务,这个判断就要反过来——锁失效的后果严重程度,决定了你愿意为可靠性付多少成本。
提示:如果你的 Redis 用的是哨兵或者集群模式,务必在压测环境里模拟一次主节点切换,观察锁的行为和业务表现。这个演练比看十篇原理文章都管用,因为只有亲眼看一遍,你才知道自己的兜底逻辑到底顶不顶得住。
6. 常见问题排查实录
以下都是我在这套方案上真实撞过的问题,按出现频率排序,配排查思路。
6.1 问题速查表
| 现象 | 可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 接口大量超时 | 锁未释放,key 永久存在 | TTL查锁剩余时间,看是否为 -1 | 检查是否漏写finally,或租期未设置 |
抛IllegalMonitorStateException | 解锁线程与加锁线程不一致,或锁已超时 | 比对加锁、解锁的线程 ID | 保证同线程加解锁,租期留足余量 |
| 看门狗不续期 | tryLock显式传了租期 | 检查调用参数是否为-1 | 需要续期就用两参数版本 |
| 一人一单仍被击穿 | 锁加在事务内部,先解锁后提交 | 看锁与事务的嵌套顺序 | 锁提到事务外层 |
| 吞吐上不去,CPU 不高 | 锁粒度过粗,或连接池太小 | 看锁 key 的分布、连接池等待数 | 收窄锁粒度,扩连接池 |
| 压测无异常,上线出问题 | 压测流量打到同一节点 | 确认负载均衡策略 | 改轮询,重新压测 |
6.2 几条不写进文档的经验
第一条,锁的 key 一定要带业务前缀。我见过两个模块不约而同用了lock:{id}这种格式,id又恰好都取自用户 ID,结果两个完全不相干的业务互相阻塞,排查了半天才定位到。现在我的命名规范是lock:业务名:资源:标识,看一眼就知道是谁的锁。
第二条,别在持锁期间调用外部接口。有一次我在锁里加了个调用第三方服务的逻辑,对方响应慢,直接把我这边锁的租期跑满了,触发锁超时,后面一串请求全乱。教训是:锁的范围要尽可能小,网络调用、批量操作、日志落盘这些全部挪到锁外面。
第三条,日志里一定要打锁的等待耗时和持锁耗时。这两个指标比什么监控都直观:等待耗时突然变长,说明锁竞争加剧,可能是粒度问题;持锁耗时变长,说明业务变慢,可能是数据库或者下游拖累。我后来就是靠这两个数,发现某次慢查询把整条链路拖垮的。
第四条,测试环境务必用多实例验证。单实例下所有分布式锁问题都会被掩盖,因为根本没有跨进程竞争。我现在的习惯是本地起两个端口,前端配置轮询,任何涉及并发控制的改动都在这套环境里过一遍再提交。
第五条,别把isLocked()当成业务判断依据。这个方法返回的是“锁是否存在”,不是“是否被当前线程持有”。有人用它来判断“别人是不是在操作”,逻辑上就不成立,因为锁存在不代表持有者是你关心的那个用户。要判断归属,用isHeldByCurrentThread()。
6.3 一个值得单独说的边界:锁与 Lua 的分工
改造过程中我一度困惑:既然扣库存和一人一单判断都已经写成 Lua 脚本在 Redis 里原子执行了,那外层这把分布式锁还有必要吗?这个问题我想了很久,最后的结论是:两者解决的不是同一个层面的问题。
Lua 脚本保证的是Redis 内部的命令原子性,它确保“判断库存 + 扣库存 + 记录用户”这三步在 Redis 里不会被打断。但它管不了 Redis 之外的事情——比如订单落库、比如调用下游服务。而分布式锁保证的是应用层的业务串行,它把一整个业务流程(包括数据库操作)圈起来,让同一个用户的请求排队执行。
所以更合理的架构是:Redis Lua 做前置的快速拦截,分布式锁做业务层的串行保护,数据库唯一索引做最终兜底。三层各司其职,任何一层失效,后面还有兜底,不至于直接击穿。我后来把三层都加上之后,压测跑了两万次请求,库存、订单数、重复率三个指标全部干净,这才敢说这套方案是稳的。
如果非要简化,把 Lua 做得足够完备、业务逻辑全部塞进脚本里,理论上确实可以省掉分布式锁。但那样的代码可维护性极差,业务一变更就要重写脚本,而且脚本里没法做数据库事务。工程上我倾向于保留锁,用可读性换那一点点性能,这笔账算得过来。
最后分享一个我调参数的小方法:租期不要凭感觉,跑一轮压测把持锁耗时的 P99 打出来,租期取 P99 的三到五倍。我这个项目 P99 大概 200 毫秒,租期定 5 秒,留了二十多倍余量,看起来浪费,但换来的是几乎不会出现锁超时导致的异常日志,运维侧清净很多。真遇到极端慢请求,宁可让它超时失败,也好过锁一直被占着把整条业务线拖死。