【Redis】Redis 面试深度系列

VibeLoop 系列:Spring Boot × Redis 面试深度系列

贯穿案例「VibeLoop」为虚拟的轻量级内容互动平台,仅用于技术演示,并非真实存在的产品。

上期速递:【Redis】分布式锁从青铜到王者

本文覆盖 RDB/AOF 持久化原理、主从复制、哨兵集群、Cluster 分片、VibeLoop 生产部署、8 道面试题、必背速查表。

目录


开篇场景:凌晨三点的宕机电话

20xx年 6 月 12 日,凌晨 3:17。

VibeLoop 运维群里炸了------"首页打不开了!"值班的张伟被电话叫醒,打开监控一看,Redis 进程没了。服务器半夜自动装了 Windows 安全补丁然后重启了。

他火速重启 Redis。RDB 文件在,数据恢复到了凌晨 3:00 的快照状态。用户登录 Session 没丢,但最近 17 分钟的热门帖子点赞数、实时评论、最新关注关系------全没了。

早上老板开会:"恢复速度还行,但整整 17 分钟的数据说没就没,下次能不能做到一点不丢?"

张伟把 appendonly yes 加上了。AOF 开启后,每条写命令都记到日志里。一个月后,AOF 文件涨到了 8GB,重启恢复要 4 分钟------老板又来问了:"Redis 不是号称微秒级响应吗?怎么重启越来越慢?"

加上运维组反馈:读流量越来越大了,Redis 单机 CPU 经常飙到 90%。张伟翻了翻 Redis 官方文档,发现后面还有一大串东西------主从复制、哨兵集群、Cluster 分片。

今天这篇文章,就沿着张伟踩过的坑,把 Redis 持久化到高可用这条路走通。


理论速览:持久化与高可用的四层金字塔

Redis 的单机高性能架构像一辆赛车:跑得快,但不出事还好,出一次事就致命。持久化和高可用就是给这辆赛车装上安全带 + 备用引擎 + 副驾驶 + 车队。

层级 能力 解决的问题 对应技术
L1 持久化 重启不丢数据 进程崩溃/宕机 RDB / AOF / 混合
L2 主从复制 读流量分流 + 数据冗余 单机读瓶颈 PSYNC 全量/增量
L3 哨兵集群 自动故障转移 主节点宕机无人值守 Sentinel Raft
L4 Cluster 数据分片 + 横向扩展 单机写瓶颈 / 内存天花板 哈希槽 16384

类比:L1 就像是给赛车装了行车记录仪(出事了能回溯);L2 是加了副驾驶(能帮你分担);L3 是自动驾驶切换(司机晕了副驾自动接手);L4 是组建车队(一辆车装不下,分到多辆车上跑)。

今晚张伟的事故,每一层都能帮上忙。


RDB:定时快照的得与失

类比:拍照 vs 录像

如果把 Redis 数据比作一个大家庭的全家福------

RDB 是定时拍照:每隔一段时间喊大家"别动!看镜头!"拍一张。优点是照片体积小、拿出来就能看。缺点也很明显------两张照片之间发生的事,照片里没有。
AOF 是全程录像:从开机到关机一直录。丢了任何一秒都能回看。但录像文件越来越大,看一遍要快进很久。

fork + Copy-On-Write:不阻塞的快照是怎么做到的

RDB 的核心命令是 bgsave(后台保存)。这里最容易混淆的点是:Redis 是单线程的,bgsave 会不会卡住主线程?

不会。bgsave 做了三件事:

  1. fork() 子进程 :Linux fork() 会创建一个和父进程一模一样的子进程。此时父子进程共享同一块物理内存,但操作系统并不真的拷贝数据------只是把页表标记为"只读"。

  2. 子进程写 RDB 文件:子进程遍历内存中的键值对,逐个序列化写入磁盘。

  3. Copy-On-Write(写时复制) :关键在这里。子进程写 RDB 期间,主进程还在处理客户端请求。当主进程要修改某个 key 时,操作系统发现这块内存是"只读"的,就会把要修改的那一页内存复制一份给主进程,子进程继续用原来的那页。只复制被修改的页,不是整个内存。

    主进程: 修改 key-A → OS 检测到共享页 → 复制 key-A 所在页 → 主进程在新页上修改
    子进程: 继续读原来的 key-A 页 → 写入 RDB 文件(内容是 fork 时刻的快照)

内存消耗估算:如果 fork 时有 10GB 内存,fork 期间修改了其中 2GB 的数据,COW 会产生约 2GB 额外内存开销------这在生产环境中必须留出余量。

