Redis相关面试题

本文为原创内容,转载请注明出处并附带原文链接。感谢您的尊重与支持!

你必须非常努力,才能看起来毫不费劲。


面试官: 分布式缓存常⻅的技术选型⽅案有哪些?

候选人

分布式缓存的话,使⽤的⽐多的主要是 MemcachedRedis。不过,现在基本没有看过还有项⽬使⽤ Memcached 来做缓存,都是直接⽤ Redis

面试官: 说⼀下 Redis 和 Memcached 的区别和共同点

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash,string 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
  3. Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持集群模式的
  4. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。
  5. Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。
  6. Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

面试官: Redis 五种常见数据结构以及使用场景分析(高频)

候选人:

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)

随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)

1. string(SDS与C字符串)

它是 Redis 最基础的数据结构,它可以存储文本、数字或二进制数据。它的特点是一个键对应一个值,且支持原子操作,比如递增(INCR)、递减(DECR)等,主要用于缓存简单的键值对数据,比如用户会话信息或计数器。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。

image-20250220231134849

拓展:SDS与C字符串的区别(源自《Redis设计与实现》 2.2节)

  1. 常数时间获取长度:C字符串需要遍历整个字符串来计算长度,复杂度为O(N)。而SDS通过len属性记录长度,获取长度只需O(1)。
  2. 防止缓冲区溢出:SDS在修改前会检查空间是否足够,若不足则自动扩展空间,避免了C字符串因空间不足导致的缓冲区溢出问题。
  3. 减少内存重分配:C字符串修改时总需重分配数组(长度与字符数直接相关),而SDS通过free属性记录未使用空间,解耦了字符串长度与数组长度的关联,减少了修改时的内存重分配次数。

2. list

它是一个有序的字符串集合,支持从两端插入和删除元素。它的特点是底层采用双向链表,插入和删除操作非常高效。另外,支持队列和栈的操作,比如先进先出(FIFO)或后进先出(LIFO)。常用于消息队列、任务调度等场景。

3. hash

它是一种键值对集合,适合存储对象。它的特点是允许在一个键下存储多个字段和值,类似于 Java 中的 Map。其底层实现为哈希表,适合存储结构化数据,比如用户信息。它支持对单个字段的操作,比如 HGETHSET

4. set

它是一个无序且不允许重复元素的字符串集合。它的特点是底层采用哈希表,在查找和插入操作的时间复杂度为 O(1)。且支持集合运算,比如交集(SINTER)、并集(SUNION)和差集(SDIFF)。常用于去重、标签系统等场景。

5. zset(跳表)

它也是一种集合,但它的每个元素都关联一个分数,元素按照分数排序。它的特点是在底层实现结合了跳表和哈希表,支持高效的范围查询。且元素唯一且有序,适合需要排序的场景,比如排行榜。还支持按分数范围获取元素,比如 ZRANGEZRANGEBYSCORE

跳表结构设计

链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。

那跳表长什么样呢?这里举个例子,下图展示了一个层级为 3 的跳表。

image-20250220231507561

image-20250311091842807

zset 的插入操作:

  1. 检查是否已存在:先查哈希表,若存在,则更新分数并调整跳表位置;若不存在,则在跳表中新增节点。
  2. 跳表插入:生成一个随机层数(Redis 使用幂次分布,最高层数 ZSKIPLIST_MAXLEVEL=32)。从头节点开始,逐层寻找合适的插入位置。在找到的位置插入新节点,并连接前后指针。
  3. 哈希表更新:将 valuescore 插入哈希表,保证快速查询。

zset 的删除操作:

  1. 从哈希表中删除该元素
  2. 在跳表中删除该元素: 遍历跳表,找到待删除元素的前驱节点;解除节点的前后指针链接,释放内存。
操作 主要数据结构 复杂度
插入 (ZADD) 跳表 + 哈希表 O(log n)
删除 (ZREM) 跳表 + 哈希表 O(log n)
查找 (ZSCORE) 哈希表 O(1)
排序 (ZRANGE) 跳表 O(log n + k)
计算排名 (ZRANK) 跳表 O(log n)

面试官: 微博上的热度排行榜用什么数据结构

候选人:

在设计微博排行榜时,可以利用 Redis 的 ZSet(有序集合)来实现高效的分数排序和管理。ZSet 允许以用户的得分作为分数(Score),用户ID作为元素的值,支持按照分数从高到低的排序。

如果分数相同,按照默认的排序规则会按照value值排序,但希望按照时间顺序排序,也就是分数相同的情况下先上榜的排在前面。

为了应对分数相同的情况,并满足“分数相同的用户按时间顺序排序”的需求,可以通过在分数中嵌入时间戳来解决。具体方法是将分数设置为一个浮点数,计算公式为:score = 分数 + 1 - 时间戳 / 1e13。其中,整数部分表示用户的实际得分,小数部分通过时间戳缩放后形成一个微小的值。由于时间早的时间戳较小,经过 1 - 时间戳 / 1e13 处理后,时间越早的小数部分越大,从而确保了在分数相同的情况下先上榜的用户排在前面。


面试官: Redis为什么用跳表而不用平衡树?

候选人:(相关阅读:揭秘!MySQL索引背后的秘密武器:B+树为何力压跳表,独领风骚?

对于这个问题,Redis的作者 @antirez 是这么说的:

  • 它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 BTree 占用更少的内存。

  • Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好。

  • 它们更易于实现、调试等。例如,由于跳表的简单性,我收到了一个补丁(已经在Redis master中),其中扩展了跳表,在 O(log(N)) 中实现了 ZRANK。它只需要对代码进行少量修改。

从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。

在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。

从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。


面试官: Redis 事务支持回滚吗?

候选人:

Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。

你可以将Redis中的事务就理解为 :Redis事务提供了⼀种将多个命令请求打包的功能。然后,再按顺序执⾏打包的所有命令,并且不会被中途打断。

然而,需要注意的是Redis的事务并非传统意义上的ACID事务,它并不保证原子性、一致性、隔离性和持久性。相反,Redis事务主要用于确保命令执行的顺序性。

拓展:Redis事务实现

Redis 可以通过 MULTIEXECDISCARDWATCH 等命令来实现事务(transaction)功能。

使用 MULTI 命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令将执行所有命令。

这个过程是这样的:

  1. 开始事务(MULTI)。
  2. 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行)。
  3. 执行事务(EXEC)。

你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。

WATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。


面试官:什么是缓存穿透 ? 怎么解决 ?(高频)

候选人

缓存穿透是指查询一个一定不存在的数据,如果在缓存和数据库中都不存在,每次这个值的查询请求都会穿透到数据库,可能导致数据库挂掉。

解决方案的话,我们通常都会用布隆过滤器来解决它,或者给缓存设个空值(null),并设置较短的过期时间。这样可以避免重复查询数据库。


面试官:好的,你能介绍一下布隆过滤器吗?(高频)

候选人

布隆过滤器主要是用于检索一个元素是否在一个集合中。

它的底层主要是先去初始化一个比较大数组,里面存放的二进制0或1。在一开始都是0,当一个key来了之后经过3次hash计算,模于数组长度找到数据的下标然后把数组中原来的0改为1,这样的话,三个数组的位置就能标明一个key的存在。查找的过程也是一样的。

当然是有缺点的,布隆过滤器有可能会产生一定的误判,我们一般可以设置这个误判率,大概不会超过5%,其实这个误判是必然存在的,要不就得增加数组的长度,其实已经算是很划分了,5%以内的误判率一般的项目也能接受,不至于高并发下压倒数据库。


面试官:什么是缓存击穿 ? 怎么解决 ?(高频)

候选人

缓存击穿的意思是对于设置了过期时间的key,缓存在某个时间点过期的时候,恰好这时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从数据库加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把数据库压垮。

解决方案有两种方式:

第一可以使用互斥锁:当缓存失效时,不立即去加载数据库,用 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行加载数据库的操作并回设缓存,否则重试get缓存的方法。

第二种方案可以设置当前key逻辑过期,大概是思路如下:

  1. 缓存数据带有过期时间:在缓存数据时,除了存储数据,还会存储一个逻辑上的过期时间
  2. 判断逻辑过期:当请求过来时,首先从缓存取数据。如果缓存还没到过期时间,直接返回数据。如果过期了,就返回旧数据(不是最新的),不马上查数据库
  3. 后台更新缓存:当缓存过期时,系统会开启一个后台线程去从数据库重新加载数据,更新缓存。这样不会让多个请求同时去打数据库,避免冲击。

当然两种方案各有利弊:

如果选择数据的强一致性,建议使用分布式锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题。

如果选择key的逻辑删除,则优先考虑的高可用性,性能比较高,但是数据同步这块做不到强一致。


面试官:什么是缓存雪崩 ? 怎么解决 ?(高频)

候选人

缓存雪崩意思是设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到数据库,数据库瞬时压力过重雪崩。与缓存击穿的区别:雪崩是很多key,击穿是某一个key缓存。

解决方案:

主要是可以将缓存失效时间分散开,设置不同的失效时间⽐如随机设置缓存的失效时间;

采⽤ Redis 集群,避免单机出现问题整个缓存服务都没办法使⽤;

采用是多级缓存,使用本地缓存作为一级缓存,Redis 作为二级缓存,减少对数据库的直接访问。


面试官: 说下三种缓存读写策略?

候选人:

Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。

Cache Aside Pattern 中服务端需要同时维系 DB 和 cache,并且是以 DB 的结果为准。

  • 先更新 DB
  • 然后直接删除 cache 。

:

  • 从 cache 中读取数据,读取到就直接返回
  • cache 中读取不到的话,就从 DB 中读取数据返回
  • 再把数据放到 cache 中。

在写数据的过程中,可以先删除 cache ,后更新 DB 么?

答案: 那肯定是不行的!因为这样可能会造成数据库(DB)和缓存(Cache)数据不一致的问题。为什么呢?比如说现在有两个请求,请求一和请求二。请求一写入数据先删除cache,然后去更新DB;请求二去读取数据时发现没有在cache中找到(因为被请求一删掉了),这个时候请求二就会读取DB中的数据。恰好这时, 请求一正在更新DB的过程中,请求二读取到了旧数据,并且返回旧数据到cache当中。 这样就导致了DB(新)和cache(旧)不一致的问题。

在写数据的过程中,先更新 DB,后删除 cache 就没有问题了么?

答案:理论上来说还是可能会出现数据不一致性的问题,不过概率非常小,因为缓存的写入速度是比数据库的写入速度快很多!在并发场景下,如果请求1更新数据库后未能成功删除缓存中的数据A,而请求2在此期间尝试读取数据A,可能会发生以下情况:

  • 请求1更新数据库成功,但删除缓存失败
  • 请求2尝试读取数据A,发现缓存中有旧数据A
  • 请求2返回旧数据A,而不是最新的数据A

现在我们再来分析一下 Cache Aside Pattern 的缺陷

缺陷 1:首次请求数据一定不存在 cache 的问题

解决办法:可以将热点数据可以提前放入 cache 中。

缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。

解决办法:

  • 数据库和缓存数据强一致场景 :更新 DB 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
  • 可以短暂地允许数据库和缓存数据不一致的场景 :更新 DB 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。

Read/Write Through Pattern(读写穿透)

Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。

这种缓存读写策略小伙伴们应该也发现了在平时在开发过程中非常少见。抛去性能方面的影响,大概率是因为我们经常使用的分布式缓存 Redis 并没有提供 cache 将数据写入 DB 的功能。

image-20250220231606531

image-20250220231618183

Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。

和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不存在 cache 的问题,对于热点数据可以提前放入缓存中。

Write Behind Pattern(异步缓存写入)

Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写

但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。

很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 DB 的话,cache 服务可能就挂掉了。

这种策略在我们平时开发过程中也非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。

Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。


面试官:如何保证Redis与MySQL的数据一致性?(超高频)

候选人

首先是直接操作数据库和Redis,它们实现简单,但可能在高并发场景中面临一致性问题,且影响系统性能。

  • 先写MySQL,再删除Redis,虽然保证了数据库一致性,但可能会出现缓存未及时更新的情况,导致短时间内缓存数据过期,影响系统的响应速度。
  • 通过Binlog异步更新Redis,阿里的中间件Canal 伪装成一个 MySQL 的从库,订阅binlog日志,并解析 binlog 日志中的数据变更事件。这种方案保证了高一致性,并且由于是异步的,可以较少地影响性能,适合对一致性要求较高的场景。

