Skip to content

第 6 章 持久化:RDB / AOF / 混合

学习目标:彻底搞清楚 Redis 的三种持久化方式 —— RDB(快照)AOF(日志) 以及 混合持久化。看完之后能回答这类追问:「BGSAVE 时主线程会不会被卡?fork 之后内存会翻倍吗?AOF 的 everysec 真的是每秒一次吗?Redis 重启后从 RDB 还是 AOF 恢复?」


6.1 为什么需要持久化?

6.1.1 内存数据库的「天生缺陷」

Redis 把数据全部放在内存里,所以快。但是内存有一个致命弱点:断电就清零

💡 生活类比:内存就像你写字台上摊开的纸 —— 拿起来用很快,但只要电源一断(保洁阿姨一吹),全没了。 磁盘就像抽屉里的账本 —— 翻起来慢,但能放过夜、过年、过几十年。

┌─────────────────────────────────────────────────────────┐
│                    没有持久化的 Redis                     │
├─────────────────────────────────────────────────────────┤
│                                                           │
│   客户端 SET k1 v1                                       │
│       ↓                                                   │
│   内存:  k1 → v1   k2 → v2   k3 → v3   ...              │
│                                                           │
│   ⚡ 突然断电 / kill -9 / 机器宕机                        │
│                                                           │
│   ❌ 内存全部丢失,重启后:空空如也                         │
│                                                           │
└─────────────────────────────────────────────────────────┘

如果 Redis 仅仅当作缓存用,丢了大不了从 MySQL 重新加载。但如果 Redis 当作主存储(比如 Session、计数器、排行榜),数据丢了那就是事故。

6.1.2 持久化的两种思路

工业界对「内存数据 → 落到磁盘」一共就两种思路:

思路比喻Redis 中的实现
拍快照每隔一段时间给数据「拍照」存档RDB
记日志每一笔变更都按时间顺序写流水账AOF

Redis 把这两种都实现了,并且从 4.0 起支持「混合」—— 用 RDB 做底片 + AOF 做增量。

6.1.3 三种持久化方式对比一览

┌──────────┬─────────────────┬──────────────────┬────────────────┐
│  方式    │  本质            │  恢复速度         │  数据丢失风险    │
├──────────┼─────────────────┼──────────────────┼────────────────┤
│  RDB     │  二进制内存快照   │  ⚡ 极快(直接加载)│  ❌ 可能丢分钟级 │
│  AOF     │  追加命令日志     │  🐢 慢(重放命令)│  ✅ 最多丢 1 秒  │
│  混合    │  RDB 头 + AOF 尾  │  ⚡ 快            │  ✅ 最多丢 1 秒  │
└──────────┴─────────────────┴──────────────────┴────────────────┘

6.2 RDB(Redis Database)

6.2.1 什么是 RDB?

RDB = Redis DataBase = 某一时刻全部数据的二进制快照文件,默认文件名 dump.rdb

📸 生活类比:一张全家福。咔嚓一拍,整个房间所有人此刻的姿势都被记录下来。下次想回到这个时刻,照着照片重新坐好就行。

某一时刻内存:                             ↓ 拍快照
┌─────────────────────┐         ┌──────────────────────┐
│ k1 → "alice"        │         │  dump.rdb(二进制)    │
│ k2 → 100            │  ────▶  │  REDIS0010 ...        │
│ k3 → ["a","b","c"]  │         │  k1=alice k2=100 ...  │
│ ...                  │         │  EOF + CRC64          │
└─────────────────────┘         └──────────────────────┘

6.2.2 三种触发方式

方式 ① SAVE(同步阻塞,已基本不用)

bash
127.0.0.1:6379> SAVE
OK     # 在主线程里同步把所有数据写到 dump.rdb
  • 主线程亲自上阵,期间所有客户端命令全部阻塞
  • 数据量大时阻塞几秒到几十秒,生产环境 ❌ 严禁使用。

方式 ② BGSAVE(后台异步,推荐)

bash
127.0.0.1:6379> BGSAVE
Background saving started
  • 主线程 fork() 出一个子进程,子进程负责写盘,主线程继续响应客户端
  • 这是生产环境的默认手段。
  • LASTSAVE 命令可查看上一次成功 BGSAVE 的时间戳。

方式 ③ 自动触发(save m n 配置)

redis.conf 中:

save 3600 1     # 3600 秒(1 小时)内至少 1 次写操作 → 触发 BGSAVE
save 300 100    # 300  秒(5 分钟)内至少 100 次写  → 触发 BGSAVE
save 60 10000   # 60   秒内至少 10000 次写          → 触发 BGSAVE

任意一条满足就触发,"或"关系。可以禁用:save ""

💡 这个策略的本质是:写得越频繁、保存得越频繁。无人写时不浪费 IO,狂写时尽量保留更新。

其他隐式触发

  • SHUTDOWN:正常关闭时自动 BGSAVE
  • 主从复制全量同步时
  • FLUSHALLDEBUG RELOAD

6.2.3 fork 与 COW(Copy-On-Write):BGSAVE 的灵魂

这是 RDB 最核心的部分,也是面试必考。

步骤一:fork 发生了什么?

fork() 是 Linux 系统调用,作用是「复制一个一模一样的进程」。

fork() 之前:
  ┌─────────────────────┐
  │ 主进程(PID=100)    │
  │ 内存:4 GB 数据      │
  └─────────────────────┘

fork() 之后(瞬间):
  ┌─────────────────────┐    ┌─────────────────────┐
  │ 父进程(PID=100)    │    │ 子进程(PID=200)    │
  │ 继续服务客户端        │    │ 负责写 RDB           │
  │                       │    │                       │
  │ 看到的内存:4 GB      │    │ 看到的内存:4 GB      │
  └──────────┬──────────┘    └──────────┬──────────┘
             │                            │
             └────── 都指向同一份 ────────┘
                  物理内存(4 GB)

