自从学了这篇Redis分布式锁,一口气拿了3个offer
Redis分布式锁的大名风澜相信很多同学都听说过,也有绝大部分同学在工作中用到过,但你有考虑过你的Redis分布式锁用的方式正确吗?它会不会有什么问题?本篇内容风澜就和大家一起再深入的了解一下我辈搬砖工与Redis分布式锁之间的孽缘。
文章目录
-
-
- 一、概述
- 二、为什么我们要使用分布式锁
- 三、Redis分布式锁的应用
-
- 1. 从源码的角度分析Redis为什么可以用于分布式锁
- 2. 常用的分布式锁指令
- 2.Java中常用的Redis客户端
- 四、遇到的问题及解决方案
-
- 1. 超时未解锁
- 2. 锁的时间不够
-
- (1) 将时间设置的大一些
- (2) 开启守护线程
- (3) Redission
- 3. 任何线程都可以解锁
- 4. 在事物提交前解锁
- 5. 主从切换问题
-
- (1) RedLock算法
- (2) ZK实现的分布式锁
- 五、可重入性
- 六、LUA
- 五、公平锁与非公平锁
- 附: 一个Redis命令行体验的在线小玩意
- 点关注,不迷路
-
一、概述
目前市面上的无论是互联网公司,还是传统软件公司,都在推广微服务、分布式架构,它相较于传统单体式架构有着很多优势,比如:高可用性、可扩展性等等。
但是当我们将一个单体应用部署成分布式应用时,就会产生一个并发的问题。原先我们就只需要在代码里使用Synchronized锁就可以防止单节点并发带来的数据安全问题。但是现在却不行了。因为Synchronized只能单机下使用,如果跨JVM,Synchronized锁住的资源无法被其它JVM应用所感知,那么其它应用就还可以获取到这个锁。
某一天小A身体不舒服,来医院看医生。这时好巧不巧,小B今天也来医院看同一个医生。
此时正规的流程应该是怎么样的呢?
- 先拿号
- 等叫号进诊室
- 望闻问切
- 出诊室
- 下一个人进去
但是如果两个人不按套路来,同时进去了,这个时候你能确保医生所说的病症就是你的病症吗?
所以我们上述的场景是一定要一个锁,把门锁住,没有解锁前,谁都不能进这个诊室。这样才能确保看病的结果是准确的。
这个锁住之后的5个操作,我们也可以称为 原子性操作。所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。
二、为什么我们要使用分布式锁
我们在使用一个技术之前,都要问自己为什么要使用这个技术,不用行不行?俗话说的好,好钢用在刀刃上,所以我们要根据我们项目的应用场景来判断是否需要使用这个技术。要是不管三七二十八,乱用的话,会让你的项目显得异常的臃肿,并且伴随着经常出错、难维护的风险。
那么我们在上述内容中已经做了分析,当一个共享资源被多个用户抢占时,就会发生资源的安全与正确性等问题,所以针对这种情况,我们就要使用分布式锁去锁住这个资源,以防止问题的发生。
目前各大厂子间流行的分布式应用就是如此,分布式应用中会经常发生共享资源被多用户、多线程同时访问的情况。
三、Redis分布式锁的应用
1. 从源码的角度分析Redis为什么可以用于分布式锁
分布式锁为什么可以用Redis去做?我相信很多同学在第一次使用的时候都在考虑这个问题。那么我们想一想,如果想用Redis去当一把锁,那么他就要满足本身没有共享资源安全的问题。
然而你去百度一查就知道,Redis是使用单线程执行我们发送给它的命令的,也就是说我们发送给Redis的命令,都是排队按顺序执行的。
所以当一个客户端在给一个资源加锁的时候,只有这个客户端加完锁了,第二个客户端请求才能进来,当然第二个客户端一定会发现资源已经上锁。这就满足了我们对加锁最主要的需求。
Redis的加锁流程:
client1查询key存不存在,如果不存在,就插入key与value,加锁成功。
client2查询key存不存在,发现key存在,加锁失败,等待或直接返回。
Redis的单线程执行流程如下图:
具体代码操作如下:所有代码皆为Windows系统环境下debug过程
- Redis启动的时候会调用server.c的main函数
- main函数会调用ae.c的aeMain函数
- aeMain函数里面有一个无限while循环,循环调用操作系统select方法,看有没有新的文件事件产生,如果有就执行。
我们发送到Redis的命令就是一个文件事件
server.cint main(int argc, char **argv) { //省略一堆代码 //主要执行命令的方法 aeMain(server.el); aeDeleteEventLoop(server.el); return 0;}
ae.cvoid aeMain(aeEventLoop *eventLoop) { eventLoop->stop = 0; //主要服务不stop,就无限循环 while (!eventLoop->stop) {if (eventLoop->beforesleep != NULL) eventLoop->beforesleep(eventLoop);//主要执行命令的函数aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP); }}
ae.cint aeProcessEvents(aeEventLoop *eventLoop, int flags){//执行时间事件先省略//调用select 阻塞等待numevents = aeApiPoll(eventLoop, tvp);/* After sleep callback. */if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)eventLoop->aftersleep(eventLoop); for (j = 0; j < numevents; j++) { //处理文件事件 } //处理已到期的时间时间 if (flags & AE_TIME_EVENTS) processed += processTimeEvents(eventLoop);}
由上述可知,Redis只有启动时的主线程会无限循环的处理我们发送给它的命令,并没有单独去起新线程,它的请求都是排队处理的,不存在资源竞争问题,由此可见,它可用于分布式锁实现
2. 常用的分布式锁指令
- SETNX指令 插入一个key value 键值对,如果key不存在就会插入成功,返回1。key存在就插入失败,返回0。
我们猛地一看这个命令好像行,但是有个问题,我们要是给资源上锁之后,一直没解锁,就死锁了咋办?所以就有了下面这个命令。 - EXPIRE:expire key seconds 可以为key增加一个时间。
- SETEX 指令:上面的俩命令同时使用,才能为key增加过期时间,不能保证原子性操作。所以就有了这个指令,setex flkey 10 flvalue 可以在插入key-value时同时原子的插入过期时间。(适用于redis版本>=2.0.0)
- SET命令:当然还有我们的set命令,支持在插入key-value后同时过期时间。如果插入并设置成功,返回OK,反之返回(nil)set flkey flvalue ex 10 nx。(适用于redis版本>=2.6.12)
set命令同时设置超时时间是通过参数实现的,不管是set还是setnx还是setex,最终都是调用了t_string.c的setGenericCommand方法,方法中做了过期时间的判断,详见小编的另一篇文章 ,单步调试Redis源码——set key value方法
2.Java中常用的Redis客户端
需要引入的依赖这里就不提了,百度很容易就能找到。
- RedisTemplate:
redisTemplate的使用 :redisTemplate.opsForValue().set(key, value, time, TimeUnit);
它的原理其实就是封装的setex方法
- Jedis:
Jedis的使用: jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
四、遇到的问题及解决方案
1. 超时未解锁
此问题解决方案,上面已经提过,可见上面的setex方法或set方法同时设置过期时间。
2. 锁的时间不够
我们在实际使用中,会有这种情况,就是当你设置一个10秒钟的超时时间,但是你当前需要执行的方法在某一次突然需要11秒才能执行完。这个时候在第10秒的时候就释放锁了,下一个请求就可以获取到锁,但是我们前一个请求没执行完,就可能会导致数据出错。
那么我们有什么解决方案吗?当然有呀。
(1) 将时间设置的大一些
依小编看完全可以设置一周吗。(小编的领导已经拎着搬砖在路上了)
(2) 开启守护线程
我们可以在每次加锁的时候开启一个守护线程,然后守护线程为锁续期,具体流程如下
这样在你执行方法的时候就可以保证一直持有锁,
此时小编的领导已经要把搬砖送给小编了。而且小编的服务器这时也因为创建了太多线程挂掉了
(3) Redission
小编这时也想到了,上面那个守护线程的方案急需改进,这时就想到了我们大名鼎鼎的Redisson,它实现的watch dog可以对锁自动延期,不是小编那种每一次都创建新线程去延期,而是有专门的线程,就只负责这个锁延期功能。
3. 任何线程都可以解锁
小编曾经看到有人写分布式锁的时候,在try catch代码块里进行加锁操作,然后在finally进行释放,但是释放的时候没有做任何判断,不管是谁,是哪一个线程,都能释放锁,如下代码所示。
public void t1() { String key = "flKey"; String value = "flValue"; try { if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){throw new RuntimeException("加锁失败"); } }catch (Exception e){ //省略 }finally { //释放锁 redisTemplate.delete(key); } }
上面这个加锁与释放锁逻辑就会导致一个问题,那就是线程1加锁,执行业务逻辑,逻辑还没执行完,这时,线程2来了,发现加锁失败,然后直接走到了finally,进行锁的释放,那这时线程1还没执行完,锁就被线程2释放了,之后来的线程又可以正常获取到锁,就会出问题。
当时这么写的同学我们叫他胖虎,也不知为何,小编之后就再也没见过他。
那么我们应该如何解决这个问题呢?答案很简单,我们加一个标志位,不就OK了吗?
public void t1() { String key = "flKey"; String value = "flValue"; boolean isLock = false; try { if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){throw new RuntimeException("加锁失败"); } isLock = true; //执行业务逻辑 }catch (Exception e){ //省略 }finally { //判断加锁标志位 if(isLock){ redisTemplate.delete(key); } } }
4. 在事物提交前解锁
小编再解决完上面的问题不久之后,又发现了一段有问题的代码,点开提交记录一看,果然又是胖虎。
代码如下
@Transactional public void t2(int a, int b) { String key = "flKey"; String value = "flValue"; boolean isLock = false; try { if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){throw new RuntimeException("加锁失败"); } isLock = true; if(a == 1){ aMapper.update(b); } }catch (Exception e){ //省略 }finally { //判断加锁标志位 if(isLock){ redisTemplate.delete(key); } } }
小编凭借着这么多年的12K钛合金狗眼,一眼就看穿了这个代码的本质,这finally是在return之前执行的呀,那就是说这个方法是先解锁,然后再提交事务,那恰巧就在这个时候我们解锁了,事务没提交,下一个线程来了,修改了数据,就会产生数据安全问题
于是小编灵机一动将这段代码的业务逻辑、数据库交互逻辑抽了出来,行程一个单独的方法,加锁后调用这个方法,如下所示,这样就可以在事物提交完进行解锁了。
@Transactional public void t2(int a, int b) { String key = "flKey"; String value = "flValue"; boolean isLock = false; try { if(!redisTemplate.opsForValue().setIfAbsent(key,value,10, TimeUnit.SECONDS)){throw new RuntimeException("加锁失败"); } //执行数据库更新逻辑,这样就执行完逻辑提交事物后再执行下面的解锁操作了 ((TestA) AopContext.currentProxy()).processUpdate(a,b);//这里通过代理的形式调用同一个类的方法注解才会生效 }catch (Exception e){ //省略 }finally { //判断加锁标志位 if(isLock){ redisTemplate.delete(key); } } }
5. 主从切换问题
Redis主从/集群模式下。
- 当你的锁刚写入Redis的主节点,返回写入成功。
- 锁信息还没来得及复制到从节点时,突然主节点挂掉了,这就会导致你的锁“丢了”。
- 当一个新的线程来的时候,正好发生了主从切换,从节点变为主节点。
- 由于从节点还没有复制你刚刚写入的锁信息,导致这个新线程又可以成功加锁。
以上的步骤就会导致分布式锁失效,那么我们怎么去解决这个问题呢?
(1) RedLock算法
RedLock算法遵循一个过半原则,当过半的从节点/集群节点都写入了锁信息,才会返回写入成功。这就保证了即时主节点挂了,从节点也能不丢失数据。 但是RedLock算法却会带来额外的性能与时间消耗,也增加了系统复杂性,并且它也不一定是完全可靠的。
这里扩展一个Redis的 Wait 指令。Redis 的主从复制是异步进行的,wait 指令可以让异步复制变成同步复制,确保系统的强一致性 (不严格)。wait 指令是 Redis3.0 版本以后才出现的。
(2) ZK实现的分布式锁
ZK实现分布式锁,是客户端向ZK注册了一个临时顺序节点。。生成临时顺序节点的名字ZK会保证唯一,不会出现重复。当新的注册请求来的时候,ZK先去查节点名有没有注册过,有的话就直接在名字后+1,然后监听前一个注册的节点,当前一个节点释放时,会主动通知当前节点。
- ZK通过ZAB协议保证数据不出现Redis那种主从切换数据丢失的情况。
- Redis是主动轮询去查锁有没有释放。ZK是给锁注册一个监听器,当释放时,会进行通知。
- 不用设置过期时间,如果你的服务挂了,临时顺序节点也会自动释放。
ZK分布式锁这里就不做过多介绍了,感兴趣的同学可自行百度,或等小编后续文章。
五、可重入性
Redis锁的可重入性,小编认为可以将键值对中的value设置为你的机器ID+你的线程ID,这样再次申请锁的时候,可以判断Value值,进而决定是否允许重入。
六、LUA
Redis提供了非常丰富的指令集,但是有些场景下,确实不满足我们的需求。所以Redis提供了LUA脚本来支持我们的个性化场景。并且 Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。
比如我们在用分布式锁的时候,可以将匹配key和删除key都让Redis自己去做,并且要保证两条指令的原子性。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1])else return 0end
执行LUA脚本的时候要通过eval函数进行执行
127.0.0.1:6379> eval 'if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end' 1 flKey flValue(integer) 1
五、公平锁与非公平锁
Redisson中实现了公平锁与非公平锁。这里只提一下,就不做过多介绍了。
//获取公平锁RLock lock = redissonClient.getFairLock("flKey") //获取非公平锁RLock lock = redissonClient.getLock("flKey")
附: 一个Redis命令行体验的在线小玩意
TRY REDIS
点关注,不迷路
创作不易,请勿白嫖,感谢各位的支持和认可。
求点赞,求关注,求分享。
如果博客有任何做错,请不吝赐教,小编将不胜感激。
获取更多内容或联系博主请关注公众号:云下风澜