Canal 拿到的 binlog 数据长什么样子?
Canal 拿到的 binlog 数据,其实并不是原始的 SQL,而是经过结构化处理的“事件对象”,例如:

1
2
3
4
5
6
7
8
9
10
11
{
"database": "test_db",
"table": "user",
"type": "INSERT",
"data": {
"id": "101",
"name": "张三",
"age": "25"
},
"old": null
}

然后是使用Redis + Kafka实现缓存与数据库的一致性,在写入数据时,可以将操作信息发送到Kafka等消息队列,然后由消费者(可以是一个专门的服务)来处理数据库和缓存的同步。这种方案通过消息队列解耦了数据库和缓存的操作,确保两者的一致性。Kafka的可靠性保证了消息不丢失,因此可以保障一致性。

其次是使用Redis + TCC事务管理,TCC(Try-Confirm-Cancel)事务模型适用于分布式事务管理。在写入Redis和MySQL时,可以先在Redis进行预写操作(Try),然后确认MySQL的数据更新(Confirm),如果遇到失败,可以取消Redis的操作(Cancel)。这种方式通过分布式事务的处理,能够确保两者一致性,但需要额外的事务管理中间件,增加系统复杂度。

最后是使用分布式数据库中间件(如Sentinel, Canal等),Redis的高可用架构可以借助Sentinel实现主从复制,保证缓存的高可用性和一致性。与此同时,Canal可以作为MySQL的增量数据订阅工具,实时同步数据库变更到Redis缓存。通过这种方式,可以实现高效的数据一致性保障,但配置和维护较为复杂。


面试官:你听说过延时双删吗?为什么不用它呢?

候选人:延迟双删,如果是写操作,我们先把缓存中的数据删除,然后更新数据库,最后再延时删除缓存中的数据,其中这个延时多久不太好确定,在延时的过程中可能会出现脏数据,并不能保证强一致性,所以没有采用它。


面试官:redis做为缓存,数据的持久化是怎么做的?(怎么保证 Redis 挂掉之后再重启数据可以进⾏恢复)(高频)

候选人

在Redis中提供了三种数据持久化的方式:1、RDB 2、AOF 3、混合持久化

1)RDB是快照文件,它是把redis在内存中的数据库保存到磁盘里面,当redis实例宕机恢复数据的时候,方便从RDB的快照文件中恢复数据。Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsava 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

在实际项目中,如果 Redis 在 执行 RDB 快照时有新的写或更新操作,Redis 会使用 写复制机制(Copy-on-Write, COW)。也就是说,Redis 在 fork 一个子进程执行持久化时,主进程仍然可以处理写请求,而这些写请求会写到一份新的内存页上,不会影响子进程正在快照的数据。这种机制保证了数据一致性,但也会带来额外的内存开销,尤其是在写请求频繁时,对系统内存压力较大。

2)AOF是追加文件,当redis操作写命令的时候,都会存储这个文件中,当redis实例宕机恢复数据的时候,会从这个文件中再次执行一遍命令来恢复数据。在 Redis 中 AOF 持久化功能默认 是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:

1
2
3
4
// redis.conf
appendonly yes //表示是否开启A0F持久化(默认 no,关闭)
appendfilename "appendonly.aof" // AOF持久化文件的名称

在 Redis 的配置⽂件中存在三种不同的 AOF 持久化⽅式,它们分别是:

image-20250220231711198

  • 如果要高性能,就选择 No策略:
  • 如果要高可靠,就选择 Always 策略;.
  • 如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。

随着执行的命令越多,AOF 文件的体积自然也会越来越大,为了避免日志文件过大, Redis 提供了 AOF 重写机制,它会直接扫描数据中所有的键值对数据,然后为每一个键值对生成一条写操作命令,接着将该命令写入到新的 AOF 文件,重写完成后,就替换掉现有的 AOF 日志。重写的过程是由后台子进程完成的,这样可以使得主进程可以继续正常处理命令。

3)为什么会有混合持久化?RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。AOF 优点是丢失数据少,但是数据恢复不快。为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据


