实现一个分布式锁到底有多难
实现一个分布式锁到底有多难
一个服务里多线程操作某共享资源时,我们会使用锁来保证并发安全,但是在分布式系统中操作共享资源,实现一个分布式锁却异常困难。
网上关于分布式锁的文章非常多,Redis 作为 Web 服务中最常用的中间件之一,也有关于实现分布式锁的方案,想知道 Redis 分布式锁为什么这么设计,要从单机 Redis 实现分布式锁说起。
设置分布式锁需要这么一条指令:
1 | |
逻辑很简单, 给一个叫 share_resourse_id 的 key, 值是一个随机的 uuid, 过期时间 30s。
必须使用1条指令来实现,如果客户端先
set再expire,set后崩溃了这个锁就无法释放了;uuid的值需要保证一段时间是唯一的, 具体原因后面会分析;NX表示只有当share_resourse_id对应的 key 不存在的时候才能SET成功。这保证了只有第一个请求的客户端才能获得锁,而其它客户端在锁被释放之前都无法获得锁;PX 30000表示这个锁有一个30秒的自动过期时间。这里只是一个例子,客户端根据需要设置合适的过期时间。
释放的时候要做到类似 CAS 的效果, 先比较 share_resourse_id 对应 key 的值还是不是当前客户端设置的 uuid,如果是就释放,如果不是就不释放,那为什么要这么做呢?
- key 一定要设置过期时间,因为客户端可能出现设置锁后崩溃、宕机等任何情况,如果不设置过期时间会导致所有客户端都无法设置锁;
uuid的值必须是一段时间内唯一的,如果客户端A设置锁后发生 GC,锁过期了,被客户端B设置上了相同的 value,而A在 GC 完成操作后发起了释放锁的操作,会把客户端B的锁释放掉;- 释放锁要使用 lua 脚本, 这样才能保证
CAS的原子性。
然而为了避免系统出现单点故障,Redis 一般是以集群的方式部署的,这就可能产生新的问题:
- 客户端A设置了锁后
redis master崩溃了, 由于异步复制的原因,slave上还没有这个锁。随后客户端B在新的redis master上设置了锁,两个客户端并发访问了;
除此之外,给锁设置过期时间还需要面对和“延时双删”一样的问题,这个过期时间多久合适?到时间了没完成怎么办?
Redis 的作者 antirez 基于 N 个 Redis 节点给出了一个 Redlock 的实现,算是 Redis 官方对于实现分布式锁的指导规范。
获取锁时客户端依次执行下面各个步骤,来完成获取锁的操作:
获取当前时间(毫秒数)。
按顺序依次向 N 个 Redis 节点执行获取锁的操作,跟前面基于单 Redis 节点的获取锁的过程相同。
为了保证在某个 Redis 节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。
客户端在向某个 Redis 节点获取锁失败以后,应该立即尝试下一个 Redis 节点。这里的失败,应该包含任何类型的失败,比如该 Redis 节点不可用,或者该 Redis 节点上的锁已经被其它客户端持有。
计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数 Redis 节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
如果最终获取锁失败了(可能由于获取到锁的 Redis 节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有 Redis 节点发起释放锁的操作。
释放锁和之前的流程一样,都需要 lua 脚本实现,但注意是需要向所有 Redis 节点发起,因为 Redis 节点可能返回的响应丢失了,但本身已经在获取锁流程时设置上了。
这种方案解决了 Redis 单节点 failover 时锁失效的问题,但真的天衣无缝了吗?
- 获取到锁后如何计算剩余时间是否足够完成操作?如果计算完剩余时间但发生 GC 了,锁实际上已经到期了被其它客户端强占了,依然存在并发问题,这是业务需要考虑的;
- Redis ABCDE 5个节点,客户端1 拿到了 ABC 节点的响应,成功设置了锁,C 节点恰好宕机重启了,由于 fsync 或其它原因没有保存之前的锁,客户端2拿到了 CDE 节点的响应也认为成功持有了锁。这点作者在后续提出了延时重启的想法,一个节点崩溃后应该在超过
expire_time后重启,避免对已持有锁的节点造成影响。
除此之外,分布式系统中的时钟情况也会有影响,如果某节点的时钟发生了跳跃,导致锁快速失效,也会产生上面 ABC、CDE 这样多客户端同时持有锁的问题。antirez 承认所有的分布式锁的实现,包括 Redlock,是没有什么好办法来应对的,只能通过基础设施和良好的运维方式来尽可能避免,好在分布式锁对于时间的精度要求也不是非常苛刻。
Kubernetes 依赖的底层存储 etcd 也能提供分布式锁的实现,etcd 是一个分布式 kv 存储,有事务、watch、全局version、lease 等多个重要特性,它的实现思路如下:
每个客户端创建固定前缀的 key, 比如
/share_resourse_id/{uuid}, 具体的实现是基于事务提供的原子性保证,先判断是否存在以/share_resourse_id为前缀且create_version比自己小的:- 如果不存在,就新建自己的 key
- 如果存在,新建自己的 key, watch
create_version离自己最近的 key - 这个 key 需要绑定到一个 lease 下,客户端会不断续约 lease 代表自己还存活(类似心跳机制),如果 lease 过期这个 key 会被删除,不会由于客户端崩溃而锁永久存在。
首先解释一下为什么用 固定前缀key 的方式而不是抢占同一个 key,多个节点抢占同一个 key 并 watch 这个 key 的删除会产生“惊群”,key 删除时所有的客户端都被惊醒继续抢占。
这种实现方式相比 Redis 有什么好处?
- 客户端可以实现阻塞直到获取锁的行为:Redis 如果取不到锁想阻塞需要业务自行
while循环实现,etcd 可以阻塞等待 watch 主动通知; - 公平:
create_version是按照客户端创建 key 的顺序递增的,近似于按照请求到达的顺序获取锁; - 业务简单:不再需要考虑客户端宕机和 key 的过期时间,lease 续约失败自动删 key;
- 避免惊群:每个客户端在获取不到锁时 watch 前一个 key,也就是锁被删除的时候只会唤醒下一个等待者,性能更好。
那 etcd 的实现就完美吗?
- lease 续约就像心跳机制一样,当客户端繁忙或者发生意外时,可能会续约失败,这时 etcd 误认为客户端宕机了,会删除锁并通知下一个客户端。
作为经典的分布式协调组件,Zookeeper 自然也提供了分布式锁的实现,和 etcd 比较类似,详细的逻辑这里就不分析了。
相信经过上面的分析,即使出现新的协调组件实现分布式锁,只要能够对所能提供的安全性的程度有充分的了解,那么我们就能做出自己的选择了。这里顺便附上 DDIA 作者对于 Redis实现分布式锁 的结论:
- 如果是为了效率 (efficiency) 而使用分布式锁,允许锁的偶尔失效,那么使用单 Redis 节点的锁方案就足够了,简单而且效率高。Redlock 则是个过重的实现(heavyweight)。
- 如果是为了正确性(correctness)在很严肃的场合使用分布式锁,那么不要使用 Redlock。它对于系统模型的假设中包含很多危险的成分(例如timing),不是建立在异步模型上的一个足够强的算法。
参考: