1.依据Redis完成分布式锁
Redis分布式锁原理如上图所示,当有多个Set指令发送到Redis时,Redis会串行处理,终究只要一个Set指令履行成功,然后只要一个线程加锁成功
2:SetNx指令加锁
运用Redis的setNx指令在Redis数据库中创立一个<Key,Value>记载,这条指令只要当Redis中没有这个Key的时分才履行成功,当已经有这个Key的时分会回来失利
运用如上的setNx指令便能够简单的完成加锁功用,当多个线程去履行这个加锁指令时,只要一个线程履行成功,然后履行事务逻辑,其他线程加锁失利回来或许重试
3:死锁问题
上面的setNx指令完成了基本的加锁功用,但存在一个致命的问题是,当程序在履行事务代码溃散时,无法再履行到下面的解锁指令,然后导致呈现死锁问题
为了处理死锁问题,这儿就需求引进过期时刻的概念,过期时刻是给当时这个key设置一定的存活时刻,当存活时刻到期后,Redis就会主动删去这个过期的Key,然后使得程序在溃散时也能到期主动开释锁
如上图所示,运用Redis的expire指令来为锁设置过期时刻,然后完成到期主动解锁的功用,但这儿仍然还存在一个问题便是加锁与给锁设置过期时刻这两个操作指令并不是原子指令
考虑下面这种状况:
当程序在加锁完成后,在设置过期时刻前溃散,这时仍然会形成锁无法主动开释,然后发生死锁现象
4:运用原子指令
针对上面加锁与设置过期时刻不是原子指令的问题,Redis为咱们提供了一个原子指令如下:
经过SetNx(key,value,timeOut) 这个结合加锁与设置过期时刻的原子指令就能完好的完成依据Redis的分布式锁的加锁进程
5:解锁原理
解锁原理便是依据Redis的del删去key指令
6:过错删去锁问题
上面直接删去key来解锁方式会存在一个问题,考虑下面这种状况:
(1) 线程1履行事务时刻过长导致自己加的锁过期
(2) 这时线程2进来加锁成功
(3) 然后线程1事务逻辑履行完毕开始履行del key指令
(4) 这时就会呈现过错删去线程2加的锁
(5) 过错删去线程2的锁后,线程3又能够加锁成功,导致有两个线程履行事务代码
7:参加锁标识
为了处理这种过错删去其他线程的锁的问题,在这儿需求对加锁指令进行改造,需求在value字段里参加当时线程的id,在这儿能够运用uuid来完成。线程在删去锁的时分,用自己的uuid与Redis中锁的uuid进行比较,假如是自己的锁就进行删去,不是则不删去
如上图所示,加锁时*在value字段中存入当时线程的id,然后在解锁时经过比较当时的锁是否是自己的来判别是否加锁成功, *这样就处理了过错删去别人的锁的问题,但这儿相同存在原子指令问题,比较并删去这个操作并不是原子指令,考虑下面这种状况
(1) 线程1获取uuid并判别锁是自己的
(2) 预备解锁时呈现GC或许其他原因导致程序卡顿无法立即履行Del指令,导致线程1的锁过期
(3) 线程2就会在这个时分加锁成功
(4) 线程1卡顿完毕持续履行解锁指令,就会过错删去线程2的锁
这个问题呈现的根本原因仍是比较并删去这两个操作并不是原子指令,只要两个指令被打断就有可能呈现并发问题,假如将两个指令变为原子指令就能处理这个问题
8:引进lua脚本完成原子删去操作
lua脚本是一个十分轻量级的脚本语言,Redis底层天生支撑lua脚本的履行,一个lua脚本中能够包含多条Redis指令,Redis会将整个lua脚本当作原子操作来履行,然后完成聚合多条Redis指令的原子操作,其原理如下图所示:
这儿在解锁时,运用lua脚本将比较并删去操作变为原子操作
//lua脚本如下
luaScript = " if redis.call('get',key) == value then
return redis.call('del',key)
else
return 0
end;"
如上面的lua脚本所示,Redis会将整个lua脚本当作一个独自的指令履行,然后完成多个指令的原子操作,防止多线程竞赛问题,终究结合lua脚本完成了一个完好的分布式的加锁和解锁进程,伪代码如下:
uuid = getUUID();
//加锁
lockResut = redisClient.setNx(key,uuid,timeOut);
if(!lockResult){
return;
}
try{
//履行事务逻辑
}finally{
//解锁
redisClient.eval(delLuaScript,keys,values)
}
//解锁的lua脚本
delLuaScript = " if redis.call('get',key) == value then
return redis.call('del',key)
else
return 0
end;"
到此,咱们终究完成了一个加锁和解锁功用较为完好的redis分布式锁了,当然作为一个锁来说,还有一些其他的功用需求进一步完善,例如考虑锁失效问题,可重入问题等
9:主动续期功用
在履行事务代码时,因为事务履行时刻长,终究可能导致在事务履行进程中,自己的锁超时,然后锁主动开释了,在这种状况下第二个线程就会加锁成功,然后导致数据不一致的状况发生,如下图所示:
对于上述的这种状况,原因是由于设置的过期时刻太短或许事务履行时刻太长导致锁过期,可是为了防止死锁问题又有必要设置过期时刻,那这就需求引进主动续期的功用,即在加锁成功时,*开启一个守时使命,主动改写Redis加锁key的超时时刻, *然后防止上诉状况发生,如下图所示:
uuid = getUUID();
//加锁
lockResut = redisClient.setNx(key,uuid,timeOut);
if(!lockResult){
return;
}
//开启一个守时使命
new Scheduler(key,time,uuid,scheduleTime)
try{
//履行事务逻辑
}finally{
//删去锁
redisClient.eval(delLuaScript,keys,values)
//撤销守时使命
cancelScheduler(uuid);
}
如上诉代码所示,*在加锁成功后能够启动一个守时使命来对锁进行主动续期, *守时使命的履行逻辑是:
(1) 判别Redis中的锁是否是自己的
(2) 假如存在的话就运用expire指令从头设置过期时刻
这儿因为需求两个Redis的指令,所以也需求运用lua脚本来完成原子操作,代码如下所示:
luaScript = "if redis.call('get',key) == value) then
return redis.call('expire',key,timeOut);
else
return 0;
end;"
10:可重入锁
对于一个功用完好的锁来说,可重入功用是必不可少的特性,所谓的锁可重入便是同一个线程,第一次加锁成功后,在第2次加锁时,无需进行排队等候,只需求判别是否是自己的锁就行了,能够直接再次获取锁来履行事务逻辑,如下图所示:
完成可重入机制的原理便是在加锁的时分记载加锁次数,在开释锁的时分削减加锁次数,这个加锁的次数记载能够存在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;
到此,咱们在完成基本的加锁与解锁的逻辑上,又参加了可重入和主动续期的功用,自此一个完好的Redis分布式锁的雏形就完成了,伪代码如下:
uuid = getUUID();
//加锁
lockResut = redisClient.eval(addLockLuaScript,keys,values);
if(!lockResult){
return;
}
//开启一个守时使命
new Scheduler(key,time,uuid,scheduleTime)
try{
//履行事务逻辑
}finally{
//删去锁
redisClient.eval(delLuaScript,keys,values)
//撤销守时使命
cancelScheduler(uuid);
}
11:Zookeeper完成分布式锁
Zookeeper是一个分布式协调服务,分布式协调主要是来处理分布式系统中多个运用之间的数据一致性,Zookeeper内部的数据存储方式类似于文件目录形式的存储结构,它的内存成果如下图所示:
12:Zookeeper加锁原理
在Zookeeper中的指定途径下创立节点,然后客户端依据当时途径下的节点状况来判别是否加锁成功,如下图一种状况为例,线程1创立节点成功后,线程2再去创立节点就会创立失利
13:Zookeeper节点类型
耐久节点: 在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());
}
});
14:运用暂时次序节点和监听机制来完成分布式锁
完成分布式锁的方式有多种,咱们能够运用暂时节点和次序节点这种方案来完成分布式锁:
1:运用暂时节点能够在客户端程序溃散时主动开释锁,防止死锁问题
2:运用次序节点的优点是,能够运用锁开释的事情监听机制,来完成堵塞监听式的分布式锁
下面将依据这两个特性来完成分布式锁
15:加锁原理
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();
}
}
16:可重入锁完成
Zookeeper完成可重入分布式锁的机制是在本地保护一个Map记载,因为假如在Zookeeper节点保护数据的话,*Zookeeper的写操作是很慢,集群内部需求进行投票同步数据, *所以在本地保护一个Map记载来记载当时加锁的次数和加锁状况,在开释锁的时分削减加锁的次数,原理如下图所示:
//运用Map记载线程持有的锁
ConcurrentMap<Thread, LockData> 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;
}
17:解锁原理
解锁的进程如下:
(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;
}
18:Redis和Zookeeper锁对比
| Redis | Zookeeper |
---|---|---|
读功能 | 依据内存 | 依据内存 |
加锁功能 | 直接写内存加锁 | Master节点创立好后与其他Follower节点进行同步,对折成功后才干回来写入成功 |
数据一致性 | AP架构Redis集群之间的数据同步是存在一定的延迟的,当主节点宕机后,数据假如还没有同步到从节点上,就会导致分布式锁失效,会形成数据的不一致 | CP架构当Leader节点宕机后,会进行集群从头推举,假如此时只要一部分节点收到了数据的话,会在集群内进行数据同步,保证集群数据的一致性 |
19:总结
运用Redis仍是Zookeeper来完成分布式锁,终究仍是要依据事务来决议,能够参考以下两种状况:
(1)假如事务并发量很大,Redis分布式锁高效的读写功能更能支撑高并发
(2)假如事务要求锁的强一致性,那么运用Zookeeper可能是更好的选择
作者:京东物流 钟磊
来历:京东云开发者社区