🐕 Redisson分布式锁深度剖析:看门狗到底是怎么工作的?

131 阅读10分钟

面试官:你用过Redisson吗?看门狗机制知道吗?
候选人:知道!就是自动续期...
面试官:底层怎么实现的?Lua脚本能说说吗?
候选人:😰💦(这...)

别慌!今天我们深入Redisson源码,把看门狗机制讲得明明白白!


🎬 开篇:为什么需要Redisson?

原始Redis锁的痛点

// 痛点1:需要手动设置过期时间
redisTemplate.setIfAbsent("lock", "value", 10, TimeUnit.SECONDS);

// 痛点2:如果业务执行超过10秒怎么办?
// - 设置太短:业务还没完成,锁就释放了
// - 设置太长:出异常后,锁长时间无法释放

// 痛点3:需要自己写Lua脚本释放锁
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1]...";

// 痛点4:无法实现可重入
// 痛点5:无法实现读写锁
// 痛点6:无法实现公平锁

Redisson的解决方案

// 一行代码搞定!
RLock lock = redisson.getLock("myLock");
lock.lock(); // 自动续期,自动释放,支持可重入!
try {
    // 业务逻辑,不用担心执行时间
    Thread.sleep(100000); // 即使100秒也没问题!
} finally {
    lock.unlock();
}

🔍 第一章:Redisson架构总览

核心组件

RedissonClient
    │
    ├─ RLock (普通锁)
    ├─ RReadWriteLock (读写锁)
    ├─ RSemaphore (信号量)
    ├─ RCountDownLatch (倒计数锁)
    └─ RPermitExpirableSemaphore (可过期信号量)

底层依赖:
    ├─ Netty (网络通信)
    ├─ Lua脚本 (原子操作)
    └─ 看门狗线程 (自动续期)

🎭 生活比喻:智能停车系统

传统停车场(原始Redis锁):
1. 你交10元,可以停2小时
2. 如果2小时没出来,车被锁 😰
3. 需要自己计算时间,提前续费

Redisson停车场(智能系统):
1. 你进入停车场,系统自动记录
2. 系统检测到你还在,自动续费 🎉
3. 你离开时,系统自动结算
4. 同一辆车可以多次进出(可重入)

🐕 第二章:看门狗机制深度剖析

核心原理

看门狗 = 定时任务 + 自动续期

默认参数:
- lockWatchdogTimeout = 30秒 (锁过期时间)
- 续期间隔 = 10秒 (lockWatchdogTimeout / 3)

时间轴:
T0:  获取锁,设置30秒过期
T10: 看门狗检查:业务还在执行 → 续期到T40
T20: 看门狗检查:业务还在执行 → 续期到T50
T25: 业务完成,手动unlock → 停止看门狗

💻 源码分析(简化版)

第一步:加锁时启动看门狗

// RedissonLock.java
public void lock() {
    try {
        // 尝试加锁,-1表示不设置等待时间
        lockInterruptibly(-1, null);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

private void lockInterruptibly(long leaseTime, TimeUnit unit) {
    // 1. 获取当前线程ID
    long threadId = Thread.currentThread().getId();
    
    // 2. 尝试获取锁
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    if (ttl == null) {
        // 加锁成功!
        return;
    }
    
    // 3. 加锁失败,订阅锁释放事件,等待...
}

private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) {
    if (leaseTime != -1) {
        // 如果手动指定了过期时间,直接使用,不启动看门狗
        return tryLockInner(leaseTime, unit, threadId);
    }
    
    // 4. 没有指定过期时间,使用默认的30秒,并启动看门狗!
    return tryAcquireAsync(
        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
        TimeUnit.MILLISECONDS, 
        threadId
    ).get();
}

private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (entry != null) {
        return; // 已经启动过了
    }
    
    // 创建一个定时任务
    ExpirationEntry newEntry = new ExpirationEntry();
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(
        getEntryName(), newEntry
    );
    
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        newEntry.addThreadId(threadId);
        // 🔥 核心:启动续期任务
        renewExpiration();
    }
}

