实习生踩坑记:Redis分布式锁为什么总是"失效"?看门狗机制深度解析

前言

又是一个稀松平常的周三下午,实习生小张盯着屏幕上密密麻麻的异常日志,眉头紧锁。

这已经是第三次出现诡异问题了------有时候任务执行到一半就莫名其妙地被其他服务实例接管,有时候同一个任务竟然在多台机器上同时执行。

"哥,你帮忙看看这个。"小张不得不来找旁边的大刘求助,"我用Redis实现了分布式锁,按理说应该能保证任务的互斥执行,但是..."

大刘走过来扫了一眼代码和日志,很快就发现了问题所在:"你的锁超时时间设置的是30秒,但是有些任务执行时间超过了这个时长。当锁自动过期释放后,其他实例就以为可以获取锁了。"

"那我把超时时间设置得长一点不就行了?"小张天真地建议。

"如果任务执行过程中服务器宕机了呢?锁就永远不会被释放,整个系统就死锁了。"大刘摇摇头,"你需要了解一下Redisson的看门狗机制,它专门解决这种锁续期的问题。"

小张一脸茫然:"看门狗?这和Redis锁有什么关系?"

看着小张困惑的表情,大刘决定给他详细讲解一下这个在分布式系统中至关重要的机制...

正文

1. 什么是看门狗机制

看门狗机制是Redisson中的一个自动续期功能,它会在锁即将过期时自动延长锁的过期时间,确保业务代码执行期间锁不会意外释放。

1.1 核心特性

  • 自动续期:定期检查并延长锁的过期时间
  • 智能感知:当业务代码执行完成时自动停止续期
  • 异常处理:当客户端异常时锁会自然过期,避免死锁

2. 简单使用示例

java 复制代码
@Service
public class OrderService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    public void processOrder(String orderId) {
        String lockKey = "order:lock:" + orderId;
        RLock lock = redissonClient.getLock(lockKey);
        
