对Redis锁延期的一些讨论与思考

上一篇文章提到使用针对不同的业务场景如何合理使用Redis分布式锁,并引入了一个新的问题
若定义锁的过期时间是10s,此时A线程获取了锁然后执行业务代码,但是业务代码消耗时间花费了15s。这就会导致A线程还没有执行完业务代码,A线程却释放了锁(因为10s到了),第11s B线程发现锁已经释放,重新获取锁也开始执行业务代码。

此时多个线程同时执行业务代码,我们使用锁就是为了保证仅有一个线程执行这一块业务代码,说明这个锁是失效的!

本文将尝试探讨如何处理这个问题!

下面这个图解释了重置超时时间是什么意思,写一个定时任务,并单独使用一个线程每3s去检查一下是否到终点(任务是否执行完毕),第3s时发现没到终点,重置时间。 假设任务执行完毕需要花费11s。那么锁一共会延期3次,第11s之后,锁被手动释放,如果没释放。等到第19s时,会被自动释放。

如何实现锁的延期

java 复制代码
伪代码:

定义锁的结构
key:uuid
value:订单服务

if key(锁的唯一标识)是否存在
	存在,if 锁是否被修改
		未修改,重置超时时间

这部分有一点需要解释:

  1. 为什么判断锁是否被修改?
    A线程获取了锁之后,B线程修改锁的value为 "文件下载服务",不加一层校验,A线程就会对修改后的锁操作,而不是原始的锁。

此时你会直接写一个定时任务去实现,会有什么问题吗?

锁延期分为2步(第一步:判断锁;第二步:重置锁),这2步之间是存在间隙的,完全可以在判断锁后,重置锁前发生一些事情(例如恰巧在重置时间前锁被其他线程修改了)。如何才能避免这个间隙不发生意外?

使用lua脚本:使用lua语法实现锁的延期,然后执行这个脚本。lua语法将这两个步骤绑定成一个操作。这也就是为什么提到锁延期的实现,基本都是采用lua实现的根本原因。redis分布式锁自身是有局限性的,不能满足我们的需求,所以我们提出了锁延期。

巧在Redis很支持lua语法,我们只需要按照lua语法要求写好命令,调用Redis提供的方法入口传进去,Redis会自动解析这些命令。更巧在lua语法实现锁延期解决了上面的隐患。。。

lua 复制代码
        /**
         * 锁续期
         */
        if (redis.call('exists', KEYS[1]) == 1) then // 锁还存在
              if (redis.call('get', KEYS[1]) == ARGV[1]) then 
                   redis.call('pexpire', KEYS[1], ARGV[2]) ");// 重置超时时间
                       return 1
              end
        end
        return 0

接下来完整的看一下如何使用Redis锁延期

java 复制代码
/**
 * redis分布式锁
 * 为了文件拉取加的,可能存在拉取任务耗时很久的情况,增加锁延时操作
 * @author lixinyu
 */
public class RedisDistributeLock {
    private static final Logger log = LoggerFactory.getLogger(RedisDistributeLock.class);

    // 默认30秒后自动释放锁
    private static long defaultExpireTime = 10 * 60 * 1000; // 默认10分钟

    // 用于锁延时任务的执行
    private static ScheduledThreadPoolExecutor renewExpirationExecutor;

    // 加锁和解锁的lua脚本 重入和不可重入两种
    private static String lockScript;
    private static String unlockScript;
    private static String renewScript;// 锁延时脚本
    private static String lockScript_reentrant;
    private static String unlockScript_reentrant;
    private static String renewScript_reentrant;// 锁延时脚本

