Skip to content

Redis常见面试题

开始学习Redis辣!!!

先来张图吧

image

首先,什么是Redis?

Redis是一个开源的内存数据结构存储。用作数据库、缓存、消息代理和流引擎。redis可以通过定期把数据转储到磁盘或者将每个命令附加基于磁盘的日志来持久化数据。

Redis还包括交易、发布/订阅、Lua脚本、生命周期有限的密钥、LRU驱逐密钥、自动故障转移等

Redis是基于内存的数据库,读写速度非常快,常用于缓存、消息队列、分布式锁等场景。

Redis的数据类型有String、Hash、List、Set、Zset、Bitmaps、HyperLogLog、GEO、Stream、并且对数据类型的操作都是原子性的,因为是单线程执行命令,没有并发竞争的问题。

Redis和Memcached有什么区别?

共同点:

  1. 都是基于内存
  2. 都有过期策略
  3. 性能都很高

区别:

  • redis支持的数据类型更丰富,Memcached只支持最简单的key-value类型。
  • redis支持数据的持久化,但是memcached没有。
  • redis原生支持集群,memcached没有
  • redis支持发布订阅模型、Lua脚本、事务等功能,而Memcached不支持。

为什么用Redis作为MySQL的缓存?

主要是因为redis具备高性能和高并发两种特性。

高性能很好理解,因为Redis是基于内存的,读写速度很快。

高并发:单台设备Redis的QPS是MySQL十倍。能轻松破10w,MySQL的单体很难破1w。

Redis数据结构

Redis数据类型和使用场景分别是什么?

常见的是五种数据类型:String字符串,Hash哈希表,List列表,Set集合,Zset有序集合。

image

随着Redis版本的更新,后面又支持了四种数据类型:BitMap、HyperLogLog、GEO、Stream。

不同数据类型的应用场景:

  • String:缓存对象、常规计数、分布式锁、共享session信息等。
  • List:消息队列
  • Hash:缓存对象、购物车等。
  • Set:聚合计算(并集、交集、差集),比如点赞、共同关注、抽奖活动等。
  • Zset:排序,比如排行榜等。

后续新加的数据类型:

  • BitMap:二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等。
  • HyperLogLog:海量数据基数统计的场景,比如百万级网页UV计数等。(UV:unique visitor)
  • GEO:存储地理位置信息的场景
  • Stream:消息队列,相比于基于List实现的消息队列,特性为自动生成全局唯一消息ID,支持以消费组形式消费数据。

image

String实现

String类型底层数据结构主要是SDS,即为简单动态字符串,simple dynamic string。

  • 它相比于C样式的String,不仅可以保存文本数据,还可以保存二进制数据。因为SDS使用len属性而不是空字符判断字符串是否结束,并且SDS所有API都会以处理二进制的方式来处理SDS存放在buf[]数组里的数据。
  • SDS获取字符串长度时间复杂度是O(1),因为有len属性。
  • SDS的API是安全的,拼接字符串不会造成缓冲区溢出。空间不够会自动扩容

List

巴拉巴拉

Redis线程模型

Redis是单线程吗?

Redis单线程指的是“接收客户端请求->解析请求->数据读写->返回信息给客户端”这个过程是由主线程完成的,也是为什么常说Redis是单线程的。

但是实际上Redis不是单线程的。Redis在启动的时候,是会开启后台线程BIO的:

Redis2.6,会启动两个后台线程,分别处理关闭文件、AOF刷盘两个任务。

4.0之后有新的lazy free线程,用来异步释放Redis内存。

为什么Redis单线程还是这么快?

Redis的大部分操作都是内存中完成,Redis的瓶颈可能是系统的内存或者网络带宽而非CPU。

单线程模型可以避免多线程竞争,省去了线程切换的开销,也没用死锁问题。

Redis6.0之前为什么用单线程?

我们都知道单线程程序是无法利用多核CPU的。那么早期Redis的主要工作:网络IO和执行命令为什么还使用单线程呢?

官方给出的说法是:CPU并不是制约Redis性能表现的瓶颈所在。更多是内存大小和网络IO的限制。所以没有必要。

同时单线程的可维护性高。多线程模型虽然在某些情况下表现优异。但是它引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、加锁解锁、死锁等会造成性能损耗的场景。

虽然Redis的主要工作一直是单线程模型,但是在Redis6.0之后,也采用了多个I/O线程在处理网络请求。因为随着硬件性能的提升,Redis的瓶颈有时会出现在网络IO的处理上。

