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只读内存 → 写新 BASEappend 增量到 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 进程里的 sdsRedis 崩溃时 buffer 里还没 flush 的命令
page cachewrite() 之后、内核缓存的文件内容操作系统机器断电,且尚未 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承诺边界(大致)典型丢失窗口适用心态
alwayswrite 后同步 fsync,再回复客户端最小用 latency 换 durability
everysecwrite 勤,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 成功结束时
INCRBASE 之后新进来的写命令日志正常运行 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

做法为何不行
直接删旧 AOFrewrite 期间父进程还在写,删了会丢新命令
用编辑器截断文件可能截在一条命令中间,语义损坏
只保留最近 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 的对比

BGSAVEAOF rewrite
手段fork + COWfork + 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 开着就以日志为准,加载失败拒绝启动——持久化首先是正确性,其次才是性能。