Redis实现分布式锁,以及可重入锁思路

Redis为单进程单线程模式,采用队列模式将并发访问的请求变成串行访问,并且多客户端对Redis的访问不存在竞争关系。

以下将会讲解如何使用Redis实现一个可靠的,自旋分布式锁。以及实现的思路,还有实现时会遇到的常见错误。
当然,这些实现的都是不可重入的。在最后,还会讲一下,实现可重入锁的思路。



实现原理


Redis操作

Redis提供了一些基本指令可以用来实现分布式锁,例如
SET,SENTX,GETSET,INCR,DEL,GET 等操作,以下是对这些指令的基本用法:

>  SET key val [NX|XX] [EX seconds | PX milliseconds]
// 将字符串值key 关联到 value。成功后,返回值为"OK"。后面有两个可选参数
// 可选参数 NX|XX:NX表示只在键不存在时,才对键进行操作,缺省方式是NX。XX表示只在键存在时对键进行操作
// 可选参数 EX|PX:键过期的时间单位,后面跟长整型数字表示过期时间。EX表示秒,PX表示毫秒。缺省不设置过期时间。

>  SETNX key val 
// 当且仅当key值不存在,将key对应的值设置为value,并且返回1,否则不做任何操作,返回0

>  GETSET key val
// 获取key的旧值,并且将新的value放入

>  INCR key
// 将key中存储的数字自增1并且返回结果。

>  DEL key
// 将对应Key的值删除


锁的可靠性

为了确保分布式锁可用,我们至少要确保锁的可靠性,要满足一下四个条件:

1)互斥性,在任意时刻,只能有一个客户端(或者说业务请求)获得锁,并且也只能由该客户端请求解锁成功。
2)避免死锁,即使获取了锁的客户端崩溃没有释放锁,也要保证锁正常过期,后续的客户端能正常加锁。
3)容错性,只要大部分Redis节点可用,客户端就能正常加锁。
4)自旋重试,获取不到锁时,不要直接返回失败,而是支持一定的周期自旋重试,设置一个总的超时时间,当过了超时时间以后还没有获取到锁则返回失败。(这一点很重要,我发现网上很多方案并没有把这个功能加上,只尝试一次加锁请求失败就返回了,加了自旋重试更好一些)


参数设置

这里有三个参数需要考虑,一般来说,设定的值,需要根据实际场景来判断:

  • 锁的过期时间 (EXPIRE_TIME)
    太短可能过早的释放锁,造成数据安全问题。太长的话,如果客户端挂掉,会长时间无法释放锁,导致其他客户端锁请求阻塞或者失败(这种场景太少见)
    我们一般会预估一下加锁需要进行的操作最长耗时,然后在最长耗时基础上再加一个buffer的时间来确定。(buffer比例多少不确定,这个自行判断吧)需要保证锁在任务执行完之前不会过期。

  • 自旋间隔时间 (WAIT_INTERVAL)
    适当间隔就好,一般是50~100ms

  • 获取锁的超时时间 (ACCQUIRE_TIME_OUT)
    在激烈的竞争环境下,超时时间设置太短会导致失败次数显著增加。建议至少设置成和锁的过期时间一样。


如何实现


代码示例

首先是代码示例,以下是使用了两种方式实现的 Redis锁:
第一种方式是利用了 Redis 的 SET key value [NX|XX] [EX seconds | PX milliseconds]
第二种方式利用了 Redis 的 SETNX key value 和 GETSET key value

/**
 * @Author Antony
 * @Since 2018/5/25 22:48
 */
public class RedisLock {

    private static final Logger logger = LoggerFactory.getLogger(RedisLock.class);
    
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME_SECOND = "EX";

    private static final int ACQUIRE_LOCK_TIME_OUT_IN_MS = 5*1000;//获取锁超时时间
    private static final int EXPIRE_IN_SECOND = 5;                  //锁超时时间
    private static final int WAIT_INTERVAL_IN_MS = 100;             //自旋重试间隔

    private static JedisPool jedisPool = JedisPoolFactory.getJedisPool();


    /**
     * 使用 set key value expireTime 获取锁
     * @param lockKey
     * @return
     */
    public static boolean tryLockWithSet(String lockKey){
        boolean flag = false;
        long timeoutAt = System.currentTimeMillis() + ACQUIRE_LOCK_TIME_OUT_IN_MS;    //此次获取锁的超时时间点
        try (Jedis jedis = jedisPool.getResource()){
            String result;
            while (true) {
                long now = System.currentTimeMillis();
                if(timeoutAt < now){
                    break;
                }
                result = jedis.set(lockKey, "", SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME_SECOND, EXPIRE_IN_SECOND);
                if(LOCK_SUCCESS.equals(result)){
                    flag = true;
                    return flag;
                }
                TimeUnit.NANOSECONDS.sleep(WAIT_INTERVAL_IN_MS);
            }

        } catch (InterruptedException e) {
            logger.error("accquire redis lock error...", e);
            e.printStackTrace();
        }

        if(!flag){
            logger.error("cannot accquire redis lock...");
        }

        return flag;
    }

