又到了金三银四跳槽季,好多同学已经开始行动了。今天我来助力一把,送出这套 Redis 面试题,助力大家通关。
图片来自 Pexels
Redis 为什么响应快
Redis 数据保存在内存中,读写操作只要访问内存,不需要磁盘 IO。
如下:
Redis 的网络 IO 和数据读写使用单线程模型,可以绑定 CPU,这避免了线程上下文切换带来的开销。
注意:Redis 6.0 对网络请求引入了多线程模型,读写操作还是用单线程。
Redis 多线程网络模型见下图:
Redis 采用 Epoll 网络模型,如下图:
内核会一直监听新的 socket 连接事件的和已建立 socket 连接的读写事件,把监听到的事件放到事件队列,Redis 使用单线程不停的处理这个事件队列。这避免了阻塞等待连接和读写事件到来。
这些事件绑定了回调函数,会调用 Redis 的处理函数进行处理。
Redis 底层数据结构
Redis 有 5 种数据类型,包括:字符串、列表、集合、有序集合和字典。
Redis 底层的数据结构有 6 种,包括:动态字符串、双向链表、压缩列表(ziplist)、hash 表、跳表(skip list)和整数数组。
Redis 数据类型和底层数据结构有如下对应关系:
底层数据结构是动态字符串。
如果同时满足下面条件,就使用压缩列表,否则使用双向链表:
压缩列表在内存中是一块儿连续的内存空间,结构如下:
压缩列表查找时间复杂度是 o(n)。
如果同时满足下面条件,就使用有序整数数组,否则使用 hash 表:
如果同时满足下面 2 个条件,就使用压缩列表,否则使用跳表:
注意:有序集合还有一个 HASH 表用于保存集合中元素的分数,做 ZSCORE 操作时,查询的就是这个 HASH 表,所以效率很高。
跳表的结构如下:
如果不加索引,查找 10 这个数字需要查询 10 次,使用了二级索引,查找 10 这个数字需要 5 次,而使用一级索引,需要查询 3 次。
跳表的每一层都是一个有序链表,最下面一层保存了全部数据。跳表插入、删除、查询的时间复杂度是 o(logN)。跳表需要存储额外的索引节点,会增加额外的空间开销。
如果同时满足下面 2 个条件,就使用压缩列表,否则使用 hash 表:
Redis 缓存淘汰策略
Redis 总共有 8 种淘汰策略,如下图:
volatile-lfu 和 allkeys-lfu 策略是 4.0 版本新增的:
Redis 数据持久化
Redis 持久化的方式有 2 种,一种是写后日志(AOF),一种是内存快照(RDB)。
AOF 日志记录了每一条收到的命令,Redis 故障宕机恢复时,可以加载 AOF 日志中的命令进行重放来进行故障恢复。
AOF 有 3 种同步策略,如下图:
如果不是对丢失数据特别敏感的业务,推荐使用 everysec,对主线程的阻塞少,故障后丢失数据只有 1s。
RDB 快照是一个内存快照,记录了 Redis 某一时刻的全部数据。
从 Redis 4.0 开始,AOF 文件也可以保存 RDB 快照,AOF 重写的时候 Redis 会把 AOF 文件内容清空,先记录一份 RDB 快照,这份数据以"REDIS"开头。
记录 RDB 内容后,AOF 文件会接着记录 AOF 命令。故障恢复时,先加载 AOF 文件中 RDB 快照,然后回放 AOF 文件中后面的命令。
Redis 主从同步时,主节点会先生成一份 RDB 快照发送给从节点,把快照之后的命令写入主从同步缓存区(replication buffer),从节点把 RDB 文件加载完成后,主节点把缓存区命令发送给从节点。
AOF 日志是用记录命令的方式追加的,这样可能存在对同一个 key 的多条命令,这些命令是可以合并成 1 条的。比如对同一个 key 的多个 set 操作日志,可以合成一条。
AOF 重写和 RDB 快照执行的过程中,Redis 都会 Fork 一个子进程来执行操作,子进程执行过程中是不是阻塞主线程的。
但是要注意 2 点:
注意:如果开启了内存大页,每次拷贝都需要分配 2MB 的内存。
Redis 高可用
下图是一个“一主二从三哨兵”的架构图:
从图我们可以看到哨兵之间、哨兵和主从节点之间、哨兵和客户端之间都建立了连接。
如果主节点挂了,哨兵集群需要完成主从切换,如下图:
下面我们依次来聊一下这 4 个步骤。
当一个哨兵监控到主节点下线时,就会给其他哨兵发送确认命令,其他命令会根据自己的判断回复"Y"或"N"。
如果有 n/2+1 以上数量的哨兵都认为主节点下线了,才会判定主节点下线。这里的n是哨兵集群的数量。
n/2+1 这个参数由 quorum 参数配置,比如有 5 个哨兵,这里一般配置成 3。也可以配置成其他值。
主节点被判定下线后,哨兵集群会重新选择新的主节点。
down-after-milliseconds 表示主从节点断开时间,10 表示次数,如果从节点跟主节点断开时间超过 down-after-milliseconds 的次数达到了 10 次以上,从节点就被淘汰了。
主节点有一个写偏移量 master_repl_offset,从节点也有一个偏移量 slave_repl_offset。
优先选择 slave_repl_offset 最接近 master_repl_offset 的从节点作为新的主节点。
所以,上图中偏移量为 114 的从节点优先被选为新的主节点。
第一个判断主节点下线的哨兵节点收到其他节点的回复并确定主节点下线后,就会给其他哨兵发送命令申请成为哨兵 Leader。
成为 Leader 的条件如下:
如果集群配置了 5 个哨兵,quorum 的值设置为 3,其中一个哨兵节点挂了,很有可能会判断到主节点下线,但是因为选举不出哨兵 Leader 而不能切换。
如果集群有 2 个哨兵,其中一个挂了,那必定选不出哨兵 Leader。
下面的图展示了哨兵一成功当选 Leader 的过程:
选出新主节点和哨兵 Leader 后,哨兵 Leader 会执行主从切换的操作。
完成后会做一些事件通知:
如果客户端的读请求会发送到从节点,可以正常处理。在客户端收到新主节点地址通知前写请求会失败。客户端可以采取一些应急措施应对主节点下线,比如缓存写请求。
为了能够及时获取到新主节点信息,客户端可以订阅哨兵的主节点下线事件和新主节点变更事件。
Redis 为什么变慢了
Redis 变慢了的原因有很多,总结一下有 11 个,见下图:
从图中看出,Redis 变慢原因主要有两类:阻塞主线程和操作系统限制。
这两个操作表面上看不阻塞主线程,但 Fork 子进程的这个过程是在主线程完成的。
Fork 子进程时 Redis 需要拷贝内存页表,如果 Redis 实例很大,这个拷贝会耗费大量的 CPU 资源,阻塞主线程的时间也会变长。
Redis 在 AOF 重写和 RDB 快照过程中,如果主线程收到新的写请求,就需要 CopyOnWrite。
使用了内存大页,即使 Redis 只修改其中一个大小是 1kb 的 key,也需要拷贝一整页的数据,即 2MB。在写入量较多时,大量拷贝就会导致 Redis 性能下降。
Redis 4.0 以后引入了 layfree 机制,可以使用子进程异步删除,从而不影响主线程执行。用 UNLINK 命令替代 DEL 命令,就可以使用子进程异步删除。
Redis 6.0 增加了配置项 lazyfree-lazy-user-del,配置成 yes 后,del 命令也可以用子进程异步删除。
如果 lazyfree-lazy-user-del 不设置为 yes,那 Redis 是否采用异步删除,是要看删除的时机的。
对于 String 类型和底层采用整数数组和压缩列表的数据类型,Redis 是不会采用异步删除的。
另外,如果 Redis 实例很大,也会造成 RDB 文件太大,从库加载时间长。所以尽量保持 Redis 实例不要太大,比如单个实例限制 4G,如果超出就采用切片集群。
除非是严格不能丢数据的场景,否则尽量不要选择 always 策略,推荐尽量选择 everysec 策略,如果对丢失数据不敏感,可以采用 no。
可以选择较快的淘汰策略,比如用随机淘汰来替换 LRU 和 LFU 算法淘汰。也可以扩大切片数量来减轻淘汰 key 的时间消耗。
操作系统没有能力分配内存的原因也可能是其他进程使用了大量的内存。
这个最好从运维层面进行监控。
为防止这种情况,可以把 Redis 绑定到一个 CPU 物理核。
设计排行榜功能
Redis 的 zset 类型保存了分数值,可以方便的实现排行榜的功能。
比如要统计 10 篇文章的排行榜,可以先建立一个存放 10 篇文章的 zset,每当有读者阅读一篇文章时,就用 ZINCRBY 命令给这篇文章的分数加 1,最后可以用 range 命令统计排行榜前几位的文章。
Redis 实现分布式锁
如下图,一个服务部署了 2 个客户端,获取分布式锁时一个成功,另一个就失败了。
Redis 一般使用 setnx 实现分布式锁,命令如下:
SETNX KEY_NAME VALUE
设置成功返回 1,设置失败返回 0。使用单节点分布式锁存在一些问题。
SET key value [EX seconds] [PX milliseconds] NX
Redis 单节点会有可靠性问题,节点故障后锁操作就会失败。Redis 为了应对单点故障的问题,设计了多节点的分布式锁,也叫红锁。
主要思想是客户端跟多个 Redis 实例请求加锁,只有超过半数的实例加锁成功,才认为成功获取了分布式锁。
如下图,客户端分别跟 3 个实例请求加锁,有 2 个实例加锁成功,所以获取分布式锁成功:
缓存雪崩、击穿、穿透
Redis 做缓存时,如果同一时间大量缓存数据失效,客户端请求会大量发送到数据库,导致数据库压力激增。
如下图:
应对方法主要有 3 个:
某个热点 key,突然过期了,大量请求发送到了数据库。解决方案是给热点 key 不设置过期时间。
某个热点 key,查询缓存和查询数据库都没有,就发生了缓存穿透。
如下图:
应对方法主要有 2 个:
数据倾斜
什么是数据倾斜?看下面这个面试题:如果 Redis 有一个热点 key,QPS 能达到 100w,该如何存储?
如果这个热点 key 被放到一个 Redis 实例上,这个实例面临的访问压力会非常大。
如下图,redis3 这个实例保存了 foo 这个热点 key,访问压力会很大:
解决方法主要有两个:
①使用客户端本地缓存来缓存 key。
这样改造会有两个问题:
②给热点 key 加一个随机前缀,让它保存到不同的 Redis 实例上。
这样也会存在两个问题:
Bitmap 使用
有一道经典的面试题,10 亿整数怎么在内存中去重排序?
我们先算一下 10 亿整数占的内存,Java 一个整数类型占四字节,占用内存大小约:
10亿 * 4 / 1024 / 1024 = 3.7G
占得内存太大了,如果内存不够,怎么办呢?
Bitmap 类型使用的数据结构是 String,底层存储格式是二进制的 bit 数组。假如我们有 1、4、6、9 四个数,保存在 bit 数组中如下图:
在这个 bit 数组中用 10 个 bit 的空间保存了四个整数,占用空间非常小。
再回到面试题,我们使用 bit 数组长度是 10 亿整数中 (最大值-最小值+1)。
如果有负数,需要进行一个转化,所有数字加最小负数的绝对值。比如 {-2, 0, 1, 3},我们转换成 {0, 2, 3, 5},因为数组下标必须从 0 开始。
要统计当天签到的员工只要用 BITCOUNT 命令就可以。
要统计当月全勤的员工,只要对当月每天的 Bitmap 做交集运算就可以。
命令如下:
BITOP AND srckey1 srckey2 srckey3 ... srckey30
srckeyN 表示第 N 天的打卡记录 Bitmap。
作者:jinjunzhu
编辑:陶家龙
出处:转载自公众号程序员jinjunzhu