save 自动触发条件

redis.conf 中默认的三条触发规则:

conf 复制代码
save 900 1     # 900 秒内至少 1 次修改 → 触发 bgsave
save 300 10    # 300 秒内至少 10 次修改
save 60 10000  # 60 秒内至少 10000 次修改

三条规则是"或"关系------满足任意一条就触发。可以注释掉所有 save 行来禁用自动 RDB。

RDB 优缺点速查

维度 RDB
数据恢复完整性 只能恢复到最近一次快照,丢失窗口 = 两次 save 间隔
恢复速度 快,二进制文件直接加载到内存
文件体积 小,紧凑压缩的二进制格式
fork 开销 大内存实例 fork 耗时可能几百毫秒,COW 额外占内存
适用场景 对数据完整性要求一般、需要快速冷备的场景

AOF:命令日志的取舍

三种同步策略

AOF 记录的是每一条写命令 (以 Redis 协议格式追加)。关键参数 appendfsync:

策略 行为 数据安全 性能影响
always 每条写命令都 fsync 到磁盘 最高,最多丢 1 条命令 性能最低(磁盘瓶颈)
everysec 每秒 fsync 一次(独立线程) 较高,最多丢 1 秒数据 性能损耗可接受
no 不主动 fsync,靠操作系统刷盘 最低,可能丢 30 秒数据 几乎无损耗

张伟选了 everysec------这是 99% 场景下的最优解。always 把 Redis 的 QPS 直接拉到机械硬盘级别,no 等于没开。

AOF 重写:给录像文件做剪辑

AOF 文件会无限增长。比如你对同一个 key 反复 INCR 了 100 万次,AOF 里有 100 万条 INCR 命令。重写就是把这些命令合并成一条 SET key 1000000。

bgrewriteaof 流程:

  1. 主进程 fork 子进程

  2. 子进程扫描内存中的所有 key,生成当前数据状态的最小命令集写入新 AOF 文件

  3. 期间主进程的新写入同时记录到 AOF 重写缓冲区

  4. 子进程完成后,父进程把缓冲区的命令追加到新文件末尾

  5. 原子地 rename 新文件覆盖旧文件

    fork → 子进程写新AOF → 父进程积累增量 → 追加重写缓冲 → rename 替换
    ↑ ↑
    COW 共享内存 主进程正常处理请求

AOF 触发条件

conf 复制代码
auto-aof-rewrite-percentage 100   # AOF 文件增长了 100% 后触发重写
auto-aof-rewrite-min-size 64mb    # 且 AOF 文件至少 64MB

AOF 优缺点速查

维度 AOF
数据恢复完整性 高(everysec 最多丢 1 秒)
恢复速度 慢,需要逐条执行命令重建数据
文件体积 大,是 RDB 的数倍(重写后可压缩)
fork 开销 重写时也需要 fork,同样有 COW
适用场景 对数据安全性要求高的场景

RDB vs AOF 选型决策树

复制代码
数据能接受丢失几分钟?
  ├─ 能 → 只用 RDB(最省资源)
  │       └─ 实例内存超过 50GB?→ 关闭自动 save,改手动定时 bgsave
  ├─ 不能 → 开启 AOF(everysec)
  │       └─ 还要考虑恢复速度?
  │              ├─ 恢复速度重要 → RDB + AOF 同时开(生产最常见)
  │              └─ 恢复速度无所谓 → 纯 AOF
  └─ Redis 4.0+?→ 开混合持久化(aof-use-rdb-preamble yes)
                    RDB 做前缀(快速加载)+ AOF 做增量(完整数据)
                    兼顾了恢复速度和数据完整性

VibeLoop 的选择:混合持久化。RDB 前缀保证重启恢复速度快(老板能接受),AOF 增量保证数据不丢(用户不投诉)。


主从复制:读流量分流

为什么需要主从

张伟的单机 Redis 读 QPS 到了 8 万,CPU 90%。VibeLoop 的 Feed 流、帖子详情、用户主页全是读------读写比例约 9:1。加一个从节点,读流量分过去一半,主节点立刻降到 45%。

PSYNC:增量同步 vs 全量同步

主从复制的核心是 PSYNC 命令。从节点连接主节点后,发 PSYNC <replid> <offset>:

复制代码
从节点:  PSYNC ? -1              → 首次连接,全量同步
主节点:  +FULLRESYNC <replid> <offset>
         → 主节点 bgsave 生成 RDB → 发送给从节点
         → 从节点清空旧数据 → 加载 RDB
         → 主节点把 RDB 生成期间的新写命令通过 replication buffer 发给从节点
         → 从节点追完 buffer → 进入命令传播阶段

