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,并停止看门狗。
相关推荐
科技小花4 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸4 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain4 小时前
linux个人心得22 (mysql)
数据库·mysql
哈里谢顿5 小时前
如何实现分布式锁
面试
阿里小阿希5 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神5 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员5 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java5 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿6 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴6 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存