【面试1v1实景模拟】Redis面试官就爱这样问

2023年3月30日

老面👴:小伙子,咱来聊聊Redis,你知道什么是Redis吗?

解读🔔:这个必须知道,不知道直接挂~😂😂😂,但面试官肯定不是想听你知道两个字,他是想让你简单的介绍下。

笑小枫🍁:Redis是我们常用的缓存中间件,是一个基于内存的高性能的key-value数据库。Redis不仅仅支持简单的key-value类型的数据,同时还提供多种数据结构的存储。Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。


老面👴:除了key-value类型的数据,你还知道Redis的哪几种数据结构?

解读🔔:当然知道,就是有点不想告诉你😁😁😁

笑小枫🍁:Redis支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及zsetsortedset:(有序集合)。

我们实际项目中比较常用的是string,hash。

PS:如果你是Redis中高级用户,还需要加上下面几种数据结构HyperLogLog、Geo、Pub/Sub。如果你说还玩过RedisModule,像BloomFilter,RedisSearch,Redis-ML,面试官得眼睛就开始发亮了。这里就不展开一一细说了,感兴趣的可以自己去搜搜,后面问题会涉及一二。


老面👴:你说说在什么场景下使用了Redis吗?

解读🔔:不就是使用Redis的场景嘛~没用过,你也要编出来,这个太常见了

笑小枫🍁:Redis常用的有缓存用户登录后的token,热点数据做缓存,利用Redis的原子递增生成订单唯一编号,用Redis来做阅读量的计算器,使用Redis做分布式锁,使用Redis做延时队列,使用Redis的Zset做排行榜等等...

PS:这里列举场景一定要挑自己理解的或用到过的,基本上每个场景用法都会牵扯出来很多的面试点,一定要熟悉~这里就不一一展开了,只举一个点吧,后面出一篇文章详细讲解每个场景怎么实现和存在的面试点吧


老面👴:你能具体说说使用Redis保存用户登录token的流程吗?

用户使用账号密码登录,后端系统进行校验,如果失败,则返回失败原因;
如果成功,会把用户id、名称等常用的不变的数据封装成一个对象,然后用jwt生成一个token;
然后把token存入Redis,并设置过期时间;
最后把token返回给前端,后续请求接口前端携带token进行验证。


老面👴:为什么要使用jwt呢?直接使用Redis保存用户信息不可以吗?

解读🔔:去公司的时候项目就这样用的,谁知道为啥用jwt呀。。。草率了不该提jwt的😓😓😓

笑小枫🍁:直接使用Redis保存也可以,使用Token做key,用户信息做value。但是使用jwt是使用用户id做key,token做value,这样请回请求时,可以先进行jwt解析,拿到id再去获取token;

如果发生大量恶意攻击时,可以通过jwt解析数据拦截掉部分错误token,减少Redis服务器的压力;

当然jwt可以存放用户信息,可以直接解析使用,在多方法间调用的时候,可以减少数据的传递,使代码更加简洁。


老面👴:只使用jwt不可以满足token认证的需求吗?为什么还要使用Redis呢?

解读🔔:都是jwt惹得祸,服了老面这个老6,咋就揪着这个点不放呀😣😣😣

笑小枫🍁:

  • jwt存在token续期的问题,每次续期都会生成一个新的token,而用Redis不需要,大部分系统,每次请求接口token会自动续期,可以避免token的更换;
  • 使用Redis可以查看用户的登录状态,只使用jwt则看不到;
  • 如果账号要实现单设备登录,当另一个设备登录时,剔除前一个设备,使用Redis可以很方便的实现,只使用jwt无法实现。

老面👴:你们项目中token过期时间是怎么设置的?

解读🔔:怎么还在token中,这不是一篇Redis的面试题嘛,跑题了跑题了~😣😣😣

笑小枫🍁:这个不同的项目不一定,像用户APP,我们一般设置是7天,用户使用会自动续期;我们内部用的软件,数据私密性强的,一般都是30分钟,30分钟未使用,则token过期。

token的过期时间,我们使用EXPIRE设置。使用TTL查看剩余过期时间或状态。

