Redis 持久化深度解析:从源码到操作系统

0 阅读12分钟

 Redis 持久化,本质上是一场数据安全与系统性能之间的精彩博弈

RDB 像是一张定期拍摄的全家福——照片清晰、体积小巧、恢复飞快,但两次拍照之间的记忆永远消失了。AOF 则像一本流水账——事无巨细地记录每一条写操作,数据更安全,但账本越来越厚,翻账也越来越慢。

它们的实现都深度依赖一个操作系统机制:写时复制(Copy-On-Write,COW) 。Redis 通过 fork() 创建子进程来完成实际的磁盘写入,主进程继续服务请求。这个过程看似简单,背后却涉及页表复制、缺页异常处理、内存引用计数等内核层面的复杂交互。

本文将沿着“源码调用链 → 操作系统机制 → 设计权衡 → 演进脉络”的路径,把 Redis 持久化的底层逻辑一一拆解开来。

一、RDB:内存快照的全景图

1.1 两条路径:SAVE 与 BGSAVE

RDB(Redis Database)是将某一时刻的内存数据以二进制格式保存到磁盘。触发方式有两种:SAVE 和 BGSAVE。

SAVE 命令在主线程中执行 rdbSave() 函数,期间 Redis 无法处理任何客户端请求。在生产环境中,这几乎等同于一次短暂的“停机”。因此,SAVE 通常只在调试或数据迁移场景下使用。

BGSAVE 则优雅得多。收到命令后,Redis 调用 rdbSaveBackground(),其中最关键的一步是 fork() 系统调用,创建出一个子进程。子进程调用 rdbSave() 完成快照写入,主进程继续处理客户端请求。伪代码如下:

def bgsave():
    pid = fork()
    if pid == 0:
        # 子进程
        rdbSave()
        signal_parent()
    elif pid > 0:
        # 父进程继续处理命令
        handle_requests_and_wait_signal()

1.2 源码走读:rdbSave 做了什么

rdbSave() 的逻辑相当直观:

1. 创建临时文件 temp-{pid}.rdb
2. 写入 REDIS 魔数(magic string)
3. 遍历所有数据库(默认 0~15)
    - 写入 SELECTDB 操作码和数据库编号
    - 遍历该库的所有键值对
    - 写入键、值、过期时间
4. 写入 EOF 操作码
5. fflush + fsync 强制落盘
6. rename 原子替换旧文件

rename 是一个关键操作。它保证了 RDB 文件替换的原子性——要么是完整的旧文件,要么是完整的新文件,不会出现一个写了一半的损坏文件。

1.3 并发控制:BGSAVE 期间的命令处理

当 BGSAVE 正在执行时,Redis 对相关命令有严格的并发控制:

  • SAVE:直接拒绝。避免父子进程同时调用 rdbSave() 产生竞争条件。
  • BGSAVE:同样拒绝。一个 RDB 快照正在生成中,再来一个没有意义。
  • BGREWRITEAOF:延迟到 BGSAVE 完成后执行。两个子进程同时运行会带来双倍的 CPU 和内存压力。

这种设计体现了 Redis 对资源管理的审慎态度——宁可让命令排队,也不让系统陷入不可控的资源竞争。

1.4 RDB 的局限

RDB 的缺点同样明显。如果两次快照之间 Redis 崩溃,这段时间内的所有写操作都会永久丢失。对于金融、订单等对数据一致性要求极高的场景,这种损失是不可接受的。这也正是 AOF 存在的根本原因。

二、AOF:写操作的流水账

2.1 三阶段模型:命令追加 → 文件写入 → 磁盘同步

AOF(Append Only File)以 Redis 命令请求协议(RESP)的格式,逐条记录所有写操作。它的实现分为三个步骤:

  1. 命令追加:每执行完一个写命令,Redis 会将其追加到 server.aof_buf 缓冲区的末尾。
  2. 文件写入:在每次事件循环结束前,调用 flushAppendOnlyFile() 将缓冲区内容写入 AOF 文件。
  3. 磁盘同步:根据 appendfsync 配置,决定何时调用 fsync() 强制将内核缓冲区数据刷入物理磁盘。

这里有一个容易被忽略的关键点:write() 和 fsync() 是两个不同的操作。write() 只是将数据从用户态缓冲区拷贝到内核缓冲区(Page Cache),此时数据尚未真正写入磁盘。只有 fsync() 才会强制将 Page Cache 中的数据刷入硬盘。如果服务器突然断电,停留在 Page Cache 中的数据就会丢失。

2.2 三种同步策略的底层语义

Redis 提供三种 appendfsync 策略:

