Redis学习:Redis可重入分布式锁、Redlock算法和底层源码分析
Redis学习
文章目录
- Redis学习
- 1. Redis分布式锁
- 2. Redlock算法和底层源码分析
1. Redis分布式锁
-
面试题
- CAP原则:C一致性、A可用性、P分区容错性:Redis集群是AP(没有一致性),Redis单机是C(没有高可用),Redis是AP保证高可用,还未同步时就返回,不保证一致性
- Redis除了做缓存的其他用法:bitmap位统计/签到等、HyperLogLog基数统计/访问量/UV等、ZSET抽奖、SET集合交并差运算/可能认识的人、GEO附近位置、轻量级消息队列LIST/STREAM、分布式锁、共享数据
-
锁的种类
- 单机锁只可对同一个微服务JVM中的资源进行加锁,而对于分布式环境下,多个JVM会访问同一个共享资源,故此时必须使用分布式锁,而且这个锁必须在可以共享的数据中
- 单机锁:synchronized或者Lock接口:只适用于单机版对同一个JVM内资源的访问进行加锁
- 分布式锁:在分布式多个不同JVM内,必须使用分布式锁,因为单机锁只可对自身的资源加锁**,而分布式下资源在多个单机间共享**,必须使用分布式锁
- 单机锁只可对Java程序所在的JVM内的资源进行加锁,即只可对当前Java程序内的资源加锁
- 但是在分布式情况下,不同的Java程序会访问同一个Redis中的数据,此时就需要分布式锁,即Redis分布式锁来进行加锁
- 可以在Redis中使用setnx实现Redis分布式锁(独占性、高可用、防死锁、不乱抢,但无法实现可重入,可以使用 HSET key id count 来实现可重入),为指定的共享资源设置一个 Key 表示分布式锁,每次谁想修改该资源则必须使用 setnx 对该key进行赋值,如果失败说明已经加锁,则继续等待重试,如果成功则加锁成功
-
靠谱分布式锁的条件
- setnx只可实现独占性、不乱抢、高可用和防死锁,不可以实现重入性,而使用hash的hset全部可以实现,也可以实现重入性
- 独占性(当存在锁时且不是自己线程的锁时必须重试,不可直接进入)、高可用、防死锁(设置过期时间防止加锁后宕机)、不乱抢(不能释放别人的锁,故先判断是不是自己的锁再释放,且必须使用lua脚本实现原子性)、可重入,使用Hash的HSET全部都可以实现 !
-
分布式锁
- 分布式锁:双检加锁必须加的是分布式锁
- SET key value NX EXPIRE / SETNX + EXPIRE(不安全,必须是原子操作)
- 分布式锁是指当多个微服务访问共享资源时,必须对数据进行加锁,而且不可在本地使用单机锁,因为各个微服务是独立的,故必须在Redis中设置一个分布式锁,使得每个微服务都必须先得到锁才可以访问
- 分布式锁是指在Redis中创建一个key表示一个锁,是Redis中的一个key,当访问数据时就请求锁,而且必须保证分布式锁不死锁,即必须设置过期时间,且必须保证原子性!
-
单机锁实现
- 分布式锁:双检加锁必须加的是分布式锁![[Pasted image 20241101102754.png]]
- 单机情况下使用synchronized和Lock锁是实现操作的原子性,使得当前单机下的其他程序无法在操作时访问资源,但是分布式情况下是多个JVM共享Redis中的数据,此时就不可以加单机锁,因为单机锁只限制JVM下资源的访问,就算加上也无法限制别人访问Redis的数据,故此时必须加上Redis分布式锁,限制一次只可以有一个访问Redis数据
- 超卖现象:当没有使用分布式锁时,多个微服务同时访问Redis中的某个数据时,可能会同时获得库存为1的数据,然后同时进行消费并写回,此时不会出现异常,但库存为1的数据却被消费了多次,就造成了超卖现象,即多个微服务同时获得一个数据,然后修改后被覆盖导致只有一次修改
- Nginx就是网关,所有访问Nginx的请求均会被Nginx根据设置的权重路由转发到指定的微服务上,Nginx就是网关,将所有访问Nginx的请求映射到配置的微服务中,根据权重进行映射
- 对于单机下不会出现错误,但是如果使用Nginx实现分布式微服务时,此时必须要加分布式锁,因为可能当库存只有1时,两个程序均获得了数据并进行了修改,从而出现超卖现象
- synchronized单机锁的作用范围只在本JVM虚拟机中,如果不加分布式锁,则多个程序可能同时获得当前Redis资源并进行操作,最后修改时可能会数据覆盖造成错误出现
-
分布式锁实现
-
为共享数据设置一个分布式锁,分布式锁就是Redis中的一个KEY,每次访问资源前必须先获得锁,然后每个进程必须使用 SETNX key value 来获得分布式锁,只要SETNX失败说明当前锁被抢占,则无法进入,要继续延迟后重试(严禁使用递归重试,使用while循环自旋),从而实现独占性,而且为分布式锁设置的value必须不可重复,且被进程保存,以实现重入性和不乱抢(只有当前锁是自己的锁时才会删除)
-
重试时用自旋代替递归:当抢不到锁时要延迟一段时间后重试,不要使用递归重试,用while自旋替代递归,直接在while中判断,不用设置变量,用自旋代替递归,必须延迟一段时间后再重试
-
必须设置过期时间:必须要对分布式锁(Redis中的一个key)加上过期时间,防止出现死锁!!有时候微服务直接宕机导致根本走不到finally则此时会导致分布式锁死锁,故必须对分布式锁加上过期时间来防止死锁,当设置好分布式锁的同时添加过期时间expire,必须保证原子操作,而且过期时间必须大于操作时间,否则仍可能出现超卖现象;为了防止死锁,必须对分布式锁加上过期时间,且必须获得锁后就添加保证原子性,不可只在finally中删除,因为可能微服务直接宕机导致获得锁后就不会删除,故必须设置过期时间expire,且必须保证原子操作
-
加锁和设置过期时间必须是原子操作,防止获得后直接宕机从而永不过期造成死锁
-
防止误删别人加的锁:因为加锁时设置了过期时间,如果操作时间大于过期时间,则当操作结束要释放锁时,此时自己加的锁已经失效,就有可能把别人的锁给误删,故删除锁时必须判断是不是自己加的锁,用value判断,只可以自己删除自己的锁,不可以删除别人的,故删锁之前要判断是否是自己的锁,当判断是自己的锁后再删除,而且这一步必须保证原子操作,否则如果判断后卡机,导致过期了然后别的进程获得锁进入,此时再删除,仍会造成误删锁,故解锁时必须判断是否是自己的锁然后删除,且用lua脚本实现原子操作
-
而且必须保证判断是否是自己的锁和删除锁是原子操作,否则可能查询后卡了,然后过了一会key过期后此时不是自己的锁,但以后仍会进行删除造成误删
-
用lua脚本保证Redis分布式锁判断和删除为原子操作
- Lua脚本:使用C语言编写为了嵌入应用程序中,为应用程序提供灵活的扩展和定制功能
- 使用lua脚本将Redis分布式锁判断和删除合二为一成为原子操作
- lua脚本浅谈
- redis通过eval命令来执行lua脚本: EVAL “脚本” numkeys KEYS ARGV ,传入写的脚本,且在脚本里可以接受传入的参数,在脚本后面指定要传入的参数,从1开始,并且要指定key的个数,以此来区分 KEYS 和 ARGV,且lua脚本执行一定是原子性的,并会返回脚本中 return 的结果,不可返回多个结果,且在lua脚本中可以使用
redis.call('command', key, ARGV[i])
来根据参数执行redis的命令 - Redis调用Lua脚本通过eval命令保证代码执行的原子性
- Redis通过eval命令来调用lua脚本,并以原子操作的方式进行实现,lua脚本通过return将结果返回给Redis,不可返回多个结果
- 在eval的lua脚本中,通过 redis.call(‘命令’, ‘key’, ‘value’) 来执行Redis命令,可以同时执行多个,但只会返回 return 中的结果,且 return 只可以有一个返回值
- 而且可以将参数传入lua脚本中,通过 eval “lua脚本” numKeys key1 key2… 来实现传入参数,在lua脚本中通过redis.call()来调用Redis的命令,并只返回return中的结果,在lua脚本中通过redis.call()执行redis命令
- 在lua脚本中使用
KEYS[i] ARGV[i]
来接受外部传入的参数,在eval后传入keys和args表示传入的参数,且要指定key的个数 numkeys,且下标从1开始
- redis通过eval命令来执行lua脚本: EVAL “脚本” numkeys KEYS ARGV ,传入写的脚本,且在脚本里可以接受传入的参数,在脚本后面指定要传入的参数,从1开始,并且要指定key的个数,以此来区分 KEYS 和 ARGV,且lua脚本执行一定是原子性的,并会返回脚本中 return 的结果,不可返回多个结果,且在lua脚本中可以使用
- lua脚本进一步
-
lua脚本中可以使用判断逻辑的代码IF/ELSE,每一个条件之后必须加上 THEN ,else 后面不需要,最后要以end结尾
-
在Redis中使用EVAL执行lua脚本时,外边用""包裹lua脚本,脚本内部使用’'包裹字符串,通过redis.call()调用redis命令
-
IF XX == XX THEN … ELSEIF THEN … ELSE… END:每一个条件后都要加上THEN,**最后一个ELSE不用加THEN
-
- Lua脚本内部一定要使用单引号’',Redis通过eval命令调用lua脚本,在stringRedisTemplate中,通过execute()来执行lua脚本,传入的第一个参数要new一个指定的对象,且传入lua脚本和返回值的class,后面传入key的List,最后面传入各个arg
-
使用Redis调用lua脚本时要选择能指定返回值类型的函数进行调用
-
可重入性锁+设计模式
- 可重入性锁(递归锁)
-
可重入锁是指当一个线程的函数获得了一个锁时,在其未释放锁时,又调用函数申请了这个锁,如果不是可重入锁则会一直阻塞,但如果是可重入锁,则不需要一直阻塞,而是直接对锁的计数器+1,此时就实现了可重入锁,且一定申请几次就要解锁几次,且必须保证两次申请的是同一个id的锁,当释放锁时,只有锁的计数为0时才会释放
-
只要获得了某个分布式锁,就可以重入,不需要再获得锁,自己可以获得自己的内部锁,不需要再重新申请锁
-
在已经获得分布式锁的进程函数中调用别的函数,而这个函数也需要该分布式锁,就会导致死锁发生,故必须让获得分布式锁的函数调用的函数也可进入分布式锁,即可重入
-
单机锁sychronized和Lock的锁都是可重入锁,在同一个同步块或者同步方法内加锁时不需要获得新的锁,可以重复可以递归调用的锁。一个类只有一个synchronized锁,在一个synchronized修饰的方法或者代码块内部调用其他synchronized修饰的方法或者代码块时,永远可以得到锁
-
必须加锁几次就解锁几次,如果不匹配,则别的进程获得锁时将会一直阻塞
-
- 可重入锁**AQS(抽象队列同步锁)**源码解析
- 单机锁synchronized和Lock都是可重入锁,其底层是对每个锁加了一个计数器,当某个线程获得一个锁时,为其对应的id计数器设置为1,此时别的锁再申请时,除了判断是否有锁外,还要判断当前锁是否是当前线程的,如果时则计数器+1,获得锁,而不是直接重试,然后删除锁时先判断是不是自己的锁,如果是则计数器-1,再判断是否为0,如果删除后计数器为0则要删除这个锁,加锁几次就必须释放几次
- 加锁几次就必须释放几次,否则后续会无法加锁
- 为了实现可重入锁,在每次加锁时,如果已经加锁了,判断是否是当前线程,如果是则直接不用再加锁,而是让占用数+1
- 使用Redis的Hash数据类型实现分布式锁,为每个锁不仅仅要设置value还要设置占用数已经占用线程,直接以uuid作为锁的field,以进程数作为value
- setnx只可以解决独占性、不乱抢、防死锁和高可用,不可以解决重入性,要使用hset解决重入性,SETNX无法实现可重入性
- 如果使用SETNX则无法对锁进行计数,故无法实现可重入,故要使用 HSET key id count,来实现可重入,且必须保证加锁和解锁逻辑的原子操作,故必须使用lua脚本进行实现,在lua脚本中实现加锁和解锁的逻辑判断,且分布式锁必须实现Lockd接口的规范:trylock()和unlock()
- 思考+设计
- 加锁和解锁全部使用lua脚本来实现
-
分布式锁所有东西必须存放到redis中,故必须使用lua脚本让redis通过eval调用来实现,否则就不是分布式锁,分布式锁必须保证所有的微服务都可以访问,故必须放在Redis中,但是由于主从复制的异步性,故Redis是不安全的,可能某个加锁后更新master后还未同步,此时master宕机,另一个从新的master中就可以申请加锁,造成不安全,这时候就要使用Redis提供的Redlock算法,基于多个独立的master实例,每次从所有申请加锁,当成功次数大于大多数时就认为加锁成功,如果失败则必须对所有均解锁防止加锁成功但未收到情况,此时要根据容错公式来设置独立master的个数,必须保证正确个数>错误个数
-
可重入锁的加锁tryLock():先判断是否存在锁EXISTS,如果不存在则直接加锁(要设置过期时间),如果存在锁,则判断是不是自己的锁,如果不是则直接返回,继续自旋延迟重试,如果是自己的锁,则让该锁对应id的数目+1,即当锁不存在或者存在且为自己的锁时,使用HICNRBY key id 1 对锁的个数+1,且必须设置过期时间以防止死锁,否则返回0自旋延迟重试
-
解锁:先判断是不是自己的锁,如果不是则直接退出即可,如果是则判断数目是否为1,如果是1则要删除锁,不为1时要自减1,即如果锁不存在或者存在但不为自己的锁时(EXISTS KEY / HEXISTS KEY ID 不存在),返回nil表示出错了,否则使用HINCRBY key id -1 对锁减去1,然后判断是否为0,如果为0说明全部解锁则删除key
-
HINCRBY key field count:包含了新建加自增,如果没有key时则会新建一个,使用HINCRBY可以新建加自增
-
- 使用SETNX可以实现分布式锁,但无法实现可重入性,因为无法记录当前加锁的个数,故使用HSET创建一个锁,并记录进入的个数,先使用EXISTS key 判断锁是否存在,不存在时说明没有加锁,然后加锁设置当前id为1,要使用lua脚本保证redis的原子性
- 此时如果有别的进程访问时,如果存在则判断是否与自己的id相同,如果相同则可重入,并且使得对应id的数目+1,使用HINCRE key field 1实现
- 当结束操作时,要删除锁,如果对应id的数目>1则HINCRE -1,如果 == 1 则直接删除锁,即删除该hash的key
- 加锁和解锁全部使用lua脚本来实现
- 可重入性锁(递归锁)
-
使用HSET+lua脚本实现可重入分布式锁
- SETNX可以实现独占性(使用SETNX新建后才可进入,否则自旋延迟重试)、高可用、不乱抢(先判断再删除,使用lua脚本实现原子性) 和防死锁(一定要设置过期时间,且刚创建就要设置),但不可以实现可重入性,为了实现可重入性,必须使用HSET和HINCRBY和lua脚本来实现分布式锁,可以参考synchronized和Lock单机锁,通过对分布式锁的id进行计数,来实现可重入
- 必须使用HASH key uuid count 才可以实现可重入的分布式锁,因为可重入必须要对锁进行计数,对获得当前锁的线程进行计数
- 要自己实现分布式锁,必须实现Lock接口,重载内部的方法lock()/unlock()/tryLock()
- lock()加锁方法重写:加锁不成功时一定要延迟重试,用自旋代替递归
- unlock()解锁方法重写:通过stringRedisTemplate调用execute来执行lua脚本,解锁时如果返回nil,说明没有这个锁,那么此时解锁是错误的,抛出异常
- 引入工厂设计模式改造分布式锁
- 当前编写的只是Redis分布式锁,可以使用工厂模式,传入什么类型TYPE就生成什么类型的分布式锁,且使用工厂模式为当前线程指定唯一的uuid,并保证同一个线程的所有分布式锁相同,从而实现可重入性
- 此时测试可重入性会出错,因为创建了两个分布式锁,两个锁的uuid不同导致不是同一个锁,就实现不了可重入性,所以会一直重试等到第一个锁到期后,第二个锁才会被创建进入函数执行,且第一个锁删除时已经没有了,故要保证同一个线程下创建的分布式锁必须是同一个才可以实现可重入性!!要保证同一个线程下创建的分布式锁是相同的才可以实现可重入性,可以在工厂初始化时生成uuid,然后创建分布式锁时带进去,此时不同函数创建都是使用同一个工厂,就保证了生成同一个锁,不可以直接使用线程作为锁id,因为不同微服务的线程可能相同,故必须使用一个uuid进行区别,此时就可以保证同一个线程下创建的分布式锁的id相同,实现了可重入性;不可以直接使用线程数作为分布式锁的id,因为不同微服务的线程id可能相同,导致是同一个分布式锁
-
自动续期:不续期仍会出现超卖问题(除非在解锁时如果出错则回滚修改),但一直续期会死锁,如果执行结束则不会续期,故续期必须先判断锁是否存在
- 确保Redis分布式锁的过期时间大于业务执行的时间
- 要设置一个定时任务不断扫描分布式锁的过期时间,先判断锁是否还存在,如果快结束了但仍未完成业务,则对分布式锁加钟,延长过期时间,保证在业务执行后再过期,从而避免超卖,否则如果过期了再执行业务,则此时会有两个线程同时操作Redis
- 直接设置一个定时器,不断执行lua脚本来查询过期时间,并判断是否快过期,如果快过期就加钟延长过期时间,可以写一个返回过期时间的函数,然后不断判断是否快结束,当快结束时且锁仍然存在时,则进行加钟,只有锁存在时才会续期
- 如果不续期仍会出现超卖问题,即当一个进程加锁后一直阻塞,直到锁到期,此时别的进行也加锁,然后两个进程同时执行,会出现超卖问题
- 如果不续期仍可能出现超卖问题,如果一直续期会出现死锁问题,但如果微服务宕机后则不会续期了,到期直接解锁
- 在函数内执行lua脚本实现续期,当续期成功时,说明还未结束且锁还存在,则阻塞一段时间后继续判断(使用while循环),当程序执行完成就会自己删除锁,此时就找不到对应的锁,就不会再续期
-
CAP
- C是一致性、A是可用性、P是分区容错性
- Redis集群是AP的(没有实现一致性,可能会出现不一致,如主机宕机还未同步到从机),Redis单机是C的
- Zookeeper集群是CP、Eureka几圈是AP、Nacos集群是AP,Consul是CP
2. Redlock算法和底层源码分析
- 自研一个分布式锁的要点:Lock规范、独占性、高可用、防死锁、不乱抢、可重入
- 按照JUC的Lock接口规范编写
- tryLock()加锁关键逻辑:加锁定期(当没有hash类型的key或者存在当前线程分布式锁时使用lua脚本实现原子操作进行HINCRBY加锁并设置过期时间)、自旋延迟重试(当加锁失败时必须延迟一段时间再重试,且不要递归),一定先判断是否有key时再加锁
- unlock()解锁关键逻辑:当不存在锁或者不是当前锁时直接报错,否则使用HINCRBY -1 解锁,并当=0时删除当前锁,即删除hash类型的key(uuid只是标识不同锁的field,其值是加当前锁的个数),必须解自己加的锁,且必须加几次锁就解几次锁
- 工厂设计模式使用工厂设计模式使得同一个线程中申请的分布式锁唯一且不同微服务的不同(使用uuid区别)
- 自动续期:不自动续期仍会出现超卖现象,加了自动续期可能会一直死锁,但当执行结束解锁后就不会自动续期,自动续期时必须先判断锁是否存在,不存在时则直接退出
- SETNX无法实现可重入性,因为无法对锁进行计数,故使用 HSET lock id count 来加锁并实现可重入性,实际上只是记录了一个当前锁的个数,仍要判断是否存在key时再加锁,当不存在锁或者存在锁且是当前锁时则可以加锁
- 自研的分布式锁,其所有的锁都存在到Redis中,但由于Redis主从复制以及集群的异步性,就会出现不安全,如果某一个加锁后更新master但还未同步时,master宕机,此时另一个加锁,则新的master仍会同意,导致同时两个同时访问Redis,造成不安全
- Redlock红锁算法:Redis提供的分布式锁实现算法,实现了更安全的DLM
- Redis提供了一个规范的分布式锁实现算法-Redlock红锁算法,实现了更安全的DLM(分布式锁管理器)
- 自研的分布式锁存在Redis缓存中,是不安全的,如果master宕机,就算有从机,但因为主从复制是异步的(Redis集群是AP的,不保证一致性),所以可能导致Redis分布式锁更新错误,一个获得锁后还未同步就宕机了,就会导致另一个也可以获得锁
- Redlock算法设计理念
- Redlock算法是Redis提供的分布式锁实现算法,实现了更安全的DLM(分布式锁管理器),为了解决自研分布式锁存放于master中由于主从复制的异步性造成的不安全
- Redlock基于多个master实例的分布式锁,锁变量由多个master实例维护,类似于集群,但不是集群,因为各个master节点完全独立,不存在主从复制,各个master完全独立,不存在异步
- 设计理念:直接用多个相互独立的master存放锁,当加锁时,对每个master上的分布式锁均获得锁(且要设置超时时间,超时时间要小于失效时间,此时当一个master实例宕机时就可以快速去写一个master加锁),且必须大多数以上加锁成功才算成功,当加锁失败时,要对所有的master上的分布式锁进行解锁(防止某些master加上锁但客户端没有收到),就算没有获得锁也要进行解锁,以此来解决由于主从复制异步性导致的不安全,只有当大多数master实例上均获得了锁,并且获得锁的时间小于有效时间时才认为加锁成功
- 解决方案:容错公式 N = 2X + 1 :其中X为容错机器数即最大可出错的机器数,N为最终部署机器数,即要确保不出错的机器数始终大于出错的机器数;如果希望X台机器出错后还能用,则必须部署 N = 2X + 1 台机器
- Redlock落地实现:Redisson