前言

作为一个后端程序员,面试过程中难免会被问到缓存的一些问题,而目前来说,Redis 就是使用的最为广泛的一个缓存中间件了。下面我们就以 Redis 为例,说一说面试过程中会经常会被问到的一些面试题。并试着了解面试官所想,抓住重点,奋力一击,让面试不再烦恼。

Redis 简介

首先,简单回顾下 Redis 的简介,并提供一个思路范式。

Redis 是一个开源(BSD许可),内存存储的数据结构服务器,可用作数据库,高速缓存和消息队列代理。它支持字符串、哈希表、列表、集合、有序集合,位图,HyperLogLogs 等数据类型。内置复制、Lua 脚本、LRU 收回、事务,以及不同级别磁盘持久化功能,同时通过 Redis Sentinel 提供高可用,通过 Redis Cluster 提供自动分区。根据月度排行网站 DB-Engines 的数据,Redis 是最流行的键值对存储数据库。

上面是对 Redis 的一个基本介绍,但是在学习一门新技术的时候,一般至少需要考虑三个基本问题 WWH:

  1. WHAT:是什么,该技术是什么,有什么特性
  2. WHY:为什么,为什么要使用该技术,该技术解决了什么问题
  3. HOW:怎么做,如何使用该技术

如果需要再深入一点,那么就还有一个 HOW:该项技术是如何实现的,即需要明白该技术的实现原理。

所以,猿们在使用某项技术或者学习某项新技术的时候,不妨按照上面的四个问题来思考下。或者回顾复习的时候也可以按照该思路来准备,因为一般的面试题也都是和上面几个问题相关的。下面要讲到的面试题或多或少都会和上面的四个问题挂钩。Let’s begin.

常见 Redis 面试题

我们围绕着 Redis 由浅入深地来列举一些可能会被问到的和 Redis 缓存相关的面试题,并给出简要的解答参考,让猿们可以快速上手,并了解一些面试官的套路。同时有能力的话,不妨试着引导面试官,让其问出你擅长方面的问题。另外,以下的回答都是基于 Redis 3.x 版本来考虑的。

另外, Redis 的学习,强烈建议去看看《redis设计与实现》。

Redis 支持哪些数据类型?

解读:这个其实是一个比较基本的问题,属于上面说的 WHAT,面试官这里其实就是想考察下你对 Redis 的认知,是不是有个基本了解。当然这个问题也是可以深入回答的。示例中给出说明。

示例:主要支持字符串、哈希表、列表、集合、有序集合五种。

一般这么说的话,面试官接下来可能就会问你使用场景了,比如说你们项目中用到了上述的哪些数据结构,在什么场景下用的,解决了什么问题。 其实这也算是引入了下一个问题:为什么要使用 Redis 缓存?

后续:当然,如果你对这个很熟悉,恰巧你还知道这几种数据结构 Redis 的内在内部实现,那么你不妨在回答的时候不要停,或者引导面试官。

例如,结束基本回答后接着说:其实 Redis 在内部对这些数据结构做了不少优化,提高了查询的性能。我想一般情况下面试官会接着问你 Redis 内部是怎么实现这些数据结构的。

笔者简单整理了下 Redis 数据结构的一个思维脑图:

为什么要使用 Redis 缓存?

解读:这个问题可回答空间比较广,属于 WHYHOW 的一个结合体。面试官在这里其实更多的是一个基本考核,想知道这个应聘者是否会有自己思想,会对项目有个思考,而不是傻傻就只知道:

示例:这个最好结合实际使用场景来说,比较真切有代入感。

比如说:

1. 订单信息,一般是写比较少,但是短期内后面可能会有较多的查询。那么可以说,项目中使用了 String 类型来缓存保单数据,查询时减轻了 DB 的压力,提高了查询接口的 QPS。

这里后面试官可能会追击:缓存和数据库的一致性如何处理、缓存穿透怎么办等。

2. 项目中有用到定时任务,但是又没用 quartz 的分布式那一套的时候。那么可以说,项目中基于 Redis 实现了简单的分布式锁,用来确保同一个定时任务同一时刻只有一台机器实例在跑。

这里后面试官可能会追击:基于 Redis 的分布式锁是如何实现的,并可能会问一些相关的细节问题。

……

后续:一般使用 Redis 的场景会比较多,建议考虑接下来面试官可能会问的问题来选择自己更熟悉的场景来回答,不至于答了这个问题,下一个问题却答不出来或答得不好。

