文章目录

  • 分布式锁设计需要考虑的问题
  • 安全和活性基本保证
  • 为什么基于故障切换的实现不够好?
  • 单实例实现
  • 为什么锁使用的my_random_value要唯一!
  • RedLock算法
  • 失效重试
  • Performance, crash-recovery and fsync
  • 思考
  • 1. 万一持锁线程在过期时间内,业务逻辑完成不了怎么办?


文章翻译自:Distributed locks with Redis

有很多研究围绕用Redis实现DLM(Distributed Lock Manager),但是它们的实现方式不同。我们提出一种实现分布式锁的算法,称为Redlock。

分布式锁设计需要考虑的问题

设计一个分布式锁需要考虑以下问题:

  1. 互斥,即线程安全的
  2. 可重入,同一客户端可以多次获取一个锁。
  3. 锁的获取是否是阻塞的
  4. 锁是否是公平的,防止出现饿死或者活锁现象。
  5. 是否容错,客户端崩溃或少数如redis节点崩溃
  6. 锁的获取和释放性能

安全和活性基本保证

我们将基于以下三个原则开始设计,以我们的观点来讲,有效实现分布式锁的算法必须至少保证这个三个属性:

安全属性:互斥,任何时间只有一个用户可以获得锁
活性属性1:无死锁,最终总能获得锁,即使持锁客户端崩溃或者被重新划分到其他网络分区。
活性属性2:容错,只要大部分Redis节点活着,客户端总能安全进行锁持有和锁释放。

为什么基于故障切换的实现不够好?

想要理解我们优化哪些方面,让我们分析下现在一些基于redis的解决方案。

最简单的方法就是使用redis key的过期机制,key最终会过期删除,锁最终被释放。(满足活性属性2)但是当客户端想要释放锁,就删除key。

表面上没什么问题,但是仔细一看会有单点故障的问题。如果redis master节点挂掉的时机不对怎么办?因为redis 主从复制是异步的,下面的场景是可能出现的:

  1. client A在master节点获取锁
  2. master节点挂了,而key还没来得及同步到slave
  3. slave被提升为主节点
  4. client B获取同样的资源,而A还没有释放,这产生了竞争条件(race condition)。

这种方式比单点redis还要尴尬,为什么呢?因为如果单实例redis不会产生竞争条件,也就是说是线程安全的。这要以牺牲锁的高可用性为代价。

单实例实现

在实现一个分布式锁之前,先从一个简单的单点场景开始。要上锁,创建一个带过期时间的key.

SET resource_key my_random_value NX PX 30000

这个命令会设置一个key,当且仅当key不存在。设置一个30000ms的锁(PX选项)。key的值my_random_value必须是在所有竞争者中唯一的,且所有锁请求中也是唯一的。

安全地释放锁方式是:仅当key存在且key的value为我所期望的值,才安全释放(删除它)。

//当key存在,且为我所期望的值
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

为什么锁使用的my_random_value要唯一!

这很重要,因为如果不是期望值有可能把别人设置的锁删了。比如一个客户端持有一个锁,然后因为一些任务阻塞了,然后key过期了(key的过期时间使我们刚才设置的,称为lock validity time)。然后,如果不是唯一的,也不判断就删了同名key的锁,而这个key是别人设置的。这就比较麻烦了。

这个key的值设置什么比较好?你可以使用/dev/urandom产生一个20字节的RC4(一种加密算法)序列。一个更简单的方案是使用unix时间和毫秒的混合,并将它与客户端ID连接到一起。这个虽然不能保证万无一失,但是满足很多场景了。

key过期时间称为锁有效期(lock validity time)。这个时间是锁自动释放期,也是持锁者必须完成相关操作的持续时间。现在,我们在一个单实例且永远不会挂掉的redis节点实现了我们的安全需求。下面将这个概念推广到分布式环境下。

RedLock算法

下面说重点,在分布式环境下,假如有5个Master节点。

获取锁的话

  1. 获取当前系统时间
  2. 5个实例去获取锁。客户端在一个实例上获取锁的时长设置要远远小于锁过期时间。假如锁过期时间是10s,那么这个单实例获取锁操作的过期时间设置为5-50ms比较合适。假如这个实例获取不了,就立即转向下一个。
  3. 客户端计算获取锁花了多少时间,如果获取锁耗费时间比锁过期时间短且大多数实例(这里是3个以上)都成了,就认为获取成功了。否则,认为获取失败
  4. 如果锁获取失败了,那么客户端尝试撤销锁获取操作。(意思就是分别向5个实例发送删除key的操作)

失效重试

客户端锁获取失败,应该等待一段时间再获取。这很合理吧,跟竞争者错开。然后,锁获取操作占的时间越短越好,所以SET命令最好一块发出去。另外,如果获取失败,应该尽可能快的把自己的key删掉。如果要删的时候网络重新分区,客户端联系不上,那完犊子了,就得等到锁自动过期(这种情况很少)。

Performance, crash-recovery and fsync

很多人使用Redis作为一个高性能的锁服务器。锁服务器一般以每秒锁获取次数和锁释放次数为性能指标。

在故障恢复场景,我们讨论下,这个算法的表现。一个客户端从5个节点中的3个拿到锁,突然3个中一个挂了。当挂的节点重启之后,又出现了3个可以获取锁的节点,这不是有点难受吗?这违反了互斥性。

如果我们开启redis的持久化功能,比如这样节点重启之后,就跟节点没重启的状态一样。因为记录的是key的起始时间。
这只是失效重启,断电了怎么办?默认行为是每秒同步磁盘一次,但是为了应付断电,我们只能一次操作同步一次磁盘。这就比较沙雕了,根本没性能。

所以从另一个角度来看问题,不让失效重启的节点参与到锁行为会怎样? 即如果一个节点重启后,假如失效前寿命最长的key的过期时间为redissonClient lock 无效_客户端,在时间redissonClient lock 无效_客户端之前,不接受在本节点设置带时间期限的key。这样就不用同步磁盘了。

思考

1. 万一持锁线程在过期时间内,业务逻辑完成不了怎么办?

基本做法是,可以在获取锁之后,新开一个线程,周期性刷新所有锁代表的key的过期时间。
如果刷新失败,回滚对临界资源的操作。如果临界资源操作成功,释放锁时停止刷新线程对锁key的刷新。

redission 本身是有个看门狗的监控线程,可以重置key的过期时间。