EXPIRE   :表示将键 key 的生存时间设置为 ttl 秒;
PEXPIRE   :表示将键 key 的生存时间设置为 ttl 毫秒;
EXPIREAT   :表示将键 key 的生存时间设置为 timestamp 所指定的秒数时间戳;
PEXPIREAT   :表示将键 key 的生存时间设置为 timestamp 所指定的毫秒数时间戳;
TTL  :以秒的单位返回键 key 的剩余生存时间。
PTTL  :以毫秒的单位返回键 key 的剩余生存时间。

XX:具有时效性的数据;
-1:永久保存的数据;
-2:已经过期的数据或被删除的数据或未被定义的数据;

老面👴:Key过期后,Redis是怎么删除的呢?你能说说Redis的删除策略吗?

解读🔔:终于回归主题了,这才是我擅长的🧐🧐🧐

笑小枫🍁:有3种删除策略,定时删除定期删除惰性删除,Redis采用的是定期删除 + 惰性删除。因为定时删除会占用大量的CPU资源,Redis没有采取这种策略。

  • 定期删除由redis.c/activeExpireCycle函数实现,函数以一定频率执行,每当Redis的服务器性执行redis.c/serverCron函数时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键
  • 惰性删除由db.c/expireIfNeeded函数实现,所有键读写命令执行之前都会调用expireIfNeeded函数对其进行检查,如果过期,则删除该键,然后执行键不存在的操作;未过期则不作操作,继续执行原有的命令

当然,除了定期删除和惰性删除外,如果Redis的内存不足时,Redis会根据内存淘汰机制,删除一些值。


老面👴:小伙子,你刚刚提到了内存淘汰机制,你能说说有哪几种吗?

解读🔔:老面,这是我为你挖的坑,就知道你会忍不住的问,又到了证明自己的时刻,千万不能翻车🤪🤪🤪

笑小枫🍁:Redis可以设置内存大小:maxmemory 100mb,超过了这个内存大小,就会触发内存淘汰机制;Redis默认maxmemory-policy noeviction,共有8种淘汰机制,分别是:

  • noeviction: 不删除,直接返回报错信息。
  • allkeys-lru:移除最久未使用(使用频率最少)使用的key。推荐使用这种。
  • volatile-lru:在设置了过期时间的key中,移除最久未使用的key。
  • allkeys-random:随机移除某个key。
  • volatile-random:在设置了过期时间的key中,随机移除某个key。
  • volatile-ttl: 在设置了过期时间的key中,移除准备过期的key。
  • allkeys-lfu:移除最近最少使用的key。
  • volatile-lfu:在设置了过期时间的key中,移除最近最少使用的key。

使用策略规则:

  1. 如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用allkeys-lru;
  2. 如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用allkeys-random。

老面👴:小伙子不错嘛,如果Redis服务器挂了,重启后数据可以恢复吗?

解读🔔:这肯定可以恢复呀,老面你是想问我Redis的持久化机制的吧

笑小枫🍁:可以恢复,Redis提供两种持久化机制RDB和AOF机制。可以将内存上的数据持久化到硬盘。


老面👴:你可以说说RDB和AOF的优缺点吗?

笑小枫🍁:

RDB(RedisDataBase)持久化方式:是指用数据集快照的方式半持久化模式,记录redis数据库的所有键值对,在某个时间点将数据写入一个临时文件,持久化结束后,用这个临时文件替换上次持久化的文件,达到数据恢复。

优点

  1. 只有一个文件dump.rdb,方便持久化。
  2. 容灾性好,一个文件可以保存到安全的磁盘。
  3. 性能最大化,fork子进程来完成写操作,让主进程继续处理命令,所以是IO最大化。
  4. 相对于数据集大时,比AOF的启动效率更高。

缺点

  1. 数据安全性低。RDB是间隔一段时间进行持久化,如果持久化之间redis发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候。

AOF(Append-onlyfile)持久化方式:是指所有的命令行记录以redis命令请求协议的格式完全持久化存储)保存为aof文件。

优点

  1. 数据更完整,安全性更高,秒级数据丢失(取决于 fsync 策略,如果是 everysec,最多丢失 1 秒的数据);
  2. AOF 文件是一个只进行追加的命令文件,且写入操作是以 Redis 协议的格式保存的,内容是可读的,适合误删紧急恢复。