缓存和数据库不一致问题

解读:这个是属于 HOW 的范畴,算是一个出现频率较高的一个问题,同时也是考虑点较多的一个问题。

使用了缓存的话,就有双写问题。通常,涉及到双写问题,就会有数据不一致的情况。需要保存数据一致需要牺牲性能,不过实际场景中一般也不要求这么高的一致性。要求严格一致的话,可以将读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况。

示例:我们采取了 Cache Aside Pattern

缓存模式:Cache Aside Pattern(先淘汰缓存,再写数据库)

  • 读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应读的时候;
  • 写的时候,先删除缓存,然后再更新数据库。

追问:那并发情况下出现了数据不一致问题怎么办?

eg. 有个写请求,此时删除了缓存中的数据,但是还没来得及写数据库;这个时候来了一个读请求,又把数据库中的旧数据 load 到缓存中去了,然后上一个请求的写数据库操作完成了。这个时候,数据库中的是最新的数据,缓存中的却是旧数据了。

解决思路:部分请求串行化或采取补偿操作。

  • 串行化:将数据库与缓存更新与读取操作进行异步串行化;将相同 id 的读写请求 hash 到同一台服务处理,服务中使用队列一个一个地执行。如果发现队列中有对应资源的写请求,那么就等待其执行结束后再去取值返回(这里需要考虑等待时间过长的问题,还有该方案影响较大,较复杂)。
  • 补偿操作:既然是延迟导致的数据不一致,那么根据数据库实际的延迟时间,我们使用定时任务或者消息触发,如果有写请求结束后,我们在指定的时间之后再删除一次该缓存值,这样即使有不一致的脏数据,那也只会出现在延迟的这一段时间中。

后续: 这一块可问空间较多,更多的建议阅读下

缓存架构设计细节二三事

缓存穿透和雪崩问题

解读:也是属于 HOW 的范畴,这里通常会有两个问题

  1. 什么是缓存穿透和雪崩
  2. 如何解决缓存穿透和雪崩

下面针对这两个问题做个简要的示例回答,并给出后续可参考的资料。

示例:总的来说,这两个问题带来的影响都是缓存失效或未命中导致 DB 压力大,从而可能拖垮服务。这些情况都是可能导致 DB 异常从而影响服务的可用性,那么我们着手于解决该问题即可。其实关键就是过滤无效的数据,增加缓存命中率,并添加对应的隔离措施以增加整个服务的可用性。

缓存穿透:访问了不存在的 key,缓存未命中,请求会穿透到 DB,量大时可能会对 DB 造成压力导致服务异常,可能的处理方案如下:

  • 针对这些不存在的 key,可以在 Redis 中保存对应的 key 和空数据,并设置较短的过期时间;
  • 或者使用 Redis Bitmap 来判断数据是否存在以过滤无效请求。

缓存雪崩:缓存同时失效或缓存服务异常,瞬时大量请求直接到达 DB,对 DB 造成压力导致服务异常,可能的处理方案如下:

  • 保证缓存服务的高可用,采用集群,多主多从模式部署,并开启 RDB 和 AOF 备份,在 Redis 服务出问题时能快速根据备份文件恢复缓存数据;
  • 缓存过期时间的设置随机化;
  • 调用缓存服务时增加熔断模块,类似 Hystrix。

基于 Redis 的分布式锁是怎么实现的?

解读:这是一个比较常用的场景,可能会被问到,但是如果你只知道 SETNX 那可能就要 GG 了。说下这里可能会有的坑。

  1. 会不会有 B 线程删除了 A 线程加的锁的情况
  2. 锁超时了怎么办

可能大部分同学都知道可以通过 SETNX 来实现一个分布式锁,但是如果没有实际的使用经验的话,很可能就不知道还会有上面两个问题,被问到的时候自然就无从下手了。

示例:我们通过 SETNX 命令来实现的分布式锁,设置了过期时间,不至于会出现线程异常导致锁无法释放的情况。

面试官:那释放锁的时候怎么做?会不会有 B 线程删除了 A 线程加的锁的情况?

答:不会的,我们加锁的时候有为每一个线程生成独立的 UUID,存储到 Redis,解锁的时候是使用的 Lua 脚本,判断了 UUID 一致才会让释放对应的锁。

