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

一灰灰blog开源RedisRedis约 2232 字大约 7 分钟

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

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

I. 背景知识

借助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相同

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

  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,导致锁超时失效,但是自己又不知道,此时就会出现多个业务方同时持有锁的场景
  • 对于锁超时的场景,需要仔细考虑,是否会出现并发问题
  • 确保只能释放自己的锁(以防止释放了别人的锁,出现问题)

参考链接

Loading...