基于redis实现分布式锁的几种方案以及不足

基于redis实现分布式锁的几种方案以及不足

方案1:setnx 方案(不建议使用)

redis 提供 setnx 命令,是「SET if Not eXists」的缩写,只有不存在时才会设置返回1,否则返回0,如下:

127.0.0.1:6379> setnx javabk.cn 1
(integer) 1
127.0.0.1:6379> setnx javabk.cn 1
(integer) 0

实际设置成功时,表示获取到锁,一般会马上通过 expire 设置过期时间,避免处理业务时没有及时删除导致后面的请求都获取不到锁,具体例子如下:

 public class DistributedLockDemoTest {

    Jedis jedis = new Jedis("127.0.0.1",6379);

    @Test
    public void setnxAndExpire() {
        String myId = UUID.randomUUID().toString();
        String key = "javabk.cn";
        //1. 通过 setnx 抢锁
        long result = jedis.setnx(key, myId);
        //2. 结果判断
        if (result == 1) {//成功获取锁
            try {
                //3. 设置过期,避免死锁
                jedis.expire(key, 30);//30 seconds expired
                //4. 业务处理....
            } finally {
                //5. 释放锁(这里可以优化成:将判断+删除写成lua脚本进行删除)
                if (myId.equals(jedis.get(key))) {//判断value是自身设置才删除
                    jedis.del(key);
                }
            }
        } else {  //获取锁失败
            // ....
        }
    }
}

该方案存在的问题

  1. 存在死锁的可能:如果在setnx设置完成后(代码 [1] 地方),再通过 expire 设置(代码 [3])之前程序重启或者挂了,那么这个key将无法解锁。核心原因是setnx + expire 是通过两次网络进行发送到redis执行的,无法保证其原子性。该问题的解决方案参考方案2 和 方案3
  2. 锁在持有期间过期:假如处理逻辑时间(加期间 FULL GC 时间)超过key的过期时间,那么锁可能别其他客户端获取,同时处理相关逻辑,可能影响业务结果,这个超时问题没很好的彻底解决办法,不过可以通过一些策略来降低发生的概率:尽量控制处理时间(比如查询接口限制超时时间等)+ 预留的 FULL GC 时间 小于key过期时间,同时考虑后台线程对key进行定时续期(有人称 watch dog,redisson框架有支持)。

方案2:set扩展命令

针对方案1存在的问题,在redis版本 >=2.8 ,针对set 命令进行扩展来解决这个setnx + expire 的原子性问题。命令如下:

SET key value [EX seconds] [PX milliseconds] [NX|XX]

其中 EX 表示秒,PX 表示毫秒,NX 表示不存在才设置,XX 表示存在才设置。将命令其实就是将 setnx + expire 2个命令合并成1个,保证了原子性。命令例子如下:

127.0.0.1:6379> set javabk.cn 1 ex 30 nx //不存在时设置成功返回OK
OK
127.0.0.1:6379> ttl javabk.cn
(integer) 24
127.0.0.1:6379> set javabk.cn 1 ex 30 nx //存在时,设置不成功,返回空
(nil)

代码例子

public void setExtendCommand() {
        String myId = UUID.randomUUID().toString();
        String key = "javabk.cn";
        //1. 通过 setnx 抢锁
        String result = jedis.set(key, myId, SetParams.setParams().ex(30).nx());
        System.out.println("result is:" + result);
        //2. 结果判断
        if ("OK".equals(result)) {//成功获取锁
            try {
                //3. 业务处理....
            } finally {
                //4.释放锁
                unLockAfterCompareWithLua(key, myId);
            }
        } else {  //获取锁失败
            // ....
        }
}

public boolean unLockAfterCompareWithLua(String key, String value) {
    String luaSrcipt = "if redis.call('get',KEYS[1]) == ARGV[1] then\n" +
        "redis.call('del',KEYS[1])\n" +
        "return 1 \n" +
        "else\n" +
        "return 0\n" +
        "end";

    List<String> placeHolderKeys = Lists.newArrayList(key);
    List<String> placeHolderValues = Lists.newArrayList(value);
    Object result = jedis.eval(luaSrcipt, placeHolderKeys, placeHolderValues);
    if (result != null  && "1".equals(result.toString())) {
        return true;
    }
    return false;
}

方案3:通过lua脚本打包 setnx + expire 命令

redis可以通过lua脚本打包多个命令进行执行,保证其执行原子性,可以解决 setnx + expire 原子性执行问题。其实多个命令执行的原子性问题都可以通过将其打包成lua脚本来保证原子性执行。

代码例子