面试官:不错,那锁超时了怎么办呢?

答:嗯,这是个问题,我们可以考虑类似租约机制的实现,启动定时任务来给线程续期,延长加锁时间;当然,还需要考虑实际情况,balabalabala……

后续:基于 Redis 的分布式锁实现,可以参考

Redis 分布式锁的正确实现方式( Java 版 )

另外,还有个对应的 Java 实现——Redisson,可以看看其源码实现学习下。

为啥 Redis 单线程模型也能效率这么高?

解读:这个问题,属于最后一个 HOW 了,考察应聘者对 Redis 的实现原理理解。感觉比较深。个人认为考察点会涉及到 Redis 的网络连接处理的方式和 Redis 的数据结构的设计。

示例: Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器叫做文件事件处理器(File Event Handler)。这个文件事件处理器是单线程的,Redis 才叫做单线程的模型,采用 IO 多路复用机制同时监听多个 Socket,根据 Socket 上的事件来选择对应的事件处理器来处理这个事件,为啥快呢:

  1. 纯内存操作
  2. 核心是基于非阻塞的 IO 多路复用机制
  3. 单线程反而避免了多线程的频繁上下文切换问题(这个感觉就 emm…)
  4. 内部数据结构设计,整个的结构都类似于一个 map,查找效率贼高

Redis 持久化方式及其区别

解读:这个主要是考察应聘者对 Redis 的一些特性的属性程度。

示例:Redis 出问题后数据会全部丢失吗?

答:不会哦,Redis 有持久化机制的,它支持 AOF 和 RDB 两种持久化方式。

面试官:哦,那这两种持久化方式有什么区别呢?你们使用的是哪种形式?又是怎么配置的?

答:我们都是两种一起使用的。区别就是,balabalabala……

RDB:通过 fork 一个子进程保存当前内存的一个快照实现备份. 适合大规模的数据恢复,但是数据的完整性和一致性不高,因为 RDB 可能在最后一次备份时宕机了。另外备份时会占用内存,因为 Redis 在备份时会独立创建一个子进程,将数据写入到一个临时文件(此时内存中的数据是原来的两倍哦),最后再将临时文件替换之前的备份文件。配置方法如下:

1
2
3
4
5
6
# save <指定时间间隔> <执行指定次数更新操作>,满足条件就将内存中的数据同步到硬盘中
save <seconds> <changes>
# 指定本地数据库文件名,一般采用默认的 dump.rdb
dbfilename dump.rdb
# 默认开启数据压缩
rdbcompression yes

AOF: 通过对每条写入命令以 append-only 的模式写入一个日志文件中,在 Redis 重启的时候,可以通过回放 AOF 日志中的写入指令来重新构建整个数据集。 AOF 对数据数据的完整性和一致性支持更好,但是其备份日志文件一般会比 RDB 方式的备份文件更大,恢复也更慢,同时由于 fsync 的频率方式,会影响 Redis 的性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 打开aof
appendonly yes
# 日志文件
appendfilename "appendonly.aof"
# 更新条件
# appendfsync always
appendfsync everysec
# appendfsync no
# 触发重写的配置
# 时间长了日志会特别大,此时需要触发重写
# 重写的原理:Redis 会fork出一条新进程,读取内存中的数据,并重新写到一个临时文件中。
# 最后替换旧的aof文件。
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

后续:Redis 4.0 之后,持久化增加了混合 RDB-AOF 持久化格式。具体可以参考

Redis 4.0 新功能简介

Redis 如何实现分布式和高可用?

解读:这个就是考察的你对 Redis 部署架构的了解;以及对项目实际部署情况的了解程度。

示例:我们采用的集群部署模式,三主三从,总共六台服务器。

面试官:这样啊,那 Redis 主从模式,数据是怎么同步的呢?

答:Redis 主从数据同步主要有两种情况,一种是全同步,一种是部分同步,balabalabala……

Redis 主从模式原理:

Redis 2.8 之前主从模式,主从之间的数据复制只有全复制机制,通过执行命令 SLAVEOF ip port,使得其成为从服务器。全数据复制流程如下:

  1. 从服务器向主服务器发送 SYNC 命令。
  2. 主服务器收到 SYNC 请求后,执行 BGSAVE 命令在后台生成 RDB 文件,并使用一个缓冲区记录从现在开始执行的写命令。
  3. 主服务器将生成的 RDB 文件发送给从服务器,从服务器根据RDB文件更新状态。
  4. 主服务器将缓冲区中的写命令发送给从服务器,从服务器执行这些命令,最终和主服务器达到一致的状态。

命令传播

数据复制完成后,为了保持一致的状态,主服务的写命令都需要传播给其从服务器。

不足:上面的方式存在不足,即如果从服务器是和主服务器断开后,又立马重新连接上后。那么此时如果还执行全同步的话,就十分浪费。因为全同步非常占用 CPU、IO 和带宽等。

Redis 2.8 之后,支持了 PSYNC,即部分重同步机制。部分重同步机制主要依靠:

  • 主从服务器的复制偏移量: 用于记录主从服务器的状态是否一致,以及数据复制时的偏移量
  • 主服务器的复制积压缓冲区:固定大小的(默认 1M)FIFO 队列,用于记录主服务器的写命令
  • 主服务器的 ID:用于判断,从服务器断开连接后重新连接到的主服务器还是不是原来那个

部分重同步流程:

主从服务器都有一个复制偏移量,当执行了写命令时,复制偏移量对应会增加。

  1. 从服务器请求同步,带上当前的 offset 和之前的主服务器 ID;
  2. 如果请求中的主服务 ID 和当前的主服务器ID不一致,或者其 offset 的值已经不再复制积压缓冲区内,那么需要执行全同步,流程同上;
  3. 否则,执行部分同步,主服务器将复制积压缓冲区中 offset 之后的命令发送给从服务器;
  4. 从服务器执行对应写命令,并修改 offset,达到和主服务器状态一致。

后续: Redis 3.x 的 PSYNC 也并不完美,Redis 在 4.0 的时候优化了下,即 PSYNC 2.0。参考:

Redis 4.0 新功能简介

Redis 的过期策略都有哪些?

解读:对 Redis 特性的考察,可能会引申到其他数据结构相关的问题上去。

示例:

面试官:Redis 怎么删除过期数据的?

答:Redis 有定期删除和惰性删除两种。

  • 定期删除:默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。
  • 惰性删除:在你获取某个 key 的时候,Redis 会检查一下 ,这个 key 如果设置了过期时间那么是否过期了?如果过期了此时就会删除,不会给你返回任何东西。

面试官:哦,那如果执行了上述的删除操作后,Redis 的内存空间还是不足怎么办呢?

答:设置了过期时间的,到期后会被上述操作删除掉;如果此时内存还是不够的话,Redis 会根据配置的策略来执行对应的操作,主要有 noeviction、allkeys-lru、allkeys-random、volatile-lru、volatile-random、volatile-ttl 这几种,balabalabala……

  • noeviction:当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用吧,实在是太恶心了。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用吧,为啥要随机,肯定是把最近最少使用的 key 给干掉啊。
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

后续:因为这里面有 LRU ,面试官可能会问 LRU 用 Java 怎么实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// LRU实现,这里又可能会问你 LinkedHashMap 怎么实现的,哈哈哈,自己看看看源码吧
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxSize;
public LRUCache(int maxSize){
this(maxSize, 16, 0.75f, false);
}

public LRUCache(int maxSize, int initialCapacity, float loadFactor, boolean accessOrder){
super(initialCapacity, loadFactor, accessOrder);
this.maxSize = maxSize;
}

/**
* 最重要就是重写该方法咯,就是说当map中的数据量大于指定的缓存个数的时候,
* 就自动删除最老的数据
*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return this.size() >this.maxSize;
}
}

使用 Redis 遇到过什么问题?

解读:这个就是考察的实际使用场景的问题了,更多的可能是一个进阶,怎么都得憋出一点问题啊,主要是要体现出自己的思路,要证明自己可是有实际使用经验并且有做对应考虑的人啊。

示例:比如这边遇到过一个热点问题。

一般处理方式:

  • 增加内存缓存,减轻Redis的压力
  • 分散热点数据

首先定位性能瓶颈,CPU/内存/IO 等:

1. ps aux | grep appname 查看进程 ID

2. top -H pid 查看 CPU 和内存的使用情况(未发现应用服务器的 CPU 和内存有较高的使用率)

CPU 繁忙一般可能的情况如下:

  • 线程中的不合理循环
  • 发生了频繁的 Full GC
  • 多线程的上下文频繁切换

3. 通过 Arthas 工具查看接口执行过程中每个方法的耗时情况:

  • $trace xxx #cost,trace 命令能主动搜索 class-pattern/method-pattern 对应的方法调用路径,渲染和统计整个调用链路上的所有性能开销和追踪调用链路。trace 能方便地帮助你定位和发现因 RT 高而导致的性能问题缺陷,但其每次只能跟踪一级方法的调用链路。

结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
+---[0.001162ms] com.paic.ibcsapi.service.dto.ApplyResultDTO:setApplyPolicyNo()
+---[0.511972ms] com.xxx.service.apply.save.ApplySaveService:fillApplyPolicyNo()
+---[19.709352ms] com.xxx.service.apply.save.ApplySaveService:saveContractDTOInfo()
+---[1.313593ms] com.xxx.service.apply.save.ApplySaveService:saveApplyParameterInfo()
+---[min=4.35E-4ms,max=9.48E-4ms,total=0.002326ms,count=3] java.lang.StringBuilder:<init>()
+---[min=5.1E-4ms,max=0.00146ms,total=0.004705ms,count=6] java.lang.StringBuilder:append()
+---[0.001361ms] com.paic.icore.acss.base.dto.BaseInfoDTO:getTransactionNo()
+---[min=4.86E-4ms,max=6.61E-4ms,total=0.001777ms,count=3] java.lang.StringBuilder:toString()
+---[523.544075ms] com.xxx.common.service.redis.RedisService:stringSet()
+---[9.91E-4ms] com.xxx.common.util.StringUtils:isNotBlank()
+---[min=6.07E-4ms,max=7.75E-4ms,total=0.001382ms,count=2] java.lang.String:equals()
+---[min=5.03E-4ms,max=6.95E-4ms,total=0.001198ms,count=2] java.lang.Integer:intValue()
+---[min=592.763104ms,max=868.853641ms,total=1461.616745ms,count=2] com.xxx.common.service.redis.RedisService:ObjectSet()

4. 通过 trace 定位到是 Redis 耗时后,定位 Redis 问题:

  • 查看 Redis 服务器状态,发现压测时有台服务器的 查看 Redis 服务器状态,发现压测时有台服务器的 CPU 达到 70% 左右;
  • slowlog get 命令获取 Redis 慢查询定位到是某个 key 的读取导致,到此发现是 Redis 数据的热点问题。

5. 解决方案

  • 出现该问题是有工具类滥用 Redis 的问题,每次获取数据都从 Redis 获取。首先,调整代码,增加内存缓存,Redis 缓存刷新时发布事件动态刷新内存缓存; 然后,优化存储的 DTO,只缓存必要的数据,减小数据量;最后,替换序列化工具,将 Jackson 序列化替换为 Fastjson。
  • 流程中部分耗时的 IO 操作线程异步后台处理。

后续: 涉及到热点数据问题,可能会问热点数据问题的一般处理访问,可以看看:

热点 Key 问题的发现与解决

Redis 最新版本是啥,有啥新特性?

解读:通常这会是相关问题的最后一个问题,类似的还有最新的 Java 版本是什么,有啥新特性之类。这种问题考察的是应聘者对技术动向的关注度,以此判断应聘者是不是一个会主动去获取最新信息和新技术动向的人员。

示例:我们项目用的是 Redis 3.x ,目前最新的已经是 Redis 5.x了,相对于 3.x 来说,5.x 新增了一些功能:

  • 新增了混合 RDB-AOF 持久化格式,这样 Redis 就可以同时兼有 RDB 持久化和 AOF 持久化的优点,既能够快速地生成重写文件,也能够在出现问题时,快速地载入数据。
  • 增加了模块系统,使得 Redis 的可拓展性更好了。
  • 优化了 3.x 版本的 PSYNC,服务重启也不一定要进行全同步呢。
  • 全新的数据类型:Streams,可以把 Redis 当做阉割版的 Kafka 来使用了呢。 ……

总结

上面只讲了部分出现过的面试题,可以帮助读者查漏补缺。但是面试题无穷尽,关键还是读者自己对技术的了解程度如何,在学习使用每一项技术的时候,我们都不该忘了思考,需要有自己的思考产出,而不是一味地填鸭,当然部分实践经验还是需要多和同业人员多交流学习的。最后,祝大家面试顺利,拿到满意的 Offer。


本文首发于 GitChat,面试指南之 Redis 缓存