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记录的方法:

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来做分布式锁,或者别的解决方案。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,711评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,932评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,770评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,799评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,697评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,069评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,535评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,200评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,353评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,290评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,331评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,020评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,610评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,694评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,927评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,330评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,904评论 2 341

推荐阅读更多精彩内容