策略行为数据安全性能影响
always每次写入后立即 fsync最高(不丢数据)最低(阻塞主线程)
everysec每秒由后台线程执行一次 fsync中等(最多丢 1 秒)平衡
no永不主动 fsync,由操作系统决定最低(可能丢大量数据)最高

everysec 是 Redis 的默认策略,也是生产环境的最佳实践。它通过一个独立的 bio 后台线程 来执行 fsync,避免了主线程的阻塞。但这并不意味着完全没有风险——如果写入流量极大,每秒产生的数据量超过磁盘吞吐能力,后台线程的 fsync 队列会堆积,最终仍可能导致主线程阻塞。

2.3 AOF 重写:给流水账“瘦身”

随着时间推移,AOF 文件会变得臃肿。比如一个计数器键被 INCR 执行了 100 次,AOF 中会记录 100 条命令,但实际上只需要一条 SET 命令就能表达最终状态。

AOF 重写(AOF Rewrite)正是为了解决这个问题。它不分析旧 AOF 文件的内容,而是直接读取当前内存数据,生成一条等价的最小化命令集合。这保证了重写过程的正确性与高效性。

rewriteAppendOnlyFileBackground() 的实现与 BGSAVE 高度相似:

  1. fork 一个子进程
  2. 子进程遍历内存数据,写入临时 AOF 文件
  3. 主进程在重写期间的新写命令,同时写入 aof_buf 和 aof_rewrite_buf
  4. 子进程完成后,主进程将 aof_rewrite_buf 中的增量数据追加到临时文件末尾
  5. rename 原子替换旧 AOF 文件

这种“全量快照 + 增量补录”的模式,保证了新旧 AOF 文件最终代表完全一致的数据库状态。

2.4 AOF 的写入瓶颈

AOF 的写入策略暴露了 Redis 单线程模型的一个根本性矛盾:既要保证数据安全,又不能阻塞命令处理

在 always 策略下,每个写命令都要等待 fsync 返回,这意味着磁盘的延迟直接叠加到了命令的响应时间上。对于机械硬盘,一次 fsync 可能耗时数毫秒甚至数十毫秒,这对 Redis 的吞吐量是致命的。

everysec 通过异步化缓解了这个问题,但并未彻底解决。当后台 fsync 线程被阻塞时(比如磁盘 IO 饱和),主线程的下一次 flushAppendOnlyFile 可能会被强制等待,从而导致短暂的命令阻塞。

三、操作系统层面的“魔法”:写时复制

3.1 fork() 的真相:页表复制,而非内存复制

无论是 BGSAVE 还是 AOF 重写,都依赖 fork() 创建子进程。很多开发者误以为 fork 会复制整个父进程的内存空间——如果真是这样,一个 16GB 的 Redis 实例 fork 一次就需要 16GB 的额外内存,还要耗费数秒的时间,这显然是不可接受的。

真相是:fork() 只复制页表,不复制物理内存

页表是虚拟内存地址到物理内存地址的映射表。fork 时,内核为子进程创建一份新的页表,但页表中的物理地址指向与父进程完全相同的物理内存页。同时,内核将所有这些共享页的页表项(PTE)标记为只读,并将每个物理页的引用计数加 1。

这个过程可以用 Mermaid 流程图直观表示:

对于 10GB 的 Redis 实例,页表大小约为 20MB。fork 时只需复制这 20MB 的页表结构,而非 10GB 的物理内存,因此耗时通常在毫秒级。

3.2 写时复制的触发:缺页异常

当父进程或子进程尝试写入一个被标记为只读的共享页时,CPU 的 MMU(内存管理单元)会检测到权限违规,触发写保护缺页异常(Page Fault)。

内核的缺页异常处理程序会识别出这是一个 COW 场景,然后执行以下操作:

  1. 分配一页新的物理内存
  2. 将原物理页的内容复制到新页
  3. 更新当前进程的页表项,指向新页并设置为可写
  4. 将原物理页的引用计数减 1

整个过程对应用程序完全透明。但有一个隐藏的成本:每次 COW 都意味着一次额外的内存分配和数据复制。如果 Redis 在 BGSAVE 期间接收了大量写请求,就会触发大量 COW,导致内存使用量上升(因为不断分配新页),同时 CPU 也会因缺页异常处理而产生额外开销。

3.3 fork 的阻塞:被低估的隐患

虽然 fork 不复制物理内存,但它必须复制页表。页表复制期间,父进程是被阻塞的。对于内存越大的实例,页表越庞大,fork 的阻塞时间就越长。根据实测数据,10GB 内存的 Redis 实例,fork 耗时约 100~200ms;16GB 时可能超过 300ms。

