你的Redis分布式锁还在裸奔?看门狗机制让锁更安全!

关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言

在高并发分布式场景中,分布式锁是我们经常使用的工具。你的Redis分布式锁是在使用set还是setnx实现的呢?是否还在裸奔?

今天我们一起看看Redis分布式锁是如何裸奔的?可能会出现什么样的问题,以及如何处理。

02 裸奔的分布式锁

在高并发或者幂等性设计的时候,我们都需要使用到分布式锁。最常见的就是防止表单重复提交的场景,我们来看看裸奔的实现。

java 复制代码
try {
   if(redis.setnx(key, objFormat) == 0){
   		return new JsonResult(false, "不可重复提交!"));
   }

    // 设置过期时间为2s
    redis.setExpireSec(key, objFormat, EXPIRE_TIME_2S); 
    
    // 其他业务逻辑......
}finally {
    redis.delete(key);
}

乍一看好像也没有什么问题,不瞒你说,我们项目里面也是这么用的!还没有出现过问题呢,哈哈哈...

2.1 裸奔式分析

setnx()实在key不存在时可以设置进去并返回1,key存在时,则表示没有设置进去,返回0。当key设置成功之后,通过setExpireSec()设置过期时间。随后继续执行其他业务,最后删除Redis中的key

细扣里面的细节,包含这几个问题:

  • 过期时间:过期时间的设置应该是多长
  • 原子操作:设置值和过期时间是非原子操作
  • 暴力删除:删除key过于暴力

过期时间

在高并发或者幂等性操作时,Rediskey一般都是临时的,请求完成之后基本就没有用了。为了防止Redis中存放大量无效的Key,我们需要对无效的key进行清理。而有效时间,会被Redis记录,到期自动清理。

但是过期时间的时长无法精确估计,设置短无法拦住多余的请求,设置的长有可能拦截住正常的请求。再加上网络抖动等硬件影响,时间更家无法确定。

原子操作

原子操作可以保证要么都成功要么都失败,设置值和时间非原子操作,可能会造成设置值成功了,但是设置过期时间的时候出现了异常,导致设置的Key没有过期时间。

try...finally可以弥补这一缺陷,但是如果出现系统的异常或者服务器宕机了,这种兜底就无法生效了。所以说还是存在瑕疵的。

暴力删除

暴力删除指的是,没有判断当前的可以是不是你自己的key,就直接删除了Key。我们假设这样一个场景:

  • 第一个请求进来执行了自己的业务后卡住了,导致自己的key已经过期了,但是还没有执行finally方法。
  • 第二个请求进来拥有相同的key,在设置到Redis之后业务还没有执行完,第一个请求突然执行了finally中删除了key的方法,这个时候就会删除了第二个请求的锁,导致第二个请求失去了锁。

所以我们在删除key之前,增加value的判断,如果相同就表示是自己的key,可以放心删除,如果不是就不能删除。

2.2 改进版

过期时长我们依然无法解决,暂时先不解决,先填平其他两个坑点。

java 复制代码
try {
   if(!"OK".equals(redis.set(key, objFormat, "NX", "EX", EXPIRE_TIME_2S))){
   		return new JsonResult(false, "不可重复提交!"));
   }
    
    // 其他业务逻辑......
}finally {
    if (objFormat.equals(redis.get(key)) {
         redis.delete(key);
    } 
        
    // Lua脚本保证原子性删除
    /*
    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                       "return redis.call('del', KEYS[1]) " +
                       "else return 0 end";
    jedis.eval(luaScript, 1, key, objFormat);
    */
}

Redisset命令自带的 nx(如果不存在就创建)和 ex(设置超时时间),将原来的setnxexpire命令合成一个了,并且是原子操作,极大的方面了我们对分布式锁的使用。当然我们还可以使用Redis支持的Lua脚本来实现原子操作。

03 看门狗

第二节介绍的方式无论初始版还是改进版,通过设置较长的过期时间,已经可以应对分布式下的并发问题、幂等性问题。用在生产环境毫无毛病,至少现在我们还没有遇到问题。

如何精确的解决过期时间的问题,看门狗机制正事通过监控机制,为较短的过期时间续约,已达到精确控制过期时间的目的。

实现思路:通过守护线程每隔一段时间就会去检查Rediskey是否快要过期了,如果是就会延长过期时间。

3.1 自定义实现

启用看门狗模式

java 复制代码
try {
   if(!"OK".equals(redis.set(key, objFormat, "NX", "EX", EXPIRE_TIME_2S))){
   		return new JsonResult(false, "不可重复提交!"));
   }
   
    /** 启动看门狗模式  */
    startWatchDog(key, objFormat);
    
    // 其他业务逻辑......
}finally {
    // 停止看门狗
    stopWatchDog(key, objFormat);
    
    // Lua脚本保证原子性删除
    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                       "return redis.call('del', KEYS[1]) " +
                       "else return 0 end";
    jedis.eval(luaScript, 1, key, objFormat);

}

