Redis实现分布式锁相关注意事项

Redis实现分布式锁相关注意事项

查看了很多关于redis实现分布式锁的文章,无疑要设计一个靠谱的分布式并不太容易,总会出现各类鬼畜的问题;如今就来小述一下,在设计一个分布式锁的过程当中,会遇到一些什么问题java

I. 背景知识

借助redis来实现分布式锁(咱们先考虑单机redis的模式),首先有必要了解下如下几点:redis

  • 单线程模式
  • setnx : 当不存在时,设置value,并返回1; 不然返回0
  • getset : 设置并获取原来的值
  • expire : 设置失效时间
  • get : 获取对应的值
  • del : 删除
  • ttl : 获取key对应的剩余时间,若key没有设置过超时时间,或者压根没有这个key则返回负数(多是-1,-2)
  • watch/unwatch : 事务相关

II. 方案设计

1. 设计思路

获取锁:安全

  • 调用 setnx 尝试获取锁,若是设置成功,表示获取到了锁
  • 设置失败,此时须要判断锁是否过时
    • 未过时,则表示获取失败;循环等待,并再次尝试获取锁
    • 已过时,getset再次设置锁,判断是否获取了锁(根据返回的值进行判断,后面给出具体的方案)
    • 若失败,则从新进入获取锁的逻辑

释放锁:服务器

  • 一个原则就是确保每一个业务方释放的是本身的锁

2. getset的实现方案

网上一种常见的case,主要思路以下网络

  • setnx 尝试获取锁
  • 失败,则 get 获取锁的value (通常是 uuid_timstamp)
  • 判断是否过时,若没有过时,则表示真的获取失败
  • 若过时,则采用 getset设置,尝试获取锁

实现代码以下并发

public class DistributeLock {

    private static final Long OUT_TIME = 30L;

    public String tryLock(Jedis jedis, String key) {
        while (true) {
            String value = UUID.randomUUID().toString() + "_" + System.currentTimeMillis();
            Long ans = jedis.setnx(key, value);
            if (ans != null && ans == 1) { // 获取锁成功
                return value;
            }

            // 锁获取失败, 判断是否超时
            String oldLock = jedis.get(key);
            if (oldLock == null) {
                continue;
            }

            long oldTime = Long.parseLong(oldLock.substring(oldLock.lastIndexOf("_") + 1));
            long now = System.currentTimeMillis();
            if (now - oldTime < OUT_TIME) { // 没有超时
                continue;
            }

            String getsetOldVal = jedis.getSet(key, value);
            if (Objects.equals(oldLock, getsetOldVal)) { // 返回的正好是上次的值,表示锁获取成功
                return value;
            } else { // 表示返回的是其余业务设置的锁,赶忙的设置回去
                jedis.set(key, getsetOldVal);
            }
        }
    }

    public void tryUnLock(Jedis jedis, String key, String uuid) {
        String ov = jedis.get(key);
        if (uuid.equals(ov)) { // 只释放本身的锁
            jedis.del(key);
        }
    }
}

观察获取锁的逻辑,特别是获取超时锁的逻辑,很容易想到有一个问题 getSet 方法会不会致使写数据混乱的问题,简单来讲就是多个线程同时判断锁超时时,执行 getSet设置锁时,最终获取锁的线程,可否保证和redis中的锁的value相同app

上面的实现方式,一个混乱的case以下:dom

  1. 三个线程a,b,c 都进入到了锁超时的阶段
  2. 线程a, 获取原始值 oldVal, 并设置 t1
  3. 线程b, 获取线程a设置的 t1, 并重设为 t2
  4. 线程c, 获取线程b设置的 t2, 并重设为 t3
  5. 线程a,判断,并正式获取到锁
  6. 线程b,判断失败,恢复原来锁的内容为t1
  7. 线程c, 判断失败,恢复原来锁的内容为t2
  8. 问题出现了,获取锁的线程a,指望所得内容为t1, 可是实际为t2; 致使没法释放锁

