分布式锁

问题复盘

Redis使用 setnx key 实现分布式锁,程序异常导致 key 锁未释放,其他 session 不断尝试获取锁,最终导致 Redis CPU 和 OPS 耗光。

总结:Redis 出现 CPU 饱和,经过搜集数据,发现该库的 OPS 从平时的 几k 暴增到 15w。

Redis 是单进程单线程的架构,CPU 出现饱和,无法通过在其他数据库上可以采取的扩容解决,只能想办法把并发降低,从而减少对 CPU 资源的消耗。

对于单一的 Redis 实例来讲,进行一般的时间复杂度 O(1) 操作,经过测试和生产上的经验可以支撑 7-8 万的 OPS。所以 15w 肯定会导致 CPU 资源被耗尽。

是什么操作使得负载突增呢?采集的数据告诉我们 90%的操作都是 SETNX 命令。

SETNX key value 将 key 的值设为 value,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不做任何动作。

处理问题时发现,SETNX 命令主要集中在两个 key 上,高频词地对这两个 key 进行 SETNX 操作,CPU 资源主要被在这两个 key 的 SETNX 操作上耗尽。

Redis 在设计模式中经常会被用来实现分布式锁,根据上面对于 SETNX 命令的解释,不难看出这也是一个分布式锁实现的机制。

下面是简要步骤:

  1. 一个 session 进来,这时还没有这个 key,SETNX 执行成功,对 key 设置 value;
  2. 执行完相关操作,通过 del 删除 key,从而释放锁;
  3. 后续的 session 继续获取锁,通过 SETNX 来设置 key 值,以此类推;
  4. 如果锁还没有释放,也就是说 key 还存在,其他 session 会不断尝试获取。这也是问题发生时看到的情况。
  5. 因此无法获取锁,自然也就无法执行后续的释放锁的动作,如果没有其他的 key 清理机制,key 会一直存在,情况不断恶化。

那么什么情况会导致锁无法释放呢?这原因就多了,其中一个是程序在获取锁之后异常退出,没有执行后续的任务以及释放锁。

我们可以假设这样一种情景,用户登录系统后,成功获取分布式锁,在 Redis 中的表现是设置了 key 值,但不幸的是,这时突然发生了程序崩溃,导致后续的一系列操作都没有执行,当然也包括分布式锁的释放。程序异常退出后用户再次登录,尝试获取分布式锁,但因为之前的锁没有释放,无法成功获取,进而进入不断重试的循环,造成 CPU 资源耗尽。这个假设也可以通过 GET 命令来验证,对应 key 的值一直没有改变。

这样就需要我们除了程序正常运行过程中的锁释放机之外,还需要有异常情况下的释放或者是 key 清理机制。

Redis 提供了 EXPIRE 命令来对 key 进行 TTL 设置,这样不就在异常情况下,由 Redis 服务器在后台异步对过期的 key 进行清理了么?似乎是一个完美的解决方案,但请注意这里面的隐患。所谓隐患,是指隐藏的危险,很难发现的危险。问题真的能完美解决么?非也!

SETNX 无法载明另种同步设置 key 的 TTL 时间,需要再执行 EXPIRE 命令单独对 key 设置 TTL,这样会带来一个问题,Redis 正常情况下没有事务概念,无法保证原子性!同样会发生在获取锁,设置 key 值后程序异常退出,key 在没有 TTL 的情况下一直存在,分布式锁无法释放。后面的 SETNX 同样无法获取锁而陷入高频重试的循环,结局一样,CPU 资源被耗尽!

两种解决方法:

  1. 启用 Redis 的事务机制
  1. redis> MULTI
  2. redis> SETNX key1 1234
  3. redis> EXPIRE key1 10
  4. redis> EXEC

通过这种方式,把多个命令的执行变成一个原子操作,保证锁的获取和 key 的 TTL 设置要么全部成功,要么全部失败。

  1. 使用 SET 命令,把 SETNX 和 EXPIRE 的效果通过一条命令实现。
  1. redis> SET key1 1234 ex 10 nx

这种方式更加高效,减少了 OPS 和其他资源的消耗。

问题解决:手工清理 key,清理后,CPU 使用率立即下降到了 10% 以下。

后续优化:引入 Redisson。

ZK 实现分布式锁

zk 的两个核心概念

  1. 文件系统结构:zk 维护一个类似文件系统的数据结构,包含以下六种类型的节点: | 节点 | 注释 | 备注 | | :—-: | :—-: | :—-: | | PERSISTENT | 持久化目录节点 | 创建后永久存在 | | PERSISTENT_SEQUENTIAL | 持久化顺序编号目录节点 | 持久化节点 + 顺序 | | EPHEMERAL | 临时目录节点 | Session 超时后,服务器删除 | | EPHEMERAL_SEQUENTIAL | 临时顺序编号目录节点 | 临时节点 + 顺序 | | CONTAINER 节点 | | 当没有子节点时被服务器删除 | | TTL 节点 | | 过了 TTL 执行时间被服务器删除 |

  2. 监听通知机制:ZK 可以实现对于某个节点、目录或者目录及其递归子节点进行监听(注意所用监听通知都是一次性的)

zk 的分布式锁就是通过临时顺序节点+监听通知机制实现的。

zk 的分布式锁实现原理

1. 非公平锁

简单描述就是试着创造节点,如果当前节点已存在或者创建不成功,则抢锁失败。添加对节点的监听,如果当前节点不存在且创建成功则取得锁,事务处理完成后释放锁。触发锁监听,其他线程收到通知重新抢锁。

弊端:锁释放时,所有等待的连接会同事抢锁,发生非公平竞争,产生“惊群效应”,造成不必要的性能开销。

2. 公平锁

公平锁在非公平锁的基础上进行了优化,不是所有连接监听同一节点,而是只对前一个节点进行监听。

加锁逻辑:

  1. 当加锁请求进来时,在/lock(自定义)节点先创建临时顺序节点
  2. 判断自己是否为/lock 下最小的节点
    2.1 如果是,如/x-001,则获取锁成功
    2.2 如果不是,如/x-002,则对 /x-001 进行监听
  3. /x-001获取锁成功,处理完事务后,删除节点,释放锁,后续节点,依次重复第二步。

以上两种加锁方式在同一时间只能由一个请求获得锁,所有请求都需要加锁,这种方式被称为互斥锁。而实际场景中并非所有请求都需要加锁,例如全部都是读请求,因此还有另外一种方式叫做共享锁。

3. 共享锁

  1. 读请求对前一个写节点进行监听
  2. 写请求对前一个节点进行监听

通过共享锁的方式可以提升高并发场景下的服务性能。

和 Redis 一样,ZK 也有实现了分布式锁的开源框架,就是 Curator。

这里不展开了。

Redis 分布式锁 VS ZK 分布式锁

Redis:AP,ZK:CP

Redis 使用主从模式时,如果一个请求成功拿到了锁,但这时 Master 挂了,并且 Master 的数据还没有同步到 Slave,当 Slave 通过选举机制晋升为新的 Master 后,刚刚请求拿到的锁就失效了。在这种情况下 Redis 优先保障了可用性,而牺牲了一致性。

而 ZK 当 Leader 的数据同步给半数以上的 Follower 后才能算作成功。当 Leader 挂掉后,会优先选择数据最新的 Follower 晋升为 Leader。因此 ZK

是优先保障了一致性,而牺牲了性能。