SET NX PX · LUA · WATCHDOG · REDISSON · REDLOCK · FENCING TOKEN

好几台机器抢一把钥匙,钥匙挂在 Redis 上

一台机器里用 synchronized 就够了;部署了好几台,每台的锁只管自己。分布式锁就是把“锁”放到大家都能看到的 Redis 里:谁先写进去 key,谁就拿到锁。难的不是加锁,而是锁会过期、会丢、会被别人删,下面一个个说。

Aorder-service #1拿到了
Border-service #2在等
Corder-service #3在等
Redis 里的一个 key
SET lock:order:1001 a1b2c3 NX PX 30000
key = lock:order:1001 value = a1b2c3(A 的令牌) NX:不存在才写入,所以只有一个人能成功 PX 30000:30 秒后自动过期
锁SET key value NX PX

写进去就是拿到了,到时间自动消失,防止拿锁的人死了没人放。

加锁和设过期必须一条命令
令牌value = UUID

证明“这把锁是我的”,解锁前先核对,免得删了别人的。

核对和删除要用 Lua 一起做
看门狗watchdog

业务还没干完,就定时给锁续命;进程一死,续命也停。

Redisson 默认 30 秒,每 10 秒续一次
递增号fencing token

每次加锁领一个更大的号,存储只认更大的号,挡住过期的锁主人。

锁靠不住时的最后一道防线

为什么本地锁不够

synchronized 和 ReentrantLock 锁的是同一个 JVM 里的线程。服务部署了两台,各锁各的,两个请求照样同时进来。

只用本地锁

JVM 1 · synchronized线程 1 进入
JVM 2 · synchronized线程 7 也进入

库存 1 件,两台机器各卖出 1 件:超卖。

锁放在 Redis

JVM 1SET NX 成功,进入
JVM 2SET NX 失败,等待

大家看的是同一个 key,同一时刻只有一个人能写进去。

扣库存、防超卖定时任务多台只跑一次缓存失效后只放一个人去重建同一用户的请求串行处理防重复提交(但更推荐幂等)

一把锁的五次进化

每一版都是为了解决上一版的问题。前四版自己写也行,第五版一般直接用 Redisson。

1版
写法SETNX lock 1… 业务 …DEL lock
问题拿锁的进程崩了,没执行 DEL,锁永远不会消失:死锁
修法给锁加过期时间
2版
写法SETNX lock 1EXPIRE lock 10
问题两条命令不是原子的,两条之间崩了,还是没有过期时间
修法一条命令同时加锁和设过期
3版
写法SET lock 1 NX PX 10000DEL lock
问题业务超过 10 秒,锁过期被 B 拿走;A 干完执行 DEL,删的是 B 的锁
修法value 存自己的令牌,Lua 比对后再删
4版
写法SET lock <uuid> NX PX 10000Lua:get == uuid 才 del
问题不会误删了,但业务超时后 A 和 B 仍然同时在干;同一线程再加锁会把自己锁住
修法看门狗自动续期;用计数支持可重入
5版
写法Redisson:RLock.lock() / tryLock()
还剩的问题主从切换时锁可能丢;进程长时间停顿(比如 GC),续期也跟着停
再往下fencing token、RedLock、ZooKeeper / etcd,或业务兜底
解锁的 Lua比对和删除一起做,中间不会被别人插进来
if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0     -- 不是我的锁(可能已经过期被别人拿了),不动
end
自己写一个StringRedisTemplate
String key = "lock:order:" + orderId, token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent(key, token, Duration.ofSeconds(10));
if (!Boolean.TRUE.equals(ok)) throw new BizException("操作太频繁");
try {
    doBusiness();
} finally {
    redis.execute(UNLOCK_SCRIPT, List.of(key), token);   // 上面那段 Lua
}

事故实验台:四种写法 × 四种事故

选一种锁的写法和一种生产上真会发生的事故,看时间线上谁拿着锁、谁在临界区里。红色阴影就是两个人同时在里面。锁的过期时间都按 10 秒算。

锁的写法:
事故:

表格是全部 16 种组合的结果,点格子可以直接跳过去。最后两列里,带过期时间的写法怎么改都过不去(不设过期的第 1 种看似安全,代价是崩溃就死锁),要靠下一节的办法。

Redisson 是怎么做的

生产上大多直接用 Redisson 的 RLock。它把前面说的几件事都做了:原子加锁、令牌核对、可重入、看门狗续期、等锁时不空转。

锁是一个 Hash:谁拿着、重入了几次
lock:order:1001
TTL 30 秒
8743c9c0-…:52(客户端 ID : 线程 ID)2同一个线程又 lock() 了一次,计数加到 2;unlock() 一次减 1,减到 0 才删 key
加锁的 Lua简化版
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)   -- 没人拿,或者就是我自己:计数 +1
    redis.call('pexpire', KEYS[1], ARGV[1])       -- 重新设过期时间
    return nil                                    -- 成功
