avatar

目录
Redis 实现分布式锁

之前看过 Katio 大佬的公众号《水滴与银弹》上发过一篇讲 Redis 实现分布式锁的文章,写得真的很全很细,看完我大受震撼,因此我也决定尝试着自己写一写关于这方面的内容。

1. 概念

通常情况下,锁是用来保证对共享资源的互斥访问的。比如在 Java 单进程环境下,多线程对于共享资源的访问,我们一般会采用 synchronized 关键字或者 ReentrantLock 来保证不同线程可以实现对共享资源的互斥访问。单进程环境下可以这样操作,但是一旦换到多进程环境,比方说微服务架构下不同物理机器上的服务对同一数据的访问,为了保证该数据的安全性和正确性,也同样需要一把 “锁” 来保证互斥访问。这就是 “分布式锁” 的来源。

举个栗子,现在有三个不同的进程都想修改数据库上的同一行记录,为了保证数据的安全性和正确性,在修改记录之前,进程必须先获得一把锁,只有获得锁的进程,才有资格对数据库记录进行修改,如图:

分布式锁示意图

2. 实现

在日常使用过程中,对 Redis 服务端而言,我们的每一个微服务进程都看作是 Redis 的客户端,在获得共享资源的使用权之前,它们需要访问 Redis 先获得一个访问的许可。要实现一个分布式锁,需要考虑的点有很多,大致罗列如下:

  1. 客户端如何加锁?

  2. 客户端加上的锁是否一直有效?

  3. 客户端加锁时有无原子性保障?

  4. 客户端如何解锁?

  5. 客户端解锁时,如何确保删除的锁是原先自己加上的?

  6. 如何确保锁的有效期要不小于业务逻辑处理时间?

  7. 集群模式下分布式锁真的安全吗?

上述问题分别从加锁、解锁、以及持有锁期间这三个方面来考虑。

为了方便,结合一小段业务代码来说明上述问题。

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Autowired
private StringRedisTemplate template;

@Autowired
private JedisPool jedisPool;

private String REDIS_LOCK_KEY = "uniqueRedisLockKey";

public String doBiz() {
// 当前请求的 UUID + 线程名
String value = UUID.randomUUID().toString() + Thread.currentThread().getName();
try {
// 代码1. setIfAbsent() 就相当于使用 set key value NX 加锁
Boolean lockFlag = template.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value);
// 代码3. 加上过期时间
template.expire(REDIS_LOCK_KEY, 10L, TimeUnit.SECONDS);

if (!lockFlag) {
return "failure";
}

// execute buiness code
...
} catch(Exception e) {
// handle exception
} finally {
// 代码2. 解锁
template.delete(REDIS_LOCK_KEY);
}
}

先看第1和第2个问题。

一. 客户端加锁通常采用 SET uniqueKey randomValue NX 这样带有 NX 选项的 SET (意为 SET If Not EXIST) 命令来加锁,其中 uniqueKey 是业务相关的唯一Key, randomValue 则是跟具体加锁的客户端相关的一个唯一标识值,解锁的时候要用到。

二. 再考虑锁的过期时间问题。加锁和解锁必须是成对出现的操作,如何上述代码中注释 代码1代码2 所示。现在假设 线程 A 通过执行 SETNX 成功获取到了锁,但是线程 A 在业务任务执行尚未结束的时候发生了宕机或者极端情况下,线程 A 所在的 JVM 因意外退出了,那么线程 A 是永远也无法执行到 finally 代码块中的解锁代码的,这就造成了 死锁 现象,下次再有其他线程过来加锁时,由于这个锁已经存在了,会导致后续的线程永远加锁失败,进而导致它们全都歇菜了。所以给锁加上过期时间是必要的,为的是让 Redis 能帮我们自动的去释放锁,这样可以避免客户端因为出现异常而没有及时释放锁所带来的不良后果。

接着看第3个问题。