实际验证分布式

在上面的代码中,配合测试case,加上一些日志输出测试

public static String tryLock(Jedis jedis, String key) throws InterruptedException {
    String threadName = Thread.currentThread().getName();
    while (true) {
        String value = threadName + "_" + UUID.randomUUID().toString() + "_" + System.currentTimeMillis();
        Long ans = jedis.setnx(key, value);
        if (ans != null && ans == 1) { // 获取锁成功
            return value;
        }

        // 锁获取失败, 判断是否超时
        String oldLock = jedis.get(key);
        if (oldLock == null) {
            continue;
        }

        long oldTime = Long.parseLong(oldLock.substring(oldLock.lastIndexOf("_") + 1));
        long now = System.currentTimeMillis();
        if (now - oldTime < OUT_TIME) { // 没有超时
            continue;
        }


        // 强制使全部的线程均可以到这一步
        Thread.sleep(50);
        System.out.println(threadName + " in getSet!");


        // 人工接入,确保t1 获取到锁, t2 获取的是t1设置的内容, t3获取的是t2设置的内容
        if ("t2".equalsIgnoreCase(threadName)) {
            Thread.sleep(20);
        } else if ("t3".equalsIgnoreCase(threadName)) {
            Thread.sleep(40);
        }

        String getsetOldVal = jedis.getSet(key, value);
        System.out.println(threadName + " set redis value: " + value);

        if (Objects.equals(oldLock, getsetOldVal)) { // 返回的正好是上次的值,表示锁获取成功
            System.out.println(threadName + " get lock!");
            if ("t1".equalsIgnoreCase(threadName)) {
                // t1获取到锁,强制sleep40ms, 确保线t2,t3也进入了 getSet逻辑
                Thread.sleep(40);
            }
            return value;
        } else { // 表示返回的是其余业务设置的锁,赶忙的设置回去
            // 人肉介入,确保t2优先执行,并设置回t1设置的值, t3后执行设置的是t2设置的值
            if ("t3".equalsIgnoreCase(threadName)) {
                Thread.sleep(40);
            } else if ("t2".equalsIgnoreCase(threadName)){
                Thread.sleep(20);
            }
            jedis.set(key, getsetOldVal);
            System.out.println(threadName + " recover redis value: " + getsetOldVal);
        }
    }
}

测试case