关键点:fork 之后父子进程逻辑上各有 4 GB物理上仍共用同一份。如果真的复制 4 GB,那 fork 就成了大魔头。

步骤二:COW 是怎么省内存的?

COW = Copy-On-Write(写时复制)。本质是 Linux 内核的一项优化:只读时共享,写入时才真正复制那一页(通常 4 KB)。

                    fork 之后的物理内存示意
   ┌────────────────────────────────────────────────────────┐
   │  Page1   Page2   Page3   Page4   Page5   ...   PageN  │
   └─────┬───────┬───────┬───────┬───────┬─────────────────┘
         │       │       │       │       │
   父进程视图:(只读共享)
         │       │       │       │       │
   子进程视图:(只读共享)

   ⚡ 此时父进程执行 SET k2 100(修改 Page2):

   ┌────────────────────────────────────────────────────────┐
   │  Page1   Page2'  Page3   Page4   Page5   ...   PageN  │
   │           ↑                                              │
   │       新复制的页                                          │
   │  Page2  ← 子进程仍看到旧版本(保证快照一致性)              │
   └────────────────────────────────────────────────────────┘

结论

  • ✅ 子进程看到的永远是 fork 那一刻的「内存快照」(一致性)
  • ✅ 大多数页只读共享,不会真的复制 4 GB(省内存)
  • ⚠️ 但如果父进程疯狂写入(比如全量改 key),最坏情况内存会接近翻倍

步骤三:fork 本身是不是无代价?

不是! fork 调用本身需要复制进程的页表(不是数据),数据量大时也会有几十到几百毫秒的阻塞:

内存大小       fork 阻塞时长(粗略经验值)
──────────────────────────────────────
1 GB           20 ~ 50 ms
10 GB          200 ~ 500 ms
30 GB          1 ~ 2 秒(可能引发主从超时)

⚠️ 生产建议:单实例内存控制在 10 GB 以内,避免 fork 卡顿;机器要有足够空闲内存(至少与 Redis 实际内存等量),防止 COW 极端场景下的 OOM。

6.2.4 RDB 文件结构

┌──────────┬──────┬──────────┬──────┬──────────┬─────┬───────┐
│  REDIS   │ 0010 │ AUX 字段  │ DBn  │ KV 数据  │ EOF │ CRC64 │
│ (5 字节) │(版本)│ (元信息)  │      │  ...     │     │ (8B)  │
└──────────┴──────┴──────────┴──────┴──────────┴─────┴───────┘
   ↑          ↑       ↑          ↑       ↑          ↑      ↑
   魔数      RDB     redis-ver   选哪个  按编码紧凑  结束   校验和
   "REDIS"   版本号  /uptime等   db 号    存 KV       标志   防损坏
  • REDIS 5 字节魔数:用来快速识别「这就是 RDB 文件」。
  • 0010 表示 RDB 版本(Redis 7 是 11,6.x 多是 9 或 10)。
  • AUX:辅助字段,比如 redis-verredis-bitsctimeused-mem
  • DBn:数据库编号(默认 16 个 db)。
  • KV:每个键值都按类型紧凑编码(int/embstr/quicklist 等都有专门表示)。
  • CRC64:8 字节校验和,用于检测文件损坏。

od 看一眼魔数

bash
$ od -c dump.rdb | head -1
0000000   R   E   D   I   S   0   0   1   1 372  \t   r   e   d   i   s

6.2.5 RDB 的优缺点

✅ 优点                              ❌ 缺点
──────────────────────              ──────────────────────
紧凑的二进制,文件小                  fork 有内存翻倍风险
恢复极快(直接 load)                可能丢失最后一次快照后的数据
适合冷备 / 灾难恢复                  写盘不频繁,时效性差
对主线程影响小(fork 后异步)         大数据集下 fork 本身耗时

6.3 AOF(Append-Only File)

6.3.1 什么是 AOF?

AOF = Append-Only File = 以追加方式写入「执行过的写命令」的日志文件,默认 appendonly.aof

📒 生活类比:超市每开一笔单都记到收银流水账。月底店里着火数据全没了?没事,把流水账上的每一笔重新「演一遍」就能恢复库存。

AOF 文件本身就是 RESP 格式的命令序列,人类可读

*3\r\n$3\r\nSET\r\n$2\r\nk1\r\n$5\r\nhello\r\n
*2\r\n$4\r\nINCR\r\n$3\r\ncnt\r\n
*4\r\n$4\r\nHSET\r\n$5\r\nuser1\r\n$3\r\nage\r\n$2\r\n30\r\n
...

打开看就是「重放一下就能恢复全部状态」。

6.3.2 写后日志 vs 写前日志:Redis 选了「写后」

思路别名流程代表
写前日志WAL(Write-Ahead Log)先写日志 → 再改内存MySQL Redo Log
写后日志先改内存 → 再写日志Redis AOF

Redis 为什么选写后?

  1. 避免给错误命令也写日志:先在内存中执行,能保证写入 AOF 的都是「已经成功」的命令。INCR foo(foo 是字符串)这种会失败的,根本不会落盘。
  2. 不阻塞当前命令:先返回客户端 OK,再异步写盘,体验好。
  3. 缺点很明显:如果命令执行成功后、写盘前宕机,那条命令就丢了 → 所以才有了 appendfsync 的三档可选。
┌────────────────────────────────────────────────────────────────┐
│              Redis 处理一条写命令的完整流程                       │
├────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ① 客户端发来 SET k1 v1                                         │
│           ↓                                                      │
│  ② 主线程执行 → 修改内存中的字典                                  │
│           ↓                                                      │
│  ③ 把 SET k1 v1(RESP 编码)追加到 AOF 缓冲区(aof_buf)          │
│           ↓                                                      │
│  ④ 给客户端回 +OK                                                │
│           ↓                                                      │
│  ⑤ 根据 appendfsync 策略,把 aof_buf 中的内容刷到磁盘              │
│                                                                  │
└────────────────────────────────────────────────────────────────┘

注意 ③ 是写到「AOF 缓冲区」(用户态内存),④ 之后才决定要不要 fsync 到磁盘。这就引出三档刷盘策略。

6.3.3 appendfsync 三档:always / everysec / no

OS 层面,写文件分两步:

write() ──── 数据进入 OS 内核缓冲区(page cache)
fsync() ──── 强制 OS 把 page cache 刷到物理磁盘

writefsync:数据还在内存里,断电就丢。fsync 是真正昂贵的那一步(磁盘 IO)。

三档策略

┌──────────┬────────────────────┬──────────┬──────────────────┐
│ 策略     │ 行为                │ 性能      │ 数据丢失最大量    │
├──────────┼────────────────────┼──────────┼──────────────────┤
│ always   │ 每条命令都 fsync   │ 🐢 最慢   │ 不丢(理论上)    │
│ everysec │ 每秒 fsync 一次     │ 🟢 折中   │ 最多 1 秒(默认)│
│ no       │ 由 OS 决定何时刷    │ ⚡ 最快   │ 视 OS 而定(30s+)│
└──────────┴────────────────────┴──────────┴──────────────────┘

everysec 真的是「每秒一次」吗?

不完全是。Redis 用一个后台 BIO 线程负责 fsync,主线程继续干活。流程其实是:

主线程                                   BIO 后台线程
   │                                          │
   │ aof_buf 写入                              │
   │ ──────────write()──────► page cache      │
   │                                          │
   │ 每次循环检查上次 fsync 距今是否 ≥ 1s      │
   │                                          │
   │ 是 → 把 fsync 任务丢给 BIO 线程 ─────────►│
   │                                          │ 调用 fsync()
   │                                          │ (可能耗 100ms+)
   │                                          │
   │ 主线程继续处理新命令                       │
   │                                          │
   │ 极端情况:上一次 fsync 还没完成时下一秒到了  │
   │ → 主线程会阻塞等待,避免堆积过多未刷数据      │

所以最坏情况下可能丢失 1~2 秒数据(不是严格 1 秒)。

6.3.4 AOF 重写(BGREWRITEAOF)

为什么要重写?

AOF 是「只追加」的命令日志,时间长了会有很多冗余和过期命令

原始 AOF 中:                             实际有用的只有:
SET k1 v1       SET k1 v2     SET k1 v3   →   SET k1 v3
INCR cnt        INCR cnt      INCR cnt    →   SET cnt 3
LPUSH q a       LPUSH q b     LPOP q      →   LPUSH q b(或 a)
DEL k1                                    →  (直接消失)
EXPIRE k2 60                              →   k2 已过期 → 不写

重写就是基于当前内存状态,生成一个等价但最精简的命令序列,替换掉旧的 AOF 文件。

怎么触发?

bash
# 手动触发
127.0.0.1:6379> BGREWRITEAOF

# 自动触发:redis.conf 中
auto-aof-rewrite-percentage 100   # AOF 文件比上次重写后增长 100% 时触发
auto-aof-rewrite-min-size 64mb    # AOF 文件至少 64MB 才考虑重写

重写期间数据怎么保证不丢?

这是个经典面试题。重写也是子进程完成的(同样靠 fork + COW),但主进程会同时接收新写入,需要解决「新数据写到哪里」的问题。

Redis 7 之前:用「AOF 重写缓冲区(aof_rewrite_buf)」:

┌────────────────────────────────────────────────────────────────┐
│                  AOF 重写期间的双写机制                          │
├────────────────────────────────────────────────────────────────┤
│                                                                  │
│   主进程                            子进程(重写)                │
│   ──────                            ─────────────                │
│      │                                    │                      │
│      │ 收到新写命令 SET k_new v             │ 遍历内存生成最精简    │
│      │       ↓                            │ 的 AOF 命令序列        │
│      │                                    │       ↓                │
│      │ ① 写入【原 AOF 缓冲区】 → 旧 aof    │ 写入临时文件           │
│      │ ② 写入【AOF 重写缓冲区】(新增)      │ temp-rewriteaof-*.aof │
│      │                                    │       │                │
│      │                                    │       ▼                │
│      │ 子进程结束信号 ◄────────────────── 完成                    │
│      │       ↓                                                    │
│      │ 把【AOF 重写缓冲区】里的命令追加到临时文件末尾                │
│      │       ↓                                                    │
│      │ rename(临时文件, appendonly.aof) ← 原子替换                │
│                                                                   │
└─────────────────────────────────────────────────────────────────┘

Redis 7+ 引入 multi-part AOF(manifest + base + incr 文件),消除了重写缓冲区的双写消耗,机制更优雅,但思想一致。

6.3.5 AOF 的优缺点

✅ 优点                              ❌ 缺点
──────────────────────              ──────────────────────
丢失数据少(最多 1 秒)              文件比 RDB 大很多
人类可读,好排查                     恢复慢(要重放命令)
误操作可救(删 FLUSHALL 行再 load)  fsync 频繁影响性能
重写机制控制文件膨胀                 重写本身有 fork 开销

6.4 混合持久化(4.0+)

6.4.1 解决了什么问题?

RDB 快但可能丢数据,AOF 安全但恢复慢 —— 混合持久化把两者糅合在一个文件里

启用方式:redis.conf 中(4.0+ 默认 yes)
aof-use-rdb-preamble yes

6.4.2 文件结构:RDB 头 + 增量 AOF 尾

重写的瞬间,把整个内存以 RDB 二进制格式写到 AOF 文件开头,之后新来的命令仍然用 AOF 格式追加:

┌─────────────────────────┬───────────────────────────────┐
│   RDB 二进制(前缀)      │   增量 AOF 命令(后缀)         │
├─────────────────────────┼───────────────────────────────┤
│ REDIS0011 ...            │ *3$3SET$2k1$3new\r\n           │
│ k1=v1 k2=v2 k3=...       │ *2$4INCR$3cnt\r\n              │
│ (上次重写时全量内存)      │ *3$3DEL$2k2\r\n                │
│ ... EOF + CRC64          │ ... 新写入持续追加 ...          │
└─────────────────────────┴───────────────────────────────┘
        ↑                              ↑
   加载时直接 RDB load              加载时按 RESP 重放
   (秒级)                           (只重放一次重写以来的增量)

6.4.3 为什么这样能既快又稳?

  • 重启加载快:RDB 部分直接二进制 load,比逐条 eval 命令快 10 倍以上。
  • 数据丢失少:增量部分仍然是 AOF(everysec),最多丢 1 秒。
  • 文件小:RDB 紧凑 + 仅保留增量,比纯 AOF 小很多。

🔧 实战建议:生产环境直接 appendonly yes + aof-use-rdb-preamble yes(4.0+ 默认),等于免费拿到「快 + 稳」。


6.5 数据恢复优先级:AOF > RDB

Redis 启动时按下面的顺序找数据:

                  ┌───────────────────┐
                  │ Redis 启动        │
                  └─────────┬─────────┘


              ┌─────────────────────────┐
              │ appendonly yes ?        │
              └────┬───────────────┬────┘
                   │ 是             │ 否
                   ▼                ▼
        ┌─────────────────┐   ┌──────────────┐
        │ 加载 appendonly │   │ 加载 dump.rdb│
        │ .aof(含混合)  │   │              │
        └─────────────────┘   └──────────────┘

为什么 AOF 优先? 因为 AOF 通常比 RDB 更新更频繁(everysec),数据更新;RDB 可能是几小时前的快照。

⚠️ 避坑:如果开了 AOF 又想从 RDB 恢复(比如 AOF 损坏),要先关掉 appendonly 再启动;或者把 AOF 文件挪走。


6.6 RDB vs AOF 对比矩阵 + 选型建议

┌─────────────────┬──────────────┬──────────────┬──────────────┐
│  维度            │  RDB         │  AOF         │  混合         │
├─────────────────┼──────────────┼──────────────┼──────────────┤
│ 数据完整性       │ ❌ 丢分钟级   │ ✅ 丢 1 秒    │ ✅ 丢 1 秒    │
│ 恢复速度         │ ⚡ 极快       │ 🐢 慢         │ ⚡ 快         │
│ 文件体积         │ ⚡ 小         │ ❌ 大         │ 🟢 中         │
│ 性能影响         │ 🟢 fork 时短暂│ 🟢 持续小消耗 │ 🟢 持续小消耗 │
│ 可读性           │ ❌ 二进制     │ ✅ 文本       │ 🟡 混合       │
│ 误操作可救       │ ❌            │ ✅            │ ✅(增量部分) │
└─────────────────┴──────────────┴──────────────┴──────────────┘

选型建议

场景建议配置
纯缓存,数据可丢失关闭 RDB+AOF,全靠 MySQL 兜底
一般业务混合持久化(默认推荐)
对持久化要求极高(金融)AOF + appendfsync always + 多副本
大数据集 + 容灾备份RDB 定期 + 异地传输
测试 / 开发用 RDB 即可,文件小

6.7 实操:跑一遍配套代码 + 玩 demo

6.7.1 配套代码

实战代码见 06_persistence/code/,三个脚本对应本章核心场景:

脚本学到什么关键 API
01_rdb_demo.pyBGSAVE 触发 RDB、LASTSAVE 时间戳变化、INFO persistence 关键字段BGSAVE / LASTSAVE / INFO persistence
02_aof_demo.py查看 AOF 配置、写入冗余命令、BGREWRITEAOF 重写并观察文件压缩CONFIG GET / BGREWRITEAOF / aof_rewrite_in_progress
03_persistence_compare.py对同一批 10 万 SET 分别在 always / everysec / no 下测吞吐CONFIG SET appendfsync / pipeline

跑法:

bash
cd 06_persistence/code
python 01_rdb_demo.py            # 看 RDB 触发
python 02_aof_demo.py            # 看 AOF 重写
python 03_persistence_compare.py # 看三档刷盘吞吐差距(耗时几十秒)

⚠️ 03_persistence_compare.py 会临时 CONFIG SET appendfsync 切换策略, 跑完会还原成原值;务必只在测试实例上运行。

6.7.2 浏览器演示

可视化页面见 06_persistence/demo.html,三个交互 Tab:

  • Tab 1 · fork + COW 写时复制:把父进程 16 个内存页画成色块,点 fork 之后父子共享物理页;点「父进程修改」会看到对应页红色 → 黄色地被「分裂」(copy)一份给父进程,子进程仍指旧页。底部计数器实时显示「物理内存 = 基础 + COW 复制数」。
  • Tab 2 · AOF 三档刷盘策略对比:一条时间轴动画,左中右三栏分别是 always / everysec / no。客户端不停下发 SET,色块依次走「内存 → page cache → 磁盘」三段;中途按「⚡ 模拟断电」就能看到三档分别丢多少数据。
  • Tab 3 · AOF 重写过程:左侧是原始 AOF(满满的 INCR / LPUSH / SET 同 key 多次),点「触发重写」后右侧出现精简后的最小命令集;同时演示重写期间双缓冲——新写入的命令会同时进「原 AOF 缓冲」与「重写缓冲区」。

每个 Tab 顶部都给了控制按钮,可以一步步触发,对照本章正文体会。

6.7.3 故障演练(命令行版)

演练 1:BGSAVE 后 kill 进程,看能恢复多少

bash
127.0.0.1:6379> MSET k1 v1 k2 v2 k3 v3
OK
127.0.0.1:6379> BGSAVE
Background saving started
127.0.0.1:6379> LASTSAVE
(integer) 1713345678
127.0.0.1:6379> MSET k4 v4 k5 v5      # 这批在快照之后

$ kill -9 <redis-pid>                  # 模拟宕机
$ redis-server /etc/redis/redis.conf   # 重启