第二步:看门狗续期逻辑

// RedissonLock.java
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return; // 锁已经释放了
    }
    
    // 创建一个定时任务,延迟 internalLockLeaseTime/3 后执行
    // internalLockLeaseTime默认30秒,所以是10秒后执行
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return; // 锁已经释放
            }
            
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            // 🔥 核心:执行续期操作(Lua脚本)
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("续期失败", e);
                    return;
                }
                
                if (res) {
                    // 续期成功,递归调用自己,继续下一次续期
                    renewExpiration();
                } else {
                    // 续期失败(锁已经不存在),停止续期
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

// 续期的Lua脚本
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE,
        RedisCommands.EVAL_BOOLEAN,
        // Lua脚本:如果锁存在,就续期到30秒
        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
        "    redis.call('pexpire', KEYS[1], ARGV[1]); " +
        "    return 1; " +
        "end; " +
        "return 0;",
        Collections.singletonList(getName()),
        internalLockLeaseTime, getLockName(threadId)
    );
}

第三步:释放锁时停止看门狗

// RedissonLock.java
public void unlock() {
    // 1. 执行释放锁的Lua脚本
    Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId()));
    
    if (opStatus == null) {
        throw new IllegalMonitorStateException(
            "尝试释放锁,但锁不属于当前线程"
        );
    }
    
    // 2. 🔥 停止看门狗
    cancelExpirationRenewal(Thread.currentThread().getId());
}

void cancelExpirationRenewal(Long threadId) {
    ExpirationEntry entry = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (entry == null) {
        return;
    }
    
    if (threadId != null) {
        entry.removeThreadId(threadId);
    }
    
    if (threadId == null || entry.hasNoThreads()) {
        // 取消定时任务
        Timeout timeout = entry.getTimeout();
        if (timeout != null) {
            timeout.cancel(); // 停止看门狗
        }
        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    }
}

🔥 核心Lua脚本详解

加锁脚本

-- KEYS[1]: 锁的key (例如: "myLock")
-- ARGV[1]: 过期时间 (30000毫秒)
-- ARGV[2]: 唯一标识 (UUID:threadId)

-- 检查锁是否存在
if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,创建锁(Hash结构)
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    -- 设置过期时间
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil; -- 加锁成功
end;

-- 锁存在,检查是否是自己的锁(可重入)
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;

-- 锁被别人持有,返回剩余过期时间
return redis.call('pttl', KEYS[1]);

数据结构示例

Redis中的数据:
myLock (Hash)
    └─ "UUID-001:Thread-1" → 2  (重入了2次)

命令演示:
> HGETALL myLock
1) "8d7e3c9a-280e-4c2f-8b1e-9c5f2a3b4d5e:1"
2) "2"

> TTL myLock
(integer) 25  (还剩25秒过期)

释放锁脚本

-- KEYS[1]: 锁的key
-- ARGV[1]: 释放锁的channel
-- ARGV[2]: 解锁消息
-- ARGV[3]: 唯一标识

-- 检查锁是否存在
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil; -- 锁不存在或不属于当前线程
end;

-- 锁存在,计数器-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);

if (counter > 0) then
    -- 还有重入,只减计数器,不删除锁
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 0;
else
    -- 计数器为0,删除锁
    redis.call('del', KEYS[1]);
    -- 发布锁释放消息
    redis.call('publish', ARGV[1], ARGV[2]);
    return 1;
end;

return nil;

场景演示

场景1:重入2次
myLock → "UUID:Thread-1" : 2

第1次unlock:
counter = 2 - 1 = 1
→ 不删除锁,只刷新过期时间

第2次unlock:
counter = 1 - 1 = 0
→ 删除锁,发布释放消息

🎯 第三章:Redisson的高级特性

特性1:可重入锁

RLock lock = redisson.getLock("myLock");

lock.lock();
try {
    // 第一次加锁,计数器 = 1
    System.out.println("外层业务");
    
    lock.lock(); // 可重入!
    try {
        // 第二次加锁,计数器 = 2
        System.out.println("内层业务");
    } finally {
        lock.unlock(); // 计数器 = 1
    }
    
} finally {
    lock.unlock(); // 计数器 = 0,锁被释放
}