看门狗实现与停止

java 复制代码
public void startWatchDog(String lockKey, String lockValue) {
    // 创建调度线程池
    scheduler = Executors.newSingleThreadScheduledExecutor();
    // 以固定的频率调度任务
    watchdogTask = scheduler.scheduleAtFixedRate(() -> {
       
       // 使用Lua脚本确保原子性校验和续期
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                           "return redis.call('pexpire', KEYS[1], ARGV[2]) " +
                           "else return 0 end";
        if (jedis.eval(luaScript, 1, key, objFormat) == 1) {
            System.out.println("锁续期成功");
        }else {
            // 锁续约失败,关闭看门狗
            stopWatchDog(lockKey, lockValue)
        }
   }, WATCHDOG_INTERVAL, WATCHDOG_INTERVAL, TimeUnit.MILLISECONDS);
}

public void stopWatchDog(String lockKey, String lockValue) {
    if (watchdogTask != null) {
        watchdogTask.cancel(true);
    }
    if (scheduler != null){
        scheduler.shutdown();
    }
}

3.2 基于Redisson的看门狗

看门狗是有重复的轮子可用的,Redisson作为Redis的客户端已经实现了这一功能,可以放心使用在生产环境上。我们无需关心续约问题,Redisson已经帮我们实现了,我们只要像用锁一样使用就可以了。

Maven依赖

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.17.0</version>
</dependency>

具体实现

java 复制代码
@Test
void testWatchDog() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");

    RedissonClient redisson = Redisson.create(config);
    RLock lock = redisson.getLock("resource_lock");
    try {
        // 获取锁并自动启动看门狗
        lock.lock();  // 默认30秒过期,看门狗每10秒续期

        // 执行长时业务逻辑
        Thread.sleep(60000); // 模拟60秒操作

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    } finally {
        lock.unlock(); // 释放锁并停止看门狗
        redisson.shutdown();
    }
}

04 关键的实现细节

  • 续期条件验证:每次续期前校验锁的value是否匹配,防止续期他人持有的锁
  • 优雅停止机制:业务完成时立即取消定时任务,避免空转
  • 原子性操作:使用Lua脚本保证「校验+删除」的原子性
  • 容错处理:网络异常时自动重试(Redisson内置重试机制)

05 小结

在分布式锁场景中,当业务执行时间超过锁的过期时间时,Redis看门狗通过自动续期机制防止锁被意外释放,避免多个客户端同时持有锁导致数据不一致。

生产环境优先选用Redisson框架,其经过大规模验证,提供完善的分布式锁实现和看门狗机制,同时支持Redis集群模式下的高可用场景。

相关推荐
岁忧33 分钟前
(LeetCode 面试经典 150 题 ) 11. 盛最多水的容器 (贪心+双指针)
java·c++·算法·leetcode·面试·go
CJi0NG37 分钟前
【自用】JavaSE--算法、正则表达式、异常
java
Nejosi_念旧1 小时前
解读 Go 中的 constraints包
后端·golang·go
风无雨1 小时前
GO 启动 简单服务
开发语言·后端·golang
Hellyc1 小时前
用户查询优惠券之缓存击穿
java·redis·缓存
小明的小名叫小明1 小时前
Go从入门到精通(19)-协程(goroutine)与通道(channel)
后端·golang
斯普信专业组1 小时前
Go语言包管理完全指南:从基础到最佳实践
开发语言·后端·golang
今天又在摸鱼2 小时前
Maven
java·maven
老马啸西风2 小时前
maven 发布到中央仓库常用脚本-02
java·maven
代码的余温2 小时前
MyBatis集成Logback日志全攻略
java·tomcat·mybatis·logback