    /**
     * 使用 setnx 和 getset 方式获取锁
     * @param lockKey
     * @return
     */
    public static boolean tryLockWithSetnx(String lockKey){
        boolean flag = false;
        try (Jedis jedis = jedisPool.getResource()) {
            long timeoutAt = System.currentTimeMillis() + ACQUIRE_LOCK_TIME_OUT_IN_MS;    //此次获取锁的超时时间点
            while (true){
                long now = System.currentTimeMillis();
                if(timeoutAt < now){
                    break;
                }

                String expireAt = String.valueOf(now + EXPIRE_IN_SECOND*1000);  //过期时间戳作为value
                long ret = jedis.setnx(lockKey, expireAt);
                if(ret == 1){//已取得锁
                    flag = true;
                    return flag;
                }else {
                    // 未获取锁,尝试重新获取
                    // 此处使用double check 的思想,防止多线程同时竞争到锁
                    // 1) 先获取上一个锁的过期时间,校验当前是否过期。
                    // 2) 如果过期了,尝试使用getset方式获取锁。此处可能存在多个线程同时执行到的情况。
                    // 3) getset更新过期时间,并且获取上一个锁的过期时间。
                    // 4) 如果getset获取到的oldExpireAt 已过期,说明获取锁成功。
                    //    如果和当前比未过期,说明已经有另一个线程提前获取到了锁
                    //    这样也没问题,只是短暂的将上一个锁稍微延后一点时间(只有在A和B线程同时执行到getset时,才会出现,延长的时间很短)
                    String oldExpireAt = jedis.get(lockKey);
                    if(oldExpireAt != null && Long.valueOf(oldExpireAt) < now){
                        oldExpireAt = jedis.getSet(lockKey, expireAt);
                        if(Long.parseLong(oldExpireAt) < now){
                            flag = true;
                            return flag;
                        }
                    }
                }

                TimeUnit.NANOSECONDS.sleep(WAIT_INTERVAL_IN_MS);
            }
        } catch (InterruptedException e) {
            logger.error("accquire redis lock error...", e);
            e.printStackTrace();
        }

        if(!flag){
            logger.error("cannot accquire redis lock...");
        }

        return flag;
    }

    /**
     * 释放锁
     * @param lockKey
     */
    public static void unLock(String lockKey){
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.del(lockKey);
        }
    }

}


思路详解

1)第一种方式,tryLockWithSet 是使用了 Redis set 的同时指定过期时间的功能。
这个方式的特点就是,简单有效,并且只有一个指令操作。一般也推荐这么使用。

注意,有一种常见的错误方式是使用 setnxexpire 组合实现加锁,这是两个操作,并没有保证原子性。如果客户端在setnx之后崩溃,那么将导致锁无法释放
错误代码如下:

    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }


2)第二种方式,tryLockWithSetnx,是把锁的过期时间,当做value存储起来。
这个方式,解决了刚才提出的setnx 和 expire 操作无法保证原子性的问题,虽然使用了setnx操作,但是没有给redis的key设置过期时间。而是把该锁的过期时间作为value保存,在获取锁的时候判断是否过期期并抢占锁。这就需要保证 各个客户端的系统时间都严格一致,不然锁的持有时间就无法真正保证。

在这里简单解释一下部分核心逻辑,主要是获取锁失败的重试阶段:

  • 如果锁获取失败,代表当前已有其他客户端持有锁,那么就根据key获取value,得到该锁的过期时间和当前时间比较,可以知道是锁否过期。如果没过期,则进入下一个自旋。

  • 如果过期,则使用getset操作,尝试抢占锁。该操作将当前锁的过期时间放入,成功后将旧值返回,并进行再一次check,确认是否拿到锁。
    注意:这里可能会出现竞争,两个线程get到旧值后都判断过期,然后都执行了getset操作。
    关键在于getset拿到的value后,进行的再一次check,和当前时间判断,替换掉的旧值是否是已过期的值。如果小于当前时间,则表示替换掉的是已过期的锁。获取锁成功。如果判断没有小于,则表示替换掉的是另一个线程设置进去的值,进入下一个自旋。
    尽管执行成功了getset操作,这也只是将上一个成功拿到的锁过期时间稍微延迟,这个延迟时间很小,可以忽略不计。

举个栗子
线程A和B尝试setnx失败,然后同时拿到了value,并且都发现过期,然后都尝试进行getset操作。A线程先执行了getset操作,获取锁成功。B线程后执行了getset操作,那么B执行的就是把A的过期时间拿到,然后把自己的过期时间设置过去。这样的操作相当于把A的锁过期时间重置。
由于A和B同时到达了竞态条件,那么这两个尝试设置的过期时间也不会相差太大,差别可以忽略不计。


可重入锁

上面的实现方式,都是不可重入的分布式锁,任何重入锁的尝试都会导致死锁的发生。导致响应超时。

那么,要实现分布式锁的可重入,那就需要设计的可以存储更多信息。
目前我知道的有两种方式(只提供思路):

1)此种方式实现较为简单:value中多存储一个 全局唯一的requestId,代表客户端请求标识。具体可以使用UUID。在重入的情况下使用同一个UUID,就能判断是否是一个请求的锁重入,从而获取锁。

2)存储锁的重入次数,以及分布式环境下唯一的线程标识。
如何在分布式线程中标识唯一线程:
MAC地址 + jvm进程ID + 线程ID(或者线程地址都行),三者结合即可唯一分布式环境中的线程。
锁的信息采用json,存储格式如下:

{
    "count":1,
    "expireAt":147506817232,
    "jvmPid":22224,
    "mac":"28-D2-44-0E-0D-9A",
    "threadId":14
}



(如果有什么错误或者建议,欢迎留言指出)
(本文内容是对各个知识点的转载整理,用于个人技术沉淀,以及大家学习交流用)


参考资料:
Redis,Zk分布式锁的实现与区别
Redis实现分布式锁,以及如何处理超时情况
Redis分布式锁思考
Redis分布式锁处理并发问题

Redis分布式锁的正确实现方式——阿里云栖社区
基于Redis的分布式锁到底安全吗——多节点Redis锁的讨论
Java实现基于redis的分布式可重入锁

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