从节点:  PSYNC <replid> <offset>  → 断线重连,增量同步
主节点:  +CONTINUE
         → offset 还在复制积压缓冲区范围内 → 只需补发断线期间的命令
         → offset 不在范围内 → 退化为全量同步

复制积压缓冲区(replication backlog) 是一个固定大小的环形缓冲区,默认 1MB。如果断线期间积压的命令超过了 1MB,只能触发全量同步------这就是为什么生产环境中 repl-backlog-size 要调大(建议 64MB+)。

从节点处理过期 Key

从节点不会主动删除过期 key 。主节点删除 key 时会在命令流中发一条 DEL,从节点同步执行。

主从复制的局限

主从解决了读扩展问题,但没有解决:

  • 主节点挂了,需要手动把从节点提升为主(改配置 + 改应用连接地址)
  • 写流量仍然只能走主节点

这两个问题分别交给哨兵和 Cluster。


哨兵集群:自动故障转移

张伟的第二件事故

开篇事故两个月后。张伟给 Redis 配了主从:1 主 3 从,读压力解决了。某个周六下午,主节点所在服务器磁盘满了,Redis 进程 OOM 被杀。

张伟在超市买菜,手机报警响了。他远程连进去,手动把从节点 slaveof no one 提为主节点,改了应用配置,重启了所有服务------整个流程 25 分钟。老板说:"下次能不能自动处理?"

哨兵就是干这事的。

主观下线 vs 客观下线

哨兵节点定期 PING 主节点。如果一个哨兵在 down-after-milliseconds(默认 30s)内没收到响应,标记为 SDOWN(主观下线)。

一个哨兵判断 SDOWN 不可靠------可能是它自己和主节点之间的网络断了。需要多个哨兵达成共识,这就是 ODOWN(客观下线):

复制代码
哨兵1 → PING 主节点 → 超时 → SDOWN → 询问其他哨兵
哨兵2 → 回复:我也连不上主节点
哨兵3 → 回复:我也连不上
→ 票数 >= quorum(配置的法定人数)→ ODOWN → 开始故障转移

哨兵领导者选举:Raft 协议

ODOWN 触发后,不是所有哨兵一起动手------需要选出一个 Leader 来执行故障转移。这个过程用 Raft 协议变体:

  1. 发现 ODOWN 的哨兵发起投票请求(SENTINEL is-master-down-by-addr)
  2. 每个哨兵在一个纪元(epoch)内只能投一票
  3. 得票数 >= max(quorum, N/2+1) 的当选 Leader
  4. Leader 负责选新主节点 + 通知其他哨兵 + 更新客户端

选新主节点的逻辑

Leader 从所有从节点中按优先级排序:

复制代码
1. 过滤掉 SDOWN / ODOWN / 断线超过 5s 的从节点
2. 按 slave-priority 升序(数字越小越优先)
3. 同优先级按复制偏移量降序(数据最新的优先)
4. 偏移量相同按 runid 字典序(唯一确定一个)

类比 :哨兵选举就像班级选班长------需要超半数投票才算当选。如果平票就重新投。落选者自动变成副班长(普通哨兵),继续参与下一轮。如果多个哨兵同时发起选举(split vote),大家随机等待不同时间重试,避免活锁。

故障转移完整时序

复制代码
SDOWN → 询问其他哨兵 → ODOWN → Raft 选举 Leader
  → Leader 选最优从节点 → SLAVEOF NO ONE 提主
  → 其他从节点 SLAVEOF 新主 → 更新哨兵监控目标
  → 发布 +switch-master 通知客户端

Cluster:数据分片与横向扩展

为什么还需要 Cluster

哨兵解决了高可用,没解决"数据太多存不下"和"写 QPS 太高扛不住"。VibeLoop 日活从 10 万涨到 100 万后,单机 Redis 内存飙到 58GB(物理内存只有 64GB),写 QPS 接近 4 万。

Cluster 把数据切成 16384 个槽,分到多台机器上。每个节点负责一部分槽,数据自动分散。

哈希槽:CRC16 % 16384

java 复制代码
// key 对应的槽号计算
int slot = CRC16.crc16(key.getBytes()) % 16384;

和一致性哈希的区别:Cluster 不维护哈希环,维护的是槽-节点映射表。扩容时人工指定槽迁移------哪些槽从节点 A 迁移到节点 B。