    static {
        /**
         * 如果指定的锁键(KEYS[1])不存在,则通过set命令设置锁的值(ARGV[1])和超时时间(ARGV[2])。
         * 如果锁键已存在,则通过pttl命令返回锁的剩余超时时间。
         */
        StringBuilder sb = new StringBuilder();
        sb.setLength(0);
        sb.append(" if (redis.call('exists', KEYS[1]) == 0) then ");// 如果不存在这个lockKey锁
        sb.append("     redis.call('set', KEYS[1], ARGV[1]) ");// 设置锁 ,key-value结构
        sb.append("     redis.call('pexpire', KEYS[1], ARGV[2]) ");// 设置锁超时时间
        sb.append("     return nil ");
        sb.append(" end ");
        sb.append(" return redis.call('pttl', KEYS[1]) ");// 如果别的线程已经加锁,返回剩余时间
        lockScript = sb.toString();

        /**
         * 如果锁存在,则删除锁
         */
        sb.setLength(0);
        sb.append(" if (redis.call('get', KEYS[1]) == ARGV[1]) then ");
        sb.append("      return redis.call('del', KEYS[1]) ");
        sb.append(" else return 0 ");
        sb.append(" end");
        unlockScript = sb.toString();

        /**
         * 可重入锁主要解决的是同一个线程能够多次获取锁的问题,而不是防止多个线程同时获取锁
         * 这通常发生在方法递归调用、嵌套调用或者同一个方法内部多次执行加锁操作的情况下
         */
        sb.setLength(0);
        sb.append(" if (redis.call('exists', KEYS[1]) == 0) then ");// 如果不存在这个lockKey锁
        sb.append("     redis.call('hset', KEYS[1], ARGV[1], 1) ");// 设置锁 ,hash结构,hashkey为当前线程id,加锁数为1
        sb.append("     redis.call('pexpire', KEYS[1], ARGV[2]) ");// 设置锁超时时间
        sb.append("     return nil ");
        sb.append(" end ");
        sb.append(" if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then ");// 如果当前线程已经加锁
        sb.append("     redis.call('hincrby', KEYS[1], ARGV[1], 1) ");// 可重入,增加锁计数
        sb.append("     redis.call('pexpire', KEYS[1], ARGV[2]) ");// 重设置锁超时时间
        sb.append("     return nil ");
        sb.append(" end ");
        sb.append(" return redis.call('pttl', KEYS[1]) ");// 如果别的线程已经加锁,返回剩余时间
        lockScript_reentrant = sb.toString();

        /**
         * 释放锁,通过判断锁的存在、当前线程是否是加锁的线程、以及锁的计数器等条件来实现解锁的操作
         */
        sb.setLength(0);
        sb.append(" if (redis.call('exists', KEYS[1]) == 0) then ");// 不存在锁,返回1表示解锁成功
        sb.append("     return 1 ");
        sb.append(" end ");
        sb.append(" if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then ");// 存在锁,不是本人加的,返回0失败
        sb.append("     return 0 ");
        sb.append(" end ");
        sb.append(" local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1) ");// 存在自己加的锁,锁计数减一
        sb.append(" if (counter > 0) then ");// 判断是否要删除锁,或重置超时时间
        sb.append("     redis.call('pexpire', KEYS[1], ARGV[2]) ");
        sb.append("     return 0 ");
        sb.append(" else ");
        sb.append("     redis.call('del', KEYS[1]) ");
        sb.append("     return 1 ");
        sb.append(" end ");
        sb.append(" return nil ");
        unlockScript_reentrant = sb.toString();

        /**
         * 锁续期
         */
        sb.setLength(0);
        sb.append(" if (redis.call('exists', KEYS[1]) == 1) then ");// 锁还存在
        sb.append("     if (redis.call('get', KEYS[1]) == ARGV[1]) then ");
        sb.append("        redis.call('pexpire', KEYS[1], ARGV[2]) ");// 重置超时时间
        sb.append("        return 1");
        sb.append("     end ");
        sb.append(" end ");
        sb.append(" return 0 ");
        renewScript = sb.toString();

        /**
         * 可重入锁续期
         */
        sb.setLength(0);
        sb.append(" if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then ");// 锁还存在
        sb.append("     redis.call('pexpire', KEYS[1], ARGV[2]) ");// 重置超时时间
        sb.append("     return 1 ");
        sb.append(" end ");
        sb.append(" return 0 ");
        renewScript_reentrant = sb.toString();

        renewExpirationExecutor = new ScheduledThreadPoolExecutor(2);
    }


    private String uuid;// 当前锁对象标识
    private boolean reentrant;// 当前锁是可重入还是不可重入
    private RedisUtils redisUtils;

    public RedisDistributeLock(boolean reentrant) {
        this.uuid = UUIDUtils.randomUUID8();
        this.reentrant = reentrant;
        this.redisUtils = SpringApplicationUtils.getBean(RedisUtils.class);
    }

    /**
     * 尝试对lockKey加锁
     * @author: lixinyu 2023/4/25
     **/
    public boolean tryLock(String lockKey) {
        String script = lockScript;
        if (reentrant) {
            script = lockScript_reentrant;
        }

        Object result = redisUtils.evalScript(script, ReturnType.INTEGER, 1, lockKey, uuid, String.valueOf(defaultExpireTime));
        boolean isSuccess = result == null;
        if (isSuccess) {
            // 若成功,增加延时任务
            scheduleExpirationRenew(lockKey, uuid, reentrant);
        }

        return isSuccess;
    }

    /**
     * 解锁
     * @author: lixinyu 2023/4/25
     **/
    public void unlock(String lockKey){
        if (reentrant) {
            redisUtils.evalScript(unlockScript_reentrant, ReturnType.INTEGER, 1, lockKey, uuid, String.valueOf(defaultExpireTime));
        } else {
            redisUtils.evalScript(unlockScript, ReturnType.INTEGER, 1, lockKey, uuid);
        }
    }

