问题引入

2018 年双十一凌晨,某电商平台的订单缓存集群突然宕机。运维团队紧急拉起备用实例,却发现两个致命问题:

  1. RDB 快照缺失了最近 7 分钟的数据——上次自动 bgsave 是在 7 分钟前,期间的所有订单状态更新都丢失了。
  2. AOF 文件膨胀到 47GB——开启 AOF 后每秒追加写,但从未触发重写,重启时需要逐条重放 47GB 的命令,实例花了 28 分钟才恢复服务。

这场故障暴露了 Redis 持久化设计中最核心的 trade-off:RDB 恢复快但数据可能丢失,AOF 数据全但恢复慢。而更深层的问题是——为什么 bgsave 能保证不阻塞主进程?AOF 重写的 "边写边发" 是如何保证数据不丢的?主从同步时从节点挂了一段时间,为什么有时能续传有时必须全量?

理解这些机制的关键,不是记住 "RDB 是全量、AOF 是增量" 这种八股,而是理解 fork 的 COW 语义、AOF 重写的双缓冲设计、PSYNC 的偏移量匹配 这些工程细节。

核心概念

RDB:内存快照的 fork 魔法

RDB(Redis Database)通过生成某一时刻的内存快照来持久化数据。关键命令:

  • SAVE:阻塞主进程,直到快照写完——生产环境禁用
  • BGSAVEfork 子进程,子进程写快照,父进程继续服务
graph TD subgraph "bgsave 的 fork 机制" P["Redis 主进程\n服务中"] -->|fork| C1["子进程\n共享父进程页表"] C1 -->|写入| RDB["RDB 文件"] P -->|继续服务| CLIENT["客户端请求"] P -->|修改 page X| COW["COW: 复制 page X\n子进程保留旧副本"] end style P fill:#f9f,stroke:#333,stroke-width:2px style COW fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是主进程——fork 后立即返回继续服务,不被阻塞。蓝色节点是 COW(Copy-on-Write)——当父进程修改某个内存页时,内核复制该页,父进程写新副本,子进程保留 fork 时刻的旧副本。子进程因此看到的是一个 "时间冻结" 的内存快照。

COW 的内存代价

fork 时父子进程共享所有物理内存页。如果 bgsave 期间父进程持续写入,被修改的页会触发复制,内存占用逐渐增长。

graph TD subgraph "COW 内存增长" T0["fork 时刻\n共享内存: 10GB\n实际占用: 10GB"] --> T1["父进程修改 20% 页面\n共享: 8GB\n父进程独占: 2GB\n子进程独占: 2GB\n总计: 12GB"] T1 --> T2["父进程修改 50% 页面\n共享: 5GB\n父进程独占: 5GB\n子进程独占: 5GB\n总计: 15GB"] T2 --> T3["父进程修改 80% 页面\n共享: 2GB\n父进程独占: 8GB\n子进程独占: 8GB\n总计: 18GB"] end style T0 fill:#9f9,stroke:#333,stroke-width:2px style T3 fill:#f9f,stroke:#333,stroke-width:2px

读图导引:绿色节点是 fork 时刻——内存占用等于数据集大小。粉色节点是大量写入后——如果 80% 的页被修改,内存峰值可能接近数据集的两倍(10GB 共享 → 父 8GB + 子 8GB = 18GB,减去未修改的 2GB 共享页,实际是 16GB 新增)。

暗面:bgsave 期间若父进程写入密集,内存可能翻倍。对于内存余量不足的实例,bgsave 可能触发 OOM。Redis 的 maxmemory 不统计 COW 复制的页,所以实际内存使用可能超过 maxmemory。

AOF:日志追加与重写

AOF(Append Only File)记录每一条写命令,重启时逐条重放。

三种刷盘策略appendfsync):

策略 行为 数据安全 性能
always 每次写命令都 fsync 最高,几乎不丢 最低,每次写都是磁盘 IO
everysec 每秒后台线程 fsync 最多丢 1 秒数据 平衡,推荐生产使用
no 由操作系统决定 fsync 可能丢几十秒数据 最高,但最危险

默认 everysec 是一个精妙的折中——后台线程每秒 fsync,主线程不被阻塞。但如果 fsync 耗时超过 1 秒,Redis 会放慢写入速度("Delayed AOF" 机制)。

AOF 重写(BGREWRITEAOF)

