写在前面:说实话,分布式锁这玩意儿,十个项目有九个实现得有问题。我见过太多人用Redis SETNX搞了个"假分布式锁",业务还没跑完锁就过期了,两个线程同时进临界区,数据直接乱套。Redisson的看门狗机制就是来解决这个问题的,今天咱们把它扒个底朝天。这个坑我踩过,希望你别踩。

在这里插入图片描述


一、分布式锁的基本问题

1.1 为什么需要分布式锁?

单体应用里,synchronizedReentrantLock用得飞起。但一到分布式环境,这些本地锁直接失效。

想象一下这个场景:

用户下单减库存
    |
    +---> 订单服务A(实例1): 查询库存=10
    |                           |
    +---> 订单服务B(实例2): 查询库存=10  <-- 同时!
                                |
                           都看到库存=10
                                |
                           都减1,结果库存=9
                                |
                           实际应该=8(超卖了!)

这就是典型的竞态条件。本地锁只能锁单个JVM,跨进程完全没用。

1.2 分布式锁的四个要求

一个靠谱的分布式锁,必须满足这四点:

要求 说明 不满足的后果
互斥 同一时间只有一个客户端能持有锁 多个线程同时执行临界区代码
防死锁 锁必须有过期时间,防止客户端崩溃后锁永远不解 其他线程永远拿不到锁
可重入 同一个线程可以多次获取同一把锁 自己把自己锁死
高性能 获取和释放锁的操作要快 成为系统瓶颈

1.3 基于Redis的SETNX实现及其问题

最基础的实现长这样:

// 获取锁
Boolean locked = jedis.setnx("lock:order:123", "1");
if (locked) {
    // 设置过期时间
    jedis.expire("lock:order:123", 30);
    try {
        // 执行业务逻辑
        doBusiness();
    } finally {
        // 释放锁
        jedis.del("lock:order:123");
    }
}

看着没问题?实际上漏洞百出:

  1. SETNX和EXPIRE不是原子的:如果SETNX成功但EXPIRE执行前服务挂了,锁就永远不过期
  2. 过期时间难以预估:业务执行时间不确定,设太短了业务没跑完锁就过期,设太长了死锁恢复慢
  3. 误删锁:A线程的锁过期了,B线程获取了锁,A线程执行完把B线程的锁删了

踩坑提醒:这个坑我踩过!曾经设了10秒过期时间,结果一个慢SQL跑了15秒,锁提前过期,另一个线程进来,两个线程同时操作同一条数据,最后数据直接乱套。分布式锁的过期时间,真不是随便设的!


二、Redisson分布式锁概述

2.1 Redisson是什么?

Redisson是一个基于Redis的Java驻内存数据网格(In-Memory Data Grid)。

说白了,它不只是Redis客户端,还提供了一堆分布式Java常用数据结构:

  • 分布式锁(Lock)
  • 分布式集合(Map、Set、List)
  • 分布式对象(Object、Bucket)
  • 分布式服务(ExecutorService、Scheduler)

2.2 RedissonLock的核心API

用起来和本地锁几乎一样:

// 获取Redisson客户端
RedissonClient redisson = Redisson.create(config);

// 获取锁对象
RLock lock = redisson.getLock("orderLock");

// 加锁(阻塞等待)
lock.lock();
try {
    // 执行业务逻辑
    processOrder();
} finally {
    // 释放锁
    lock.unlock();
}

// 尝试加锁(非阻塞)
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (locked) {
    try {
        doSomething();
    } finally {
        lock.unlock();
    }
}

2.3 与Jedis/Lettuce的区别

特性 Jedis Lettuce Redisson
连接方式 阻塞IO 非阻塞IO(Netty) 非阻塞IO(Netty)
分布式锁 需自己实现 需自己实现 内置完善实现
数据结构 基础Redis命令 基础Redis命令 高级分布式数据结构
看门狗机制
可重入 需自己实现 需自己实现 内置支持
适用场景 简单操作 高性能基础操作 分布式系统开发

三、看门狗续期机制原理

3.1 核心设计思想

Redisson的看门狗(Watch Dog)机制,核心就一句话:

加锁时不指定过期时间,Redisson自动设置30秒过期,然后启动一个后台线程,每隔10秒检查一次,如果业务还没执行完,就把锁的过期时间重新设为30秒。

