Redis-分布式锁


前言

​ 在分布式的环境下,对共享资源的并发修改,普通的本地锁显得无能为力。这时我们需要分布式锁解决问题,基于 Redis 构建的分布式锁则是一种常见的实现。

基本要求

​ 一个分布式锁至少要保证 3 个属性

  • 安全:互斥,任何情况下,只有一个客户端能够持有锁。
  • 无死锁:即使持有锁的客户端奔溃或分区,其它活动的客户端仍然可以获得锁。
  • 容错:只要大部分的 Redis 节点存活,客户端可以获得锁。

WATCH

​ 在事务执行前,由客户端 watch(监视)key,如果被其它的客户端修改,则在事务执行时失败。watch 并不能够阻止其它客户端修改数据,被称为乐观锁

基于 Redis 构建的分布式锁

简易锁

​ 锁的基本操作:获取锁 -> 执行操作 -> 释放锁。简易锁对锁的基本操作做了基本实现,在一些情况下并不能正常运作。但这是一个健壮的分布式锁的基本,后面会对可能面临到的问题,对锁操作实现做改进。

1
2
3
4
5
6
7
8
9
// 获取锁
public boolean lock(String key) {
    return jedis.setnx(key, "") == 1;
}

// 释放锁
public boolean unlock(String key) {
    return jedis.del("") == 1;
}

失败自动重试

​ 在简易锁中,一旦锁获取失败,则直接返回。如果想让获取锁失败后,不断尝试获取,直到成功。又或者在给出的超时时间范围内,重试获取锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 失败自动重试获取锁
public boolean lock(String key) throws InterruptedException {
    while (jedis.setnx(key, "") == 1) {
        // 防止 CPU 资源过度消耗
        TimeUnit.MILLISECONDS.sleep(10);
    }
    return true;
}

// 指定超时时间内,重试获取锁
public boolean lock(String key, long timeout) throws InterruptedException {
    long endTime = System.currentTimeMillis() + timeout;
    while (System.currentTimeMillis() <= endTime) {
        if (jedis.setnx(key, "") == 1) {
            return true;
        }
        // 防止 CPU 资源过度消耗
        TimeUnit.MILLISECONDS.sleep(10);
    }
    return false;
}

死锁

​ 当一个进程获取锁后,在释放锁之前奔溃,导致锁无法释放,陷入死锁。通过对锁设置过期时间,可以让锁在指定时间后得到释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 设置过期时间
public boolean lock(String key, long timeout) throws InterruptedException {
    long endTime = System.currentTimeMillis() + timeout;
    while (System.currentTimeMillis() <= endTime) {
        SetParams setParams = SetParams.setParams().ex(5).nx();
        String status = jedis.set(key, "", setParams);
        if ("OK".equals(status)) {
            return true;
        }
        // 防止 CPU 资源过度消耗
        TimeUnit.MILLISECONDS.sleep(10);
    }
    return false;
}

误释放

​ 进程 A 获取到锁后,执行操作… 嗯,可能需要点时间。

​ 而就在这个时候,锁的过期时间到了。被释放后,由进程 B 获取到。没过多久,A 执行完操作,并执行释放锁操作,导致 B 获取的锁被释放。可以通过在加锁时,设置一个标识符并返回,在释放锁时通过匹配标识符进行释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 对锁设置 uuid 值并返回
public String lock(String key, long timeout) throws InterruptedException {
    long endTime = System.currentTimeMillis() + timeout;
    while (System.currentTimeMillis() <= endTime) {
        String uuid = UUID.randomUUID().toString();
        SetParams setParams = SetParams.setParams().ex(5).nx();
        String status = jedis.set(key, uuid, setParams);
        if ("OK".equals(status)) {
            return uuid;
        }
        // 防止 CPU 资源过度消耗
        TimeUnit.MILLISECONDS.sleep(10);
    }
    return null;
}

// 释放锁校验 uuid
// 校验跟删除不是原子操作,需要使用 Lua 脚本保证多个指令的原子性执行
public boolean unlock(String key, String identifier) {
    String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "return redis.call('del', KEYS[1]) " +
        "else " +
        "return 0 " +
        "end";
    return jedis.eval(luaScript, Collections.singletonList(key),
                      Collections.singletonList(identifier)).equals(1L);
}

Redlock

​ 上述的分布式锁运行在单实例的 Redis 中 ,表面上能够很好的运行,实际存在单点故障的问题。没问题,我们加个 slave 节点吧。但由于复制机制是异步进行的,会违反基本要求中的安全属性。

  1. 客户端 Amaster 获得锁。
  2. master 在将 lockkey 传输给 slave 之前奔溃。
  3. slave 晋升为 master
  4. 客户端 B 从新的 master 中获得锁,B 持有的锁与 A 持有的锁,锁定同一个资源。

​ 对于基于 Redis 的分布式锁实现,作者给出了 Redlock[3] 算法。它使用多 Redis 实例解决单点故障问题,实例间完全独立避免了异步复制带来的问题。

算法描述

  1. 客户端获取当前时间(毫秒)。
  2. 依次从 NRedis 相互独立的节点,使用相同的 key随机值,尝试获取锁。对于客户端获取锁的超时时间,应该小于锁的自动释放时间。比如,锁的自动释放时间为 10s,那么客户端获取这个锁的超时时间可以在 5~50ms 之间。这样做,可以防止客户端向一个不可用的 Redis 节点请求等待过多的时间,当服务节点不可用时,应尽快转向下一个节点。
  3. 客户端计算出向每个节点获取锁的时间,使用当前时间减去步骤1获取的时间戳。当客户端获取大多数的锁(N/2 + 1,分布式环境中讨论的 N 通常为奇数),且获取所有锁的总时间小于锁的自动释放时间,这样的锁才可以被使用。
  4. 如果锁被获取,那么它的有效时间应该为:初始的有效时间 - 步骤3获取锁的时间
  5. 如果客户端获取锁失败,它会去释放所有实例上的锁(即使是那些它无法锁定的实例)。

Redisson 实现 Redlock

RedissonJava 的一个 Redis 客户端,提供了 Redlock 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class RedissonRedlock {

    private RedissonClient redisson1;
    private RedissonClient redisson2;
    private RedissonClient redisson3;

    @Before
    public void before() {
        Config config1 = new Config();
        Config config2 = new Config();
        Config config3 = new Config();
        
        config1.useSingleServer().setAddress("redis://192.168.124.134:6379");
        config2.useSingleServer().setAddress("redis://192.168.124.135:6379");
        config3.useSingleServer().setAddress("redis://192.168.124.136:6379");
        
        redisson1 = Redisson.create(config1);
        redisson2 = Redisson.create(config2);
        redisson3 = Redisson.create(config3);
    }

    @Test
    public void testRedlock() throws InterruptedException {
        RLock lock1 = redisson1.getLock("lock1");
        RLock lock2 = redisson2.getLock("lock2");
        RLock lock3 = redisson3.getLock("lock3");

        // 同时加锁:lock1 lock2 lock3
        // 红锁在大部分节点上加锁成功就算成功。
        RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);

        // 为加锁等待100秒时间,并在加锁成功10秒钟后自动解开
        boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);

        if (res) {
            System.out.println("成功获取 Redlock");
        }
        lock.unlock();
    }
}

参考

​ [1] Josiah L. Carlson 著. 黄健宏 译. Redis实战. 6.2 分布式锁

​ [2] 钱文品. Redis 深度历险:核心原理与应用实践. 1.3 千帆竞发——分布式锁

​ [3] Redis 官方文档——Distributed locks with Redis