Redis 锁的“续命”艺术:看门狗机制与原子性陷阱

各位sir,咱们一步步来到了深水区,没有学游泳的抓紧啦

上回说到"为什么需要分布式锁",这一节咱们要把显微镜调到最大,直击 Redis 锁最核心、最容易翻车的两个命门:原子性续期

核心痛点:当"时间"成为你的敌人

在分布式系统中,时间是最不可靠的变量。网络延迟、Full GC、下游服务卡顿,都可能让一个原本只需 100ms 的业务逻辑,突然跑上 30 秒;这时候,如果你的锁有一个固定的"过期时间",灾难就开始了~

场景一:死锁的阴霾

你(线程 A)住进酒店(加锁),前台规定最多住 30 分钟(过期时间)。

结果你在洗澡时睡着了,睡了 40 分钟。

第 30 分钟,前台以为你走了,把房间清理了,放进了新客人(线程 B)。

第 40 分钟,你洗完出来,顺手把房门锁砸了(删除 Key),心想"我退房了"。
结局:新客人(线程 B)正洗着澡,门被你砸了,隐私泄露(数据错乱)!

"线程 A 超时 -> 锁释放 -> 线程 B 拿到锁 -> 线程 A 醒来误删 B 的锁"事故

场景二:原子性缺失

生活化比喻

你想进房间,规则是"先检查门锁,再挂上我的牌子"。

如果你分两步走:

  1. 检查门锁(SETNX)。
  2. 挂牌子设时间(EXPIRE)。

就在你刚检查完门锁(成功),还没来得及挂牌子时,你突然心脏病发作(宕机)了。
结局:门锁上了,但没人知道什么时候该开。这个房间永远被锁死(死锁),直到管理员手动清理。

复制代码
// 【反面教材】千万不要在生产环境这么写!
public void wrongLock() {
    String key = "lock:order:1001";
    String value = UUID.randomUUID().toString();
    
    // 1. 尝试加锁 (非原子!)
    Boolean success = redisTemplate.opsForValue().setIfAbsent(key, value);
    
    if (success) {
        // 2. 设置过期时间 (非原子!如果这一步之前宕机,就死锁了)
        redisTemplate.expire(key, 30, TimeUnit.SECONDS);
        
        try {
            // 业务逻辑...
            // 假设这里卡顿了 40 秒
            Thread.sleep(40000); 
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 3. 释放锁 (极度危险!如果不判断 value,可能删掉别人的锁)
            // 即使加了判断,如果 A 超时后 B 进来了,A 醒来依然可能删掉 B 的锁
            if (value.equals(redisTemplate.opsForValue().get(key))) {
                redisTemplate.delete(key); 
            }
        }
    }
}
  1. 非原子性setIfAbsentexpire 是两次网络请求。中间宕机 = 死锁。
  2. 锁超时:业务执行时间 > 30s,锁自动失效,并发冲突。
  3. 误删风险 :虽然加了 value 判断,但如果 A 超时,B 进来并设置了新的 value。A 醒来时,发现 key 还在,但 value 变了(这是好的)。但是,如果 A 在 B 进来之前就已经拿到了锁的引用,或者逻辑判断有细微漏洞,依然风险巨大。更可怕的是,如果 A 和 B 的 value 碰巧一样(概率极低但理论存在),或者逻辑顺序问题,都会导致误删。

正确姿势:Lua 脚本的原子魔法

为了解决 原子性 问题,Redis 提供了 Lua 脚本
核心原理:Redis 是单线程处理命令的,Lua 脚本一旦开始执行,中间不会插入其他客户端的命令。它将"加锁 + 设过期"打包成一个原子操作。

复制代码
-- KEYS[1]: 锁的 key
-- ARGV[1]: 唯一标识 (UUID + ThreadID)
-- ARGV[2]: 过期时间 (毫秒)

if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
    return 1
else
    return 0
end


-- 只有当锁的 value 等于当前线程的标识时,才删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end

但是! Lua 脚本解决了原子性和误删,依然解决不了"业务执行时间过长导致锁自动过期"的问题 。这就需要我们的主角登场了:看门狗 (WatchDog)

深度解析:Redisson 的看门狗机制

Redisson 是 Java 领域事实标准的 Redis 分布式锁客户端。它最核心的黑科技就是 WatchDog

1. 什么是看门狗?