更糟的是,如果系统开启了透明大页(Transparent Huge Pages,THP) ,fork 的阻塞时间会进一步恶化。THP 将默认的 4KB 内存页合并为 2MB 的大页,虽然减少了页表项数量,但在 fork 时内核需要处理更复杂的 COW 逻辑,实际耗时反而更长。因此,在生产环境中部署 Redis 时,必须关闭 THP

  1. 临时关闭:运行以下命令立即生效,但重启后失效。

    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/defrag
    

  2. 永久关闭(推荐) :必须将配置写入系统文件,才能确保重启后依然生效。

    • 方法一:将上述命令添加到 /etc/rc.local 文件中(在 exit 0 之前)。
    • 方法二:创建一个 systemd 服务(如 /etc/systemd/system/disable-thp.service)来在启动时执行这些命令,这在现代Linux系统中是更推荐的做法。
  3. 验证结果:运行以下命令验证,输出应显示 [never] 字样。

    cat /sys/kernel/mm/transparent_hugepage/enabled
    

四、演进与融合:从“非此即彼”到“兼收并蓄”

4.1 混合持久化(Redis 4.0)

RDB 恢复快但数据不完整,AOF 数据完整但恢复慢。能不能把两者的优点结合起来?

Redis 4.0 引入的混合持久化给出了答案:在 AOF 重写时,不再生成纯命令格式的 AOF 文件,而是前半部分写入 RDB 格式的全量快照,后半部分追加增量命令。配置项为:

aof-use-rdb-preamble yes

重启时,Redis 先加载 RDB 部分快速恢复大部分数据,再重放 AOF 尾部的小量命令补全最新状态。恢复速度接近纯 RDB,数据完整性接近纯 AOF。这是一个典型的“工程折中”方案。

4.2 Multi-Part AOF(Redis 7.0)

混合持久化虽然优秀,但 AOF 重写期间的 aof_rewrite_buf 在高写入流量下仍可能占用大量内存——因为增量命令全部缓存于内存中,等待子进程消费。

Redis 7.0 的 Multi-Part AOF(MP-AOF,由阿里云 Tair 团队贡献)彻底重构了这一模型。它将 AOF 拆分为:

  • base.aof:全量数据快照(类似 RDB)
  • incr-*.aof:多个增量日志文件,按时间顺序命名

重写时,Redis 不再依赖内存中的重写缓冲区,而是将增量命令直接写入新的 incr 文件。这种设计带来了三个关键收益:

  1. 内存压力大幅降低:不再需要在内存中缓存整个重写期间的增量命令。
  2. IO 更平滑:取消了一次性的大文件 rename,改为分片式增量追加。
  3. 崩溃恢复更快:只需回放最后一个 base 和它之后的 incr 文件,而非整个庞大的单一 AOF 文件。

这是 Redis 持久化机制从“单文件追加”向“多文件分片管理”的一次重要范式转移。

五、实战优化清单

理解原理之后,以下优化建议可以作为生产环境 Redis 持久化配置的检查清单:

配置项推荐值原因
appendfsynceverysec数据安全与性能的最佳平衡点
aof-use-rdb-preambleyes(4.0+)兼顾快速恢复与数据完整性
no-appendfsync-on-rewriteyes重写期间暂停 fsync,避免 IO 双倍压力
aof-rewrite-incremental-fsyncyes(默认)分批次 fsync,减少子进程刷盘对主线程的干扰
操作系统 THPnever避免 fork 阻塞时间恶化
vm.overcommit_memory1允许内存过量分配,避免 fork 失败

此外,还有一些运维层面的经验:

  • 控制单实例内存:建议不超过 10GB,否则 fork 阻塞时间可能显著影响响应延迟。
  • 错峰重写:避免在业务高峰期触发自动重写,可考虑在低峰期手动执行 BGREWRITEAOF。
  • 监控 fork 耗时INFO persistence 中的 latest_fork_usec 是重要指标,超过 100ms 应引起警惕。

总结

回顾全文,Redis 持久化的设计哲学可以用一句话概括:以操作系统能力为基座,在数据安全与系统性能之间寻求最优解

RDB 利用了 COW 实现零阻塞快照,AOF 通过缓冲区和后台 fsync 在尽可能不阻塞的前提下保证数据完整性,混合持久化和 MP-AOF 则是在前两者基础上不断演进的工程优化。每一个设计决策,背后都有清晰的技术权衡——理解了这些权衡,才能真正驾驭 Redis 的持久化机制。

掌握这些,再去看 Redis 的源码,你会发现自己已经站上了一个更高的台阶。

本文首发:blog.csdn.net/emeson_ch/a…