当前位置: 首页 > 科技观察

Redis分布式锁|从青铜到钻石的五种进化方案

时间:2023-03-15 01:30:33 科技观察

上一篇我们讲了如何使用本地内存:《缓存实战(上篇)》作为缓存来提升系统的性能。另外,我们讨论了如何通过加锁来解决缓存崩溃的问题。但是本地锁的方式在分布式场景下并不适用,所以本文我们讨论如何引入分布式锁来解决本地锁的问题。本文所有代码和业务均基于我的开源项目PassJava。本文主要内容如下:1.本地锁的问题首先回顾一下本地锁的问题:当前话题微服务拆分为四个微服务。前端请求进来,会被转发到不同的微服务。假设前端收到10W请求,每个微服务收到2.5W请求。如果缓存失效,每个微服务在访问数据库时都会加锁,通过锁(synchronzied或lock)锁定自己的线程资源。从而防止缓存崩溃。这是一种本地加锁方式,在分布式情况下会造成数据不一致:比如服务A获取数据后,更新缓存key=100,服务B不受服务A的锁限制,并发更新cachekey=99,最终结果可能是99或100,但这是未知状态,与预期结果不一致。流程图如下:2.什么是分布式锁基于上面的本地锁问题,我们需要一个支持分布式集群环境的锁:查询DB时,只有一个线程可以访问,其他线程需要等待第一个线程释放锁资源后才能继续执行。生活中的案例:你可以把锁想象成门外的一把锁。所有的并发线程都比作人。他们都想进房间,而且只有一个人可以进房间。有人进来,门就锁上,其他人必须等进来的人出来。我们先来看看分布式锁的基本原理,如下图所示:我们来分析一下上图中的分布式锁:1、前端向四个主题微服务转发10W个高并发请求。2、每个微服务处理2.5W的请求。3、每个处理一个请求的线程在执行业务前需要抢占锁。可以理解为“占坑”。4.获得锁的线程执行完业务后释放锁。可以理解为“放坑”。5、未获取的线程需要等待锁被释放。6.锁释放后,其他线程抢占锁。7.重复步骤4、5和6。大白话解释:所有请求的线程都去同一个地方“占坑”。有坑就执行业务逻辑。如果没有坑,其他线程就需要释放这个“坑”。这个坑对所有线程可见,你可以把这个坑放在Redis缓存或者数据库中。这篇文章讲的是Redis作为“分布式坑”的使用方法。3、Redis的SETNXRedis作为一个可以公开访问的地方,可以作为一个“占坑”的地方。用Redis实现分布式锁的几种方案,我们都是使用SETNX命令(设置key等于某个值)。只是高层scheme传递的参数个数不一样,考虑异常情况。我们来看看这个命令,SETNX是setIfnotexist的缩写。意思是当key不存在时,设置key的值,存在时什么都不做。在Redis命令行中,是这样执行的:setNX我们可以进入redis容器试试SETNX命令。首先进入容器:dockerexec-itredid-cli然后执行SETNX命令:设置wukong键对应的值为1111,setwukong1111NX返回OK,表示设置成功。重复执行该命令,返回nil表示设置失败。4.铜牌方案我们先用Redis的SETNX命令来实现最简单的分布式锁。3.1铜牌原理看下面的流程图:多个并发线程去Redis申请锁,也就是执行setnx命令。假设线程A执行成功,说明当前线程A已经获取到了。其他线程执行setnx命令会失败,所以需要等待线程A释放锁。线程A执行完自己的业务后,删除锁。其他线程继续抢占锁,即执行setnx命令。因为线程A已经删除了锁,所以还有其他线程可以抢占锁。代码示例如下,Java中setnx命令对应的代码为setIfAbsent。setIfAbsent方法的第一个参数代表key,第二个参数代表value。//1。先抢锁Booleanlock=redisTemplate.opsForValue().setIfAbsent("lock","123");if(lock){//2。抢占成功,执行业务ListtypeEntityListFromDb=getDataFromDB();//3。解锁redisTemplate.delete("lock");returntypeEntityListFromDb;}else{//4.睡眠一段时间sleep(100);//5.抢占失败,等待释放锁returngetTypeEntityListByRedisDistributedLock();}一个小问题:那为什么要休眠一段时间呢?因为程序有递归调用,可能会造成栈空间溢出。3.2青铜溶液的缺陷青铜之所以称为青铜,是因为它是最基本的,肯定会引起很多问题。想象一个家庭场景:晚上小孔一个人开锁进屋,开灯??,然后突然没电了。人进不去。从技术上看:setnx成功占用锁,业务代码异常或服务器宕机,没有执行删除锁的逻辑,导致死锁。那么如何规避这种风险呢?设置锁的自动过期时间,一段时间后锁会自动删除,以便其他线程获取锁。4.白银计划4.1生活中的例子上面提到的青铜计划会出现死锁问题,所以我们就用上面的避险计划来设计,这就是我们的白银计划。还是生活中的例子:小孔开锁成功后,给这把智能锁设置了一个沙漏倒计时?沙漏结束后,门锁会自动打开。即使房间突然断电,一段时间后,锁会自动打开,其他人可以进来。4.2技术原理图和青铜方案的区别在于锁被成功占用后,过期时间的锁被设置。这两个步骤是一步步执行的。如下图所示:4.3示例代码清理rediskey的代码如下//10s后自动清理lockredisTemplate.expire("lock",10,TimeUnit.SECONDS);完整代码如下://1.先抢占锁Booleanlock=redisTemplate.opsForValue().setIfAbsent("lock","123");if(lock){//2.10s后自动清理lockredisTemplate.expire("lock",10,TimeUnit.SECONDS);//3.抢占成功,执行业务ListtypeEntityListFromDb=getDataFromDB();//4.解锁redisTemplate.delete("lock");returntypeEntityListFromDb;}4.4silver方案的缺陷silver方案貌似解决了线程异常或者服务器宕机锁没有释放导致的问题,但是还有其他问题:因为占用锁和设置过期时间是在两个步骤,如果在这两个步骤之间出现异常,则根本没有设置成功锁过期时间。所以它和青铜方案有同样的问题:锁永远不会过期。5.Gold方案5.1原子指令在上面的silver方案中,占用锁和设置锁过期时间是分两步执行的。这时候,我们可以想到一个东西:事务的原子性(Atom)。原子性:多个命令要么执行成功,要么不执行。两步合一:占用锁+设置锁过期时间。Redis正好支持这种操作:#设置一个key的值,设置多少毫秒或多少秒过期。setPX<多少毫秒>NXorsetEX<多少秒>NX然后可以使用下面的命令查看key的变化ttl下面演示如何设置密钥和设置过期时间。注意:执行命令前需要删除key,可通过客户端或命令删除。#设置key=wukong,value=1111,expirationtime=5000mssetwukong1111PX5000NX#查看key的状态ttlwukong执行结果如下图:每次运行ttl命令可以看到wukong的过期时间会减少.最终会变成-2(过期)。5.2技术示意图黄金计划和白银计划的区别:在获取锁的时候,还需要设置锁的过期时间。这是一个原子操作,要么全部执行成功,要么根本不执行。如下图所示:5.3示例代码设置lock的值为123,过期时间为10秒。如果10秒后锁仍然存在,则锁将被清除。setIfAbsent("锁定","123",10,TimeUnit.SECONDS);5.4金液的缺陷我们举一个生活中的例子,看看金液的缺陷。5.4.1用户A抢占锁用户A先抢占锁,并设置10秒后自动解锁。锁号为123,10秒后A还在执行任务,此时锁自动打开。5.4.2用户B抢占锁用户B看到房间的锁被打开,于是抢占锁,设置锁号为123,设置过期时间为10秒。由于只允许一个用户在房间内执行任务,因此用户A和用户B执行的任务会发生冲突。用户A在15秒后完成任务,而用户B仍在执行任务。用户A主动打开了123号锁,用户B还在执行任务,发现锁已经打开了。用户B很生气:我任务还没做完呢,锁怎么开了?5.4.3用户C抢锁用户B的锁被A打开后,A离开房间,B仍在执行任务。用户C抢到了锁,C开始执行任务。由于只允许一个用户在房间中执行任务,因此用户B和用户C执行的任务相互冲突。从上面的案例我们可以知道,由于用户A处理任务所需要的时间大于自动清锁(解锁)的时间,所以在自动解锁后,其他用户抢占了锁。当用户A完成任务后,他会主动打开其他用户抢占的锁。为什么要在这里开别人的锁?因为锁号都叫“123”,用户A只认得锁号,看到“123”就开锁。结果,用户B的锁被打开了。不完成任务当然会生气。6.白金方案6.1生活中的例子和绿锁不一样。这样就不会被A打开了。为了方便理解我做了个动图:动图显示静图更高清,可以看:6.2技术原理图和黄金图的区别解决方法:在设置锁的过期时间的时候,还需要设置一个唯一的编号。主动删除锁时,需要判断锁号是否与设置一致。如果一致,则认为是自己设置的锁,可以主动删除。6.3代码示例//1。生成唯一的idStringuuid=UUID.randomUUID().toString();//2.抢占锁Booleanlock=redisTemplate.opsForValue().setIfAbsent("lock",uuid,10,TimeUnit.SECONDS);if(lock){System.out.println("抢占成功:"+uuid);//3.抢占成功,执行业务ListtypeEntityListFromDb=getDataFromDB();//4.获取当前锁的值StringlockValue=redisTemplate.opsForValue().get("lock");//5.如果锁的值等于设定值,则清理自己的锁if(uuid.equals(lockValue)){System.out.println("清理锁:"+lockValue);redisTemplate.delete("lock");}returntypeEntityListFromDb;}else{System.out.println("抢占失败,等待锁释放");//4.睡一会儿sleep(100);//5。抢占失败,等待释放锁returngetTypeEntityListByRedisDistributedLock();}1.生成一个随机的唯一id,给锁加上一个唯一值。2.抢占锁,并设置过期时间为10s,锁有一个随机的唯一id。3、成功抢占和执行业务。4.执行完业务后,获取当前锁的值。5.如果锁值等于设定值,清理自己的锁。6.4铂金方案的缺陷上面的方案看似完美,但仍有一个问题:第4步和第5步不是原子的。矩:0s。线程A抢到了锁。时间:9.5s。线程A向Redis查询当前键的值。时间:10s。锁自动过期。时间:11s。线程B抢到了锁。时间:12s。线程A在查询过程中花费了很长时间,最终得到了multi-lock值。时间:13s。线程A还是会比较自己设置的锁的值和返回值。值相等,锁被清空,但是这个锁其实是线程B抢到的锁。那么如何规避这个风险呢?钻石计划在现场。7、线程A在diamondplan中的querylock和deletelock的逻辑不是原子的,所以querylock和deletelock这两个步骤可以作为原子指令。7.1技术示意图如下图所示,红圈部分为菱形方案的区别。使用脚本删除,实现原子操作。7.2代码示例如何用脚本删除?先来看看这个Redis专属脚本:ifredis.call("get",KEYS[1])==ARGV[1]thenreturnredis.call("del",KEYS[1])elsereturn0end这个脚本很像白金计划中获取密钥和删除密钥的方式。先获取KEYS[1]的值,判断KEYS[1]的值是否与ARGV[1]的值相等,若相等则删除KEYS[1]。那么这个脚本在Java项目中是如何执行的呢?分两步:首先定义脚本;使用redisTemplate.execute方法执行脚本。//脚本解锁Stringscript="ifredis.call('get',KEYS[1])==ARGV[1]thenreturnredis.call('del',KEYS[1])elsereturn0end";redisTemplate.execute(newDefaultRedisScript(script,Long.class),Arrays.asList("lock"),uuid);上面代码中,KEYS[1]对应“lock”,ARGV[1]对应“uuid”,意思是如果lock的值等于uuid则删除锁。而这个Redis脚本是由Redis内嵌的Lua环境执行的,所以也叫Lua脚本。金刚石方案是否完美?有更好的解决方案吗?在下一篇文章中,我们将介绍分布式锁的另一个王者解决方案:Redisson。8.小结本文通过本地锁的问题扩展了分布式锁的问题。然后介绍了五种分布式锁方案,由浅入深地讲解了不同方案的改进。从以上几种方案的不断演进中,我们知道系统哪里可能会出现异常,以及如何更好的处理。以此类推,这种不断发展的思维方式也可以应用于其他技术。下面总结一下以上五种方案的不足和改进之处。铜牌方案:缺陷:业务代码异常或服务器宕机,没有执行主动删除锁的逻辑,导致死锁。改进:设置锁的自动过期时间。一段时间后,锁会自动删除,以便其他线程获取锁。银方案:缺陷:占用锁和设置锁过期时间分两步进行,不是原子操作。改进:计算锁和设置锁过期时间保证原子操作。黄金方案:缺陷:当主动删除锁时,由于锁的值相同,其他客户端占用的锁被删除。改进:每次锁被占用时,随机设置一个较大的值。主动删除锁时,比较锁的值是否等于自己设置的值。白金方案:缺陷:获取锁、比较锁值、删除锁,这三个步骤是非原子的。中间可能锁自动过期,锁被其他客户端抢占,导致删除锁时其他客户端占用的锁也被删除。改进:使用Lua脚本进行获取锁、比较锁、删除锁的原子操作。钻石方案:缺陷:非专业的分布式锁方案。改进:Redission分布式锁。王者之解,见下篇~以上代码均基于PassJava开源项目。后台、前台、小程序都上传到同一个仓库。可以通过github或者码云访问。地址如下:Github:https://github.com/Jackson0714/PassJava-Platform码云:https://gitee.com/jayh2018/PassJava-Platform配套教程:www.passjava.cn参考资料:http://redis.cn/commands/set.htmlhttps://www.bilibili.com/video/BV1np4y1C7Yf本文转载自微信公众号“悟空聊天结构”,可通过以下二维码关注。转载本文请联系悟空聊天架构公众号。