特性2:自动续期(看门狗)

RLock lock = redisson.getLock("myLock");

// 不指定leaseTime,自动启用看门狗
lock.lock(); 
try {
    // 即使业务执行100秒,锁也不会过期!
    Thread.sleep(100000);
} finally {
    lock.unlock();
}

// 如果手动指定leaseTime,看门狗不会启动
lock.lock(10, TimeUnit.SECONDS); // 10秒后自动释放,没有续期

特性3:尝试加锁(非阻塞)

RLock lock = redisson.getLock("myLock");

// 尝试加锁,最多等待10秒,锁30秒后自动释放
boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);

if (locked) {
    try {
        // 业务逻辑
    } finally {
        lock.unlock();
    }
} else {
    // 获取锁失败,执行降级逻辑
    System.out.println("系统繁忙,请稍后再试");
}

特性4:公平锁

// 普通锁:抢占式,后来的可能先获得
RLock lock = redisson.getLock("myLock");

// 公平锁:先来先得(FIFO)
RLock fairLock = redisson.getFairLock("myFairLock");

fairLock.lock();
try {
    // 保证按请求顺序执行
} finally {
    fairLock.unlock();
}

公平锁原理

Redis中维护一个有序队列:
myFairLock:queue (ZSet)
    └─ "Thread-1" → 时间戳1
    └─ "Thread-2" → 时间戳2
    └─ "Thread-3" → 时间戳3

只有队列头部的线程能获取锁!

特性5:读写锁

RReadWriteLock rwLock = redisson.getReadWriteLock("myRWLock");

// 读锁(共享锁):多个线程可以同时持有
RLock readLock = rwLock.readLock();
readLock.lock();
try {
    // 读操作
    String data = getData();
} finally {
    readLock.unlock();
}

// 写锁(排他锁):只有一个线程可以持有
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
    // 写操作
    setData("new value");
} finally {
    writeLock.unlock();
}

💼 第四章:生产环境最佳实践

实践1:合理配置看门狗时间

Config config = new Config();
config.useSingleServer()
    .setAddress("redis://localhost:6379");

// 设置看门狗超时时间(默认30秒)
config.setLockWatchdogTimeout(30000); // 30秒

RedissonClient redisson = Redisson.create(config);

选择建议

业务执行时间 < 10秒:使用默认30秒
业务执行时间 10-30秒:设置60秒
业务执行时间 > 30秒:
  - 优先优化业务逻辑
  - 或者拆分为多个小任务

实践2:异常处理

@Service
public class SafeLockService {
    
    @Autowired
    private RedissonClient redisson;
    
    public void safeExecute(String lockKey, Runnable business) {
        RLock lock = redisson.getLock(lockKey);
        
        try {
            // 尝试加锁,最多等待10秒,锁30秒后自动释放
            boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
            
            if (!locked) {
                throw new RuntimeException("获取锁超时");
            }
            
            try {
                // 执行业务
                business.run();
            } catch (Exception e) {
                log.error("业务执行失败", e);
                throw e;
            } finally {
                // 🔥 重要:一定要在finally中释放锁
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("加锁被中断", e);
        }
    }
}

实践3:监控和告警

@Component
public class LockMonitor {
    
    @Autowired
    private RedissonClient redisson;
    
    @Scheduled(fixedRate = 60000) // 每分钟检查一次
    public void monitorLocks() {
        // 获取所有锁的key
        RKeys keys = redisson.getKeys();
        Iterable<String> lockKeys = keys.getKeysByPattern("*lock*");
        
        for (String key : lockKeys) {
            RLock lock = redisson.getLock(key);
            
            // 检查锁是否被长时间持有
            if (lock.isLocked()) {
                long remainTime = lock.remainTimeToLive();
                if (remainTime > 60000) { // 超过1分钟
                    log.warn("锁被长时间持有:key={}, remainTime={}ms", 
                        key, remainTime);
                    // 发送告警...
                }
            }
        }
    }
}

实践4:分段锁(提高并发)

@Service
public class SegmentLockService {
    
