前景回顾
上篇文章Redis之分布式锁实现原理简述了通过SET key_name my_random_value NX PX 30000(NX:if not exist -> True,否则 -> False;PX 表示过期时间用毫秒级)方式实现的redis分布锁以及redisson锁。
但是文末也提出这种实现方式也是存在问题的:redis这种方式的锁只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因(网络波动、宕机)发生了主从切换,那么就会出现锁丢失的情况。在Redis的master节点上拿到了锁,但是这个加锁的key还没有同步到slave节点,master节点发生故障转移,slave节点升级为master节点,这样在之前master节点上获取的锁就会。这个问题redisson也不能避免,由此 redis官方推荐 redlock 来解决这个问题。官网文档地址如下:https://redis.io/topics/distlock。在聊redlock之前需要理解两个定义:
- TTL:Time To Live;指 redis key 的过期时间或有效生存时间;
- clock drift:时钟漂移,指两台服务器间时间流速基本相同的情况下,两台服务器(或两个进程间)时间的差值,如果服务器之间的距离过远会造成时钟漂移值过大;
RedLock算法概述
Redlock算法是Antirez在单Redis节点基础上引入的高可用模式。在Redis的分布式环境中,我们假设有N个完全互相独立的Redis节点,在N个Redis实例上使用与在Redis单实例下相同方法获取锁和释放锁。现在假设有5个Redis主节点(大于3的奇数个),这样基本保证他们不会同时都宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:
- 获取当前Unix时间,以毫秒为单位;
- client尝试按照顺序使用相同的key,value获取所有redis服务的锁,当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间,这样可以避免客户端死等,并且试着获取下一个redis实例。比如:TTL为5s,设置获取锁最多用1s,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁;
- 客户端使用当前时间减去开始获取锁时间就得到获取锁使用的时间。当且仅当从半数以上的Redis节点取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功;
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间,比如:TTL 是5s,获取所有锁用了2s,则真正锁有效时间为3s(其实应该再减去时钟漂移);
- 如果因为某些原因,获取锁失败(没有在半数以上实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁,无论Redis实例是否加锁成功,因为可能服务端响应消息丢失了但是实际成功了,毕竟多释放一次也不会有问题。
// 获取锁
##现在来分析下其中的lua脚本
if (redis.call('exists', KEYS[1]) == 0)
##首先分布式锁的KEY不能存在,,
then redis.call('hset', KEYS[1], ARGV[2], 1);
##如果确实不存在,那么执行hset命令(hset REDLOCK_KEY uuid+threadId 1)
redis.call('pexpire', KEYS[1], ARGV[1]);
##并通过pexpire设置失效时间(也是锁的租约时间)
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
##如果分布式锁的KEY已经存在,并且value也匹配,表示是当前线程持有的锁,
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
##那么重入次数加1,并且设置失效时间
return redis.call('pttl', KEYS[1])
##获取分布式锁的KEY的失效时间毫秒数
// 释放锁
if (redis.call('exists', KEYS[1]) == 0)
then redis.call('publish', KEYS[2], ARGV[1])
##如果分布式锁KEY不存在,那么向channel发布一条消息
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)
then return nil
##如果分布式锁存在,但是value不匹配,表示锁已经被占用,那么直接返回
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
##如果就是当前线程占有分布式锁,那么将重入次数减1
if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]);
##重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,还不能删除
return 0; else redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
##重入次数减1后的值如果为0,表示分布式锁只获取过1次,那么删除这个KEY,并发布解锁消息
RedLock性能及崩溃恢复的相关解决方法
- 如果redis没有持久化功能,在clientA获取锁成功后,所有redis重启,clientB能够再次获取到锁,这样违法了锁的排他互斥性;
- 如果启动AOF持久化存储,情况会有所好转, 例如:当重启redis后,由于redis过期机制是按照unix时间戳走的,所以在重启后,然后会按照规定的时间过期,不影响业务;但是由于AOF同步到磁盘的方式默认是每秒-次,如果在一秒内断电,会导致数据丢失,立即重启会造成锁互斥性失效;但如果同步磁盘方式使用Always(每一个写命令都同步到硬盘)造成性能急剧下降;所以在锁完全有效性和性能方面要有所取舍;
- 有效解决既保证锁完全有效性及性能高效及即使断电情况的方法是redis同步到磁盘方式保持默认的每秒,在redis无论因为什么原因停掉后要等待TTL时间后再重启(学名:延迟重启) ;缺点是 在TTL时间内服务相当于暂停状态;
RedLock使用注意事项:
- TTL时长 要大于正常业务执行的时间+获取所有redis服务消耗时间+时钟漂移
- 获取redis所有服务消耗时间要 远小于TTL时间,并且获取成功的锁个数要 在总数的一般以上:N/2+1
- 尝试获取每个redis实例锁时的时间要 远小于TTL时间
- 尝试获取所有锁失败后 重新尝试一定要有一定次数限制
- 在redis崩溃后(无论一个还是所有),要延迟TTL时间重启redis
- 在实现多redis节点时要结合单节点分布式锁算法 共同实现