Redis面试-两种方式实现分布式锁

Redis面试-两种方式实现分布式锁

文章目录

    • Redis面试-两种方式实现分布式锁
      • [3.1 在短信登录时为什么不用session保存登录信息并进行校验,而用Redis](#3.1 在短信登录时为什么不用session保存登录信息并进行校验,而用Redis)
      • [3.2 缓存更新策略](#3.2 缓存更新策略)
        • [Cache Aside Pattern(旁路缓存模式)](#Cache Aside Pattern(旁路缓存模式))
        • [Read/Write Through Pattern(读写穿透)](#Read/Write Through Pattern(读写穿透))
        • [Write Behind Pattern(异步缓存写入)](#Write Behind Pattern(异步缓存写入))
      • [3.3 基于Redis自增的全局ID生成器](#3.3 基于Redis自增的全局ID生成器)
      • [3.4 超卖问题](#3.4 超卖问题)
        • [3.4.1 乐观锁](#3.4.1 乐观锁)
      • [3.5 分布式锁 解决一人一单](#3.5 分布式锁 解决一人一单)
        • [3.5.1 什么是分布式锁](#3.5.1 什么是分布式锁)
        • [3.5.2 分布式锁的实现](#3.5.2 分布式锁的实现)
        • [3.5.3 基于Redis的分布式锁](#3.5.3 基于Redis的分布式锁)
        • [3.5.4 Redis分布式锁误删问题](#3.5.4 Redis分布式锁误删问题)
        • [3.5.5 分布式锁的原子性问题](#3.5.5 分布式锁的原子性问题)
          • [3.5.5.1 Redis的Lua脚本](#3.5.5.1 Redis的Lua脚本)
          • [3.5.5.2 Java调用Lua脚本改造分布式锁](#3.5.5.2 Java调用Lua脚本改造分布式锁)
        • [3.5.6 分布式锁总结](#3.5.6 分布式锁总结)
      • [3.6 Redisson功能介绍 分布式锁开源的框架](#3.6 Redisson功能介绍 分布式锁开源的框架)
        • [3.6.1 Redission快速入门](#3.6.1 Redission快速入门)
        • [3.6.2 Redission可重入锁原理](#3.6.2 Redission可重入锁原理)
        • [3.6.3 Redission的锁重试和WatchDog机制](#3.6.3 Redission的锁重试和WatchDog机制)
        • [3.6.4 总结](#3.6.4 总结)
        • [3.6.5 Redission分布式锁解决主从一致性问题------multiLock 联锁](#3.6.5 Redission分布式锁解决主从一致性问题——multiLock 联锁)
      • [3.56 总结](#3.56 总结)

3.1 在短信登录时为什么不用session保存登录信息并进行校验,而用Redis

session共享问题: 多台Tomcat并不共享session存储空间,当请求切换到不同Tomcat服务时,导致数据丢失的问题。

就算用户一开始登录成功,其中一个Tomcat保存了对应的登录信息。但是当次用户再发送请求时,由于负载均衡,此请求可能会被另一个Tomcat所接收。但是这个Tomcat中session并没有保存对应的登录信息,这就导致用户信息都直接校验失败,请求更加没法执行。但是对用户而言,他明明登录过了,这就出现了问题。

虽然session支持在不同Tomcat间进行复制,但是这仍然存在问题,就是不同Tomcat都有一个相同信息的区域,浪费资源。以及不同Tomcat在复制session信息的时候,会有一定延迟,如果用户请求在这个延迟之间发送请求,还是会出现以上的问题。

因此session应该被替代,session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value结构

这就正好到了Redis出手了。

3.2 缓存更新策略

业务场景:

  • 低一致性需求:使用内存淘汰机制,例如店铺类型的查询缓存

  • 高一致性需求:主动更新,并以超时剔除作为兜底方案,例如店铺详情查询的缓存。

    主动更新包含以下三种

Cache Aside Pattern(旁路缓存模式)

由缓存的调用者,在更新数据库的同时更新缓存

操作缓存和数据库时有三个问题需要考虑:

  1. 删除缓存还是更新缓存?

    • 更新缓存:每次更新数据库都更新缓存,无效写操作多

      因为你可能连续更新n次数据库,每次都要更新缓存。但实际只有最后一次更新缓存才是有效的,所以无效写操作多

    • 删除缓存:更新数据库时让缓存失效,查询时再更新缓存

  2. 如何保证缓存与数据库的操作的同时成功或失败 (确保数据库与缓存操作的原子性)

    • 单体系统,将缓存与数据库操作放在一个事务
    • 分布式系统,利用TCC等分布式事务方案
  3. 先操作缓存还是先操作数据库

    • 先删除缓存,在操作数据库
    • 先操作数据库,再删除缓存 这种方案更优

    1.4.1已经分析过了这个

Read/Write Through Pattern(读写穿透)

缓存与数据库整合为一个服务,由服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题。

Write Behind Pattern(异步缓存写入)

调用者只操作缓存,由其它线程(独立线程)异步的将缓存数据持久化到数据库,保证最终一致。

后两种主动更新方案实现起来比较复杂,也找不到比较好的第三方组件,所以企业一般就用Cache Aside Pattern

3.3 基于Redis自增的全局ID生成器

全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:

Redis的自增不满足安全性,为了增加ID的安全性,可以不直接用Redis的数值,而是拼接一些其它信息:

ID的组成部分:

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID

其中数据库自增不是指单纯的各个表内自己的自增策略,因为这样不同表还是会有相同的ID,就不是全局ID了。而是所有表的ID都统一由一张专门自增ID的表生成,这样全局的ID都来自一张自增ID的表,由于此表中不会产生两个相同的ID,所以其它表就不会出现两张相同的ID。

3.4 超卖问题

超卖问题就是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁 。

与前面什么缓存不同,这里超卖不会从缓存中取数据啥的,因为超卖是一个更新操作,顶多就是更新完数据库后再删除缓存中的数据。

悲观锁: 认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。

  • 例如Synchronized、Lock都属于悲观锁
  • 悲观锁将并行转化为了串行,所以性能下降了,在高并发的场景下不适合。

乐观锁: 认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其他线程对数据做了修改。

  • 如果没有修改则认为是安全的,自己才更新数据。
  • 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

乐观锁不需要加锁,所以性能比悲观锁要好。

悲观锁实现很简单,加个锁就行。这里就介绍一下乐观锁。

3.4.1 乐观锁

乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种:

  • 版本号法------给数据加版本号

    每次修改后,版本号就发生变化,可能自增。

    比如前后两个线程都得到了数据以及对应的版本号,版本号相同。此时线程1先去进行相关判断,比如库存是否大于零,条件满足后,就去执行扣减操作。此时扣减操作就需要额外加上查询条件 version=1,即查询的版本号要等于自己刚开始得到的版本号,满足,则扣减成功,并且修改了数据库中此数据的版本号。然后线程2也去进行相应操作,虽然此时库存为0了,但是线程2在库存不为0的时候就查出了结果为1,所以线程2正常也会去扣减库存,且对应查询的版本号为一开始查询到的版本号1,那么执行扣减操作的条件时,由于版本号不匹配,所以找不到对应的数据去扣减,扣减库存就失败,这样就避免了超卖现象。也就是避免了多线程同时对同一查出来的结果并行的进行扣减

  • CAS法------不加版本号

从版本号法可以看出,查询出来的库存和版本号是同步的,可以直接用库存直接代替版本号。即在进行set时的查询条件改为 修改数据的库存要和第一次查询到的库存相同,这样可保证数据没有进被其它线程修改,如果被其他线程修改了,则不会扣减成功,因为此件商品的库存已经发生变化,此线程查询不到了。

但是这种会使得很多同步进来的线程都只能有一个进行操作,其它的版本不匹配就失败了,这样效率也低了,所以只为了防止超卖,就设置最后的查询条件stock>0就行了,这样并发进来的只要不会超卖都可以并行操作

注意这里并不能考虑从缓存中取数据啥的,这里是更新操作,只会删除缓存。

3.5 分布式锁 解决一人一单

直接上悲观锁,只要查到了有购买记录就返回,不再允许购买。同一时间内要是多个请求发送过来,就悲观锁,只让一个请求过去,这里就不用考虑性能问题,只为确实整个过程只允许一个线程,而且是最先的过程,所以就是先到先得,后面的全失败。

通过加悲观锁可以解决在单机情况下的一人一单安全问题,但在集群模式(多个服务端JVM)下就不行了。

3.5.1 什么是分布式锁

分布式锁: 满足分布式系统或集群模式下多进程可见并且互斥的锁

3.5.2 分布式锁的实现

分布式所的核心是实现 多进程之间互斥 ,而满足这一点的方式有很多,常见的有三种:

3.5.3 基于Redis的分布式锁

实现分布式锁时需要实现的两个基本方法:

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁

    • 非阻塞:尝试一次,成功返回true,失败返回false

  • 释放锁

    • 手动释放

    • 超时释放:获取锁时添加一个超时时间

3.5.4 Redis分布式锁误删问题

现在讨论一种异常情况: Redis分布式锁误删问题

线程1去拿Redis的锁,成功拿到后去执行业务。如果业务执行时间过长 比如业务阻塞,或者锁的失效时间设置的太短,那么就会出现线程1的业务还没执行完,锁就因为失效时间到了,而被释放。此时线程2就可以拿到锁并执行业务,在线程2执行业务期间,线程1可能执行完毕并执行最后释放锁的操作,导致把线程2拿到的锁给释放了,就会出现在线程2执行业务期间,线程3可以拿到锁并执行自己的业务。

以上情况就会出现线程并发的问题。

解决方法: 获取锁标示并判断是否一致,就是在拿到锁的时候设置自己的唯一标识

比如将自己的线程标识(可以用UUID表示某个服务,再拼接自己的线程ID 同一个进程(JVM) UUID设置为一样,但是每个进程下线程id不一样,这样就保证拼接起来是唯一的 见代码)设置为key对应的valule值,这样在执行完业务 最后去释放锁时,先判断此时锁的value值是否和自己一开始放进去的值相同,相同则说明是自己设置的key,就可以直接删除;否者说明key超时了,已经被别的线程拿到并重新设置了value值,这时候直接返回就行了。

3.5.5 分布式锁的原子性问题

再分析一种极端的情况造成的误删问题,STOP THE WORLD

比如线程1刚判断完锁是自己的,并且准备释放锁时。此时JVM突然进行垃圾回收,导致自己的线程被停止了,在这个垃圾回收过程中,突然又到了锁的失效时间,锁就自己被释放了。这时候其它JVM的线程2就可以趁虚而入,获取锁并进行业务,等到线程1的JVM结束垃圾回收后,虽然自己的锁已经被释放了,但是它不知道,因为它在前面已经执行过了判断,判断锁是自己的,导致垃圾回收结束后,他也要继续删除不是它的锁。这样就实际上把线程2拿到的锁给删除了,又出现了锁误删的情况 线程3就可以趁虚而入了,又出现了多线程的情况。

那么问题就出现在,判断锁存在已经删除锁是两个动作,这两个动作应该 保持一个原子性 ,才能不出现误删问题。

3.5.5.1 Redis的Lua脚本

Redis提供了Lua脚本功能,在一个脚本中编写了多条Redis命令,确保多条命令执行时的原子性 。Lua是一种编程语言,它的基本语法可以参考官方网站

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

例:

3.5.5.2 Java调用Lua脚本改造分布式锁

锁误删问题其实也就是锁超时导致的:

一种是业务进行中超时,导致锁释放,这种可以在锁对应的value值中设置此线程的唯一标识,在删除锁前判断此锁是不是自己的,不是就不删除,是就删除。

另一种是在垃圾回收时,导致判断锁和释放锁不是统一动作导致的,就是用Lua脚本来实现redis操作的原子性。

3.5.6 分布式锁总结

3.6 Redisson功能介绍 分布式锁开源的框架

基于setnx实现的分布式锁存在下面的问题:

  • 不可重入性

    比如A业务执行时要调用业务B,且A、B业务都需要拿到同一把锁,这就导致A业务调用B业务时,B业务拿不到锁,因为A业务拿到了锁没有释放,而A业务又在等待B执行,就出现死锁。 所以不可重复入就可能出现死锁。

  • 不可重试

  • 超时释放

  • 主从一致性

Redission是一个在Redis的基础上实现的Java驻内存数据网络(In-Memeory Data Grid)。它不仅提供了一系列的分布式的Java对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

3.6.1 Redission快速入门

注意: Redission中设置锁的时候 在获取锁等待时间内,会不断进行重试,但是使用setnx时是不会进行重试的。当然也可以设置为不等待,没拿到锁直接返回

加锁、设置过期时间等操作都是基于lua脚本完成

3.6.2 Redission可重入锁原理

对同一个key(同一个线程做为key),可以重复获取锁,并记录下重入的次数,每个函数执行完之后再释放一把锁,当最后一把锁释放完之后,次数就变为0,此时说明所有锁都释放了,就可以释放锁的时候删除锁了。

3.6.3 Redission的锁重试和WatchDog机制

如果不使用看门狗,则就是普通的到了时间就失效。使用看门狗则解决了锁超时的问题;使用订阅功能,则实现了锁重试的机制,且锁在等待阶段不占用CPU资源,等到唤醒了才占用。所以即使线程崩了,看门狗也不再起作用,

到了时间锁还是会被释放,不用但是死锁。但是需要担心的就是万一Redis宕机怎么办,此时好像可以使用红锁的功能。

锁重试提高了高并发下的性能,让其等待后一段时间后再试,可能就能成功。但是如果不重试,直接拒绝,则就肯定请求失败了。

3.6.4 总结
3.6.5 Redission分布式锁解决主从一致性问题------multiLock 联锁

往往Redis会有集群,这样不至于一台Redis宕机了,整个服务都坏了。一台坏了还可以其它顶上。

其中一台为主节点,其它的作为从节点。职责也不同,读写分离,主节点负责所有Redis写的操作,比如增删改,从节点只负责Redis读的操作,查

主从一致性问题: 由于写只发生在主节点,所以主节点需要与从节点之间做数据的同步。主节点会不断的把数据同步到所有的从节点,确保数据的一致性。但是毕竟不是同一台机器,这就会存在一定的延迟,尽管可能延迟很短。

比如当从主节点获取到锁之后,还没来得及同步数据,主节点突然宕机了,这就导致锁丢失了。此时某一个从节点就会变成新的主节点,其它线程再去从新节点拿锁时就能拿到新的锁,这就出现并发的安全问题。

解决方法:

所有的节点都是主节点,都具备读写能力,且每个线程获取锁时都得经过所有的Redis,这样所有的Redis都能保存所,当其中有一台Redis宕机后,还有其它也保存了锁的Redis,仍然不会出现并发的安全问题。

当然相要可用性更强一点,让所有的节点仍然具有主从关系也可以:

当其中一台Redis宕机,其从节点变成主节点,虽然此节点没有锁。但是其它主节点有锁,只要某一个线程没有从 所有的主节点 都获取到锁,仍然算没拿到锁,这就仍然保证了并发的安全性。

也可以用RedLock(红锁) 不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n/2+1),避免在一个redis实例上加锁。

但是存在如下缺点:

3.56 总结

相关推荐
走,我们去吹风27 分钟前
redis实现分布式锁,go实现完整code
redis·分布式·golang
三日看尽长安花1 小时前
【Redis:原理、架构与应用】
数据库·redis·架构
Ivanqhz2 小时前
Spark RDD
大数据·分布式·spark
半夏之沫3 小时前
✨最新金九银十✨大厂后端面经✨
java·后端·面试
旋转的油纸伞4 小时前
大模型,多模态大模型面试【LoRA,分类,动静态数据类型,DDPM,ControlNet,IP-Adapter, Stable Diffusion】
算法·leetcode·面试·职场和发展·散列表
程序员yt5 小时前
2025秋招八股文--服务器篇
linux·运维·服务器·c++·后端·面试
web_code5 小时前
vite依赖预构建(源码分析)
前端·面试·vite
秋恬意7 小时前
LinkedList 源码分析
java·开发语言·面试
孟章豪7 小时前
从零开始:在 .NET 中构建高性能的 Redis 消息队列
redis·c#
隔窗听雨眠7 小时前
深入理解Redis的四种模式
java·redis·mybatis