写在前面
在上一篇《分布式锁的代价与选择:为什么我们最终拥抱了Redisson?》中,我们聊到了手写 SETNX 的"茹毛饮血"时代。既然选择了 Redisson,就意味着我们已经告别了那些让人提心吊胆的死锁噩梦。
很多时候,我们以为只是调用了一个简单的 lock.lock(),但背后其实是一整套复杂的自动续期、Lua 脚本原子执行和发布订阅机制在默默支撑。
这篇文章不讲虚的,我们从常用的 API 起手,一路通过生产环境的避坑实战,最后钻进底层数据结构与 Lua 源码里,把 Redisson 彻底扒个干干净净。
一、不仅是 Lock 这么简单:核心 API 全景
Redisson 之所以受欢迎,是因为它把分布式锁封装成了我们最熟悉的 java.util.concurrent.locks.Lock 接口风格,极大地降低了学习成本。但除了最基础的 lock(),还有核心功能是你必须掌握的。
1. 基础那把锁:RLock
这是 90% 场景下的默认选择。它对应 Redis 底层的 Hash 结构。- RLock lock = redisson.getLock("order:1001");
- lock.lock(); // 阻塞式等待,默认 30秒过期,自带看门狗
- try {
- // 业务逻辑
- } finally {
- lock.unlock();
- }
复制代码 2. 更聪明的锁:tryLock (⚡️推荐)
在实际业务中,我们往往不希望线程无限死等,浪费资源。这里有两种常见姿势:
姿势 A:要等待 + 启用看门狗 (最常用)
只指定 waitTime,不指定 leaseTime。这是既想要非阻塞(或有限等待),又想要自动续期的最佳实践。- // 参数1:wait time,我只愿意排队 3秒,拿不到就走人
- // 参数2:时间单位
- // 重点:没传 leaseTime,所以看门狗机制会自动生效!
- boolean res = lock.tryLock(3, TimeUnit.SECONDS);
- if (res) {
- try {
- // 处理业务(哪怕跑 5分钟 也不怕锁过期)
- } finally {
- lock.unlock();
- }
- } else {
- log.warn("抢锁失败,别挤了!");
- }
复制代码 姿势 B:要等待 + 自动过期 (慎用)
指定了 leaseTime,看门狗会失效。- // 参数1:wait time,排队 3秒
- // 参数2:lease time,上锁后 10秒 自动强制释放(注意:指定 leaseTime 会让看门狗失效!)
- // 参数3:时间单位
- boolean res = lock.tryLock(3, 10, TimeUnit.SECONDS);
- if (res) {
- try {
- // 处理业务,必须保证在 10秒 内完成!
- } finally {
- lock.unlock();
- }
- }
复制代码 3. 文明的排队:公平锁 FairLock
默认的锁是非公平的(Non-Fair),线程抢锁全靠 CPU 调度,谁快谁得。但如果你的业务要求"先来后到"(比如抢票排队),请务必使用公平锁。- // 内部利用 Redis 的 List(作为线程等待队列)和 Hash(作为超时记录)实现
- RLock fairLock = redisson.getFairLock("ticket:queue");
- fairLock.lock();
复制代码 4. 读多写少的神器:读写锁 ReadWriteLock
这个场景太经典了:商品详情页,读的人多(10000次/秒),改库存的人少(1次/秒)。如果全互斥,性能直接崩盘。- RReadWriteLock rwLock = redisson.getReadWriteLock("product:stock:101");
- // 读锁:多个线程可以同时加读锁,只要没有写锁
- rwLock.readLock().lock();
- // 写锁:必须等所有读锁和写锁都释放了才能加,全互斥
- rwLock.writeLock().lock();
复制代码 5. 联锁 MultiLock (原子性加多把锁)
有时候我们需要同时锁定多个资源,比如"库存"和"余额",要么都锁住,要么都不锁,防止死锁。- RLock lock1 = redisson.getLock("lock:order");
- RLock lock2 = redisson.getLock("lock:stock");
- // 同时加锁:lock1 lock2
- RedissonMultiLock lock = new RedissonMultiLock(lock1, lock2);
- lock.lock();
复制代码 二、扒开底层:Hash 结构与 Lua 脚本
以下源码基于 Redisson 3.16+ 版本(目前生产环境主流版本)分析。
Redisson 为什么能实现可重入锁?为什么它比我们自己写的 SETNX 强?
答案藏在 Redis 的数据结构里。Redisson 并没有使用简单的 String 类型,而是使用了 Hash。
1. Redis 里的样子
假设我们对 order:1001 加锁,Redis 里实际存储的数据长这样:- KEY: order:1001
- TYPE: Hash
- # hash 对应 value 内容
- {
- "UUID:ThreadID" : 1 # 锁的持有者 : 重入次数
- }
复制代码
- KEY: 锁的名字。
- FIELD (Key): UUID:ThreadId。这里由客户端生成的唯一 UUID 加上当前线程 ID 拼接而成。为什么要加 UUID? 因为不同服务器上的 JVM 进程 ID 可能一样,必须通过客户端启动时生成的 UUID(ConnectionManagerId)来唯一标识一个 Redisson 实例。
- VALUE: 1。这是重入计数器。如果同一个线程再 lock 一次,这里变成 2。
2. 加锁的 Lua 脚本
Redisson 为了保证一系列判断和写入是原子的,把它封装在 Lua 脚本里发给 Redis。- -- KEYS[1] = 锁名称
- -- ARGV[1] = 过期时间 (默认 30000ms)
- -- ARGV[2] = 锁持有者唯一ID (UUID:ThreadId)
- -- 情况 1:锁根本不存在
- if (redis.call('exists', KEYS[1]) == 0) then
- -- 创建 Hash,设置重入次数为 1
- redis.call('hincrby', KEYS[1], ARGV[2], 1);
- -- 设置过期时间
- redis.call('pexpire', KEYS[1], ARGV[1]);
- return nil; -- 返回 null 表示加锁成功
- end;
- -- 情况 2:锁存在,且持有者就是我(重入)
- if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
- -- 重入次数 +1
- redis.call('hincrby', KEYS[1], ARGV[2], 1);
- -- 重新续期
- redis.call('pexpire', KEYS[1], ARGV[1]);
- return nil;
- end;
- -- 情况 3:锁存在,但不是我
- -- 返回当前锁还剩多少毫秒过期,方便客户端等待
- return redis.call('pttl', KEYS[1]);
复制代码 这段脚本完美解释了:
- 原子性:这一大坨逻辑在 Redis 里是原子执行的,不会插队。
- 可重入:通过 hexists 判断是不是自己,是的话就 hincrby。
- 互斥性:如果既不是新锁,也不是自己的锁,直接返回剩余时间,让你可以去睡一会儿再来。
三、拆开看门狗的黑盒:源码漫游
经常听说"看门狗",它到底长什么样?
其实,它本质上是一个 HashedWheelTimer(时间轮) 驱动的定时任务。
1. 启动入口
当我们调用 lock() 不传时间时,最终会走到这里:- // RedissonLock.java
- private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
- long threadId = Thread.currentThread().getId();
- Long ttl = tryAcquire(leaseTime, unit, threadId);
- // 如果 lock 成功,ttl 会返回 null
- if (ttl == null) {
- return;
- }
-
- // 如果失败,会订阅一个 Redis Channel,等待锁释放的消息(不用死循环空转)
- // ... 省略订阅逻辑
- }
复制代码 关键在 tryAcquireAsync 里:- private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
- if (leaseTime != -1) {
- // 如果你传了时间,就按你的时间走,不启动看门狗
- return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
- }
-
- // 没传时间(leaseTime = -1)
- // 先设置默认 30秒 过期
- RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
-
- // 加锁成功后,开启续期任务
- ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
- if (e == null) {
- if (ttlRemaining == null) {
- // 重点:启动定时续期
- scheduleExpirationRenewal(threadId);
- }
- }
- });
- return ttlRemainingFuture;
- }
复制代码 2. 续期的无限套娃
scheduleExpirationRenewal 最终会调用 renewExpiration:- private void renewExpiration() {
- // 这里的 1/3 是硬编码的规则
- // 默认 lockWatchdogTimeout 是 30000ms
- // 所以每 10000ms 执行一次
- Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
- @Override
- public void run(Timeout timeout) throws Exception {
-
- // 执行 Lua 脚本,把 ttl 重新刷回 30秒
- RFuture<Boolean> future = renewExpirationAsync(threadId);
-
- future.onComplete((res, e) -> {
- if (res) {
- // 如果续期成功,这就形成了递归调用:自己调自己
- renewExpiration();
- }
- });
- }
- }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
- }
复制代码 核心逻辑总结:
- 三分之一原则:每隔锁超时时间的 1/3(默认10秒),检查一次。
- 无限递归:只要检查到锁还在,就重置过期时间,并注册下一次检查。
- 生死绑定:这个任务跑在客户端进程里,如果客户端宕机,任务停止,Redis 里的锁在 30秒 后自动过期。
四、我在生产环境踩过的坑:避坑实战
API 谁都会调,但能避开坑的才是老司机。这六个坑,都是真金白银换来的教训。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |