本文简单记录 Redis(缓存)相关的面试知识点

为什么要用缓存

使用缓存的原因要从以下两个场景来分析:

  • 高性能

如果没有缓存,所有得数据都是从磁盘/硬盘读取(数据库本质上也是从文件读取),此时的读写效率会比较慢。如果将数据存储读写速度超快的内存中就不会有这样的问题,所以要使用缓存

  • 高可用/高并发

在高并发的场景下,如果没有缓存,大量涌入的请求会导致数据库宕机。如果把部分数据转移到效率更高的内存中,此时部分请求不会请求到数据库,这样减小了数据库系统的压力,同时提高了相应速度

有哪些缓存中间件?为什么选用 Redis

常见的缓存中间件有 Memcached 和 Redis。和 Redis 一样 Memcached 也是基于内存以键值对存储的高性能数据库,但由于二者之间的区别我们通常使用 Redis 而不是 Memcached。

  • Redis 有着更丰富的数据结构和特性,Memcached 仅支持字符串
  • Redis 支持数据持久化
  • Memcached 可以利用多核性能更高,而 Redis 只能使用单核
  • Redis 支持分布式

利用了多核的 Memcached 性能更高,如果仅需要一个高性能的缓存时选择 Memcached 准没错,但相比之下 Redis 更可靠,所以通常选用 Redis。

为什么 Redis 是单线程的

因为 Redis 是完全基于内存存储,所以 CPU 不会成为瓶颈,如果引入多线程会带来频繁的上下文切换以及锁的操作,这必然会对性能造成损耗,所以 Redis 是单线程的。

  • 纯内存操作
  • 避免上下文切换
  • IO 多路复用

也正是因为上面的三点让单线程的 Redis 也能有如此高的性能。

Redis 有哪些数据结构

Redis 有 5 种基本数据类型

  • String 字符串类型

String 类型是 Redis 中最重要且最常用的数据类型。Redis 是基于 C 语言开发的,因为 C 语言中的字符串不是二进制安全的,所以 Redis 使用了一种 Simple Dynamic String(简单动态字符串,简称 SDS)数据结构改良了 C 语言中的字符串(使用单独的字段记录字符串的长度)。

C 语言中的字符串是以 \0 截断,如果字符串本身含有 \0 ,那么字符串就会在不该被截断的位置截断导致出现错误,这种情况下是不能用于存储二进制文本,比如图像、音频和视频等文件,此时被称为是非二进制安全,而 Redis 的 SDS 则避免了这个问题。

  • Hash 散列表

和 Java 中的散列表一样存储的是键值对(K - V结构),散列表有两种底层数据结构:ZipList(压缩列表)和 HashTable (哈希表),当以下满足以下两种情况时时以 ZipList 存储:

- 散列表中所有的键/值的大小都不超过64字节时
- 散列表中键值对的个数不超过512个的时候
  • List

一种列表,每个节点都包含一个字符串。其底层数据结构和 Hash 一样,在数据不多或者数据不大的情况下是 ZipList 存储;当无法满足时采用 LinkedList 存储

  • Set

和 List 一样可以存储若干字符串,但在 Set 内部通过维护一张只有 Key 的散列表来保证元素不会重复,但元素之间的顺序无法保证。Set 的底层有两种数据结构:HashTable 和 InSet,当以下满足以下两种情况时时以 InSet 存储:

- 集合中元素的个数不超过512个的时候
- 集合中元素可以用整型表示
  • ZSet

和 Hash 类似存储键值对,但 ZSet 的键叫做 Member(成员),值叫做 Score (分数,浮点数);同时又和 Set 一样内部元素不允许重复。ZSet 底层同样有两种数据结构:ZipList 和 [ Dict(字典) + SkipList(跳跃表)],当以下满足以下两种情况时时以 ZipList 存储:

- 有序集合中所有的元素的大小都不超过64字节时
- 有序集合中元素的个数不超过128个的时候

同时也有 4 种高级数据类型

  • HyperLogLog

一种基数估算算法,主要用于统计一组数据中不重复的数据的个数。该算法存在一定误差,但占用空间小,可统计数量高达 $2^{64}$ 个

  • Geo

用来存储地理位置信息,实现原理是 Geo Hash

  • Bitmap

位图,利用比特位来映射某个元素的状态,是 Redis 基于字符串实现的功能

  • Stream

可持久化的消息队列

Redis 的使用场景

  • 缓存

  • 分布式锁(set nx ex / lua 脚本)

  • 计数器(利用 incr 命令实现)

  • 附近的人(Geo)

  • 消息队列(rpush + blpop/lpush + rlpop

  • 延迟队列(ZSet)

什么是缓存雪崩、击穿和穿透

  • 雪崩

雪崩是指在一瞬间大量的 Key 过期导致大量请求涌入数据库造成的系统崩溃,可以通过在设置过期时间时增加小随机数的方法来避免雪崩

  • 击穿

击穿和雪崩类似,不过击穿指的是一个热点 Key 过期导致的崩溃,可以利用互斥锁让只有一个请求到数据库查询并回填缓存

  • 穿透

穿透是指不断请求缓存中没有的 Key 导致的问题,可以通过对请求参数的校验来避免或者使用布隆过滤器

Redis 有哪些内存淘汰策略

因为机器的内存是有限的,所以 Redis 是不可以不停的往里写入的,当 Redis 使用量已经达到最大内存时会触发淘汰机制,会根据配置的不同策略删除不同的 Key

redis官方文档

淘汰策略 说明
noeviction 不删除任何 Key,当继续写入时报错
allkeys-lru 在所有 Key 中删除最近最少使用的
allkeys-lfu 在所有 Key 中删除最少访问的
allkeys-random 在所有 Key 中随机删除
volatile-ttl 在设置了过期时间的 Key 中删除临近过期的
volatile-lru 在设置了过期时间的 Key 中删除最近最少使用的
volatile-lfu 在设置了过期时间的 Key 中删除最少访问的
volatile-random 在设置了过期时间的 Key 中随机删除

Redis 的持久化方法

因为内存中的数据在机器重新启动的时候会全部丢失,所以 Redis 会周期性的进行内存数据的持久化。Redis 的持久化策略有两种:

  • RDB

RDB 是对当前内存中所有数据的一个快照,即保存了当前所有数据的状态。 RDB 的工作原理是 fork 一个子进程出来(此时会阻塞主线程),Redis 进程和子进程共享内存,快照操作在子进程中完成,并不会影响 Redis 线程继续处理命令,如果 Redis 内存占用比较高又频繁改动的时候,最多可能会导致 Redis 使用了双倍的内存。

  • AOF

与 RDB 全盘备份不同,AOF 是把每个命令在执行成功后写入缓存中,再根据配置的触发策略将缓存中的内容写入文件中

  • 混合持久化

混合持久化是 Redis 4.0 以后新加入的方式,是将上面两种持久化方式结合起来,先利用 RDB 保存系统的快照,在保存快照的同时产生的新的命令以 AOF 的方式记录,最终将二者结合起来一并写入文件中

Redis 的持久化策略该如何选择

  • 如果缓存中的数据丢失不重要的时候选择不持久化
  • 如果允许丢失较长时间的数据,那么选择 RDB,对性能影响小
  • 如果对数据丢失比较敏感,那么只能选择 AOF
  • 如果在主从分离的环境下,关闭主节点的持久化(提高性能),开启子节点的 RDB

有哪些方式保证 Redis 的高可用

  • 主从模式

避免单节点的 Redis 发生故障,将 Redis 中的数据全量复制到其他节点上。此时 Redis 有两种角色:主节点(Master)和从节点(Slave),在主节点上可以进行读写操作,而在从节点上只能进行读操作。主从模式下主要是为了分担主节点的的压力,并提供容灾恢复的能力

  • 哨兵模式(Sentinel)

在主从模式下如果主节点发生故障,此时的 Redis 是无法进行更新操作的,需要手动修改从节点为主节点才能恢复全部功能。针对这个问题,在 Redis 2.8 中引入了哨兵(Sentinel)机制。由 n 个哨兵(n $\geq$ 3, n 为奇数)监控主节点,当发现主节点挂掉后会自动的在可用的从节点中选举出一个新的主节点,保证 Redis 能够正常对外提供服务

  • 集群模式(Cluster)

以上两种模式,每个 Redis 节点都保存着全量的数据,比较浪费空间。所以在 Redis 3.0 中引入了集群(Cluster)模式。若干个 Redis 节点组成一个集群,每个 Redis 节点都保存着部分数据;每个节点都互相连通,只需连接到任一节点,即可访问全部节点的数据。为了保证高可用,集群中的每个 Redis 都可以有多个子节点,也就是形成了小范围的主从复制,如果主节点挂了,可以选举任一子节点成为主节点,如果主节点连通其子节点一起挂掉,此时集群无法正常对外提供服务

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

只要在系统中同时使用了缓存和数据库,那么一定会面临数据的一致性问题。合理的设置缓存的过期时间只能保证最终一致性,但我们追求的是更快的一致性,此时就要在修改数据库的同时对缓存中的数据一并修改,针对缓存和数据库的先后修改顺序,共有 4 中方案:

  • 先修改缓存,再修改数据库
  • 先修改数据库,再修改缓存
  • 先删除缓存,再修改数据库
  • 先修改数据库,再删除缓存

修改缓存还是删除缓存

先说结论,通常情况下都选择删除缓存而不是更新缓存。

因为在对一个写多读少的数据修改的时候,可能短时间内会修改很多次,但只会读取一次,此时就没有必要浪费时间在每次修改数据库后再更新到缓存中,只需等待着唯一的一次读数据时缓存回填即可。

接下来再讨论删除缓存的情况:

  • 先删除缓存,再修改数据库

在并发量不高的情况下没有什么问题,就算是第一步删除缓存成功,第二步写数据库失败也不会产生脏数据,但如果系统并发量较高,先删除了缓存,此时数据库的更新还没有提交,此时又来了一个请求,从数据库中读取到的还是旧的数据,再将旧数据写入缓存就会造成数据不一致的问题

  • 先修改数据库,再删除缓存

如果第一步写数据库成功,第二步删除缓存失败,这样会产生脏数据,其实如果不考虑操作失败的情况下,这种方案是最靠谱的方案,但在极小概率的情况下仍然会产生脏数据:

  1. 缓存中的 Key 失效
  2. 请求 A 从数据库中读取值准备写入缓存
  3. 此时请求 B 修改数据库的值,并删除缓存
  4. 请求 A 将旧值写入缓存中

不过实际情况下,出现的概率极小,因为要满足缓存失效的瞬间有两个并发的请求,一个读,一个写这样的条件才有可能出现。

针对上面的情况,可以采用延时双删的方案,即在执行完原流程后休眠若干时间(根据自己项目情况配置)再删除缓存,这样最多也就只有若干时间的数据不一致