[PAI精选]面试必考题:第一弹 Redis篇

Carlos 发布于 7 天前 24 次阅读


第一部分:缓存经典三剑客(穿透、击穿、雪崩)

1:什么是缓存穿透?怎么解决?

  • 面试官想问:如果黑客用不存在的 ID 恶意攻击,你怎么保护数据库不被打死?
  • 满分回答模板:缓存穿透是指查询的数据在 Redis 中没有,在数据库(MySQL)中也没有。导致每次请求都会直接穿透缓存打到数据库上,极易引发数据库连接爆满而崩溃。我们团队在实际项目(如短链系统)中,主要通过两种组合拳来解决:
    1. 布隆过滤器(Bloom Filter):在访问缓存之前筑起第一道防线。将所有可能存在的数据哈希映射到一个足够大的 Bitmap 中。如果布隆过滤器判定数据不存在,直接返回 404,绝对不打扰 Redis 和 MySQL。
    2. 缓存空对象(Null Value Padding):如果某个请求穿过了布隆过滤器,但在查库后发现确实没有,我们会在 Redis 里写入一个特殊标志(如空字符串或特定 JSON),并设置一个极短的过期时间(如 60 秒)。这样在接下来的 60 秒内,相同的恶意请求会直接在 Redis 中被拦截。

2:什么是缓存击穿?怎么解决?你笔记里说的“存储 JSON 字符串”和逻辑过期是啥意思?

  • 面试官想问:超级爆款数据(热点 Key)失效的瞬间,成千上万请求涌入,你怎么解决?分布式锁和逻辑过期的取舍是什么?
  • 满分回答模板:缓存击穿是指数据库里有这个数据,但是 Redis 里的热点 Key 刚好过期了。这一瞬间成千上万的并发请求同时涌入,发现 Redis 没数据,就会全部去 MySQL 查数据,导致数据库瞬间被压垮。我们在架构设计时,会根据业务场景在以下两种方案中做取舍:
    1. 互斥锁方案(Redisson 分布式锁):只有拿到锁的那一个线程去查库并回填缓存,剩下的 999 个线程原地自旋等待。这种方案保证了数据的强一致性,但由于线程需要等待,高并发下的吞吐量和用户体验会受影响。
    2. 逻辑过期方案(你笔记里的“存储 JSON 字符串”)
      • 解答你的疑问:在 Redis 存储值时,我们不设置 Redis 自身的 TTL(让它永远不物理过期)。而是把原本的对象和我们人为定义的expire_time(逻辑过期时间戳)打包成一个对象,序列化成 JSON 字符串存进 Redis(即普通的 K-V 结构,Value 是个 JSON)。
      • 怎么解决击穿:当成千上万请求进来时,发现该 JSON 里的逻辑时间已经过期了。此时,这 1000 个请求不用原地等待,立刻读取当前 Redis 里的旧数据返回(用户体验极佳,零卡顿)。同时,只有一个线程成功拿到互斥锁,开启一个异步线程去后台默默查询 MySQL 并更新 Redis 里的 JSON 字符串,剩下的 999 个线程拿到锁失败也不等待,继续返回旧数据,直到后台异步更新完成。

3:什么是缓存雪崩?怎么解决?

  • 面试官想问:大批 Key 同时失效或者 Redis 整个集群直接挂了,导致流量暴洪,怎么防灾?
  • 满分回答模板:缓存雪崩是指在极短的时间内,Redis 里面大量的 Key 同时过期,或者 Redis 服务器直接宕机。导致原本应该由缓存扛住的请求全部涌向 MySQL,引发数据库雪崩。我们应对雪崩的工业级防线分为四层:
    1. 微观层(随机偏移量):给每个 Key 的过期时间(TTL)加上一个随机的扰动值(如基础 24 小时 + 随机 0~60 分钟),从根本上防止大量热点 Key 在同一秒钟集体失效。
    2. 流量层(降级与限流):利用 Sentinel 或 Hystrix 组件,在极端雪崩发生时开启限流和熔断,部分非核心接口直接返回友好提示(服务降级),保住数据库的命。
    3. 架构层(构建高可用):部署 Redis Cluster 集群 架构,搭配主从复制和哨兵,防止单机 Redis 宕机引发的毁灭性雪崩。
    4. 互补层(多级缓存):引入 Caffeine 等 JVM 本地缓存 作为一级缓存,Redis 作为二级缓存。即使 Redis 集群整体瘫痪,本地内存依然能扛住绝大部分的读压力。

