当前位置: 首页>后端>正文

基于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多平台发布


https://www.xamrdz.com/backend/3k41934005.html

相关文章: