乐趣区

关于redis:图解Redis和Zookeeper分布式锁-京东云技术团队

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:可重入锁

对于一个性能残缺的锁来说,可重入性能是必不可少的个性,所谓的锁可重入就是同一个线程,第一次加锁胜利后,在第二次加锁时,无需进行排队期待,只须要判断是否是本人的锁就行了,能够间接再次获取锁来执行业务逻辑,如下图所示:

实现可重入机制的原理就是_在加锁的时候记录加锁次数,在开释锁的时候缩小加锁次数,这个加锁的次数记录能够存在 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 可能是更好的抉择

作者:京东物流 钟磊
起源:京东云开发者社区

退出移动版