这个设计真的很妙。它解决了SETNX方案最大的痛点——过期时间难以预估

3.2 完整流程

用流程描述一下:

1. 线程A调用 lock.lock()
   |
   v
2. Redisson向Redis写入Hash结构:
   KEY: orderLock
   FIELD: {UUID}:threadId
   VALUE: 1(重入次数)
   EXPIRE: 30秒
   |
   v
3. 启动看门狗线程(TimerTask)
   每隔 30/3 = 10秒 执行一次
   |
   v
4. 线程A执行业务逻辑
   |
   v
5. 看门狗第1次检查(10秒后)
   如果锁还被线程A持有 -> 续期到30秒
   |
   v
6. 看门狗第2次检查(20秒后)
   如果锁还被线程A持有 -> 续期到30秒
   |
   v
7. 线程A执行完毕,调用 unlock()
   |
   v
8. 删除Redis中的锁
   取消看门狗定时任务

3.3 关键参数

参数 默认值 说明
lockWatchdogTimeout 30000ms 锁的过期时间
续期间隔 10000ms lockWatchdogTimeout / 3
续期时机 剩余时间 < 10000ms 定时任务触发时判断

类比一下:就像你租了个房子,合同30天到期。但Redisson是个贴心的中介,每隔10天就问你一次"还住吗?",你说住,他就自动帮你续签30天。你搬走了(unlock),他就不问了。


四、看门狗源码解析

4.1 RedissonLock.lock()方法流程

// RedissonLock.java
@Override
public void lock() {
    try {
        // 调用重载方法,leaseTime=-1表示使用看门狗
        lock(-1, null, false);
    } catch (InterruptedException e) {
        throw new IllegalStateException("Interrupted", e);
    }
}

// 核心加锁逻辑
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) 
        throws InterruptedException {
    // 1. 尝试获取锁
    Long ttl = tryAcquire(-1, leaseTime, unit, Thread.currentThread().getId());
    
    // 2. 获取成功,ttl为null
    if (ttl == null) {
        return;
    }
    
    // 3. 获取失败,订阅锁释放通知(阻塞等待)
    // ... 省略订阅和重试逻辑
}

4.2 tryAcquire获取锁

// 尝试获取锁,返回剩余过期时间(null表示获取成功)
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}

// 异步获取锁
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, 
        TimeUnit unit, long threadId) {
    
    // 关键点:leaseTime == -1 时启用看门狗
    if (leaseTime != -1) {
        // 用户指定了过期时间,不用看门狗
        return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    
    // 使用看门狗:先尝试获取锁,内部默认30秒过期
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime,
            commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
            TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    
    // 加锁成功后,注册看门狗续期
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e != null) {
            return;
        }
        
        // ttlRemaining == null 表示加锁成功
        if (ttlRemaining == null) {
            // 启动看门狗续期!
            scheduleExpirationRenewal(threadId);
        }
    });
    
    return ttlRemainingFuture;
}

4.3 renewExpiration续期逻辑

这是看门狗的核心!

// 定时续期任务
private void renewExpiration() {
    // 从内部存储中获取过期条目
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return;
    }
    
    // 创建定时任务,延迟 lockWatchdogTimeout / 3 后执行(默认10秒)
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            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) {
                    // 续期异常,移除条目
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                
                if (res) {
                    // 续期成功,递归调用,继续下一次续期
                    renewExpiration();
                } else {
                    // 续期失败(锁已被释放或易主),停止续期
                    cancelExpirationRenewal(null);
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);  // 默认10秒
    
    ee.setTimeout(task);
}

4.4 renewExpirationAsync续期Lua脚本

// 续期的核心:执行Lua脚本,原子性地延长过期时间
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
        // Lua脚本:如果锁还被当前线程持有,就续期
        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
            "redis.call('pexpire', KEYS[1], ARGV[1]); " +  // 重置过期时间为30秒
            "return 1; " +
        "end; " +
        "return 0;",
        Collections.singletonList(getRawName()),  // KEYS[1]: 锁的key
        internalLockLeaseTime,                     // ARGV[1]: 30秒(毫秒)
        getLockName(threadId)                      // ARGV[2]: {UUID}:threadId
    );
}

4.5 加锁的Lua脚本

