分布式锁

分布式锁

随着互联网技术的不断发展、数据量的大幅增加、业务逻辑的复杂化导致传统的集中式系统已经无法应用于当前的业务场景,因此分布式系统被应用在越来越多的地方,但是在分布式系统中,由于网络、机器(如网络延迟、分区,机器宕机)等情况导致场景更加复杂,充满了不可靠的情况。为了保证一致性,在这种情况下我们就需要用到分布式锁

那么分布式锁需要具备哪些条件呢?

  • 获取、释放锁的性能要高
  • 判断锁的获取操作必须要是原子的(防止同一个锁被多个节点获取)
  • 网络或者机器出现问题导致无法继续工作时,必须要释放锁(防止死锁)
  • 可重入的,一个线程可以多次获取同一把锁(防止死锁)
  • 阻塞锁(依据业务需求)

但是目前并没有能够满足上面所有要求的完美结局方案,对于分布式锁,我们通常使用以下三种方法来实现

  1. 数据库
  2. Redis(缓存)
  3. Zookeeper

数据库

唯一索引

我们可以利用数据库中的唯一索引来实现。由于唯一索引能够保证记录只被插入一次,因此我们可以利用其判断当前是否处于锁定状态。所以当想要获取锁的时候,就向数据库中插入一条记录,而释放锁的时候就删除这条记录即可。

但是该方法存在以下问题

  • 锁没有失效时间,如果解锁失败的话其他进程无法再获得该锁(死锁)
  • 非阻塞锁,插入失败就直接报错,没有办法进入队列重试
  • 不可重入,同一线程在没有释放锁之前无法重复获得该锁

对于数据库来说我们还可以选择使用排他锁、乐观锁等方法来实现分布式锁,但是由于这些方法对原表有侵入、占用数据库连接等情况,一般情况下都不做考虑,因此这里也就不详细描述。

Redis

SETNX、EXPIRE

我们可以利用setnx(set if not exist)命令来实现锁。只有在缓存中不存在Key的时候才会set并返回true,而Key已存在的时候就直接返回false。同时为了防止获取锁失败而导致的死锁情况,我们可以利用expire命令对这个key设置一个超时时间。

为了防止我们setnx成功之后线程发生异常中断导致我们来不及设置expire而导致死锁,我们通常会使用以下命令来设置

1
2
3
4
5
6
7
8
SET key random_value NX EX 30000 

/*
  EX seconds – 设置键key的过期时间,单位时秒
  PX milliseconds – 设置键key的过期时间,单位时毫秒
  NX – 只有键key不存在的时候才会设置key的值
  XX – 只有键key存在的时候才会设置key的值
*/

该命令仅在Key不存在(NX选项)时才插入,并且设置到期时间为30000毫秒(PX选项),value设置为随机值,该值在所有客户端和所有锁定请求中必须唯一(防止被他人误删)。

当我们想要释放锁时,为了保证安全(防止误删除另一个客户端创建的锁),仅当密钥存在且存储在密钥上的值恰好是期望的值时,才删除该密钥,下面是以lua脚本完成的删除逻辑

1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这种方法虽然实现起来非常简单,但是其存在着单点问题,它加锁时只作用于一个Redis节点上,如果该节点出现故障故障,即使使用哨兵来保证高可用,也会出现锁丢失的情况,如下场景

  1. 在Redis的Master节点拿到锁,此时锁还没有同步到Slave节点
  2. 此时Master发生故障,哨兵主导进行故障转移,Slave节点升级为Master节点
  3. 由于锁没来得及同步,因此导致锁丢失

考虑到这种情况,Redis作者antirez基于分布式环境下提出了一种更高级的分布式锁的实现方式:Redlock

RedLock算法

Redlock 是 Redis 的作者 antirez 给出的集群模式的 Redis 分布式锁,它基于 N 个完全独立不存在主从复制或者其他集群协调机制)的 Redis节点(通常情况下 N 设置成 5,为了资源的合理利用通常为奇数)。

算法的流程如下

  1. 获取当前时间
  2. 客户端依次尝试从5个(奇数)相互独立的Redis实例中使用相同的key和具有唯一性的value获取锁
  3. 计算获取锁消耗的时间,只有时间小于锁的过期时间,并且从**大多数(N / 2 + 1)**实例上获取了锁,才认为获取锁成功;
  4. 如果获取了锁,则重新计算有效期时间,即原有效时间减去获取锁消耗的时间
  5. 如果客户端获取锁失败,则释放所有实例上的锁