@Test
public void testLock() throws InterruptedException {
    // 先无视获取jedis的方式
    JedisPool jedisPool = cacheWrapper.getJedisPool(0);
    Jedis jedis = jedisPool.getResource();
    String lockKey = "lock_test";

    String old = DistributeLock.tryLock(jedis, lockKey);
    System.out.println("old lock: " + old);

    // 确保锁超时
    Thread.sleep(40);

    // 建立三个线程
    Thread t1 = new Thread(() -> {
        try {
            Jedis j =jedisPool.getResource();
            DistributeLock.tryLock(j, lockKey);
            System.out.println("t1 >>>> " + j.get(lockKey));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "t1");
    Thread t2 = new Thread(() -> {
        try {
            Jedis j =jedisPool.getResource();
            DistributeLock.tryLock(j, lockKey);
            System.out.println("t2 >>>>> " + j.get(lockKey));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "t2");
    Thread t3 = new Thread(() -> {
        try {
            Jedis j =jedisPool.getResource();
            DistributeLock.tryLock(j, lockKey);
            System.out.println("t3 >>>>> " + j.get(lockKey));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, "t3");


    t1.start();
    t2.start();
    t3.start();


    Thread.sleep(10000);
};

部分输出结果:

main in getSet!
main set redis value: main_d4cc5d69-5027-4550-abe1-10126f057779_1515643763130
main get lock!
old lock: main_d4cc5d69-5027-4550-abe1-10126f057779_1515643763130
t1 in getSet!
t2 in getSet!
t1 set redis value: t1_105974db-7d89-48bf-9669-6f122a3f9fb6_1515643763341
t1 get lock!
t3 in getSet!
t2 set redis value: t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341
t1 >>>> t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341
t3 set redis value: t3_9aa5d755-43b2-43bd-9a0b-2bad13fa31f6_1515643763345
t2 recover redis value: t1_105974db-7d89-48bf-9669-6f122a3f9fb6_1515643763341
t3 recover redis value: t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341

重点关注 t1 >>>> t2_be06f80a-9b70-4a0e-a86d-44337abe8642_1515643763341,表示t1线程过去了锁,可是锁的内容不是其value,即使t2去恢复,也会被t3给覆盖


如何解决上面这个问题呢?

上面是典型的并发致使的问题,固然能够考虑从解决并发问题的角度出发来考虑,一个常见的方式就是加锁了,思路以下:(不详细展开了)

  • 在判断超时以后,加锁
  • 再次获取对应的值,判断是否超时,是则执行上面的操做
  • 不然退出逻辑,继续循环

这种实现方式,会有如下的问题:

  • getset 这个方法执行,可能致使写入脏数据
  • 基于服务器时钟进行超时判断,要求全部服务器始终一致,不然有坑

3. expire实现方式

相比于前面一种直接将value设置为时间戳,而后来比对的方法,这里则直接借助redis自己的expire方式来实现超时设置,主要实现逻辑相差无几

public class DistributeExpireLock {

    private static final Integer OUT_TIME = 3;

    public static String tryLock(Jedis jedis, String key) {
        String value = UUID.randomUUID().toString();

        while(true) {
            Long ans = jedis.setnx(key, value);
            if (ans != null && ans == 1) { // 获取锁成功
                jedis.expire(key, OUT_TIME); // 主动设置超时时间为3s
                return value;
            }

            // 获取失败,先确认下是否有设置国超是时间
            // 防止锁的超时时间设置失效,致使一直竞争不到
            if(jedis.ttl(key) < 0) {
                jedis.expire(key, OUT_TIME);
            }
        }
    }


    public static void tryUnLock(Jedis jedis, String key, String uuid) {
        String ov = jedis.get(key);
        if (uuid.equals(ov)) { // 只释放本身的锁
            jedis.del(key);
            System.out.println(Thread.currentThread() +" del lock success!");
        } else {
            System.out.println(Thread.currentThread() +" del lock fail!");
        }
    }
}

获取锁的逻辑相比以前的,就简单不少了,接下来则须要简单的分析下,上面这种实现方式,会不会有坑呢?咱们主要看一下获取锁失败的场景

  • 若是获取锁失败
  • 表示有其余的业务方已经获取到了锁
  • 此时,只能等持有锁的业务方主动释放锁
  • 判断锁是否设置了超时时间,若没有则加一个(防止设置超时时间失败致使问题)

从上面这个逻辑来看问题不大,可是有个问题,case :

  • 如某个业务方setnx获取到了锁,可是由于网络问题,过了好久才获取到返回,此时锁已经失效并被其余业务方获取到了,就会出现多个业务方同时持有锁的场景

III. 小结说明

想基于redis实现一个相对靠谱的分布式锁,须要考虑的东西仍是比较多的,并且这种锁并不太适用于业务要求特别严格的地方,如

  • 一个线程持有锁时,若是发生gc,致使锁超时失效,可是本身又不知道,此时就会出现多个业务方同时持有锁的场景
  • 对于锁超时的场景,须要仔细考虑,是否会出现并发问题
  • 确保只能释放本身的锁(以防止释放了别人的锁,出现问题)

参考连接

V. 其余

声明

尽信书则不如,已上内容,纯属一家之言,因本人能力通常,看法不全,若有问题,欢迎批评指正

扫描关注,java分享

QrCode

相关文章
相关标签/搜索