秒杀优惠券这个功能,我在不同量级的项目里前后写过三遍,每一遍栽的坑都不一样。第一次是在本地单机压测,所有用例全绿,库存数字漂亮得很;部署到两台机器之后,库存直接跑成负数。第二次学乖了,引入分布式锁,超卖确实没了,但冒出一个更诡异的现象:用户明明抢到了,页面却提示"已抢完"。第三次才把这套东西真正捋顺,也才明白优惠券秒杀这条链路上,分布式锁只是其中一环,把它当成万能药反而会踩更深的坑。这篇文章就把分布式锁这条线完整拆一遍:为什么单体应用里一个synchronized能解决的事,上了集群就得写这么多代码;手写的 Redis 锁究竟差在哪几口气;Redisson 替你补了什么、又埋了什么;以及最容易被忽略的——锁和事务的先后顺序、锁的粒度选择,这两件事比选哪个锁实现更决定成败。
1. 从"一人一单"在集群下失效说起
1.1 synchronized 的护城河只有一条 JVM
先把场景摆清楚。优惠券秒杀的典型业务约束是"一人一单":同一个用户对同一张券,只能下一单成功。单机时代这个约束实现起来很轻松,把校验和下单逻辑用synchronized包一层,或者锁在用户 ID 的字符串常量上,问题就解决了。原因在于synchronized的互斥范围是当前 JVM 内的对象监视器,只要所有线程都在同一个进程里,监视器就是同一个,互斥成立。
但这个前提一旦被打破,整个方案就崩了。项目从一台机器扩到两台,前面挂了个负载均衡,同一个用户的两次请求可能分别落到 A 机器和 B 机器上。这两次请求各自在自己进程里竞争各自的对象锁,谁都锁不住谁,两个线程同时通过了"是否已下单"的校验,于是两条订单记录、两次库存扣减。这就是超卖的经典成因,跟代码写得好不好没关系,是锁的作用域和业务的作用域不匹配。
判断要不要用分布式锁,只需要问一句:互斥的临界区是否可能被多个进程同时进入。只要答案可能是"是",本地锁就一律不算数。
很多人在这一步会尝试一个更"聪明"的办法:把库存校验和扣减写成一条 SQL,update voucher set stock = stock - 1 where id = ? and stock > 0,靠数据库行锁保证原子性。这个思路本身没错,行锁确实是跨进程的,但它在秒杀场景下有两个硬伤:一是热点行会被大量并发请求堵在同一把行锁上,连接池迅速耗尽;二是它只能保证"库存不超卖",管不住"一人一单"这种业务约束——你没法用一条 update 同时表达"用户没买过"和"库存够"。所以数据库方案通常只作为最后的兜底,而不是主链路。
1.2 把锁搬到进程之外:一个合格分布式锁的三个硬指标
既然锁要在进程之外共享,那就得找一个所有实例都能访问的公共组件来存放"锁状态"。常见的选择是 Redis、ZooKeeper、数据库,本项目用的是 Redis,因为秒杀链路上 Redis 本来就在,不需要额外引入组件,而且它的写入性能足够扛住秒杀流量。
但"能存状态"不等于"是一把好锁"。一个能在生产环境用的分布式锁,至少要同时满足三个条件,缺一个都会在某个特定时刻翻车:
| 指标 | 含义 | 不满足时的后果 |
|---|---|---|
| 互斥 | 同一时刻只有一个客户端能持有锁 | 直接失去锁的意义,等于没加 |
| 防死锁 | 持有者异常退出后,锁能自动释放 | 锁永久占用,业务全线阻塞,比超卖更严重 |
| 可识别持有者 | 只有加锁的那个客户端才能解锁 | 误删别人的锁,互斥被破坏且难以复现 |
第三个指标经常被忽略,但它是手写方案最容易出事的地方。举个具体例子:线程 A 加锁成功,执行到一半因为 Full GC 卡住了 12 秒,锁的过期时间只有 10 秒,锁自然过期;线程 B 趁机加锁成功开始执行;这时候 A 恢复过来,执行完 finally 里的删除逻辑——删掉的是 B 的锁。B 还在临界区里,但锁已经没了,第三个线程 C 也能进来。整个互斥机制土崩瓦解,而且这种问题在压测里几乎看不出来,只会在线上偶发。
除了这三个,还有一个工程上的隐性要求:加锁失败要有合理的等待策略。秒杀场景下大量请求同时涌进来,如果加锁失败就直接返回"系统繁忙",用户体验会很差,通常需要短暂重试或者阻塞等待一小段时间。这个能力手写起来不难,但很容易写出一个不断自旋把 Redis 打满的版本。
2. 手写 Redis 分布式锁:一场必须经历的三轮演进
我建议每个做秒杀的人都亲手把这条演进路线走一遍,哪怕最后用的是 Redisson。因为你不摔这几跤,就永远不知道 Redisson 那些看起来啰嗦的代码是在防什么。
2.1 第一版:setnx 加 del,两个动作三种死法
最直觉的实现是两个命令:SETNX加锁,DEL解锁。代码大概长这样:
public boolean tryLock(String key) { // 存在则不加,不存在则写入,返回是否写入成功 Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, "1"); return Boolean.TRUE.equals(ok); } public void unlock(String key) { redisTemplate.delete(key); }这段代码在演示环境能跑通,但它有致命伤:加锁之后没有过期时间。一旦持有锁的进程在finally之前挂掉(进程被 kill、机器宕机、甚至只是抛出Error没被捕获),这把 key 就永远留在 Redis 里,所有后续请求全部加锁失败,业务彻底停摆。这种故障比超卖可怕得多,因为超卖只是少部分数据错,死锁是整个功能不可用。
于是我加了过期时间,这是第二版:
public boolean tryLock(String key, long timeoutSec) { Boolean ok = redisTemplate.opsForValue() .setIfAbsent(key, "1", timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok); }注意这里必须用setIfAbsent(key, value, timeout, unit)这个重载,而不是先setIfAbsent再expire。前者在 Redis 里是一条SET key value NX EX timeout命令,原子执行;后者是两条命令,中间如果进程崩了,锁又变成永久的了。这个细节看起来小,但它决定了你是不是把上一版的坑真正填上了。
2.2 第二版:过期时间带来的误删,比死锁更隐蔽
加了过期时间之后,新的问题浮现出来:线程 A 执行业务耗时超过了锁的过期时间,锁自动释放,线程 B 加锁成功;A 执行完后照常调unlock,把 B 的锁删了。这就是前面说的"误删别人的锁"。
解决思路很直接:加锁的时候写入一个能标识自己身份的值(UUID + 线程 ID),解锁前先比对,是我的才删。但这里有个陷阱——比对和删除是两条命令,中间依然有窗口。比对时锁还是我的,比对完、删除前,锁刚好过期并被别人拿走,那我还是删了别人的。这类"检查后再操作"的竞态,在并发场景下永远不能靠两条命令拼,必须靠服务端原子执行。
于是就有了第三版,用 Lua 脚本把比对和删除打包成一次原子操作:
-- unlock.lua if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 endJava 侧这么调:
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>( "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class); public void unlock(String key, String token) { redisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), token); }为什么 Redis 能保证这段脚本的原子性?因为 Redis 执行命令是单线程的(指命令执行阶段),一段 Lua 脚本在被加载后会作为一个整体执行,中间不会插入其他客户端的命令。所以"读到的是我写的值"和"删除"这两步之间,外人插不进来。这是 Redis 做分布式锁的技术底座,也是为什么几乎所有成熟实现最终都落到 Lua 上。
一个容易被忽略的点:Lua 脚本里返回 0 表示"锁已经不是你的了",业务上应该记录一条告警日志。这条日志出现的频率,就是你的锁超时配置是否合理的直接指标。我在项目里把它做成一个计数指标,超过阈值就说明锁的
timeout给短了,需要调大或者改用续期方案。
2.3 第三版:锁过期与业务耗时之间的那道算术题
到第三版,互斥、防死锁、可识别持有者这三个指标基本都满足了。但还剩下一个绕不开的矛盾:锁的过期时间到底给多少。
给短了,业务没跑完锁就没了,互斥失效;给长了,进程崩溃后其他请求要白等到过期才能进来,可用性下降。这是一个典型的"两难",因为你在加锁的那一刻无法预知业务要跑多久。有人会说按压测 P99 的 3 倍来给,但秒杀这种场景,抖动是常态,某一次数据库慢查询就能把耗时从 50 毫秒拉到 3 秒。
工程上的解法有两条路:
第一条是给业务加一个硬性的执行预算。比如锁超时 10 秒,那就在业务代码里设定所有操作必须在 8 秒内完成,超时就主动放弃并回滚。这要求下游调用都设超时,实际上是个好习惯,但实现成本不低。
第二条是引入看门狗续期:起一个后台任务,在锁快过期时自动续命,只要持有锁的进程还活着,锁就不会过期;进程一死,后台任务随进程消失,锁在自然过期后释放。这条路把"预知耗时"变成"探测存活",明显更合理,但也引入了新的复杂度——续期任务本身的调度、续期失败的处理、进程假死(还在但线程卡住)怎么办。这些正是 Redisson 帮你封装掉的部分,也是它值得引入的原因。
2.4 手写版本的最后一公里:阻塞等待与可重入
还有两个能力,是我在自研锁里做到最后才发现必须补上的。
第一个是阻塞等待。原始版本加锁失败直接返回 false,前端弹"手速慢了"。但在优惠券场景,用户点击之后被拒绝的体验很差,而且真实并发中锁的持有时间往往只有几十毫秒,只要稍等一下大概率能拿到。所以需要一个带超时的自旋:每隔 50 毫秒试一次,最多等 3 秒。这里的关键是自旋间隔不能太小,否则几十个线程同时自旋会把 Redis 的 QPS 打满,反而拖慢了持锁线程的释放速度,形成负反馈。
第二个是可重入。听起来秒杀场景用不上,但实际代码里很常见:加锁的服务方法调用了另一个同样加了锁的方法,如果锁不可重入,自己就把自己锁死了。手写可重入需要用一个 Hash 结构存{持有者标识: 重入次数},加锁时判断是不是自己,是自己就把计数加一;解锁时计数减一,减到零才真正删除。这套逻辑用 Lua 写出来大概二十行,本身不难,但一旦开始写,你就等于在重新实现一个 Redisson。
所以我的建议很明确:手写版本写到第三版(UUID + Lua)就够了,用来理解原理;真正上生产,直接上 Redisson。
3. 换到 Redisson 之后,那些必须搞懂的内部机制
引入 Redisson 之后,加锁代码会短到让人心虚:
RLock lock = redissonClient.getLock("lock:order:" + userId); boolean locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return Result.fail("系统繁忙,请稍后再试"); } try { // 校验一人一单、扣减库存、创建订单 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }代码短,但底下的机制不能不懂,否则参数会配错、异常会吞掉。
3.1 加锁用的为什么是 Hash 而不是 String
Redisson 在 Redis 里存的结构是这样的:
key: lock:order:1001 value: { "3f2a...-1": 1 }外层 key 是锁名,内层是一个 Hash,field 是"客户端 ID + 线程 ID"拼成的唯一标识,value 是重入次数。加锁时执行一段 Lua:锁不存在就创建并设重入次数为 1;锁存在且 field 是自己,就把次数加一;否则返回当前锁的剩余存活时间。解锁同理,次数减到 0 才del。
这个结构解释了两件事。第一,判断锁归属的维度是"实例 + 线程",不是"用户"。同一个 JVM 里两个线程去抢一把锁,是互斥的;不同 JVM 的两个线程,标识不同,也互斥。第二,重入是靠计数实现的,所以unlock()必须和lock()成对出现,多调一次会抛IllegalMonitorStateException。我在早期代码里就犯过在 catch 块里也调了一次 unlock 的错误,结果异常路径下报错信息完全对不上根因,排查了半天。
3.2 看门狗:默认不生效,这点最容易踩
Redisson 的看门狗(watchdog)逻辑是:加锁成功后起一个定时任务,每隔leaseTime / 3(默认 30 秒锁,10 秒续一次)检查一次,如果锁还持有,就重置过期时间为 30 秒。
但有个前提条件经常被忽略:只有调用不带leaseTime参数的lock()或tryLock(waitTime, unit)时,看门狗才生效。一旦你像上面那样写成tryLock(3, TimeUnit.SECONDS),也就是显式指定了租期,看门狗直接不启动,锁到点就过期,不管业务跑没跑完。
// 看门狗生效:不指定 leaseTime,默认 30 秒,自动续期 lock.lock(); lock.tryLock(3, TimeUnit.SECONDS); // 看门狗不生效:显式指定 10 秒租期,到点必过期 lock.tryLock(3, 10, TimeUnit.SECONDS);这个差异在文档里写着,但很多人是照着某篇博客抄的参数,抄完根本不知道自己把看门狗关了。我的做法是:内部服务调用、链路耗时可控(几百毫秒)的场景,显式指定租期,短一点没关系,避免续期任务带来额外开销;涉及外部接口、耗时不可控的场景,用不指定租期的版本,让看门狗兜底。
顺带提醒一个副作用:看门狗续期是每个锁一个定时任务。如果业务里有大量细粒度锁(比如按用户 ID 加锁),高峰期会同时存在几千个定时任务,虽然开销不大,但也不是零。锁用完及时释放、避免在循环里大量创建锁对象,是必要的好习惯。
3.3 tryLock 的参数怎么定才不用背锅
tryLock(waitTime, leaseTime, unit)这三个参数,我见过太多配错的案例:
| 参数 | 含义 | 配错的典型后果 |
|---|---|---|
| waitTime | 最多等多久拿不到就放弃 | 配太大(如 30 秒):请求堆积,线程池打满 |
| leaseTime | 锁的自动过期时间 | 配太小:业务没跑完锁就没了,互斥失效 |
| unit | 时间单位 | 用错单位是低级但高频的错误 |
waitTime的取值应该和用户能忍受的等待时间对齐。秒杀按钮点下去,用户最多愿意等一两秒,所以 2 到 3 秒是合理区间。配成 30 秒意味着大量请求卡在等锁上,Tomcat 线程被占满,整个服务对无关请求也不响应了——这就是典型的"锁把服务拖死"。
leaseTime的判断标准是"业务正常耗时 + 合理余量"。我的习惯是先压测出 P99,然后乘以 3。比如下单链路 P99 是 200 毫秒,那我给 1 秒。留 5 倍余量也行,但不要留到几十秒,因为一旦进程崩溃,其他请求就得干等这么久。
还有一个细节:tryLock抛InterruptedException,方法签名上是要处理的。有些人图省事直接catch (Exception e) {}吞掉,然后继续往下走业务——这会在一把没拿到的锁上执行业务逻辑,比不加锁还危险。正确做法是捕获后恢复中断标记并立即失败返回:
boolean locked = false; try { locked = lock.tryLock(3, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail("请求被中断"); }4. 锁明明加对了,为什么还会超卖
这是我在项目里花时间最多的一段。把 Redisson 接进去、参数也调对了、压测也没问题,但做数据核对的时候,还是能查出极少数订单存在库存为负或者超卖一张的情况。查了很久,根因都不在锁上。
4.1 锁和事务的释放顺序:那个著名的"先放锁后提交"
问题代码大概是这样:
@Transactional public Result seckill(Long voucherId) { RLock lock = redissonClient.getLock("lock:order:" + UserHolder.getUserId()); if (!lock.tryLock()) return Result.fail("系统繁忙"); try { // 1. 校验一人一单 // 2. 判断库存 // 3. 扣库存、创建订单 return Result.ok(); } finally { lock.unlock(); } }看起来没毛病:锁在方法内加,在 finally 释放。但@Transactional的本质是 AOP 代理,事务的提交发生在方法返回之后、代理层收尾时。也就是说执行顺序是:
- 业务方法体执行完
finally里的lock.unlock()执行,锁释放- 代理层提交事务
第 2 步和第 3 步之间有一个窗口,虽然很短,但足够让另一个线程拿到锁、读到尚未提交的旧库存(数据库默认隔离级别下,别的事务看不到未提交的修改),校验通过,然后也去扣库存。两个事务都提交后,库存被扣了两次,超卖发生。这个问题在低并发下几乎不复现,因为它依赖那个几毫秒的窗口,属于典型的"压测压不出来、线上偶发"。
修法有三种,按推荐程度排序:
第一种,把锁提到事务外层。在 Controller 或者一个不带事务的外层服务方法里加锁,锁住之后调用带事务的内层方法,内层返回即代表事务已提交,再释放锁:
public Result seckill(Long voucherId) { RLock lock = redissonClient.getLock("lock:order:" + UserHolder.getUserId()); if (!lock.tryLock()) return Result.fail("系统繁忙"); try { // 调用事务方法,方法返回时事务已提交 return orderService.createOrder(voucherId); } finally { lock.unlock(); } }第二种,用编程式事务,把commit显式放在锁内:
TransactionStatus status = txManager.getTransaction(def); try { // 业务逻辑 txManager.commit(status); } catch (Exception e) { txManager.rollback(status); } finally { lock.unlock(); }第三种,把库存扣减改成原子 SQL 兜底,update ... where stock > 0,即使并发进来也不会扣成负数。这是最省事的兜底手段,我一般会同时加上,作为防线的最后一层。
4.2 锁的粒度:锁用户还是锁券,这道选择题决定吞吐量
另一个常见误区是锁的粒度选错。很多示例代码锁的是优惠券 ID:
RLock lock = redissonClient.getLock("lock:voucher:" + voucherId);这个粒度下,"一人一单"确实不会出错,因为同一张券的所有请求都串行化了。但代价是同一张券完全失去并发能力,一秒只能处理 N/单次耗时的请求量,剩下的全在排队或者被拒绝。热门券本来就是要被大量用户抢的,用这把锁等于自己给自己设了个瓶颈。
更合理的组合是按业务约束来:一人一单,那就锁用户 + 券的维度,不同用户之间互不影响;库存扣减的原子性,交给 Redis 预扣或者数据库的原子 update 保证,不需要用锁去串行化整张券。
String key = "lock:seckill:" + voucherId + ":" + userId;这样不同用户的请求可以完全并行,同一用户的重复请求被挡住,正好对应业务语义。库存安全由另一条独立防线负责。这是我踩了"锁整张券导致压测 QPS 只有几百"的坑之后才想明白的——锁管的是业务规则,不是数据一致性,这两件事该用不同手段解决。
如果确实需要控制总的并发扣减速度,还有分段锁的思路:把一张券的库存拆成若干个桶,每个桶一把锁,请求按用户哈希落到某个桶上。这把锁的竞争范围就降到 1/N,吞吐量提升 N 倍。但分段锁会带来"某个桶空了但总库存还有"的碎片问题,需要用额外逻辑做调剂,实现复杂度比 Redisson 高一个数量级。我的经验是:只有单券 QPS 需要上万时才值得上分段锁,普通秒杀场景用 Redisson 的细粒度锁 + Redis 预扣库存足够。
4.3 库存预扣与异步下单:锁之外的真正解法
说到最后,秒杀场景的正确姿势其实是把"抢"和"下单"拆开:
- 抢的环节在 Redis 里完成,用 Lua 脚本一次性做三件事:判断用户是否已抢过(
SISMEMBER)、判断库存是否大于零、扣减库存并记录用户(SADD+DECR)。整个脚本原子执行,天然互斥,根本不需要分布式锁。 - 下单的环节异步化,脚本执行成功后把消息丢进队列,由消费者慢慢落库。
-- seckill.lua local stock = tonumber(redis.call('get', KEYS[1])) if stock == nil or stock <= 0 then return -1 end if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then return -2 end redis.call('incrby', KEYS[1], -1) redis.call('sadd', KEYS[2], ARGV[1]) return 0这个方案的好处是彻底消除了锁竞争——所有请求都在 Redis 里被串行处理(Redis 单线程模型),互斥是天然的,既不需要加锁也不需要续期,延迟还低。而且库存和用户去重放在同一个脚本里,不会出现"扣了库存但没记录用户"的半成品状态。
那分布式锁还有没有用?有。它在这个架构里退到了第二道防线:异步消费者落库时,如果担心消息重复投递导致重复下单,可以在消费者侧对用户 + 券加锁做幂等,或者更简单——给订单表建(user_id, voucher_id)唯一索引,让数据库来做最后一道拦截。这两者都是"低频、短临界区"的场景,用 Redisson 正合适。
我在项目里的最终形态是:Redis Lua 预扣做第一道闸,消息队列削峰,消费端 Redisson 锁 + 唯一索引兜底。分布式锁没有消失,但它从"主逻辑"降级成了"补偿逻辑",这才是我认为合理的定位。
5. 怎么证明这把锁真的生效了
写完代码就跑,是分布式锁最大的敌人。因为它的失效往往是概率性的,本地开发环境几乎不可能复现。所以验证这件事必须专门设计。
5.1 单机测试为什么一定会骗你
最典型的自欺欺人是:本地起一个实例,用 50 个线程并发调接口,结果库存正确、订单数正确,于是认为锁没问题。但这个测试根本没有测到锁的价值——单实例、单 JVM 的情况下,synchronized也能过,你的 Redisson 锁和它没有区别。
要测出问题,最低要求是:同一个应用起两个端口,比如 8081 和 8082,然后用压测工具把请求同时打到两个端口上。不用 nginx 也行,直接在压测脚本里轮流请求两个地址。这样才能真正让两个独立进程去抢同一把 Redis 锁,把主从、网络、时钟这些问题暴露出来。
我的压测脚本大致是这么组织的:
# 用 ab 交替打两个实例 ab -n 2000 -c 100 http://127.0.0.1:8081/api/seckill/1 ab -n 2000 -c 100 http://127.0.0.1:8082/api/seckill/1两个命令要同时跑,而不是一前一后。前一后是顺序执行,并发度根本不够。真要做严格测试,用 JMeter 的线程组配两个 HTTP 请求默认值,或者干脆写个多线程程序,随机选端口发请求。
5.2 用数据核对代替"看起来没超卖"
压测跑完不要只看接口返回的成功率,那玩意儿会骗人。必须做数据核对,我一般查三样东西:
-- 1. 库存是否被扣成负数 SELECT stock FROM voucher WHERE id = 1; -- 2. 是否出现同一用户的重复订单 SELECT user_id, COUNT(*) FROM voucher_order WHERE voucher_id = 1 GROUP BY user_id HAVING COUNT(*) > 1; -- 3. 订单总数是否等于初始库存减去剩余库存 SELECT COUNT(*) FROM voucher_order WHERE voucher_id = 1;这三条查询分别是"超卖检查""一人一单检查""数量守恒检查"。任何一条不通过,就说明这一层的防护有漏洞。我建议把这三条做成测试用例,每次改完锁相关代码都跑一遍,而不是靠肉眼判断。
除了数据,还有一个更有用的观测指标:解锁时 Lua 脚本返回 0 的次数。这个数字代表"我去解锁的时候,锁已经不属于我了",也就是锁超时被抢走了。它大于零,说明leaseTime给短了,或者业务里有慢操作需要优化。我在生产环境把这条做成日志告警,一旦出现就去看对应链路的耗时。
5.3 主动制造故障:那些只能靠演练发现的坑
还有一类问题,正常压测永远碰不到,必须主动制造故障才能验证:
- 手动 kill 进程。在锁持有期间
kill -9掉应用,看锁是否能在过期后自动释放,新请求是否能正常进来。这一条能验证你究竟有没有把过期时间设上。 - Redis 主从切换。如果用的是主从架构,在主节点写入锁之后立刻让主节点下线,观察从节点晋升后锁是否存在。Redis 的主从复制是异步的,锁在切换瞬间有丢失的可能——这就是所谓的锁可靠性问题,后面会细说。
- Redis 短暂不可用。把 Redis 停 5 秒再启动,观察应用的表现:是熔断降级,还是全量请求卡在
tryLock上把线程池耗光。这个演练很值,因为 Redis 抖动在云环境里并不罕见。
6. 选型收口:什么时候该用 Redisson,什么时候不该
6.1 Redis 主从下的锁丢失,概率与影响要算清楚
先说清楚一个事实:基于 Redis 主从(非 RedLock、非集群多数派)的分布式锁,在故障切换的瞬间存在锁丢失的可能。原因是写入主节点的锁命令通过异步复制同步到从节点,如果主节点在复制完成前宕机,从节点晋升后就没有这把锁,另一个客户端可以再次加锁成功。
概率有多大?取决于主从复制延迟和故障频率。在正常的同机房部署下,延迟通常在毫秒级,主节点恰好在这个窗口内宕机,概率并不高。但"不高"不等于"不会",是否需要为此买单,取决于业务对超卖的容忍度:
| 业务场景 | 超卖影响 | 建议方案 |
|---|---|---|
| 优惠券、秒杀券(有限量) | 多卖几张,需要补偿或退款 | Redisson + 数据库唯一索引兜底 |
| 积分、余额扣减 | 资金损失,不可接受 | 数据库乐观锁 + 对账,慎用纯 Redis 锁 |
| 防止重复提交、重复任务 | 多跑一次,幂等可解 | Redisson 足够 |
| 缓存重建、定时任务选主 | 重复执行,浪费资源 | Redisson 足够 |
这张表的结论其实很明确:分布式锁解决的是"并发互斥",不解决"数据绝对正确"。凡是涉及钱的,最后一道防线一定要落在数据库的唯一约束或者对账上,锁只是在前面挡住绝大部分无效并发,降低数据库压力。指望一把 Redis 锁扛住资金安全,是把工程问题当成了信仰问题。
至于 RedLock(向多个独立 Redis 实例加锁,多数成功才算成功),它确实能降低单点故障导致的锁丢失概率,但代价是每次加锁要访问 N 个实例,延迟和复杂度都上升,而且社区里对它的争论一直没停。我的态度是:普通业务用单实例或主从的 Redisson 就够,真有极端要求,用 ZooKeeper 或数据库行锁更省心,不必在 RedLock 上纠缠。
6.2 一段可以直接拿去用的模板代码
最后把我自己在项目里用的模板贴出来,包含了几处容易漏的细节:
public Result seckillVoucher(Long voucherId) { Long userId = UserHolder.getUserId(); String lockKey = "lock:seckill:" + voucherId + ":" + userId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { // waitTime 3s 与前端超时对齐,leaseTime 不指定,交给看门狗续期 locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return Result.fail("当前参与人数过多,请稍后再试"); } // 事务方法调用,返回即表示已提交 return voucherOrderService.createVoucherOrder(voucherId, userId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return Result.fail("请求被中断,请重试"); } catch (Exception e) { log.error("秒杀异常 voucherId={} userId={}", voucherId, userId, e); return Result.fail("系统异常"); } finally { // 关键:判断当前线程是否持有锁,避免误放别人的锁 if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } }几处细节值得说一下。第一,lockKey里带上了userId,粒度精确到"人 + 券",不同用户互不影响。第二,isHeldByCurrentThread()这个判断不能省——如果tryLock抛异常或者没拿到锁,直接unlock会抛IllegalMonitorStateException,把真实的异常信息盖掉,排查时非常痛苦。第三,锁在外、事务在内,这是前面说的顺序问题的正确写法。第四,异常分类处理,InterruptedException单独分支,是因为它需要恢复中断标记,不属于普通业务异常。
6.3 一个我至今觉得值得的小优化
最后分享一个不太起眼但收益挺明显的改动。最早的实现里,每个请求进来都无条件去tryLock,哪怕是已经下过单的用户。结果就是同一个用户疯狂重复点击,每次都去 Redis 抢一把锁,白白消耗了 Redis 的 QPS。
后来我在加锁之前加了一层本地缓存判断:用 Caffeine 缓存最近成功下单的userId + voucherId组合,存活 5 秒。命中就直接返回"您已抢过",不进 Redis。这一层的命中率在真实流量下比我预想的高得多——秒杀期间用户的重复点击是非常集中的行为,往往几十次请求里只有一次是真的要去抢。加完之后,Redis 的 QPS 下降了一大截,而业务正确性没有受任何影响,因为缓存只是快速拒绝,没命中还是会老老实实走锁。
这个优化也说明一个道理:分布式锁的成本不只是那一次 Redis 命令,还有它带来的排队、续期、异常处理。能在这把锁之前挡掉一部分无效请求,比优化锁本身的实现收益更大。
6.4 收个尾:我踩过最深的一个坑
如果只能留一条经验,我会留这条:别把锁当成并发控制的终点。我最早的做法是给所有涉及库存和订单的方法都套上一层 Redisson 锁,以为万无一失,结果压测时 QPS 只有三位数,锁竞争让整条链路排队,最后不得不把并发相关逻辑全部重构成"Redis Lua 预扣 + 异步落库 + 消费端幂等"。
锁的位置越靠前、临界区越大,系统的吞吐就越差。真正合理的结构是:把能原子化的操作交给 Redis 脚本或数据库的一条 SQL,把分布式锁压缩到"必须跨进程互斥、且临界区极短"的那一小段上。剩下的正确性,交给唯一索引、状态机和对账去保证。这条思路调整完之后,同样的机器数量,秒杀接口的 QPS 涨了一个量级,且再没出现过库存为负的数据。