    @Autowired
    private RedissonClient redisson;
    
    private static final int SEGMENT_COUNT = 16; // 分16段
    
    /**
     * 根据productId哈希,路由到不同的锁
     */
    public void deductStock(Long productId, Integer quantity) {
        // 计算分段
        int segment = (int) (productId % SEGMENT_COUNT);
        String lockKey = "stock:segment:" + segment;
        
        RLock lock = redisson.getLock(lockKey);
        lock.lock();
        try {
            // 业务逻辑
            doDeduct(productId, quantity);
        } finally {
            lock.unlock();
        }
    }
}

效果

原来:1个锁,QPS = 1000
现在:16个锁,QPS = 16000(理想情况)

🎓 第五章:面试高分回答

问题:Redisson的看门狗机制是怎么实现的?

标准回答

"Redisson的看门狗机制是为了解决Redis锁过期时间难以设置的问题。

工作原理

  1. 当调用lock()方法不传leaseTime时,默认使用30秒过期时间,并启动看门狗
  2. 看门狗是一个定时任务,每隔10秒(lockWatchdogTimeout/3)执行一次
  3. 定时任务会检查锁是否还存在,如果存在就续期到30秒
  4. 当业务执行完,调用unlock()时,会停止看门狗定时任务

底层实现

  1. 使用Netty的HashedWheelTimer创建定时任务
  2. 续期操作通过Lua脚本保证原子性
  3. 使用ThreadLocal存储定时任务引用,释放锁时取消任务

使用建议

  • 如果业务执行时间可预估,建议手动设置leaseTime,不启用看门狗
  • 如果业务执行时间不确定,使用看门狗机制
  • 注意一定要在finally中释放锁,否则看门狗会一直续期"

常见追问

Q1:看门狗续期失败怎么办?

A:续期失败的原因:
1. Redis服务器宕机
2. 网络故障
3. 锁已经被删除(比如Redis内存不足被LRU淘汰)

处理方式:
1. Redisson会记录续期失败,但不会抛异常
2. 下次续期时如果锁不存在,会停止看门狗
3. 业务层面应该有幂等性保证,即使锁失效也不影响数据一致性

Q2:如果业务执行完忘记unlock会怎样?

A:后果:
1. 看门狗会一直续期,锁永远不会释放
2. 其他线程永远获取不到锁
3. 造成资源泄漏

防范措施:
1. 一定要在finally中unlock
2. 使用try-with-resources模式
3. 设置全局监控,检测长时间持有的锁
4. 代码review时重点检查

Redisson的保护机制:
- 如果JVM进程退出,Redis连接断开,看门狗线程自然停止
- 但如果进程没退出,只是业务线程卡住,看门狗会一直续期

Q3:Redisson的锁为什么使用Hash结构而不是String?

A:使用Hash结构的好处:
1. 支持可重入:Hash的value可以记录重入次数
2. 支持多字段:可以存储更多元数据(如线程ID、时间戳等)
3. 原子操作:HINCRBY可以原子地增减计数

数据结构对比:
String: "myLock""UUID:ThreadId"  (只能记录是否持有)
Hash: "myLock" → {"UUID:ThreadId": 2}  (可以记录重入次数)

🎁 总结

核心要点

  1. 看门狗 = 定时任务 + Lua脚本续期
  2. 默认30秒过期,每10秒续期一次
  3. 手动指定leaseTime不会启动看门狗
  4. 一定要在finally中unlock

一句话记住

Redisson的看门狗就像一个贴心的保姆,定时检查你的锁还需要吗,需要就自动续费,你离开时自动停止服务!🐕


📚 源码阅读推荐

  • org.redisson.RedissonLock - 锁的核心实现
  • org.redisson.command.CommandExecutor - 命令执行器
  • org.redisson.client.codec.Codec - 序列化编解码
  • org.redisson.connection.ConnectionManager - 连接管理

记住:理解原理比记住API更重要!🎯

祝你面试顺利!💪✨