虽然RedLock算法比上面的单点Redis锁更可靠,但是由于分布式的复杂性,实现起来的条件也更加的苛刻。

  1. 由于必须获取**(N / 2 + 1)**个节点上的锁,所以可能会出现锁冲突的情况(即每个人都获取了一些锁,但是没有人获取一半以上的锁)。针对这个问题,其借鉴了Raft算法的思路,即产生冲突后为每个节点设置一个随机开始时间,在时间到后重新尝试获取锁,但是这也导致了获取锁的成本增加。
  2. 如果5个节点有2个宕机,锁的可用性会极大降低,因为必须等待这两个宕机节点的结果超时才能返回。另外只剩3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,加锁难度也加大了。
  3. 如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况。

介于以上情况,我们还可以选择更加可靠的方法,即Zookeeper实现的分布式锁。

Zookeeper

Zookeeper是一个为分布式应用提供一致性服务的软件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。

由于Zookeeper同样没有实现锁API,所以我们利用其数据节点来表示锁,数据节点分为以下三种类型

  • 永久节点:节点创建后永久存在,不会因为会话的消失而消失
  • 临时节点:与永久节点相反,当客户端结束会话后立即删除
  • 顺序节点:会在节点名的后面加一个数字后缀,并且是有序的,例如生成的有序节点为 /lock/node-0000000000,它的下一个有序节点则为 /lock/node-0000000001,以此类推。

实现原理

除了上面介绍的节点外,我们还需要用到Watcher(监视器)来注册对节点状态的监听

  • Watcher:注册一个该节点的监视器,当节点状态发生变化时,Watcher就会触发,此时Zookeeper将会向客户端发送一条通知(Watcher只能被触发一次)

根据上述特性,我们就可以使用临时顺序节点与Watcher来实现分布式锁

  1. 创建一个锁目录/lock
  2. 当需要获取锁时,就在lock目录下创建临时顺序节点
  3. 获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则到lock目录下注册一个子节点变更的Watcher监听,获得子节点的变更通知后重复此步骤直至获得锁
  4. 执行完业务逻辑后,主动删除自己的节点来释放锁。此时会触发其他节点的Watcher,让其他人获取锁。

那如果出现网络中断或者机器宕机,锁还能释放吗?

这里需要注意的是,我们使用的是临时节点,所以当客户端因为某种原因无法继续工作时,就会导致会话的中断,临时节点就会被Zookeeprer自动删除。这也就是Zookeeper相较于Redis更加可靠的原因。

羊群效应

上面这个实现方法,大体上能够满足一般的分布式集群竞争锁的需求,并且能够保证一定的性能。但是随着机器规模的扩大,其效率会越来越低。

为什么呢?我们思考一下锁的释放流程

在我们获取锁失败后,会注册一个对lock目录的Watcher监控,当有节点变更消息时,就会通知给所有注册了的机器。然而这个通知除了使序号最小的节点获取锁外,对其他的节点没有产生任何实际作用。

性能瓶颈的原因就是上面这个问题,大量的Watcher通知子节点列表获取两个操作重复运行,并且绝大多数运行结果都是判断出自己并非是序号最小的节点,从而继续等待下一次的通知,浪费了大量的资源。

如果集群规模较大,不仅会对Zookeeper服务器造成巨大的性能影响和网络冲击,更严重的时候甚至会因为多个节点对应的客户端同时释放锁导致大量的节点消失,从而短时间内向剩余客户端发送大量的事件通知——这就是所谓的羊群效应

改进方法

羊群效应出现根源在于其没有找到事件的真正关注点,对于分布式锁的竞争过程来说,它的核心逻辑就是判断自己是否是所有子节点中序号最小的。那么问题就简单了,我们只需要关注比自己序号小的那一个相关节点的变更情况就可以了,而不再需要关注全局的子列表变更情况。

于是,改进后的获取流程如下

  1. 创建一个锁目录/lock
  2. 当需要获取锁时,就在lock目录下创建临时顺序节点
  3. 获取/lock下的子节点列表,判断自己创建的子节点是否为当前子节点列表中序号最小的子节点,如果是则认为获得锁;否则到Watcher自己的次小节点(防止羊群效应)
  4. 执行完业务逻辑后,主动删除自己的节点来释放锁,此时会触发顺序的下一个节点的Watcher

总结

方案 优点 缺点 应用场景
数据库 直接使用数据库,操作简单 分布式系统的性能瓶颈大部分都在数据库,而使用数据库锁加大了负担 业务逻辑简单,对性能要求不高
Redis 性能较高,且实现起来方便 锁超时机制不可靠,当线程获取锁时,可能因为处理时间过长导致锁超时失效 追求高性能,允许偶发的锁失效问题
ZooKeeper 不依赖超时时间释放锁,可靠性高 由于频繁的创建和删除节点,性能比不上Redis锁 系统要求高可靠性
Built with Hugo
主题 StackJimmy 设计