关于锁的思考和总结(二)

2024/05/21

Tags: Lock Redis Zookeeper 分布式锁 Redlock

书接上文, 在单机模式下, 可以借助操作系统能力, 使用原子指令去实现锁, 但是在分布式场景中, 这种方案就会无法实现, 因为要竞争锁的进程在不同的机器上, 分布式锁因此而诞生.

分布式锁的常见问题

举一个很常见的案例, 如果某个服务为了实现高可用而采用了多副本模式, 当服务中存在定时任务, 如何保证同时只有一个定时任务在运行呢? 从这里, 问题就开始变得复杂.

很常规的思路就是借助数据库, 操作系统提供了原子指令, 同样, 数据库也提供了事务来保证原子性, 那么案例中的问题可以这么解决:

  1. 可以设计一张表 lock, id, key 两个字段, 把 key 设置为唯一索引; key 的业务意义是定时任务的唯一标识;
  2. 每个实例执行定时任务之前, 往表里写入一条数据: (1, tastA), 由于事务机制的存在, 如果此时有其他实例往这个表里写数据时就会失败, 此时跳过当前实例的定时任务;
  3. 执行完定时任务之后, 把 (1, taskA) 这条记录删除;

问题解决了吗? 考虑一下异常情况: 当实例A拿到锁之后挂了, 那其他实例永远也拿不到锁了;

一个很直观的思路就是给锁设置超时时间, 但是设置超时时间就需要权衡了, 如果定时任务本身的耗时跟锁的超时时间还要长, 那就会出现锁超时而导致同时两个实例在执行定时任务, 因此, 这个方案是需要一定的前提的, 这取决于实际的业务场景;

再更进一步思考, 如果真的定时任务比锁的超时时间还长, 怎么解决呢? 锁的超时时间如果能动态变化, 这个问题就引刃而解了, 这就是锁续期;

  1. lock 表结构改为: id, key, createTime, expiredTime ;
  2. 在执行定时任务时,往表里写一条数据 (1, tastA), 同步开一个线程去给锁续期, expiredTime 时间增加;
  3. 定时任务执行结束时, 续期线程退出, 删除记录 (1, tastA);

注意, 续期的前提是加了锁超时的机制, 如果使用数据库的话, 需要定期扫描, 发现已经达到 expiredTime 时, 就删除记录;

问题真的解决了吗? 有一种场景, 有 A, B 两个实例, A 拿到锁了, 然后 A 开始执行定时任务, 然后 A 开始 full GC, GC 期间, 锁已经过期了, B 中检测锁过期时, 发现过期了, 然后就删除 lock 的记录, 此时 B 可以拿到锁, 如果 B 现在拿到锁了, 并开始执行定时任务, 如果 A 又恢复了, 对于 A 来说, 此时是拿到锁的状态, A 也会开始执行定时任务, 锁失效!

这个问题可以先放一下, 看完文章可能会有自己的理解和思考.

总结一下, 实现分布式锁会遇到哪些问题

基于 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 节点而言, 获取锁/释放锁的方式跟单机版本的方式一致;

算法步骤:

加锁

  1. 客户端获取当前时间戳 T1;
  2. 客户端依次在每个 redis 节点去获取锁, 此时, 使用相同的 key 和 random value; 客户端获取锁的请求有超时时间, 这个时间小于锁的总的超时时间(示例值: lock 10s 超时, client 请求超时时间 5-50 ms), 这个机制是为了防止某个 redis 实例不可用导致阻塞影响整个流程;
  3. 客户端再次获取当前时间戳 T2, 如果此时获取到半数之上节点的锁(N=5 时, 需要获取 N/2+1=3 个节点的锁), 并且 T2-T1 < lock expired time, 则认为获取锁成功;
  4. 如果客户端获取到了锁, 锁的有效时间就是 T2 - T1;
  5. 如果客户端由于某种原因未能获得锁(要么无法锁定 N/2+1 个实例,要么有效时间为负),客户端将尝试解锁所有实例(甚至是客户端认为没有锁定的实例)。

释放锁

释放锁很简单, 向所有节点发送释放锁的请求, 使用 lua 脚本保证原子性;

算法中超过半数节点加锁成功很好理解, 类似于分布式系统中的选举问题, 步骤3 计算 T2-T1 < lock expired time 才算是加锁成功是为什么呢?

由于要请求多个节点, 网络情况是不可预期的, 请求越多, 响应的延迟、丢包等问题出现的概率就越大, 如果获取锁的时间都已经超过了锁的超时时间, 那最开始加锁成功节点上的锁就会超时失效了, 那本次加锁就没有意义了.

为什么释放锁要向所有节点发送请求?

