美文网首页
redis分布式锁与多线程

redis分布式锁与多线程

作者: 努力工作和养猫 | 来源:发表于2018-08-17 14:39 被阅读0次

    简介

    • 关于多线程

      首先,先复习一下Java多线程。我们都知道,启动一个Java程序,操作系统会为其创建一个进程,而一个进程中可以创建多个线程,线程之间能够访问共享的内存变量,通过操作系统处理器的调度,可以让我们的程序变得更加高效。

      Java线程在运行的生命周期中有6种不同的状态。

    状态名称 说明
    NEW 初始状态,没有调用start()方法
    RUNABLE 运行状态
    BLOCKED 阻塞状态,表示线程阻塞于锁
    WAITING 等待状态
    TIME_WAITING 超时等待状态
    TERMINATED 终止状态

    实际项目中,我们经常会遇到类似这样的并发场景:

    优惠券的抢购:多个线程抢购一定数量的优惠券,最后剩余的优惠券数量为负数

    产生的原因是:当多个线程对同一个变量进行操作时,会出现一个线程的业务逻辑没有结束,另一个线程就取获取变量进行操作,这时变量还处于之前的值。

    解决这种问题的方式有很多,比如,可以用volatile修饰成员变量,这样对该变量的访问必须从共享内存中获取,同时它的改变必须同步刷新到共享内存中,保证所有线程的可见性;还有最常见的方法,使用关键字synchronized实现对同步块同步方法的上锁。

    当然,在一个JVM中这样的方法是可行的,当出现分布式,多个节点,即在WEB项目中,多个客户端对一个数据进行请求时,则需要使用分布式锁。

    分布式锁 Java主要有两种实现,redis和zookeeper。本文主要是介绍redislock的实现

    -关于redis

      redis英文全称[Remote Dictionary Service],(WIKI解释:Redis是一个使用ANSI C编写的开源、支持网络、基于内存、可选持久性的键值对存储数据库),在互联网技术领域,Redis是使用最为广泛的存储中间件,在实际项目开发中,redis常被用来做一些数据的缓存,以及本文所讲的分布式锁,不论应用于哪方面其效率都是很高的。

    实现

      redis分布式锁的实现原理是线程执行业务逻辑前,必须先获取锁,获取锁的方法其实是不断尝试在redis中set一条记录,set成功才返回true,随后执行业务逻辑,执行结束后,释放锁,即从redis中将这条记录删除,以便其他线程可以获取锁。

    • 获取锁

      redis的官方文档给出了解决思路,如下图为获取锁——set记录的方法:

    image.png

    这条指令是setnx和expire组合在一起的原子指令,30000对应的是过期时间,单位是毫秒。

      在以前的redis版本,这个指令需要分两部分执行。一般执行一个并发业务时,定义一个唯一的key,通过setnx(set if not exists)指令,当只有不存在此key值的记录时才能set,返回true。这时会出现一个问题,当业务逻辑没执行完,锁没有释放的情况下,出现服务宕机,那这时redis中锁就会一直存在,别的客户端就获取不到锁,造成死锁现象。这时需要给锁添加过期时间,即进行expire指令,超出过期时间锁自动删除。即如下:

    > setnx lock:codehole true
    OK
    > expire lock:codehole 5
    

    这个时候又会出现一个问题,在setnx后锁成功插入,执行expire之前,服务出现宕机,进程挂掉了,那么expire还是得不到执行,一样会造成死锁。

    为解决这种情况,出现了现在的原子指令。

    所谓原子操作,即保证操作一直运行到结束。在这里,把setnx与expire结合成一条语句执行,保持了操作的原子性,要么都成功,要么都不成功。保证操作原子性在分布式锁是非常非常非常关键的。

    下面是Java中的实现:

    /**
         * 上锁成功后返回值
         */
        private static final String LOCK_SUCCESS = "OK";
    
        /**
         * SetNX方法中NX的含义
         */
        private static final String SET_IF_NOT_EXIST = "NX";
    
        /**
         * SetNX方法中PX的含义
         */
        private static final String SET_WITH_EXPIRE_TIME = "PX";
    
     @Resource
        private StringRedisTemplate stringRedisTemplate;
    
    /**
         * 使用Jedis客户端执行原子指令
         * 
         * @param key
         * @param value
         * @param expiry
         * @return
         */
        public boolean setNX(String key, String value, long expiry) {
            //指令执行成功的话返回"OK"
            String result = stringRedisTemplate.execute((RedisCallback<String>) connection -> {
                JedisCommands commands = (JedisCommands) connection.getNativeConnection();
                return commands.set(key, value, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expiry);
            });
    
            return LOCK_SUCCESS.equals(result);
        }
    

    这个demo基于springboot1.5,对redis进行了整合,通过@Resource注释,注入了StringRedisTemplate,并结合Jedis对redis进行操作。

    有了这个方法,就可以对获取锁的方法进行进一步的处理。可以在一定的时间内不断的重试获取锁,这里必须设置一个重试超时时间和最多重试次数,防止线程饥饿,一直在重试获取锁。

    • 解锁

      成功获取锁了,接下来的问题就是如何安全的解锁——将锁删除。先来看看官方文档的实现:

    image.png

    解锁需要注意一个问题,如果一个线程由于某些原因,执行任务的时间超过了锁的过期时间,那么redis将会释放锁,其他线程将会获取锁,这时,原来的超时的线程执行完自己的业务逻辑以后,会执行解锁操作,把其他线程业务逻辑还没执行完的锁就被误删,别的线程也会再次取得锁。如此反复。

    为了解决这个问题,官方给出的方法是,redis执行Lua脚本,删除锁前,比较value值是否相等,相等才能进行删除操作。Lua脚本可以保证连续多个指令原子性的完成。因为value的比较和key的删除不是一个原子操作。

    下面是Java中执行Lua脚本的方法:

        /**
         * lua脚本:key相等时判断value值是否相等,相等的话则删除
         */
        private static final String LUA_UNLOCK_SCRIPT = "if redis.call(\"get\",KEYS[1]) == ARGV[1] " +
                "then " +
                "return redis.call(\"del\",KEYS[1]) " +
                "else " +
                "return 0 " +
                "end";
    
        /**
         * 调用Lua脚本删除key
         *
         * @param keys
         * @param args
         * @return
         */
        public boolean delate(List<String> keys, List<String> args) {
            Object result = stringRedisTemplate.execute((RedisCallback<Object>) connection -> {
                Object nativeConnection = connection.getNativeConnection();
                
                if (nativeConnection instanceof Jedis) {
                    return ((Jedis) nativeConnection).eval(LUA_UNLOCK_SCRIPT, keys, args);
                } 
                //如果时redis集群
                else if (nativeConnection instanceof JedisCluster) {
                    return ((JedisCluster) nativeConnection).eval(LUA_UNLOCK_SCRIPT, keys, args);
                }
                return 0L;
            });
    
            return result != null && Long.parseLong(result.toString()) > 0;
        }
    

    可以使用如下的方式设置value值,尽可能保证每个线程的value值唯一,并可以使用ThreadLocal线程变量,存储当前线程的value。ThreadLocal 是一个以ThreadLocal对象为键、任意对象为值得存储结构。

    String value =  UUID.randomUUID().toString();
    

    思考

      这种方案也不是那么的完美,如果出现线程业务超时完成的话,那么一样会有别的线程可以获取到锁执行自己的业务,这样虽然不会误删当前线程的锁,但是,这个线程获取到的数据或者变量是上一个锁执行完成之后的,一样可能会造成数据异常。

      如果项目要求高的话,可以尝试使用zookeeper来做分布式锁,或者别的解决方案。

    相关文章

      网友评论

          本文标题:redis分布式锁与多线程

          本文链接:https://www.haomeiwen.com/subject/zzazbftx.html