2021年最新Redis面试题汇总(4)

目录
  • 1、Redis 实现分布式锁
  • 2、Redis 分布式锁过期了,还没处理完怎么办
  • 3、守护线程续命的方案有什么问题吗
  • 4、RedLock
  • 5、使用缓存时,先操作数据库 or 先操作缓存
  • 6、为什么是让缓存失效,而不是更新缓存
  • 7、如何保证数据库和缓存的数据一致性
  • ​8、缓存穿透
  • 9、布隆过滤器
  • 10、缓存击穿
  • 11、缓存雪崩
  • 总结

1、Redis 实现分布式锁

1)加锁

加锁通常使用 set 命令来实现,伪代码如下:

set key value PX milliseconds NX

几个参数的意义如下:

key、value:键值对

PX milliseconds:设置键的过期时间为 milliseconds 毫秒。

NX:只在键不存在时,才对键进行设置操作。SET key value NX 效果等同于 SETNX key value。

PXexpireTime 参数则是用于解决没有解锁导致的死锁问题。因为如果没有过期时间,万一程序员写的代码有 bug 导致没有解锁操作,则就出现了死锁,因此该参数起到了一个“兜底”的作用。

NX 参数用于保证在多个线程并发 set 下,只会有1个线程成功,起到了锁的“唯一”性。

2)解锁

解锁需要两步操作:

1)查询当前“锁”是否还是我们持有,因为存在过期时间,所以可能等你想解锁的时候,“锁”已经到期,然后被其他线程获取了,所以我们在解锁前需要先判断自己是否还持有“锁”

2)如果“锁”还是我们持有,则执行解锁操作,也就是删除该键值对,并返回成功;否则,直接返回失败。

由于当前 Redis 还没有原子命令直接支持这两步操作,所以当前通常是使用 Lua 脚本来执行解锁操作,Redis 会保证脚本里的内容执行是一个原子操作。

脚本代码如下,逻辑比较简单:

if redis.call("get",KEYS[1]) == ARGV[1]
then
    return redis.call("del",KEYS[1])
else
    return 0
end

两个参数的意义如下:

KEYS[1]:我们要解锁的 key

ARGV[1]:我们加锁时的 value,用于判断当“锁”是否还是我们持有,如果被其他线程持有了,value 就会发生变化。

上述方法是 Redis 当前实现分布式锁的主流方法,可能会有一些小优区别,但是核心都是这个思路。看着好像没啥毛病,但是真的是这个样子吗?让我们继续往下看。

2、Redis 分布式锁过期了,还没处理完怎么办

为了防止死锁,我们会给分布式锁加一个过期时间,但是万一这个时间到了,我们业务逻辑还没处理完,怎么办?

首先,我们在设置过期时间时要结合业务场景去考虑,尽量设置一个比较合理的值,就是理论上正常处理的话,在这个过期时间内是一定能处理完毕的。

之后,我们再来考虑对这个问题进行兜底设计。

关于这个问题,目前常见的解决方法有两种:

  • 守护线程“续命”:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间。
  • 超时回滚:当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行回滚,并返回失败。

同时,需要进行告警,人为介入验证数据的正确性,然后找出超时原因,是否需要对超时时间进行优化等等。

3、守护线程续命的方案有什么问题吗

Redisson 使用看门狗(守护线程)“续命”的方案在大多数场景下是挺不错的,也被广泛应用于生产环境,但是在极端情况下还是会存在问题。

问题例子如下:

  1. 线程1首先获取锁成功,将键值对写入 redis 的 master 节点
  2. 在 redis 将该键值对同步到 slave 节点之前,master 发生了故障
  3. redis 触发故障转移,其中一个 slave 升级为新的 master此时新的 master
  4. 并不包含线程1写入的键值对,因此线程2尝试获取锁也可以成功拿到锁
  5. 此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据

解决方法:上述问题的根本原因主要是由于 redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致。

当前比较主流的解法和思路有两种:

1)Redis 作者提出的 RedLock;

2)Zookeeper 实现的分布式锁。

4、RedLock

首先,该方案也是基于文章开头的那个方案(set加锁、lua脚本解锁)进行改良的,所以 antirez 只描述了差异的地方,大致方案如下。

假设我们有 N 个 Redis 主节点,例如 N = 5,这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统,为了取到锁,客户端应该执行以下操作:

  • 获取当前时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的 key 和随机值(例如UUID)获取锁。当向Redis 请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在 5-50 毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis 节点对话时长时间处于阻塞状态。如果一个实例不可用,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端通过当前时间减去步骤1记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功。
  • 如果取到了锁,其真正有效时间等于初始有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果由于某些原因未能获得锁(无法在至少N/2+1个Redis实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。

可以看出,该方案为了解决数据不一致的问题,直接舍弃了异步复制,只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点,官方建议是 5。

该方案看着挺美好的,但是实际上我所了解到的在实际生产上应用的不多,主要有两个原因:1)该方案的成本似乎有点高,需要使用5个实例;2)该方案一样存在问题。

该方案主要存以下问题:

  • 严重依赖系统时钟。如果线程1从3个实例获取到了锁,但是这3个实例中的某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,当他释放后,此时又有3个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。
  • 如果线程1从3个实例获取到了锁,但是万一其中有1台重启了,则此时又有3个实例是空闲的,则线程2也可以获取到锁,此时又出现两个线程同时持有锁了。

针对以上问题其实后续也有人给出一些相应的解法,但是整体上来看还是不够完美,所以目前实际应用得不是那么多。

5、使用缓存时,先操作数据库 or 先操作缓存

1)先操作数据库

案例如下,有两个并发的请求,一个写请求,一个读请求,流程如下:

​可能存在的脏数据时间范围:更新数据库后,失效缓存前。这个时间范围很小,通常不会超过几毫秒。

2)先操作缓存

案例如下,有两个并发的请求,一个写请求,一个读请求,流程如下:

​可能存在的脏数据时间范围:更新数据库后,下一次对该数据的更新前。这个时间范围不确定性很大,情况如下:

  • 如果下一次对该数据的更新马上就到来,那么会失效缓存,脏数据的时间就很短。
  • 如果下一次对该数据的更新要很久才到来,那这期间缓存保存的一直是脏数据,时间范围很长。

结论:通过上述案例可以看出,先操作数据库和先操作缓存都会存在脏数据的情况。但是相比之下,先操作数据库,再操作缓存是更优的方式,即使在并发极端情况下,也只会出现很小量的脏数据。

6、为什么是让缓存失效,而不是更新缓存

1)更新缓存

案例如下,有两个并发的写请求,流程如下:

​分析:数据库中的数据是请求B的,缓存中的数据是请求A的,数据库和缓存存在数据不一致。

2)失效(删除)缓存

案例如下,有两个并发的写请求,流程如下:

​分析:由于是删除缓存,所以不存在数据不一致的情况。

结论:通过上述案例,可以很明显的看出,失效缓存是更优的方式。

7、如何保证数据库和缓存的数据一致性

在上文的案例中,无论是先操作数据库,还是先操作缓存,都会存在脏数据的情况,有办法避免吗?

答案是有的,由于数据库和缓存是两个不同的数据源,要保证其数据一致性,其实就是典型的分布式事务场景,可以引入分布式事务来解决,常见的有:2PC、TCC、MQ事务消息等。

但是引入分布式事务必然会带来性能上的影响,这与我们当初引入缓存来提升性能的目的是相违背的。

所以在实际使用中,通常不会去保证缓存和数据库的强一致性,而是做出一定的牺牲,保证两者数据的最终一致性。

如果是实在无法接受脏数据的场景,则比较合理的方式是放弃使用缓存,直接走数据库。

保证数据库和缓存数据最终一致性的常用方案如下:

1)更新数据库,数据库产生 binlog。

2)监听和消费 binlog,执行失效缓存操作。

3)如果步骤2失效缓存失败,则引入重试机制,将失败的数据通过MQ方式进行重试,同时考虑是否需要引入幂等机制。

​兜底:当出现未知的问题时,及时告警通知,人为介入处理。

人为介入是终极大法,那些外表看着光鲜艳丽的应用,其背后大多有一群苦逼的程序员,在不断的修复各种脏数据和bug。

​8、缓存穿透

描述:访问一个缓存和数据库都不存在的 key,此时会直接打到数据库上,并且查不到数据,没法写缓存,所以下一次同样会打到数据库上。

此时,缓存起不到作用,请求每次都会走到数据库,流量大时数据库可能会被打挂。此时缓存就好像被“穿透”了一样,起不到任何作用。

解决方案:

1)接口校验。在正常业务流程中可能会存在少量访问不存在 key 的情况,但是一般不会出现大量的情况,所以这种场景最大的可能性是遭受了非法攻击。可以在最外层先做一层校验:用户鉴权、数据合法性校验等,例如商品查询中,商品的ID是正整数,则可以直接对非正整数直接过滤等等。