看门狗是一个后台定时任务。当你调用 lock() 方法且没有指定租约时间 (leaseTime) 时,Redisson 会自动启动这个机制。

  • 默认锁时长 :30 秒 (lockWatchdogTimeout)。
  • 续期间隔 :每 10 秒 (lockWatchdogTimeout / 3) 检查一次。
  • 逻辑 :只要当前线程还持有锁,看门狗就会把锁的过期时间重置为 30 秒

生活化比喻

就像那个贴心的酒店服务员。你入住时给了 30 分钟。

过了 10 分钟,服务员敲门:"先生,您还在吗?"

你答:"在!"

服务员立刻把退房时间顺延到从现在起的 30 分钟后。

只要你一直在洗澡(业务没结束),服务员就一直帮你续费。

只有当你退房(主动 unlock)或者你突发疾病失联(宕机,看门狗线程也死了),服务员才会停止续费,30 分钟后房间自动释放。

2. Redisson 源码级逻辑拆解 (简化版)

让我们潜入 RedissonLock 的核心逻辑,看看它是如何实现的。

A. 加锁流程 (tryLockInnerAsync)
复制代码
// 伪代码:Redisson 内部加锁逻辑
RFuture<Long> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    // 如果用户指定了 leaseTime,就不启用看门狗
    if (leaseTime != -1) {
        // 直接设置固定过期时间
        return commandExecutor.evalWriteAsync(..., SET_LOCK_SCRIPT, ...);
    }
    
    // 如果没指定 leaseTime (默认 -1),则使用默认看门狗时间 (30s)
    // 并且尝试加锁
    return commandExecutor.evalWriteAsync(..., SET_LOCK_SCRIPT, ...);
}
B. 启动看门狗 (renewExpiration)

一旦加锁成功,且没有指定 leaseTime,Redisson 会调用 renewExpiration

复制代码
private void renewExpiration() {
    // 创建一个定时任务
    ee.scheduleAtFixedRate(() -> {
        // 获取当前锁的剩余时间,或者直接续期
        // 核心逻辑:只要锁还在,就把它重置为 30s
        redisCommands.expire(lockName, 30, TimeUnit.SECONDS); 
    }, 10, 10, TimeUnit.SECONDS); // 每 10 秒执行一次
}
  • 这个定时任务是绑定在当前 JVM 进程里的。
  • 如果当前 JVM 宕机,这个定时任务也就停止了。
  • 因为没有新的续期指令发出,30 秒后 Redis 会自动删除 Key。
  • 完美平衡:既解决了长业务导致的超时,又保证了宕机后锁能自动释放(不会死锁)。
C. 可重入性 (Reentrant)

Redisson 的锁是可重入的。底层使用 Hash 结构 存储:

  • Key: lock:order:1001
  • Value: { "threadId:uuid": 1 } (Field: 线程标识,Value: 重入次数)

每次重入,计数 +1;每次释放,计数 -1。只有计数归零,才真正删除 Key 并停止看门狗。

代码实战:手写简易版看门狗 vs Redisson

为了彻底理解,先手撕 一个简易版,再展示工业级写法

复制代码
public class ManualWatchDogLock {
    private final Jedis jedis;
    private final String lockKey;
    private final String requestId;
    private final int LOCK_TIME = 30000; // 30s
    private ScheduledExecutorService scheduler;
    private Future<?> future;

    public ManualWatchDogLock(Jedis jedis, String lockKey) {
        this.jedis = jedis;
        this.lockKey = "lock:" + lockKey;
        this.requestId = UUID.randomUUID().toString() + ":" + Thread.currentThread().getId();
    }

    public boolean lock() {
        // 1. 原子加锁 (Lua)
        String script = "if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return 1 else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Arrays.asList(requestId, String.valueOf(LOCK_TIME)));
        
        if ("1".equals(result)) {
            // 2. 启动看门狗
            startWatchDog();
            return true;
        }
        return false;
    }

    private void startWatchDog() {
        scheduler = Executors.newSingleThreadScheduledExecutor();
        // 每 10 秒检查并续期
        future = scheduler.scheduleAtFixedRate(() -> {
            // 检查锁是否还是自己的
            String currentVal = jedis.get(lockKey);
            if (requestId.equals(currentVal)) {
                // 续命!
                jedis.pexpire(lockKey, LOCK_TIME);
                System.out.println("🐶 [WatchDog] 锁已续期: " + lockKey);
            } else {
                // 锁已经没了(被别人抢走或自己释放了),停止看门狗
                stopWatchDog();
            }
        }, LOCK_TIME / 3, LOCK_TIME / 3, TimeUnit.MILLISECONDS);
    }

