新鲜出炉的 Redis 原味面试题,备战路上的一道好菜。
1. 为什么要用缓存
在使用传统的关系型数据库时,会存在下面两个问题:
- 第一,传统关系型数据库在面对复杂计算逻辑时的响应时间过慢,会导致系统整体吞吐量降低。
- 第二,传统关系型数据在面对高并发场景时可能会直接被打爆,从而致系统不可用。
基于以上两点原因,缓存应运而生。
缓存由于是内存操作,能大大提高响应时间,在系统性能优化方面十分常用。同时,对于高并发场景缓存的支持能力也远高于关系型数据库,如 MySQL 单机 QPS 到 2000 就会开始报警,而作为缓存使用的 Key-Value 型数据库 Redis 的单机读写能力据官网描述能达到 10w/s。
除此之外,使用缓存还有一个原因是当程序需要频繁访问一些更新频率较低的数据时,为了提高响应时间以及减少不必要的 IO 消耗,我们通常会使用缓存来存储这些数据。
2. 为什么选 Redis
目前市面上常见的缓存中间件有 Redis 和 Memcached,这两者也经常会拿来作对比。
区别 | Redis | Memcached |
---|---|---|
支持的数据类型 | String, list, hash, set, zset, bitmaps, HyperLogLog, Geospatial | String |
持久化 | 支持,有较好的容灾恢复机制 | 不支持(纯内存数据库) |
集群 | 原生支持(Redis Cluster) | 原生不支持(依靠客户端实现集群) |
线程模型 | 单线程多路 IO 复用模型(Redis 6.0 后引入多线程用于提高网络 IO) | 多线程,非阻塞 IO 复用的网络模型 |
过期策略 | 惰性删除+定期删除 | 惰性删除 |
其他功能 | 支持发布订阅模型、Lua 脚本、事务等功能 |
3. Redis 数据结构
TODO
String, list, hash, set, zset, bitmaps, HyperLogLog, Geospatial
4. Redis IO 模型
Redis 的网络模型是基于多路复用的 IO 模型(Reactor 模型)开发的以单线程运行的文件事件处理器(File Event Handler):
- 多路复用的 IO 模型允许 Redis 在只运行单线程的情况下,允许单线程同时监听大量客户端连接
- 文件事件分派器将监听到的客户端连接关联到对应的事件处理器(select/epoll)
- 事件处理器进行具体事件的处理及回调
5. Redis 线程模型
Redis 的单线程一般指的是它对网络 IO 和键值读写的操作采用单线程进行处理,而对持久化、异步删除、集群数据同步等操作是通过额外的线程(或子进程)执行的。
Redis 不使用多线程的原因:
单线程编程更易维护,且 Redis 的性能瓶颈主要是内存和网络而非 CPU
- 多线程虽能提高系统吞吐量,但处理共享变量的访问时会带来额外开销(并发访问控制)
- 线程上下文切换存在一定开销
- 非阻塞 IO 多路复用机制提高单线程吞吐率
在 Redis4.0 以后引入对多线程的支持:大键值的删除操作采用异步线程执行。
在 Redis6.0 以后引入对多线程的支持:提高网络 IO 读写性能。Redis6.0 的多线程默认是禁用的,需要通过修改 redis 配置文件 redis.conf
来启用多线程:
io-threads-do-reads yes # 启用多线程
io-threads 4 #设置线程数,否则多线程不生效
6. Redis 缓存淘汰策略
当内存不够用时,Redis 提供了一系列策略来保证新键值对的写入。
- 不淘汰策略:
- noeviction:缓存写满后直接返回错误
- 面向设置了过期时间数据的淘汰策略:
- volatile-random:随机删除设置了过期时间的数据
- volatile-ttl:面向设置了过期时间的数据,基于过期时间,先过期先删除
- volatile-lru:面向设置了过期时间的数据,基于 LRU 算法进行删除
- volatile-lfu:面向设置了过期时间的数据,基于 LFU 算法进行删除
- 面向所有数据的淘汰策略:
- allkeys-random:面向所有数据进行随机删除
- allkeys-lru:面向所有数据,基于 LRU 算法进行删除
- allkeys-lfu:面向所有数据,基于 LFU 算法进行删除
LRU(Least Recently Used,最近最少使用)算法,按照最近是否访问时间进行排序,最近访问时间越远,优先被删除。
LFU(Least Frequently Used,最少访问算法)算法,按照一段时间内访问频次进行排序,这段时间内访问频次越低,越容易被删除。
7. Redis 过期策略
对于设置了过期时间的键值对,Redis 有惰性过期+定期过期两大过期策略:
- 惰性过期:在查询数据时,先进行过期检查,若过期将删除数据并返回空
- 定期过期:定期从设置了过期时间的键值对中取一部分数据进行过期检查,并删除已过期的数据,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
Redis 采用惰性过期+定期过期的策略来实现键值对的过期是出于避免内存浪费及更好利用 CPU 的考虑:
- 若只采用惰性过期,虽然对 CPU 较友好,只在查询数据时才会对当前键进行过期检查,但极有可能浪费内存空间,如存在大量过期键且未被访问时,就会出现大量无用数据占用内存的情况。
- 若只采用定期过期,虽与定时删除相比对 CPU 更友好(若存在大量过期键会影响吞吐量),与惰性过期相比对内存更友好(会主动扫描部分过期字典中的键进行过期操作),但其执行删除操作的时长及定期周期较难定义,所以需要与惰性过期配合来工作。
8. Redis 事务
Redis 通过 MULTI, EXEC, DISCARD, WATCH
四个命令来支持事务机制,它们的作用分别是:
- MULTI:开启一个事务
- EXEC:提交事务,从命令列表中取出命令进行实际执行
- DISCARD:放弃未提交的事务,并清空命令队列
- WATCH:检测键值在事务执行期间是否发生变化,如有变化则当前事务放弃执行
Redis 能保证一致性和隔离性,但无法保证持久性和原子性(仅当事务中命令语法正确时能保证原子性)
9. Redis 持久化
Redis 提供了 RDB(Redis DataBase) 和 AOF(Append Only File) 两种持久化方案:
- RDB:在指定的时间间隔内将内存中的数据集快照写入磁盘, 恢复时是将快照文件直接读到内存里
- 两种 RDB 实现命令:
- save(阻塞主线程):手动持久化操作,由主线程执行 save 操作,其他操作全部阻塞
- bgsave(不阻塞主线程,默认策略):自动持久化操作,Redis 在后台创建子进程异步进行快照操作, 同时主线程还可以响应客户端请求
- 对于写操作,主线程基于写时复制技术(Copy-On-Write, COW),将要修改的数据复制一份副本,对副本进行修改,bgsave 子进程继续将原来的未修改数据写入 RDB 文件
- 主线程修改后的数据只能等待下一次 bgsave 才能记录到快照文件中
- 两种 RDB 实现命令:
- AOF:以日志的形式来增量记录每个写操作,将 Redis 执行过的所有写指令记录下来(先执行命令,再记录日志),只允许追加文件但不可以改写文件
- 写后日志好处:
- 避免出现记录错误命令的情况
- 不会阻塞当前写操作
- 写后日志潜在风险
- 日志可能丢失(刚执行完命令还没来得及写日志,系统就宕机了)
- 虽然不会阻塞当前写操作,但可能会对下一个操作带来阻塞风险
- AOF 写回策略(同步磁盘频率)
- always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘
- everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写回磁盘
- no,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 的内存缓冲区,由操作系统解决何时将缓冲区内容写回磁盘
- AOF 重写机制
- 目的:避免 AOF 追加写入文件过程中,文件越来越大的情况
- 原理:当AOF超过所设定的阈值(文件超过64M且为上次重写文件的两倍)时,Redis 就会 fork 出一条新锦成来进行 AOF 文件的重写,只保留可以恢复数据的最小指令集
- 可以恢复数据的最小指令集:若当前有三条对同一个键的写操作,只会保留最新版本,只将该最新版本生成为一条命令写入 AOF 文件
- 潜在风险点
- Redis 主线程 fork 创建子进程时,内核需要创建用户管理子进程的相关数据结构(进程控制块,PCB),并将主线程 PCB 拷贝给子进程,这个创建与拷贝的过程可能会阻塞主线程。具体表现为:拷贝时,子进程需要拷贝父进程的页表,这个过程的耗时与 Redis 内存大小有关。若 Redis 实例内存大,页表就会大,fork 时间过长可能就导致阻塞主线程。
- fork 的子进程与主线程共享内存,当主线程收到写操作时,会申请新的内存空间,若此时操作的是 bigkey,可能会因为申请大空间而被阻塞
- AOF 缺点:
- AOF 文件过大带来的性能问题
- AOF 比 RDB 占用更多的磁盘空间
- 文件过大后继续追加命令效率会变低
- 故障恢复速度缓慢
- 日志可能丢失(刚执行完命令还没来得及写日志,系统就宕机了)
- 虽然不会阻塞当前写操作,但可能会对下一个操作带来阻塞风险(AOF 日志也是主线程执行)
- AOF 文件过大带来的性能问题
- 写后日志好处:
10. Redis 主从复制
主从复制保证了 Redis 多实例的数据一致性,Redis 提供了三种主从复制的策略:
- 全量同步:
- 第一阶段:主从库间建立连接、协商同步。从库与主库建立连接,通知主库即将进行同步(psync),主库确认回复后,主从库间就可以开始同步了
- 第二阶段:主库将所有数据同步给从库(基于 RDB),从库先清空,再恢复成主库发送的 RDB 文件,此时主库会用专门的 replication buffer 内存记录 RDB 文件生成后的所有写操作
- 第三阶段:主库将第二阶段执行过程中新收到的写操作(replication buffer)发送给从库,从库重新执行该命令,从而完成主从同步
- 基于长连接的命令传播:
- 主从库完成全量复制后,会维护一个网络连接,用于主库将接受到的操作命令同步给从库,使用长连接可以避免频繁建立连接的开销
- 增量复制
- 基于环形缓冲区 repl_backlog_buffer 实现,主库会记录写位置,从库会记录已读位置,断连后又重新连接,从库会从上一个记录的已读位置开始继续同步命令
- 潜在问题
- 环形缓冲区主库数据被覆盖。缓冲区写满后,主库继续写入会导致覆盖之前的写入操作(该部分操作未同步至从库),从而导致主从库数据不一致。可通过调整 repl_backlog_size 参数,即调整缓冲区大小来解决
- 环形缓冲区从库已读位置数据被覆盖。断连时间过长,在 repl_backlog_buffer 的 slave_repl_offset 位置的数据被覆盖,会导致主从库进行全量复制
11. Redis 哨兵机制
哨兵机制是用于解决 Redis 主从复制模式下在主节点故障后,无法自动选举出新的主节点的问题。
哨兵进程的作用:
- 监控(Monitoring):检查主从库是否正常运行
- 主观下线:由哨兵进程周期性地给所有主从库发送 PING 命令,若未在规定时间响应,将被标记为下线状态
- 客观下线:哨兵集群中有 N/2+1 个实例判断主库为主观下线,则主库将被标记为客观下线
- 自动故障转移(Automatic Failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
- 如何选取新主库
- 从库当前状态与历史状态(从库与主库断连[时间超过最大连接超时时间 down-after-milliseconds]大于10次,将不会作为备选主库)
- 给从库打分
- 手动设置的从库优先级 slave-priority 高者得分高
- 与旧主库同步程度最高的从库得分高
- 当优先级与复制进度相同时,ID 号小的从库将会被选为新主库
- 如何选取新主库
- 通知(Notification):当主库故障及完成故障转移后,将新主库的连接方式通知客户端与其他从库
哨兵集群如何选举主哨兵:
- 拿到半数以上哨兵的赞成票(N/2+1)
- 票数需要大于等于哨兵配置文件中的 quorum 值(该值描述了可以标记主库为客观下线的最少哨兵数量)
12. Redis 常见问题
12.1 缓存穿透
缓存击穿是指要查询的 key 在 Redis 和数据库中都不存在,每次查询都未命中缓存,直接打到数据库,频繁请求从而压垮数据库。
缓存穿透的解决方案是:
- 对不存在的 key 也进行缓存,缓存默认值(NULL 或0),同时加上一个较短的过期时间(一般不超过5分钟)
- 使用布隆过滤器,当布隆过滤器判断 key 不在数据库中时,意味着数据库中比不存在此 key,不将请求打到数据库
- 实时监控 Redis 命中率,若发现命中率极速降低时,可设置黑名单限流
12.2 缓存击穿
缓存击穿是指 Redis 中缓存的单个热点 key 过期,此时若有大量对于该热点 key 的请求,可能会瞬间将数据库打爆。
缓存击穿的解决方案是:
- 使用互斥锁(setnx),当有大量对于热点 key 的请求过来时,若 Redis 未命中,基于互斥锁保证只有一个请求从数据库加载热点 key 数据
- 对热点 key 进行特殊处理,在有大量对于热点 key 的请求过来前,提前设置热点 key 同时加大过期时间
- 对热点 key 考虑不设置过期时间
12.3 缓存雪崩
缓存雪崩是指 Redis 中缓存的大量热点 key 同时过期,此时此时若有大量对于这些热点 key 的请求,可能会瞬间将数据库打爆。
缓存雪崩的解决方案是:
- 分散设置缓存过期时间,在设置缓存失效时间时,可以加一个随机数,保证不发生大量缓存同时失效的情况
- 设置过期标志更新缓存,基于缓存过期标志,使用另外的线程在后台更新将要过期的缓存数据
- 使用互斥锁(setnx),当有大量对于热点 key 的请求过来时,若 Redis 未命中,基于互斥锁保证只有一个请求从数据库加载热点 key 数据
- 非核心数据雪崩时可以考虑服务降级,直接返回预定义值或错误信息
12.4 缓存污染
缓存污染是指若存在大量访问一次之后就不再访问的缓存数据,会造成占用内存空间,从而导致缓存污染。
缓存污染的解决方案是合理地使用缓存淘汰策略,一般是 LRU(Least Recently Used,最近最少使用) 和 LFU(Least Frequently Used,最少访问算法)。
12.5 缓存一致性
缓存一致性是指数据库数据与缓存数据的一致性,这里的一致性包括了两种情况:
- 缓存命中时,要保证缓存数据与数据库数据一致
- 缓存未命中时,要保证从数据库加载的数据应该是最新版本(不能出现先将数据加载到缓存后,再更新数据库数据的情况)
要保证数据库数据与缓存数据的一致性,首先我们要知道为什么这两者中的数据会发生不一致的情况。我们已经知道缓存有三种读写策略,我们就以旁路缓存模式为例来讲述缓存一致性。
再复习一遍旁路缓存模式:对于读请求,先查询缓存,若未命中再查询数据库,数据库命中后先写入缓存再返回;对于写请求,直接写数据库,同时删除缓存,当下一次请求该数据时再加载到缓存中。
在描述旁路缓存模式时,我刻意模糊了写请求情况下写数据库与删除缓存的时序,因为不同的情况会有不同的问题,下面来具体分析:
- 先删缓存,再写数据库
- 若删缓存成功,写数据库失败:再有读请求时,缓存未命中,会从数据库中加载数据。对于数据一致性来说,这或许没啥问题,但此时若有大量读请求,就可能发生缓存击穿问题,严重的情况可能会打爆数据库。
- 若删除缓存失败,写数据库成功:这里缓存的值就与数据库的值不一致了,再有读请求时,缓存命中,且此时命中的是缓存中的旧值。
- 若写数据库成功,删除缓存也成功,在并发场景下可能存在问题:线程A删除缓存成功,此时有线程B发起读请求,发现缓存未命中,然后从数据库加载旧版本数据到缓存中,线程B操作结束。然后线程A继续进行写数据库操作。这种并发场景下就导致了缓存中数据与数据库中数据不一致。
- 先写数据库,再删缓存
- 若写数据库成功,删除缓存失败:与「先删缓存,再写数据库」的第二种情况一致。
- 若写数据库失败,删除缓存成功:与「先删缓存,再写数据库」的第一种情况一致。
- 若写数据库成功,删除缓存也成功,在并发场景下可能存在问题:线程A写数据库成功,此时有线程B发起读请求,缓存命中(此时为旧版本数据),线程B操作结束,然后线程A删除缓存。这种并发场景下就导致线程B读到的数据并非数据库中的最新版本数据,即发生缓存中数据与数据库数据不一致的情况
说完了缓存不一致的情况,接下来说说对于不同缓存不一致情况的解决方案:
- 重试机制:面对写数据库失败或删除缓存失败的情况时,可以基于重试机制对失败的操作进行重试,若超过一定次数后依旧失败,需要回滚数据库操作,手动回滚缓存数据,并抛出业务性异常。
- 延时双删:面对写数据库且删除缓存都成功但是在并发场景下时,由于两个操作之间并发串行,可能导致其他读操作发生在两者之间,从而导致缓存不一致现象。这种情况可以考虑使用延时双删,即先删缓存,再写数据库,然后延迟一定时间后再次删缓存来解决该问题。
需要注意的是,延时双删中的延迟一定时间,需要尽量保证在写数据库操作后再进行删除,这个时间一般只能估算。
若业务需要保证缓存与数据库的强一致性,则只能基于加锁来使得读写请求串行化,从而实现缓存强一致性。
13. 参考资料
- 《Redis 设计与实现》
- 《Redis 核心技术与实战》
- 《Redis 深度历险:核心原理与应用实践》
最后,本文收录于个人语雀知识库: 我所理解的后端技术,欢迎来访。