end
return redis.call('pttl', KEYS[1])            -- 失败:告诉我还要多久才过期
拿不到时怎么等

不是 while 循环一直问。客户端订阅一个频道 redisson_lock__channel:{lock:order:1001},然后睡着;持锁的人解锁时往频道里发一条消息,等的人被叫醒再去抢。最多等到上面返回的剩余时间。

看门狗什么时候有

调用 lock() 或 tryLock(waitTime, unit),不传 leaseTime 时才有。传了 leaseTime,到点就过期,不续期。

看门狗:默认 30 秒的锁,每 10 秒续回 30 秒

续期时间是 lockWatchdogTimeout(默认 30000 毫秒)的三分之一。续期也是一段 Lua:先确认锁还是自己的,再重设过期时间。进程挂了,续期任务跟着没了,锁最多 30 秒后释放。

推荐写法Redisson
RLock lock = redisson.getLock("lock:order:" + orderId);   // 按业务 ID 加锁,别一把锁锁全部
if (!lock.tryLock(3, TimeUnit.SECONDS)) {               // 最多等 3 秒;不传 leaseTime,有看门狗
    throw new BizException("系统繁忙,请稍后再试");
}
try {
    orderService.create(orderId);                           // 带 @Transactional 的方法,在另一个 Bean 里
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();      // 锁可能已经过期,先确认是不是自己的
}

生产上的经典问题和解法

锁本身写对了,问题往往出在它和事务、主从、业务的配合上。

问题一:锁在事务里面,先解锁后提交,照样超卖

在 @Transactional 方法里加锁、解锁,finally 里的 unlock 先执行,事务由外面的代理在方法返回后才提交(Spring 第 02 篇)。解锁到提交之间,B 拿到锁,读到的还是没提交前的旧库存。

错:锁在事务里面

事务(代理开启)
锁查库存 = 1扣减,写入 0(还没提交)
已解锁,还没提交:B 拿到锁,读到库存仍是 1 提交

对:锁包住整个事务

锁
事务(另一个 Bean 的 @Transactional 方法)查库存 = 1扣减,写入 0提交
解锁:B 进来时读到的是 0

问题二:主从切换,锁丢了

A 在主节点上加了锁,还没复制到从节点,主节点就挂了。从节点升为主,上面没有这把锁,B 也加锁成功(复制是异步的,见 Redis 第 03 篇)。有三条路:

RedLock:找 5 个独立的 Redis 投票
R1成功R2成功R3成功R4超时R5已被占

依次向 5 个互相独立的主节点加锁,超过半数(3 个)成功、并且总耗时小于锁的过期时间,才算拿到。锁的有效时间要扣掉加锁花的时间和时钟误差。

争议很大:它依赖各台机器的时钟和“停顿不会太久”的假设,碰上 GC 停顿、时钟跳变照样会出错。Redisson 已经把 RedissonRedLock 标为过时。

fencing token:让存储来把关
A加锁,拿到号 33;然后停顿或断网
B锁过期后加锁,拿到号 34,写入时带上 34
A醒来写入,带的是 33 < 34,存储拒绝

号要从一个强一致的地方来,比如 ZooKeeper 的 zxid、etcd 的 revision;存储要能比较号的大小,比如数据库里加一个 version 条件:UPDATE … WHERE id = ? AND token < 34。

先分清是哪种锁效率锁:偶尔两个人同时进来,最多多干一次活、多发一条通知,可以接受。用单个 Redis 的锁就够了,业务上再加唯一索引、幂等兜底。正确性锁:两个人同时进来就会出错账。别只靠 Redis,用 ZooKeeper / etcd 的锁,配合 fencing token,或者直接用数据库的行锁和唯一约束。

问题三:秒杀热点,一把锁成了瓶颈

一件爆款商品,所有人都在抢同一把锁,同一时刻只有一个请求在扣库存,再多机器也没用。

做法怎么做好处代价
分布式锁锁商品拿锁 → 查库存 → 扣减 → 解锁好理解同一商品完全串行,吞吐最低
Redis Lua 原子扣减一段 Lua 里判断库存并 DECR,成功了再异步下单不用锁,单个分片每秒几万次Redis 和数据库要对账
数据库条件更新UPDATE stock SET n = n - 1 WHERE id = ? AND n > 0,看影响行数不会超卖,不用分布式锁热点行的行锁排队(数据库第 02 篇)
库存分段100 件拆成 10 段,每段一把锁,请求随机选一段并发提高到 10 倍某段卖完要换段,实现复杂
问题四:定时任务部署了 3 台,同一个任务跑了 3 次

用分布式锁让同一时刻只有一台在跑。注意任务很快就结束时,另外两台可能因为时钟差一点点又拿到锁再跑一遍。ShedLock 这类库提供“至少锁多久”(lockAtLeastFor)和“最多锁多久”(lockAtMostFor)来解决。

问题五:用锁防重复提交,还是有重复订单

锁只管“同一时刻”,锁一释放,重复的请求照样进来。防重复靠幂等:前端带一个请求号,后端用唯一索引或 SET 请求号 1 NX EX 600 记下来,第二次直接返回第一次的结果。

几种分布式锁对比

Redis 锁快,但不保证绝对正确;ZooKeeper 和 etcd 慢一些,但它们的数据是强一致的。

方案怎么加锁持锁人挂了锁会不会丢性能适合
Redis(Redisson)SET NX + 过期时间,Hash 计数过期自动释放主从切换时可能丢很高效率锁,绝大多数业务
RedLock5 个独立 Redis 多数派过期自动释放依赖时钟,有争议较高很少用
ZooKeeper临时顺序节点,监听前一个节点会话断开,节点自动删除不会,强一致中正确性锁
etcd租约 + 事务比较版本号租约到期自动删除不会,强一致;版本号可当 fencing token中正确性锁,云原生环境
数据库唯一索引插入一行,或 SELECT … FOR UPDATE要自己清理,或靠连接断开回滚跟数据库一样可靠低并发不高、不想多引入组件

JDK 8 和 JDK 17

锁的原理不变。要注意 Redisson 的版本搭配,以及新的线程模型下“哪个线程持有锁”。

Spring Boot 2

JDK 8+
  • Redisson 3.x 支持 JDK 8,用 redisson-spring-boot-starter,它会读 spring.redis.*
  • 自己写的锁用 StringRedisTemplate.setIfAbsent(key, value, Duration),Spring Data Redis 2.1 起有这个重载
  • Spring Integration 的 RedisLockRegistry 也能用,但默认没有看门狗

Spring Boot 3

JDK 17+
  • Redisson starter 要用和 Spring Boot 3 匹配的版本,配置读 spring.data.redis.*
  • Redisson 的锁属于“客户端 ID + 线程 ID”。响应式或 CompletableFuture 代码里,加锁和解锁可能不在同一个线程,会报错;用带 threadId 参数的异步方法
  • JDK 21 虚拟线程下,每个虚拟线程有自己的 ID,可重入照常;但拿着锁时别做长时间阻塞,否则看门狗一直续,别人一直等
没变的:SET NX PX令牌 + Lua 解锁看门狗续期主从切换会丢锁锁要包住事务

常踩的坑

分布式锁的问题,几乎都是“锁以为自己还在,其实已经不在了”。

某台机器重启后,这个功能再也用不了

加锁和设过期分成了两条命令,中间崩了,锁没有过期时间。一定用 SET key value NX PX 一条命令。

偶尔有两个请求同时进了临界区

锁过期后被别人拿走,自己干完又把别人的锁删了。value 存令牌,解锁用 Lua 比对后再删。

用了 Redisson,锁还是在业务中途过期了

调用时传了 leaseTime,比如 lock(10, SECONDS),传了就没有看门狗。业务时间不确定时,别传 leaseTime。

加了锁还是超卖

锁在 @Transactional 方法里面,解锁比提交早。把锁放到事务外层,或者让带事务的方法在另一个 Bean 里被调用。

IllegalMonitorStateException: attempt to unlock lock, not locked by current thread

解锁时锁已经过期了,或者在别的线程里解锁。finally 里先判断 isHeldByCurrentThread(),并查清为什么业务比锁还长。

高峰期接口全部超时

用了 lock() 无限等待,一个慢请求拿着锁,后面的线程全卡在等锁上,线程池被耗光。用 tryLock(等待时间, 单位),等不到就快速失败。

加了锁,整个下单接口的吞吐掉到几十

锁的粒度太粗,比如所有订单共用一把 lock:order。按业务 ID 加锁:lock:order:{订单号}、lock:sku:{商品号}。

拿不到锁就 while 循环 + sleep,Redis 的 QPS 被打高

几百个线程不停地 SET NX。用 Redisson,它靠发布订阅通知等锁的人,不用空转。

Redis 主从切换那一下,出了两张一样的单

锁在切换时丢了。效率锁可以接受,再用唯一索引兜底;正确性要求高的,改用 ZooKeeper / etcd 加 fencing token。

用了锁还是有重复提交

锁释放后,重复的请求照样能进来。防重复要靠幂等:请求号 + 唯一索引,或者把处理结果记下来直接返回。