Java 基于 Redis 实现分布式锁需要注意什么?
在如今这样一个张口分布式,闭口微服务的软件开发趋势下,多实例似乎已经不是某种选择而是一个无需多说的基本技术要求了。
多实例为我们带来稳定性提升的同时,也伴随着更复杂的技术要求,原先在本地即可处理的问题,全部扩展为分布式问题,其中就包含我们今天会聊到的多实例同步即分布式锁问题。JDK 提供的锁实现已经能够非常好的解决本地同步问题,而扩展到多实例环境下,Redis、ZooKeeper 等优秀的实现也使得我们使用分布式锁变得更加简单。
其实对于分布式锁的原理、分布式锁的 Redis 实现、ZK 实现等等各类文章不计其数,然而只要简单一搜就会发现,大多数文章都在教大家 Redis 分布式锁的原理和实现方法,但却没有几篇会写什么实现是好的实现,是适合用于生产环境,高效而考虑全面的实现。这将是本文讨论的内容。
分布式锁的要求
本节简单阐述分布式锁的基本要求,通常满足下述要求便可以说是比较完整的实现了。
- 操作原子性
- 与本地锁一样,加锁的过程必须保证原子性,否则失去锁的意义
- Redis 的单线程模型帮我们解决了大部分原子性的问题,但仍然要考虑客户端代码的原子性
- 可重入性
- 分布式锁一样要考虑可重入的问题
- Redis 通常能解决实例间的可重入问题,那么实例内线程间的可重入怎么办?
- 效率
- Redis 作为通过 TCP 通信的外部服务,网络延迟不可避免,因此相比本地锁操作时间更久
- 分布式锁获取失败的通常做法是线程休眠一段时间
- 如何才能尽可能减少不必要的通信与休眠?
Local + Remote 结合实现分布式锁
正如上一节所述,采用 Redis,我们能很好的实现实例间的原子性(单线程模型),可重入性(各实例分配 UUID)。
而 JDK 的本地锁(如 ReentrantLock)又能非常完善的解决线程间同步的原子性、可重入性。
此外,对于实例内不同线程间的同步,JDK 通过 AQS 中一系列的方法确保高效稳定,因此省去了与 Redis 通信的消耗。
综上,如果将本地锁与远程锁结合在一起,便可以分别实现分布式锁在实例内与实例间的各项要求了。
代码实现
下文代码中,本地锁使用 ReentrantLock, Redis client 使用 Jedis。如替换其他方案,按照流程也很简单。
整体架构
初始化锁
1
2
3.
└── 初始化锁
└── new instance获取锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16.
└── 获取锁
└── 尝试获取本地锁
├── 成功
│ └── 尝试获取远程锁
│ ├── 成功
│ │ └── 加锁完成
│ ├── 失败
│ │ └── 轮询远程锁
│ └── 超时
│ ├── 释放本地锁
│ └── 退出
├── 失败
│ └── 阻塞等待
└── 超时
└── 退出释放锁
1
2
3
4
5
6
7
8
9
10.
└── 释放锁
├── 当前线程持有本地锁?
│ ├── 是重入状态?(hold count > 1)
│ │ └── 释放本地锁
│ └── 非重入状态
│ ├── 释放远程锁
│ └── 释放本地锁
└── 未持有本地锁
└── 无法释放,抛出错误
代码框架
1 | public class RedisDistributedLock implements Lock { |
由上述代码可见,我们的分布式锁实现了 Lock
接口,来确保依赖倒置,用户可以方便的在本地锁与分布式锁之前切换而无需改动逻辑。
在 class field 中, - OBTAIN_LOCK_SCRIPT
是用于执行
redis 获取锁操作的 lua script,详情见后文。 -
clientId
用于唯一标识当前所在实例,是分布式锁进行重入的重要属性,注意该
field 为 static,因此仅此一份。 - lockKey
为锁
key,用于标识一个锁,在构造函数中初始化。 - localLock
即本地锁,代码中采用 ReentrantLock
用作本地锁。
出于演示性质考虑,只实现了 Lock
中定义的三个方法:lock()
,
tryLock(long time, TimeUnit unit)
,
unlock()
,其他方法可以自由发散。
接下来我们将主要介绍获取锁、释放锁这两部分代码。
lock
1 | private static final String OBTAIN_LOCK_SCRIPT = |
lock()
中包含了绝大多数的核心逻辑,可以看到其主要流程如下:
- 获取本地锁 - 循环调用 obtainRemoteLock()
直至其返回
true,或抛出异常 - 假如跳出循环后仍未能获取到锁,则释放本地锁
以上流程中,需要细说的正是 obtainRemoteLock()
:
该方法直接通过 eval
来执行了前面提到的 lua
脚本,我们来看看脚本的内容: 1.
local lockClientId = redis.call('GET', KEYS[1])
-
此处是通过 get 获取到了 key 值,并赋值为 lockClientId,其中
KEYS[1]
是 eval 传入的 key 参数 2.
if lockClientId == ARGV[1] then
- 这里将拿到的值与参数
ARGV[1] 进行判断,结合 obtainRemoteLock()
的逻辑我们发现
ARGV[1] 其实是 clientId
,所以假如获取的值与 clientId
相等,则代表一种情况:获取锁的线程与锁处于同一个实例 -
又因为:每次获取远程锁之前需要先获取本地锁,在同一实例下,本地锁确保了同一时间只能有一个线程尝试获取远程锁
- 结合上述两点,可以确定:当 lockClientId 等于 clientId
的时候,是同一实例下的同一线程重入了代码段。 -
redis.call('EXPIRE', ARGV[2])
在重入之后刷新锁超时时间,ARGV[2] 即我们传入的
EXPIRE_SECONDS
- 最后直接返回 true,结束逻辑 3.
else if not lockClientId then
- 假如 get 的结果为 null(nil)
表明锁还没有被任何人获取,直接获取后返回 true - 这里用到了 redis 的 set
命令 redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
4.
return false
-
即不是重入,锁又存在,证明锁被其他实例持有了,返回 false
上述一连串判断逻辑,因为全部都是在 Redis
内执行的,我们完全不用考虑原子性问题,因此可以放心大胆的相信执行结果。
#### tryLock 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if ( !localLock.tryLock(time, unit)) {
return false;
}
boolean acquired = false;
try {
long expire = System.currentTimeMillis() + TimeUnit.MILLISECONDS.convert(time, unit);
while (!(acquired = obtainRemoteLock()) && System.currentTimeMillis() < expire) {
sleep();
}
return acquired;
} finally {
if (!acquired) {
localLock.unlock();
}
}
}lock()
的逻辑,tryLock()
看起来只是增加了超时逻辑,并没有本质的区别。
unlock
1 |
|
相比本地锁,分布式锁的解锁过程需要考虑的多一些: 1. 先判断尝试解锁的线程与持有本地锁的线程是否一致,实际上 ReentrantLock.unlock() 原生即有相关判断,但是目前我们还暂时不想让本地锁直接被解锁,因此手动判断一下。 2. 当本地锁重入计数大于 1 时,本地锁解锁后直接返回。由于我们的远程锁并没有记录重入计数这一参数,因此对于重入线程的解锁,只解锁本地。 3. 先判断当前远程锁的值是否与本实例 clientId 相等,如果不等则认为是远程锁超时被释放,因此分布式锁的逻辑已经被破坏,只能抛出异常。 - 这里涉及到远程锁超时时间的设定问题,设定过长可能会导致死锁时间过长,设定过短则容易在逻辑未执行完便自动释放,因此实际上应该结合业务来设定。 4. 假如一切正常,则释放远程锁,之后再释放本地锁。
RedisLockRegistry
对 Spring Integration Redis 熟悉的同学,一定已经发现,前面的代码完全就是 RedisLockRegistry 的简化版,许多变量名都没改。
是的,其实前文所述的代码就是 RedisLockRegistry 的核心逻辑。RedisLockRegistry 是 Redis 分布式锁中代码比较简单、功能比较完善的一种实现,可以很好的满足常见的分布式锁要求。(由于采用 sleep-retry 的方式尝试获取锁,在低时延或高并发要求下并不适用)
RedisLockRegistry 对外部库的依赖较少,虽然执行 redis 命令主要使用的 Spring Redis Template,不过也很容易迁移为类似 Jedis 的方案。
不过截至目前 Spring-Integration-Redis 在 github 上面并没有放置任何 licence,按照 github 的规定,没有 licence 的代码版权默认受到保护,因此我们可以学习其设计思想并自己尝试实现,但是最好不要直接移植代码。
Redis 多实例
通过上述方法,我们似乎可以成功的将实例间同步的问题转交给 Redis 来处理。然而就存在两种情况: 1. 采用单实例 Redis -- Redis 存在单点风险,应用服务都依赖 Redis, 一旦宕机业务全挂 2. 采用 Redis 集群 -- 应用服务实例间的同步问题转化为了 Redis 实例间的同步问题
单实例 Redis 一定是不可接受的,所以似乎允许上生产环境的唯一方案就是 Redis 集群了。那么如何保证 Redis 实例间的同步呢?
我们知道,Redis 集群的数据冗余策略不同于类似 HDFS 的 3 Replica,而是采用一对一主从的形式,每个节点一主一从,主节点宕机备节点上,备节点也宕机就全完。同时,主从之间的数据同步是异步的。以上这些都是为了超高吞吐量而做出的妥协。
所以,设想会有这种情况:
当应用服务节点 App-A 从 Redis 某主节点 R-Master 获取到锁后,R-Master 宕机,此时 R-Master 的数据还没来得及同步到 R-Slave。现在 R-Slave 成为了主节点,这时候 App-B 尝试获取锁,不出意外的也获取成功了。
基于以上问题,Redis 给出了 RedLock 方案,该方案采用相互孤立的奇数个 Redis 节点来共同存储锁,对于获取锁的操作,只有当 (N-1)/2 + 1 个 Redis 实例都获取成功且获取时间不超过锁失效时间的前提下,才真正被判定为获取到了锁,这种场景下锁的争抢就看谁能先成功操作超过半数的 Redis 实例。Redisson 实现了 RedLock 的客户端方案。
当然,在 Redis 官网上也贴出了各方对于 RedLock 方案的争论,这里不再赘述。
总之,对于问题的处理终归是结合实际情况来权衡的, - 假如小概率(但几乎一定会发生)的 Redis 宕机未同步导致锁失效的问题,业务可以承受,那么 RedisLockRegistry + Redis 集群的方案就没问题 - 对性能和可靠性都有更高要求的情况下,不妨使用 RedLock 方案 - 业务非常关键,一定要求强一致的分布式锁,使用 ZooKeeper 的方案会更好(性能没法和 Redis 比)