Redis面经
Redis面经
Redis为什么快?
MemBased、单线程事件循环和 IO 多路复用
Redis数据类型
目前:五种基本、三张特殊
Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
Redis 还支持 3 种特殊的数据类型:Bitmap(位图)、HyperLogLog(基数,不精确)、GEO(地理)。
需要注意的是:String类型使用SDS而非C语言字符串实现。
缓存策略
旁路缓存
Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。Cache Aside Pattern 中服务端需要同时维系 db 和 cache,并且是以 db 的结果为准。
写:
- 先更新 db
- 然后直接删除 cache 。
读 :
- 从 cache 中读取数据,读取到就直接返回
- cache 中读取不到的话,就从 db 中读取数据返回
- 再把数据放到 cache 中。
读写穿透
Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。
异步缓存写入
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
Redis Module
Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特殊的需求。这些 Module 以动态链接库(so 文件)的形式被加载到 Redis 中,这是一种非常灵活的动态扩展功能的实现方式,值得借鉴学习!
Redis应用
Redis消息队列
不推荐用Redis做消息队列,如果要用,推荐stream,老版本用List简易代替。
Redis搜索引擎
Redis 是可以实现全文搜索引擎功能的,需要借助 RediSearch ,这是一个基于 Redis 的搜索引擎模块。但是有重要缺点:Redis基于内存,而内存大小往往是有限的,数据规模稍微大点就会让成本过高。而且不支持分布式、复杂查询等等。
如果要实现搜索引擎,推荐Elasticsearch。
Redis实现延时任务
基于 Redis 实现延时任务的功能无非就下面两种方案:
- Redis 过期事件监听
- Redisson 内置的延时队列
过期事件监听时间上是不准确的,而且如果集群部署可能出现重复消费的问题。
建议选择 Redisson 内置的 DelayedQueue 这种方案。
过期事件为什么不好?
__keyevent@0__:expired
是一个默认的 channel,负责监听 key 的过期事件。也就是说,当一个 key 过期之后,Redis 会发布一个 key 过期的事件到__keyevent@<db>__:expired
这个 channel 中。
我们只需要监听这个 channel,就可以拿到过期的 key 的消息,进而实现了延时任务功能。但是过期事件消息是在 Redis 服务器删除 key 时发布的,而不是一个 key 过期之后就会就会直接发布。而Redis采用的是定时+惰性删除的做法去删除过期key,而不是一到时间就直接删除。
而且Redis 的 pub/sub 模式目前只有广播模式,这意味着当生产者向特定频道发布一条消息时,所有订阅相关频道的消费者都能够收到该消息。
Redisson 延迟队列原理是什么?
Redisson 的延迟队列 RDelayedQueue 是基于 Redis 的 SortedSet 来实现的。SortedSet 是一个有序集合,其中的每个元素都可以设置一个分数,代表该元素的权重。Redisson 利用这一特性,将需要延迟执行的任务插入到 SortedSet 中,并给它们设置相应的过期时间作为分数。
Redisson 使用 zrangebyscore
命令扫描 SortedSet 中过期的元素,然后将这些过期元素从 SortedSet 中移除,并将它们加入到就绪消息列表中。就绪消息列表是一个阻塞队列,有消息进入就会被监听到。这样做可以避免对整个 SortedSet 进行轮询,提高了执行效率。
Redis持久化机制
使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据,或者是为了做数据同步。
RDB持久化
Redis 可以通过创建快照来获得存储在内存里面的数据在 某个时间点 上的副本。
Redis 提供了两个命令来生成 RDB 快照文件:
-
save
: 同步保存操作,会阻塞 Redis 主线程; -
bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
AOF持久化
与快照持久化相比,AOF 持久化的实时性更好。
开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf
中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync
策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。
AOF 持久化功能的实现可以简单分为 5 步:
- 命令追加(append):所有的写命令会追加到 AOF 缓冲区中。
- 文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用
write
函数(系统调用),write
将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。 - 文件同步(fsync):AOF 缓冲区根据对应的持久化方式(
fsync
策略)向硬盘做同步操作。这一步需要调用fsync
函数(系统调用),fsync
针对单个文件操作,对其进行强制硬盘同步,fsync
将阻塞直到写入磁盘完成后返回,保证了数据持久化。 - 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
- 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。
AOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式:write后立即同步(阻塞同步),由后台进程决定合适同步(默认1s),由操作系统决定何时同步(默认30s)。
Redis线程模型
对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。
具体线程模型看图
其中,IO多路复用是Linux的系统调用,可以通过一个线程监听多个socket,节约资源。
Redis内存管理
Redis过期key删除策略
惰性删除+定期删除:查询时检查是否过期+定期检查是否过期。
首先,在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O(1)),如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。
这就是惰性删除,然后除此以外就是定期删除。
简单的说,定时删除就是单独的开个循环,每几秒检测一次,但是Redis不只是这么简单,这个检测间隔是动态的。
Redis 的定期删除过程是随机的(周期性地随机从设置了过期时间的 key 中抽查一批),所以并不保证所有过期键都会被立即删除。这也就解释了为什么有的 key 过期了,并没有被删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
另外,定期删除还会受到执行时间和过期 key 的比例的影响:
- 执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。
- 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。
Redis事务
Redis 可以通过 MULTI
,EXEC
,DISCARD
和 WATCH
等命令来实现事务(Transaction)功能。
这个过程是这样的:
- 开始事务(
MULTI
); - 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行);
- 执行事务(
EXEC
)。
你也可以通过 DISCARD
命令取消一个事务,它会清空事务队列中保存的所有命令。
Redis性能
网络请求次数
瓶颈在网络部分,需要考虑如何减少网络传输次数。
使用批量操作, 可以视为有三种批量操作:
- 原生批量命令
- pipeline
- lua
Redis 中有一些原生支持批量操作的命令,比如:
-
MGET
(获取一个或多个指定 key 的值)、MSET
(设置一个或多个指定 key 的值)、 -
HMGET
(获取指定哈希表中一个或者多个指定字段的值)、HMSET
(同时将一个或多个 field-value 对设置到指定哈希表中)、 -
SADD
(向指定集合添加一个或多个元素)
另外,Redis还可以使用pipeline进行组合,将一批命令一起提交到Redis服务器。但pipeline不具有原子性,但实际上除了原生批量命令之外像lua、Redis Function都不是原子操作。
Key集中过期
定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。
如何解决呢? 下面是两种常见的方法:
- 给 key 设置随机过期时间。
- 开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。
大Key问题
如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
- 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
如何发现大Key?使用SCAN命令或分析RDB文件。
HotKey问题
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key) 。如重大的热搜事件、参与秒杀的商品。处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。
如何处理?
hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
- 读写分离:主节点处理写请求,从节点处理读请求。
- 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
- 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。
如何发现?
使用 Redis 自带的 --hotkeys
参数来查找。
慢查询
有一部分命令是O(n)的,有些甚至会全表扫描。在执行这些命令的时候,就会阻塞后面的请求。
缓存穿透
缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。
个人认为比较好的解决方案:IP/用户限流+参数校验。
缓存击穿
缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期) 。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
个人认为解决方案:数据预热、HotKey自动续期
缓存雪崩
缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。或者缓存服务宕机,也会产生这种情况。
设置随机失效时间:为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。