127.0.0.1:6379> KEYS *
1) "k1"  2) "k2"  3) "k3"              # k4/k5 没了 ❌

结论:纯 RDB 模式下,最后一次 BGSAVE 之后的数据全部丢失

演练 2:开启 AOF 再做同样的实验

bash
127.0.0.1:6379> CONFIG SET appendonly yes
127.0.0.1:6379> MSET a 1 b 2 c 3
$ kill -9 <redis-pid>
$ redis-server /etc/redis/redis.conf
127.0.0.1:6379> KEYS *
1) "a" 2) "b" 3) "c"                   # 全部恢复 ✅

演练 3:误执行 FLUSHALL,怎么救?

bash
127.0.0.1:6379> FLUSHALL
OK

# 救援步骤:
$ redis-cli SHUTDOWN NOSAVE            # ① 不要让 RDB 覆盖
$ vim appendonly.aof                   # ② 删掉末尾那条 FLUSHALL
$ redis-check-aof --fix appendonly.aof # ③ 校验
$ redis-server /etc/redis/redis.conf   # ④ 重启 → 数据回来 ✅

💡 这就是 AOF「人类可读」的救命价值。RDB 是二进制改不了,救不了。


6.8 本章小结

┌──────────────────────────────────────────────────────────────┐
│                     第 6 章 核心要点                            │
├──────────────────────────────────────────────────────────────┤
│                                                                │
│  ① 持久化两条路:拍快照(RDB)vs 记日志(AOF)                  │
│                                                                │
│  ② RDB:BGSAVE = fork + COW 子进程写盘                         │
│      - fork 复制页表(不复制数据),但本身有阻塞                 │
│      - COW:父进程写时才真正复制内存页(最坏可能翻倍)            │
│                                                                │
│  ③ AOF:写后日志,三档刷盘                                      │
│      - always(不丢但慢)/ everysec(推荐)/ no(最快但不安全)  │
│      - everysec 由 BIO 后台线程异步 fsync                       │
│                                                                │
│  ④ AOF 重写:基于内存生成最精简命令序列,子进程完成               │
│      - 重写期间用「重写缓冲区」收集新命令                        │
│      - Redis 7 升级为 multi-part AOF                          │
│                                                                │
│  ⑤ 混合持久化(4.0+,默认开启)                                 │
│      - RDB 头 + 增量 AOF 尾,恢复快 + 数据全                    │
│                                                                │
│  ⑥ 启动时优先加载 AOF(更新更新)                                │
│                                                                │
└──────────────────────────────────────────────────────────────┘

6.9 面试高频题

Q1:RDB 和 AOF 有什么区别?怎么选?

考察点:对两种持久化的本质理解 + 工程选型能力。

标准答案

本质区别

  • RDB二进制内存快照,记录某一时刻全部数据。
  • AOF追加式命令日志,记录每一条写命令。

对比

维度RDBAOF
数据安全性可能丢分钟级最多丢 1 秒
恢复速度极快(直接 load)慢(重放命令)
文件体积小(紧凑二进制)大(文本命令)
可读性不可读人类可读
性能影响fork 时短暂持续小消耗

选型建议

  • 纯缓存可以都不开,靠数据库兜底;
  • 一般业务推荐混合持久化(4.0+ 默认);
  • 金融级数据用 AOF + always,并配多副本与异地备份。

加分项:提到 4.0 引入的混合持久化(aof-use-rdb-preamble),结合 RDB 的快和 AOF 的稳,是当前最佳实践。

易错点:说 "AOF 比 RDB 快" —— 这是反的,AOF 文件大、恢复慢。


Q2:BGSAVE 时主线程能继续服务吗?fork 和 COW 是怎么工作的?

考察点:操作系统 + Redis 内核理解。

标准答案

。这正是 BGSAVE 区别于 SAVE 的核心。

fork 的过程

  1. 主线程调用 fork() 系统调用,OS 创建子进程;
  2. fork 不会复制数据,只复制进程页表;父子进程的页表都指向同一份物理内存;
  3. fork 完成后,子进程负责遍历内存写 RDB父进程继续响应客户端

COW(Copy-On-Write)

  • fork 时所有内存页被标记为只读
  • 父进程一旦写入某一页,触发 page fault,OS 才把那一页真正复制一份给父进程,子进程继续看旧的。
  • 这样保证了子进程的数据快照一致,同时大多数页只读共享,省内存。

注意点

  • fork 本身不是无代价:复制页表的开销随内存大小增长(10 GB 数据可能 200~500ms)。
  • 极端情况下(父进程疯狂写入),COW 可能让物理内存接近翻倍,需要预留足够内存。

加分项:说出 fork 阻塞主线程时通过 INFO statslatest_fork_usec 可以观察;建议单实例内存控制在 10 GB 以内。

易错点:说 "BGSAVE 会复制全部内存" —— 不会,只复制页表,COW 才决定要不要复制具体页。


Q3:AOF 三种刷盘策略的区别?everysec 真的是「每秒一次」吗?

考察点:write/fsync 区别 + Redis 后台线程机制。

标准答案

写文件分两步:write() 进入 OS 内核 page cache,fsync() 把 page cache 强制刷到磁盘。只有 fsync 完成数据才真正落盘

策略行为性能最坏丢失
always每条命令都 fsync最差0(理论)
everysec每秒 fsync折中(默认)1~2 秒
no由 OS 决定最好30 秒+

everysec 实际是「每秒一次」吗?不严格是。

Redis 启动一个 BIO 后台线程专门执行 fsync,主线程只 write 到 page cache:

  1. 主线程每次事件循环检查上次 fsync 距今是否 ≥ 1 秒,是则把任务派发给 BIO 线程;
  2. BIO 线程的 fsync 可能耗时 100ms+;
  3. 如果上次 fsync 还没完成又到 1 秒了,主线程会阻塞等待避免数据堆积;
  4. 所以最坏情况丢失 1~2 秒数据,并伴随主线程阻塞。

