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
8if(setnx(lock_sale_商品ID,1) == 1){
expire(lock_sale_商品ID,30)
try {
do something ......
} finally {
del(lock_sale_商品ID)
}
}
非原子性操作
即使我们通过expire设置了超时时间手动的释放了锁,但是由于setnx和expire命令是 2 个操作,并不是原子性的。那么,设想一个极端场景,当线程 A 执行setnx后得到了锁:

若setnx刚执行成功,却还未来得及执行expire指令,节点 1 便挂掉了。
此时,线程 A 的锁就没有设置过期时间,其占有的资源被锁住,那么别的线程便再也无法获得该资源的锁了。
解决方案:setex 命令
在 Redis 2.8 版本中,添加了setex命令,由于setex支持setnx和expire指令组合的原子操作,因此可以解决该问题。
伪代码如下:1
2SETEX 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
2String threadId = Thread.currentThread().getId()
setex(key,30,threadId)
解锁:1
2
3if(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
19Config 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 实现分布式锁的基本步骤如下:
- 锁的存储:Redisson 通过将锁信息存储在 Redis 中的特定键来实现分布式锁。这个键通常会包含一个随机生成的锁值(作为锁的唯一标识),及其它必要的元数据,如锁定状态、锁持有线程标识等。
- 加锁:Redisson 通过使用
SET命令来添加一个锁键到 Redis 中,这个命令会利用NX(Not eXist,如果不存在则SET)和PX(到期时间-毫秒)选项以确保只有当锁不存在的时候才能设置成功。如果加锁命令执行成功,则该客户端拥有了这个锁;如果锁已经存在表示锁目前被其他客户端持有,当前操作就会等待直到获取锁或者超时。 - 看门狗(自动延期机制):Redisson 内置的“看门狗”机制会自动检测锁键是否快到期了,并在锁还被当前线程持有时自动延长锁的有效期。这确保了在持有锁的线程在执行较长操作时不会突然失去锁。
- 解锁:当事务或任务完成时,加锁的客户端要释放锁,Redisson 会用 Lua 脚本执行一个原子操作,它会检查锁键的值,确保当前释放锁的是同一个持有锁的客户端,然后删除这个锁键。
- 高可用的锁: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 time和TimeUnit 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 的工作过程如下:
- 获取当前时间(毫秒)
- 依次向多个 Redis 实例尝试加锁,每个实例使用相同的 key 和随机生成的 value。在这个过程中,客户端要求加锁的操作有一个固定的超时时间,这对于防止在某个实例无响应时无限期等待很重要
- 加锁时,应该确保从大多数(n/2+1)的 Redis 实例上都成功获得了锁
- 如果锁在大多数节点上都成功设定,客户端计算整个锁定过程中消耗的时间。如果这个时间小于锁设置的有效时间,那么认为这个锁是有效的
- 如果由于某些原因锁在多数节点上没有成功设置,或者锁定过程耗时太长,客户端会立即向所有 Redis 实例发起解锁操作,以免这些锁影响之后的操作
RedLock 可以提高分布式锁的可靠性,因为即使有几个 Redis 实例挂掉,只要大多数的实例是健康的,客户端就仍然能够获得和释放锁。不过,RedLock 算法的设计还是有争议的,比如对于时间同步和网络延迟有一定的要求,系统时间的不精确或不一致可能会导致锁的不正确行为。此外,分布式系统理论专家已经指出了 RedLock 在理论上存在的一些问题,尤其是在极端或边界条件下可能无法保证锁的安全性。因此,在使用 RedLock 时需要特别小心,明确你的业务需求和场景。
参考
文章信息
| 时间 | 说明 |
|---|---|
| 2019-06-06 | 初稿 |