三. 解决了怎么加锁以及加锁需要给加上有效期这俩问题之后,又有一个新的问题了。那便是——加锁的原子性问题。同第二点中的描述,由于 代码1 这里代表着一处网络连接,若是这一步没有执行成功或者就在执行这一步之后 JVM 退出了,那么后续 代码3就没有机会执行了,相当于同样地会造成 死锁 问题。因此加锁和加过期时间这两步操作应该合成一步,以确保上锁操作的原子性。因此应该将代码1代码3 处的代码改为如下:

java
1
2
// 代码4. 原子性加锁
template.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value, 10L, TimeUnit.SECONDS);

接着再看第4和第5个问题。

四. 目前看来,finally块中的 代码2 似乎已经实现了解锁功能。这里需要考虑第5个问题。 由于各个进程加锁时采用的 key 是全局唯一的,那么就有可能出现以下情形:

Redis 误伤他人的锁

如图,进程A 在 T2 时刻成功获取到锁,接着在 T5 时刻锁过期时间到,因此进程 B 得以在后续的 T6 时刻加锁成功,进程 A 一顿操作后来到了 T7 时刻,进程 A 执行解锁操作,这个时候就把进程 B 加的锁给释放掉了,下一时刻 T8,进程 B 去解锁的时候锁已经不存在了。更惨的是,T8 时刻进程 B 也可能解了别人的锁。对于 T7 时刻之后的时间而言,此时进程 B 对于共享资源的操作的互斥性也就被破坏了。这意味着在这段时间内只要进程 B 操作还未结束,完成有可能再来一个进程 C 加锁成功,再同时跟进程 B 操作同一个资源,就没有互斥可言了,分布式锁的作用也就丧失了。分析了这么一大段之后,可以得出结论:进程 A 解锁时只能解除进程 A 自己加上的锁,而不能解除其他进程的锁,不然就没有互斥可言了。因此 代码2 也是需要改造的,如下:

java
1
2
3
4
5
// 代码5. 解锁改造原 代码2
// 通过跟进程绑定的 value 值来判断是否可以解锁
if (value.equals(template.get(REDIS_LOCK_KEY)) {
template.delete(REDIS_LOCK_KEY);
}

五. 经过上述改造之后,看似解决了解锁时容易出现的 “张冠李戴” 现象,实际上此时又引出了一个新的问题。上述内容已经讲过,加锁需要确保动作的原子性,同样地,解锁时也是需要确保原子性的。由于加上了 if 判断语句,解锁动作又被分成了两步执行,因此又不具备原子性了。那么该怎么做才是正确的解锁姿势呢?

一种通用的做法是——采用 Lua Script. Lua 脚本在 Redis 中是被原子性执行的。脚本如下:

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

有了这段脚本,相应的代码改造为:

java
1
2
3
4
5
6
7
8
9
10
11
// 代码6. 使用 Lua 脚本完成原子解锁
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1] then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
try (Jedis jedis = jedisPool.getResource()) {
return "1".equals(jedis.eval(script,
Collections.singletonList(lockKey),
Collections.singletonList(value)));
}

接着再来看这最后两个问题。6. 如果保证上锁的时间要长于业务的执行时间?也就是说,在业务代码还没执行完之前,这个锁必须由上锁的进程一直持有,不然就有可能发生刚刚在描述第四点时出现的无法保证对资源互斥访问的问题。一种可行的解决办法是,在后台另起一个守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。7. 集群模式下分布式锁的安全性问题? 通常我们在部署 Redis 时会采用 集群+哨兵的方式。如果上锁完成之后,master 节点挂了,那么哨兵会重新选主,将其中某个从节点提升为主节点。问题是,上锁是上在了 master 节点中,此时 master 挂掉了,而由原来的从节点重新成为主节点的那个节点上并没有我们加上去的锁,锁就这样 “消失” 了!

要同时解决上述这两个问题,我们只好搬出终极救兵 Redission 了! Redission 是一个 Java 语言实现的 Redis SDK 客户端,提供了非常易用且友好的 API, 使得用户在程序中可以像使用本地锁那般地使用分布式锁。Redission 中提供了 Watch Dog 看门狗机制,帮助我们在业务未执行完毕期间不断地对锁进行自动续期,以解决问题6. 不仅如此,Redission 自身还是 RedLock 算法的 Java 版本实现。而这个 RedLock 算法就是 Redis 作者提出来用以解决问题7的。