2)缓存空值。当访问缓存和DB都没有查询到值时,可以将空值写进缓存,但是设置较短的过期时间,该时间需要根据产品业务特性来设置。

3)布隆过滤器。使用布隆过滤器存储所有可能访问的 key,不存在的 key 直接被过滤,存在的 key 则再进一步查询缓存和数据库。

9、布隆过滤器

布隆过滤器的特点是判断不存在的,则一定不存在;判断存在的,大概率存在,但也有小概率不存在。并且这个概率是可控的,我们可以让这个概率变小或者变高,取决于用户本身的需求。

布隆过滤器由一个 bitSet 和 一组 Hash 函数(算法)组成,是一种空间效率极高的概率型算法和数据结构,主要用来判断一个元素是否在集合中存在。

在初始化时,bitSet 的每一位被初始化为0,同时会定义 Hash 函数,例如有3组 Hash 函数:hash1、hash2、hash3。

写入流程

当我们要写入一个值时,过程如下,以“jionghui”为例:

1)首先将“jionghui”跟3组 Hash 函数分别计算,得到 bitSet 的下标为:1、7、10。

2)将 bitSet 的这3个下标标记为1。

假设我们还有另外两个值:java 和 diaosi,按上面的流程跟 3组 Hash 函数分别计算,结果如下:

java:Hash 函数计算 bitSet 下标为:1、7、11

diaosi:Hash 函数计算 bitSet 下标为:4、10、11

查询流程

当我们要查询一个值时,过程如下,同样以“jionghui”为例::

1)首先将“jionghui”跟3组 Hash 函数分别计算,得到 bitSet 的下标为:1、7、10。

2)查看 bitSet 的这3个下标是否都为1,如果这3个下标不都为1,则说明该值必然不存在,如果这3个下标都为1,则只能说明可能存在,并不能说明一定存在。

其实上图的例子已经说明了这个问题了,当我们只有值“jionghui”和“diaosi”时,bitSet 下标为1的有:1、4、7、10、11。

当我们又加入值“java”时,bitSet 下标为1的还是这5个,所以当 bitSet 下标为1的为:1、4、7、10、11 时,我们无法判断值“java”存不存在。

其根本原因是,不同的值在跟 Hash 函数计算后,可能会得到相同的下标,所以某个值的标记位,可能会被其他值给标上了。

这也是为啥布隆过滤器只能判断某个值可能存在,无法判断必然存在的原因。但是反过来,如果该值根据 Hash 函数计算的标记位没有全部都为1,那么则说明必然不存在,这个是肯定的。

降低这种误判率的思路也比较简单:

  • 一个是加大 bitSet 的长度,这样不同的值出现“冲突”的概率就降低了,从而误判率也降低。
  • 提升 Hash 函数的个数,Hash 函数越多,每个值对应的 bit 越多,从而误判率也降低。

布隆过滤器的误判率还有专门的推导公式,有兴趣的可以去搜相关的文章和论文查看。

10、缓存击穿

描述:某一个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,由于此时缓存过期了,所以请求最终都会走到数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。

解决方案:

1)加互斥锁。在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。

关于互斥锁的选择,网上看到的大部分文章都是选择 Redis 分布式锁(可以参考我之前的文章:面试必问的分布式锁,你懂了吗?),因为这个可以保证只有一个请求会走到数据库,这是一种思路。

但是其实仔细想想的话,这边其实没有必要保证只有一个请求走到数据库,只要保证走到数据库的请求能大大降低即可,所以还有另一个思路是 JVM 锁。

JVM 锁保证了在单台服务器上只有一个请求走到数据库,通常来说已经足够保证数据库的压力大大降低,同时在性能上比分布式锁更好。

需要注意的是,无论是使用“分布式锁”,还是“JVM 锁”,加锁时要按 key 维度去加锁。

我看网上很多文章都是使用一个“固定的 key”加锁,这样会导致不同的 key 之间也会互相阻塞,造成性能严重损耗。

使用 redis 分布式锁的伪代码,仅供参考:

public Object getData(String key) throws InterruptedException {
    Object value = redis.get(key);
    // 缓存值过期
    if (value == null) {
        // lockRedis:专门用于加锁的redis;
        // "empty":加锁的值随便设置都可以
        if (lockRedis.set(key, "empty", "PX", lockExpire, "NX")) {
            try {
                // 查询数据库,并写到缓存,让其他线程可以直接走缓存
                value = getDataFromDb(key);
                redis.set(key, value, "PX", expire);
            } catch (Exception e) {
                // 异常处理
            } finally {
                // 释放锁
                lockRedis.delete(key);
            }
        } else {
            // sleep50ms后,进行重试
            Thread.sleep(50);
            return getData(key);
        }
    }
    return value;
}

