redis分布式锁与多线程

redis分布式锁与多线程

简介

  • 关于多线程

  首先,先复习一下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记录的方法:

redis分布式锁与多线程_第1张图片

这条指令是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) 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进行操作。

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

  • 解锁

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

redis分布式锁与多线程_第2张图片

解锁需要注意一个问题,如果一个线程由于某些原因,执行任务的时间超过了锁的过期时间,那么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 keys, List args) {
        Object result = stringRedisTemplate.execute((RedisCallback) 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,java)