-- 加锁核心Lua脚本(原子操作)
if (redis.call('exists', KEYS[1]) == 0) then
    -- 锁不存在,直接获取
    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
    -- 锁存在且是当前线程持有(可重入)
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;

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

4.6 解锁的Lua脚本

// unlock方法最终调用
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
        // 解锁Lua脚本
        "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
            "return nil;" +  // 不是当前线程持有的锁,不能释放
        "end; " +
        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +  // 重入次数减1
        "if (counter > 0) then " +
            "redis.call('pexpire', KEYS[1], ARGV[2]); " +  // 还有重入,重置过期时间
            "return 0; " +
        "else " +
            "redis.call('del', KEYS[1]); " +  // 重入次数为0,删除锁
            "redis.call('publish', KEYS[2], ARGV[1]); " +  // 发布解锁通知
            "return 1; " +
        "end; " +
        "return nil;",
        Arrays.asList(getRawName(), getChannelName()),  // KEYS[1]=锁key, KEYS[2]=频道
        LockPubSub.UNLOCK_MESSAGE,                       // ARGV[1]=解锁消息
        internalLockLeaseTime,                           // ARGV[2]=过期时间
        getLockName(threadId)                            // ARGV[3]={UUID}:threadId
    );
}

五、Redisson锁的其他特性

5.1 可重入锁实现

Redisson用Hash结构实现了可重入锁:

Redis中存储的数据结构:

KEY: "orderLock"
TYPE: Hash
FIELD: "{UUID}:threadId"   <-- 客户端ID + 线程ID
VALUE: 3                    <-- 重入次数

示例:
HGETALL orderLock
1) "58f6c4a2-5f8e-4b3c-9d1e-8a7b6c5d4e3f:37"
2) "3"

同一个线程多次调用lock(),重入次数递增;unlock()时递减,到0才真正删除锁。

// 可重入示例
RLock lock = redisson.getLock("orderLock");

lock.lock();    // 重入次数=1
try {
    lock.lock();  // 重入次数=2
    try {
        lock.lock();  // 重入次数=3
        try {
            // 业务逻辑
        } finally {
            lock.unlock();  // 重入次数=2
        }
    } finally {
        lock.unlock();  // 重入次数=1
    }
} finally {
    lock.unlock();  // 重入次数=0,删除锁
}

5.2 读写锁(RedissonReadWriteLock)

RReadWriteLock rwLock = redisson.getReadWriteLock("cacheLock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();

// 读操作(多个读线程可同时持有)
readLock.lock();
try {
    // 读取缓存
    Object data = cache.get(key);
} finally {
    readLock.unlock();
}

// 写操作(独占)
writeLock.lock();
try {
    // 更新缓存
    cache.put(key, newData);
} finally {
    writeLock.unlock();
}

读写锁规则

  • 读 + 读:不互斥,可以同时进行
  • 读 + 写:互斥
  • 写 + 写:互斥
  • 写 + 读:互斥

5.3 红锁(RedLock)原理与争议

RedLock是Redis作者antirez提出的多节点锁算法:

// RedLock使用示例
RLock lock1 = redisson1.getLock("lock1");
RLock lock2 = redisson2.getLock("lock2");
RLock lock3 = redisson3.getLock("lock3");

RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
    redLock.lock();
    // 执行业务
} finally {
    redLock.unlock();
}

原理:向N个独立的Redis节点申请锁,如果成功获取超过半数(N/2+1)节点的锁,且总耗时小于锁的过期时间,则认为加锁成功。

争议

Martin Kleppmann(《 Designing Data-Intensive Applications》作者)曾发文质疑RedLock的可靠性,主要观点:

  1. 时钟漂移问题:如果节点时钟不同步,可能导致锁提前过期
  2. 网络延迟:获取锁的过程中,某个节点可能已经故障
  3. 不如直接用Zookeeper或etcd的分布式锁可靠

我的看法:RedLock增加了复杂度,但并不能完全解决分布式锁的根本问题。一般业务用单节点Redisson锁就够了,金融级场景考虑Zookeeper。

5.4 公平锁 vs 非公平锁

// 非公平锁(默认)
RLock unfairLock = redisson.getLock("unfairLock");

