Redis实现分布式锁
文章目录
- Redis实现分布式锁
- Redission
-
-
- 基于setnx实现的分布式锁存在的问题
- Redisson可重入锁原理
-
- 什么是可重入锁
- [为什么`SET NX`不能实现可重入](#为什么
SET NX
不能实现可重入) - Redisson实现可重入锁
- Redisson锁重试和WatchDog机制
- Redisson的multiLock原理
-
为什么需要分布式锁
在单个 JVM 进程内,可以使用 Java 的 synchronized
关键字或 ReentrantLock
类来实现线程同步,确保同一时刻只有一个线程可以访问关键代码段。这种方式可以确保在单个 JVM 进程内的多个线程同步执行。
然而,在分布式集群环境中,多个节点之间的通信和同步变得更加复杂。由于每个节点都有自己的 JVM 进程和内存空间,因此在这种情况下,单纯依靠 Java 的 synchronized
或 ReentrantLock
无法实现跨节点的线程同步。在分布式环境中,需要使用分布式锁来确保不同节点的线程同步执行。
分布式锁需要满足的条件
- 互斥性(Mutual Exclusion): 在任意时刻,只有一个客户端或节点能够持有锁,其他客户端或节点不能获取相同的锁。这确保了对共享资源的互斥访问,避免了并发修改导致的数据不一致性。
- 可重入性(Reentrancy): 允许同一个客户端或节点在持有锁的情况下多次获取该锁,而不会产生死锁或其他问题。这种机制使得在嵌套调用或递归函数中也能正常使用分布式锁。
- 超时机制(Timeout): 提供一种机制,确保如果持有锁的客户端或节点出现故障或异常情况,锁不会永久性地被占用。通过设置超时时间,可以避免死锁并保证系统的可用性。
- 高可用性(High Availability): 分布式锁应该设计为高可用的,即使在网络分区、节点故障或其他异常情况下,系统仍然能够正常工作,不会出现单点故障或数据不一致的情况。
- 一致性(Consistency): 分布式锁应该保证在不同节点上的锁状态保持一致,即使系统中的各个部分在不同的时间和地点进行操作,也能够保证一致的结果。
- 性能(Performance): 分布式锁的设计应该尽可能地减少性能开销,避免成为系统的瓶颈,保证系统的吞吐量和响应时间。
使用Redis做分布式锁的优点
- 简单易用: Redis 提供了原子性的操作,如 SETNX(SET if Not eXists)可以用来设置某个键的值,如果该键不存在,则设置成功并返回 1,如果键已经存在,则设置失败并返回 0。这种机制非常适合用来实现分布式锁,并且使用起来非常简单。
- 高性能: Redis 是一个高性能的内存数据库,可以快速处理大量的请求。使用 Redis 实现的分布式锁能够提供快速的加锁和释放锁的操作,不会成为系统的性能瓶颈。
- 可靠性: Redis 支持持久化机制,可以将数据存储在磁盘上,以保证数据的持久性和可靠性。即使发生系统故障或者重启,分布式锁的状态也能够得到保留,不会因为数据丢失而导致数据不一致。
- 分布式环境下的可扩展性: Redis 可以很容易地部署在多个节点上,实现分布式存储和高可用性。这样可以确保分布式锁的可用性和可扩展性,即使系统规模扩大或者节点发生故障,也能够保证分布式锁的正常工作。
- 支持超时机制: Redis 的 SET 命令可以设置键的过期时间,这意味着可以为分布式锁设置超时时间。如果获取锁的客户端在指定的时间内没有完成操作并释放锁,那么锁会自动过期释放,防止锁被永久占用。
基于Redis实现分布式锁
核心原理

Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」
- 如果 key 不存在,返回OK,则显示插入成功,可以用来表示加锁成功;
- 如果 key 存在,返回nil,则会显示插入失败,可以用来表示加锁失败。
加锁
SET lock_key unique_value NX EX 10000
- lock_key 就是 key 键,即锁的名字;
- unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- EX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
lock_key :多个线程去操作同一个数据的时候,确保获取的是同一个锁,实现互斥
unique_value:可以让每个客户端(JVM)生成一个随机数,作为前缀,每个JVM中每个线程的的threadId都是不同的,这样保证每个不同的线程都有不同的值。
加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
释放锁
锁的误删问题:
持有锁的线程在锁的内部出现了阻塞,导致他的锁超时自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明(这里可用一人一单的思路来想,同一个用户一直下单,使用的是同一把锁)
解决方案:解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果不属于自己,则不进行锁的删除,假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。

锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端(这就是前边为什么要设置每个线程都有唯一的值)
释放锁时的原子性问题:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,这个时候线程阻塞了,在线程1阻塞的过程中,锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
将删锁的操作放到一个Lua脚本中,一起去执行,保证了原子性
红锁
- Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
Redis 如何解决集群情况下分布式锁的可靠性?
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
Redlock 算法加锁三个过程:
- 第一步是,客户端获取当前时间(t1)。
- 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
- 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
- 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
- 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):
- 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
- 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
Redission
基于setnx实现的分布式锁存在的问题
- 重入问题:重入问题是指 获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
- 不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
- **超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患
- 主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
Redisson可重入锁原理
什么是可重入锁
可重入锁是一种特殊类型的锁,允许同一个线程在持有锁的情况下多次获取同一个锁而不会发生死锁。这种锁的实现会跟踪当前持有锁的线程,并为每个线程记录获取锁的次数。当同一个线程再次尝试获取锁时,它会增加自己的锁计数。只有当锁计数为零时,锁才会真正释放。
为什么SET NX
不能实现可重入
SET NX
命令在执行时,只会检查键是否已经存在,如果不存在,则执行设置操作;如果存在,则不做任何操作。这个特性可以用于实现基本的锁机制,但是无法支持同一个线程多次获取锁的情况。因为它无法记录锁的持有者,也无法记录获取锁的次数。
Redisson实现可重入锁
将SET NX
中的对String的操作换为Hash。
Hash类型的数据,key为锁的名称,field则为线程的唯一标识,value为该线程获取锁的次数
获取锁
每次尝试获取锁,如果锁已经存在,就去判断锁的标识是否是本线程,如果是本线程就将锁计数+1,如果不是则表示获取锁失败。

释放锁
每次释放锁都去判断当前获取锁的线程是否是本线程,如果是,则锁计数-1,同时判断锁计数是否为0,如果是0,则去释放锁。

Redisson锁重试和WatchDog机制

- 重试机制: Redisson 的分布式锁支持自动重试功能。当一个线程尝试获取锁时,如果锁已经被其他线程持有,则当前线程可以选择等待一段时间后再次尝试获取锁。这个等待时间可以由用户自定义。这种重试机制可以有效地处理在高并发环境下竞争锁资源的情况,提高了系统的可用性和稳定性。
- Watchdog 机制: Watchdog 是 Redisson 提供的一种监视锁状态的机制。当一个线程成功获取锁后,Watchdog 会启动一个定时任务,定期续约(维持)锁的有效期(默认30秒)。(默认每10秒续约1次)这样可以确保锁不会因为持有线程意外退出或者异常终止而被意外释放。如果持有锁的线程因为某种原因长时间未能续约锁,Redisson 会认为该线程失去了对锁的控制权,从而自动释放锁资源,以防止死锁或者长时间占用锁资源的情况发生。
当 leaseTime
不为 -1 时,表示为锁设置了一个固定的过期时间,即锁会在一定时间后自动释放。
Redisson的multiLock原理

配置多个主Redis,可在多个Redis配置主从,这样可以批量获取锁
- 构建多个锁对象: 首先,根据传入的多个锁名称(键名),Redisson 会构建对应的多个锁对象。
- 批量获取锁: Redisson 使用 Redis 的
MULTI
和EXEC
命令来保证多个锁的原子性获取。在MULTI
命令开始事务之后,Redisson 会依次尝试获取每一个锁。如果某个锁获取失败,则会将当前事务中的所有操作全部撤销,确保事务的原子性。只有当所有锁都成功获取时,EXEC
命令才会提交事务,表示所有锁都已经成功获取。 - 锁释放策略: 在多个锁都成功获取后,Redisson 会返回一个包含所有锁对象的
MultiLock
对象。当使用者操作这个MultiLock
对象时,它会同时操作所有包含的锁对象。例如,当调用MultiLock.unlock()
方法时,会依次释放所有锁对象,确保释放的原子性。这样可以保证所有锁的一致性,避免了部分锁释放而导致的不一致情况。 - 超时机制: 如果某个锁获取失败,Redisson 可以选择使用重试机制来等待一段时间后再次尝试获取锁。这样可以有效地处理竞争激烈的情况,提高系统的可用性。
配置多个redissionClient

获取multiLock

之后的使用方式和lock一样