因此,针对上述业务代码,我们需要引进 Redission 来加以改进其中的加锁和解锁步骤。

java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 @Autowired
private Redisson redisson; // 引入 Redission

try {
// 获取锁
RLock redissonLock = redisson.getLock(REDIS_LOCK_KEY);
// 上锁
redissonLock.lock(10L, TimeUnit.SECONDS);

// ... do buiness code

}
// 解锁
finally {
redissonLock.unlock();
}

3. Redlock 算法

Redlock 算法是 Redis 作者提出来的解决集群模式下分布式锁可能无效问题的一种解决方案。其基本思想如下:

假设我们有 N 个 Redis 主节点,注意:都是主节点,没有从节点,也不需要哨兵! 在这个示例中,假设取 N=5. (官方推荐)

并且要求这 5 个 主节点实例是各自独立的,它们之间若是宕机了也互不影响。

3.1 算法步骤

为了获取锁,客户端执行以下操作:

  1. 它以毫秒为单位获取当前时间。
  2. 它尝试顺序获取所有 N 个实例中的锁,在所有实例中使用相同的键名和随机值。在第 2 步中,当在每个实例中设置锁时,客户端使用一个比总锁自动释放时间更小的超时来获取它。例如,如果自动释放时间为 10 秒,则超时可能在 ~ 5-50 毫秒范围内。这可以防止客户端长时间处于阻塞状态,试图与已关闭的 Redis 节点通信:如果实例不可用,我们应该尽快尝试与下一个实例通信。
  3. 客户端通过从当前时间中减去步骤 1 中获得的时间戳来计算获取锁所用的时间。当且仅当客户端能够在大多数实例(至少 3 个)中获取锁,并且获取锁所用的总时间小于锁有效时间,则认为该锁已获取。
  4. 如果获得了锁,则其有效时间被视为初始有效时间减去经过的时间,如步骤 3 中计算的那样。
  5. 如果客户端由于某种原因获取锁失败(或者它无法锁定 N/2+1 个实例或有效时间为负),它将尝试解锁所有实例(即使是它认为没有锁定的实例)能够锁定)。
3.2 失败重试

当一个客户端无法获取锁时,它应该在随机延迟后重试,以尝试去同步多个尝试同时获取同一资源锁的客户端(这可能导致没有人 获胜)。 此外,客户端在大多数 Redis 实例中尝试获取锁的速度越快,出现裂脑情况的窗口就越小(以及需要重试),因此理想情况下,客户端应尝试将 SET 命令发送到 N 个实例 同时使用多路复用。

值得强调的是,对于未能获取大部分锁的客户端而言,尽快释放(部分)获取的锁是多么重要,这样就无需等待密钥到期才能再次获取锁( 但是,如果发生网络分区并且客户端不再能够与 Redis 实例通信,则在等待密钥到期时需要支付可用性损失)。

3.3 释放锁

释放锁很简单,无论客户端是否相信它能够成功锁定给定的实例,释放锁的时候,会解除涉及到的所有实例上的锁。

上述这一段描述其实来自于 Redis 官网关于分布式锁这部分内容页面的原文翻译,原网页上下面还有两大段关于 Redlock 算法是否真的安全的争论和该算法时效性的讨论。可以自行前往观看 Distributed locks with Redis,又或者直接参阅 Kaito 大佬的公众号原文:深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!

限于篇幅,此处就不再展开了。


本文主要参考链接:

  1. 深度剖析:Redis分布式锁到底安全吗?看完这篇文章彻底懂了!
  2. Distributed locks with Redis
  3. 尚硅谷2021逆袭版Java面试题第三季 P48~P61
文章作者: JanGin
文章链接: http://jangin.github.io/2021/07/15/redis-distributed-lock/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 JanGin's BLOG

评论