类比 :一致性哈希像环形跑道接力 ------新增选手只需交接相邻的那一棒,其他选手的跑道路线完全不受影响。哈希槽则是一整排储物柜(16384 个格子),每个节点管一段连续的柜子。扩容就是把一部分柜子移到新节点去管,迁移期间新旧节点共同服务被迁移的柜子。

Gossip 协议:节点间怎么通信

Cluster 没有中心节点,每个节点都通过 Gossip 协议和其他节点交换信息:

消息类型 用途
PING 定期发送,携带自身状态 + 已知的其他节点信息
PONG 对 PING 的回复 + 回应 MEET 握手
MEET 邀请新节点加入集群
FAIL 广播某个节点已下线
UPDATE 通知槽配置变更

每个节点定期随机选几个其他节点发 PING,通过"病毒式传播"让集群状态最终一致。

请求重定向:MOVED vs ASK

客户端随便连集群中任意一个节点发请求。如果该 key 的槽不在这台机器上:

复制代码
客户端: GET post:10001
节点A: -MOVED 8462 192.168.1.103:6379   // 槽 8462 在 103 上,去那找
客户端: → 连接 192.168.1.103:6379 → GET post:10001 → 成功

如果是槽正在迁移中:

复制代码
客户端: GET post:10001
节点A: -ASK 8462 192.168.1.104:6379   // 槽正在迁,试试去 104 查
客户端: → ASKING → GET post:10001 → 成功(这次临时重定向)

关键区别 :MOVED 是永久重定向(客户端应更新槽映射表),ASK 是临时重定向(槽还在迁移中,下次可能回到原节点)。

扩容流程

复制代码
1. 新节点 CLUSTER MEET 加入集群
2. CLUSTER SETSLOT <slot> IMPORTING <source_node>  // 目标节点标记槽为"正在迁入"
3. CLUSTER SETSLOT <slot> MIGRATING <target_node>  // 源节点标记槽为"正在迁出"
4. MIGRATE 逐 key 迁移数据
5. CLUSTER SETSLOT <slot> NODE <target>  // 完成迁移

生产环境一般用 redis-cli --cluster reshard 自动化执行,但理解底层命令对排查迁移卡住问题很重要。


VibeLoop 生产部署方案

从单机 Redis 到 Cluster,VibeLoop 经历了三次架构升级:

阶段 架构 痛点 升级原因
初创期 单机 Redis + RDB 半夜宕机丢 17 分钟数据 数据安全
成长期 主从 + 混合持久化 主节点挂了手动切 25 分钟 高可用
爆发期 6 节点 Cluster 单机内存 58GB 接近天花板 横向扩展

最终生产配置:

conf 复制代码
# 持久化:混合模式
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 128mb

# 主从复制
repl-backlog-size 64mb
repl-diskless-sync yes          # 无盘复制,不落 RDB 文件直接网络传输

# 哨兵
sentinel monitor mymaster 192.168.1.101 6379 2
sentinel down-after-milliseconds mymaster 10000
sentinel parallel-syncs mymaster 1

# Cluster
cluster-enabled yes
cluster-node-timeout 5000

部署拓扑:3 主 3 从,每对主从跨机架部署(避免单机架断电全挂)。哨兵 3 节点部署在独立机器上。


面试八连问 + 详解

Q1:RDB 的 bgsave 会阻塞主线程吗?为什么?

答:不会。bgsave 通过 fork() 创建子进程来执行磁盘写入,主线程继续处理请求。fork() 本身是阻塞的(复制页表),大内存实例可能耗时几十到几百毫秒。fork 之后父子进程通过 COW 共享内存,主进程修改数据时才触发页面复制。所以fork 瞬间有短暂阻塞,写 RDB 期间不阻塞。

Q2:AOF 重写期间,重写缓冲区会不会无限增长?

答:不会。如果 AOF 重写期间父进程累积的重写缓冲超过了限制,Redis 会限流------暂停接收客户端请求直到子进程完成。生产环境中应确保有足够的写入余量,避免触发限流。

Q3:混合持久化比纯 AOF 好在哪?

答:Redis 4.0 引入。RDB 做文件头(加载快),AOF 做文件尾(数据完整)。恢复时先加载 RDB 部分(二进制直接 rdbLoad),再回放尾部 AOF------恢复速度接近纯 RDB,数据完整性达到 AOF 级别。解决了"纯 RDB 丢数据"和"纯 AOF 恢复慢"的 tradeoff。

Q4:主从切换期间会发生什么?客户端会丢请求吗?