向所有节点获取锁的过程不一定都会成功, 有可能有的节点由于网络原因, 加锁时间相对长甚至是加锁成功, 但是响应客户端的请求失败了, 此时客户端已经拿到锁了, 客户端是无法感知哪些节点加锁成功, 也不需要感知哪些节点加锁成功, 直接向所有节点发送释放锁的请求, 这样处理更为简洁;

分布式锁的一些业界争论

Martin 对 Redlock 的质疑

在 Redlock 方案提出之后, 分布式领域的另一位大佬《Designing Data-Intensive Applications》作者 Martin 基于 Redlock 提出了一些质疑:

Martin 认为, 分布式锁的目地是效率和正确性;

unsafe-lock

这里的 GC 只是一种举例, 在分布式系统中, NPC (N:Network Delay,网络延迟; P:Process Pause,进程GC/重启; C:Clock Drift,时钟漂移) 问题随处可见;

fecing token 方案

Martin 不仅质疑了 Redlock, 还给出了解决方案, 思路是在资源层做隔离, 保证修改共享资源的正确性, 具体思路如下图:

unsafe-lock

  1. 客户端获取到锁时, 由锁服务提供一个递增的 token;
  2. 操作共享资源时带上这个 token;
  3. 共享资源层拒绝掉后来者的操作请求, 避免各种 NPC 问题;

Martin 的这个思路是端到端的解决问题, 而不仅仅是着眼于算法如何才算拿到安全的锁.

Redlock 作者的反驳

Antirez 的反驳主要针对 NPC 的各种问题, 以及fecing token 方案;

Redlock 应对 NPC 的问题

在此之前, 先来回顾一下 Redlock 加锁的流程:

  1. 客户端获取当前时间戳 T1;
  2. 客户端依次在每个 redis 节点去获取锁, 此时, 使用相同的 key 和 random value; 客户端获取锁的请求有超时时间, 这个时间小于锁的总的超时时间(示例值: lock 10s 超时, client 请求超时时间 5-50 ms), 这个机制是为了防止某个 redis 实例不可用导致阻塞影响整个流程;
  3. 客户端再次获取当前时间戳 T2, 如果此时获取到半数之上节点的锁(N=5 时, 需要获取 N/2+1=3 个节点的锁), 并且 T2-T1 < lock expired time, 则认为获取锁成功;
  4. 如果客户端获取到了锁, 锁的有效时间就是 T2 - T1;
  5. 如果客户端由于某种原因未能获得锁(要么无法锁定 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 机制提出两点质疑:

  1. 局限性, 数据面的隔离需要依赖于数据存储的容器, 如果是类似于mysql的数据, 很容易实现带条件的更新, 如果是无状态的 http 服务, 无法隔离数据, 这种方案就不适用了;
  2. 数据层面已经做了隔离, 那分布式锁存在还有必要吗?

个人认为, 在分布式领域没有银弹, 随处可以见妥协和折中的设计, 结合业务端到端看问题才是正解, 这里 fencing token 的局限性客观存在, 但其背后的思路却是很值得借鉴: 如果业务对数据正确性要求非常高, 对上层的机制的可靠性需要提出质疑, 数据面的隔离是必要的.

基于 zookeeper 的分布式锁

在ZooKeeper中,ephemeral节点是一种临时节点,它与创建它的客户端会话相关联。当创建这样的节点的客户端会话结束(例如客户端断开连接或会话超时),这些节点将被自动删除。 一些关于ZooKeeper ephemeral节点的重要特性包括:

基于 ZooKeeper 以上特性, 可以实现分布式锁:

  1. 客户端尝试创建一个 znode 节点(ephemeral),比如/lock。那么第一个客户端就创建成功了,相当于拿到了锁;而其它的客户端会创建失败(znode 已存在),获取锁失败。
  2. 持有锁的客户端访问共享资源完成后,将 znode 删掉,这样其它客户端接下来就能来获取锁了。

如果创建 znode 的那个客户端崩溃了,那么相应的 znode 会被自动删除。这保证了锁一定会被释放, 因此不需要考虑过期的问题, 也需要设计续期的机制.

但是基于 ZooKeeper 实现的分布式锁, 依然会面临 NPC 问题, 当 client1 获取到锁之后, 进入长时间 GC, 此时 client1 与 ZooKeeper 的 seesion 超时, ephemeral节点会被删除, 此时其他客户端可能会拿到锁, 锁失效.

总结

基于上文, 对于分布式锁的实现而言:

  1. 分布式锁并不是 100% 安全, 无关于实现方式, redis、zk、数据库;
  2. 一个严谨的分布式锁模型应该考虑锁租期、锁归属、NPC 问题; 工程实践时, 需要根据业务进行取舍;
  3. 对于严格要求数据正确性的场景下, 需要端到端的考虑数据的正确性, 不应该强依赖于分布式锁机制, 分布式锁可以在上层拦截大批量请求, 底层数据面需要有相应的兜底策略;

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