跳到正文
约 15 分钟

Redis 持久化:RDB、AOF 的设计动机与源码证据

1 问题背景:内存数据库的持久化困境

Redis 的性能来自一个简单前提:数据在内存里,读写路径极短。但内存是 volatile 的——进程崩溃、机器断电、容器被强杀,内存中的键值空间会在毫秒级消失。持久化要回答的是:如何在尽量不打折在线性能的前提下,把内存状态映射到磁盘,并在重启时重建

这里需要先划清边界。持久化解决的是单机上的崩溃恢复冷启动重建;它不替代主从复制、哨兵或 Cluster 提供的高可用。副本挂了可以从其他节点拉数据,但所有副本同时丢失时,能救你的只有磁盘上的 RDB 或 AOF。同样,持久化也不等于备份——把 appendonlydir/dump.rdb 留在本机,机房级故障照样全没;异地拷贝仍是最后一道防线。

Redis 的单线程事件模型给持久化加了四条硬约束,后文每个设计分支几乎都在这几条之间找平衡:

  1. 主线程不能被磁盘 I/O 长时间阻塞——否则所有客户端排队。
  2. 写入路径的 latency 要可控——AOF 的 fsync 是最大变量。
  3. 重启恢复时间要可接受——纯 AOF 命令重放随运行时间线性变慢。
  4. 磁盘占用与 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-preambleaof_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
    end

3.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 对象:readwritetellflush 指向不同后端,上层 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 的子进程最终调 rdbSaverdbSaveRio;主从全量同步也走 rdbSaveRio,只是 rio 绑在 replica 的连接上而不是 FILE*同一份 RDB 编码规则,出口不同而已。

这跟后文两处设计直接挂钩:

  1. §6 的 RDB preamble:AOF rewrite 子进程不是另发明一种 BASE 格式,而是对临时文件 rioInitWithFile 之后调 rdbSaveRio(..., RDBFLAGS_AOF_PREAMBLE, ...)——BASE 文件头部就是标准 RDB 二进制,后面再 append 增量命令。没有 rio 这层抽象,「AOF 文件里嵌 RDB」就要维护两套序列化代码,混合加载很难做干净。

  2. 加载路径对称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.rdb

4.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 完成的瞬间做这种事。大致步骤是:

  1. 复制进程描述符:子进程拿到新的 task_struct、PID,以及一份独立的页表结构。
  2. 页表指向同一批物理页:父子虚拟地址虽然各自独立,但起初映射到相同的物理内存页,并把这批页标成「共享、写时复制(COW)」。
  3. 复制或共享其他资源:文件描述符表、信号处理等按 Unix 语义继承或复制(细节见 §4.3.3)。
  4. 把子进程放进就绪队列:之后父、子谁先用 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 的核心是延迟复制

  1. fork 刚完成:父子进程的页表指向同一批物理页,页被标记为共享 / 写保护。
  2. 只读访问:父子都读同一物理页,不触发复制,几乎不增加 RSS(Resident Set Size,常驻内存集大小,进程实际占用的物理内存)。
  3. 写入访问:内核对该页缺页或写保护异常,复制出一个新物理页,只让写入方修改副本;另一方仍保留 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,只读内存 → 大量页面持续共享。
  • 父进程 继续执行 SETDEL 等写命令 → 写到的页被逐个拆开。

因此子进程看到的不是「现在的 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 本身的 latencyredisFork 在父进程侧记录 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 时尤其如此)。常见路径是:

  1. 改内存 → 2. 追加 aof_buf → 3. 本轮 beforeSleepflushAppendOnlyFile → 4. 再给客户端写回复

客户端收到 OK 时,数据至少已经在内存里;是否在 aof_buf、是否已 write、是否已 fsync,取决于卡在哪一层——§5.3 的 appendfsync 就是在选这道边界。

5.2.3 第 2 层:aof_buf → write → 内核 page cache

aof_buf 攒的是一批命令字节;真正往文件里写是 flushAppendOnlyFile 里对 server.aof_fdwrite(封装在 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_fsyncbio 线程,主线程只 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 顺序恢复:

  1. 校验 manifest 里登记的文件在磁盘上都存在、大小合法。
  2. 先加载 BASEloadSingleAppendOnlyFile):若文件头是 REDIS 魔数,走 rdbLoadRio 快速灌内存;否则从头 replay 命令。
  3. 再按 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 为何用「先拷贝、再原子切换」

openNewIncrAofForAppendbackgroundRewriteDoneHandler 都不会直接改 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,经 feedAppendOnlyFileaof_buf → 新 INCR 文件。Redis 7 不再用单独的 aof_rewrite_buf,而是用新 INCR 文件承接 rewrite 期间的增量——这比旧版双缓冲更直观,也和 MP-AOF 的文件角色一致。

④ 子进程成功退出:backgroundRewriteDoneHandler 切换世界线

父进程在 backgroundRewriteDoneHandler 里:

  1. rename(temp-rewriteaof-bg-pid, appendonly.aof.M.base.rdb) — 临时 BASE 转正。
  2. 旧 BASE(若有)和 rewrite 开始前的那批 INCR 标为 HIST
  3. persistAofManifest 原子更新清单。
  4. 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 note

6 混合持久化:不是第三种机制,而是 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
    end

7.1 为什么用 fake client 重放命令

纯命令格式的 AOF 段不用 rdbLoad,而是造一个 createAOFClient(),在加载上下文里一条条走和线上一样的命令路径。好处是不用再维护一套「反序列化 → 内存对象」的 loader,加载和运行行为一致。isAOFLoadingContext() 会在 bootstrap 阶段跳过一些此时不该执行、或有副作用的命令。

7.2 为什么失败即 exit

loadDataFromDisk 在 AOF 加载返回 AOF_FAILEDAOF_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 总结

  1. Redis 用 RDB 与 AOF 两套机制,是因为 durabilityrestart latency 无法被单一格式同时做到最优——RDB 采样换速度,AOF 日志换细粒度。
  2. fork + COW 是单线程内存数据库的「无锁快照」答案;COW 峰值与 fork latency 是使用这个答案的账单,不是旁路 bug。
  3. 缓冲 + beforeSleep 刷盘 + 三档 fsync 把 durability 从硬约束变成可配置光谱;everysec 是 latency 与丢失窗口的默认平衡点。
  4. AOF rewrite 是语义压缩而非删文件;RDB preambleMulti-Part AOF 是同一思路的两次演化——把「一次性大操作」拆成可管理、可快速加载的片段。
  5. 启动优先级与 fail-fast 把 durability 语义写进了代码:AOF 开着就以日志为准,加载失败拒绝启动——持久化首先是正确性,其次才是性能。

评论