目录
[Lua 脚本](#Lua 脚本)
[watch dog(看门狗)](#watch dog(看门狗))
[Redlock 算法](#Redlock 算法)
什么是分布式锁
在之前的文章 线程安全问题_线程安全示例-CSDN博客 中,我们学习了如何使用 synchronized 来保证 多个线程访问共享资源 时,同一时刻只有一个线程能够执行某段代码的控制逻辑 ,避免出现线程安全问题,但是,synchronized 只能解决同一个 JVM 进程内多线程问题
而在分布式系统中,常常会涉及到多个 节点/进程 访问同一个公共资源的情况
那么,此时该如何保证同一时刻只有一个 节点/进程 能够访问共享资源呢?
此时就需要使用到分布式锁 了,而分布式锁的本质是利用外部存储 (如 Redis、zookeeper(zookeeper 实现分布式锁_zk实现分布式锁-CSDN博客)),来记录当前的加锁状态(谁持有资源) ,从而让成功加锁的 节点/进程 进行访问操作,其他 节点/进程 不能进行操作
接下来,我们就来看 Redis 如何实现分布式锁
分布式锁实现
锁的基础实现
当使用 Redis 来实现分布式锁时,我们可以通过一个键值对来标识锁的状态
例如,在购买某件商品时,服务器需要先查询商品的库存,若库存 > 0,则库存 -=1:

而在上述场景中,存在多个服务器节点,它们都需要操作数据库中的商品库存,而此时若不进行加锁,就很可能会出现超卖问题:

上述库存只减扣了一次,但却有两个服务器都返回了购买成功,此时就导致实际卖出的商品多于库存数量
那么,我们如何进行加锁操作呢?
我们在上述场景中引入Redis 作为分布式锁的管理器:

此时,当服务器尝试减扣库存时,需要先访问 Redis,在 Redis 上设置一个商品键值对:
若设置失败,则加锁失败,当前不能对数据库进行读写操作,需要等待或暂时放弃
若设置成功,则加锁成功,服务器能够对数据库进行读写操作,操作完成后再删除 Redis 上的键值对即可解锁成功
而 Redis 中提供了原子性写入的 setnx 操作:SETNX key value,若 key 不存在就设置,返回1;存在则设置失败,返回 0,这样我们就可以根据返回值判断加锁是否成功
过期时间
上述我们通过 setnx操作来实现分布式锁的加锁和解锁操作,但此时存在问题:
当 服务器1 加锁成功后,开始进行库存减扣逻辑处理,然而,若 服务器1 在处理过程中意外宕机了,就会导致解锁操作(删除 key)不能执行,也就导致其他服务器始终无法获取到锁
那么这个问题该如何解决呢?
我们可以在设置 key 的同时设置过期时间 ,通过set key ex nx的方式,明确这个锁最多持有多少时间,就应该被释放
注意:在设置 key 的过期时间时,需使用set key ex nx 命令方式设置,若分开进行操作,先 setnx 再单独设置 expire,由于Redis 多个指令之间并不存在关联性 ,且即使使用Redis 事务也不能保证这两个操作一定都成功,因此,就很可能会出现 setnx 操作成功,但 expire 操作失败的情况,从而导致无法正确释放锁
校验ID
然而,此时还存在问题:Redis 中写入的加锁键值对,其他节点也是可以删除的
例如:服务器1 写入键值对:key: t_shirt,value:1,服务器2是可以将这个键值对进行删除的
为了解决这个问题,我们可以将键值对的值设置为服务器编号,如 key: t_shirt,value:001,此时,在进行解锁操作时,服务器会先检查 value 是否为自己的服务器编号,若是,则进行删除;若不是,则不能进行删除
java
String key = "t_shirt";
String serverId = "001";
// 加锁,并设置过期时间为 10s
redis.set(key, serverId, "NX", "EX", "10s");
// 执行业务逻辑
...
// 解锁
if(redis.get(key) == serverId){
redis.del(key)
}
但此时出现了新的问题:解锁逻辑的 get 和 del 操作并不是原子的
Lua 脚本
为了解决解锁操作的原子性问题,我们可以使用 Redis 的 Lua 脚本功能:
Lua 的语法类似于 JS,是一个动态弱类型语言 ,Lua 的语法简单精炼,执行速度快,解释器也比较轻量,而 Redis 内嵌 Lua 脚本,通过脚本将多条 Redis 命令封装为一段代码,并在 Redis 中以单线程原子方式执行脚本
我们使用 Lua 脚本来进行原子性解锁:
Lua
-- unlock.lua
-- KEYS[1]: 锁的 Key
-- ARGV[1]: 加锁时传入的唯一标识(如 serverId + UUID + ThreadId)
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
watch dog(看门狗)
在之前,为了防止加锁后无法解锁问题,我们引入了过期时间,但此时存在一个新的问题:设置过期时间后,任务还未完成,key 就先过期了,从而导致锁提前失效
由于业务逻辑执行时间的不确定性,我们也就无法保证任务一定能在过期时间前执行完成;而若我们设置更大的过期时间,就会导致若获取锁的服务器宕机,其他服务器不能及时地获取到锁
那么,该如何解决上述问题呢?
我们可以使用watch dog(看门狗) 来对锁的过期时间进行动态"续约", watch dog 是一个后台守护线程,在持有锁期间自动为锁"续期",防止业务未执行完锁就因过期而被释放:
设置初始情况下过期时间(TTL = 10s),并设置 watch dog 每隔 3s 检测一次:
若检测到任务未完成,执行续期操作,重置 TTL = 10s
若任务已完成,释放锁
此时,就不会出现锁提前失效的问题了,且若持有锁的服务器挂了,watch dog 线程也随之挂了,此时没有线程执行续期操作,key 也可以快速过期,让其他服务器获取到锁
Redlock 算法
在实际情况中,Redis 通常是以集群的方式部署的,此时就可能会出现以下极端情况:
服务器1向 master 节点进行加锁操作,刚刚写入 key,还未将其同步给 slave,master 就挂了;此时挑选出的 slave 节点升级为 新master,之前写入的 key 就丢失了,导致其他服务器仍然可以进行加锁操作
为了解决这个问题,Redis 提供了 Redlock :分布式锁算法,通过向多个独立的 Redis 节点依次申请锁,只有获得 多数派节点的锁且总耗时小于锁有效期,才认为加锁成功。
通过一组 Redis 节点(每组 Redis 节点都包含一个 master 和若干 slave),每组节点存储的数据都是一致的,相互为备份关系
在进行加锁时,按照一定的顺序,向多个 master 节点执行写操作 ,并设置写操作的超时时间,如 set 操作必须在 50ms 内完成;若超过 50ms 还未完成,则加锁失败:

若某个节点加锁失败,立即让下一个节点尝试加锁
当加锁成功的节点数超过总节点数的一半时,加锁成功
此时,就算这组节点中的某些节点挂了,其他节点中也存储了正确的锁信息
在释放锁时,也需要将所有节点都进行解锁操作(即使是之前超时的节点,也要尝试解锁)