AOF 文件会无限增长(如反复修改同一个 key,日志里会记录多条命令),需要定期重写——生成一个 "最小化" 的 AOF 文件,只保留数据的最终状态。

graph TD subgraph "AOF 重写双缓冲" P["Redis 主进程"] -->|fork| SP["子进程"] SP -->|遍历内存| NAOF["新 AOF 文件\n只写最终状态"] P -->|新命令| OAOF["旧 AOF 文件\n继续追加"] P -->|同时写入| BUF["重写缓冲区\nrewrite buffer"] SP -->|写完| MERGE["将缓冲区\n追加到新文件"] end style P fill:#f9f,stroke:#333,stroke-width:2px style BUF fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是主进程——fork 子进程后,新命令同时写入旧 AOF 文件(保持原有 AOF 的连续性)和重写缓冲区(为后续合并准备)。蓝色节点是重写缓冲区——子进程写完内存快照后,主进程将缓冲区中的增量命令追加到新文件尾部,保证重写期间的数据不丢失。

重写的触发条件

  • auto-aof-rewrite-percentage 100:AOF 文件比上次重写后增长了 100%
  • auto-aof-rewrite-min-size 64mb:且 AOF 文件至少 64MB

面试官视角:问 "AOF 重写期间宕机怎么办"?答案是:新 AOF 文件写完后才会原子替换旧文件。如果重写期间宕机,旧 AOF 文件仍然完整可用,只是没有包含重写期间的新数据——丢失的是重写期间的增量,而非全部。

混合持久化:RDB 的加载速度 + AOF 的丢失精度

Redis 4.0 引入混合持久化(aof-use-rdb-preamble yes),解决纯 AOF 恢复慢的问题:

复制代码
混合 AOF 文件 = [RDB 格式前缀] + [AOF 格式后缀]
              = 全量数据快照   +  增量命令
graph TD subgraph "混合持久化文件结构" R["RDB 前缀\n二进制格式\n加载速度快"] R --> A["AOF 后缀\n文本命令\n精度高"] end style R fill:#9f9,stroke:#333,stroke-width:2px style A fill:#bbf,stroke:#333,stroke-width:2px

读图导引:绿色节点是 RDB 前缀——启动时像加载 RDB 一样快速恢复全量数据。蓝色节点是 AOF 后缀——RDB 快照点之后的增量命令,保证丢失窗口极小。

加载流程:先解析 RDB 前缀恢复大部分数据,再逐条执行 AOF 后缀的命令。既避免了纯 AOF 的慢速重放,又避免了纯 RDB 的大窗口丢失。

主从同步:全量与增量的博弈

Redis 主从复制(Replication)是高可用的基础。同步分为两个阶段:

  1. 全量同步(Full Resynchronization):主节点生成 RDB 发给从节点,同时缓冲期间的写命令
  2. 部分同步(Partial Resynchronization):从节点断线重连后,如果条件满足,只同步断线期间的命令
sequenceDiagram participant S as 从节点 participant M as 主节点 S->>M: PSYNC ? -1 (首次同步,无 runid 和 offset) M-->>S: +FULLRESYNC runid 1000 Note over M: 启动 bgsave 生成 RDB M->>S: 发送 RDB 文件 Note over M: RDB 生成期间的新命令写入 replication buffer M->>S: 发送缓冲区的写命令 S->>S: 加载 RDB + 执行缓冲命令 Note over S,M: 正常运行中... S->>M: 网络断开 Note over M: 继续将命令写入 repl_backlog S->>M: PSYNC runid 1500 (重连,上报 offset) M-->>S: +CONTINUE (offset 1500 在 backlog 中) M->>S: 发送积压命令 1501 ~ 2000

读图导引:观察从节点首次同步时走 FULLRESYNC 全量路径——主节点 fork bgsave,RDB 传输期间的新命令先入 replication buffer 再发给从节点。断线重连时走 CONTINUE 部分同步路径——如果上报的 offset 在主的 repl_backlog 环形缓冲区内,直接发送积压命令,避免全量同步。

部分同步的前提条件

  1. 从节点上报的 runid 与主节点当前 runid 一致(没换主)
  2. 从节点上报的 offset 在主的 repl_backlog 缓冲区内

repl_backlog 是一个固定大小的环形缓冲区(默认 1MB,可配置 repl-backlog-size)。如果断线时间太长,offset 超出了 backlog 范围,只能走全量同步。

