Redis分布式锁原理如上图所示,当有多个Set命令发送到Redis时,Redis会串行处理,最终只有一个Set命令执行成功,从而只有一个线程加锁成功
利用Redis的setNx命令在Redis数据库中创建一个
可以借助于redis中的命令setnx(key, value),key不存在就新增,存在就什么都不做。同时有多个客户端发送setnx命令,只有一个客户端可以成功,返回1(true);其他的客户端返回0(false),流程图如下图所示:
利用如上的setNx命令便可以简单的实现加锁功能,当多个线程去执行这个加锁命令时,只有一个线程执行成功,然后执行业务逻辑,其他线程加锁失败返回或者重试
public void testLock() {
// 1. 从redis中获取锁,setnx
Boolean lock = this.redisTemplate.opsForValue().setIfAbsent("lock", "111");
if (lock) {
// 查询redis中的num值
String value = this.redisTemplate.opsForValue().get("num");
// 没有该值return
if (StringUtils.isBlank(value)){
return ;
}
// 有值就转成成int
int num = Integer.parseInt(value);
// 把redis中的num值+1
this.redisTemplate.opsForValue().set("num", String.valueOf(++num));
// 2. 释放锁 del
this.redisTemplate.delete("lock");
} else {
// 3. 每隔1秒钟回调一次,再次尝试获取锁
try {
Thread.sleep(1000);
testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
设置过期有俩种方式可以选择:
代码实现优化就是在设置锁的时候设置过期时间:
public void testLock() {
// 1. 从redis中获取锁,setnx
Boolean lock = this.redisTemplate.opsForValue().setIfAbsent("lock", "111",3, TimeUnit.MINUTES);
if (lock) {
//与之前相同代码略过
...
}
}
那么还会不会存在问题呢?
场景:如果业务逻辑的执行时间是7s。执行流程如下:
解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁。
上面直接删除key来解锁方式会存在一个问题,考虑下面这种情况:
(1)线程1执行业务时间过长导致自己加的锁过期
(2)这时线程2进来加锁成功
(3)然后线程1业务逻辑执行完毕开始执行del key命令
(4)这时就会出现错误删除线程2加的锁
(5)错误删除线程2的锁后,线程3又可以加锁成功,导致有两个线程执行业务代码
public void testLock() {
// 1. 从redis中获取锁,setnx
String uuid = UUID.randomUUID().toString();
Boolean lock = this.redisTemplate.opsForValue().setIfAbsent("lock", uuid,3, TimeUnit.MINUTES);
if (lock) {
//与之前相同代码略过
...
// 2. 释放锁 del
if (StringUtils.equals(redisTemplate.opsForValue().get("lock"),uuid)){
this.redisTemplate.delete("lock");
}
}
}
场景:
上面的setNx命令实现了基本的加锁功能,但存在一个致命的问题是,当程序在执行业务代码崩溃时,无法再执行到下面的解锁指令,从而导致出现死锁问题
为了解决死锁问题,这里就需要引入过期时间的概念,过期时间是给当前这个key设置一定的存活时间,当存活时间到期后,Redis就会自动删除这个过期的Key,从而使得程序在崩溃时也能到期自动释放锁
如上图所示,使用Redis的expire命令来为锁设置过期时间,从而实现到期自动解锁的功能,但这里仍然还存在一个问题就是加锁与给锁设置过期时间这两个操作命令并不是原子命令
考虑下面这种情况:
当程序在加锁完成后,在设置过期时间前崩溃,这时仍然会造成锁无法自动释放,从而产生死锁现象。
if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1])
else return 0 end
public void testLock() {
// 1. 从redis中获取锁,setnx
String uuid = UUID.randomUUID().toString();
Boolean lock = this.redisTemplate.opsForValue().setIfAbsent("lock", uuid, 3, TimeUnit.SECONDS);
if (lock) {
//与之前相同代码略过
...
// 2. 释放锁 del
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
this.redisTemplate.execute(new DefaultRedisScript<>(script), Arrays.asList("lock"), uuid);
} else {
// 3. 每隔1秒钟回调一次,再次尝试获取锁
try {
Thread.sleep(1000);
testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
上述加锁命令使用了 SETNX ,一旦键存在就无法再设置成功,这就导致后续同一线程内继续加锁,将会加锁失败。当一个线程执行一段代码成功获取锁之后,继续执行时,又遇到加锁的子任务代码,可重入性就保证线程能继续执行,而不可重入就是需要等待锁释放之后,再次获取锁成功,才能继续往下执行。
可重入锁最大特性就是计数,计算加锁的次数。所以当可重入锁需要在分布式环境实现时,我们也就需要统计加锁次数。
我们基于Redis Hash 实现方案:
Redis 提供了 Hash (哈希表)这种可以存储键值对数据结构。所以我们可以使用 Redis Hash 存储的锁的重入次数,然后利用 lua 脚本判断逻辑。
if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1)
then
redis.call('hincrby', KEYS[1], ARGV[1], 1);
redis.call('expire', KEYS[1], ARGV[2]);
return 1;
else
return 0;
end
假设值为:KEYS:[lock], ARGV[uuid, expire]
如果锁不存在或者这是自己的锁,就通过hincrby(不存在新增,存在就加1)获取锁或者锁次数加1。 代码实例如下:
private Boolean tryLock(String lockName, String uuid, Long expire){
String script = "if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) " +
"then" +
" redis.call('hincrby', KEYS[1], ARGV[1], 1);" +
" redis.call('expire', KEYS[1], ARGV[2]);" +
" return 1;" +
"else" +
" return 0;" +
"end";
if (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, expire.toString())){
try {
// 没有获取到锁,重试
Thread.sleep(200);
tryLock(lockName, uuid, expire);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 获取到锁,返回true
return true;
}
lua复制代码-- 判断 hash set 可重入 key 的值是否等于 0
-- 如果为 nil 代表 自己的锁已不存在,在尝试解其他线程的锁,解锁失败
-- 如果为 0 代表 可重入次数被减 1
-- 如果为 1 代表 该可重入 key 解锁成功
if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
return nil;
end;
-- 小于等于 0 代表可以解锁
if (redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then
return 0;
else
redis.call('del', KEYS[1]);
return 1;
end;
这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:
private void unlock(String lockName, String uuid){
String script = "if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then" +
" return nil;" +
"end;" +
"if (redis.call('hincrby', KEYS[1], ARGV[1], -1) > 0) then" +
" return 0;" +
"else" +
" redis.call('del', KEYS[1]);" +
" return 1;" +
"end;";
// 这里之所以没有跟加锁一样使用 Boolean ,这是因为解锁 lua 脚本中,三个返回值含义如下:
// 1 代表解锁成功,锁被释放
// 0 代表可重入次数被减 1
// null 代表其他线程尝试解锁,解锁失败
Long result = this.redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Lists.newArrayList(lockName), uuid);
// 如果未返回值,代表尝试解其他线程的锁
if (result == null) {
throw new IllegalMonitorStateException("attempt to unlock lock, not locked by lockName: "
+ lockName + " with request: " + uuid);
}
}
public void testLock() {
// 加锁
String uuid = UUID.randomUUID().toString();
Boolean lock = this.tryLock("lock", uuid, 300l);
if (lock) {
// 读取redis中的num值
String numString = this.redisTemplate.opsForValue().get("num");
if (StringUtils.isBlank(numString)) {
return;
}
// ++操作
Integer num = Integer.parseInt(numString);
num++;
// 放入redis
this.redisTemplate.opsForValue().set("num", String.valueOf(num));
// 测试可重入性
this.testSubLock(uuid);
// 释放锁
this.unlock("lock", uuid);
}
}
// 测试可重入性
private void testSubLock(String uuid){
// 加锁
Boolean lock = this.tryLock("lock", uuid, 300l);
if (lock) {
System.out.println("分布式可重入锁。。。");
this.unlock("lock", uuid);
}
}
A线程超时时间设为10s(为了解决死锁问题),但代码执行时间可能需要30s,然后redis服务端10s后将锁删除。 此时,B线程恰好申请锁,redis服务端不存在该锁,可以申请,也执行了代码。那么问题来了, A、B线程都同时获取到锁并执行业务逻辑,这与分布式锁最基本的性质相违背:在任意一个时刻,只有一个客户端持有锁(即独享排他)。
对于上述的这种情况,原因是由于设置的过期时间太短或者业务执行时间太长导致锁过期,但是为了避免死锁问题又必须设置过期时间,那这就需要引入自动续期的功能,即在加锁成功时,开启一个定时任务,自动刷新Redis加锁key的超时时间,从而避免上诉情况发生,如下图所示:
锁延期方法:开启子线程执行延期。在加锁成功后可以启动一个定时任务来对锁进行自动续期,定时任务的执行逻辑是:
(1)判断Redis中的锁是否是自己的
(2)如果存在的话就使用expire命令重新设置过期时间
这里由于需要两个Redis的命令,所以也需要使用lua脚本来实现原子操作,代码如下所示:
/**
* 锁延期
* 线程等待超时时间的2/3时间后,执行锁延时代码,直到业务逻辑执行完毕,因此在此过程中,其他线程无法获取到锁,保证了线程安全性
* @param lockName
* @param expire 单位:毫秒
*/
private void renewTime(String lockName, String uuid, Long expire){
String script = "if(redis.call('hexists', KEYS[1], ARGV[1]) == 1) then redis.call('expire', KEYS[1], ARGV[2]); return 1; else return 0; end";
new Thread(() -> {
while (this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Lists.newArrayList(lockName), uuid, expire.toString())){
try {
// 到达过期时间的2/3时间,自动续期
Thread.sleep(expire / 3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
获取锁成功后,调用延期方法给锁 定时延期:
private Boolean tryLock(String lockName, String uuid, Long expire){
String script = "if (redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1) " +
"then" +
" redis.call('hincrby', KEYS[1], ARGV[1], 1);" +
" redis.call('expire', KEYS[1], ARGV[2]);" +
" return 1;" +
"else" +
" return 0;" +
"end";
if (!this.redisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuid, expire.toString())){
try {
// 没有获取到锁,重试
Thread.sleep(200);
tryLock(lockName, uuid, expire);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 锁续期
this.renewTime(lockName, uuid, expire * 1000);
// 获取到锁,返回true
return true;
}
redis集群状态下的问题:
我们知道java中有synchronized、lock锁、读写锁ReadWriteLock,众所周知这些锁都是本地锁。
提到锁就不得不提JUC:java.util.concurrent包,又称concurrent包。jdk1.5提供,为多线程高并发编程而提供的包,但此文章的场景是分布式场景,后续会出JUC的文章。
redis、ab工具(压测)
@GetMapping("/test")
public void testNoLock(){
String count = (String) this.redisTemplate.opsForValue().get("count");
if (count == null){
//没有值直接返回
return;
}
// 有值就转成成int
int number = Integer.parseInt(count);
// 把redis中的num值+1
this.redisTemplate.opsForValue().set("count", String.valueOf(++number));
}
测试之前的查看值为1
@GetMapping("/getCount")
public String getCount(){
String count = String.valueOf(this.redisTemplate.opsForValue().get("count"));
return count; //1
}
cmd复制代码// ab -n(一次发送的请求数) -c(请求的并发数) 访问路径
ab -n100 -c50 http://127.0.0.1:8080/test/test
再次查询结果为6。
public synchronized void testNoLock(){
String count = String.valueOf(this.redisTemplate.opsForValue().get("count"));
if ("null".equals(count)){
//没有值直接返回
return;
}
// 有值就转成成int
int number = Integer.parseInt(count);
// 把redis中的num值+1
this.redisTemplate.opsForValue().set("count", String.valueOf(++number));
}
ab -n100 -c50 http://127.0.0.1:8080/test/test
此次结果为106,说明结果是正确的,看样子结果是非常完美的,但是真的很完美吗?
cmd复制代码ab -n100 -c50 http://127.0.0.1:8080/test/test
此次的结果为58!!!
到此说明了,本地锁是有局限性的。
对于一个功能完整的锁来说,可重入功能是必不可少的特性,所谓的锁可重入就是同一个线程,第一次加锁成功后,在第二次加锁时,无需进行排队等待,只需要判断是否是自己的锁就行了,可以直接再次获取锁来执行业务逻辑,如下图所示:
实现可重入机制的原理就是在加锁的时候记录加锁次数,在释放锁的时候减少加锁次数,这个加锁的次数记录可以存在Redis中,如下图所示:
如上图所示,加入可重入功能后,加锁的步骤就变为如下步骤:
(1)判断锁是否存在
(2)判断锁是否是自己的
(3)增加加锁的次数
由于增加次数以及减少次数是多个操作,这里需要再次使用lua脚本来实现,同时由于这里需要在Redis中存入加锁的次数,所以需要使用到Redis中的Map数据结构Map(key,uuid,lockCount),加锁lua脚本如下:
//锁不存在
if (redis.call('exists', key) == 0) then
redis.call('hset', key, uuid, 1);
redis.call('expire', key, time);
return 1;
end;
//锁存在,判断是否是自己的锁
if (redis.call('hexists', key, uuid) == 1) then
redis.call('hincrby', key, uuid, 1);
redis.call('expire', key, uuid);
return 1;
end;
//锁不是自己的,返回加锁失败
return 0;
加入可重入功能后的解锁逻辑就变为:
(1)判断锁是否是自己的
(2)如果是自己的则减少加锁次数,否则返回解锁失败
//判断锁是否是自己的,不是自己的直接返回错误
if (redis.call('hexists', key,uuid) == 0) then
return 0;
end;
//锁是自己的,则对加锁次数-1
local counter = redis.call('hincrby', key, uuid, -1);
if (counter > 0) then
//剩余加锁次数大于0,则不能释放锁,重新设置过期时间
redis.call('expire', key, uuid);
return 1;
else
//等于0,代表可以释放锁了
redis.call('del', key);
return 1;
end;
到此,在实现基本的加锁与解锁的逻辑上,又加入了可重入和自动续期的功能。
Zookeeper是一个分布式协调服务,分布式协调主要是来解决分布式系统中多个应用之间的数据一致性,Zookeeper内部的数据存储方式类似于文件目录形式的存储结构,它的内存结果如下图所示:
在Zookeeper中的指定路径下创建节点,然后客户端根据当前路径下的节点状态来判断是否加锁成功,如下图一种情况为例,线程1创建节点成功后,线程2再去创建节点就会创建失败
持久节点:在Zookeeper中创建后会进行持久储存,直到客户端主动删除
临时节点:以客户端会话Session维度创建节点,一旦客户端会话断开,节点就会自动删除
临时/持久顺序节点:在同一个路径下创建的节点会对每个节点按创建先后顺序编号
zookeeper.exists("/watchpath",new Watcher() {
@Override
public void process(WatchedEvent event) {
System.out.println("进入监听器");
System.out.println("监听路径Path:"+event.getPath());
System.out.println("监听事件类型EventType:"+event.getType());
}
});
实现分布式锁的方式有多种,我们可以使用临时节点和顺序节点这种方案来实现分布式锁:
1:使用临时节点可以在客户端程序崩溃时自动释放锁,避免死锁问题
2:使用顺序节点的好处是,可以利用锁释放的事件监听机制,来实现阻塞监听式的分布式锁
下面将基于这两个特性来实现分布式锁
1:首先在Zookeeper上创建临时顺序节点Node01、Node02等
2:第二步客户端拿到加锁路径下所有创建的节点
3:判断自己的序号是否最小,如果最小的话,代表加锁成功,如果不是最小的话,就对前一个节点创建监听器
4:如果前一个节点删除,监听器就会通知客户端来准备重新获取锁
加锁原理和代码入下图所示:
//加锁路径
String lockPath;
//用来阻塞线程
CountDownLatch cc = new CountDownLatch(1);
//创建锁节点的路径
Sting LOCK_ROOT_PATH = "/locks"
//先创建锁
public void createLock(){
//lockPath = /locks/lock_01
lockPath = zkClient.create(LOCK_ROOT_PATH+"/lock_", CreateMode.EPHEMERAL_SEQUENTIAL);
}
//获取锁
public boolean acquireLock(){
//获取当前加锁路径下所有的节点
allLocks = zkClient.getChildren("/locks");
//按节点顺序大小排序
Collections.sort(allLocks);
//判断自己是否是第一个节点
int index = allLocks.indexOf(lockPath.substring(LOCK_ROOT_PATH.length() + 1));
//如果是第一个节点,则加锁成功
if (index == 0) {
System.out.println(Thread.currentThread().getName() + "获得锁成功, lockPath: " + lockPath);
return true;
} else {
//不是序号最小的节点,则监听前一个节点
String preLock = allLocks.get(index - 1);
//创建监听器
Stat status = zkClient.exists(LOCK_ROOT_PATH + "/" + preLockPath, watcher);
// 前一个节点不存在了,则重新获取锁
if (status == null) {
return acquireLock();
} else {
//阻塞当前进程,直到前一个节点释放锁
System.out.println(" 等待前一个节点锁释放,prelocakPath:"+preLockPath);
//唤醒当前线程,继续尝试获取锁
cc.await();
return acquireLock();
}
}
}
private Watcher watcher = new Watcher() {
@Override
public void process(WatchedEvent event) {
//监听到前一个节点释放锁,唤醒当前线程
cc.countDown();
}
}
Zookeeper实现可重入分布式锁的机制是在本地维护一个Map记录,因为如果在Zookeeper节点维护数据的话,Zookeeper的写操作是很慢,集群内部需要进行投票同步数据,所以在本地维护一个Map记录来记录当前加锁的次数和加锁状态,在释放锁的时候减少加锁的次数,原理如下图所示:
//利用Map记录线程持有的锁
ConcurrentMap lockMap = Maps.newConcurrentMap();
public Boolean lock(){
Thread currentThread = Thread.currentThread();
LockData lockData = lockMap.get(currentThread);
//LockData不为空则说明已经有锁
if (lockData != null) {
//加锁次数加一
lockData.lockCount.increment();
return true;
}
//没有锁则尝试获取锁
Boolean lockResult = acquireLock();
//获取到锁
if (lockResult) {
LockData newLockData = new LockData(currentThread,1);
lockMap.put(currentThread, newLockData);
return true;
}
//获取锁失败
return false;
}
解锁的步骤如下:
(1)判断锁是不是自己的
(2)如果是则减少加锁次数
(3)如果加锁次数等于0,则释放锁,删除掉创建的临时节点,下一个监听这个节点的客户端会感知到节点删除事件,从而重新去获取锁
public Boolean releaseLock(){
LockData lockData = lockMap.get(currentThread);
//没有锁
if(lockData == null){
return false;
}
//有锁则加锁次数减一
lockCount = lockData.lockCount.decrement();
if(lockCount > 0){
return true;
}
//加锁次数为0
try{
//删除节点
zkClient.delete(lockPath);
//断开连接
zkClient.close();
finally{
//删除加锁记录
lockMap.remove(currentThread);
}
return true;
}
Redis |
Zookeeper |
|
读性能 |
基于内存 |
基于内存 |
加锁性能 |
直接写内存加锁 |
Master节点创建好后与其他Follower节点进行同步,半数成功后才能返回写入成功 |
数据一致性 |
AP架构Redis集群之间的数据同步是存在一定的延迟的,当主节点宕机后,数据如果还没有同步到从节点上,就会导致分布式锁失效,会造成数据的不一致 |
CP架构当Leader节点宕机后,会进行集群重新选举,如果此时只有一部分节点收到了数据的话,会在集群内进行数据同步,保证集群数据的一致性 |
使用Redis还是Zookeeper来实现分布式锁,最终还是要基于业务来决定,可以参考以下两种情况:
(1)如果业务并发量很大,Redis分布式锁高效的读写性能更能支持高并发。
(2)如果业务要求锁的强一致性,那么使用Zookeeper可能是更好的选择。
(3)在做技术选型的时候,也应该酌情考虑团队成员技能及现有资源情况,如果部署有Redsi集群克优先考虑使用Redis。