加分项:提到 INFO persistence 中的 aof_delayed_fsync 字段,可以观察因 fsync 慢导致主线程阻塞的次数。

易错点:以为 everysec 严格 1 秒,且无任何阻塞 —— 错。


Q4:AOF 重写是怎么回事?为什么要重写?重写期间数据怎么不丢?

考察点:AOF 工程问题 + 子进程协作机制。

标准答案

为什么要重写? AOF 是只追加日志,时间长了会有大量冗余命令(如对同一 key 的多次 SET、过期 key 的命令、被 DEL 的 key 历史等)。重写会基于当前内存状态生成最精简的命令序列,大幅缩小文件体积,加快恢复速度。

怎么重写?

  1. 主进程 fork() 出子进程;
  2. 子进程遍历内存,按当前状态生成最少的命令写到临时文件(不读旧 AOF);
  3. 完成后通知主进程;
  4. 主进程把这期间新写入的命令追加到临时文件末尾;
  5. rename 临时文件,原子替换旧 AOF。

重写期间数据怎么不丢?

主进程双写新命令:

  • 一份写入原 AOF 缓冲区(保证宕机时旧 AOF 仍可用)
  • 一份写入AOF 重写缓冲区(用于追加到新 AOF 末尾)
主进程收到新写命令
   ├─ 写到原 AOF 缓冲区(→ 现有 appendonly.aof)
   └─ 写到 AOF 重写缓冲区(→ 子进程结束后追加到新文件)

子进程完成 → 主进程把重写缓冲区内容追加到临时文件 → rename 替换。

加分项

  • Redis 7 引入 multi-part AOF(manifest + base.rdb + incr.aof),消除重写缓冲区的双写消耗;
  • 重写触发条件:auto-aof-rewrite-percentage 100 + auto-aof-rewrite-min-size 64mb

易错点:以为重写是"读旧 AOF 再压缩"—— 错,是基于当前内存状态重新生成。


Q5:混合持久化解决了什么问题?

考察点:对 RDB/AOF 各自缺点的理解 + 4.0+ 的演进。

标准答案

痛点

  • 纯 RDB:恢复快,但可能丢分钟级数据
  • 纯 AOF:数据全,但恢复慢(要逐条重放命令,10 GB 数据可能要几十分钟)。

混合持久化aof-use-rdb-preamble yes,4.0+ 默认)的做法:

重写时把当前内存以 RDB 二进制形式写入 AOF 文件开头,之后新命令按 AOF 格式追加:

[ RDB 二进制(重写时全量内存) ] + [ 增量 AOF 命令 ]

恢复时

  • 先 load RDB 部分(极快);
  • 再重放后面的增量 AOF(量很小,最多一次重写以来的)。

结果:兼顾 RDB 的恢复速度与 AOF 的数据完整性。

加分项:提到 Redis 7 的 multi-part AOF 把 RDB 部分独立成 appendonly.aof.1.base.rdb,增量部分是 appendonly.aof.1.incr.aof,机制更清晰。

易错点:以为混合持久化是「同时开 RDB + AOF」—— 不是,混合是 AOF 文件内部结构变了。


Q6:Redis 重启后从 RDB 还是 AOF 恢复?为什么?

考察点:启动恢复流程。

标准答案

优先级AOF > RDB(如果 AOF 开启)。

完整流程

  1. 检查 appendonly 是否为 yes
  2. 是 → 加载 appendonly.aof(含混合持久化的 RDB 头);
  3. 否 → 加载 dump.rdb
  4. 都没有 → 空启动。

为什么优先 AOF? AOF 默认 everysec 刷盘,数据更新最近;RDB 是周期性快照,可能是几十分钟甚至几小时前的版本。优先 AOF 可以最大化保留数据

加分项

  • 加载 AOF 失败时(文件损坏),可用 redis-check-aof --fix 修复;
  • 如果想强制从 RDB 恢复,要先关掉 appendonly 或把 AOF 文件挪走;
  • 加载完成后会有日志:DB loaded from append only file: x.xxx seconds

易错点

  • 说 "RDB 比 AOF 优先" —— 反了;
  • 说 "两个都加载" —— 错,只加载一个。

📌 下一章预告:第 7 章我们看 Redis 的「事务、Pipeline、Lua、Pub/Sub、Stream」—— Redis 怎么玩多命令原子性?为什么 Redis 事务不是「真事务」?Pipeline 与事务的本质区别是什么?

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

python
"""
Ch6 配套代码 1 / 3 —— RDB 触发与观察

演示:
  1. 写入一批数据后用 BGSAVE 异步触发 RDB 快照
  2. 通过 LASTSAVE 时间戳前后对比,确认快照已落盘
  3. 用 INFO persistence 解读 rdb_changes_since_last_save / latest_fork_usec
     等关键字段,体会 fork + COW 在生产中的可观测性
"""

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


def section(title: str) -> None:
    print("\n" + "=" * 60)
    print(title)
    print("=" * 60)


def fmt_ts(ts) -> str:
    if hasattr(ts, "timestamp"):
        ts = int(ts.timestamp())
    return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(int(ts)))


def demo_prepare_data(n: int = 2000) -> None:
    section(f"Demo 1: 写入 {n} 个 key 作为 RDB 的『原料』")
    r.flushdb()
    pipe = r.pipeline()
    for i in range(n):
        pipe.set(f"rdb:key:{i}", f"value-{i}-{'x' * 20}")
    pipe.execute()
    print(f"  已写入 {r.dbsize()} 个 key")


def demo_lastsave_around_bgsave() -> None:
    section("Demo 2: BGSAVE 前后观察 LASTSAVE")

    before = r.lastsave()
    print(f"  BGSAVE 之前  LASTSAVE = {fmt_ts(before)}")

    print("  → 触发 BGSAVE ...")
    r.bgsave()

    for _ in range(20):
        time.sleep(0.5)
        now = r.lastsave()
        if now != before:
            print(f"  BGSAVE 完成  LASTSAVE = {fmt_ts(now)}")
            return
    print("  ⚠️ 等待 10s 仍未观察到 LASTSAVE 变化(数据集太大或被 throttle)")


