文章目录
- 分布式锁设计需要考虑的问题
- 安全和活性基本保证
- 为什么基于故障切换的实现不够好?
- 单实例实现
- 为什么锁使用的my_random_value要唯一!
- RedLock算法
- 失效重试
- Performance, crash-recovery and fsync
- 思考
- 1. 万一持锁线程在过期时间内,业务逻辑完成不了怎么办?
有很多研究围绕用Redis实现DLM(Distributed Lock Manager),但是它们的实现方式不同。我们提出一种实现分布式锁的算法,称为Redlock。
分布式锁设计需要考虑的问题
设计一个分布式锁需要考虑以下问题:
- 互斥,即线程安全的
- 可重入,同一客户端可以多次获取一个锁。
- 锁的获取是否是阻塞的
- 锁是否是公平的,防止出现饿死或者活锁现象。
- 是否容错,客户端崩溃或少数如redis节点崩溃
- 锁的获取和释放性能
安全和活性基本保证
我们将基于以下三个原则开始设计,以我们的观点来讲,有效实现分布式锁的算法必须至少保证这个三个属性:
安全属性:互斥,任何时间只有一个用户可以获得锁
活性属性1:无死锁,最终总能获得锁,即使持锁客户端崩溃或者被重新划分到其他网络分区。
活性属性2:容错,只要大部分Redis节点活着,客户端总能安全进行锁持有和锁释放。
为什么基于故障切换的实现不够好?
想要理解我们优化哪些方面,让我们分析下现在一些基于redis的解决方案。
最简单的方法就是使用redis key的过期机制,key最终会过期删除,锁最终被释放。(满足活性属性2)但是当客户端想要释放锁,就删除key。
表面上没什么问题,但是仔细一看会有单点故障的问题。如果redis master节点挂掉的时机不对怎么办?因为redis 主从复制是异步的,下面的场景是可能出现的:
- client A在master节点获取锁
- master节点挂了,而key还没来得及同步到slave
- slave被提升为主节点
- 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节点。
获取锁的话
- 获取当前系统时间
- 5个实例去获取锁。客户端在一个实例上获取锁的时长设置要远远小于锁过期时间。假如锁过期时间是10s,那么这个单实例获取锁操作的过期时间设置为5-50ms比较合适。假如这个实例获取不了,就立即转向下一个。
- 客户端计算获取锁花了多少时间,如果获取锁耗费时间比锁过期时间短且大多数实例(这里是3个以上)都成了,就认为获取成功了。否则,认为获取失败
- 如果锁获取失败了,那么客户端尝试撤销锁获取操作。(意思就是分别向5个实例发送删除key的操作)
失效重试
客户端锁获取失败,应该等待一段时间再获取。这很合理吧,跟竞争者错开。然后,锁获取操作占的时间越短越好,所以SET命令最好一块发出去。另外,如果获取失败,应该尽可能快的把自己的key删掉。如果要删的时候网络重新分区,客户端联系不上,那完犊子了,就得等到锁自动过期(这种情况很少)。
Performance, crash-recovery and fsync
很多人使用Redis作为一个高性能的锁服务器。锁服务器一般以每秒锁获取次数和锁释放次数为性能指标。
在故障恢复场景,我们讨论下,这个算法的表现。一个客户端从5个节点中的3个拿到锁,突然3个中一个挂了。当挂的节点重启之后,又出现了3个可以获取锁的节点,这不是有点难受吗?这违反了互斥性。
如果我们开启redis的持久化功能,比如这样节点重启之后,就跟节点没重启的状态一样。因为记录的是key的起始时间。
这只是失效重启,断电了怎么办?默认行为是每秒同步磁盘一次,但是为了应付断电,我们只能一次操作同步一次磁盘。这就比较沙雕了,根本没性能。
所以从另一个角度来看问题,不让失效重启的节点参与到锁行为会怎样? 即如果一个节点重启后,假如失效前寿命最长的key的过期时间为,在时间之前,不接受在本节点设置带时间期限的key。这样就不用同步磁盘了。
思考
1. 万一持锁线程在过期时间内,业务逻辑完成不了怎么办?
基本做法是,可以在获取锁之后,新开一个线程,周期性刷新所有锁代表的key的过期时间。
如果刷新失败,回滚对临界资源的操作。如果临界资源操作成功,释放锁时停止刷新线程对锁key的刷新。
redission 本身是有个看门狗的监控线程,可以重置key的过期时间。