public void setNxAndExpireWithLua() {
        String myId = UUID.randomUUID().toString();
        String key = "javabk.cn";
        //放到服务端执行的lua脚本
        String luaSrcipt = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then\n" +
                                "redis.call('expire',KEYS[1],ARGV[2])\n" +
                                "return 1 \n" +
                            "else\n" +
                                "return 0\n" +
                            "end";

        //1. 通过 setnx 抢锁
        List<String> placeHolderKeys = Lists.newArrayList(key);
        List<String> placeHolderValues = Lists.newArrayList(myId, "30");
        Object result = jedis.eval(luaSrcipt, placeHolderKeys, placeHolderValues);//核心改动
        System.out.println("result is:" + result);
        //2. 结果判断
        if ("1".equals(result)) {//成功获取锁(跟lua脚本的返回1对应)
            try {
                //3. 业务处理....
            } finally {
                //4. 释放锁
                unLockAfterCompareWithLua(key, myId);
            }
        } else {  //获取锁失败
            // ....
        }
}

public boolean unLockAfterCompareWithLua(String key, String value) {
    String luaSrcipt = "if redis.call('get',KEYS[1]) == ARGV[1] then\n" +
        "redis.call('del',KEYS[1])\n" +
        "return 1 \n" +
        "else\n" +
        "return 0\n" +
        "end";

    List<String> placeHolderKeys = Lists.newArrayList(key);
    List<String> placeHolderValues = Lists.newArrayList(value);
    Object result = jedis.eval(luaSrcipt, placeHolderKeys, placeHolderValues);
    if (result != null  && "1".equals(result.toString())) {
        return true;
    }
    return false;
}

其实通过方案2和方案3,可以解决大部分业务场景,如果有些业务场景需要锁的可重入性,那么可以参考[可重入性]

方案4 基于redisson(推荐使用)

redisson 是基于一个 redis java client,底层实现做了很多封装,比如分布式锁、读写锁等等,具体请看 官网

核心代码:

public class RedisLockWithRedisson {

    RedissonClient redissonClient = null;

    public RedisLockWithRedisson(String host, int port) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + host + ":" + port);
        config.setLockWatchdogTimeout(10 * 1000);//10s. 覆盖 watch log 默认30s 超时的配置
        redissonClient = Redisson.create(config);
    }

    public boolean lockWithWatchDog(String key,  int waitSecond) throws Exception {

        RLock lock = redissonClient.getLock(key);
        //尝试加锁,第一个参数表示最多等待多少秒。启动 watch log 续期
        boolean locked = lock.tryLock(waitSecond, TimeUnit.SECONDS);
        if (locked) {
            System.out.println("成功获取锁.key:" + key);
            System.out.println("业务处理...");
            //业务处理
            for (int i = 0; i < 10; i++) {
                System.out.println("业务处理中,剩余时间:" + lock.remainTimeToLive());
                Thread.sleep(1500);
            }
            System.out.println("处理业务完成后,锁是否存在:" + lock.isLocked());
            lock.unlock();
            System.out.println("解锁后,锁是否存在:" + lock.isLocked());
            return true;
        } else {
            System.out.println("获取锁失败在等待: " + waitSecond+ "秒,key:" + key);
            return false;
        }
    }
}

说明:redisson 对比上面几个方案,其实实现是类似的,只不过做了大量的封装,使用非常简单,而且内部增加了 watch dog 续期机制。我们看看上面的最核心代码:lock.tryLock(waitSecond, TimeUnit.SECONDS) ,其底层实现就是一个lua脚本,如下:

if (redis.call('exists', KEYS[1]) == 0) then 
redis.call('hincrby', KEYS[1], ARGV[2], 1); 
redis.call('pexpire', KEYS[1], ARGV[1]); 
return nil; 
end; 
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
 redis.call('hincrby', KEYS[1], ARGV[2], 1); 
 redis.call('pexpire', KEYS[1], ARGV[1]); 
 return nil; 
end; 
return redis.call('pttl', KEYS[1]);

通过脚本可发现,其通过 hash结构来支持锁的可重入性,hash key 是给每个线程(客户端)分配的唯一ID,hash value 是同个线程重复成功加锁的次数。加锁成功后,开启一个定时任务每隔一段时间继续续期,默认是过期时间是30秒,每隔 1/3 超时时间进行1次续期,上面例子覆盖默认超时时间,改成10秒。例子中特意实现业务处理时间超过过期时间,但是由于续期机制,保证处理期间不过期。