def demo_info_persistence() -> None:
    section("Demo 3: INFO persistence 关键字段")
    info = r.info("persistence")

    fields = [
        ("rdb_changes_since_last_save",  "上次 save 后变更的 key 数(决定自动触发)"),
        ("rdb_bgsave_in_progress",       "当前是否正在 BGSAVE(1 = 是)"),
        ("rdb_last_save_time",           "上次成功 RDB 的 unix 时间戳"),
        ("rdb_last_bgsave_status",       "上次 BGSAVE 状态:ok / err"),
        ("rdb_last_bgsave_time_sec",     "上次 BGSAVE 耗时(秒)"),
        ("rdb_current_bgsave_time_sec",  "当前 BGSAVE 已运行秒数(-1 表示无)"),
        ("rdb_last_cow_size",            "上次 BGSAVE 期间 COW 复制的字节数"),
        ("latest_fork_usec",             "最近一次 fork 耗时(微秒,越大越卡)"),
    ]

    print(f"  {'字段':<35} {'值':<18} 含义")
    print(f"  {'-'*35} {'-'*18} {'-'*40}")
    for k, desc in fields:
        v = info.get(k, "N/A")
        if k == "rdb_last_save_time" and isinstance(v, int):
            v = f"{v} ({fmt_ts(v)})"
        print(f"  {k:<35} {str(v):<18} {desc}")


def demo_save_vs_bgsave_note() -> None:
    section("Demo 4: SAVE / BGSAVE / 自动触发 三档对比")
    print("""
    SAVE   ➜ 主线程同步执行,期间所有客户端命令阻塞
            ➜ 数据量大时阻塞数秒,⚠️ 生产严禁使用

    BGSAVE ➜ fork 子进程,主线程继续服务
            ➜ fork 本身仍有短暂阻塞(页表复制),可观察 latest_fork_usec

    自动   ➜ save m n 配置;任意一条满足即触发 BGSAVE
            ➜ 默认:3600s/1次, 300s/100次, 60s/10000次
            ➜ 禁用:CONFIG SET save ""
    """)


if __name__ == "__main__":
    try:
        demo_prepare_data()
        demo_lastsave_around_bgsave()
        demo_info_persistence()
        demo_save_vs_bgsave_note()
    except redis.ConnectionError as e:
        print(f"❌ Redis 连接失败: {e}")
python
"""
Ch6 配套代码 2 / 3 —— AOF 配置查看 + 重写触发

演示:
  1. CONFIG GET 看 AOF 是否启用、刷盘策略、混合持久化开关等
  2. 写入并删除大量 key 制造冗余命令,观察 AOF 文件膨胀
  3. BGREWRITEAOF 触发重写,通过 INFO persistence 的 aof_rewrite_in_progress
     与 aof_current_size 看到「重写完成 → 文件被压缩」的过程
"""

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)


def section(title: str) -> None:
    print("\n" + "=" * 60)
    print(title)
    print("=" * 60)


def show_aof_config() -> None:
    section("Demo 1: CONFIG GET —— AOF 相关参数")

    keys = [
        ("appendonly",                   "是否启用 AOF(yes/no)"),
        ("appendfsync",                  "刷盘策略:always / everysec / no"),
        ("appendfilename",               "AOF 文件名(7+ 是 manifest 名)"),
        ("appenddirname",                "AOF 目录名(Redis 7 新增)"),
        ("aof-use-rdb-preamble",         "混合持久化开关"),
        ("auto-aof-rewrite-percentage",  "AOF 增长百分比触发自动重写"),
        ("auto-aof-rewrite-min-size",    "AOF 最小多大才考虑自动重写"),
        ("no-appendfsync-on-rewrite",    "重写期间是否暂停 fsync(避免阻塞)"),
    ]

    print(f"  {'参数':<35} {'当前值':<22} 含义")
    print(f"  {'-'*35} {'-'*22} {'-'*38}")
    for k, desc in keys:
        v = r.config_get(k)
        val = list(v.values())[0] if v else "(不存在)"
        print(f"  {k:<35} {val:<22} {desc}")


def write_then_delete(prefix: str, n: int) -> None:
    pipe = r.pipeline()
    for i in range(n):
        pipe.set(f"{prefix}:{i}", f"v-{i}-{'a' * 30}")
    pipe.execute()
    pipe = r.pipeline()
    for i in range(n):
        pipe.delete(f"{prefix}:{i}")
    pipe.execute()


def demo_trigger_rewrite() -> None:
    section("Demo 2: BGREWRITEAOF —— 把冗余命令压成最小集")

    if r.config_get("appendonly").get("appendonly") != "yes":
        print("  ⚠️ 当前未启用 AOF,临时打开 ...")
        r.config_set("appendonly", "yes")
        time.sleep(0.5)

    info1 = r.info("persistence")
    size_before = info1.get("aof_current_size", 0)
    print(f"  step 1  aof_current_size = {size_before:>10} bytes")

    print("  step 2  写入并删除 3000 个 key(制造 6000 条冗余命令)...")
    write_then_delete("rewrite", 3000)

    info2 = r.info("persistence")
    size_after_write = info2.get("aof_current_size", 0)
    print(f"  step 3  aof_current_size = {size_after_write:>10} bytes  "
          f"(增加了 {size_after_write - size_before})")

    print("  step 4  触发 BGREWRITEAOF ...")
    r.bgrewriteaof()

    for _ in range(40):
        time.sleep(0.5)
        info3 = r.info("persistence")
        if info3.get("aof_rewrite_in_progress", 0) == 0:
            size_final = info3.get("aof_current_size", 0)
            saved = (1 - size_final / max(size_after_write, 1)) * 100
            print(f"  step 5  aof_current_size = {size_final:>10} bytes  "
                  f"(压缩率 {saved:.1f}%)")
            return
    print("  ⚠️ 重写超时未完成,可稍后再 INFO persistence 查看")


