// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
设想这样一种场景:
- 客户端 1 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
- 客户端 2 加锁成功,开始操作共享资源
- 客户端 1 操作共享资源完成,释放锁(但释放的是客户端 2 的锁)
这里存在两个严重的问题:
- 锁过期:客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
- 释放别人的锁:客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁
锁被别人释放怎么办?
客户端在加锁时,可以设置一个只有自己知道的「唯一标识」进去。
这个唯一标识,可以是自己的线程ID,也可以是一个UUID(随机且唯一)
// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:
// 锁是自己的,才释放
if redis.get("lock") == $uuid:
redis.del("lock")
这里释放锁使用的是 GET + DEL 两条命令,可能会存在原子性问题:
- 客户端 1 执行 GET,判断锁是自己的(然后客户端 1 加的锁在 GET 之后过期了)
- 客户端 2 执行 SET 命令,强制获取到锁(虽然发生概率比较低,但我们需要严谨地考虑锁的安全性问题)
- 客户端 1 执行 DEL,却释放了客户端 2 的锁
所以,这两个命令还是必须要原子执行才行。
使用lua脚本解决原子性问题,因为 Redis 处理每一个请求都是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。
安全释放锁的 Lua 脚本如下:
// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
使用redis分布式锁严谨的流程
- 加锁:SET lock_key $unique_id EX $expire_time NX
- 操作共享资源
- 释放锁:Lua 脚本,先 GET 判断锁是否归属自己,再 DEL 释放锁
锁过期时间不好评估怎么办?
加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
集群模式下,分布式锁问题
客户端在主库上执行 SET 命令,加锁成功,此时,主库发生异常宕机,SET 命令还未同步到从库上(主从复制是异步的),从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
解决方案:redlock(红锁) (至少 5 个实例)
Redlock 整体流程:
- 客户端先获取「当前时间戳T1」
- 客户端依次向这 5 个 Redis 实例发起加锁请求,且每个请求会设置请求超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳T2」,如果 T2 - T1 < 锁的过期时间,此时认为客户端加锁成功,否则认为加锁失败
- 加锁成功,去操作共享资源(例如修改 MySQL 某一行,或发起一个 API 请求)
- 加锁失败,向「全部节点」发起释放锁请求(Lua 脚本释放锁)
为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩下的实例加锁成功,整个锁服务依旧可用。
为什么大多数加锁成功,才算成功?
多个 Redis 实例一起使用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以在讨论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统「容错」问题,这个问题的结论是:如果存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正常服务的
为什么加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个 Redis 节点,所以耗时肯定会比操作单个实例耗时更久,而且因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那么此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,可能因为「网络问题」导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。所以在释放锁时,不管之前有没有加锁成功,都需要释放「所有节点」的锁,以保证清理某些节点上「残留」的锁。