graph TD subgraph "repl_backlog 环形缓冲区" B["backlog size=1MB"] B -->|已覆盖| O["offset 800\n已被覆盖"] B -->|有效范围| R["offset 1200 ~ 2000\n在缓冲区内"] B -->|未来| F["offset 2001+\n尚未产生"] end style O fill:#f9f,stroke:#333,stroke-width:2px style R fill:#9f9,stroke:#333,stroke-width:2px

读图导引:粉色节点是已被覆盖的 offset——backlog 是环形缓冲区,新命令不断覆盖旧命令。绿色节点是有效范围——从节点上报的 offset 如果落在这个区间,可以走部分同步。

暗面:repl_backlog 默认 1MB 对于写入量大的主节点很容易溢出。建议根据主节点的写入速率计算:backlog_size = write_rate * max_disconnect_time * 2。例如 10MB/s 的写入,允许断线 30 秒,至少需要 300MB 的 backlog。

哨兵:分布式仲裁的困境

Sentinel(哨兵)是 Redis 的高可用解决方案,负责监控主从节点、自动故障转移。

核心机制

  1. 主观下线(SDOWN):单个 Sentinel 认为某节点不可达(down-after-milliseconds 超时)
  2. 客观下线(ODOWN):足够多的 Sentinel(quorum)达成共识,认为主节点下线
  3. Leader Election:Sentinel 集群通过 Raft 算法选出一个 Leader 执行故障转移
  4. 故障转移:Leader 选一个从节点提升为主,通知其他从节点切主,通知客户端更新地址
graph TD subgraph "哨兵故障转移流程" M["Master"] -->|超时| S1["Sentinel1\nSDOWN"] S1 -->|广播| S2["Sentinel2\n同意 SDOWN"] S1 -->|广播| S3["Sentinel3\n同意 SDOWN"] S2 -->|quorum=2| OD["ODOWN 达成"] S3 -->|quorum=2| OD OD -->|Raft 选举| LE["Sentinel2 成为 Leader"] LE -->|提升| SL["Slave1 → New Master"] LE -->|通知| SL2["Slave2 切主"] LE -->|发布切换消息| PUB["__sentinel__:hello"] end style S1 fill:#f9f,stroke:#333,stroke-width:2px style OD fill:#bbf,stroke:#333,stroke-width:2px style LE fill:#9f9,stroke:#333,stroke-width:2px

读图导引:粉色节点是第一个发现问题的 Sentinel。蓝色节点是 ODOWN 达成——quorum 个 Sentinel 达成共识。绿色节点是 Leader Election——只有 Leader 才能执行故障转移,避免多个 Sentinel 同时操作引发混乱。

Raft 选举的核心:每个 Sentinel 发现主节点 ODOWN 后,会向其他 Sentinel 发送 "is-master-down-by-addr" 请求,同时要求对方投票给自己。获得多数票的 Sentinel 成为 Leader,负责执行故障转移。

原理分析

fork 的 COW 在 Linux 内核中的实现

Linux 的 fork() 使用写时复制(Copy-on-Write)机制:

  1. fork 时,内核复制父进程的页表给子进程,但不复制物理页,只将页表项标记为只读
  2. 父或子进程尝试写入某页时,触发页错误(page fault)
  3. 内核复制该物理页,给写入者一个独立副本,恢复写权限
  4. 另一个进程仍保留对原页的只读引用
graph TD subgraph "COW 页分裂过程" F1["fork 前\n父进程页表 → 物理页 A"] -->|fork| F2["fork 后\n父页表 → 页 A (只读)\n子页表 → 页 A (只读)"] F2 -->|父写入| F3["页错误\n复制页 A\n父页表 → 页 A' (读写)\n子页表 → 页 A (只读)"] end style F2 fill:#f9f,stroke:#333,stroke-width:2px style F3 fill:#bbf,stroke:#333,stroke-width:2px

读图导引:粉色节点是 fork 后——父子共享物理页,页表项标记为只读。蓝色节点是父进程写入触发 COW——内核复制该页,父进程获得可写副本,子进程保留原页的快照。

在 Redis 的 bgsave 场景中,子进程不会写入任何页(它只读取内存写 RDB),所以所有 COW 复制都由父进程的写入触发。

AOF 重写的缓冲区溢出风险