def demo_aof_info_fields() -> None:
    section("Demo 3: INFO persistence —— AOF 相关字段")
    info = r.info("persistence")
    fields = [
        ("aof_enabled",                  "AOF 是否启用"),
        ("aof_rewrite_in_progress",      "是否正在重写"),
        ("aof_rewrite_scheduled",        "是否有重写排队"),
        ("aof_last_rewrite_time_sec",    "上次重写耗时(秒)"),
        ("aof_current_rewrite_time_sec", "当前重写已运行秒数"),
        ("aof_last_bgrewrite_status",    "上次重写状态"),
        ("aof_last_write_status",        "上次 write 是否成功"),
        ("aof_last_cow_size",            "上次重写 COW 占用字节"),
        ("aof_current_size",             "当前 AOF 文件大小"),
        ("aof_base_size",                "上次重写后的基准大小"),
        ("aof_buffer_length",            "AOF 缓冲区还未 write 的字节数"),
        ("aof_delayed_fsync",            "因 fsync 慢而被迫等待的次数(关键指标)"),
    ]
    for k, desc in fields:
        v = info.get(k, "N/A")
        print(f"  {k:<32} = {str(v):<14} {desc}")


if __name__ == "__main__":
    try:
        show_aof_config()
        demo_trigger_rewrite()
        demo_aof_info_fields()
    except redis.ConnectionError as e:
        print(f"❌ Redis 连接失败: {e}")
python
"""
Ch6 配套代码 3 / 3 —— appendfsync 三档刷盘策略性能对比

演示:
  对同样一批写入(默认 10 万 SET),分别在 always / everysec / no 三种刷盘
  策略下用 pipeline 跑,统计耗时与 QPS。直观感受:always 多慢、everysec
  与 no 之间差距其实不大。

⚠️ 运行前请确认:
  - 当前实例不是生产实例
  - 已开启 AOF(脚本会临时切换 appendfsync,结束后还原)
"""

import time
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

TOTAL_OPS    = 100_000     # 默认写入条数
PIPELINE_BAT = 200         # 每个 pipeline 的批大小
KEY_PREFIX   = "perf:fsync"


def section(title: str) -> None:
    print("\n" + "=" * 60)
    print(title)
    print("=" * 60)


def ensure_aof() -> None:
    if r.config_get("appendonly").get("appendonly") != "yes":
        print("  ⚠️ 临时开启 appendonly = yes")
        r.config_set("appendonly", "yes")
        time.sleep(0.3)


def cleanup() -> None:
    cur = b"0"
    cur, keys = r.scan(cursor=0, match=f"{KEY_PREFIX}:*", count=2000)
    while keys:
        if keys:
            r.delete(*keys)
        if cur in (0, "0", b"0"):
            break
        cur, keys = r.scan(cursor=cur, match=f"{KEY_PREFIX}:*", count=2000)


def run_one(strategy: str, total: int) -> tuple:
    r.config_set("appendfsync", strategy)
    cleanup()

    start = time.perf_counter()
    pipe = r.pipeline(transaction=False)
    for i in range(total):
        pipe.set(f"{KEY_PREFIX}:{strategy}:{i}", f"v-{i}")
        if (i + 1) % PIPELINE_BAT == 0:
            pipe.execute()
    pipe.execute()
    elapsed = time.perf_counter() - start

    qps = total / elapsed if elapsed > 0 else 0
    info = r.info("persistence")
    delayed = info.get("aof_delayed_fsync", 0)
    return elapsed, qps, delayed


def main() -> None:
    section("appendfsync 三档刷盘策略 · 吞吐对比")
    print(f"  写入量:{TOTAL_OPS:,} SET  ·  pipeline 批大小:{PIPELINE_BAT}")
    print(f"  ⚠️ always 在 SSD 上仍可能比 everysec 慢 5~20 倍\n")

    original = r.config_get("appendfsync").get("appendfsync", "everysec")
    results = []
    try:
        ensure_aof()
        for strat in ("always", "everysec", "no"):
            print(f"  → 跑 {strat} ...")
            elapsed, qps, delayed = run_one(strat, TOTAL_OPS)
            results.append((strat, elapsed, qps, delayed))
            print(f"    耗时 {elapsed:7.2f} s  ·  QPS {qps:>10,.0f}"
                  f"  ·  aof_delayed_fsync = {delayed}")
    finally:
        r.config_set("appendfsync", original)
        cleanup()

    section("结果汇总(以 everysec 为基准)")
    base = next((q for s, _, q, _ in results if s == "everysec"), 1) or 1
    print(f"  {'策略':<12} {'耗时(s)':<12} {'QPS':<14} {'相对 everysec':<14}")
    print(f"  {'-'*12} {'-'*12} {'-'*14} {'-'*14}")
    for strat, elapsed, qps, _ in results:
        ratio = qps / base
        print(f"  {strat:<12} {elapsed:<12.2f} {qps:<14,.0f} {ratio:<14.2%}")

    print("""
  💡 解读:
    · always   每条命令同步 fsync(),磁盘 IO 是瓶颈;HDD 更明显
    · everysec 主线程只 write 进 page cache,BIO 异步 fsync,性能/安全最佳折中
    · no       完全交给 OS,性能略好于 everysec,但宕机最多丢 30s+
    · aof_delayed_fsync 持续上涨 → 磁盘扛不住,需要换 SSD 或换策略
    """)


if __name__ == "__main__":
    try:
        main()
    except redis.ConnectionError as e:
        print(f"❌ Redis 连接失败: {e}")

01_rdb_demo.py ↗ · 02_aof_demo.py ↗ · 03_persistence_compare.py ↗