    /**
     * 锁延时,定时任务队列,定时判断一次是否续期
     */
    private void scheduleExpirationRenew(String lockKey, String lockValue, boolean reentrant) {
        Runnable renewTask = new Runnable(){

            @Override
            public void run() {
                try {
                    String script = renewScript;
                    if (reentrant) {
                        script = renewScript_reentrant;
                    }
					// 将lua语法传给redis解析
                    Object result = evalScript(script, ReturnType.INTEGER, 1, lockKey, lockValue, String.valueOf(defaultExpireTime));
                    if (result != null && !result.equals(false) && result.equals(Long.valueOf(1))) {
                        // 延时成功,再定时执行
                        scheduleExpirationRenew(lockKey, lockValue, reentrant);

                        log.info("redis锁【" + lockKey + "】延时成功!");
                    }
                } catch (Exception e) {
                    log.error("scheduleExpirationRenew run异常", e);
                }
            }
        };

        renewExpirationExecutor.schedule(renewTask, defaultExpireTime / 3, TimeUnit.MILLISECONDS);
    }
}
java 复制代码
 /**
  *  将lua语法传给redis
  */ 
 public Object evalScript(String script, ReturnType returnType, int numKeys,
                             String... keysAndArgs)
    {
        Object value = false;
        try
        {
            value = redisTemplate.execute((RedisCallback<Object>)conn -> {
                try
                {
                    byte[][] keysAndArgsByte = new byte[keysAndArgs.length][];
                    for (int i = 0; i < keysAndArgs.length; i++ )
                    {
                        keysAndArgsByte[i] = redisTemplate.getStringSerializer().serialize(keysAndArgs[i]);
                    }
                    return conn.eval(redisTemplate.getStringSerializer().serialize(script), returnType, numKeys,
                            keysAndArgsByte);
                }
                catch (SerializationException e)
                {
                    log.error("异常", e);
                    return false;
                }
            });
        }
        catch (Exception e)
        {
           log.error("异常", e);
        }
        return value;
    }

使用锁

java 复制代码
 private void demo() {
            RedisDistributeLock lock = new RedisDistributeLock(false);
            String lockKey = redisSeqPrefix + "lock:" + seqName;
            try {
                if (lock.tryLock(lockKey)) {
                    String redisValue = redisUtils.get(redisSeqPrefix + seqName);

                    // 加锁之后再次判断是否超出规定长度,防止并发时重置多次
                    if (redisValue != null && redisValue.length() > seqLength) {
                        redisUtils.set(redisSeqPrefix + seqName, "1");
                    }
                }
            } catch (Exception e) {
                logger.error("resetSeqValue异常", e);
            } finally {
                lock.unlock(lockKey);
            }
        }

不仅仅是锁延期需要两步(判断锁是否存在、重置时间),删除锁也需要两步(判断锁是否存在、删除锁)这也需要保证原子性,所以建议使用lua脚本实现。

你干脆想到:要不然我获取锁、解锁、获取可重入锁、解可重入锁,锁延期等等都写成lua脚本吧,但是工作量好像就变多了。

Redisson 提供了高级的分布式对象和服务,使用起来非常简单,不需要手动编写复杂的 Lua 脚本。只需要引入Redisson 的依赖库

Redisson 提供了许多高级功能,如分布式集合、分布式锁、分布式队列等。这些功能是为了解决常见的分布式应用场景而设计的,使用 Redisson 可以更轻松地集成这些功能

如果你只是一些简单的功能,可以自定义lua脚本实现,毕竟引入新的依赖库,就需要维护这个库,看怎么考虑了。

相关推荐
only-qi44 分钟前
146. LRU 缓存
java·算法·缓存
阿里小阿希1 小时前
Vue3 + Element Plus 项目中日期时间处理的最佳实践与数据库设计规范
数据库·设计规范
xuxie132 小时前
SpringBoot文件下载(多文件以zip形式,单文件格式不变)
java·spring boot·后端
白鹭2 小时前
MySQL源码部署(rhel7)
数据库·mysql
重生成为编程大王2 小时前
Java中的多态有什么用?
java·后端
666和7772 小时前
Struts2 工作总结
java·数据库
还听珊瑚海吗2 小时前
SpringMVC(一)
数据库
中草药z2 小时前
【Stream API】高效简化集合处理
java·前端·javascript·stream·parallelstream·并行流
野犬寒鸦2 小时前
力扣hot100:搜索二维矩阵 II(常见误区与高效解法详解)(240)
java·数据结构·算法·leetcode·面试
zru_96023 小时前
centos 系统如何安装open jdk 8
java·linux·centos