Redis 系列 · 第六篇

Redis 进程被 kill -9 之后,数据到底还在不在?

Redis 把数据放在内存里,断电或者进程崩溃,内存里的东西说没就没——持久化就是"把内存里的东西定期或者实时地也写一份到磁盘上"。Redis 提供两种机制:RDB(某一刻的全量快照)和 AOF(把每条写命令都记下来)。这一篇不只讲两种机制怎么实现,还在本机真实模拟了一次进程崩溃,实测两种模式下到底丢了什么。

本机真实运行的 Redis 8.10.1:RDB-only 模式下,一次 kill -9 真实丢失了快照之后的写入;开着 AOF 之后,同样的崩溃,数据完整拿了回来。

1 条丢失
RDB-only 模式,快照之后写入的 key,真实崩溃测试后没了
0 条丢失
开启 AOF(appendfsync everysec),同样的崩溃,真实测试全部保留

问题:内存里的数据,靠什么活过重启

两种持久化方式,对应两种完全不同的"记住数据"的思路。

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.1redis-bits 64ctime ... 都是明文可读的辅助字段(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.crdbSaveBackgroundrdb.hRDB_VERSIONaof.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.czmalloc_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 场景下持久化文件的差异。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电