测试代码:

 @Test
    public void testLockWithWhatDogAndWait() throws Exception {
        String key = "javabk.cn";
        for (int i = 0; i < 2; i++) {
            Runnable runnable = () -> {
                try {
                    lockWithRedisson.lockWithWatchDog(key, 10);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            };
            executor.submit(runnable);
        }
        Thread.currentThread().join();
    }

测试结果如下:由于设置watch dog 超时时间10秒,所以 3秒进行1次续期(1/3 * 10),所以从6970 ttl 变成 8834 主要是由于续期带来的

成功获取锁.key:javabk.cn
业务处理...
业务处理中,剩余时间:9993
业务处理中,剩余时间:8480
业务处理中,剩余时间:6970
业务处理中,剩余时间:8834
业务处理中,剩余时间:7323
业务处理中,剩余时间:9212
业务处理中,剩余时间:7701
获取锁失败在等待: 10秒,key:javabk.cn
业务处理中,剩余时间:9588
业务处理中,剩余时间:8082
业务处理中,剩余时间:9971
处理业务完成后,锁是否存在:true
解锁后,锁是否存在:false

可重入性

可重入性主要是表示同一个线程可以对一个锁重复加锁和释放锁,类似java的 ReentrantLock。因为是针对同一个线程,可以考虑将重入锁的统计和方式记录到本地内存,提升性能。下面例子是基于内存来实现锁的可重入性,也可以通过 redis 的hash 结构来实现,hash key 为给线程分配的唯一ID,value 是重入次数,Redisson 实现锁重入就是这样实现的,可以参考上面的例子。

基于内存实现锁的可重入性核心代码入下:

  private ThreadLocal<Map<String, Integer>> threadLocks = new ThreadLocal<>();
  
  private Map<String, Integer> getCurrentThreadLocks() {
        Map<String, Integer> refs = threadLocks.get();
        if (refs != null) {
            return refs;
        }
        threadLocks.set(new HashMap<>());
        return threadLocks.get();
    }

    //锁-可重入性
    public boolean lockReentrant(String key, String value, int expireSecond) {
        Map<String, Integer> currentThreadLocks = getCurrentThreadLocks();
        Integer thisLockCount = currentThreadLocks.get(key);
        if (thisLockCount != null) {//如果本地有锁,说明已经在redis上加过锁,本地内存进行累计即可
            currentThreadLocks.put(key, thisLockCount + 1);
            return true;
        }
        boolean ok = this.lock(key, value, expireSecond);
        if (!ok) {
            return false;
        }
        currentThreadLocks.put(key, 1);
        return true;
    }

    //解锁-可重入性
    public boolean unlock(String key, String value) {
        Map<String, Integer> currentThreadLocks = getCurrentThreadLocks();
        Integer thisLockCount = currentThreadLocks.get(key);
        if (thisLockCount == null) {
            return true;
        }
        thisLockCount = thisLockCount - 1;
        if (thisLockCount > 0) {
            currentThreadLocks.put(key, thisLockCount);
        } else {//如果没有锁,需要对redis的锁进行删除
            currentThreadLocks.remove(key);
            unLock(key, value);
        }
        return true;
    }

redis分布式锁在集群的问题

如果redis 不仅仅是一个节点,比如搭建主从集群,那么可能存在这样的问题:如果在主节点成功加锁,但是在同步给从节点的时候还没完成将命令同步给从节点时,主节点就挂了,从节点变成了主节点,这时候这个锁在新主节点就不存在,导致新的客户端就可以加锁成功,这样可能导致同一把锁可能被两个客户端持有。其实这种case出现的概率比较低,而且持续时间很短,大部分实际的场景都是可以接受的。不过出于学习,可以了解其解决方案,目前解决方案有 RedLock算法。

RedLock 算法

核心原理主要是:需要提供多个 redis 节点,在加锁时,需向过半节点发送加锁命令,只要过半节点加锁成功,就认为加锁成功,释放时,需要将所有节点发送释放锁命令。但是可能存在以下问题:

  1. 加锁的主节点最好不要建立从节点,否则也会出现多个客户端同时获取同一把锁的可能。

    比如:3对主从节点,客户端分别给主1,主2加锁成功,主3加锁失败(比如网络抖动),由于加锁数量超过1半,所以认为获取锁,这时候还没释放锁,主2挂了failover到主2的从节点,但是主2的加锁命令还没同步到从节点,导致从节点成为新的主节点时没有加锁信息,这时候客户端B来加锁,成功在主2加锁,同时在主3加锁成功,这时候就有2个客户端同时获取锁。虽然出现这种case的可能性比较小,但是理论上是存在这个问题。

  2. 没有开启AOF或者AOF不是每条flush(一般开启也是N秒才flush),在服务挂了重启存在多个客户端获取锁。比如:3个主节点,客户端1分别给主1,主2 加锁成功,主3加锁失败,这时候主2挂了重启,这时候客户端1还没解锁,导致客户端1的加锁数据丢了,然后客户端2这时候在主2+主3加锁成功,这时候就2个客户端同时获取锁。

总结:虽然redis基于RedLock算法可以解决redis主从切换导致的分布式锁的问题,但是其限制条件多,性能下降,导致用不上 redis的高性能特性,如果业务真的需要强一致(任何情况都不能出现多个客户端同时获取锁),那建议不要用redis来实现分布式锁,可以考虑使用 zookeeper 实现分布式锁。不过最终还是得结合业务选择最合适的方案,没有最完美的通用方案,只有最合适自己业务的方案。

附录

本文章的代码:点击传送门

本文由mdnice多平台发布

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

推荐阅读更多精彩内容