面试官:这两种方式,哪种恢复的比较快呢?

候选人:RDB文件是一个经过压缩的二进制文件,在保存的时候体积也是比较小的,它恢复的比较快,但是它有可能会丢数据。我们通常在项目中使用AOF来恢复数据,虽然AOF恢复的速度慢一些,但是它丢数据的风险要小很多,在AOF文件中可以设置刷盘策略,我们当时设置的就是每秒批量写入一次命令。


面试官:Redis的过期删除策略有哪些 ? (高频)

候选人:(源自《Redis设计与实现》 9.5节)

在redis中提供了三种数据过期删除策略:

第一种是定时删除,在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。对内存是最友好的,对CPU时间是最不友好的,影响服务器的响应时间和吞吐量。

第二种是惰性删除,放任键过期不管,但每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话就删除该键;如果没有过期,就返回该键。对CPU时间是最友好的,对内存是最不友好的,有内存泄漏的风险。

第三种是定期删除,每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。定期删除是前两种策略的一种整合和折中。

定期删除策略的难点是确定删除操作执行的时长和频率

  • 如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将 CPU 时间过多地消耗在删除过期键上面。
  • 如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况。

因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

Redis的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用。

Redis是如何判断数据是否过期的呢?

每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

  • 如果不在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

过期字典存储在 redisDb 结构中,如下:

1
2
3
4
5
typedef struct redisDb {
    dict *dict;    /* 数据库键空间,存放着所有的键值对 */
    dict *expires; /* 键的过期时间 */
    ....
} redisDb;

面试官:Redis的数据淘汰策略有哪些 ? (MySQL ⾥有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?)

候选人

仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致⼤量过期 key 堆积在内存⾥,然后就OOM了。怎么解决这个问题呢?答案就是:Redis 内存淘汰机制

image-20250220231802566

这个在redis中提供了很多种,默认是noeviction,不删除任何数据,内部不足直接报错。

这是可以在redis的配置文件中进行设置的,里面有两个非常重要的概念,一个是LRU,另外一个是LFU

LRU的意思是最少最近使用,用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。

LFU的意思是最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

我们在项目设置的allkeys-lru,挑选最近最少使用的数据淘汰,把一些经常访问的key留在redis中。

可以使用 config get maxmemory-policy 命令,来查看当前 Redis 的内存淘汰策略,命令如下:

1
2
3
127.0.0.1:6379> config get maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"

通过“config set maxmemory-policy <策略>”命令设置Redis 内存淘汰策略。


面试官:Redis的内存用完了会发生什么?

候选人

嗯~,这个要看redis的数据淘汰策略是什么,如果是默认的配置,redis内存用完以后则直接报错。我们当时设置的 allkeys-lru 策略。把最近最常访问的数据留在缓存中。


面试官:热 key 是什么?怎么解决?

Redis热key是指被频繁访问的key,可能会导致单个key的访问量过大,影响系统性能。解决方法包括:

• 开启内存淘汰机制,并选择使用LRU算法来淘汰不常用的key,保证内存中存储的是最热门的数据。

• 设置key的过期时间,确保key在一段时间后自动删除,防止长时间占用内存。

• 对热点key进行分片,将数据分散存储在不同的节点上,减轻单个key的压力。


面试官:普通的Redis分布式锁如何实现 ? (高频)

候选人:Redis 分布式锁实现步骤:

  1. 使用 SETNX 命令(SET if Not Exists)

    • SETNX 是 Redis 的一个命令,用来在一个键不存在的情况下设置其值。这个命令可以保证只有一个客户端能够成功地设置值,其他客户端会失败。这相当于锁的加锁操作。

    示例:

    1
    2
    3
    4
    > SETNX lockKey uniqueValue
    (integer) 1
    > SETNX lockKey uniqueValue
    (integer) 0

    setnx 返回 1 时,表示获取到锁;如果返回 0,表示锁已存在,未能获取到锁。

  2. 给锁设置过期时间,防止死锁

    • 如果客户端在持有锁期间发生崩溃或者网络异常,没有及时释放锁,其他客户端将永远无法获取到锁。这时可以设置一个自动释放锁的时间。
    1
    2
    127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
    OK
    • EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
  3. 释放锁(DEL)

    • 当客户端完成操作后,需要释放锁,通常通过 DEL 命令删除锁。
    1
    2
    > DEL lockKey
    (integer) 1
  4. 防止误删其他客户端的锁

    • 为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

    示例:

    1
    2
    3
    4
    5
    6
    // 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
    if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
    else
    return 0
    end

普通的Redis 分布式锁可以通过 SETNX 实现,配合设置过期时间防止死锁,并使用 Lua 脚本确保只有锁的持有者才能释放锁,保证释放锁的原子性。


面试官:那你如何控制Redis实现分布式锁有效时长呢?(高频)

候选人

普通的 Redis 分布式锁的缺陷:

我们在网上看到的redis分布式锁的工具方法,大都满足互斥、防止死锁的特性,有些工具方法会满足可重入特性。

如果只满足上述3种特性会有哪些隐患呢?redis分布式锁无法自动续期,比如,一个锁设置了1分钟超时释放,如果拿到这个锁的线程在一分钟内没有执行完毕,那么这个锁就会被其他线程拿到,可能会导致严重的线上问题,比如超卖。

的确,redis的setnx指令不好控制这个问题,我们当时采用的redis的一个框架redisson实现的。

在redisson中需要手动加锁,并且可以控制锁的失效时间和等待时间,当锁住的一个业务还没有执行完成的时候,在redisson中引入了一个看门狗机制,就是说每隔一段时间就检查当前业务是否还持有锁,如果持有就增加加锁的持有时间,当业务执行完成之后需要使用释放锁就可以了。

还有一个好处就是,在高并发下,一个业务有可能会执行很快,先客户1持有锁的时候,客户2来了以后并不会马上拒绝,它会自旋不断尝试获取锁,如果客户1释放之后,客户2就可以马上持有锁,性能也得到了提升。

image-20250225095502987


面试官: Redisson分布式锁如何实现?(高频)

候选人:

核心实现步骤:

  1. 创建 Redisson 客户端:通过 Redisson 配置连接 Redis。
  2. 获取锁:使用 RLock 获取分布式锁。
  3. 执行业务逻辑:在锁保护的临界区内处理业务。
  4. 释放锁:业务完成后,手动释放锁。

核心代码实现(Java):

  1. 引入依赖:

确保在你的 pom.xml 中已经添加了 Redisson 依赖:

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.22.0</version> <!-- 请根据需要选择最新版本 -->
</dependency>
  1. 实现分布式锁
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class RedissonLockExample {
public static void main(String[] args) {
// 1. 配置 Redisson 客户端
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(config);

// 2. 获取锁对象
RLock lock = redissonClient.getLock("myLock");

try {
// 3. 尝试加锁
if (lock.lock()) {
try {
// 4. 临界区 - 执行你的业务逻辑
System.out.println("Lock acquired, performing sensitive operations.");
Thread.sleep(5000); // 模拟处理时间
} finally {
// 5. 释放锁
lock.unlock();
System.out.println("Lock released.");
}
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 6. 关闭 Redisson 客户端
redissonClient.shutdown();
}
}
}

代码说明:

  1. 连接 Redis:通过 Config 配置 Redis 服务器地址,创建 RedissonClient 实例。

  2. 获取锁:使用 getLock("myLock") 获取一个分布式锁对象 RLock,其中 "myLock" 是锁的名称,不同线程和服务可以通过相同的名称竞争该锁。

  3. 尝试加锁:使用 lock(),表示拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制。

    1
    2
    // 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
    lock.lock(10, TimeUnit.SECONDS);
  4. 执行业务逻辑:模拟业务逻辑操作(如数据库操作、文件操作等)。

  5. 释放锁:通过 unlock() 手动释放锁。即使在业务执行时发生异常,finally 块确保锁能正常释放。

  6. 关闭 Redisson 客户端:在程序结束时调用 shutdown() 关闭 Redis 客户端,释放资源。


面试官:好的,redisson实现的分布式锁是可重入的吗?

候选人:是的,Redisson 实现的分布式锁是可重入的。这意味着同一个线程可以多次获取相同的锁,而不会被阻塞。每次获取锁时,锁的计数器会增加;当线程释放锁时,计数器会减少。只有当计数器降到 0 时,锁才真正释放,这样可以确保锁的持有者能够完成其工作,而不会被意外释放。

代码验证:

首先需要在pom.xml文件中添加redisson依赖。

1
2
3
4
5
6
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.22.0</version> <!-- 根据需要选择最新版本 -->
</dependency>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class ReentrantLockExample {

public static void main(String[] args) {
// 配置 Redisson
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6380");
RedissonClient redisson = Redisson.create(config);

// 获取可重入锁
RLock lock = redisson.getLock("reentrantLock");

try {
// 第一次获取锁
lock.lock();
System.out.println("Lock acquired once");

// 在同一线程中再次获取锁
lock.lock();
System.out.println("Lock acquired twice");

// 执行业务逻辑
System.out.println("Processing critical section...");

} finally {
// 释放锁,计数器减少
lock.unlock();
System.out.println("Lock released once");

// 再次释放锁,彻底释放
lock.unlock();
System.out.println("Lock released completely");
}

// 关闭 Redisson 客户端
redisson.shutdown();
}
}

输出结果:

image-20250225095520963


面试官: redisson实现的分布式锁能解决主从一致性的问题吗

候选人:这个是不能的,比如,当线程1加锁成功后,master节点数据会异步复制到slave节点,此时当前持有Redis锁的master节点宕机,slave节点被提升为新的master节点,假如现在来了一个线程2,再次加锁,会在新的master节点上加锁成功,这个时候就会出现两个节点同时持有一把锁的问题。

我们可以利用redisson提供的红锁来解决这个问题,它的主要作用是在多个redis实例上创建锁,并且要求在大多数redis节点上都成功创建锁,红锁中要求是redis的节点数量要过半。这样就能避免线程1加锁成功后master节点宕机导致线程2成功加锁到新的master节点上的问题了。

但是,如果使用了红锁,因为需要同时在多个节点上都添加锁,性能就变的很低了,并且运维维护成本也非常高,所以,我们一般在项目中也不会直接使用红锁,并且官方也暂时废弃了这个红锁。


面试官:好的,如果业务非要保证数据的强一致性,这个该怎么解决呢?

候选人: redis本身就是支持高可用的,做到强一致性,就非常影响性能,所以,如果有强一致性要求高的业务,建议使用zookeeper实现的分布式锁,它是可以保证强一致性的。


面试官:Redis集群有哪些方案, 知道嘛 ?

候选人:在Redis中提供的集群方案总共有三种:主从复制、哨兵模式、Redis分片集群。


面试官:那你来介绍一下主从复制

候选人:嗯,是这样的,单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,可以搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中。

图片


面试官:说一下主从复制?(高频)

候选人:主从复制共有三种模式:全量复制、基于长连接的命令传播、增量复制

1)全量复制是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:

第一:从节点请求主节点同步数据,其中从节点会携带自己的replication id和offset偏移量。

第二:主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致。

第三:在同时主节点会执行bgsave,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致。

当然,如果在rdb生成执行期间,依然有请求到了主节点,而主节点会以命令的方式记录到缓冲区,缓冲区是一个日志文件,最后把这个日志文件发送给从节点,这样就能保证主节点与从节点完全一致了,后期再同步数据的时候,都是依赖于这个日志文件,这个就是全量同步。

image-20250225095536335

2)命令传播指的是主从服务器在完成第一次同步后,双方之间就会维护一个 TCP 连接。后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。而且这个连接是长连接的,目的是避免频繁的 TCP 连接和断开带来的性能开销。这个过程被称为基于长连接的命令传播,通过这种方式来保证第一次同步后的主从服务器的数据一致性。

3)增量复制指的是,如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,当网络恢复正常之后,数据就不一致了。所以这个时候,从节点会请求主节点同步数据,主节点还是判断是不是第一次请求,不是第一次就获取从节点的offset值,然后主节点从命令日志中获取offset值之后的数据,发送给从节点进行数据同步。

拓展:怎么判断要执行的是全量同步还是增量同步呢?

在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此这个缓冲区里会保存着最近传播的写命令。

网络断开后,当从服务器重新连上主服务器时,主从服务器会根据两者的 offset 之间的差距,然后来决定对从服务器执行哪种同步操作:

  • 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;

  • 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。


面试官:怎么保证Redis的高并发高可用

候选人:首先可以搭建主从集群,再加上使用redis中的哨兵模式,哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知;如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主;同时Sentinel也充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端,所以一般项目都会采用哨兵的模式来保证redis的高并发高可用。

图片


面试官:redis集群脑裂,该怎么解决呢?

候选人:嗯! 这个在项目很少见,不过脑裂的问题是这样的,我们现在用的是redis的哨兵模式集群的

有的时候由于网络等原因可能会出现脑裂的情况,就是说,由于redis master节点和redis salve节点和sentinel处于不同的网络分区,使得sentinel没有能够心跳感知到master,所以通过选举的方式提升了一个salve为master,这样就存在了两个master,就像大脑分裂了一样,这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将old master降为salve,这时再从新master同步数据,这会导致old master中的大量数据丢失。

关于解决的话,我记得在redis的配置中可以设置:第一可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据;第二个可以设置主从数据复制和同步的延迟时间不能超过xx秒,如果超过,主节点会禁止写数据。


面试官:redis的分片集群有什么作用

候选人:分片集群主要解决的是,海量数据存储的问题,集群中有多个master,每个master保存不同数据,并且还可以给每个master设置多个slave节点,就可以继续增大集群的高并发能力。同时每个master之间通过ping监测彼此健康状态,就类似于哨兵模式了。当客户端请求可以访问集群任意节点,最终都会被转发到正确节点。

图片


面试官:Redis分片集群中数据是怎么存储和读取的?

候选人

Redis 集群引入了哈希槽的概念,有 16384 个哈希槽,集群中每个主节点绑定了一定范围的哈希槽范围, key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,通过槽找到对应的节点进行存储。

取值的逻辑是一样的。


面试官: Redis 没有使⽤多线程?为什么不使⽤多线程?

候选人:

虽然说 Redis 是单线程模型,但是, 实际上,Redis在4.0之后的版本中就已经加⼊了对多线程的⽀持。

image-20250225095604217

不过,Redis 4.0 增加的多线程主要是针对⼀些⼤键值对的删除操作的命令,使⽤这些命令就会使⽤主处理之外的其他线程来“异步处理”。

⼤体上来说,Redis 6.0 之前主要还是单线程处理。那Redis6.0之前 为什么不使⽤多线程?

主要原因有下⾯ 3 个:

  1. 单线程编程容易并且更容易维护;
  2. Redis 的性能瓶颈不再 CPU ,主要在内存和⽹络;
  3. 多线程就会存在死锁、线程上下⽂切换等问题,甚⾄会影响性能。

面试官:Redis是单线程的,但是为什么还那么快?

候选人

官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:

图片

1、完全基于内存的,C语言编写

2、采用单线程,避免不必要的上下文切换可竞争条件

3、使用多路I/O复用模型,非阻塞IO


面试官:能解释一下I/O多路复用模型?(高频)

候选人:I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。

其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;

在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程


面试官: select、poll、epoll 的区别是什么?(高频)

候选人:

selectpollepoll 都用于 I/O 多路复用,但它们在实现方式和性能上存在明显区别。select 采用固定大小的 fd 集合(默认 1024,可调),每次调用都需要遍历整个集合,导致性能随 fd 数量增加而下降。poll 虽然去除了 fd 数量的限制,改用链表存储,但仍然需要线性遍历所有 fd,效率并未本质提升。

epoll 采用事件驱动模型,利用 红黑树 维护 fd,并通过 回调机制 仅监听活跃的 fd,避免了无意义的遍历,查询复杂度从 O(n) 降至 O(1),适合大并发场景。此外,epoll 提供 LT(水平触发)和 ET(边缘触发)两种模式,ET 需要开发者处理可能的 数据丢失问题,但在高并发下性能更优。

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出, 它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。


面试官: Redis 如何实现延迟队列?

候选人:

延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:

  • 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;
  • 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;
  • 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;

在 Redis 可以使用 有序集合(ZSet) 的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。

使用 zadd score1 value1 命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。

image-20250225095627187