所以为了提高网络IO的并行度,6.0之后用多线程处理网络IO。但是注意,对命令的执行依然还是单线程的。

Redis持久化

Redis的读写操作都是基于内存的,所以性能才会高。但是当Redis重启之后,内存中的数据就会丢失了。那么为了保证内存中的数据不会丢失,Redis有持久化机制。

Redis一共有三种数据持久化的方式:

  • AOF日志:AOF就是append only file,仅追加文件。每执行一条写操作命令,把命令以追加的方式写入到一个文件里。
  • RDB快照:将某时刻的内存数据,以二进制方式写入磁盘。
  • 混合持久化方式:Redis4.0新增的方式,集成了AOF和RDB的优点

AOF日志如何实现?

Redis执行完写命令后,把命令以追加的形式写入到文件里,Redis重启时就读取该文件记录的命令,然后逐一执行命令进行数据恢复。

image

为什么先执行命令再写入日志?

  1. 避免额外的检查开销:先执行命令,再写日志,这样如果命令是错误的,肯定就执行失败,也就不会写日志了。

  2. 不会阻塞当前写操作命令的执行。

当然也会带来风险:

  1. 数据可能会丢失。命令执行完,准备写入日志时宕机,数据就丢失了。
  2. 可能阻塞其他操作:由于写操作命令执行成功后才记录到AOF,所以不会阻塞当前命令的执行。但是AOF日志的写入也是主线程执行的,会阻塞后续操作。

AOF的写回策略也有三种:有Always、Everysec、no。

always就是每执行一条写命令就写入一次AOF日志。性能最差,但是安全性最高 everysec就是先把写命令写入到AOF文件的内核缓冲区,然后每秒刷盘一次 no就是不由Redis控制写回硬盘的时间,而是交给操作系统。Redis只负责写入到AOF文件的内核缓冲区。

AOF日志过大怎么办?

因为每条写指令都会写入到AOF文件,长久以往肯定会导致日志过大。

所以Redis提供了AOF重写机制。当AOF文件大小超过阈值,那么就会读取当前Redis中的所有键值对,然后记录到AOF文件。完毕后,替换掉旧的AOF。

Redis重写AOF的过程是由后台子进程bgrewriteaof完成的。 好处:

  • 子进程重写期间,主进程可以继续处理命令请求,不会阻塞
  • 子进程有主进程的数据副本。如果使用线程,数据是共享的,多线程之间会有线程安全问题。 那么创建子进程的时候,操作系统会让父子线程先共享数据,而父子进程任意一个修改数据的时候才会实际上给子进程分配单独的数据空间。

但是这里又引入了一个问题。子进程在重写数据的时候,父进程又往AOF写入了数据,那么操作系统发生写时复制,这时就导致父子进程的数据不一致了。怎么办?

为了解决这个数据不一致的问题,Redis设置了AOF重写缓冲区。这个缓冲区在创建bgrewriteaof子进程的时候使用。

在子进程重写AOF文件的期间,Redis执行完一个写命令后,会同时写入AOF缓冲区和AOF重写缓冲区。

这里我有个思考,为什么要同时写入两个缓冲区?旧的AOF到最后都是要被替换的?为什么还要继续维护它?

是为了考虑到数据持久化的连续性!假如我们只写入到重写AOF缓冲区,那么假设在重写过程中,系统宕机了。此时内存全部清空,我们因为没有继续维护AOF缓冲区,而导致我们Redis只能恢复到重写服务启动前的状态,而丢失了重写过程中的数据库信息。

子进程重写完毕后,会给主进程发送一条信号。主进程收到信号后会调用一个信号处理函数,主要工作如下:

  • 将AOF重写缓冲区中的所有内容追加到AOF文件中,使得新旧两个AOF文件是一致的。
  • 新的AOF文件改名,覆盖现有的AOF文件。

RDB快照如何实现?

RDB,全称Redis Database

因为AOF记录的是操作命令而不是实际的数据。用AOF做故障恢复的时候,要把全量日志斗执行一遍。如果AOF很大,Redis的恢复操作就会很慢。

为了解决这个问题,Redis增加了RDB快照。记录的是实际数据。

因此在Redis恢复数据的时候,RDB会比AOF效率高。因为直接把RDB文件读入内存就可以。

RDB做快照时会阻塞线程吗?