第二部分:Redis 分布式锁底层演进(从 SETNX 到 Redisson 源码)

4:原始的 SETNX 加锁有什么痛点?SET key value EX 10 NX 以及 expire 是啥关系?

  • 面试官想问:你懂分布式锁的发展史吗?为什么加锁和设过期时间必须是一个动作?
  • 满分回答模板:早期的分布式锁利用 SETNX lock_key 1 来抢占。如果返回 1 代表抢锁成功,接着在代码里调用 expire(lock_key, 10)解答你的疑问:expire 就是设置过期时间的 Java 代码/Redis 命令)来给锁定一个 10 秒的闹钟,防止死锁。但这引发了原子性问题:如果执行完 SETNX 成功之后,Java 服务器刚好断电宕机了,没来得及执行 expire 命令。这把锁在 Redis 里就会变成“永不过期”的死锁,其他线程永远进不来。为了解决这个痛点,Redis 官方推出了合二为一的原子命令:SET lock_key 1 EX 10 NX。这行命令将“抢占(NX)”和“定闹钟(EX 10秒)”融合成了一个不可分割的原子操作,确保了即使客户端中途挂掉,锁也绝对会在 10 秒后自动释放,彻底根治了这一阶段的死锁 Bug。

5:即使加了 SET EX NX,锁过期时间不够(业务没执行完锁失效)以及锁被误删怎么解决?

  • 面试官想问:如果业务执行了 15 秒,但锁只有 10 秒,你怎么保证锁不失效?又怎么保证 A 线程不把 B 线程的锁删掉?
  • 满分回答模板:这引发了分布式锁的另外两个经典灾难:锁提前失效误删别人的锁
    1. 误删别人的锁:如果 A 业务太慢,10秒过期了。B 线程进来了重新加锁。A 业务执行完后调用 DEL,就会把 B 的锁给删了。
      • 解决方案:加锁时,Value 不能写死成 1,而是存入一个全网唯一的 ID(如机器 UUID + 线程 ID)。在解锁时,先 GET 出来看一下是不是自己的 ID,确认是自己加的锁才能执行 DEL。因为 GETDEL 是两步,我们必须使用 Lua 脚本 包裹它们,交给 Redis 执行以保证这一步的原子性。
    2. 业务时间超过锁时间(看门狗机制)
      • 为了彻底摆脱“人肉预估业务时间”的愚蠢做法,我们引入了 Redisson 客户端
      • 如果我们在加锁时没有显式指定过期时间,Redisson 会自动开启看门狗(Watchdog)机制
      • 看门狗会在后台启动一个定时任务,每隔 10 秒钟(默认锁超时时间 30 秒的 1/3)就会去检查当前线程是否还持有锁。如果持有,说明业务还没执行完,看门狗会使出一个 Lua 脚本,强行把这把锁的 TTL 重新刷新续期回 30 秒
      • 只要业务没完,锁就永远不死;如果应用宕机,看门狗线程物理消失,没人续期,这把锁最多撑 30 秒也会在 Redis 端自动释放,绝不产生死锁。