// 公平锁
RLock fairLock = redisson.getFairLock("fairLock");
特性 非公平锁 公平锁
获取顺序 随机,先到的可能后拿到 按请求顺序获取
性能 高(无额外开销) 低(需要维护队列)
饥饿问题 可能存在 不存在
适用场景 大多数业务场景 对公平性要求高的场景

公平锁的实现原理:每个线程加锁前先到一个FIFO队列排队,按顺序获取锁。


六、问题与解答

Q1: 看门狗续期时如果Redis挂了怎么办?

A: 这是个好问题。分两种情况:

  1. Redis主从切换期间:如果Redis主节点挂了,看门狗向从节点续期。但如果是异步复制,从节点可能还没有收到最新的锁数据,续期会失败。Redisson会抛出异常,业务线程感知到后可以做降级处理。

  2. Redis完全不可用:看门狗续期失败,锁会在30秒后自动过期。业务线程如果还在执行,可能会失去锁的保护。这时候需要在业务层面做幂等或乐观锁兜底。

// 建议:业务层面加兜底
lock.lock();
try {
    // 1. 先查状态,确认可以执行
    Order order = orderDao.selectById(orderId);
    if (!"PENDING".equals(order.getStatus())) {
        return;  // 已经处理过了,幂等
    }
    
    // 2. 执行业务
    processOrder();
    
    // 3. 更新状态(乐观锁)
    orderDao.updateStatus(orderId, "PROCESSING", "PENDING");
} finally {
    lock.unlock();
}

Q2: 业务执行时间超过30秒会怎样?

A: 不会怎样,看门狗会一直续期。

只要业务线程还在执行,没有调用unlock(),看门狗每隔10秒就会续期一次。锁永远不会过期。

但要注意:如果业务线程卡死了(比如死循环),看门狗也会一直续期,锁永远不会释放。这就是活锁问题。

建议:

  • 业务逻辑加超时控制
  • 监控锁的持有时间,异常告警

Q3: 看门狗和手动设置过期时间哪个更好?

A: 看场景:

场景 推荐方式 原因
业务执行时间不确定 看门狗 自动续期,不用担心业务没跑完锁就过期
业务执行时间确定且很短 手动设置 减少看门狗线程开销
对性能极度敏感 手动设置 省去定时任务调度开销
// 手动设置过期时间(不用看门狗)
lock.lock(10, TimeUnit.SECONDS);  // 10秒后自动过期,不续期

七、面试高频考点汇总

考点1:看门狗续期机制的原理是什么?

答案

  1. 加锁时不指定过期时间,Redisson默认设置30秒过期
  2. 加锁成功后,启动一个Netty Timer定时任务
  3. 每隔10秒(lockWatchdogTimeout / 3)检查一次
  4. 如果锁还被当前线程持有,执行Lua脚本将过期时间重置为30秒
  5. 业务执行完调用unlock(),删除锁并取消定时任务

考点2:Redisson可重入锁是怎么实现的?

答案

Redisson使用Redis的Hash结构存储锁信息:

  • KEY:锁的名称
  • FIELD{UUID}:{threadId},标识客户端和线程
  • VALUE:重入次数

加锁时用HINCRBY增加重入次数,解锁时用HINCRBY ... -1减少。当重入次数减到0时,用DEL删除锁。

考点3:RedLock有什么争议?

答案

RedLock需要向多个独立Redis节点申请锁,超过半数成功才算获取锁。

争议点:

  1. 时钟漂移:节点时钟不同步可能导致锁判断错误
  2. 网络延迟:获取锁过程中节点可能故障
  3. 复杂度:多节点增加了系统复杂度和故障点

Martin Kleppmann认为RedLock不如Zookeeper可靠,Redis作者antirez则坚持RedLock在特定场景下可用。

考点4:读写锁的使用场景和规则?

答案

Redisson的读写锁(RReadWriteLock)遵循:

  • 读读不互斥
  • 读写互斥
  • 写写互斥

适用场景:缓存系统。读操作远多于写操作,用读写锁可以提高并发度。

考点5:公平锁和非公平锁的区别?

答案

  • 非公平锁(默认):线程获取锁的顺序是随机的,先请求的不一定先拿到。性能好,但可能有线程饥饿。
  • 公平锁:线程按请求顺序排队获取锁。性能稍差,但保证了公平性,不会有饥饿。

八、模拟面试官提问

场景题1:设计一个分布式锁

