Redis 实现分布式锁

2,444 阅读7分钟

在单节点情况下,实现线程安全需要靠同步状态来控制。而在分布式应用中,使程序正确执行不被并发问题影响,就需要分布式锁来控制。

在单节点中,需要用一个并发线程都能访问到的资源的状态变化来控制同步。在分布式应用中,使用应用所有节点都能访问到的 Redis 中的某个 key 来控制并发问题。

单节点 Redis 分布式锁

setnx

setnx 指令会在 key 不存在的情况下放入 redis,如果存在则不会设置。

>setnx lock:distributed true
OK
...
other code
...
>del lock:distributed

这种方式的问题在于,执行到 other code 时,程序出现异常,导致 del 指令不会被执行,key 没有被释放,这样会陷入死锁。

setnx then expire

为了解决死锁,乍一看可以使用 expire 来给 key 设置超时时间。

>setnx lock:distributed true
OK
>expire lock:distributed 5
...
other code
...
>del lock:distributed

这种处理其实仍然有问题,因为 setnxexpire 不是原子操作, 执行 expire 语句之前可能发生异常。死锁仍然会出现。

set and expire

为了解决非原子性操作被中断的问题,在 Redis 2.8 中加入了 setnxexpire 组合在一起的原子指令。

>set lock:distributed true ex 5 nx
OK
...
other code
...
>del lock:distributed

这种方式保证了加锁并设置有效时间操作的原子性,但是依然有问题。

假设我们在加锁与释放锁之间的业务代码执行时间超过了设置的有效时间,此时锁会因为超时被释放。会导致两种情况:

  1. 其他节点 B 获取锁之后,执行超时节点 A 执行完成,释放了 B 的锁。
  2. 其它节点获取到了锁,执行临界区代码时就可能会出现并发问题。

解决锁被其他线程释放问题

因为在加锁时,各个节点使用的同一个 key,所以会存在超时节点释放了当前加锁节点的锁的情况。这种情况下,可以给加锁的 key 设置一个随机值,删除的时候需要判断 key 当前的 value 是不是等于随机值。

val = Random.nextInt();
if( redis.set(key,val,true,5) ){
	...
	other code
	...
	value = redis.get(key);
	if(val == value){
		redis.delete(key);
	}
}

上述代码实现了根据随机值删除的逻辑,但是获取 value 直到 delete 指令并非是原子指令,仍然可能有并发问题。这时候需要使用 lua 脚本处理,因为 lua 脚本可以保证连续多个指令原子执行。

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这种方式可以避免锁被其他线程释放的问题。

临界区并发问题

临界区代码出现并发问题的本质是业务代码执行时间大于锁过期时间。

我们可以定时刷新加锁时间,保证业务代码在锁过期时间内执行完成。

private volatile boolean isFlushExpiration = true;

while(redis.set(lock, val, NOT_EXIST, SECONDS, 20)){
    Thread thread = new Thread(new FlushExpirationTherad());
	thread.setDeamon(true);
  	thread.start();
    ...
    other code
    ... 
}

isFlushExpiration = false;
String deleteScript = "if redis.call("get",KEYS[1]) == ARGV[1] then" 
    + "return redis.call("del",KEYS[1])"
    + "else return 0 end";
redis.eval(deleteScript,1,key,val);
    

private class FlushExpirationTherad implements Runnable{
    @Override
	public void run(){
        while(isFlushExpiration){
            String checkAndExpireScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('expire',KEYS[1],ARGV[2]) " +
                        "else return 0 end";
            redis.eval(checkAndExpireScript,1,key,val,"20");
            // 每隔十秒检查是否完成
            Thread.sleep(10);
        }
    }    
}

这种实现是用一个线程定期监控客户端是否执行完成。也可以由服务端实现心跳检测机制来保证业务完成(Zookeeper)。

所以实现单节点 Redis 分布式锁要关注三个关键问题:

  1. 获取锁与设置超时时间实现为原子操作(Redis2.8 开始已支持)
  2. 设置随机字符串保证释放锁时能保证只释放自己持有的锁(给对应的 key 设置随机值)
  3. 判断与释放锁必须实现为原子操作(lua 脚本实现)

