书接上文, 在单机模式下, 可以借助操作系统能力, 使用原子指令去实现锁, 但是在分布式场景中, 这种方案就会无法实现, 因为要竞争锁的进程在不同的机器上, 分布式锁因此而诞生.
分布式锁的常见问题
举一个很常见的案例, 如果某个服务为了实现高可用而采用了多副本模式, 当服务中存在定时任务, 如何保证同时只有一个定时任务在运行呢? 从这里, 问题就开始变得复杂.
很常规的思路就是借助数据库, 操作系统提供了原子指令, 同样, 数据库也提供了事务来保证原子性, 那么案例中的问题可以这么解决:
- 可以设计一张表 lock,
id, key
两个字段, 把 key 设置为唯一索引; key 的业务意义是定时任务的唯一标识; - 每个实例执行定时任务之前, 往表里写入一条数据:
(1, tastA)
, 由于事务机制的存在, 如果此时有其他实例往这个表里写数据时就会失败, 此时跳过当前实例的定时任务; - 执行完定时任务之后, 把 (1, taskA) 这条记录删除;
问题解决了吗? 考虑一下异常情况: 当实例A拿到锁之后挂了, 那其他实例永远也拿不到锁了;
一个很直观的思路就是给锁设置超时时间, 但是设置超时时间就需要权衡了, 如果定时任务本身的耗时跟锁的超时时间还要长, 那就会出现锁超时而导致同时两个实例在执行定时任务, 因此, 这个方案是需要一定的前提的, 这取决于实际的业务场景;
再更进一步思考, 如果真的定时任务比锁的超时时间还长, 怎么解决呢? 锁的超时时间如果能动态变化, 这个问题就引刃而解了, 这就是锁续期;
- lock 表结构改为:
id, key, createTime, expiredTime
; - 在执行定时任务时,往表里写一条数据
(1, tastA)
, 同步开一个线程去给锁续期, expiredTime 时间增加; - 定时任务执行结束时, 续期线程退出, 删除记录
(1, tastA)
;
注意, 续期的前提是加了锁超时的机制, 如果使用数据库的话, 需要定期扫描, 发现已经达到 expiredTime 时, 就删除记录;
问题真的解决了吗? 有一种场景, 有 A, B 两个实例, A 拿到锁了, 然后 A 开始执行定时任务, 然后 A 开始 full GC, GC 期间, 锁已经过期了, B 中检测锁过期时, 发现过期了, 然后就删除 lock 的记录, 此时 B 可以拿到锁, 如果 B 现在拿到锁了, 并开始执行定时任务, 如果 A 又恢复了, 对于 A 来说, 此时是拿到锁的状态, A 也会开始执行定时任务, 锁失效!
这个问题可以先放一下, 看完文章可能会有自己的理解和思考.
总结一下, 实现分布式锁会遇到哪些问题
- 问题1: 如何保证获取锁, 释放锁的原子性?
- 问题2: client 拿到锁之后挂了, 锁如何释放?
- 问题3: 锁加了超时时间后, 如何续期?
- 问题4: 是否需要释放其他 client 的锁?
基于 redis 的分布式锁
使用 redis
实现天然就避免了问题2, 因为 redis
支持过期时间;
在 redis
中, SETNX 命令,它用于设置键值对的值。具体来说,就是这个命令在执行时会判断键值对是否存在,如果不存在,就设置键值对的值,如果存在,就不做任何设置。
SETNX key value
// 加锁
SETNX lock_key 1
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key
那在 redis
中, 如何保证原子性呢?
# NX 不存在即设置,EX PX 设置过期时间
SET key value [EX seconds | PX milliseconds] [NX]
对于释放锁而言, DEL lock_key
本身不存在原子性的问题; 但是如果要解决问题4, 保证 client 只释放自己的锁, 此时加锁时就需要把 value 设置为 client 的一个标识, 与此同时, 释放锁时, 也需要先判断当前 client 是否能释放锁, 此时的命令为:
// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000
//释放锁 比较unique_value是否相等,避免误释放, 使用 lua 脚本保证原子性
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
KEYS[1]表示 lock_key,ARGV[1]是当前客户端的唯一标识,这两个值都是我们在执行 Lua 脚本时作为参数传入.
redis-cli --eval unlock.script lock_key , unique_value
redlock
上述基于redis实现的分布式锁, redis 是单实例的, 如果要增强锁的可靠性, 可以基于多个redis节点去实现, 问题从这里开始变得复杂, 业界已经出现了基于多个redis实例实现分布式锁的算法, redlock.
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。
前置: 选取 N 个 redis 节点, 无需组成哨兵或者 cluster 模式, 这里假设 N = 5
, 对于每个 redis 节点而言, 获取锁/释放锁的方式跟单机版本的方式一致;
算法步骤:
加锁
- 客户端获取当前时间戳 T1;
- 客户端依次在每个 redis 节点去获取锁, 此时, 使用相同的 key 和 random value; 客户端获取锁的请求有超时时间, 这个时间小于锁的总的超时时间(示例值: lock 10s 超时, client 请求超时时间 5-50 ms), 这个机制是为了防止某个 redis 实例不可用导致阻塞影响整个流程;
- 客户端再次获取当前时间戳 T2, 如果此时获取到半数之上节点的锁(N=5 时, 需要获取 N/2+1=3 个节点的锁), 并且
T2-T1 < lock expired time
, 则认为获取锁成功; - 如果客户端获取到了锁, 锁的有效时间就是 T2 - T1;
- 如果客户端由于某种原因未能获得锁(要么无法锁定 N/2+1 个实例,要么有效时间为负),客户端将尝试解锁所有实例(甚至是客户端认为没有锁定的实例)。
释放锁
释放锁很简单, 向所有节点发送释放锁的请求, 使用 lua 脚本保证原子性;
算法中超过半数节点加锁成功很好理解, 类似于分布式系统中的选举问题, 步骤3 计算 T2-T1 < lock expired time
才算是加锁成功是为什么呢?
由于要请求多个节点, 网络情况是不可预期的, 请求越多, 响应的延迟、丢包等问题出现的概率就越大, 如果获取锁的时间都已经超过了锁的超时时间, 那最开始加锁成功节点上的锁就会超时失效了, 那本次加锁就没有意义了.
为什么释放锁要向所有节点发送请求?
向所有节点获取锁的过程不一定都会成功, 有可能有的节点由于网络原因, 加锁时间相对长甚至是加锁成功, 但是响应客户端的请求失败了, 此时客户端已经拿到锁了, 客户端是无法感知哪些节点加锁成功, 也不需要感知哪些节点加锁成功, 直接向所有节点发送释放锁的请求, 这样处理更为简洁;
分布式锁的一些业界争论
Martin 对 Redlock 的质疑
在 Redlock 方案提出之后, 分布式领域的另一位大佬《Designing Data-Intensive Applications》作者 Martin 基于 Redlock 提出了一些质疑:
Martin 认为, 分布式锁的目地是效率和正确性;
-
就效率而言, Redlock 如果保证加锁的高效, 例如多个定时任务同时执行一个, 就算是无法做到完全互斥也无伤大雅,而 Redlock 在效率层面表现不太好, 实现过于重了, 保持高效用单体的 redis 就能达到目的, 当然这样会损失一些正确性;
-
就正确性而言, Redlock 无法保证正确性, 如下图:
这里的 GC 只是一种举例, 在分布式系统中, NPC (N:Network Delay,网络延迟; P:Process Pause,进程GC/重启; C:Clock Drift,时钟漂移) 问题随处可见;
fecing token 方案
Martin 不仅质疑了 Redlock, 还给出了解决方案, 思路是在资源层做隔离, 保证修改共享资源的正确性, 具体思路如下图:
- 客户端获取到锁时, 由锁服务提供一个递增的 token;
- 操作共享资源时带上这个 token;
- 共享资源层拒绝掉后来者的操作请求, 避免各种 NPC 问题;
Martin 的这个思路是端到端的解决问题, 而不仅仅是着眼于算法如何才算拿到安全的锁.
Redlock 作者的反驳
Antirez 的反驳主要针对 NPC 的各种问题, 以及fecing token 方案;
Redlock 应对 NPC 的问题
在此之前, 先来回顾一下 Redlock 加锁的流程:
- 客户端获取当前时间戳 T1;
- 客户端依次在每个 redis 节点去获取锁, 此时, 使用相同的 key 和 random value; 客户端获取锁的请求有超时时间, 这个时间小于锁的总的超时时间(示例值: lock 10s 超时, client 请求超时时间 5-50 ms), 这个机制是为了防止某个 redis 实例不可用导致阻塞影响整个流程;
- 客户端再次获取当前时间戳 T2, 如果此时获取到半数之上节点的锁(N=5 时, 需要获取 N/2+1=3 个节点的锁), 并且
T2-T1 < lock expired time
, 则认为获取锁成功;- 如果客户端获取到了锁, 锁的有效时间就是 T2 - T1;
- 如果客户端由于某种原因未能获得锁(要么无法锁定 N/2+1 个实例,要么有效时间为负),客户端将尝试解锁所有实例(甚至是客户端认为没有锁定的实例)。
首先对于 NPC 的时钟问题, 这个确实会对锁产生影响, 但是时钟问题可以通过有效的运维手段解决, 比如: 1. 系统管理员修改了时钟; 2. 从时钟服务器收到一个更大的时钟; 对于这两种情况而言, 是可以通过运维手段保证的, 手动修改时钟这种情况, 类似于有人手动修改 Raft 的日志, 这种情况下 Raft 也无法正常工作; NTP server 是可以通过调整配置来保证时钟不会大幅度跳跃;
而对于 NPC 的另外两种情况, 进程GC 、网络延迟, 这两种情况都可以在算法的 1-4步发现, 这也就是为什么需要 T2-T1, 当发现 T2-T1 < lock expired time
不成立时, 则获取锁失败了, 那就执行解锁流程就好了. 而在第4步之后, client 拿到锁遇到异常情况, 这并非是 RedLock 独有的问题, 任何一种分布式锁实现都会面临这个问题, 不在讨论范畴;
值得一提的是, Antirez 这里为什么要单独解释时钟问题, 这其实是 Redlock 算法的前提, 如果计算 T2-T1 < lock expired time
T1 到 T2 的时钟出现跳跃, 那其实锁的过期时间就会计算有误, 比如redis 实例中的 value 其实已经过期, 但是客户端认为还拿到锁了, 这个才是算法的关键所在!
质疑 fencing token 机制
Antirez 针对 fencing token 机制提出两点质疑:
- 局限性, 数据面的隔离需要依赖于数据存储的容器, 如果是类似于mysql的数据, 很容易实现带条件的更新, 如果是无状态的 http 服务, 无法隔离数据, 这种方案就不适用了;
- 数据层面已经做了隔离, 那分布式锁存在还有必要吗?
个人认为, 在分布式领域没有银弹, 随处可以见妥协和折中的设计, 结合业务端到端看问题才是正解, 这里 fencing token 的局限性客观存在, 但其背后的思路却是很值得借鉴: 如果业务对数据正确性要求非常高, 对上层的机制的可靠性需要提出质疑, 数据面的隔离是必要的.
基于 zookeeper 的分布式锁
在ZooKeeper中,ephemeral节点是一种临时节点,它与创建它的客户端会话相关联。当创建这样的节点的客户端会话结束(例如客户端断开连接或会话超时),这些节点将被自动删除。 一些关于ZooKeeper ephemeral节点的重要特性包括:
- 临时性:节点与客户端会话相关联,会话结束时节点自动删除。
- 顺序性:可以为ephemeral节点设置顺序标志,使节点按照创建顺序进行编号。
- 通知机制:ZooKeeper允许客户端注册对节点变化的监听器,当ephemeral节点创建或删除时,客户端可以收到通知。
基于 ZooKeeper 以上特性, 可以实现分布式锁:
- 客户端尝试创建一个 znode 节点(ephemeral),比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode 已存在),获取锁失败。
- 持有锁的客户端访问共享资源完成后,将 znode 删掉,这样其它客户端接下来就能来获取锁了。
如果创建 znode 的那个客户端崩溃了,那么相应的 znode 会被自动删除。这保证了锁一定会被释放, 因此不需要考虑过期的问题, 也需要设计续期的机制.
但是基于 ZooKeeper 实现的分布式锁, 依然会面临 NPC 问题, 当 client1 获取到锁之后, 进入长时间 GC, 此时 client1 与 ZooKeeper 的 seesion 超时, ephemeral节点会被删除, 此时其他客户端可能会拿到锁, 锁失效.
总结
基于上文, 对于分布式锁的实现而言:
- 分布式锁并不是 100% 安全, 无关于实现方式, redis、zk、数据库;
- 一个严谨的分布式锁模型应该考虑锁租期、锁归属、NPC 问题; 工程实践时, 需要根据业务进行取舍;
- 对于严格要求数据正确性的场景下, 需要端到端的考虑数据的正确性, 不应该强依赖于分布式锁机制, 分布式锁可以在上层拦截大批量请求, 底层数据面需要有相应的兜底策略;
Martin: For me, this is the most important point: I don’t care who is right or wrong in this debate — I care about learning from others’ work, so that we can avoid repeating old mistakes, and make things better in future. So much great work has already been done for us: by standing on the shoulders of giants, we can build better software.
参考
https://redis.io/docs/latest/develop/use/patterns/distributed-locks/ https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html