SET NX PX · LUA · WATCHDOG · REDISSON · REDLOCK · FENCING TOKEN
好几台机器抢一把钥匙,钥匙挂在 Redis 上
一台机器里用 synchronized 就够了;部署了好几台,每台的锁只管自己。分布式锁就是把“锁”放到大家都能看到的 Redis 里:谁先写进去 key,谁就拿到锁。难的不是加锁,而是锁会过期、会丢、会被别人删,下面一个个说。
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 里的线程。服务部署了两台,各锁各的,两个请求照样同时进来。
只用本地锁
库存 1 件,两台机器各卖出 1 件:超卖。
锁放在 Redis
大家看的是同一个 key,同一时刻只有一个人能写进去。
一把锁的五次进化
每一版都是为了解决上一版的问题。前四版自己写也行,第五版一般直接用 Redisson。
SETNX lock 1… 业务 …DEL lockSETNX lock 1EXPIRE lock 10SET lock 1 NX PX 10000DEL lockSET lock <uuid> NX PX 10000Lua:get == uuid 才 delRedisson:RLock.lock() / tryLock()if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0 -- 不是我的锁(可能已经过期被别人拿了),不动
end
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。它把前面说的几件事都做了:原子加锁、令牌核对、可重入、看门狗续期、等锁时不空转。
TTL 30 秒
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,到点就过期,不续期。
续期时间是 lockWatchdogTimeout(默认 30000 毫秒)的三分之一。续期也是一段 Lua:先确认锁还是自己的,再重设过期时间。进程挂了,续期任务跟着没了,锁最多 30 秒后释放。
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 拿到锁,读到的还是没提交前的旧库存。
错:锁在事务里面
对:锁包住整个事务
问题二:主从切换,锁丢了
A 在主节点上加了锁,还没复制到从节点,主节点就挂了。从节点升为主,上面没有这把锁,B 也加锁成功(复制是异步的,见 Redis 第 03 篇)。有三条路:
依次向 5 个互相独立的主节点加锁,超过半数(3 个)成功、并且总耗时小于锁的过期时间,才算拿到。锁的有效时间要扣掉加锁花的时间和时钟误差。
争议很大:它依赖各台机器的时钟和“停顿不会太久”的假设,碰上 GC 停顿、时钟跳变照样会出错。Redisson 已经把 RedissonRedLock 标为过时。
号要从一个强一致的地方来,比如 ZooKeeper 的 zxid、etcd 的 revision;存储要能比较号的大小,比如数据库里加一个 version 条件:UPDATE … WHERE id = ? AND token < 34。
问题三:秒杀热点,一把锁成了瓶颈
一件爆款商品,所有人都在抢同一把锁,同一时刻只有一个请求在扣库存,再多机器也没用。
| 做法 | 怎么做 | 好处 | 代价 |
|---|---|---|---|
| 分布式锁锁商品 | 拿锁 → 查库存 → 扣减 → 解锁 | 好理解 | 同一商品完全串行,吞吐最低 |
| Redis Lua 原子扣减 | 一段 Lua 里判断库存并 DECR,成功了再异步下单 | 不用锁,单个分片每秒几万次 | Redis 和数据库要对账 |
| 数据库条件更新 | UPDATE stock SET n = n - 1 WHERE id = ? AND n > 0,看影响行数 | 不会超卖,不用分布式锁 | 热点行的行锁排队(数据库第 02 篇) |
| 库存分段 | 100 件拆成 10 段,每段一把锁,请求随机选一段 | 并发提高到 10 倍 | 某段卖完要换段,实现复杂 |
用分布式锁让同一时刻只有一台在跑。注意任务很快就结束时,另外两台可能因为时钟差一点点又拿到锁再跑一遍。ShedLock 这类库提供“至少锁多久”(lockAtLeastFor)和“最多锁多久”(lockAtMostFor)来解决。
锁只管“同一时刻”,锁一释放,重复的请求照样进来。防重复靠幂等:前端带一个请求号,后端用唯一索引或 SET 请求号 1 NX EX 600 记下来,第二次直接返回第一次的结果。
几种分布式锁对比
Redis 锁快,但不保证绝对正确;ZooKeeper 和 etcd 慢一些,但它们的数据是强一致的。
| 方案 | 怎么加锁 | 持锁人挂了 | 锁会不会丢 | 性能 | 适合 |
|---|---|---|---|---|---|
| Redis(Redisson) | SET NX + 过期时间,Hash 计数 | 过期自动释放 | 主从切换时可能丢 | 很高 | 效率锁,绝大多数业务 |
| RedLock | 5 个独立 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 key value NX PX 一条命令。
锁过期后被别人拿走,自己干完又把别人的锁删了。value 存令牌,解锁用 Lua 比对后再删。
调用时传了 leaseTime,比如 lock(10, SECONDS),传了就没有看门狗。业务时间不确定时,别传 leaseTime。
锁在 @Transactional 方法里面,解锁比提交早。把锁放到事务外层,或者让带事务的方法在另一个 Bean 里被调用。
解锁时锁已经过期了,或者在别的线程里解锁。finally 里先判断 isHeldByCurrentThread(),并查清为什么业务比锁还长。
用了 lock() 无限等待,一个慢请求拿着锁,后面的线程全卡在等锁上,线程池被耗光。用 tryLock(等待时间, 单位),等不到就快速失败。
锁的粒度太粗,比如所有订单共用一把 lock:order。按业务 ID 加锁:lock:order:{订单号}、lock:sku:{商品号}。
几百个线程不停地 SET NX。用 Redisson,它靠发布订阅通知等锁的人,不用空转。
锁在切换时丢了。效率锁可以接受,再用唯一索引兜底;正确性要求高的,改用 ZooKeeper / etcd 加 fencing token。
锁释放后,重复的请求照样能进来。防重复要靠幂等:请求号 + 唯一索引,或者把处理结果记下来直接返回。