多节点 Redis 分布式锁

为了保证项目的高可用性,项目一般都配置了 Redis 集群,以防在单节点 Redis 宕机之后,所有客户端都无法获得锁。

在集群环境下,Redis 存在 failover 机制。当 Master 节点宕机之后,会开始异步的主从复制(replication),这个过程可能会出现以下情况:

  1. 客户端 A 获取了 Master 节点的锁。
  2. Master 节点宕机了,存储锁的 key 暂未同步到 Slave 上。
  3. Slave 节点升级为 Master 节点。
  4. 客户端 B 从新的 Master 节点上获取到了同一资源的锁。

在这种情况下,锁的安全性就会被打破,Redis 作者 antirez 针对此问题设计了 Redlock 算法。

Redlock 算法

Redlock 算法获取锁时客户端执行步骤:

  1. 获取当前时间(start)。
  2. 依次向 N 个 Redis 节点请求锁。请求锁的方式与从单节点 Redis 获取锁的方式一致。为了保证在某个 Redis 节点不可用时该算法能够继续运行,获取锁的操作都需要设置超时时间,需要保证该超时时间远小于锁的有效时间。这样才能保证客户端在向某个 Redis 节点获取锁失败之后,可以立刻尝试下一个节点。
  3. 计算获取锁的过程总共消耗多长时间(consumeTime = end - start)。如果客户端从大多数 Redis 节点(>= N/2 + 1) 成功获取锁,并且获取锁总时长没有超过锁的有效时间,这种情况下,客户端会认为获取锁成功,否则,获取锁失败。
  4. 如果最终获取锁成功,锁的有效时间应该重新设置为锁最初的有效时间减去 consumeTime
  5. 如果最终获取锁失败,客户端应该立刻向所有 Redis 节点发起释放锁的请求。

在释放锁时,需要向所有 Redis 节点发起释放锁的操作,不管节点是否获取锁成功。因为可能存在客户端向 Redis 节点获取锁时成功,但节点通知客户端时通信失败,客户端会认为该节点加锁失败。

Redlock 算法实现了更高的可用性,也不会出现 failover 时失效的问题。但是如果有节点崩溃重启,仍然对锁的安全性有影响。假设共有 5 个 Redis 节点 A、B、C、D、E:

  1. 客户端 A 获取了 A、B、C 节点的锁,但 D 与 E 节点的锁获取失败。
  2. 节点 C 崩溃重启,但是客户端 A 在 C 上加的锁没有持久化下来,重启后丢失
  3. 节点 C 重启后,客户端 B 锁住了 C、D、E,获取锁成功。

在这种情况下,客户端 A 与 B 都获取了访问同一资源的锁。

这里第 2 步中节点 C 锁丢失的问题可能由多种原因引起。默认情况下,RedisAOF 持久化方式是每秒写一次磁盘(fsync),这情况下就有可能丢失 1 秒的数据。我们也可以设置每次操作都触发 fsync,这会影响性能,不过即使这样设置,也有可能由于操作系统的问题导致操作写入失败。

为了解决节点重启导致的锁失效问题,antirez 提出了延迟重启的概念,即当一个节点崩溃之后并不立即重启,而是等待与分布式锁相关的 key 的有效时间都过期之后再重启,这样在该节点重启后也不会对现有的锁造成影响。

一些插曲

关于 Redlock 的安全性问题,在分布式系统专家 Martin Kleppmann 和 Redis 的作者 antirez 之间发生过一场争论,这个问题引发了激烈的讨论。关于这场争论的内容可以关注 基于Redis的分布式锁到底安全吗 这篇文章。 最后得出的结论是 Redlock 在效率要求的应用中是合理的,所以在 Java 项目中可以使用 RedlockJava 版本 Redission 来控制多节点访问共享资源。但是仍有极端情况会造成 Redlock 的不安全,我们应该知道它在安全性上有哪些不足以及会造成什么后果。如果需要进一步的追求正确性,可以使用 Zookeeper 分布式锁。

相关链接