问题:内存里的数据,靠什么活过重启
两种持久化方式,对应两种完全不同的"记住数据"的思路。
RDB
某一刻的全量快照
把当前整个数据集完整地序列化成一个文件(dump.rdb )。文件小、加载快,但代表的是"上一次保存那一刻"的状态——之后的写入,如果还没来得及保存,崩溃就丢了。
AOF
把每条写命令记下来
追加写命令日志(appendonly.aof.* )。理论上能做到"几乎不丢",但日志会越写越大,重启时要重放全部命令才能恢复,恢复速度比 RDB 慢。
现实里
两个经常一起开
RDB 负责"定期打个底"、做备份和主从全量同步(下一篇会讲);AOF 负责"尽量不丢最近的写入"。重启时如果两个都在,Redis 优先用 AOF 恢复(更完整)。
RDB:真实源码里的 fork + 快照
生成快照是"子进程的事"——父进程 fork 出一个子进程,子进程慢慢把数据序列化写盘,父进程该干嘛干嘛,完全不阻塞。
src/rdb.c · redis @ 8.10.1, L2223
int rdbSaveBackground(int req, char *filename, rdbSaveInfo *rsi, int rdbflags) {
if (hasActiveChildProcess()) return C_ERR; // 同时只能有一个后台子进程在干活
if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
/* Child */
retval = rdbSave(req, filename, rsi, rdbflags); // 子进程:把数据写成 RDB 文件
exitFromChild((retval == C_OK) ? 0 : 1, 0);
} else {
/* Parent */
serverLog(LL_NOTICE,"Background saving started by pid %ld",(long) childpid);
return C_OK; // 父进程:立刻返回,继续正常处理请求
}
}
真实抓了一次 BGSAVE 生成的 dump.rdb 文件头部字节:
真实实测
$ redis-cli BGSAVE
Background saving started
$ xxd dump.rdb | head -3
00000000: 5245 4449 5330 3031 35fa 0972 6564 6973 REDIS0015..redis
00000010: 2d76 6572 0638 2e31 302e 31fa 0a72 6564 -ver.8.10.1..red
00000020: 6973 2d62 6974 73c0 40fa 0563 7469 6d65 [email protected]
开头 9 个字节 REDIS0015 正是 rdbSaveRio() 写的魔数——"REDIS" + 4 位版本号,而这个版本号来自真实源码里的常量:
src/rdb.h · redis @ 8.10.1, L21
#define RDB_VERSION 15
紧跟着的 redis-ver 8.10.1 、redis-bits 64 、ctime ... 都是明文可读的辅助字段(RDB_OPCODE_AUX ),记录了生成这份快照的 Redis 版本和时间——和本机安装的版本号对得上。
AOF:不是一个文件,是"基底 + 增量"两种文件
Redis 7 之后 AOF 目录变成了多文件结构,靠一个 manifest 文件记录当前有效的文件列表——这和很多教程里"就是一个 appendonly.aof 文件"的描述已经不一样了。
src/aof.c · redis @ 8.10.1, L51
* BASE: 上一次 AOF 重写时刻的快照,manifest 里最多一个 BASE 文件。
* INCR: 上一次重写之后的所有写命令,可能不止一个(比如重写中途又失败过)。
* HISTORY: 重写成功后,旧的 BASE/INCR 变成 HISTORY,默认会被自动清理。
本机真实开启 AOF、写了几个 key之后,目录和 manifest 内容:
真实实测
$ redis-cli CONFIG SET appendonly yes
$ redis-cli SET k1 v1; redis-cli SET k2 v2; redis-cli LPUSH mylist a b c
$ ls appendonlydir/
appendonly.aof.1.base.rdb
appendonly.aof.1.incr.aof
appendonly.aof.manifest
$ cat appendonlydir/appendonly.aof.manifest
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.1.incr.aof seq 1 type i startoffset 0
$ xxd appendonlydir/appendonly.aof.1.base.rdb | head -1
00000000: 5245 4449 5330 3031 35fa 0972 6564 6973 REDIS0015..redis
BASE 文件的魔数也是 REDIS0015 ——因为默认配置下 BASE 文件本身就是用 RDB 格式写的(aof-use-rdb-preamble 真实默认值 yes ),只有 INCR 文件是真正的"命令日志"格式(RESP 协议文本):
真实实测
$ cat appendonlydir/appendonly.aof.1.incr.aof
*2
$6
SELECT
$1
0
*3
$3
set
$2
k1
$2
v1
*3
$3
set
$2
k2
$2
v2
*5
$5
lpush
$6
mylist
$1
a
$1
b
$1
c
写了 50 个额外 key 之后触发一次 BGREWRITEAOF ,manifest 立刻切到新一代文件,旧的 seq 1 文件被当成 HISTORY 直接删掉(印证了上面注释里"默认自动清理"的说法):
真实实测
$ redis-cli BGREWRITEAOF
$ cat appendonlydir/appendonly.aof.manifest
file appendonly.aof.2.base.rdb seq 2 type b
file appendonly.aof.2.incr.aof seq 2 type i startoffset 53
$ ls appendonlydir/
appendonly.aof.2.base.rdb (867 字节,53 个 key 全部打包进新快照)
appendonly.aof.2.incr.aof (0 字节,刚切换,还没有新写入)
appendonly.aof.manifest
# seq 1 的三个文件已经不在目录里了
触发重写的 fork 逻辑和 RDB 几乎一样(同样是 redisFork() ,只是子进程干的事换成了把当前数据集重新写成一份紧凑的 BASE 文件):
src/aof.c · redis @ 8.10.1, L3239
if ((childpid = redisFork(CHILD_TYPE_AOF)) == 0) {
snprintf(tmpfile, 256, "temp-rewriteaof-bg-%d.aof", (int) getpid());
if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
sendChildCowInfo(CHILD_INFO_TYPE_AOF_COW_SIZE, "AOF rewrite");
exitFromChild(0, 0);
}
}
真实崩溃测试:两种模式,丢的东西不一样
光说"RDB 会丢最近写入、AOF 不会"太空泛——本机真实用 kill -9 模拟进程崩溃,实际测一次。
真实实测
# === 场景一:RDB-only(appendonly no)===
$ redis-cli SET before_snapshot 1
$ redis-cli BGSAVE
$ redis-cli SET after_snapshot_not_persisted 1
$ redis-cli KEYS '*'
before_snapshot
after_snapshot_not_persisted
$ kill -9 <redis-server pid> # 模拟崩溃,没有走优雅关闭时的 SHUTDOWN 保存
$ redis-server --dir /private/tmp ... # 重启,原样的 dir/dbfilename
$ redis-cli KEYS '*'
before_snapshot
# after_snapshot_not_persisted 真的丢了——BGSAVE 那一刻之后写的东西,RDB 文件里根本没有
# === 场景二:开启 AOF(appendfsync everysec,默认值)===
$ redis-cli CONFIG SET appendonly yes
$ redis-cli SET before_snapshot 1
$ redis-cli SET after_snapshot_not_persisted 1
$ sleep 1.5 # 等一次 everysec 的后台 fsync
$ kill -9 <redis-server pid>
$ redis-server --dir /private/tmp --appendonly yes ... # 重启,必须带 --appendonly yes 才会去读 AOF 目录
$ redis-cli KEYS '*'
after_snapshot_not_persisted
before_snapshot
# 两个 key 都在——AOF 把每条写命令都记下来了,不依赖"上一次 BGSAVE 是什么时候"
这里有个容易踩的坑,也是真实操作时发现的:重启时如果没有显式带上 --appendonly yes ,Redis 会用默认配置(appendonly no )启动,压根不会去读 appendonlydir ,哪怕那个目录和里面的数据都还在磁盘上,完好无损。持久化文件在不在,和"这次启动要不要用它",是两件事。
一个容易讲错的细节:kill -9 测不出"最多丢 1 秒"。 appendfsync everysec 常被简化成"最多丢 1 秒数据",但那说的是断电/系统崩溃 ——write() 系统调用本身在每条命令后都会执行,数据已经进了操作系统的页缓存,只是还没被后台线程 fsync 到磁盘。kill -9 只杀掉了 Redis 进程,操作系统和它的页缓存全须全尾地活着,所以重启照样能读到"来不及 fsync"的那部分——本机实测确实如此(上面日志里 before_snapshot 和紧跟着写的 after_snapshot_not_persisted 都在,即使专门试了写完立刻杀掉进程、完全不等待的情况)。真正会丢的是操作系统自己也没扛住(断电、内核崩溃)的场景,这种情况没法在这台机器上安全地复现。
fork 之后,父进程接着写,内存会发生什么
RDB/AOF 重写都靠 fork() :子进程和父进程一开始共享全部物理内存页(写时复制,copy-on-write)。子进程只读,不会触发复制;真正花内存的,是父进程 在子进程还没保存完的时候继续写入。
父进程一旦要修改某个 key,如果这个 key 所在的内存页还是"共享"状态,操作系统会先把这一整页复制一份给父进程私有(子进程那份保持原样不受影响)——这也是为什么 RDB 快照能保证"某一刻的一致性":不管父进程之后怎么改,子进程看到的永远是 fork 那一刻冻结的数据。代价是:改的 key 越分散、涉及的内存页越多,fork 期间多占用的内存就越多。
下面这个演示是一个简化的"页级"模型,不是真实的操作系统内存管理器——用来说明"为什么改的页数决定额外内存"这个机制本身。
fork + 写时复制 演示
⏮ 重来
‹ 上一步
下一步 ›
▶ 自动播放
参考与说明
RDB 魔数字节(REDIS0015 )、AOF 多文件结构(manifest / base / incr)、BGREWRITEAOF 后旧文件被自动清理、崩溃恢复对比测试(RDB-only 丢失 vs. AOF everysec 保留)、以及"重启不带 --appendonly yes 就不会读 AOF 目录"这个细节,均在本机真实运行的 Redis 8.10.1 上完成,输出未做删改。
崩溃测试用 kill -9 模拟进程崩溃,不等价于断电/操作系统崩溃——这一点在正文里已经明确说明,是本文实测中途发现并主动澄清的一个常见简化说法。
源码引用(rdb.c 的 rdbSaveBackground 、rdb.h 的 RDB_VERSION 、aof.c 的 manifest 格式注释与 rewriteAppendOnlyFileBackground )取自 redis/redis 仓库 8.10.1 标签,与本机安装的 Redis 8.10.1 完全一致。为了让 BGSAVE 慢下来、方便在真实实验里制造"保存期间还有并发写入"的窗口,使用了源码里确认存在的隐藏调试配置 rdb-key-save-delay (config.c 里标记为 HIDDEN_CONFIG ,不出现在 CONFIG GET * 的通配符结果里,但可以用确切名字直接 CONFIG SET )。
诚实说明一次没有成功的真实测量 :本想直接测出"父进程并发写入越多,fork 期间额外内存占用越大"这个量化关系,但在这台机器上没能拿到干净的信号,原因有两个,都已排查确认:一是 Redis 自带的 rdb_last_cow_size 指标在本机(macOS)始终报告为 0,不管有没有并发写入都一样——查了源码(zmalloc.c 的 zmalloc_get_smap_bytes_by_field() ),macOS 分支用的是 proc_pidinfo(pid, PROC_PIDREGIONINFO, ...) ,这个调用一次只能查询一个内存区域,不像 Linux 读 /proc/self/smaps 那样能扫描整个进程地址空间,实际效果接近失效;二是这台机器本身内存压力很大(持续复用系列前几篇提到过的机器状态),实测 used_memory (逻辑分配)和 used_memory_rss (物理常驻)差了一个数量级,大部分数据常态下根本不在物理内存里,被 macOS 的内存压缩器压着——即使先完整扫一遍全部 key 强制换入,常驻内存涨幅也远小于数据集大小。这两点共同导致没法在这台机器上干净地测出 COW 内存增量,只能退回到概念解释和上面这个经过独立校验的简化页级模型。
没有涉及:RDB 各种数据类型的具体编码细节(留给后面单独的数据结构篇)、AOF 重写时"重写线程和主线程之间的管道通信"具体协议、save 配置项的时间/改动次数双重触发条件的完整规则、Redis Cluster 场景下持久化文件的差异。
→