Redis实现分布式可重入锁——CAS操作

一、前言

Redis实现的分布式锁被大家广泛用于解决在分布式环境下的并发问题——使用set NX EX,当某一个key存在时,返回失败,当key不存在时,设置新值和过期时间,返回成功。

那么如何通过Redis实现一个可重入的分布式锁呢?

二、解决思路

我们可以参考一下Java中ReentrantLock的实现,在持有锁时记录下线程信息,获取锁时检查线程id是否相同,那么在Redis中也可参考相同实现:

  • 1、获取锁信息;
  • 2、比较持有线程ID;
  • 3、更新锁信息。

此时就出现了并发问题,需要先比较在更新,Redis并未提供CAS原子性命令,需要借助Redis的其它特性解决,下面为大家介绍两种方法。

三、基于Redis事务

1、简单介绍

redis支持了简单的事务,提供了以下几个命令:

  • WATCH:监控某些键值对;
  • MULTI:用于开启一个事务;
  • EXEC:执行事务;
  • DISCARD:取消事务;
  • UNWATCH:取消监控。

通过watch命令,实现对某些Key的监听,当一个事务提交时,会优先检测监听的key是否发生改变,如果已发生改变,取消事务,若并未改变,执行事务。

2、jedis实现

public boolean tryLock(String key, int timeout, String threadId){
		Transaction multi = null;
         try {
             // 监控key
             jedis.watch(key);
             // 获取锁信息
             String lock = jedis.get(key);
             // 已持有锁且不是当前线程,获取锁失败
             if (StringUtils.isNotEmpty(lock) && !lock.equals(threadId)) {
                 return false;
             }
             // 开启事务
             multi = jedis.multi();
             // 添加命令
             multi.setex(key, timeout, threadId);
             // 执行事务
             multi.exec();
             return true;
         } catch (Exception e) {
             if (Objects.nonNull(multi)) {
                 multi.discard();
             }
             return false;
         } finally {
             jedis.unwatch();
         }
}

注意:

  • redis提供的事务,中间一条命令执行失败,并不会导致前面已经执行的指令回滚,也不会造成后续的指令不做
  • WATCH监视了一个带过期时间的键,那么即使这个键过期了,事务仍然可以正常执行;
  • WATCH机制不存在ABA问题。

3、WATCH机制原理

redis中存在一个字典,用于保存所有被监视的key和其对应监视的客户端列表,字典的键是被监视的key,而值则是监视其的客户端链表。
Redis实现分布式可重入锁——CAS操作_第1张图片

  • 当某一个客户端通过watch添加对key的监视时,会在字典中检索出对应的链表,将其添加到末尾,保存下监视状态;
  • 当redis对任何key执行修改命令之后,都会检查当前key是否在watch字典中处于被监视状态,若存在,则将监视其的客户端节点中的状态标记为已修改;
  • 当客户端提交事务时,通过查询watch字典中监视的key,其对应的客户端节点状态是否修改,决定是否需要执行事务。

四、基于LUA

1、简单介绍

Lua是一种小巧的脚本语言,redis提供了对lua脚本执行的能力。通过将多个请求通过脚本的形式一次发送,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。

  • EVAL
EVAL script numkeys key [key …] arg [arg …]

向redis发送具体的脚本内容和参数,完成脚本的执行。

  • SCRIPTLOCAD 和 EVALSHA
// 预加载脚本,返回其对应的sha1值
SCRIPTLOCAD script
// 提交脚本对应的sha1值和参数,执行脚本
EVALSHA sha1 numkeys key [key …] arg [arg …]

redis具有缓存能力,提前将脚本语句在redis缓存,后续只需发送脚本对应的sha1值,有效的减少带宽的消耗。

2、jedis实现

public class QueueStateOperateClient {
    private static final Object LOCK_OBJECT = new Object();

    private static volatile String lockSha;

    private static volatile String unLockSha;

    public static Response lock(Pipeline pipeline, String key) {
    	// 脚本检查
        preCheck();
        return pipeline.evalsha(lockSha, 2, new String[]{setSuffix(key), setSuffix(QueueConstant.LOCAL_HOST)});
    }

    public static Response unlock(Pipeline pipeline, String key) {
        preCheck();
        return pipeline.evalsha(unLockSha, 2, new String[]{setSuffix(key), setSuffix(QueueConstant.LOCAL_HOST)});
    }

    public static void preCheck() {
    	// 脚本是否已经加载
        if (StringUtils.isNotBlank(lockSha) && StringUtils.isNotBlank(unLockSha)) {
            return;
        }
        synchronized (LOCK_OBJECT) {
            if (StringUtils.isNotBlank(lockSha) && StringUtils.isNotBlank(unLockSha)) {
                return;
            }
            // 加载script
            SuishenRedisTemplate queueRedisTemplate = (SuishenRedisTemplate) SourceEventQueueManager
                    .getApplicationContext().getBean("queueRedisTemplate");
            new SuishenRedisExecutor().exe(jedis -> {
                lockSha = jedis.scriptLoad(getLockScript());
                unLockSha = jedis.scriptLoad(getUnLockScript());
                return true;
            }, queueRedisTemplate);
        }
    }

    private static String getLockScript() {
        return "local ip = redis.call(\"get\",KEYS[1]);" +
                "if (not ip) or ip==KEYS[2] " +
                "then " +
                "   return redis.call(\"setex\",KEYS[1],10,KEYS[2]);" +
                "else " +
                "   return \"FAIL\"" +
                "end ";
    }

    private static String getUnLockScript() {
        return "local ip = redis.call(\"get\",KEYS[1]);" +
                "if ip and ip==KEYS[2] " +
                "then " +
                "  redis.call(\"del\",KEYS[1]);" +
                "end " +
                "return \"OK\"";
    }

    /**
     * 集群redis设置分片规则
     *
     * @param key
     * @return
     */
    private static String setSuffix(String key) {
        return key + "{queue}";
    }
}

3、扩展知识

a、分布式redis

对于分布式redis,执行lua时,需要保证所有的key均在同一分片下才可正确的执行,当key中存在{}时,分布式redis只会对{}中的字符进行分片规则计算,通过这种方式,可以保证不同的key均在同一分片下。

b、redis.clients.jedis.exceptions.JedisDataException: NOSCRIPT No matching script. Please use EVAL异常

使用EVALSHA时,如果当前sha1对应的脚本在redis中不存在时,会抛出此异常,常见的场景:

  • redis因为某种原因重启;
  • 分布式redis出现扩容,导致新的节点未缓存脚本;

当发现此异常时,需要重新SCRIPTLOAD脚本,加入redis缓存。

你可能感兴趣的:(redis,分布式,java)