    public void unlock() {
        // 1. 停止看门狗
        stopWatchDog();
        
        // 2. 原子释放 (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(requestId));
    }

    private void stopWatchDog() {
        if (future != null) future.cancel(true);
        if (scheduler != null) scheduler.shutdown();
    }
}

这个版本实现了核心逻辑,但缺少了很多生产级特性(如断线重连、锁的可重入计数、多节点 RedLock 支持等)。

2. 工业级写法:Redisson (生产推荐)

复制代码
@Autowired
private RedissonClient redissonClient;

public void businessProcess() {
    RLock lock = redissonClient.getLock("lock:order:1001");
    
    // 关键:不传 leaseTime 参数,默认启用看门狗!
    // 如果传了 lock.tryLock(5, 10, TimeUnit.SECONDS),则 10 秒后自动过期,不看门狗
    boolean isLocked = false;
    try {
        // 尝试加锁,等待 5 秒,不指定租约时间 (启用 WatchDog)
        isLocked = lock.tryLock(5, -1, TimeUnit.SECONDS);
        
        if (isLocked) {
            // 业务逻辑,哪怕跑 1 个小时,锁也不会过期(只要机器活着)
            doHeavyBusiness(); 
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        if (isLocked && lock.isHeldByCurrentThread()) {
            lock.unlock(); // 自动停止看门狗并释放锁
        }
    }
}

彩蛋

Q1: Redis 分布式锁的过期时间怎么设置?设多少合适?

  1. 如果使用原生 setnx,很难设置,设短了业务没跑完,设长了宕机死锁。
  2. 最佳实践 是使用 Redisson不指定过期时间 (即启用 WatchDog 机制)。
  3. WatchDog 默认 30 秒过期,每 10 秒自动续期。只要业务线程活着,锁就不会过期;一旦线程宕机,看门狗停止,30 秒后自动释放。这完美平衡了安全性可用性

Q2: 看门狗机制有什么缺点?

  • 缺点 1 :依赖客户端进程存活。如果客户端发生 Stop-The-World (STW) 长时间的 Full GC,看门狗线程也可能无法按时发送续期指令,导致锁意外过期。(虽然概率低,但存在)。
  • 缺点 2:增加了 Redis 的网络交互压力(每 10 秒一次写操作)。
  • 缺点 3:如果是集群模式,主从切换瞬间,看门狗的续期指令可能丢失,导致锁失效(这是 Redis AP 模型的通病,需配合 RedLock 或数据库兜底)。

Q3: 如果业务执行时间真的非常长(比如几小时),用看门狗合适吗?

  • 不合适。长时间持有分布式锁是架构设计的反模式(Anti-Pattern)。
  • 解决方案
    1. 异步化:将长耗时任务拆分为"提交任务" + "异步执行"。锁只保护"提交"这个动作,后续长任务通过消息队列异步处理,不需要持锁。
    2. 状态机 :利用数据库的状态字段(如 status = PROCESSING)来控制并发,而不是依赖内存锁。

Q4: Redisson 的可重入锁底层是怎么实现的

  • 底层使用 Hash 数据结构。
  • Key 是锁的名称,Field 是 UUID:ThreadID,Value 是重入次数(Counter)。
  • 加锁时:如果 Field 不存在,设值为 1;如果存在,值 +1。
  • 释放时:值 -1。只有当值减为 0 时,才删除整个 Key,并停止看门狗。
相关推荐
oradh2 小时前
Oracle 19c数据库软件和数据库静默安装
数据库·oracle·oracle19c·oracle 19c安装
阳光下的米雪2 小时前
存储过程的使用以及介绍
java·服务器·数据库·pgsql
ruanyongjing2 小时前
Navicat for MySQL下载安装教程
数据库·mysql
yoyo_zzm2 小时前
Spring Boot 各种事务操作实战(自动回滚、手动回滚、部分回滚)
java·数据库·spring boot
Teable任意门互动2 小时前
中小企业进销存实战:Teable多维表格从零搭建高效库存管理系统
开发语言·数据库·excel·飞书·开源软件
Y001112362 小时前
Day7-MySQL-约束
数据库·sql·mysql
ZhengEnCi2 小时前
J0A-JPA持久化技术专栏链接目录
java·数据库
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ2 小时前
DB-GPT 和 Dify 区别
数据库·gpt
Insist7532 小时前
kingbase数据库--指定备份集恢复
数据库