面试官:如果不用Redisson,让你基于Redis设计一个分布式锁,你会怎么设计?

参考答案

  1. 加锁:用SET key value NX EX 30原子命令,NX保证不存在才设置,EX设置过期时间
  2. 防误删:value用UUID+线程ID,解锁时先判断是不是自己加的锁
  3. 可重入:用Hash结构记录重入次数
  4. 续期:启动守护线程,每隔10秒检查,如果锁还在就续期
  5. 阻塞等待:用Redis的Pub/Sub或轮询实现阻塞获取
// 简化版实现
public class SimpleRedisLock {
    private String lockKey;
    private String lockValue;
    private Jedis jedis;
    
    public boolean lock() {
        // SET key value NX EX 30
        String result = jedis.set(lockKey, lockValue, "NX", "EX", 30);
        if ("OK".equals(result)) {
            // 启动续期线程
            startRenewThread();
            return true;
        }
        return false;
    }
    
    public void unlock() {
        // Lua脚本原子判断+删除
        String script = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(script, Collections.singletonList(lockKey), 
            Collections.singletonList(lockValue));
        stopRenewThread();
    }
}

场景题2:Redisson高可用

面试官:Redis是单点的,如果挂了分布式锁不就失效了吗?

参考答案

  1. Redis主从+哨兵:Redisson支持哨兵模式,主节点挂了自动切换到从节点
  2. RedLock多节点:向多个Redis节点申请锁,超过半数成功才算获取
  3. 业务兜底:锁只是辅助,业务层面加幂等和乐观锁
  4. 监控告警:Redis健康状态监控,提前发现问题

但要注意:Redis主从切换是异步的,极端情况下可能丢数据。对一致性要求极高的场景,建议用Zookeeper或etcd。

场景题3:锁过期时间设置

面试官:如果手动设置过期时间,10秒够不够?怎么评估?

参考答案

评估方法:

  1. 压测:统计业务逻辑P99执行时间
  2. 设置:过期时间 = P99 * 3(留足余量)
  3. 监控:实际持有时间监控,超过阈值告警

但说实话,手动评估很难准确。业务逻辑会变,数据量会增长,今天的P99不代表明天的P99。

这就是看门狗的价值——让锁的过期时间自适应业务执行时间

场景题4:可重入锁应用场景

面试官:什么情况下会用到可重入锁?举个例子。

参考答案

典型场景:递归调用嵌套调用

比如订单处理服务:

public class OrderService {
    @Autowired
    private PaymentService paymentService;
    
    public void processOrder(Long orderId) {
        RLock lock = redisson.getLock("order:" + orderId);
        lock.lock();
        try {
            // 处理订单
            validateOrder(orderId);
            
            // 调用支付服务,支付服务里也需要获取同一把锁
            paymentService.pay(orderId);  // 内部也会lock.lock()
            
        } finally {
            lock.unlock();
        }
    }
}

public class PaymentService {
    public void pay(Long orderId) {
        RLock lock = redisson.getLock("order:" + orderId);
        lock.lock();  // 可重入,不会死锁
        try {
            // 处理支付
        } finally {
            lock.unlock();
        }
    }
}

如果没有可重入,同一个线程调用pay()时会把自己锁死。

场景题5:RedLock是否可靠

面试官:你们系统用RedLock了吗?你觉得RedLock可靠吗?

参考答案

我们系统用的是单节点Redisson锁+业务兜底,没有用RedLock。

RedLock的问题:

  1. 时钟依赖:假设各节点时钟同步,但现实中时钟漂移很常见
  2. 网络分区:获取锁的过程中可能发生网络分区
  3. 故障恢复:如果某个节点挂了重启,可能丢失锁信息

我的观点:

  • 一般业务场景:单节点Redis锁 + 业务幂等就够了
  • 高一致性场景:用Zookeeper(ZAB协议保证一致性)
  • RedLock的复杂度收益比不高,除非真的有N个独立Redis节点的环境

九、互动话题

你在生产环境中用过Redisson吗?有没有遇到过锁过期但业务还没执行完的情况?你是怎么处理的?欢迎在评论区分享你的经验!


参考资料

  1. Redis分布式锁的正确实现方式
  2. How to do distributed locking
  3. Redisson源码分析 - 看门狗机制

更多推荐