Redis提供了两个命令生成RDB文件,save和bgsave,区别在于是否在主线程中执行命令。

RDB在执行的时候,数据可以修改吗?

可以的,执行bgsave过程中,Redis依然可以继续处理命令。关键在于写时复制计数。

执行bgsave的时候,通过fork原语创建子进程,此时父子进程共享同一片内存数据。因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是同一个。此时如果主线程执行读操作,则不会影响。

image

为什么会有混合持久化?

RDB优点是恢复数据块,但是快照的频率不好把握。频率太低,丢失数据就会变多,频率太高,性能有影响。

AOF的优点是丢失数据少,但是恢复比较慢。

为了集成两者的优点,Redis4.0提出了混合使用AOF日志和内存快照,也叫混合持久化。

混合持久化工作在AOF日志重写过程:开启混合持久化时,在AOF重写日志时,子进程会先将跟主进程共享的内存数据以RDB的方式写入到AOF文件。然后主进程处理的操作命令会被记录在重写缓冲区内。重写缓冲区内的增量命令会以AOF方式写入到AOF文件。

也就是说,使用了混合式持久化,在重写AOF开始时,直接把当前Redis的数据全部以RDB方式写入到AOF里面,此时如果再有新的写操作进来,就以AOF的方式加入到后面。

这样做的好处在于,重启Redis加载数据的时候,前半部分是RDB内容,这样加载的时候速度快。

缺点:可读性差,因为前面是RDB内容。并且不兼容4.0之前的版本。

Redis集群

Redis如何实现服务高可用?

要想设计一个高可用的Redis服务,一定要从Redis的多服务节点来考虑。比如Redis的主从复制、哨兵模式、切片集群。

主从复制

主从复制是Redis高可用的最基础的保证,并且采用读写分离机制。

主服务器可以同时读写,从服务器一般是只读,并接受主服务器同步过来的写操作指令,然后执行命令。

注意,Redis里面的主从复制是异步的,主服务器不会等从服务器返回同步成功的消息就会继续往下执行。所以有可能会发生主从不一致的情况。无法实现强一致性保证。

这里和MySQL不同。回顾一下MySQL的三种主从同步策略。一种是同步,也就是主服务器必须等所有从服务器都返回同步成功消息才会继续执行。这种方式的效率很低,几乎不用

一种是异步,也就是Redis这种。还有一种是半同步,只需要等待其中一个从节点返回成功,主节点就会正常往下执行。

哨兵模式

使用Redis主从服务的适合,如果主从服务器出现故障宕机,要手动恢复。为了解决这个问题,Redis增加了哨兵模式。因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

image

切片集群模式

当Redis缓存数据量很大,一台服务器无法缓存时,就需要使用Redis切片集群了。它把数据分布在不同的服务器上,降低系统对单个节点的依赖,从而提高Redis服务的读写性能。

Redis Cluster方案采用哈希槽,来处理数据和节点之间的映射关系。在Redis Cluster中,一个切片集群有16384个哈希槽,这些哈希槽类似于数据分区。每个键值对都会根据key,被映射到一个哈希槽中。

接下来的问题是,这些哈希槽怎么被映射到具体的Redis节点上?

  • 平均分配:创建集群时,Redis自动把所有哈希槽平均分布到集群节点上。
  • 手动分配:用cluster meet命令手动建立节点连接,组成集群,再用cluster addslots命令指定每个节点上的哈希槽个数。

集群脑裂导致数据丢失怎么办?

什么是脑裂?

一个人有两个大脑,那么到底受谁控制呢?

在Redis中,集群脑裂产生数据丢失是什么现象?

在Redis主从结构中,部署方式一般是一主多从。如果主节点网络突然发生问题,跟所有的从节点都失去联系,但是主节点和客户端的网络是正常的,这个客户端不知道Redis内部已经出现了问题。还在照常向这个失联的主节点写数据,但是主从之间网络出问题了,无法同步到从节点上。

这时,哨兵也发现主节点失联了,它认为主节点挂了(但是实际上只是失联)于是哨兵会在从节点中选出一个主节点。

那么这时!实际上就出现了两个主节点。

然后假如网络又恢复了,哨兵因为之前已经选举出主节点了,就会把原来的主节点降级为从节点A,然后A会向新主节点请求数据同步。

第一次同步是全量同步,此时从节点会清空掉自己的本地数据再做全量同步。所以问题出现了:客户端不知道主节点失联,往主节点发消息,但主节点恢复通讯后降级,清空自己的信息,向新主节点发起同步,客户端发送的数据丢失了!