        try {
            // 尝试获取锁,不指定超时时间,默认启用看门狗机制
            if (lock.tryLock()) {
                // 执行业务逻辑,可能耗时很长
                processLongRunningTask(orderId);
            } else {
                throw new RuntimeException("获取锁失败");
            }
        } finally {
            // 释放锁时会自动停止看门狗
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    private void processLongRunningTask(String orderId) {
        // 模拟长时间运行的业务逻辑
        try {
            Thread.sleep(60000); // 60秒的业务处理
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

3. 看门狗机制工作原理

3.1 整体工作流程

sequenceDiagram participant Client as 客户端 participant Redis as Redis服务器 participant WatchDog as 看门狗任务 Client->>Redis: 1. 尝试获取锁 Redis-->>Client: 2. 获取成功,设置30s过期时间 Client->>WatchDog: 3. 启动看门狗任务 Note over WatchDog: 每10秒检查一次 Client->>Client: 4. 执行业务逻辑 loop 看门狗续期循环 WatchDog->>Redis: 5. 检查锁是否存在且为当前线程持有 Redis-->>WatchDog: 6. 返回检查结果 alt 锁存在且为当前线程持有 WatchDog->>Redis: 7. 延长锁过期时间到30s Redis-->>WatchDog: 8. 续期成功 else 锁不存在或不属于当前线程 WatchDog->>WatchDog: 9. 停止看门狗任务 end end Client->>Redis: 10. 业务完成,释放锁 Client->>WatchDog: 11. 停止看门狗任务

3.2 核心参数配置

java 复制代码
@Configuration
public class RedissonConfig {
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://localhost:6379");
        
        // 配置看门狗相关参数
        config.setLockWatchdogTimeout(30000); // 看门狗超时时间:30秒
        
        return Redisson.create(config);
    }
}

其实主要的也就这个参数,这里再做下详细的讲解:

lockWatchdogTimeout(看门狗超时时间)

  • 默认值:30000毫秒(30秒)

  • 作用:当获取锁时没有指定过期时间,看门狗会以此时间间隔对锁进行续期

  • 续期频率 :每隔 lockWatchdogTimeout / 3 的时间进行一次续期检查

  • 配置建议

    • 设置过短:续期频率过高,增加Redis压力
    • 设置过长:服务异常后锁释放时间过长,影响系统可用性
    • 一般建议:根据业务执行时间的平均值来设定,通常10-60秒较为合适,一般来讲默认值够用

4. 源码实现分析

了解了核心参数,那继续对源码做深入的分析。

4.1 锁获取与看门狗启动

先看 tryLock 方法的实现:

java 复制代码
public class RedissonLock implements RLock {
    
    // 看门狗超时时间,默认30秒
    private long lockWatchdogTimeout;
    // 内部锁租约时间
    private long internalLockLeaseTime;
    
    @Override
    public boolean tryLock() {
        return tryLock(-1, -1, null);
    }
    
    @Override
    public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) {
        long time = unit.toMillis(waitTime);
        long current = System.currentTimeMillis();
        long threadId = Thread.currentThread().getId();
        
        // 尝试获取锁
        Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
        if (ttl == null) {
            // 获取锁成功
            if (leaseTime != -1) {
                // 指定了租约时间,不启动看门狗
                internalLockLeaseTime = unit.toMillis(leaseTime);
            } else {
                // 未指定租约时间,启动看门狗机制
                // 使用默认的看门狗超时时间
                internalLockLeaseTime = lockWatchdogTimeout;
                scheduleExpirationRenewal(threadId);
            }
            return true;
        }
        
        // 获取锁失败的处理逻辑...
        return false;
    }
}

可以看出关键流程的一些

  1. 租约时间判断:这里是看门狗机制的分水岭

    • leaseTime != -1:用户指定了过期时间,Redisson不会启动看门狗
    • leaseTime == -1:用户没有指定过期时间,启动看门狗自动续期
  2. 线程ID的作用:用于标识当前持有锁的线程,确保只有持锁线程才能进行续期操作

4.2 看门狗调度实现

java 复制代码
public class RedissonLock implements RLock {
    
    // 全局的续期任务管理Map
    private static final ConcurrentMap<String, ExpirationEntry> EXPIRATION_RENEWAL_MAP = 
            new ConcurrentHashMap<>();
    
    private void scheduleExpirationRenewal(long threadId) {
        ExpirationEntry entry = new ExpirationEntry();
        entry.addThreadId(threadId);
        
        // 将当前锁的续期任务放入全局Map中
        ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
        
        if (oldEntry != null) {
            // 锁已存在续期任务,添加当前线程ID(支持重入锁)
            oldEntry.addThreadId(threadId);
        } else {
            // 首次创建续期任务,启动看门狗
            renewExpiration();
        }
    }
    
    private void renewExpiration() {
        ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (ee == null) {
            return;
        }
        
        // 创建定时任务 - 这里是看门狗的核心
        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;
                }
                
                // 执行续期操作
                RFuture<Boolean> future = renewExpirationAsync(threadId);
                future.onComplete((res, e) -> {
                    if (e != null) {
                        log.error("Can't update lock " + getName() + " expiration", e);
                        // 续期异常,移除续期任务
                        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                        return;
                    }
                    
                    if (res) {
                        // 续期成功,继续下一次续期调度
                        renewExpiration();
                    } else {
                        // 续期失败(锁可能已被释放),停止看门狗
                        cancelExpirationRenewal(null);
                    }
                });
            }
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 关键:每1/3锁时间执行一次
        
        ee.setTimeout(task);
    }
    
    // 取消续期任务
    void cancelExpirationRenewal(Long threadId) {
        ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
        if (task == null) {
            return;
        }
        
        if (threadId != null) {
            task.removeThreadId(threadId);
        }
        
        if (threadId == null || task.hasNoThreads()) {
            Timeout timeout = task.getTimeout();
            if (timeout != null) {
                timeout.cancel();
            }
            EXPIRATION_RENEWAL_MAP.remove(getEntryName());
        }
    }
}

这里有两个亮点可以看下:

  1. 为什么是续期时间是 internalLockLeaseTime / 3

    • 确保在锁过期前有足够时间完成续期
    • 避免网络延迟导致的续期失败
    • 平衡续期频率和系统性能
  2. 全局Map EXPIRATION_RENEWAL_MAP 的作用

    • 避免重复创建续期任务
    • 支持可重入锁的多线程管理
    • 统一管理所有锁的续期任务

4.3 续期Lua脚本

java 复制代码
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE,
            // Lua脚本:原子性检查并续期
            "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()),           // KEYS[1]: 锁名称
            internalLockLeaseTime,                          // ARGV[1]: 续期时间
            getLockName(threadId));                         // ARGV[2]: 线程标识
}

其实重点就在于这个LUA脚本,我们来做个解析:

  1. hexists 检查当前线程是否仍持有锁

    • 如果锁已被其他线程获取或已释放,则不进行续期
    • 保证续期操作的安全性
  2. pexpire 重新设置锁的过期时间

    • 使用毫秒级精度
    • 续期时间为配置的 lockWatchdogTimeout
  3. 整个检查和续期过程保持原子化

    • 避免并发问题
    • 确保数据一致性

5. 看门狗机制详细流程

5.1 流程图

基于上面看到的关键代码实现,我们整理一份清晰的流程图:

5.2 使用场景

看门狗的核心目标挺明确:

一是防止持有锁的客户端突然"掉线"或者卡住太久,导致锁没人管成了"死锁",它得确保这锁最终能被放出来;

二是对于那些跑得久但又一切正常的任务,它能悄悄给锁"续命",别让锁因为超时被意外或提前"掐掉",保证任务能从头跑到尾。

但也不是所有地方都适合把这只"小看门狗"牵出来溜溜。

毕竟,它在帮忙的同时,也会带来额外的开销。

所以,还是得搞清楚,什么时候它能帮上大忙,什么时候就最好让它歇着。

5.2.1 适合使用的场景
  1. 执行时间不确定的任务

    • 文件上传下载:网络状况不稳定,处理时长波动大
    • 外部API调用:第三方服务响应时间难掌控
    • 复杂业务处理:涉及多步骤计算、状态转换等
  2. 长时间运行的任务

    • 大规模数据处理:数据迁移、批量更新等
    • 复杂报表生成:需要长时间运算和数据聚合
  3. 数据一致性要求高

    • 关键数据修改:需要确保事务完整执行
    • 多步骤操作:中间步骤不能因锁失效中断
5.2.2 不适合使用的场景

下面这些场景,就得多加留意,尽量不要使用看门狗机制,避免带来不必要的隐患:

  1. 执行时间短且可预测

    • 简单CRUD操作:毫秒级就能搞定
    • 缓存操作:追求极低延迟的场景
  2. 对性能极度敏感

    • 高并发系统:看门狗的额外开销可能成为瓶颈
    • 低延迟要求:后台续期可能带来不必要的负担
  3. 需要严格限制锁时间

    • 明确超时要求:必须在固定时间释放锁
    • 有现成重试机制:已有框架能处理超时情况

结尾

大刘的话音刚落,小张的眼中闪过一丝恍然大悟的光芒。

他若有所思地点点头,脑海中似乎正在将刚刚听到的知识与自己之前遇到的异常日志联系起来。

"原来如此,"小张喃喃自语,"看门狗机制就像是一个贴心的助手,在我忙着处理任务时,默默地为锁续期,确保任务不会因为锁过期而被其他实例抢走。"

大刘点点头,"没错,可以把它想象成一个忠实的看门狗,守护着你的任务不被外界干扰。"

"明白了,"小张补充道,"而且还得根据实际情况来判断,不能一刀切地用。"

大刘拍了拍小张的肩膀:"技术是工具,关键在于怎么用。希望你能把今天聊的这些,应用到你的代码里,解决那些烦人的问题。"

小张感激地笑了笑,"谢谢你,大刘。我回去就试试看。"

随着夕阳的余晖洒在办公桌上,两人的对话也渐渐落下了帷幕。

小张转身回到自己的工位,打开电脑,准备将刚学到的知识融会贯通到项目里。

这一次,他心里多了一份底气。

相关推荐
百度智能云几秒前
零依赖本地调试:VectorDB Lite +VectorDB CLI 高效构建向量数据库全流程
后端
wxid:yiwoxuan14 分钟前
购物商城网站 Java+Vue.js+SpringBoot,包括商家管理、商品分类管理、商品管理、在线客服管理、购物订单模块
java·vue.js·spring boot·课程设计
WispX88818 分钟前
【设计模式】门面/外观模式
java·开发语言·设计模式·系统架构·外观模式·插件·架构设计
琢磨先生David21 分钟前
简化复杂系统的优雅之道:深入解析 Java 外观模式
java·设计模式·外观模式
ademen21 分钟前
spring4第7-8课-AOP的5种通知类型+切点定义详解+执行顺序
java·spring
flzjkl28 分钟前
【Spring】【事务】初学者直呼学会了的Spring事务入门
后端
aneasystone本尊35 分钟前
使用 OpenMemory MCP 跨客户端共享记忆
后端
花千烬36 分钟前
云原生之Docker, Containerd 与 CRI-O 全面对比
后端
快乐肚皮36 分钟前
EasyExcel高级特性和技术选型
java
tonydf37 分钟前
还在用旧的认证授权方案?快来试试现代化的OpenIddict!
后端·安全