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 总结

相关推荐
何中应2 分钟前
Redis的两个小错误
数据库·redis·缓存
Overt0p29 分钟前
抽奖系统(6)
java·spring boot·redis·设计模式·rabbitmq·状态模式
Java 码农29 分钟前
RabbitMQ集群部署方案及配置指南04
分布式·rabbitmq
独自破碎E40 分钟前
在RabbitMQ中,怎么确保消息不会丢失?
分布式·rabbitmq
Java 码农42 分钟前
RabbitMQ集群部署方案及配置指南02
分布式·rabbitmq
乐观主义现代人43 分钟前
redis 源码学习笔记
redis·笔记·学习
虫小宝1 小时前
京东返利app分布式追踪系统:基于SkyWalking的全链路问题定位
分布式·skywalking
Dream it possible!1 小时前
LeetCode 面试经典 150_二分查找_搜索二维矩阵(112_74_C++_中等)
leetcode·面试·矩阵
jmxwzy1 小时前
Redis
数据库·redis·缓存