1 问题背景:内存数据库的持久化困境
Redis 的性能来自一个简单前提:数据在内存里,读写路径极短。但内存是 volatile 的——进程崩溃、机器断电、容器被强杀,内存中的键值空间会在毫秒级消失。持久化要回答的是:如何在尽量不打折在线性能的前提下,把内存状态映射到磁盘,并在重启时重建。
这里需要先划清边界。持久化解决的是单机上的崩溃恢复与冷启动重建;它不替代主从复制、哨兵或 Cluster 提供的高可用。副本挂了可以从其他节点拉数据,但所有副本同时丢失时,能救你的只有磁盘上的 RDB 或 AOF。同样,持久化也不等于备份——把 appendonlydir/ 或 dump.rdb 留在本机,机房级故障照样全没;异地拷贝仍是最后一道防线。
Redis 的单线程事件模型给持久化加了四条硬约束,后文每个设计分支几乎都在这几条之间找平衡:
- 主线程不能被磁盘 I/O 长时间阻塞——否则所有客户端排队。
- 写入路径的 latency 要可控——AOF 的 fsync 是最大变量。
- 重启恢复时间要可接受——纯 AOF 命令重放随运行时间线性变慢。
- 磁盘占用与 rewrite 成本要可控——日志会膨胀,快照有 fork 成本。
2 核心结论:两条路径,各补一块短板
在动手看源码之前,先把 Redis 的「立场」说清楚:持久化不是「RDB 或 AOF 二选一」,而是用两套格式分别优化不同维度,再用混合加载和 Multi-Part AOF 修补各自演化中暴露的短板。
| 机制 | 优化目标 | 牺牲什么 | 源码中的立场声明 |
|---|---|---|---|
| RDB | 重启快、文件小、适合备份 | 两次快照间的写入可能丢失 | rdbSaveBackground 异步 fork |
| AOF | 写入级 durability、操作可审计 | 文件膨胀、replay 慢、rewrite 有 fork 成本 | feedAppendOnlyFile + 可配 fsync |
| 混合 / MP-AOF | 修补 AOF 的加载与运维短板 | 实现与运维复杂度上升 | aof-use-rdb-preamble、aof_manifest |
启动时的优先级同样体现这种立场:AOF 开启时,Redis 只走 loadAppendOnlyFiles,不会再去读独立的 dump.rdb。因为 AOF 记录的是操作历史,在正常运行下通常比最近一次 RDB 快照更新——这不是配置偏好,而是 durability 语义的自然结果。下面这段启动入口代码,就是把这一判断写进了分支:
// src/server.c — loadDataFromDisk()
void loadDataFromDisk(void) {
long long start = ustime();
if (server.aof_state == AOF_ON) {
int ret = loadAppendOnlyFiles(server.aof_manifest);
if (ret == AOF_FAILED || ret == AOF_OPEN_ERR)
exit(1);
/* ... */
} else {
rdbSaveInfo rsi = RDB_SAVE_INFO_INIT;
int rdb_load_ret = rdbLoad(server.rdb_filename, &rsi, rdb_flags);
/* ... */
}
}
这段代码说明:只要 AOF 开着,启动就以日志为准;加载失败直接退出,宁可起不来也不带脏数据跑。
3 持久化全景:共享基础设施背后的统一思路
RDB 和 AOF 看起来是两条路,但底层共用几件基础设施。下面三节按「何时落盘 → 如何拿到一致性视图 → 编码后写到哪里」展开,先把全景拼完整,再进 §4、§5 分讲两种格式。
flowchart TB
subgraph runtime [运行时写入路径]
Cmd[写命令] --> Propagate[propagateNow]
Propagate --> Dirty[server.dirty++]
Propagate --> AOFfeed[feedAppendOnlyFile]
AOFfeed --> AOFbuf[aof_buf]
Cron[serverCron] --> BGSAVE[rdbSaveBackground]
Cron --> AOFrewrite[rewriteAppendOnlyFileBackground]
end
subgraph disk [落盘]
AOFbuf --> Fsync[beforeSleep / flushAppendOnlyFile]
BGSAVE --> RDBfile[dump.rdb]
Fsync --> AOFfiles[manifest + base + incr]
AOFrewrite --> AOFfiles
end
subgraph startup [启动恢复]
Main[main] --> Load[loadDataFromDisk]
Load -->|aof_state==AOF_ON| LoadAOF[loadAppendOnlyFiles]
Load -->|else| LoadRDB[rdbLoad]
LoadAOF --> Mem[内存 db]
LoadRDB --> Mem
end3.1 为什么用 server.dirty,而不是纯定时器
RDB 的自动触发条件来自 save m n:在 m 秒内若发生至少 n 次写操作(server.dirty 计数),才发起 BGSAVE。serverCron 里的判断逻辑如下:
// src/server.c — serverCron() 片段
if (server.dirty >= sp->changes &&
server.unixtime - server.lastsave > sp->seconds &&
(server.unixtime - server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
serverLog(LL_NOTICE, "%d changes in %d seconds. Saving...", ...);
rdbSaveBackground(SLAVE_REQ_NONE, server.rdb_filename, rsiptr, RDBFLAGS_NONE);
break;
}
换句话说,Redis 不想在没人写数据的时候还去 fork 一把;光用定时器也不行,低频写入可能永远攒不够次数。save m n 把「时间」和「变更量」绑在一起,正好符合 RDB 的定位:接受丢最近一段写入,换低开销和快恢复——本来就是采样,不是连续同步。
3.2 为什么 RDB 和 AOF rewrite 都走 fork
两种看似不同的操作——全量快照与日志压缩——在 Redis 里共用同一套「子进程 + Copy-On-Write」模型。子进程在 fork 时刻看到一致的内存视图;父进程继续处理客户端写请求,写时触发页复制。操作系统层的 fork/COW 语义、COW 内存账单与 THP 影响,见 §4.3。
为什么不 spawning 线程去遍历内存? Redis 的核心数据结构(dict、quicklist 等)并非为并发读写设计;若主线程写、后台线程读同一棵 dict,要么加锁(与单线程模型冲突),要么拷贝(等价于自己做 COW)。fork 把一致性快照委托给操作系统,是单线程内存数据库下最务实的「无锁快照」方案。
代价:info memory 中的 COW 峰值、写入高峰时的 latency spike、fork 本身在数据集很大时的停顿——这是选用 fork 的账单,不是实现缺陷。子进程结束前还会通过 sendChildCowInfo 上报 COW 大小,方便运维观测。
3.3 rio:快照和日志共用的一套「写盘接口」
§3.1 讲什么时候该做一次快照(save m n + server.dirty),§3.2 讲怎么在不停服的前提下拍这张快照(fork + COW)。还差一环:子进程把内存里的 key 序列化成字节之后,这些字节往哪儿写?
如果每种出口各写一套逻辑,持久化会很快变成一团乱麻:
| 场景 | 若各写各的,会怎样 |
|---|---|
BGSAVE 写 dump.rdb | 一套「dict → 磁盘文件」编码 |
| 主从全量同步 | 再来一套「dict → socket」编码 |
| AOF rewrite 写 BASE | 第三套「dict → AOF 文件」编码 |
三份格式稍有偏差,就会出现「RDB 能加载、复制流解析失败」这类很难查的 bug。Redis 的做法是把「怎么写磁盘 / 怎么写网络」从「怎么编码 Redis 对象」里拆出来——前者就是 rio(Redis I/O 的缩写)。
rio 本质上是一个带函数指针的 I/O 对象:read、write、tell、flush 指向不同后端,上层 rdbSaveRio / rdbLoadRio 只关心「往 rio 里写字节」,不关心底下是文件还是 socket:
// src/rio.h — rio 结构(节选)
struct _rio {
size_t (*read)(struct _rio *, void *buf, size_t len);
size_t (*write)(struct _rio *, const void *buf, size_t len);
off_t (*tell)(struct _rio *);
int (*flush)(struct _rio *);
void (*update_cksum)(struct _rio *, const void *buf, size_t len);
/* 后端:内存 buffer / FILE* / 连接 / fd */
union { ... } io;
};
初始化时选后端即可,编码逻辑不变:
| 初始化函数 | 后端 | 持久化/复制里的用途 |
|---|---|---|
rioInitWithFile | 本地文件 | BGSAVE 写 dump.rdb;AOF 加载时读 BASE |
rioInitWithConn | 连接 | 主从全量同步,把 RDB 流直接打进 socket |
rioInitWithBuffer | 内存 sds | 临时缓冲、测试、部分模块路径 |
所以你在源码里会看到:rdbSaveBackground 的子进程最终调 rdbSave → rdbSaveRio;主从全量同步也走 rdbSaveRio,只是 rio 绑在 replica 的连接上而不是 FILE*。同一份 RDB 编码规则,出口不同而已。
这跟后文两处设计直接挂钩:
§6 的 RDB preamble:AOF rewrite 子进程不是另发明一种 BASE 格式,而是对临时文件
rioInitWithFile之后调rdbSaveRio(..., RDBFLAGS_AOF_PREAMBLE, ...)——BASE 文件头部就是标准 RDB 二进制,后面再 append 增量命令。没有 rio 这层抽象,「AOF 文件里嵌 RDB」就要维护两套序列化代码,混合加载很难做干净。加载路径对称:
loadSingleAppendOnlyFile发现文件头是REDIS魔数时,同样rioInitWithFile+rdbLoadRio快速灌内存,不必从第一条命令 replay 全库。
把 §3 三节串起来看:dirty 决定何时落盘,fork 决定如何不阻塞地拿到一致性视图,rio 决定编码后的字节能否用同一套规则写到文件、管道或 socket。持久化全景图里,rio 是容易被跳过、但实际上把 RDB、复制、AOF BASE 拴在同一根绳子上的那一环。
4 RDB:为什么需要「时间点快照」
4.1 何时值得做一次全量 dump
RDB 回答的问题与 AOF 不同:如果现在必须重启,能否用一份紧凑、可快速加载的文件恢复绝大部分数据? 触发方式包括:
save m n自动条件(上文 §3.1)- 手动
BGSAVE/ 阻塞SAVE(运维与调试) - 优雅关闭时的 save(若配置允许)
为什么不只用 AOF? 因为 AOF 是命令日志,文件随时间膨胀,重启要逐条 replay——运行越久,启动越慢。RDB 提供的是某一时刻键空间的完整投影,加载时直接重建对象,绕开命令解析。
4.2 为什么用 fork + COW,而不是阻塞写盘
三种做法摆在一起看更清楚:
| 方案 | 优点 | 为何 Redis 生产路径不首选 |
|---|---|---|
阻塞 rdbSave | 实现简单 | GB 级数据集会阻塞主线程数秒甚至分钟 |
| 后台线程遍历 | 不阻塞 accept | 与主线程共享内存需同步,复杂且易错 |
| fork + BGSAVE | 父进程继续服务 | COW 内存峰值、fork latency |
rdbSaveBackground 是生产路径的核心。它先检查是否已有子进程,再 fork:
// src/rdb.c — rdbSaveBackground()
int rdbSaveBackground(int req, char *filename, rdbSaveInfo *rsi, int rdbflags) {
if (hasActiveChildProcess()) return C_ERR;
server.dirty_before_bgsave = server.dirty;
server.lastbgsave_try = time(NULL);
if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
/* Child */
redisSetProcTitle("redis-rdb-bgsave");
retval = rdbSave(req, filename, rsi, rdbflags);
if (retval == C_OK)
sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB");
exitFromChild((retval == C_OK) ? 0 : 1);
} else {
/* Parent — 记录 child pid,继续事件循环 */
server.rdb_child_type = RDB_CHILD_TYPE_DISK;
return C_OK;
}
}
说白了,Redis 把「这一刻长什么样」交给 fork 瞬间的快照,父进程该接请求还接请求。SAVE 那种阻塞写盘留着给运维用,线上默认走 BGSAVE,就是因为这条路径能撑住数据量。
sequenceDiagram
participant Parent as 父进程
participant OS as 操作系统
participant Child as 子进程
Parent->>OS: redisFork(CHILD_TYPE_RDB)
OS-->>Parent: child pid
OS-->>Child: 共享内存 COW 视图
Parent->>Parent: 继续 processCommand
Child->>Child: rdbSave 写 temp-pid.rdb
Child->>Parent: exitFromChild
Parent->>Parent: rename 为 dump.rdb4.3 操作系统 fork 与 COW:Redis 快照的底层机制
上一节说 Redis「用 fork 做非阻塞快照」,但 fork 本身做了什么、COW 何时触发、生产里内存为何上涨——这些答案在操作系统层。把这一层补全,BGSAVE 和 AOF rewrite 的很多「怪现象」才有落脚点。
4.3.1 操作系统里的 fork 做了什么
先从操作系统视角把 fork() 说清楚,再回头看 Redis 的 redisFork() 封装在干什么。
进程是什么
在 Linux 这类 Unix 系统里,进程是操作系统分配资源的基本单位:有自己的虚拟地址空间、页表、打开的文件描述符表、信号处理设置、当前工作目录等。你执行的 redis-server,在内核眼里就是一个进程;客户端连上来,内核再为每个连接创建处理流程,但 Redis 主逻辑仍跑在一个主进程里。
fork 系统调用
fork() 是内核提供的一条系统调用,语义很直白:以调用 fork 的进程为父本,再创建一个子进程。创建完成后,父、子是两个独立的进程,各自有 PID,各自会被调度器安排上 CPU——但从代码角度看,它们从同一条指令、同一个调用栈位置继续往下跑,就像突然岔成两路。
返回值用来区分身份(这也是 C 程序里辨认父/子的标准写法):
| 返回值 | 谁在返回 | 含义 |
|---|---|---|
> 0 | 父进程 | 子进程的 PID |
0 | 子进程 | 自己是子进程 |
-1 | 父进程 | 创建失败,errno 说明原因 |
经典教材里的例子长这样:
pid_t pid = fork();
if (pid == 0) {
/* 子进程分支 */
} else if (pid > 0) {
/* 父进程分支 */
} else {
/* fork 失败 */
}
Redis 的 rdbSaveBackground 用的就是同一套模式,只是子进程里不是去 exec 另一个程序,而是直接调 rdbSave() 写盘,写完 exitFromChild() 退出。
内核实际干了什么(不涉及全量拷内存)
很多人第一次学 fork 会以为:父进程有几 GB 内存,子进程就得再占几 GB。现代 Linux 不会在 fork 完成的瞬间做这种事。大致步骤是:
- 复制进程描述符:子进程拿到新的
task_struct、PID,以及一份独立的页表结构。 - 页表指向同一批物理页:父子虚拟地址虽然各自独立,但起初映射到相同的物理内存页,并把这批页标成「共享、写时复制(COW)」。
- 复制或共享其他资源:文件描述符表、信号处理等按 Unix 语义继承或复制(细节见 §4.3.3)。
- 把子进程放进就绪队列:之后父、子谁先用 CPU,由调度器决定。
所以 fork 的「快」,指的是没有立刻 memcpy 整个堆;代价是后面谁写入共享页,谁触发真正的页复制——那是 §4.3.2 的话题。
在 Linux 上,fork() 底层往往通过 clone() 实现,但你可以仍把它理解成:造了一个新进程,内存先共享、写时再拆。
sequenceDiagram
participant App as 用户态 redis-server
participant Kernel as 内核
participant PTparent as 父页表
participant PTchild as 子页表
participant RAM as 物理内存
App->>Kernel: fork()
Kernel->>Kernel: 创建子进程 PCB
Kernel->>PTchild: 复制页表结构
PTparent->>RAM: 映射物理页 P1,P2,...(共享+COW)
PTchild->>RAM: 同样映射 P1,P2,...(共享+COW)
Kernel-->>App: 父得 pid,子得 0
Note over App: 两路代码从 fork 返回处继续执行和 exec 族的区别(Redis 为什么只 fork 不 exec)
Unix 里另一个常见组合是 fork + exec:子进程 fork 出来后立刻 execve() 加载另一个可执行文件,典型如 shell 跑命令。这时子进程的内存映像会被整个换掉,和父进程再无共享堆数据。
Redis BGSAVE 不做 exec:子进程仍是同一个 redis-server 二进制,堆里仍是 fork 时刻那份数据库的 COW 视图,只是执行路径改成「遍历 dict → 写 RDB → 退出」。这正是快照语义需要的:子进程读的是 fork 那一瞬间的内存,不是 fork 之后又变过的父进程内存。
Redis 在裸 fork 外面包了一层 redisFork()
裸 fork() 对 Redis 来说不够用:要防止 RDB 和 AOF rewrite 子进程同时存在、要统计 fork 耗时和 COW、子进程里要关不该继承的资源、调 OOM score 等。于是有了 redisFork():
// src/server.c — redisFork() 核心
int redisFork(int purpose) {
if (isMutuallyExclusiveChildType(purpose) && hasActiveChildProcess())
return -1;
long long start = ustime();
if ((childpid = fork()) == 0) {
/* Child */
server.in_fork_child = purpose;
setupChildSignalHandlers();
dismissMemoryInChild();
closeChildUnusedResourceAfterFork();
} else {
/* Parent */
server.stat_fork_time = ustime() - start;
latencyAddSampleIfNeeded("fork", server.stat_fork_time / 1000);
server.stat_current_cow_peak = 0;
server.stat_current_cow_bytes = 0;
...
}
return childpid;
}
父进程侧:stat_fork_time 记的是内核完成 fork 花了多久(页表越大往往越久),latency doctor 里的 fork 事件就来自这里——大实例上偶发的 latency spike,很多时候是这一步,不是子进程写盘本身。
子进程侧:closeChildUnusedResourceAfterFork() 关掉监听 socket 等不该碰的 fd;dismissMemoryInChild() 尝试把用不到的页标记给内核回收,减轻后续 COW 压力。子进程路径被刻意压成「只读内存 + 写 RDB/AOF 文件 + exit」,就是因为 fork 不会把其他线程带过来(§4.3.3 会展开),复杂逻辑放在子进程里极易踩坑。
一句话收束:操作系统 fork 给 Redis 的是「瞬间冻结的一份内存视图 + 几乎即时的创建」;Redis 在其上叠的是互斥、计费和子进程生命周期管理。至于共享页什么时候真正复制、RSS 为什么涨——接着看 §4.3.2。
4.3.2 Copy-On-Write(写时复制)如何工作
COW 的核心是延迟复制:
- fork 刚完成:父子进程的页表指向同一批物理页,页被标记为共享 / 写保护。
- 只读访问:父子都读同一物理页,不触发复制,几乎不增加 RSS(Resident Set Size,常驻内存集大小,进程实际占用的物理内存)。
- 写入访问:内核对该页缺页或写保护异常,复制出一个新物理页,只让写入方修改副本;另一方仍保留 fork 时刻的内容。
flowchart LR
subgraph forkMoment [fork 完成时]
PTparent[父进程页表] --> PhysP[物理页 P]
PTchild[子进程页表] --> PhysP
end
subgraph afterWrite [父进程写入后]
PTparent2[父进程页表] --> PhysP2[物理页 P 副本]
PTchild2[子进程页表] --> PhysP
end
forkMoment --> afterWrite对 Redis BGSAVE 而言,语义正好对齐:
- 子进程 fork 后遍历 dict 写 RDB,只读内存 → 大量页面持续共享。
- 父进程 继续执行
SET、DEL等写命令 → 写到的页被逐个拆开。
因此子进程看到的不是「现在的 Redis」,而是 fork 调用瞬间 的数据库状态——这正是时间点快照的定义。§4.2 里 rdbSaveBackground 的取舍,依赖的就是这一内核语义。
4.3.3 fork 复制了哪些资源
| 资源 | fork 后行为 | 对 Redis 的含义 |
|---|---|---|
| 堆上的 dict、对象 | 虚拟独立,物理先共享,写时 COW | 快照遍历的是 fork 时刻键空间 |
| 代码段 | 通常只读共享 | 子进程执行同一套 rdbSave 代码 |
| 文件描述符 | 引用计数共享 | 子进程继承 fd,BGSAVE 写 temp 文件后 exit |
| 其他线程 | 不复制(仅 fork 调用线程存活于子进程) | Redis 只在主线程发起 fork,子进程只做有限工作 |
最后一条很重要:fork 在多线程程序里并不「克隆所有线程」。Redis 的 I/O 线程、后台线程不会随子进程一起运行;子进程若尝试做复杂操作,可能踩锁或资源状态不一致。因此 BGSAVE / rewrite 子进程的路径被刻意限制为:序列化 → 写盘 → exit,并在 fork 前通过 closeChildUnusedResourceAfterFork() 等清理不必要的资源。
4.3.4 COW 的账单:为什么 BGSAVE 期间内存会涨
COW 不是免费午餐。fork 之后:
额外 RSS ≈ 父进程在 BGSAVE 期间「写脏」的页面总和
不是「内存必然翻倍」,但在写入密集、大 key 更新、dict 扩容时,复制页数可能接近数据集规模。Redis 子进程结束前会调用 sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB"),父进程侧累积 stat_current_cow_bytes / stat_current_cow_peak,供 INFO persistence 观测——这是在度量 COW 账单,而不是报告 bug。
另外还有两类常被忽略的成本:
fork 本身的 latency。redisFork 在父进程侧记录 stat_fork_time,并写入 latency 事件的 fork 样本。内核复制页表、建立映射需要时间与数据集规模相关;主线程同步调用 fork 时,客户端可能感受到 P99 spike。COW 解决的是「fork 时不复制全部物理页」,不消除 fork syscall 自身的停顿。
透明大页(THP)的放大效应。Linux THP 可能把 COW 粒度从 4KB 放大到 2MB——一次小写入触发整 huge page 复制,COW 内存膨胀可能远超预期。Redis 长期建议生产环境谨慎对待 THP,部分原因就在这里。
Redis 还在应用层主动 dismissMemory()(madvise(MADV_DONTNEED))尝试把空闲页还给 OS,减轻 fork 后的 COW 压力——说明 COW 成本足够真实,值得在源码里专门对冲。
4.3.5 回到 Redis:为什么 AOF rewrite 也走同一条路
§3.2 已提到 RDB 与 AOF rewrite 共用 fork 模型。从 OS 视角看,两者完全同构:
| 操作 | 子进程职责 | 父进程职责 | COW 触发方 |
|---|---|---|---|
| BGSAVE | 只读内存 → 写 RDB | 继续处理写命令 | 父进程写入 |
| AOF rewrite | 只读内存 → 写新 BASE | append 增量到 aof_buf | 父进程写入 |
hasActiveChildProcess() 禁止两者并行,是为了避免双倍 fork 压力与 COW 叠加——这不是业务逻辑限制,而是资源模型限制。
一句话:Redis 把拍快照这事交给内核 COW,换来主线程不堵;代价是写入猛的时候内存可能涨、fork 可能卡一下,还得留意 THP 这类内核行为。 搞懂 fork/COW,再看 §4.2 的源码,就不是记技巧,而是看清跟操作系统签了什么约。
4.4 RDB 二进制格式:为什么不直接用 JSON 或文本
重启要快,磁盘上的东西就得能直接变回内存对象。JSON、RESP 文本好读好调试,但解析慢、占地方;RDB 选了难读的二进制,换的是加载速度和体积——格式是为 restart latency 服务的,不是写给人看的。键名长度用变长编码(6 bit / 14 bit / 32 bit / 64 bit),短 key 只占一字节——大多数业务 key 都落在这一档。类型码 RDB_TYPE_* 区分 string、hash、zset、stream 等,并保留对旧编码(ziplist、intset)的兼容。
rdbSaveRio 的主流程体现了「快照 = 某一时刻全库投影」:
// src/rdb.c — rdbSaveRio() 核心结构
int rdbSaveRio(int req, rio *rdb, int *error, int rdbflags, rdbSaveInfo *rsi) {
snprintf(magic, sizeof(magic), "REDIS%04d", RDB_VERSION);
if (rdbWriteRaw(rdb, magic, 9) == -1) goto werr;
if (rdbSaveInfoAuxFields(rdb, rdbflags, rsi) == -1) goto werr;
/* 逐库写入 */
for (j = 0; j < server.dbnum; j++) {
if (rdbSaveDb(rdb, j, rdbflags, &key_counter) == -1) goto werr;
}
if (rdbSaveType(rdb, RDB_OPCODE_EOF) == -1) goto werr;
/* CRC64 校验 */
...
}
所以 RDB 文件不是给人类读的——加载时直接 rdbLoadObject,不用走命令引擎,这才是它比纯 AOF replay 快得多的根本原因。
4.5 RDB 在持久化体系中的位置
RDB 接受「两次快照之间的写入可能丢失」,换取低开销周期性 dump、小文件、快恢复。它适合备份、灾备、克隆副本;不适合「每条写入都必须落盘」的金融级 durability——那是 AOF 的战场。
5 AOF:为什么需要「命令日志」
AOF 把每次写操作记成可重放的命令流。要理解后面的 appendfsync、rewrite、Multi-Part AOF,得先弄清数据在内存、aof_buf、内核缓存、物理磁盘之间怎么流动——§5.2 专讲这个;§5.1 先说 AOF 在持久化里补哪块短板。
5.1 RDB 补不上的那一块:写入级 durability
RDB 是采样;两次 BGSAVE 之间的写入,崩溃即丢。若业务要求尽可能少丢写入,就需要记录每一次变更。
写命令在执行后,经 propagateNow 分流到 AOF 与复制链路:
// src/server.c — propagateNow()
static void propagateNow(int dbid, robj **argv, int argc, int target) {
if (server.aof_state != AOF_OFF && target & PROPAGATE_AOF)
feedAppendOnlyFile(dbid, argv, argc);
if (target & PROPAGATE_REPL)
replicationFeedSlaves(server.slaves, dbid, argv, argc);
}
feedAppendOnlyFile 把命令序列化为 RESP 格式,追加到 server.aof_buf:
// src/aof.c — feedAppendOnlyFile()
void feedAppendOnlyFile(int dictid, robj **argv, int argc) {
/* 必要时插入 SELECT */
buf = catAppendOnlyGenericCommand(buf, argc, argv);
/* 追加到 AOF 缓冲;在重新进入事件循环、回复客户端之前刷盘 */
if (server.aof_state == AOF_ON ||
(server.aof_state == AOF_WAIT_REWRITE && server.child_type == CHILD_TYPE_AOF))
{
server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
}
}
AOF 记的是命令,不是内存 dump。所以 FLUSHALL 也会进日志——恢复完照样是空的,这是「操作可追溯」该有的样子,不是 bug。而且 AOF 和主从复制走同一套 propagation,日志里看到的和发给 replica 的保持一致。
5.2 三层数据在哪里:内存、aof_buf 与物理磁盘
很多人把 AOF 理解成「写命令 → 落盘」,中间其实隔着好几层。搞清每一层是什么、崩在哪一层会丢什么,后面看 appendfsync、rewrite 才不会晕。
5.2.1 一张分层图
一次 SET 从客户端到真正写在磁盘上,大致经过这些关卡:
flowchart TB
subgraph L1 [第 1 层:Redis 进程内存]
Db["内存 db(dict / 对象)"]
AofBuf["aof_buf(用户态 sds 缓冲)"]
end
subgraph L2 [第 2 层:内核]
PageCache["文件页缓存 page cache"]
end
subgraph L3 [第 3 层:硬件]
Disk["物理磁盘 / SSD"]
end
Cmd[SET 命令] --> Db
Db --> AofBuf
AofBuf -->|"beforeSleep → write(aof_fd)"| PageCache
PageCache -->|"fsync / fdatasync"| Disk| 层级 | 是什么 | 谁管 | 典型丢失场景 |
|---|---|---|---|
| 内存 db | 键值真正驻留的地方,读请求只看这里 | Redis 堆内存 | 进程崩溃后未持久化的部分 |
| aof_buf | 已序列化、尚未 write 的 RESP 命令字节 | Redis 进程里的 sds | Redis 崩溃时 buffer 里还没 flush 的命令 |
| page cache | write() 之后、内核缓存的文件内容 | 操作系统 | 机器断电,且尚未 fsync |
| 物理磁盘 | fsync 之后介质上的持久副本 | 磁盘控制器 | 介质损坏等极端情况 |
在线服务时,权威数据源是内存 db——客户端 GET 不会读 AOF 文件。AOF 是内存变更的异步副本,沿「命令日志」这条旁路慢慢追到磁盘上去。
5.2.2 第 1 层:先改内存,再往 aof_buf 塞命令
call() 里的顺序写得很死:先执行命令处理函数改 db,再 propagation:
// src/server.c — call()
dirty = server.dirty;
c->cmd->proc(c); /* 先改内存 db */
dirty = server.dirty - dirty;
/* ... */
if (dirty)
alsoPropagate(c->db->id, c->argv, c->argc, PROPAGATE_AOF | PROPAGATE_REPL);
alsoPropagate 最终调到 §5.1 里的 feedAppendOnlyFile,把 RESP 文本追加进 server.aof_buf——仍在 Redis 进程堆里,还没碰内核、更没碰磁盘。
源码注释写得很清楚:刷盘发生在重新进入事件循环之前、给客户端回 OK 之前(appendfsync=always 时尤其如此)。常见路径是:
- 改内存 → 2. 追加
aof_buf→ 3. 本轮beforeSleep里flushAppendOnlyFile→ 4. 再给客户端写回复
客户端收到 OK 时,数据至少已经在内存里;是否在 aof_buf、是否已 write、是否已 fsync,取决于卡在哪一层——§5.3 的 appendfsync 就是在选这道边界。
5.2.3 第 2 层:aof_buf → write → 内核 page cache
aof_buf 攒的是一批命令字节;真正往文件里写是 flushAppendOnlyFile 里对 server.aof_fd 做 write(封装在 aofWrite):
// src/aof.c — flushAppendOnlyFile()
nwritten = aofWrite(server.aof_fd, server.aof_buf, sdslen(server.aof_buf));
/* 写成功后清空或复用 aof_buf */
if ((sdslen(server.aof_buf) + sdsavail(server.aof_buf)) < 4000)
sdsclear(server.aof_buf);
else
server.aof_buf = sdsempty();
server.aof_fd 在 Redis 7 Multi-Part AOF 下,指向当前正在 append 的 INCR 文件(appendonlydir/ 里由 manifest 登记的那一个),模式是 O_WRONLY|O_APPEND。
write() 返回成功,只表示数据交给了内核里该文件对应的 page cache,不等于已经落在盘片上。此时若 Redis 进程被 kill -9,只要 write 已完成,恢复时通常能从 INCR 文件里读到这些字节;若还在 aof_buf 里没 flush,则丢。
再往下走要靠 fsync 把 page cache 刷到物理介质——什么时候 fsync、fsync 多勤,由 appendfsync 决定,§5.3 专讲。
5.2.4 串起来:一条 SET 经过哪些层
sequenceDiagram
participant C as 客户端
participant Main as 主线程
participant M as 内存 db
participant B as aof_buf
participant K as 内核 page cache
participant D as 物理磁盘
C->>Main: SET
Main->>M: call → proc
Main->>B: feedAppendOnlyFile
Note over B: 仍在 Redis 堆内
Main->>Main: beforeSleep → flushAppendOnlyFile
B->>K: write(aof_fd)
K->>D: fsync(策略见 §5.3)崩溃时对照三层(fsync 细节见 §5.3):
- 只丢了 aof_buf:Redis 进程突然死,还没
write。 - write 了但没 fsync:进程或机器挂,page cache 里可能有、盘上可能没有。
- fsync 完成:在 durability 语义上算「落地了」(各档
appendfsync保证到哪一步,见 §5.3)。
5.2.5 rewrite 时多出来的缓冲
正常运行时旁路是 db → aof_buf → INCR 文件。AOF rewrite 期间会在 openNewIncrAofForAppend 时新开一个 INCR,父进程的新命令照常走 aof_buf 写入这个新文件;子进程并行写临时 BASE。rewrite 全流程见 §5.5。
5.3 appendfsync:在哪一层向业务承诺「不丢」
§5.2 把路径拆开了;这一节只回答设计上的问题:为什么不每条命令都同步 fsync?以及三档策略各自把承诺画在哪一层。
要是每条写命令都在 call() 里 write + fsync,磁盘 I/O 会钉在 hot path 上,P99 根本没法看。Redis 的做法是:write 可以频繁(推进 page cache),fsync 可配——durability 是一根光谱,不是开关。
| appendfsync | 承诺边界(大致) | 典型丢失窗口 | 适用心态 |
|---|---|---|---|
| always | write 后同步 fsync,再回复客户端 | 最小 | 用 latency 换 durability |
| everysec | write 勤,fsync 最多约 1 秒一次(bio 后台) | 约 1 秒 | 默认:多数业务的平衡点 |
| no | 只保证 write 进 page cache | 不可控 | 把落盘交给 OS |
三档的 write 路径相同:都在 beforeSleep 末尾调 flushAppendOnlyFile,把 I/O 从命令 hot path 挪到事件循环尾部——这是「先缓冲、再集中刷」的关键落点,而不是在 feedAppendOnlyFile 里碰盘:
// src/server.c — beforeSleep() 片段
if (server.aof_state == AOF_ON || server.aof_state == AOF_WAIT_REWRITE)
flushAppendOnlyFile(0);
everysec 下还有两处容易忽略的细节。一是若上次 fsync 仍在进行,新的 write 可短暂推迟(最多约 2 秒),避免和 fsync 抢盘;若磁盘一直忙,则降级为不等待、先 write,并打日志。二是 aof_background_fsync 走 bio 线程,主线程只 write 进 page cache,不自己卡 fsync。
no-appendfsync-on-rewrite 是另一类取舍:rewrite 时父进程写入飙、子进程也在写盘,再死磕 fsync 容易全线卡顿。配置允许有子进程做 I/O 时跳过 fsync——durability 暂时松一点,换服务可用:
// src/aof.c — flushAppendOnlyFile() 片段
try_fsync:
if (server.aof_no_fsync_on_rewrite && hasActiveChildProcess())
return;
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
redis_fsync(server.aof_fd); /* 失败时 always 模式可能 exit(1) */
} else if (server.aof_fsync == AOF_FSYNC_EVERYSEC && ...) {
aof_background_fsync(server.aof_fd);
}
默认 everysec 的意思很直白:在「最多丢一秒」和「别被磁盘拖死」之间取了个多数业务能接受的点。
5.4 Multi-Part AOF:从单文件到「目录 + 清单」
Redis 7 之前,开启 AOF 通常就是根目录下一个 appendonly.aof,所有命令从头 append 到尾。跑得越久,这个文件越大;rewrite 时要整体替换它,恢复时要从头 replay——运维和启动都越来越难受。
5.4.1 单文件模型卡在哪
| 痛点 | 具体表现 |
|---|---|
| rewrite 风险集中 | 一次 rewrite 失败,面对的是整个巨型文件;回滚、重试成本高 |
| 恢复边界模糊 | 压缩后的内容和之后的增量混在一个文件里,难以单独校验或拷贝 |
| 归档不便 | 想只备份「基准」或只拷贝「rewrite 之后的增量」,没有清晰文件边界 |
| 与 RDB preamble 难配合 | 二进制头 + 文本尾塞在同一文件,加载逻辑和运维心智都绕 |
Multi-Part AOF(MP-AOF)的核心思路:把 AOF 从「一个不断变长的文件」拆成「一个目录 + 一份清单 + 多个有角色的文件」。
5.4.2 目录里有什么
开启 AOF 且使用 MP-AOF 后(Redis 7 默认),数据在 appendonlydir/(由 aof-use-rdb-preamble 等配置决定细节):
appendonlydir/
├── appendonly.aof.manifest # 清单:记录当前有效的 BASE / INCR 是哪些
├── appendonly.aof.1.base.rdb # BASE:某次 rewrite 后的压缩基准(可为 RDB 或纯 AOF 格式)
├── appendonly.aof.2.incr.aof # INCR:BASE 生成之后追加的增量命令
└── ... # 被标记为 HISTORY 的旧文件,稍后异步删除
三类文件角色(manifest 里用 type 区分):
| 类型 | 含义 | 何时产生 |
|---|---|---|
| BASE | 某次 rewrite 时,按当时内存压缩出的基准快照 | BGREWRITEAOF / 自动 rewrite 成功结束时 |
| INCR | BASE 之后新进来的写命令日志 | 正常运行 append;rewrite 开始时也会新开一个 INCR |
| HIST | 已被新 BASE/新 INCR 取代的旧文件 | rewrite 成功后,旧 BASE、旧 INCR 被标记进 history,由 bio 线程删除 |
manifest 是文本文件,每行描述一个 AOF 文件,例如(示意):
file appendonly.aof.1.base.rdb seq 1 type b
file appendonly.aof.2.incr.aof seq 2 type i
aofLoadManifestFromFile 解析这些行,校验 seq 单调、BASE 唯一,构建内存里的 aof_manifest 结构:
// src/aof.c — manifest 解析
if (ai->file_type == AOF_FILE_TYPE_BASE) {
am->base_aof_info = ai;
} else if (ai->file_type == AOF_FILE_TYPE_INCR) {
listAddNodeTail(am->incr_aof_list, ai);
} else if (ai->file_type == AOF_FILE_TYPE_HIST) {
listAddNodeTail(am->history_aof_list, ai);
}
清单比文件名重要:运行时 Redis 认的是 manifest 里登记的「当前有效 BASE + 有序 INCR 列表」,不是你自己猜哪个文件最新。
5.4.3 启动时怎么加载
loadAppendOnlyFiles 按 manifest 顺序恢复:
- 校验 manifest 里登记的文件在磁盘上都存在、大小合法。
- 先加载 BASE(
loadSingleAppendOnlyFile):若文件头是REDIS魔数,走rdbLoadRio快速灌内存;否则从头 replay 命令。 - 再按 seq 顺序加载所有 INCR,每条命令走与线上一致的执行引擎(fake client)。
// src/aof.c — loadAppendOnlyFiles()
if (am->base_aof_info) {
ret = loadSingleAppendOnlyFile(base_aof_name);
}
listRewind(am->incr_aof_list, &li);
while ((ln = listNext(&li)) != NULL) {
ret = loadSingleAppendOnlyFile(incr_aof_name);
}
这样重启时:大状态从 BASE 秒级加载,只有 BASE 之后的增量需要 replay——启动时间和「全库运行了多久」解耦大半。
5.4.4 从旧版单文件升级
若磁盘上还有老版本根目录下的 appendonly.aof,而 appendonlydir 尚未就绪,loadAppendOnlyFiles 会走 aofUpgradePrepare,把旧单文件迁移进 MP-AOF 目录结构。这也是线上从 Redis 6 升到 7 时常见的一次性形态变化。
5.4.5 manifest 为何用「先拷贝、再原子切换」
openNewIncrAofForAppend 和 backgroundRewriteDoneHandler 都不会直接改 server.aof_manifest,而是 aofManifestDup 出一份临时副本,改完再 aofManifestFreeAndUpdate 换指针。
目的是:清单文件的更新可以和 BASE/INCR 的 rename 配合,避免 manifest 指向了尚不存在的文件。失败时可以丢弃临时副本,旧 manifest 仍然有效。
5.5 AOF Rewrite:按内存态「语义压缩」日志
日志只会 append,不会自己变短。里面堆着大量对当前内存已无意义的记录:过期 key 的 SET、计数器中间态的 INCR、写完就删的临时 key。文件会无限涨,重启 replay 也越来越慢。
5.5.1 为什么不能直接删或 truncate
| 做法 | 为何不行 |
|---|---|
| 直接删旧 AOF | rewrite 期间父进程还在写,删了会丢新命令 |
| 用编辑器截断文件 | 可能截在一条命令中间,语义损坏 |
| 只保留最近 N MB | 无法保证截断点是合法命令边界 |
正确做法是:fork 子进程,按当前内存重新生成一份干净的 BASE;父进程同时保证 rewrite 窗口内的新命令不丢。这和 BGSAVE 一样用 fork,但产物是「可 replay 的基准」(或带 RDB 头的 BASE),不是给备份用的 dump.rdb。
5.5.2 一次后台 rewrite 的完整时间线
源码在 rewriteAppendOnlyFileBackground 开头用注释写清了步骤,MP-AOF 下可以对照理解:
sequenceDiagram
participant Parent as 父进程
participant Child as 子进程
participant Manifest as manifest
participant Disk as appendonlydir
Note over Parent: 1. flushAppendOnlyFile(1) 刷清旧 INCR
Parent->>Disk: 2. openNewIncrAofForAppend 新开 INCR
Parent->>Child: 3. redisFork
Child->>Disk: 4. temp-rewriteaof-bg-pid 写新 BASE
Note over Parent: 5. 继续服务,写命令进新 INCR
Child-->>Parent: 6. exit 成功
Parent->>Disk: 7. rename temp → 新 BASE 文件名
Parent->>Manifest: 8. 旧 INCR 标 HIST,写新 manifest
Parent->>Disk: 9. bio 异步删 HIST 文件分步说明:
① rewrite 开始前:父进程先「封口」旧 INCR
server.aof_selected_db = -1; /* 下次 feed 会补 SELECT */
flushAppendOnlyFile(1); /* 强制把 aof_buf 写入当前 INCR */
openNewIncrAofForAppend(); /* 关旧 fd、开新 INCR,并更新 manifest */
openNewIncrAofForAppend 会:对旧 aof_fd 做 fsync 并关闭;创建新的 appendonly.aof.N.incr.aof;把新 INCR 登记进 manifest。从这一刻起,父进程的新写命令只进这个新 INCR,不再往即将被取代的那条增量链上 append。
② 子进程:按内存写临时 BASE
if ((childpid = redisFork(CHILD_TYPE_AOF)) == 0) {
snprintf(tmpfile, 256, "temp-rewriteaof-bg-%d.aof", (int)getpid());
rewriteAppendOnlyFile(tmpfile); /* 子进程内 */
exitFromChild(0);
}
rewriteAppendOnlyFile 里若开启 aof-use-rdb-preamble,走 rdbSaveRio(..., RDBFLAGS_AOF_PREAMBLE) 写 RDB 头;否则走 rewriteAppendOnlyFileRio 写纯命令格式的 BASE。子进程写完会 fflush + fsync 临时文件——BASE 在子进程内就尽量落稳,不依赖父进程。
③ 父进程:照常服务
子进程在遍历 fork 时刻的内存;父进程继续 SET/DEL,经 feedAppendOnlyFile → aof_buf → 新 INCR 文件。Redis 7 不再用单独的 aof_rewrite_buf,而是用新 INCR 文件承接 rewrite 期间的增量——这比旧版双缓冲更直观,也和 MP-AOF 的文件角色一致。
④ 子进程成功退出:backgroundRewriteDoneHandler 切换世界线
父进程在 backgroundRewriteDoneHandler 里:
rename(temp-rewriteaof-bg-pid, appendonly.aof.M.base.rdb)— 临时 BASE 转正。- 旧 BASE(若有)和 rewrite 开始前的那批 INCR 标为 HIST。
persistAofManifest原子更新清单。aofDelHistoryFiles用 bio 线程删 HIST 文件(删失败不致命)。
失败则保留旧 manifest、删掉临时 BASE,并指数退避限制下次自动 rewrite(aofRewriteLimited:连续失败则 1min、2min…最多 1h 才再自动试;手动 BGREWRITEAOF 不受限)。
5.5.3 自动 rewrite 何时触发
serverCron 在 AOF 开启、无其他子进程、体积超过 auto-aof-rewrite-min-size 时检查增长率:
long long growth = (server.aof_current_size * 100 / base) - 100;
if (growth >= server.aof_rewrite_perc && !aofRewriteLimited()) {
rewriteAppendOnlyFileBackground();
}
base 是上次 rewrite 后的基准大小(aof_rewrite_base_size)。默认「涨了 100% 且至少 64MB」才自动 rewrite——避免小文件频繁 fork。
也可手动 BGREWRITEAOF。若当时正在 BGSAVE,会设 aof_rewrite_scheduled,等 RDB 子进程结束后再 rewrite(hasActiveChildProcess 互斥)。
5.5.4 与 BGSAVE 的对比
| BGSAVE | AOF rewrite | |
|---|---|---|
| 手段 | fork + COW | fork + COW |
| 输出 | dump.rdb(纯状态二进制) | 新 BASE(RDB preamble 或命令集) |
| 父进程写入 | 继续服务,写旧 INCR/内存 | 继续服务,写新 INCR |
| 目的 | 备份、快恢复快照 | 压缩日志,减小 INCR 与 replay 成本 |
| 能否并行 | 与 rewrite 互斥 | 与 BGSAVE 互斥 |
5.5.5 rewrite 期间 durability 的松动
no-appendfsync-on-rewrite 默认开启时,有子进程做 I/O 时 flushAppendOnlyFile 可能跳过 fsync——父进程、子进程同时抢盘,硬 fsync 容易全线卡顿。这是用短暂放松落盘保证换可用性;rewrite 结束后恢复正常 fsync 节奏。
stateDiagram-v2
[*] --> AOF_ON: 正常 append 当前 INCR
AOF_ON --> Rewriting: BGREWRITEAOF / 自动触发
Rewriting --> AOF_ON: 子进程成功 manifest 切换
Rewriting --> AOF_ON: 子进程失败保留旧 manifest
note right of Rewriting
父进程写新 INCR
子进程写 temp BASE
end note6 混合持久化:不是第三种机制,而是 AOF 的加载优化
纯 AOF 重启有个老毛病:跑得越久,要 replay 的命令越多,启动越来越慢。就算 rewrite 压过一轮,INCR 段仍可能很长;更早的单文件 AOF 更是从头播到尾。
aof-use-rdb-preamble(Redis 7 里 BASE 默认就按这套来)的思路很直接:BASE 头部用 RDB 秒级加载,后面 INCR 只 replay rewrite 之后那点增量。加载时 loadSingleAppendOnlyFile 先看文件头是不是 REDIS 魔数:
// src/aof.c — loadSingleAppendOnlyFile()
if (fread(sig, 1, 5, fp) != 5 || memcmp(sig, "REDIS", 5) != 0) {
fseek(fp, 0, SEEK_SET); /* 纯命令格式,从头 replay */
} else {
rioInitWithFile(&rdb, fp);
if (rdbLoadRio(&rdb, RDBFLAGS_AOF_PREAMBLE, NULL) != C_OK) {
ret = AOF_FAILED;
goto cleanup;
}
/* RDB 部分加载完毕,继续 replay 尾部 AOF 命令 */
}
rewrite 子进程写 BASE 时同样调用 rdbSaveRio(..., RDBFLAGS_AOF_PREAMBLE, ...),与独立 RDB 共用编码路径。
混合模式并没有多出一种持久化机制——启动还是以 AOF 为准,单独的 dump.rdb 在 AOF 开着时根本不读。RDB preamble 只是把 time-to-ready 拉下来,用格式混搭换启动速度。
7 启动恢复:优先级背后的 durability 逻辑
sequenceDiagram
participant Main as main
participant Load as loadDataFromDisk
participant AOF as loadAppendOnlyFiles
participant RDB as rdbLoad
Main->>Load: 启动
alt aof_state == AOF_ON
Load->>AOF: 读 manifest
AOF->>AOF: BASE 含 RDB 则 rdbLoadRio
AOF->>AOF: 顺序 replay INCR
else AOF 关闭
Load->>RDB: rdbLoad dump.rdb
end7.1 为什么用 fake client 重放命令
纯命令格式的 AOF 段不用 rdbLoad,而是造一个 createAOFClient(),在加载上下文里一条条走和线上一样的命令路径。好处是不用再维护一套「反序列化 → 内存对象」的 loader,加载和运行行为一致。isAOFLoadingContext() 会在 bootstrap 阶段跳过一些此时不该执行、或有副作用的命令。
7.2 为什么失败即 exit
loadDataFromDisk 在 AOF 加载返回 AOF_FAILED 或 AOF_OPEN_ERR 时直接 exit(1)。durability 子系统的契约是:要么恢复到一致快照,要么拒绝启动——不 silently 降级到空库或半残数据。
8 设计哲学收束:Redis 持久化到底在优化什么
| 维度 | RDB 立场 | AOF 立场 | Redis 的折中 |
|---|---|---|---|
| Durability | 弱(采样) | 强(可配 fsync) | 默认 AOF everysec + 可选 RDB 备份 |
| 重启速度 | 快 | 慢(纯 replay) | RDB preamble + MP-AOF BASE |
| 磁盘占用 | 小 | 大(rewrite 缓解) | 定期 rewrite + 分片 INCR |
| 运行时开销 | fork spike | 持续写放大 | 缓冲 + 异步 fsync |
| 运维复杂度 | 低 | 中 | manifest 目录、升级迁移工具 |
生产选型不是回答「哪个更好」,而是「你的业务在哪个维度输不起」:
- 丢 1 秒数据行不行?→ 决定 fsync 档位。
- 重启 10 分钟行不行?→ 决定要不要开 preamble、rewrite 频率。
- 内存与磁盘预算多少?→ 决定 save 频率、rewrite 阈值、是否接受 fork 高峰。
- 是否需要审计每条写操作?→ 倾向 AOF;只要备份快照?→ RDB 足够,但通常仍建议 AOF 保底。
常见组合:AOF everysec 保 durability,定期 BGSAVE 或文件系统快照做异地备份——两者优化目标不同,不是重复建设。
9 总结
- Redis 用 RDB 与 AOF 两套机制,是因为 durability 与 restart latency 无法被单一格式同时做到最优——RDB 采样换速度,AOF 日志换细粒度。
- fork + COW 是单线程内存数据库的「无锁快照」答案;COW 峰值与 fork latency 是使用这个答案的账单,不是旁路 bug。
- 缓冲 + beforeSleep 刷盘 + 三档 fsync 把 durability 从硬约束变成可配置光谱;everysec 是 latency 与丢失窗口的默认平衡点。
- AOF rewrite 是语义压缩而非删文件;RDB preamble 与 Multi-Part AOF 是同一思路的两次演化——把「一次性大操作」拆成可管理、可快速加载的片段。
- 启动优先级与 fail-fast 把 durability 语义写进了代码:AOF 开着就以日志为准,加载失败拒绝启动——持久化首先是正确性,其次才是性能。