AOF 重写期间,主进程将新命令同时写入旧 AOF 和重写缓冲区。如果重写耗时很长(大实例、慢磁盘),而写入速率很高,缓冲区可能溢出。

Redis 的处理策略:重写缓冲区没有固定大小限制,它用普通的 client output buffer 机制。但如果缓冲区增长过快,Redis 会:

  • 触发 client-output-buffer-limit 的限制
  • 如果子进程写入太慢,主进程可能阻塞等待(aof_rewrite_buf_blocks 链表增长)
graph TD subgraph "AOF 重写缓冲区增长" R1["重写开始\n缓冲区: 0"] -->|写入 100MB/s\n重写耗时 60s| R2["60s 后\n缓冲区: 6GB"] R2 -->|子进程写完 RDB| R3["追加缓冲区\n到新 AOF"] R3 -->|原子替换| R4["新 AOF 生效\n旧 AOF 删除"] end style R2 fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点是高风险时刻——重写耗时 60 秒、写入速率 100MB/s 时,缓冲区可能累积到 6GB。这要求系统有足够的内存余量。

PSYNC 的 runid 与复制偏移量

每个 Redis 实例有一个 40 字节的 runid(启动时随机生成)。主节点维护一个全局的复制偏移量(replication offset),每发送一个字节给从节点,offset 增加 1。

graph LR subgraph "复制偏移量追踪" M["Master offset=10000"] -->|发送 500 字节| S["Slave offset=9500"] M -->|发送 500 字节| S2["Slave offset=10000"] S -->|断线 3 秒\nMaster 写了 300B| S3["Slave 重连\noffset=9500"] S3 -->|请求 PSYNC| M2["Master backlog=500B\n覆盖范围 9700~10200"] M2 -->|offset 9500 不在范围| F["FULLRESYNC"] end style S3 fill:#f9f,stroke:#333,stroke-width:2px style F fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了部分同步失败的原因——从节点断线 3 秒,主节点写了 300 字节,从节点重连时 offset=9500,但 backlog 只保留了 9700~10200 的范围(500B)。9500 已经被覆盖,只能走全量同步。

哨兵的脑裂问题与防护

网络分区(Network Partition)下,哨兵系统可能出现 "双主" 脑裂:

graph TD subgraph "脑裂场景" M["Master A"] -->|S1 S2 分区| NET["网络分区"] NET -->|S1 S2 侧| ODOWN["ODOWN\n提升 Slave 为 New Master"] NET -->|S3 侧| M2["Master A 仍存活\n继续接收写入"] ODOWN -->|客户端写入| NM["New Master B"] end style NET fill:#f9f,stroke:#333,stroke-width:2px style M2 fill:#f9f,stroke:#333,stroke-width:2px style NM fill:#f9f,stroke:#333,stroke-width:2px

读图导引:粉色节点展示了脑裂——网络分区后,左侧的 Sentinel 提升从节点为新主,右侧的旧主仍然存活并接收写入。分区恢复后,两个主节点都有数据,需要复杂的冲突解决。

防护配置

bash 复制代码
# 主节点至少有 N 个从节点连接时,才允许写入
min-slaves-to-write 2
# 且从节点的延迟不超过 N 秒
min-slaves-max-lag 10

这个配置的含义是:如果主节点与大多数从节点断开(如网络分区导致从节点失联),主节点会自动停止接收写入,避免脑裂期间的数据不一致。代价是可用性降低——极端情况下主节点可能因从节点全挂而拒绝写入。

暗面:哨兵的 min-slaves-to-write 是一种 "fencing"(围栏)机制,但它依赖主节点自己能感知到从节点断开。如果主节点所在分区与 Sentinel 所在分区不一致,仍可能出现误判。更彻底的方案是使用 Redis Cluster 的分布式一致性,或者在外部引入分布式协调服务。

实战/源码

配置生产级持久化

bash 复制代码
# redis.conf 生产配置示例

# RDB: 每 900 秒至少 1 个 key 变化,或 300 秒至少 10 个 key 变化,或 60 秒至少 10000 个 key 变化
save 900 1
save 300 10
save 60 10000

# AOF
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 混合持久化 (Redis 4.0+)
aof-use-rdb-preamble yes

# 重写缓冲区限制(避免无限增长)
client-output-buffer-limit replica 256mb 64mb 60

观察 COW 内存增长

bash 复制代码
# Linux 下查看 Redis 进程的内存信息
cat /proc/$(pgrep redis-server)/status | grep -E "VmRSS|RssAnon|RssFile"

