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)的格式,逐条记录所有写操作。它的实现分为三个步骤:
- 命令追加:每执行完一个写命令,Redis 会将其追加到
server.aof_buf缓冲区的末尾。 - 文件写入:在每次事件循环结束前,调用
flushAppendOnlyFile()将缓冲区内容写入 AOF 文件。 - 磁盘同步:根据
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 高度相似:
- fork 一个子进程
- 子进程遍历内存数据,写入临时 AOF 文件
- 主进程在重写期间的新写命令,同时写入
aof_buf和aof_rewrite_buf - 子进程完成后,主进程将
aof_rewrite_buf中的增量数据追加到临时文件末尾 - 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
整个过程对应用程序完全透明。但有一个隐藏的成本:每次 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:
-
临时关闭:运行以下命令立即生效,但重启后失效。
echo never > /sys/kernel/mm/transparent_hugepage/enabled echo never > /sys/kernel/mm/transparent_hugepage/defrag -
永久关闭(推荐) :必须将配置写入系统文件,才能确保重启后依然生效。
- 方法一:将上述命令添加到
/etc/rc.local文件中(在exit 0之前)。 - 方法二:创建一个 systemd 服务(如
/etc/systemd/system/disable-thp.service)来在启动时执行这些命令,这在现代Linux系统中是更推荐的做法。
- 方法一:将上述命令添加到
-
验证结果:运行以下命令验证,输出应显示
[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 文件。这种设计带来了三个关键收益:
- 内存压力大幅降低:不再需要在内存中缓存整个重写期间的增量命令。
- IO 更平滑:取消了一次性的大文件 rename,改为分片式增量追加。
- 崩溃恢复更快:只需回放最后一个 base 和它之后的 incr 文件,而非整个庞大的单一 AOF 文件。
这是 Redis 持久化机制从“单文件追加”向“多文件分片管理”的一次重要范式转移。
五、实战优化清单
理解原理之后,以下优化建议可以作为生产环境 Redis 持久化配置的检查清单:
| 配置项 | 推荐值 | 原因 |
|---|---|---|
appendfsync | everysec | 数据安全与性能的最佳平衡点 |
aof-use-rdb-preamble | yes(4.0+) | 兼顾快速恢复与数据完整性 |
no-appendfsync-on-rewrite | yes | 重写期间暂停 fsync,避免 IO 双倍压力 |
aof-rewrite-incremental-fsync | yes(默认) | 分批次 fsync,减少子进程刷盘对主线程的干扰 |
| 操作系统 THP | never | 避免 fork 阻塞时间恶化 |
vm.overcommit_memory | 1 | 允许内存过量分配,避免 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…