2)热点数据不过期。直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。

这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。

11、缓存雪崩

描述:大量的热点 key 设置了相同的过期时间,导在缓存在同一时刻全部失效,造成瞬时数据库请求量大、压力骤增,引起雪崩,甚至导致数据库被打挂。

缓存雪崩其实有点像“升级版的缓存击穿”,缓存击穿是一个热点 key,缓存雪崩是一组热点 key。

解决方案:

1)过期时间打散。既然是大量缓存集中失效,那最容易想到就是让他们不集中生效。可以给缓存的过期时间时加上一个随机值时间,使得每个 key 的过期时间分布开来,不会集中在同一时刻失效。

2)热点数据不过期。该方式和缓存击穿一样,也是要着重考虑刷新的时间间隔和数据异常如何处理的情况。

3)加互斥锁。该方式和缓存击穿一样,按 key 维度加锁,对于同一个 key,只允许一个线程去计算,其他线程原地阻塞等待第一个线程的计算结果,然后直接走缓存即可。

总结

本篇文章就到这里了,希望能给你带来帮助,也希望您能够多多关注我们的更多内容!

(0)

相关推荐

  • 几道常问Redis面试题,你能答对吗?

    目录 1.Redis支持的数据类型? 2.什么是Redis持久化?Redis有哪几种持久化方式?优缺点是什么? 3.Redis 有哪些架构模式?讲讲各自的特点 4.使用过Redis分布式锁么,它是怎么实现的? 5.使用过Redis做异步队列么,你是怎么用的?有什么缺点? 6.什么是缓存穿透?如何避免?什么是缓存雪崩?何如避免? 7.Redis常用命令 8.Redis单例.主从模式.sentinel以及集群的配置方式及优缺点对比 9.为什么Redis 单线程却能支撑高并发? 10.Redis常见性

  • Redis分布式锁的实现方式(redis面试题)

    什么是分布式锁? 要介绍分布式锁,首先要提到与分布式锁相对应的是线程锁.进程锁. 线程锁:主要用来给方法.代码块加锁.当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段.线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state). 进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronize

  • Java面试题冲刺第二天--Redis篇

    目录 面试题1:为什么要用 Redis ?业务在哪块儿用到的? 正经回答: 深入追问: 追问1:Redis里有哪些数据类型? 追问2:Redis与Memcached有哪些区别? 追问3:那Redis怎样防止异常数据不丢失的?如何持久化? 面试题2:Redis为啥是单线程的? 正经回答: 深入追问: 追问1:单线程只使用了单核CPU,太浪费,有什么办法发挥多核CPU的性能嘛? 面试题3:聊一下对缓存穿透.缓存击穿.缓存雪崩的理解吧 正经回答: 深入追问: 追问1:那你说一下针对缓存击穿的解决方法

  • 硬核 Redis 高频面试题解析

    目录 1.Redis 是单线程还是多线程? 2.为什么 Redis 是单线程? 3.Redis 为什么使用单进程.单线程也很快 4.Redis 在项目中的使用场景 5.Redis 常见的数据结构 6.Redis 的字符串(SDS)和C语言的字符串区别 7.Sorted Set底层数据结构 8.Sorted Set 为什么同时使用字典和跳跃表? 9.Sorted Set 为什么使用跳跃表,而不是红黑树? 10.Hash 对象底层结构 11.Hash 对象的扩容流程 12.渐进式 rehash 的优

  • 2021年最新Redis面试题汇总(1)

    目录 1.Redis 是单线程还是多线程? 2.为什么 Redis 是单线程? 3.Redis 为什么使用单进程.单线程也很快 4.Redis 在项目中的使用场景 5.Redis 常见的数据结构 6.Redis 的字符串(SDS)和C语言的字符串区别 7.Sorted Set底层数据结构 8.Sorted Set 为什么同时使用字典和跳跃表? 9.Sorted Set 为什么使用跳跃表,而不是红黑树? 10.Hash 对象底层结构 11.Hash 对象的扩容流程 总结 1.Redis 是单线程还

  • 2021年最新Redis面试题汇总(3)

    目录 1.Redis 怎么保证高可用.有哪些集群模式 2.主从复制 ​3.哨兵 1)哨兵故障检测 2)哨兵故障转移流程 4.集群模式 ​5.集群选举 6.如何保证集群在线扩容的安全性?(Redis 集群要增加分片,槽的迁移怎么保证无损) 7.Redis 事务的实现 8.Redis 的 Java 客户端有哪些?官方推荐哪个? 9.Redis 里面有1亿个 key,其中有 10 个 key 是包含 java,如何将它们全部找出来? 10.使用过 Redis 做消息队列么? 11.Redis 和 Me

  • 2021年最新Redis面试题汇总(2)

    目录 1.渐进式 rehash 的优点 2.rehash 流程在数据量大的时候会有什么问题吗(Hash 对象的扩容流程在数据量大的时候会有什么问题吗) 3.Redis 的网络事件处理器(Reactor 模式) 4.Redis 删除过期键的策略(缓存失效策略.数据过期策略) 5.Redis 的内存淘汰(驱逐)策略 6.Redis 的 LRU 算法怎么实现的? 7.Redis 的持久化机制有哪几种,各自的实现原理和优缺点? 1)RDB 2)AOF 8.为什么需要 AOF 重写 9.介绍下 AOF 重

  • 2021年最新Redis面试题汇总(4)

    目录 1.Redis 实现分布式锁 2.Redis 分布式锁过期了,还没处理完怎么办 3.守护线程续命的方案有什么问题吗 4.RedLock 5.使用缓存时,先操作数据库 or 先操作缓存 6.为什么是让缓存失效,而不是更新缓存 7.如何保证数据库和缓存的数据一致性 ​8.缓存穿透 9.布隆过滤器 10.缓存击穿 11.缓存雪崩 总结 1.Redis 实现分布式锁 1)加锁 加锁通常使用 set 命令来实现,伪代码如下: set key value PX milliseconds NX 几个参数

  • 2021最新Android笔试题总结美团Android岗职能要求

    目录 Android开发面试的几部分 1.基础知识 Java部分: Android部分: 数据结构与算法: 计算机基础: 设计模式: 开源项目: 重点项目经历 技术以外的东西 自我驱动和追求 沟通和协作 我的面经总结 Android Java 计算机网络 数据结构及算法 题外话 优秀的战士需要出色的剑才能战斗.同样,在现代IT中,每个编码人员都需要最好的Android开发人员工具来提高他们的技能和效率.在Android应用程序开发这个残酷的竞争行业中,只有优秀的开发人员才能生存下去.您需要向客户

  • GO必知必会的常见面试题汇总

    目录 引言 值类型和引用类型 值类型有哪些? 引用类型有哪些? 值类型和引用类型的区别? 垃圾回收 一图胜千言 堆和栈 栈 堆 切片 比较 比较的详解 深拷贝和浅拷贝 操作对象 区别如下: new和make new 特点 举个例子: 使用技巧 make make函数的函数签名 特点 使用技巧 小结:new与make的区别 go的map实现排序 解决思路 代码实现: 运行结果 逃逸分析 最后,听我说 引言 今年互联网的就业环境真的好糟糕啊,好多朋友被优化. 我们平常在工作中除了撸好代码,跑通项目之

  • Android 面试题汇总

        Android 70道面试题汇总不再愁面试 本文为开发者奉献了70道经典Android面试题加答案--重要知识点几乎都涉及到了,你还等啥,赶紧收藏吧!! 1. 下列哪些语句关于内存回收的说明是正确的? (b) A. 程序员必须创建一个线程来释放内存 B. 内存回收程序负责释放无用内存 C. 内存回收程序允许程序员直接释放内存 D. 内存回收程序可以在指定的时间释放内存对象 2. 下面异常是属于Runtime Exception 的是(abcd)(多选) A.ArithmeticExcep

  • Java常用类String的面试题汇总(java面试题)

    1.比较两个字符串时使用"=="还是equals()方法? 当然是equals方法."=="测试的是两个对象的引用是否相同,而equals()比较的是两个字符串的值是否相等.简单来说,基本数据类型都可以使用==.而引用类型使用==比较不了. 2.如何将字符串转化成int? 使用包装类Integer.Integer.valueOf("2");其他基本数据类型都是类似 3.为什么在Java中存储密码要使用char[],而不使用String. 因为St

  • 关于vue面试题汇总

    vue的底层原理? vue组件之间的通信? JS中判断数据类型的方法有几种? 最常见的判断方法:typeof 判断已知对象类型的方法: instanceof 根据对象的constructor判断: constructor 无敌万能的方法:jquery.type() vue与angular的区别? 1.vue仅仅是mvvm中的view层,只是一个如jquery般的工具库,而不是框架,而angular而是mvvm框架. 2.vue的双向邦定是基于ES5 中的 3.getter/setter来实现的,

随机推荐