解决方案

当主节点发现从节点下线或者通信超时,禁止主节点进行写数据,直接返回错误给客户端

Redis中有两个参数可以设置

  • min-slaves-to-write:主节点必须有至少x个从节点连接,少于这个数,主节点会禁止写数据。
  • min-slaves-max-lag:主从数据复制和同步延时不超过x秒,否则禁止主节点写数据。

这两个参数可以搭配使用。即使原主库是假故障,它也无法响应哨兵心跳,也不能和从库进行同步。这个主库就会被限制接收客户端请求了。

Redis过期删除与内存淘汰。

Redis使用的过期删除策略是什么?

每当我们对一个key设置过期时间时,Redis会把该key带上过期时间存储到一个过期字典。

当我们查询一个key时,Redis首先检查该key是否存在于过期字典中:

  • 如果不在,正常读取
  • 如果存在,获取key过期时间,跟当前系统时间进行比对判断是否过期

Redis使用的过期删除策略是惰性删除+定期删除

惰性删除策略

不主动删除过期键,每次访问时检查key是否过期,过期再删除。

优点:占用系统资源少,对CPU时间友好。 缺点:如果一个key过期了,但是一直没人访问,就会占据内存空间,对内存不友好。

定期删除策略

每隔一段时间随机从过期字典取出一定量key检查,删除其中的过期key。

流程:

  1. 从过期字典中随机抽取20个key。
  2. 检查并删除过期key
  3. 如果本轮检查的已过期key数量超过5个,也就是过期key数量在随机抽取key数量中占比大于25%,则继续重复步骤1,继续抽取。如果不满足,到此为止,等待下一轮检查

可以看到定期删除是循环的流程。

为了保证定期删除不会出现循环过度而卡死线程,默认执行时间不超过25ms。

image

优点: 能删除掉长时间未被访问的过期key,减少内存占用。

缺点: 难以确定删除操作执行的时常和频率。执行太频繁,CPU不友好,执行太少,过期key不能及时释放。

所以为了结合各自的优点,Redis选择结合这两种方式。

Redis持久化时,如何处理过期键?

RDB文件生成时,过期key不会保存到RDB中。RDB文件写入时,主节点不载入过期key,从节点则无视过期时间直接载入。但是也没什么影响,毕竟数据同步时从数据库要清空

AOF文件写入时,如果数据库某个过期key还没被删除,AOF就保留此过期key。过期后,AOF显式添加DEL命令来删除key。AOF重写节点,Redis中已过期的key不会被保存到重写后的AOF文件中。

Redis主从模式中,对过期键如何处理?

当Redis运行在主从模式下时,从库是不会进行过期扫描的。即使从库的key过期了,客户端访问从库也能正常获取到。

主库会在key到期时,在AOF文件里增加一条del指令,同步到所有的从库,从库执行这条del指令来删除过期的key。

Redis内存满了会发生什么

会触发内存淘汰机制。Redis最大内存是可配置的,是maxmemory。

Redis内存淘汰策略有哪些?

Redis内存淘汰策略共有八种,大体为不进行数据淘汰进行数据淘汰两类策略。

  1. 不进行数据淘汰的策略 noeviction:当运行内存超过限制,不淘汰任何数据,而是不再提供服务,直接返回错误。
  2. 进行数据淘汰的策略

在设置了过期时间的数据中淘汰:

  • volatile-random:随机淘汰设置了过期时间的键值对
  • volatile-ttl:优先淘汰将会更早过期的键值对
  • volatile-lru:设置了过期时间,且最久未使用的键值对
  • volatile-lfu:设置了过期时间,且最少被使用的键值对

所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值对
  • allkeys-lru:懂的都懂
  • allkeys-lfu:懂的都懂

Redis的LRU和LFU

Redis的LRU

LRU,即为least recent use,最近最少使用。传统的LRU通过链表实现,每次访问数据,就移动到表头。淘汰数据时就淘汰掉尾节点的。但是带来的问题是,需要用链表管理所有缓存数据,带来额外空间开销,同时大量访问操作会带来大量链表移动操作,很耗时,降低Redis性能。

Redis实现的LRU算法是一种近似LRU算法,目的是更好地节约内存。实现方式是:在Redis的对象结构体中添加一个额外的字段,记录最后访问时间。

