分布式锁

一、背景

在多线程环境中,我们经常会使用到锁,目的是为了保证共享资源被安全地访问。

单机环境下,我们可以通过本地锁来保证资源安全;

在分布式环境下,不同服务运行在不同的JVM进程上,本地锁就没法实现资源的互斥访问了,因此我们需要一个 分布式的锁。

而一个分布式锁需要满足哪些条件呢?

  1. 互斥:锁的基本要求
  2. 高可用:锁本身的高可用;释放锁的节点出现问题,锁也可以被释放,不影响其他节点对共享资源的访问。
  3. 可重入:一个节点获得锁之后,还可以再次获得锁。

除此之外,还需要满足以下条件:

  1. 高性能:快
  2. 非阻塞:获取不到锁,不能无限期等待

二、实现方案

基于Redis

锁的核心在于“互斥”。在redis中,SETNX可以实现互斥。

1
2
3
4
5
6
> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0
> DEL lockKey
(integer) 1

为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。

1
2
3
4
5
6
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

设置过期时间

为了避免锁无法被释放,可以想到的一个解决方法就是给这个锁设置一个过期时间

1
2
> SET lockKey uniqueValue EX 3 NX
OK

但是这样会引入另一个问题:如果对共享资源的使用还没结束,锁就过期了

实现锁的优雅续期

redis Java客户端redisson 自带自动续期机制。

其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。

Redisson 看门狗自动续期

如何实现可重入锁?

可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。

实践中Redisson就可以满足需要。

如何解决集群下的可靠性问题?

如果主节点获取到锁以后,宕机了,就会出现多个应用同时获取锁的情况。

redis作者提出了一个redlock算法,需要半数redis客户端成功。实现复杂,性能差。

实际了解一下就可以了,不建议使用。

如果不需要实现绝对可靠的分布式锁,普通redis实现就够了,性能高。

如果需要实现绝对可靠的分布式锁,可以基于ZK。

基于ZK

ZooKeeper 分布式锁是基于 临时顺序节点Watcher(事件监听器) 实现的。

获取锁:

  1. 首先我们要有一个持久节点/locks,客户端获取锁就是在locks下创建临时顺序节点。
  2. 假设客户端 1 创建了/locks/lock1节点,创建成功之后,会判断 lock1是否是 /locks 下最小的子节点。
  3. 如果 lock1是最小的子节点,则获取锁成功。否则,获取锁失败。
  4. 如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端 1 并不会不停地循环去尝试加锁,而是在前一个节点比如/locks/lock0上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端 1 就加锁成功了。

释放锁:

  1. 成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除。
  2. 成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放。
  3. 我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放。

img

为什么要用临时顺序节点?

每个数据节点在 ZooKeeper 中被称为 znode,它是 ZooKeeper 中数据的最小单元。

我们通常是将 znode 分为 4 大类:

  • 持久(PERSISTENT)节点:一旦创建就一直存在即使 ZooKeeper 集群宕机,直到将其删除。
  • 临时(EPHEMERAL)节点:临时节点的生命周期是与 客户端会话(session) 绑定的,会话消失则节点消失 。并且,临时节点只能做叶子节点 ,不能创建子节点。
  • 持久顺序(PERSISTENT_SEQUENTIAL)节点:除了具有持久(PERSISTENT)节点的特性之外, 子节点的名称还具有顺序性。比如 /node1/app0000000001/node1/app0000000002
  • 临时顺序(EPHEMERAL_SEQUENTIAL)节点:除了具备临时(EPHEMERAL)节点的特性之外,子节点的名称还具有顺序性。

可以看出,临时节点相比持久节点,最主要的是对会话失效的情况处理不一样,临时节点会话消失则对应的节点消失。这样的话,如果客户端发生异常导致没来得及释放锁也没关系,会话失效节点自动被删除,不会发生死锁的问题。

使用 Redis 实现分布式锁的时候,我们是通过过期时间来避免锁无法被释放导致死锁问题的,而 ZooKeeper 直接利用临时节点的特性即可。

假设不使用顺序节点的话,所有尝试获取锁的客户端都会对持有锁的子节点加监听器。当该锁被释放之后,势必会造成所有尝试获取锁的客户端来争夺锁,这样对性能不友好。使用顺序节点之后,只需要监听前一个节点就好了,对性能更友好。

附:参考

https://javaguide.cn/distributed-system/distributed-lock-implementations.html#%E5%A6%82%E4%BD%95%E5%9F%BA%E4%BA%8E-zookeeper-%E5%AE%9E%E7%8E%B0%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81


分布式锁
https://yzaf.top/2024/distributed/Lock/
作者
why
发布于
2024年4月5日
许可协议