想要实现分布式锁,Redis必须要有互斥能力,比如setnx命令,即如果key不存在,才会设置它的值。
客户端1:
客户端2:
此时,加锁成功的客户端就可以去操作共享资源。操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会,这里我们可以使用del命令删除这个key即可。
问题:当客户端1命到锁后,如果程序处理业务逻辑异常,没有及时释放锁或是进程挂了,没机会释放锁,那么就会造成死锁,客户端1一直占用这个锁,其它客户端就永远拿不到锁了。
如何避免上述的死锁呢?
可以给这个key设置一个过期时间,假设操作共享资源的时间不会超过10s,那么在加锁时给这个key设置10s过期即可:这样无论客户端是否异常,这个锁10s后都会被自动释放。
setnx lock 1 //加锁
expire lock 10 //10s后自动过期
这样处理的问题又来了。。。
加锁和设置过期是2条命令,如果只执行了一条呢?不能保证原子性
那么我们就用下面这一条命令来执行吧,以保证执行的原子性
set lock 1 ex 10 nx
这样虽然解决了死锁,但是。。。。你以为就这样了吗?不。。。问题来了:
想想客户端1加锁成功,开始操作共享资源,但是由于种种原因10s后还没有处理完,锁就被自动释放了,然后客户端2来加锁成功,开始操作共享资源,这时客户端1操作共享资源完成,释放锁(释放的是客户端2的锁)
有什么好的解决方案吗?
锁过期:我们可以延长过期时间,比如把10s改成15s,
锁被别人释放:我们可以在客户端在加锁时,设置一个只有自己知道的唯一标识进去
set lock $uuid ex 20 nx
在释放锁的时候,先判断这把锁是否还归自己持有,伪代码:
if redis.get("lock") == $uuid:
redis.del("lock")
这里释放锁使用的get、del两条命令,那么新的问题又来了,在这里我们又会遇到之前说的原子性问题
解决方案:可以写成lua脚本,让Redis执行。
因为 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 释放锁
现在要考虑的是锁过期时间怎样评估,这个不好评估要怎么办???
方案来了:加锁时,先设置一个过期时间,然后我们开启一个守护线程,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行续期,重新设置过期时间。
嗯嗯,Redis都已经帮我们封装好了,这个守护线程一般我们把它叫做看门狗线程
Github上可以学习如何使用:https://github.com/redisson/redisson/
好了,我们再来看看前面遇到的几个主要问题的解决方案:
1、死锁:设置过期时间
2、过期时间评估不好,锁提前过期:守护线程,自动续期
3、锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放
这还只是在单机的情况下,那么我们一般使用Redis都会采用主从集群+哨兵模式部署,在主从发生切换时,这个分布式锁还会安全吗?
试想:客户端1在主库上执行set命令加锁成功,此时主库异常宕机,set命令还未同步到从库上(主从复制是异步的)从库被哨兵提升为新主库,这个锁在新的主库上,丢失了!
那么又怎样解决这个问题呢?
Redis的作者提出的一种解决方案:红锁(Redlock)
来看看Redis作者是怎样用红锁来解决主从切换后,锁失效问题的:
不再需要部署从库和哨兵实例,只部署主库,3、5、7。。。官方推荐至少5个实例,而且都是主库,它们之间没有任何关系,都是孤立的实例。
整体流程分为5步:
- 客户端先获取当前时间戳 T1
- 客户端依次向这 5 个 Redis 实例发起加锁请求,且每个请求会设置超时时间(毫秒级,要远小于锁的有效时间),如果某一个实例加锁失败(包括网络超时、锁被其它人持有等各种异常情况),就立即向下一个 Redis 实例申请加锁
- 如果客户端从 >=3 个(大多数)以上 Redis 实例加锁成功,则再次获取「当前时间戳 T2」,如果 T2 - T1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。
- 加锁成功,去操作共享资源
- 加锁失败,向「全部节点」发起释放锁请求(前面讲到的 Lua 脚本释放锁)
Redlock还会遇到三座大山:NPC问题
- N:Network Delay,网络延迟
- P:Process Pause,进程暂停(GC)
- C:Clock Drift,时钟漂移
总体来说:Redlock还是不太建议用的,对于效率来说,Redlock比较重,没必要同时部署那么多台实例,对于正确性来说,Redlock是不够安全的,时钟假设不合理,该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。 无法保证正确性。
基于Zookeeper的锁安全:
- 客户端1和2都尝试创建临时节点 如/lock
- 假设客户端1先到达,则加锁成功,客户端2加锁失败
- 客户端1操作共享资源
- 客户端1删除 /lock 节点,释放锁
它是采用了「临时节点」,保证客户端 1 拿到锁后,只要连接不断,就可以一直持有锁。
如果客户端 1 异常崩溃了,那么这个临时节点会自动删除,保证了锁一定会被释放。
客户端 1创建临时节点后,Zookeeper 是如何保证让这个客户端一直持有锁呢?
客户端 1 此时会与 Zookeeper 服务器维护一个 Session,这个 Session 会依赖客户端「定时心跳」来维持连接。 如果 Zookeeper 长时间收不到客户端的心跳,就认为这个 Session 过期了,也会把这个临时节点删除。
总结
Redlock 只有建立在「时钟正确」的前提下,才能正常工作,如果你可以保证这个前提,那么可以拿来使用。
但是时钟偏移在现实中是存在的:
第一,从硬件角度来说,时钟发生偏移是时有发生,无法避免。
第二,人为错误也是很难完全避免的。
所以,Redlock 尽量不用它,而且它的性能不如单机版 Redis,部署成本也高,优先考虑使用主从+ 哨兵的模式 实现分布式锁。
优化方案:
1、使用分布式锁,在上层完成「互斥」目的,虽然极端情况下锁会失效,但它可以最大程度把并发请求阻挡在最上层,减轻操作资源层的压力。
2、但对于要求数据绝对正确的业务,在资源层一定要做好「兜底」,设计思路可以借鉴 fencing token 的方案来做。
键的迁移
move
move key db
move 命令用于在 Redis 内部进行数据迁移,Redis 内部可以有多个数据库,这里只需要知道 Redis 内部可以有多个数据库,彼此在数据上是相互隔离的,move key db 就是把指定的键从源数据库移动到目标数据库中,但多数据库功能不建议在生产环境使用。
dump + restore
dump key
restore key ttl value
dump + restore 可以实现在不同的 Redis 实例之间进行数据迁移的功能,整个迁移的过程分为两步:
- 在源 Redis 上,dump 命令会将键值序列化,格式采用的是 RDB 格式。
- 在目标 Redis 上,restore 命令将上面序列化的值进行复原,其中 ttl 参数代表过期时间,如果 ttl=0 代表没有过期时间。
需要注意的二点:
第一,整个迁移过程并非原子性的,而是通过客户端分步完成的。
第二,迁移过程是开启了两个客户端连接,所以 dump的结果不是在源 Redis 和目标 Redis 之间进行传输。
migrate
migrate host port key |"" destination-db timeout [copy] [replace] [keys key [key ...]]
migrate 命令也是用于在 Redis 实例间进行数据迁移的,实际上 migrate 命令就是将 dump,restore,del 三个命令进行组合,从而简化了操作流程。migrate 命令具有原子性,而且从 Redis 3.0.6 版本以后已经支持迁移多个键的功能,有效地提高了迁移效率,migrate 在水平扩容中起到重要作用。
整个过程和 dump + restore 基本类似,但是有 3 点不太相同:
第一,整个过程是原子执行的,不需要在多个 Redis 实例上开启客户端的,只需要在源 Redis 上执行 migrate 命令即可。
第二,migrate 命令的数据传输直接在源 Redis 和目标 Redis 上完成的。
第三,目标 Redis 完成 restore 后会发送 OK 给源 Redis,源 Redis 接收后会根据 migrate 对应的选项来决定是否在源 Redis 上删除对应的键。
migrate的参数:
host:目标 Redis 的 IP 地址。
port:目标 Redis 的端口。
keyl “”:在 Redis 3.0.6 版本之前,migrate 只支持迁移一个键,所以此处是要迁移的键,但 Redis 3.0.6 版本之后支持迁移多个键,如果当前需要迁移多个键,此处为 空字符串""。
destination-db :目标 Redis 的数据库索引,例如要迁移到 0 号数据库,这里就写 0。
timeout:迁移的超时时间(单位为毫秒)。
[copy] :如果添加此选项,迁移后并不删除源键。
[replace] :如果添加此选项,migrate 不管目标 Redis 是否存在该键都会正常迁移进行数据覆盖。
[ keys key [ key …]] :迁移多个键,例如要迁移 key1、key2、key3,此处填写“keys key1 key2 key3”。
假设有两个Redis,分别使用源6379端口、目标6380端口,现在将Redis源Redis的键hello迁移到目标Redis中,分为以下几种情况:
情况1:源Redis有键hello,目标Redis没有
migrate 127.0.0.1 6380 hello 0 1000OK
情况2:源Redis和目标Redis都有键hello
如果 migrate 命令没有加 replace 选项会收到错误提示,如果加了 replace 会返回 OK 表明迁移成功。
情况3:源Redis没有键hello
此种情况会收到nokey的提示
情况4:源 Redis执行如下命令完成多个键的迁移
migrate 127.0.0.1 6380 "" 0 5000 keys key1 key2 key3