当Redis进行内存淘汰时,会随机采样,取可配置的数量的值,淘汰掉最久没有使用的那个。

优点:不需要为所有数据维护一个链表,节省空间占用。并且访问数据不需要移动链表项,提升缓存性能。

但是LRU算法有个问题,无法解决缓存污染的问题。如果应用一次性读取了大量数据,而这些数据只会被读取一次,那么这些数据从被访问到最后被淘汰的时间很长,造成缓存污染。

因此,在Redis4.0之后,引入了LFU算法来解决这个问题。

LFU算法会记录每个数据的访问次数,淘汰时就淘汰最少被使用的数据。

Redis缓存设计

如何避免缓存雪崩、缓存击穿、缓存穿透?

缓存雪崩:同一时间Redis大量key失效,如果此时有大量用户请求打入,就会有大量请求直接访问数据库,严重可能造成宕机。

解决:

  • 随机打散缓存失效值。
  • 设置缓存不过期。也就是逻辑缓存。存储的value中设置一个时间戳,后端读取到Redis的数据过期了,先直接返回默认值或者旧的值,同时查询新的数据并且覆盖掉这个旧的键值对。

缓存击穿:业务通常会有一些热点数据被频繁访问。如果缓存中的某个热点数据过期了,此时有大量请求访问了该热点数据,就有大量请求打到数据库。

解决:

  • 互斥锁方案:Redis有原子的setNX方法,后台收到大量请求,每个线程先抢占锁,只有拥有锁的线程可以访问数据库,其他线程自旋等待拥有锁的线程把数据更新到Redis中。
  • 逻辑过期:巴拉巴拉。

缓存穿透:用户请求的数据既不在缓存中,也不在数据库中。场景一般是业务误操作,或者恶意攻击。

解决:

  • 非法请求限制:在API入口处判断请求参数是否合理,不合理拒绝请求。如布隆过滤器
  • 设置空值或默认值:给这些不存在结果的请求在Redis中缓存空值或者默认值。但是如果恶意用户大量请求不同参数,如果对每个参数都缓存对应默认值,Redis空间很快被占满。

如何设计一个缓存策略,可以动态缓存热点数据?

总体思路:通过数据最新访问时间来做排名,过滤掉不常访问的数据,只留下经常访问的数据。

以电商平台为例子:要求只缓存用户经常访问的Top1000商品。细节如下: 先通过缓存系统做一个排序队列,系统根据商品访问时间更新队列信息。系统定期过滤掉队列中排名最后的200个商品,然后再从数据库中随机读取出200个商品加入队列中。这样每次请求到达时,先从队列中获取商品ID,如果命中,就根据ID从另一个缓存数据结构中读取实际的商品信息,并返回。

常见的缓存更新策略?

共三种:

  • Cache Aside:旁路缓存策略。
  • Read/Write Through:读穿/写穿策略。
  • Write Back:写回策略

实际开发中,Redis和MySQL的更新策略是Cache Aside,其他的用不了。

Redis的大key如何处理?

大key指的并不是key大,而是value大。

一般情况下,String类型值大于10KB、Hash、List、Set、ZSet元素超过5000个称为大key

大key造成的影响:

  • 客户端超时阻塞:Redis处理命令是单线程,大key耗时
  • 网络阻塞:每次获取大key耗费网络流量较大。
  • 阻塞工作线程:用del删除大key会卡,没办法处理后续命令
  • 内存分布不均:集群模型在slot分片均匀情况下,部分有大key的Redis节点内存占用多,QPS大

Redis管道

管道技术是客户端提供的一种批处理技术,用于一次处理多个Redis命令。

image

基于Redis的分布式锁

实现我已经知道了,就是用setnx命令。

缺点:超时时间不好设置。如果锁的超时时间过长,影响性能。

如何合理设置超时时间?可以基于续约的方式。先给锁设置一个超时时间,然后启动一个守护线程,让守护线程一段时间后重新设置这个锁的超时时间。

Redis主从复制中的数据是异步复制的,这导致分布式锁的不可靠性。

Redis怎么解决集群下分布式锁的可靠性?

Redis官方设计了一个红锁。

它是基于多个Redis节点的分布式锁,即使有节点发生了故障,锁变量依然存在,客户端依然可以完成锁操作。

思路:让客户端和多个独立的Redis节点依次申请加锁,如果客户端可以和半数以上的节点成功完成加锁操作,那么就认为,客户端成功地获得了分布式锁。

wow!