掘金 后端 ( ) • 2024-04-10 15:15

闲话漫谈: 本来是想要写一篇关于 Spring 提供的 RedisLockRegistry 的使用方法及底层原理解析的文章,写着写着发现得先介绍一下分布式锁的基本原理,才能有助于理解一些实现内容。最后发现关于分布式锁的基本介绍也占据了较大篇幅,因此另开一篇,介绍一下分布式锁。

1. 什么是分布式锁?

何为分布式锁,为什么需要分布式锁?在解答这个问题之前,需要先介绍本地锁的概念。Java 提供了 synchronized 关键字和 juc 中的 Lock 锁,这两者都是本地锁。本地锁指的是,该锁只针对当前虚拟机有效,也就是当你部署运行了一个 Java 项目 A 并且获取锁时,新起另一个 Java 项目,同样还可以获取锁,这两个锁之间没有任何关系。这便属于本地锁。因此,当你的服务只需要部署一个节点时,那么只需要用到本地锁即可。当你的系统需要支撑高并发、高性能等特性而去部署多节点时,本地锁便不够用了。本地锁无法解决多节点之间的资源竞争问题。因此,便需要用到分布式锁。在分布式锁中,当一个节点获取到锁后,其余节点在该锁被释放前均不可以获取锁。 如何实现分布式锁?分布式锁没有那么玄乎,其实就是将资源竞争条件从单个节点中拎出来,放到一个公共的、各节点均可以访问到的地方,各节点都共同去争抢这个锁。这样,大家就都能看到这个锁的争抢情况,自然可以进行锁的调度管控。

2. 分布式锁理论的简单图示

如下图所示,一开始,三个节点共同去争抢一把锁

图1

接着,节点 A 率先抢到了这个锁

图2

在节点 A 执行完锁内操作后,释放了这把锁,另外两个节点继续争抢该锁

3. 分布式锁的特征

为了能够稳定可靠的实现上述目的,一个分布式锁需要具备以下特征:

  • 互斥性: 任意时刻,只有一个客户端能持有锁。
  • 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁。
  • 可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁。
  • 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
  • 安全性:锁只能被持有的客户端删除,不能被其他客户端删除

4. Redis 分布式锁实现方式简介

分布式锁的实现可以有多种方式,目前比较熟知的有 Zookeeper、数据库和 Redis 实现。我们这里主要介绍在 Redis 中实现分布式锁。

先说一下 Redis 中实现分布式锁的基本理论。多个节点去同一个 Redis 服务设置一个相同的 key,谁先设置成功谁就抢到了该锁,未成功设置 key 的节点则等待锁释放(这里有两种方式,一种是节点主动定时去尝试设置该 key,失败了就说明该锁还在使用;另一种方式是利用 Redis 提供的发布订阅功能,节点订阅该 key,当该 key 失效时,redis 主动通知节点)。当然,一个 key 不能永远生效,节点需要为 key 设置过期时间,这段时间就是该节点持有该锁的时间。当该 key 过期,其他线程可以继续争抢该锁。因此,第 3 小节中提到的互斥性和锁超时释放得以基本实现。

Redis 为我们提供了setnxexpire命令。使用setnx来抢锁,如果抢到之后,再用expire为锁设置过期时间,防止锁忘记了释放。

然而 setnx 和 expire 是两个命令,并非原子操作。如果执行了setnx命令后,节点崩溃了,还没来得及执行expire命令,那么该锁就永远不会过期了,其他节点就永远无法获得该锁了。

这里的原子操作与我们在数据库事务中用到的原子性并不等同。数据库事务中的原子性指“要么都成功要么都失败”。而我们这里的原子操作,仅是指操作不可被拆分,实际上 Redis 中执行 Lua 脚本即使出错也不会回滚。

为了解决该问题,我们可以使用 Lua 脚本。Lua 脚本允许我们将多个命令打包执行,成为原子操作。

除了使用 Lua 脚本,保证 setnx + expire 两条指令的原子性,Redis 还为我们提供了 set 指令扩展参数,通过使用扩展参数,我们也可以实现上面的目的。

SET key value [EX seconds] [PX milliseconds] [NX|XX])
  • EX seconds :设定 key 的过期时间,时间单位是秒。
  • PX milliseconds: 设定 key 的过期时间,单位为毫秒
  • NX :表示 key 不存在的时候,才能 set 成功。
  • XX: 表示 key 存在的时候,才能 set 成功。

解决了设置 key 和为 key 设置过期时间两个命令的原子操作问题,我们还无法实现一把靠谱的分布式锁。假设这种场景:节点 a 获取锁成功,开始执行临界区代码。当锁过期了,节点 a 还没执行完临界区代码。此时节点 b 也请求过来,显然节点 b 是可以成功获得该锁的。节点 b 也开始执行临界区代码,那么临界区代码的互斥性串行性就被破坏了。此外,当节点 a 执行完临界区代码,去释放锁,然而此时节点 b 还没执行完临界区代码(节点 a 以为释放的是自己的锁)。这个问题同样很严重。一个节点的锁被另一个节点错误释放了,不满足 第 3 小节中提到安全性

总结以下,上面提到的两个问题:

  1. 锁过期释放了,节点的业务还未执行完。
  2. 锁被别的节点误删。

我们先解决问题 2。为了防止节点上的锁被别的节点误删,我们可以将 value 值设置为一个能够标记当前节点的唯一值。在释放锁时,对 value 进行校验,仅释放属于当前节点上的锁。 伪代码如下:

// 上锁
redisClient.set(key, clientId, "NX", "EX", expireTime) // clientId即是标记当前节点的唯一值
// 执行业务方法
doBusness()
// 比较并解锁
if compare(redisClient.get(key), clientId)
  redisClient.del(key)

上述比较 value 相等后删除 key 的操作也可以使用 Lua 脚本来实现原子操作

离设计一个靠谱的分布式锁越来越近了,我们还剩下一个问题没解决: 锁过期释放了,节点的业务还未执行完

一种简单的方法是将锁的过期时间设置得久一点,这当然也可以。 但是这种处理方式很不灵活,而且不同业务方法的执行时间不同,有的久一点,有的短一点。如果对所有业务方法都应用一个比较久的过期时间,那么系统的响应速度就会变慢,不足以称得上高可用高性能

一个更好的方法是设置一个定时线程,周期性地检查锁,对即将过期的锁延长其过期时间,防止锁过期提前释放。在这方面,Java 的 Redission 便是采用了这种方式,其使用了一个 watch dog 机制来周期检查锁,对锁执行延长过期时间操作。

到这里,我们的分布式锁已经做到了互斥性锁超时释放高性能高可用安全性,还剩下一个可重入特性还没实现。关于可重入,通常由具体的客户端来实现,比如 Spring 提供的 RedisLockRegistry,则是利用的 JDK 提供的 Lock 来实现可重入。

::: tip 拉个 Star

  • 看到这里,如果本篇文章的内容帮助到你,还请点个免费的 Star,感谢。传送门:GitHub :::