答:主节点故障到哨兵完成切换之间(通常 10-30s),写请求会失败。读请求如果连的是从节点不受影响。客户端应实现重试 + 退避 逻辑,或使用哨兵/Cluster 感知的客户端(如 Lettuce 的 RedisURI 配置哨兵地址)自动感知切换。

Q5:哨兵集群为什么必须是奇数个?

答:Raft 选举需要超半数投票(N/2+1)。3 个哨兵允许挂 1 个(2/3 > 1/2);4 个哨兵也只允许挂 1 个(需要 3 票),没有比 3 个更可靠却多费一台机器。所以实践中哨兵集群都是 3、5、7 个。

Q6:Cluster 的 16384 个槽为什么是这个数字?不能更多吗?

答:16384 = 2^14。设计考量:① 心跳消息中携带槽位图,16384 位 = 2KB,网络开销合理;② 每个节点维护 16384 个槽的映射,内存开销小;③ 实际生产集群很少有超过 1000 个节点的,16384 个槽足够均匀分配。理论上可以改,但不建议------16384 是编译期常量。

Q7:Cluster 模式下,Lua 脚本和事务怎么处理跨槽 key?

答:Cluster 要求 Lua 脚本中涉及的所有 key 必须在同一个槽。使用 hash tag :把 key 写成 {user:100}:profile 和 {user:100}:posts,CRC16 只计算 {} 内的部分------保证它们落在同一个槽。MULTI/EXEC 事务同理。没有 hash tag 的跨槽操作直接报 CROSSSLOT 错误。

Q8:集群扩容期间,正在迁移的槽被写入了怎么办?

答:槽迁移期间,源节点和目标节点短暂共存。写请求到源节点:先查 key 是否已被迁移(lookupKey),已迁移则返回 -ASK 重定向。读请求到目标节点:ASKING 命令告知目标节点"暂时接管这个槽的请求"。迁移完成后,源节点的槽绑定解除,所有请求正常走目标节点。


必背速查表

概念 一句话 面试关键词
RDB bgsave fork 子进程 + COW,不阻塞主线程 fork / COW / save 触发条件
RDB 丢失窗口 两次 bgsave 之间数据不在磁盘上 save 60 10000
AOF 三策略 always/everysec/no,everysec 是平衡点 fsync / 最多丢 1 秒
AOF 重写 扫描内存生成最小命令集,解决文件膨胀 bgrewriteaof / 重写缓冲
混合持久化 RDB 头 + AOF 尾,恢复快数据又完整 aof-use-rdb-preamble / Redis 4.0
PSYNC replid + offset 实现断线增量同步 +CONTINUE / +FULLRESYNC
复制积压缓冲 环形 buffer,太小退化为全量同步 repl-backlog-size 64MB
SDOWN/ODOWN 主观下线 / 客观下线(多数哨兵确认) down-after-milliseconds / quorum
哨兵选举 Raft 协议选 Leader 执行故障转移 epoch / N/2+1 投票
哈希槽 crc16 % 16384,数据分布靠槽不分 ring slot / hash tag {...}
MOVED vs ASK 永久重定向 vs 临时重定向(迁移中) -MOVED / -ASK / ASKING
Gossip 协议 去中心化节点状态传播 PING/PONG/MEET/FAIL

这是Redis系列的最后一期,若有问题欢迎交流指正,若有帮助麻烦各位支持支持

相关推荐
夏幻灵3 小时前
JavaScript this 面试:五种绑定规则、优先级与常见面试陷阱
开发语言·javascript·面试
IvorySQL4 小时前
PostgreSQL 日报|修复截断 zstd 备份检测(10 月 9 日)
数据库·postgresql
刘胡子大叔4 小时前
SQL 脚本的导入顺序
数据库·sql
代码什么用5 小时前
Spring对IoC的实现
数据库·spring
禾小西6 小时前
07丨Redis 哨兵机制:主库故障后,如何恢复服务?
java·开发语言·redis
hz567897 小时前
涉密视频会议设备配置指南:终端、音视频采集与配套设施选型
服务器·网络·数据库·安全·实时音视频·信息与通信·智能硬件
广州浮点FLOATLIC7 小时前
许可证服务器迁移后软件打不开:研发 IT 怎样定位连接问题
linux·服务器·数据库
程序员Sunday7 小时前
MySQL 为什么使用 B+ 树索引?把范围查询、回表和覆盖索引连起来
数据库·mysql
半杯咖啡半行码7 小时前
Qt开发实战:数据库、MV 模式、QProcess与串口通信全攻略
数据库·qt
小米里的大麦8 小时前
16 MySQL 事务
数据库·mysql