6:什么是可重入锁?Redisson 底层是怎么用 Hash 结构实现的?

  • 面试官想问:如果同一个线程的 A 方法拿到了锁,内部调用的 B 方法也要拿同一把锁,你怎么保证不自锁?
  • 满分回答模板:普通的 SETNX 锁是不可重入的,自己调用自己的子方法会把自己锁死。Redisson 为了解决死锁,将底层的 String 结构升级为了 Hash(哈希表) 结构。其底层设计极其精妙:
    • 大 Key:锁的名字(如 skyroute:lock:order)。
    • 小 Key(Field):当前服务器的 UUID + 线程 ID(用来辨别是不是自己人)。
    • Value:重入次数计数器。
    动作回放
    1. 首次加锁:执行 HSET lock_key UUID:ThreadId 1,加锁成功,计数器初始为 1
    2. 再次拿锁(可重入):同一线程进入子方法,再次请求这把锁。Redis 底层判断大 Key 存在,且小 Key 的身份证就是当前线程。Redis 直接放行,并将计数器加 1,Value 变成 2
    3. 释放锁:子方法执行完,释放锁,Value 减 1 变成 1(锁不删,因为外层还要用)。当最外层方法也执行完,Value 减 1 变成 0。底层的 Lua 脚本一旦检测到 Value 归零,立刻触发 DEL lock_key,彻底释放锁,让别的线程来抢。

第三部分:缓存与数据库一致性(大厂终极命题)

7:你怎么保证缓存和数据库的一致性?什么是延时双删?

  • 面试官想问:先改库还是先删存?为什么不选另一个?
  • 满分回答模板:在绝大多数互联网业务中,我们追求的是最终一致性,业界标准的起手式是 Cache Aside(旁路缓存) 模式,核心原则是“先更新数据库,再删除缓存”,并且是“只删除,不更新”(更新容易引发高并发下的无序覆盖导致脏数据)。
    • 反面教材:为什么不能“先删缓存,再改数据库”?因为在高并发下,线程 A 先删了缓存,还没来得及改数据库;线程 B 进来读数据,发现缓存是空的,就会去 MySQL 读出了老数据,并回写到了 Redis 里。接着线程 A 把 MySQL 改成了新数据。结果就是:MySQL 变成新值,Redis 永远卡在老值,产生严重的脏数据。
    • 什么是延时双删来补救?如果不小心或者因为特殊老旧业务写成了“先删缓存”,为了补救,就会采用延时双删。线程序列是:先删缓存 $\rightarrow$ 改数据库 $\rightarrow$ 让当前线程原地休眠 1~2 秒 $\rightarrow$ 再次出手删一次缓存。休眠的这一两秒,是为了别的线程把错读的脏数据写回 Redis 后,我们再一脚把这个脏数据补刀删掉。但由于让线程休眠会极大地拉低系统的吞吐量,我们在工业级高并发场景下一般不鼓励使用。

8:大厂生产环境中,如何完美保证缓存数据最终一致性,并做到对业务代码的 0 侵入?

  • 面试官想问:如果第二步删缓存失败了怎么办?你听过 Canal 吗?
  • 满分回答模板:无论是先改库还是延时双删,如果最后一脚去删 Redis 的时候,由于网络抖动或者 Java 程序崩了导致删除失败,一致性就破产了。为了在生产环境中彻底解决这个问题,我们通常采用 Canal 中间件 + RabbitMQ 的组合拳:
    1. 引入 MQ 保证删除成功:Java 业务代码更新完 MySQL 后,发一条消息给 RabbitMQ。由消息队列异步地去执行删除 Redis 的操作。利用 MQ 的可靠性投递和失败自动重试机制,如果删除 Redis 失败,MQ 会不断尝试,直到删除成功为止,保证最终一致性。
    2. 引入 Canal 实现 0 侵入(核心大招)
      • 什么是伪装成从库:MySQL 自身的主从复制靠的是 Binlog(二进制变更日志)。阿里开源的 Canal 中间件启动后,会利用 MySQL 的协议,把自己伪装成一台 MySQL 的 Slave(从库),连上主库大喊:“我是从库,请把最新的 Binlog 发给我!”
      • 0 侵入链路:主库把变更日志发给 Canal,Canal 监听到解析后,自动把变更事件推送给 RabbitMQ,再去清理 Redis。
      • 通过这种设计,我们的 Java 业务代码里不需要写任何一行清理缓存或发 MQ 的代码。业务层只管开开心心对 MySQL 做增删改,底层的缓存同步和容错全部被解耦到了基础设施层,实现了完美的 0 侵入

第四部分:Redis 高可用与集群架构(主从、哨兵、Cluster)

9:主从和单哨兵有什么单点故障和误判风险?哨兵集群如何投票选主?

  • 面试官想问:哨兵是从节点吗?它是怎么防止“脑裂”和“误判”的?
  • 满分回答模板:首先明确一点:哨兵(Sentinel)绝对不是从节点! 它是一个完全独立的、不存储业务数据的特殊 Redis 进程,它的唯一职责就是当“监工”。
    • 单哨兵的致命缺陷
      1. 单点故障:如果唯一的这台哨兵自己挂了,整个自动故障转移(HA)体系就直接瘫痪了。
      2. 误判风险:如果主节点好好的,只是单哨兵由于自己的网络抖动卡了一下(主观下线 SDOWN),它就会误以为主节点死了,从而盲目发起故障转移,强行把主节点撤职,引发集群震荡。
    • 哨兵集群与 Raft 选主:为了彻底根治误判,生产环境都是哨兵集群(至少3台)。当哨兵 A 联系不上主节点时(主观下线),它必须去询问其他哨兵。只有当半数以上(法定人数 Quorum)的哨兵都认为主节点断联了,才会判定为客观下线(ODOWN)。接着,哨兵集群内部会基于 Raft 协议 快速进行投票:谁的随机选举超时时间最先到期,谁就先发起提议拉选票。一旦某个哨兵拿到了多数哨兵的选票,它就会成为“带头大哥(Leader)”,由这个 Leader 去执行提议,把某台健康的 Slave 节点晋升为新的 Master,并通知给客户端,完美化解脑裂和误判风险。

10:Redis Cluster 是如何解决单机容量与写瓶颈的?什么是哈希槽与 Gossip 协议?

  • 面试官想问:大厂海量数据(如几百G)是怎么分布在 Redis 里的?怎么做到不停机扩容?
  • 满分回答模板:当单机内存容量遇到瓶颈,或者并发写压力太大时,大厂的标准解法是部署 Redis Cluster(集群模式)。它是一个去中心化的架构,去除了外挂哨兵,节点自己监控自己。
    1. 哈希槽机制(数据分片):Redis Cluster 固定的将整个集群的虚拟存储空间划分为了 16384 个哈希槽(Hash Slot)。这些槽位会被均匀或者根据性能分配到不同的 Master 节点上。当客户端写入一个 Key 时,内部会进行 CRC16(key) % 16384 的哈希计算,算出来的数字在哪台机器的槽位区间,数据就落在哪台机器上。
    2. 不停机动态扩缩容:因为有 16384 个虚拟槽作为中介,如果我们想在国庆、双十一前临时加两台服务器,集群可以在完全不停机、不影响线上读写的情况下,把部分槽位连同里面的数据动态迁移到新节点上,实现横向平滑扩容。
    3. Gossip 协议(无中心化闲聊):整个集群不需要老大。各个节点之间通过 Gossip(八卦)协议 异步地、定期随机找几个邻居交换彼此所知道的机器状态。就像小区里大妈传播八卦一样,某个节点挂掉的消息会在几毫秒内传遍全网。当多数节点达成共识,集群自己就能原地完成主从切换和高可用。

第五部分:Redis 底层高性能内功(单线程、IO多路复用、持久化)

11:Redis 是单线程的,为什么还那么快?什么是 I/O 多路复用?

  • 面试官想问:为什么单线程能扛住每秒十几万的并发?select 和 epoll 差在哪?
  • 满分回答模板:Redis 的快是一个系统性的设计结果,核心有三点:
    1. 纯内存操作:所有读写全在内存里完成,没有磁盘 I/O 的拖累,这是基础。
    2. 优秀的 C 语言底层数据结构:设计极其精巧(如 SDS 动态字符串、跳表 SkipList 等),源码级别把时间复杂度压到了极致。
    3. 核心:非阻塞的 I/O 多路复用机制
      • 以前的愚蠢做法:传统网络编程(BIO)是来一个连接就得开一个线程去死等。并发一高,服务器光是切换线程上下文就会直接卡死。
      • 多路复用(现在的聪明做法):Redis 利用 Linux 操作系统的 I/O 多路复用机制,只用一个主线程,就能同时盯住成千上万个网络连接(Socket)。哪个连接有数据发过来了,主线程才过去处理它;没数据时就歇着,绝不浪费 CPU。
      • 底层 select 与 epoll 的代差:以前老的 select 机制很笨,有连接来数据了,它只告诉你有动静,但不知道是谁,主线程必须用一个 for 循环把一万个连接挨个查一遍(时间复杂度 $O(N)$)。而现代 Linux 提供的 epoll 机制 贼聪明,哪个 Socket 有数据,它会精准地把那个 Socket 拎出来直接递给 Redis 主线程,主线程拿过来直接处理(时间复杂度 $O(1)$),所以并发量再大,Redis 的效率也高得恐怖。

12:详解 Redis 的 RDB 和 AOF 持久化机制?什么是混合持久化?

  • 面试官想问:内存数据怎么落盘?高并发下怎么在“速度”与“安全”之间做权衡?
  • 满分回答模板:Redis 的持久化主要有 RDB(快照)和 AOF(追加日志)两种经典方式:
    1. RDB(内存快照):就像定时给当前内存拍一张全景全量照片,保存为紧凑的二进制文件。
      • 优点:文件体积小,恢复速度极快。
      • 缺点:它是周期性拍照的(如每5分钟),如果 Redis 意外宕机,最后一次拍照到宕机期间的所有新数据都会彻底丢失。
    2. AOF(追加日志):就像记流水账日记。Redis 执行的每一条写命令,都会像追加日志一样写到 AOF 文件里。
      • 优点:数据安全性极高,通常配置为每秒刷盘一次(everysec),宕机最多只丢 1 秒数据。
      • 缺点:日志文件会变得异常巨大。虽然有“AOF 重写机制”可以扫描内存自动瘦身(把多条改动精简为一条结果命令),但重启时要把所有的命令重新跑一遍,数据恢复速度慢到令人发指。
    3. 大厂标配:混合持久化(Redis 4.0+):在实际大厂生产环境里,我们一般选择开启混合持久化。在执行 AOF 重写时,把当前的内存数据以 RDB 的二进制快照格式放在文件的开头,而重写期间新增的写命令则以 AOF 的文本格式追加在后面。这样,Redis 在重启恢复数据时,前半部分直接抄二进制快照(极快),后半部分重放少量命令(极安全),兼顾了 RDB 的速度和 AOF 的安全。

13:谈谈 bgsave 拍快照时,底层的 forkCOW(写时复制)机制是怎么工作的?

  • 面试官想问:Redis 在边拍快照边写磁盘的时候,主线程还能接收写请求改内存,这难道不会把照片拍糊(数据污染)吗?
  • 满分回答模板:这是操作系统层面的神级设计。当 Redis 需要执行 bgsave 生成 RDB 文件时,主线程绝对不会亲自去写磁盘,而是会调用操作系统的 fork() 函数,创建一个 子进程 去后台默默写磁盘。
    • 瞬间完成的 forkfork 在创建子进程时,并不会把几百兆或几个 G 的物理内存真的全量拷贝一份(那会直接卡死主线程),而是只拷贝了虚拟内存的映射页表。页表很小,所以瞬间就能完成,此时父子进程在物理上是共享同一块物理内存的。
    • 写时复制(Copy On Write - COW)如何防止照片拍糊:由于共享内存,如果子进程在写磁盘的过程中,主线程突然收到一个新的写请求要修改 Key1 的值。此时操作系统会触发 COW 机制:它不会直接让主线程在原来的地方改,而是Key1 所在的这一块物理内存页,偷偷复制出来一个副本,让主线程在副本上进行修改。而子进程指针指向的依然是原本那个没被污染的旧物理页。通过这种写时复制机制,子进程看到的数据永远停留在 fork 出来的那一瞬间(保证快照完整性),而主线程又能开开心心地继续处理读写请求,实现了真正的高并发非阻塞。

第六部分:基础扩充(高级数据结构与淘汰自旋)

14:Redis 都有哪些数据类型?内存满了怎么办?

  • 面试官想问:基础扎实吗?手写过 LRU 吗?
  • 满分回答模板
    1. 五大数据类型:String(字符串)、List(列表)、Hash(哈希表)、Set(集合)、ZSet(有序集合)。
    2. 大厂高级加分项:在项目中我们还会用到高级结构。比如 BitMap(位图),用 0 和 1 的位操作做几千万用户的日签到统计,一个用户一年才花几十个比特,极其节省昂贵的 Redis 内存;还有 HyperLogLog,采用概率算法,用极小的、固定 12KB 的内存就能统计出千万级网站的 UV(独立访客数),允许微小的误差,性价比极高。
    3. 内存淘汰策略:当 Redis 内存超过了配置的 maxmemory 时,会触发淘汰机制。大厂最常用的策略是 volatile-lru / allkeys-lru(淘汰最久没用过的数据)LFU(淘汰使用频率最低的数据)
    4. 手写 LRU 算法(大厂高频面试手写题):如果面试官让我手写一个 LRU,其核心结构必须是 哈希表(HashMap) + 双向链表(Doubly LinkedList)
      • 哈希表:用来保证 get(key) 的查找时间复杂度是 $O(1)$。
      • 双向链表:用来维护数据的访问时序。每次数据被访问了(无论是被修改还是被读取),就把它挪到链表的头部(代表最新鲜)。当内存满了要淘汰时,直接把链表尾部(最久没用过)的节点咔嚓掉,并同步从哈希表中删掉。

15:谈谈 CAS 自旋在 Java 并发包(JUC)和 Redisson 中的底层应用?

  • 面试官想问:你懂“无锁化”高并发的设计思想吗?
  • 满分回答模板CAS(Compare And Swap - 比较并交换) 是一种乐观锁、无锁化的核心思想。在高并发组件的底层,为了避免线程被操作系统挂起、切换上下文带来的巨大开销,广泛使用了 CAS 自旋重试 机制:
    1. 在 Java 官方的 JUC 并发包中:诸如 AtomicIntegerLongAdder 等原子类,其底层的 incrementAndGet() 方法,核心就是一个 do-while 死循环。内部调用操作系统的 CPU 原语指令。如果多个线程同时改,只有一个能成功,失败的线程绝对不阻塞,而是在 do-while 循环里疯狂自旋,拿着新值重新尝试改,直到成功为止。保证了极高的线程安全与高并发效率。
    2. 在 Redisson 分布式锁中:当我们调用 tryLock(maxWaitTime) 去抢锁时,如果锁被别人占了,Redisson 并不是用一个粗暴的 while(true) 去死循环空转(那会把 CPU 直接飙到 100% 跑满)。
      • Redisson 底层会利用 Redis 的 发布订阅(Pub/Sub)机制,当前线程先订阅这个锁释放的消息。
      • 在等待期间,它会结合 Semaphore(信号量) 机制让线程进行有限度的自旋或等待。一旦锁释放了,Redis 会发出通知,唤醒当前线程,线程再次通过 CAS 机制去尝试修改 Redis 的 Hash 计数器。这种 CAS 自旋与异步通知的完美结合,既免去了线程在操作系统层面被挂起、唤醒的上下文切换昂贵成本,又优雅地保护了客户端的 CPU 不被空转榨干。
✨职务:华夏大地区域代理人 | 熬夜秃头项目主理人 💳黑卡:校园一卡通全球辅导版持有者 📍地点:宇宙-银河系-地球-东北蹲分部 🥂生活方式:沉迷于廉价多巴胺 | 致力于在该醒的时候睡觉 🚫拒绝:拒绝早起 | 拒绝内卷| 拒绝借钱 简介:虽然我没钱,但我有时间;虽然我没才华,但我有脾气。
最后更新于 2026-06-02