总结:
1. SET KEY Value PX milliseconds NX
SET 商品ID 线程ID PX 30000 NX
1.PX 防止锁过期,NX互斥
2.线程ID 记录下来,作为唯一值
3.释放锁验证value,不能让别人的线程,解了别人的锁。
假设集群5个节点
P1.获取当前Unix时间,毫秒为单位
P2.依次从5台机器,相同的KEY 和 唯一性的Value请求锁,为每个锁设置一个过期时间比如10秒
P3.当前时间减去开始获取锁的时间(步骤1记录的时间)得到锁的使用时间。当且仅当N/2+1 5对应3 Redis节点都取到锁,且锁使用的时间小于锁失效时间,获取成功。
P4.如果取到了锁,key的有效时间=设置过期时间-获取锁花费的时间
P5.如果获取锁失败,比如只拿到了2把锁,或者时间太长超过了设置的过期时间,那么,将所有Redis机器进行解锁
1. 加锁 LUA脚本
HGETALL myLock
1) UUID:THREADID
2) 1 //次数
2. 锁互斥
第一个客户端有锁
第二个客户端来获取锁
return redis.call(‘pttl’, KEYS[1]);
P1.返回null加锁成功,返回数值,说明存在锁,返回锁剩余时间
P2.客户端2获取失败,那么通过Redis的channel订阅锁释放事件,等锁释放进行重试
P3.每次尝试获取锁,并得到锁剩余时间,拿到锁,返回,拿不到继续等待。锁释放的时候release()方法被调用
3. 锁续期
客户端1加锁成功,默认生存30秒,如果超过了30秒,那么启动一个Watch Dog,每隔10秒(1/3时间)检查下,如果客户端依然持有锁,那么给锁时间延长
4. 可重入锁
独占,只有一个线程可以获取锁
可重入锁,可以另外启动一个线程,可以同步再次加锁,锁次数+1
5. 锁释放
mylock.unlock();
P1. 删除锁
P2.广播释放锁消息,通知阻塞等待的进程
P3.取消watch dog线程
末尾有彩蛋
set key value PX milliseconds NX
要点
1.要带PX(加一个过期时间)和NX(互斥)
2.value要具有唯一性;
3.释放锁时要验证value值,不能误解锁;
具体参考之前笔记缓存击穿部分 https://java-er.com/blog/redis-cache-down/
假设一种情况锁时间为3秒,但是业务处理进程卡住了,比如处理了11秒,结果锁到期了,其他线程获得锁。就是说锁需要被续期。这个问题在后面的redisson分布式锁里会被解决,请继续向下看。
单机redis琐最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
p1.Redis的master节点上拿到了锁;
p2.但是这个加锁的key还没有同步到slave节点;
p3.master故障,发生故障转移,slave节点升级为master节点;
p4.导致锁丢失。
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
p1.获取当前Unix时间,以毫秒为单位。
p2.依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
p3.客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
p4.如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
p5.如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)
Redisson中有一个MultiLock的概念,可以将多个锁合并为一个大锁,对一个大锁进行统一的申请加锁以及释放锁而Redisson中实现RedLock就是基于MultiLock 去做的
2.2.1 加锁
RLock lock = redisson.getLock("myLock");
默认30秒,
加锁的LUA脚本
if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);"
这里 KEYS[1] 代表的是你加锁的 key,比如你自己设置了加锁的那个锁 key 就是 “myLock”。
这里 ARGV[1] 代表的是锁 key 的默认生存时间,默认 30 秒。ARGV[2] 代表的是加锁的客户端的 ID,类似于下面这样:285475da-9152-4c83-822a-67ee2f116a79:52。至于最后面的一个 1 是为了后面可重入做的计数统计,后面会有讲解到。
我们来看一下在 Redis 中的存储结构:
127.0.0.1:6379> HGETALL myLock
1) "285475da-9152-4c83-822a-67ee2f116a79:52"
2) "1"
存储了一个HASH结构
2.2.2 锁互斥机制
此时,如果客户端 2 来尝试加锁,会如何呢?首先,第一个 if 判断会执行 exists myLock,发现 myLock 这个锁 key 已经存在了。接着第二个 if 判断,判断一下,myLock 锁 key 的 hash 数据结构中,是否包含客户端 2 的 ID,这里明显不是,因为那里包含的是客户端 1 的 ID。所以,客户端 2 会执行:
return redis.call('pttl', KEYS[1]);
返回的一个数字,这个数字代表了 myLock 这个锁 key 的剩余生存时间。
其他进程来获取锁
p1.尝试获取锁,返回 null 则说明加锁成功,返回一个数值,则说明已经存在该锁,ttl 为锁的剩余存活时间。
p2.如果此时客户端 2 进程获取锁失败,那么使用客户端 2 的线程 id(其实本质上就是进程 id)通过 Redis 的 channel 订阅锁释放的事件,。如果等待的过程中一直未等到锁的释放事件通知,当超过最大等待时间则获取锁失败,返回 false,如果等到了锁的释放事件的通知,则开始进入一个不断重试获取锁的循环。
p3.循环中每次都先试着获取锁,并得到已存在的锁的剩余存活时间。如果在重试中拿到了锁,则直接返回。如果锁当前还是被占用的,那么等待释放锁的消息,具体实现使用了 JDK 的信号量 Semaphore 来阻塞线程,当锁释放并发布释放锁的消息后,信号量的 release() 方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。
2.2.3 锁的续期机制
客户端 1 加锁的锁 key 默认生存时间才 30 秒,如果超过了 30 秒,客户端 1 还想一直持有这把锁,怎么办呢?
Redisson 提供了一个续期机制, 只要客户端 1 一旦加锁成功,就会启动一个 Watch Dog。
Watch Dog 机制其实就是一个后台定时任务线程,获取锁成功之后,会将持有锁的线程放入到一个 RedissonLock.EXPIRATION_RENEWAL_MAP里面,然后每隔 10 秒 (internalLockLeaseTime / 3) 检查一下,如果客户端 1 还持有锁 key(判断客户端是否还持有 key,其实就是遍历 EXPIRATION_RENEWAL_MAP 里面线程 id 然后根据线程 id 去 Redis 中查,如果存在就会延长 key 的时间),那么就会不断的延长锁 key 的生存时间。
2.2.5 可重入加锁机制
Java本身有个重入锁ReentrantLock 是一个可重入的互斥(/独占)锁,又称为“独占锁”。
ReentrantLock通过自定义队列同步器(AQS-AbstractQueuedSychronized,是实现锁的关键)来实现锁的获取与释放。
其可以完全替代 synchronized 关键字。JDK 5.0 早期版本,其性能远好于 synchronized,但 JDK 6.0 开始,JDK 对 synchronized 做了大量的优化,使得两者差距并不大。
“独占”,就是在同一时刻只能有一个线程获取到锁,而其它获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。
“可重入”,就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。
Redisson实现了可重入机制,就可以对一个锁不停的加锁来延长对持有锁的时间。
代码例子
@Override
public void lock() {
RLock lock = redissonSingle.getLock("myLock");
try {
lock.lock();
// 执行业务
doBusiness();
lock.lock();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
lock.unlock();
lock.unlock();
logger.info("任务执行完毕, 释放锁!");
}
}
这个线程自己进行了两次lock是可以的。
127.0.0.1:6379> HGETALL myLock
1) "285475da-9152-4c83-822a-67ee2f116a79:52"
2) "2"
锁的本质是一个hash结构, key 是锁的名称,field 是客户端 ID,value 是该客户端加锁的次数。
2.2.5 释放锁机制
lock.unlock()
p1. 删除锁(这里注意可重入锁,在上面的脚本中有详细分析)。
p2. 广播释放锁的消息,通知阻塞等待的进程(向通道名为 redisson_lock__channel publish 一条 UNLOCK_MESSAGE 信息)。
p3. 取消 Watch Dog 机制,即将 RedissonLock.EXPIRATION_RENEWAL_MAP 里面的线程 id 删除,并且 cancel 掉 Netty 的那个定时任务线程。
参考资料
https://redis.io/topics/distlock
https://mp.weixin.qq.com/s/JLEzNqQsx-Lec03eAsXFOQ
https://blog.csdn.net/qq_42046105/article/details/111350721
https://www.cnblogs.com/AnXinliang/p/10019389.html
https://zhuanlan.zhihu.com/p/135864820
https://www.cnblogs.com/gxyandwmm/p/9387833.html
强烈推荐这个视频,学习下一个库存超卖的案例
https://www.bilibili.com/video/av667586995/