(*)Redis 实战应用之分布式锁

2020-06-12 注:部分图片需要重画及排版

前言

  阅读此文之前,应首先阅读初识 Zookeeper 一文中什么是分布式协调服务?分布式锁两小节。

Redis 分布式锁实现核心步骤

  在 Redis 中,分布式锁实现的两个核心步骤要素包括:

  • 加锁
  • 解锁

加锁

  在 Redis 中,加锁最简单的方法是使用 setnx(key,value) 命令。
  key 是锁的唯一标识,因此应该按具体业务来进行命名。
  举个栗子:现在想要给秒杀活动的一种商品加锁,可以给 key 命名为 lock_sale_product_id
  而 value 又应该设置成什么呢?
  对 value 来说,无所谓设置成什么数,因为对setnx(key,value) 命令而言:

  • 只有在 key 不存在的情况下, 才能将 key 的值设置为 value
  • 若 key 已经存在, 则 SETNX 命令不做任何动作

  因此,value 其实并无特定含义。

  加锁的伪代码如下:

1
setnx(lock_sale_商品ID,666)

  对该命令而言:

  • 命令在设置成功时返回 1 ,
  • 命令在设置失败时返回 0

  因此,当一个线程执行setnx后:

  • 若返回 1,说明 key 原本不存在,该线程成功得到了锁
  • 若返回 0,说明 key 已经存在,该线程抢锁失败

解锁

  有加锁自然也有解锁:当得到锁的线程执行完任务,就需要释放锁,以便其他线程可以进入。
  在 Redis 中,释放锁的最简单方式是执行del指令,伪代码如下:

1
del(lock_sale_商品ID)

  释放锁之后,其他线程便可以继续执行 setnx 命令来获得锁。

存在的问题

  对于上述 2 个步骤,若考虑一些极端场景,其实还是存在一些问题的,比如:

  • 死锁
  • 并发问题:
    • 锁误删
    • 锁提前释放
  • 扩展问题:
    • 不可重入
    • 不可重试

死锁

  对于前文两步实现的分布式锁而言,会由于以下两个问题导致死锁:

  • 未自动释放锁
  • 非原子性操作

未自动释放锁

  若一个得到锁的线程在执行任务的过程中挂掉,来不及手动显式地释放锁,这块资源便会永远被锁住,别的线程就再也无法操作这块资源。

  为了避免死锁问题,须保证锁即使没有被显式释放,也必须在一定时间后自动释放,因此setnx 的 key 必须设置一个超时时间,

  由于setnx不支持超时参数,因此需要额外的expire指令,伪代码如下:

1
expire(lock_sale_商品ID, 30)

  综合伪代码如下:

1
2
3
4
5
6
7
8
if(setnx(lock_sale_商品ID,1) == 1){
expire(lock_sale_商品ID,30
try {
do something ......
} finally {
del(lock_sale_商品ID)
}
}

非原子性操作

  即使我们通过expire设置了超时时间手动的释放了锁,但是由于setnxexpire命令是 2 个操作,并不是原子性的。那么,设想一个极端场景,当线程 A 执行setnx后得到了锁:

  若setnx刚执行成功,却还未来得及执行expire指令,节点 1 便挂掉了。
  此时,线程 A 的锁就没有设置过期时间,其占有的资源被锁住,那么别的线程便再也无法获得该资源的锁了。

解决方案:setex 命令

  在 Redis 2.8 版本中,添加了setex命令,由于setex支持setnxexpire指令组合的原子操作,因此可以解决该问题。
  伪代码如下:

1
2
SETEX KEY_NAME TIMEOUT VALUE
setex(lock_sale_商品ID,30,1)

并发问题:锁误删

  若某线程的执行时间超过了超时时间,那么由于锁会自动释放,此时若有后续线程获取到锁,便会删除后续一个线程加的锁。
  设想一个极端场景,线程 A 成功得到了锁,并且设置的超时时间是 30 秒。

  若某些原因导致线程 A 执行的很慢很慢,过了 30 秒都未执行完成,这时锁过期后便自动释放,线程 B 若过来,便又得到了锁。

  随后,线程 A 执行完了任务,线程 A 接着执行del指令来释放锁。
  但由于此时线程 B 还未执行完,因此线程 A 实际上删除的是线程 B 加的锁,若线程 C 又跑了过来,数据就会出现问题了。

  那么,又该如何解决此问题呢?
  我们可以在del释放锁之前做一个判断,验证当前的锁是不是自己加的锁:

  • 若是,则允许释放
  • 若不是,则不允许释放

  至于具体的实现,可以在加锁的时候把当前的线程 ID 当做 value,并在删除之前验证 key 对应的 value 是不是自己线程的 ID。

  加锁:

1
2
String threadId = Thread.currentThread().getId()
setex(key,30,threadId)

  解锁:

1
2
3
if(threadId .equals(redisClient.get(key))){
del(key)
}

  但是,这样做又隐含了一个新的问题:判断和释放锁是两个独立操作,又不是原子性了。

  遇到上述情况,我们就没法简单的通过命令去进行处理了,不过我们可以借助 Lua 脚本进行操作,在一个脚本中编写多条 Redis 命令,确保多条命令执行时的原子性。

并发问题:锁提前释放

  当一个线程获取锁并设置了超时时间后,如果业务执行时间过长,而超时时间设置的太短,锁会提前释放,那么在并发请求下,其他的线程可以获得锁,可能会造成并发问题。

  因此,我们最好让获得锁的线程开启一个守护线程,用来给快要过期的锁“续命”。

  当线程 A 获得锁后过去了 29 秒,若线程 A 还没执行完,这时守护线程便会执行expire指令,为这把锁“续命” 20 秒。
  守护线程从第 29 秒开始执行,每 20 秒执行一次。
  当线程 A 执行完任务,就会显式关掉守护线程。

  即使考虑极端场景,比如节点 1 忽然断电了。
  由于线程 A 和守护线程在同一个进程,线程 A 停止的同时守护线程也会停止。
  因此,若锁到了超时时间,而又没人给它续命,便会自动释放了。

扩展问题:不可重入

同一个线程无法多次获取同一把锁,但在某些业务场景下希望存在这种重试机制。

扩展问题:不可重试

获取锁只尝试一次就返回false,没有重试机制,在某些业务场景下希望存在这种重试机制。

分布式锁的使用

  若不想基于 Redis 原生 Api 来手动实现 Redis 分布式锁之外,亦可使用开源框架:Redission。

  应用集成相应的依赖包后使用即可,非常简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Config config = new Config();  
config.useClusterServers()
.addNodeAddress("redis://192.168.31.101:7001")
.addNodeAddress("redis://192.168.31.101:7002")
.addNodeAddress("redis://192.168.31.101:7003")
.addNodeAddress("redis://192.168.31.102:7001")
.addNodeAddress("redis://192.168.31.102:7002")
.addNodeAddress("redis://192.168.31.102:7003");

RedissonClient redisson = Redisson.create(config);


RLock lock = redisson.getLock("productLock");
// 加锁
lock.lock();
// 自定义业务代码

// 解锁
lock.unlock();

  现在仅需通过 Redission Api 中的 lock 和 unlock 即可完成分布式锁,Redisson 是如何解决我们上面提出的问题呢?
  在 Redisson 中:

  • 所有指令都通过 Lua 脚本执行,而 Redis 支持 Lua 脚本原子性执行
  • 存在watchdog(看门狗)机制:可在获取锁之后,每隔 10 秒帮你把 key 的超时时间设为 30s 为锁续命,保证了没有死锁发生

  Redisson 实现分布式锁的基本步骤如下:

  1. 锁的存储:Redisson 通过将锁信息存储在 Redis 中的特定键来实现分布式锁。这个键通常会包含一个随机生成的锁值(作为锁的唯一标识),及其它必要的元数据,如锁定状态、锁持有线程标识等。
  2. 加锁:Redisson 通过使用SET命令来添加一个锁键到 Redis 中,这个命令会利用 NX(Not eXist,如果不存在则SET)和PX(到期时间-毫秒)选项以确保只有当锁不存在的时候才能设置成功。如果加锁命令执行成功,则该客户端拥有了这个锁;如果锁已经存在表示锁目前被其他客户端持有,当前操作就会等待直到获取锁或者超时。
  3. 看门狗(自动延期机制):Redisson 内置的“看门狗”机制会自动检测锁键是否快到期了,并在锁还被当前线程持有时自动延长锁的有效期。这确保了在持有锁的线程在执行较长操作时不会突然失去锁。
  4. 解锁:当事务或任务完成时,加锁的客户端要释放锁,Redisson 会用 Lua 脚本执行一个原子操作,它会检查锁键的值,确保当前释放锁的是同一个持有锁的客户端,然后删除这个锁键。
  5. 高可用的锁:Redisson 还支持在 Redlock 算法上实现的高可靠性的分布式锁。Redlock 算法由 Redis 的创建者提出,是在多个独立的 Redis 节点上同时加锁,只有大多数节点加锁成功时,才认为获取到了锁。这增加了容错性,即便个别 Redis 节点宕机,只要大部分的节点正常,整个分布式锁的机制仍然可用。

  Redisson 的分布式锁实施简化了复杂性,提供了易用的API,并且自动处理了许多可能在分布式环境中遇到的复杂问题,如锁的自动续期、加锁失败的重试等。但是,正确使用分布式锁需要在业务逻辑层面上仔细设计,确保锁的使用不会导致死锁或资源的不当利用。

RLock API

  RLock实现了 java.util.concurrent.locks.Lock 接口,符合 Java 中的 Lock 接口规范的。RLock的中有四个方法来源于 Java 中的 Lock 接口:

  • void lock() 获取锁,如果锁不可用,则当前线程一直等待,直到获得到锁
  • void lockInterruptibly()lock() 方法类似,区别是 lockInterruptibly() 方法在等待的过程中可以被 interrupt 打断
  • boolean tryLock() 获取锁,不等待,立即返回一个 boolean 类型的值表示是否获取成功
  • boolean tryLock(long time, TimeUnit unit) 获取锁,如果锁不可用,则等待一段时间,等待的最长时间由 long timeTimeUnit unit 两个参数指定,如果超过时间未获得锁则返回 false,获取成功返回 true

  除了以上四个方法外,还有三个方法属于 RLock特有的方法。这三个方法和上面四个方法有一个最大的区别就是 long leaseTime 参数。leaseTime 指的就是 Redis 中的 key 的失效时间。通过这三个方法获取到的锁,如果达到 leaseTime 锁还未释放,那么这个锁会自动失效。

扩展

若 Redis 采用 Cluster 模式,Redission Lua 脚本还能工作嘛?

  Redis Cluster 提供了一种在多个 Redis 节点间自动分割数据的机制,来提供一定程度的可用性和分区容错能力。在这种模式下,数据(键)被分配到不同的节点上,而且,因为 Lua 脚本是要求以原子的方式在一个节点上执行的,所以必须确保在执行脚本时所涉及的所有键都位于同一个节点上。

  Redisson 在处理分布式锁时,会确保所有的操作都在同一个 Redis 节点上执行。它通过在键名前加上特定的{...}哈希标签来实现这一点。当你使用哈希标签时,所有包含相同标签的键都会被分配到同一个节点上。这意味着即使在集群模式下,只要键被正确地哈希到了相同的节点,Lua脚本就能按预期工作。

  举个例子,如果你有一个名为myLock{myLockKey}的锁,Redisson 会确保所有跟这个锁相关的命令和 Lua 脚本都在保存有{myLockKey}哈希标签所对应哈希槽的集群节点上执行。

什么是红锁(RedLock)

  红锁(RedLock)是 Redis 官方推荐的一种分布式锁算法,由 Redis 的作者 Antirez(Salvatore Sanfilippo)提出。这不是特定于 Redisson 的,但 Redisson 提供了 RedLock 算法的实现。

  在单个 Redis 节点的情况下,如果这个节点不可用了,那么所有持有该节点锁的服务就会出问题。为了解决这个问题,Antirez 提出了 RedLock 算法。这个算法不依赖于单个 Redis 节点,而是在多个独立的 Redis 实例上创建锁。

  RedLock 的工作过程如下:

  1. 获取当前时间(毫秒)
  2. 依次向多个 Redis 实例尝试加锁,每个实例使用相同的 key 和随机生成的 value。在这个过程中,客户端要求加锁的操作有一个固定的超时时间,这对于防止在某个实例无响应时无限期等待很重要
  3. 加锁时,应该确保从大多数(n/2+1)的 Redis 实例上都成功获得了锁
  4. 如果锁在大多数节点上都成功设定,客户端计算整个锁定过程中消耗的时间。如果这个时间小于锁设置的有效时间,那么认为这个锁是有效的
  5. 如果由于某些原因锁在多数节点上没有成功设置,或者锁定过程耗时太长,客户端会立即向所有 Redis 实例发起解锁操作,以免这些锁影响之后的操作

  RedLock 可以提高分布式锁的可靠性,因为即使有几个 Redis 实例挂掉,只要大多数的实例是健康的,客户端就仍然能够获得和释放锁。不过,RedLock 算法的设计还是有争议的,比如对于时间同步和网络延迟有一定的要求,系统时间的不精确或不一致可能会导致锁的不正确行为。此外,分布式系统理论专家已经指出了 RedLock 在理论上存在的一些问题,尤其是在极端或边界条件下可能无法保证锁的安全性。因此,在使用 RedLock 时需要特别小心,明确你的业务需求和场景。

参考

文章信息

时间 说明
2019-06-06 初稿
0%