# 或使用 redis-cli
redis-cli INFO memory
# 关注 used_memory 和 used_memory_rss 的差距

bgsave 期间如果 used_memory_rss 快速增长,说明 COW 复制了大量页。可以通过 INFO statskeyspace_hits/keyspace_misses 估算写入压力。

手动触发 AOF 重写并观察

bash 复制代码
redis-cli BGREWRITEAOF

# 观察重写进度
redis-cli INFO persistence
# 关注 aof_rewrite_in_progress 和 aof_current_size

哨兵故障转移演练

bash 复制代码
# 1. 启动哨兵集群(至少 3 个 Sentinel)
redis-sentinel sentinel.conf

# 2. 查看主节点状态
redis-cli -p 26379 SENTINEL master mymaster

# 3. 手动触发故障转移(演练)
redis-cli -p 26379 SENTINEL failover mymaster

# 4. 观察切换过程
redis-cli -p 26379 SENTINEL slaves mymaster

常见问题

Q1:RDB 和 AOF 同时开启,重启时用哪个?

Redis 优先加载 AOF(因为 AOF 的数据更完整)。如果 AOF 关闭或文件不存在,才加载 RDB。可以配置 aof-load-truncated yes 允许加载末尾被截断的 AOF(如宕机导致)。

Q2:bgsave 和 bgrewriteaof 能同时执行吗?

不能。Redis 通过 server.child_pid 维护单个子进程。如果 bgsave 正在执行,bgrewriteaof 会被延迟;反之亦然。即使 Redis 7.0 引入了增量 fsync(rdb-save-incremental-fsync)等优化,bgsave 和 bgrewriteaof 仍不能同时执行。

Q3:为什么 AOF 重写的文件比 RDB 小?

不一定。AOF 重写生成的是 "命令最小集"(如 100 次 INCR 变成 1 次 SET),但对于大 key(如大 Hash、大 ZSet),RDB 的二进制格式通常比 AOF 的命令文本更紧凑。混合持久化兼顾了两者优势。

Q4:主从同步的延迟怎么监控?

bash 复制代码
redis-cli INFO replication
# 关注 master_repl_offset 和 slave 的 slave_repl_offset
# 差值就是复制延迟(字节数)

对于读写分离架构,如果从节点延迟过大,可能读到旧数据。建议设置 slave-serve-stale-data no 让延迟过大的从节点拒绝读取。

Q5:哨兵的 quorum 怎么设?

bash 复制代码
sentinel monitor mymaster 127.0.0.1 6379 2

最后的 2 是 quorum——认为主节点客观下线所需的最小 Sentinel 数。 quorum 不需要超过 Sentinel 总数的一半,它的作用是触发 ODOWN。但 故障转移的 Leader Election 需要多数票(N/2+1)。所以 3 个 Sentinel 时 quorum 设为 2 是合理的:2 个即可触发 ODOWN,而 Leader Election 需要 2 票(3/2+1=2)。

总结

Redis 持久化与高可用的设计,是在数据安全、恢复速度、系统开销三者之间的精密平衡:

  • RDB 的 fork + COW:子进程写快照不阻塞主进程,但 COW 复制可能导致内存翻倍。fork 本身在超大内存实例上也可能耗时数百毫秒("fork 抖动")
  • AOF 的 everysec + 重写:每秒刷盘是数据安全和性能的折中;重写的双缓冲设计(子进程写新文件 + 父进程写重写缓冲区)保证重写期间不丢数据
  • 混合持久化:RDB 前缀快速恢复全量 + AOF 后缀精确保留增量,是 Redis 4.0 后推荐的持久化方案
  • 主从 PSYNC:runid + 偏移量实现部分同步,repl_backlog 环形缓冲区的大小直接决定断线续传的容错窗口
  • 哨兵 Raft 选举:quorum 触发客观下线,多数票选举 Leader 执行故障转移。min-slaves-to-write 是脑裂防护的 "fencing" 机制,但会牺牲极端场景下的可用性

理解这些机制的关键,是追问:当网络分区发生时,系统的行为是什么?当写入速率超过 AOF 重写速度时,缓冲区会怎样?当 fork 的 COW 内存超过物理内存时,会发生什么? 从"知道配置参数"到"懂得故障场景下的行为边界",是跨越中级和高级的分水岭。