主题
第 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- 主从复制全量同步时
FLUSHALL、DEBUG 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-ver、redis-bits、ctime、used-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 s6.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 为什么选写后?
- 避免给错误命令也写日志:先在内存中执行,能保证写入 AOF 的都是「已经成功」的命令。
INCR foo(foo 是字符串)这种会失败的,根本不会落盘。 - 不阻塞当前命令:先返回客户端 OK,再异步写盘,体验好。
- 缺点很明显:如果命令执行成功后、写盘前宕机,那条命令就丢了 → 所以才有了
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 刷到物理磁盘只 write 不 fsync:数据还在内存里,断电就丢。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 yes6.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.py | BGSAVE 触发 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 是追加式命令日志,记录每一条写命令。
对比:
| 维度 | RDB | AOF |
|---|---|---|
| 数据安全性 | 可能丢分钟级 | 最多丢 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 的过程:
- 主线程调用
fork()系统调用,OS 创建子进程; - fork 不会复制数据,只复制进程页表;父子进程的页表都指向同一份物理内存;
- fork 完成后,子进程负责遍历内存写 RDB,父进程继续响应客户端。
COW(Copy-On-Write):
- fork 时所有内存页被标记为只读。
- 父进程一旦写入某一页,触发 page fault,OS 才把那一页真正复制一份给父进程,子进程继续看旧的。
- 这样保证了子进程的数据快照一致,同时大多数页只读共享,省内存。
注意点:
- fork 本身不是无代价:复制页表的开销随内存大小增长(10 GB 数据可能 200~500ms)。
- 极端情况下(父进程疯狂写入),COW 可能让物理内存接近翻倍,需要预留足够内存。
加分项:说出 fork 阻塞主线程时通过 INFO stats 的 latest_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:
- 主线程每次事件循环检查上次 fsync 距今是否 ≥ 1 秒,是则把任务派发给 BIO 线程;
- BIO 线程的 fsync 可能耗时 100ms+;
- 如果上次 fsync 还没完成又到 1 秒了,主线程会阻塞等待避免数据堆积;
- 所以最坏情况丢失 1~2 秒数据,并伴随主线程阻塞。
加分项:提到 INFO persistence 中的 aof_delayed_fsync 字段,可以观察因 fsync 慢导致主线程阻塞的次数。
易错点:以为 everysec 严格 1 秒,且无任何阻塞 —— 错。
Q4:AOF 重写是怎么回事?为什么要重写?重写期间数据怎么不丢?
考察点:AOF 工程问题 + 子进程协作机制。
标准答案:
为什么要重写? AOF 是只追加日志,时间长了会有大量冗余命令(如对同一 key 的多次 SET、过期 key 的命令、被 DEL 的 key 历史等)。重写会基于当前内存状态生成最精简的命令序列,大幅缩小文件体积,加快恢复速度。
怎么重写?
- 主进程
fork()出子进程; - 子进程遍历内存,按当前状态生成最少的命令写到临时文件(不读旧 AOF);
- 完成后通知主进程;
- 主进程把这期间新写入的命令追加到临时文件末尾;
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 开启)。
完整流程:
- 检查
appendonly是否为yes; - 是 → 加载
appendonly.aof(含混合持久化的 RDB 头); - 否 → 加载
dump.rdb; - 都没有 → 空启动。
为什么优先 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 ↗