缺点

  1. AOF文件比RDB文件大,且恢复速度慢。
  2. 数据集大的时候,比rdb启动效率低。所以这种方式更适合数据要求相对严谨的时候。

Redis 4.0 版本提供了一套基于 AOF-RDB 的混合持久化机制,保留了两种持久化机制的优点。这样重写的 AOF 文件由两部份组成,一部分是 RDB 格式的头部数据,另一部分是 AOF 格式的尾部指令。


老面👴:可以说一下什么是缓存穿透、缓存击穿、缓存雪崩吗?

笑小枫🍁:

缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。

解决方案:

  1. 最基本的就是做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。
    比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。
  2. 缓存无效 key
    如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,具体命令如下: SET key value EX 10086 。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
  3. 布隆过滤器
    把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

缓存雪崩是指缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。

解决方案:

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
  2. 限流,避免同时处理大量的请求。

针对热点缓存失效的情况:

  1. 设置不同的失效时间比如随机设置缓存的失效时间。
  2. 缓存永不失效。

缓存击穿问题也叫热点Key问题,就是缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。

缓存击穿和缓存雪崩的区别在于缓存击穿针对某一key缓存,缓存雪崩是很多key。

  1. 互斥锁:当同个业务不同线程访问redis未命中时,先获取一把互斥锁,然后进行数据库操作,此时另外一个线程未命中时,拿不到锁,等待一段时间后重新查询缓存,此时之前的线程已经重新把数据加载到redis之中了,线程二就直接缓存命中。这样就不会使得大量访问进入数据库

优点:没有额外的内存消耗,保证一致性,实现简单

缺点:线程需要等待,性能受影响,可能有死锁风险

  1. 实时调整,监控哪些数据是热门数据,实时的调整key的过期时长
  2. 针对热点Key设置缓存永不失效

老面👴:Redis如何找出以某个前缀开头的数据

笑小枫🍁:如果数据量不大,可以使用keys指令可以扫出指定模式的key列表。
如果数据量很大,因为Redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。


老面👴:最后再说一下缓存数据一致性的问题?

笑小枫🍁:

  1. 先更新数据库,在更新缓存(不推荐)
  2. 先删缓存,再更新数据库(不推荐)
  3. 延时双删
  4. 先更新数据库,再删除缓存,引入消息队列
  5. 先更新数据库,再删除缓存,使用mysql binlog日志

先更新数据库,再更新缓存(不建议,要了解原因!)

这套方案,大家是普遍反对的。为什么呢?有如下两点原因。

原因一(线程安全角度)

同时有请求A和请求B进行更新操作,那么会出现

  1. 线程A更新了数据库
  2. 线程B更新了数据库
  3. 线程B更新了缓存
  4. 线程A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

原因二(业务场景角度)

有如下两点:

  1. 如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
  2. 如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。

接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。

先删缓存,再更新数据库

该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:

  1. 请求A进行写操作,删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B去数据库查询得到旧值
  4. 请求B将旧值写入缓存
  5. 请求A将新值写入数据库

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

延时双删策略

那么,如何解决呢?采用延时双删策略
伪代码如下

    public void write(String key,Object data) {
        redis.delKey(key);
        db.updateData(data);
        Thread.sleep(1000);
        redis.delKey(key);
    }

转化为中文描述就是

  1. 先淘汰缓存
  2. 再写数据库(这两步和原来一样)
  3. 休眠1秒,再次淘汰缓存

这么做,可以将1秒内所造成的缓存脏数据,再次删除。

那么,这个1秒怎么确定的,具体该休眠多久呢?
针对上面的情形,读者应该自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

如何解决?
提供一个保障的重试机制即可,这里给出两套方案。

先更新数据库,再删除缓存,引入消息队列

如下图所示

流程如下所示

  1. 更新数据库数据;
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息,获得需要删除的key
  5. 继续重试删除操作,直到成功

然而,该方案有一个缺点,对业务线代码造成大量的侵入。于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

先更新数据库,再删除缓存,使用mysql binlog日志

流程如下图所示:

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作。

    备注说明:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。另外,重试机制,采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。

服务器托管,北京服务器托管,服务器租用 http://www.hhisp.net

hackdl

咨询热线/微信 13051898268