AOF(Append Only File)持久化机制通过记录所有写命令来保证数据安全,但随之而来的问题是:随着运行时间增长,AOF 文件会不断膨胀。假设你反复对一个 key 执行 INCR 操作 1000 次,AOF 文件中会记录 1000 条 INCR 命令,但恢复时只需要一条 SET key 1000 即可。这就是 AOF 重写的价值所在。AOF 重写的核心目标就是将 AOF 文件重写为包含相同数据集的最小命令集,从而减少文件体积,加速数据恢复。
一、问题背景:AOF 文件的膨胀
1.1 AOF 持久化回顾
AOF 机制以日志形式记录每个写命令。当执行以下操作时:
cpp
127.0.0.1:6379> SET user:1000:name "Alice"
OK
127.0.0.1:6379> SET user:1000:name "Bob"
OK
127.0.0.1:6379> SET user:1000:name "Charlie"
OK
AOF 文件会记录三条命令。但实际上,只有最后一条 SET user:1000:name "Charlie" 是恢复数据所需要的。
1.2 膨胀带来的问题
| 问题 | 影响 |
|---|---|
| 磁盘空间浪费 | 大量冗余命令占用存储 |
| 数据恢复缓慢 | 启动时需回放所有历史命令 |
| 主从复制压力 | 传输大文件占用网络带宽 |
二、核心设计思想
AOF 重写与直接处理旧 AOF 文件不同,它采用了一种巧妙的方式:不是"重写旧文件",而是"基于当前内存数据生成新文件"**。**这种方式相当于定期对数据库做一个"快照",用一系列能够重建当前数据状态的命令替换旧的、冗长的命令序列。
2.1 核心原则
-
最小命令集原则:只生成重建当前数据集所需的最少命令。
-
后台执行原则:通过子进程完成,不阻塞主进程。
-
原子替换原则:新旧文件切换是原子的,不会丢失数据。
三、重写工作的三阶段
3.1 准备阶段:触发与状态检查
在redis中,无论是手动触发还是自动触发,rewriteAppendOnlyFileBackground() 函数是入口:
cpp
// 伪代码:AOF重写入口
int rewriteAppendOnlyFileBackground() {
// 1. 检查是否已有重写进程在运行
if (server.aof_rewrite_child_pid != -1) {
return C_ERR; // 已有重写进程,忽略本次请求
}
// 2. 检查是否正在执行 RDB 持久化
if (server.rdb_child_pid != -1) {
// 等待 RDB 完成后再执行
server.aof_rewrite_scheduled = 1;
return C_OK;
}
// 3. 执行 fork 创建子进程
pid_t child_pid = fork();
if (child_pid == 0) {
// 子进程执行重写
rewriteAppendOnlyFile();
exit(0);
} else {
// 父进程记录子进程 PID,初始化重写缓冲区
server.aof_rewrite_child_pid = child_pid;
server.aof_rewrite_buf = sdsempty(); // 创建重写缓冲区
return C_OK;
}
}
3.2 执行阶段:子进程的核心工作
这是重写的核心工作,子进程遍历整个数据库,生成新的 AOF 文件:
cpp
// 伪代码:子进程重写 AOF 文件
void rewriteAppendOnlyFile() {
// 1. 创建临时文件
FILE* fp = createTempFile("temp-rewrite.aof");
// 2. 写入 SELECT 命令(选择数据库)
// Redis 默认有 16 个数据库,需要确保恢复时切换到正确的数据库
for (int db_id = 0; db_id < server.dbnum; db_id++) {
redisDb* db = &server.db[db_id];
if (dictSize(db->dict) == 0) continue; // 空数据库跳过
// 写入 SELECT db_id 命令
writeSelectCommand(fp, db_id);
// 3. 遍历当前数据库的所有键
dictIterator* di = dictGetIterator(db->dict);
dictEntry* de;
while ((de = dictNext(di)) != NULL) {
robj* key = dictGetKey(de);
robj* val = dictGetVal(de);
// 4. 根据键的类型生成对应的重建命令
if (val->type == OBJ_STRING) {
// 字符串:SET key value
writeSetCommand(fp, key, val);
} else if (val->type == OBJ_LIST) {
// 列表:RPUSH key value1 value2 ...
writeListCommand(fp, key, val);
} else if (val->type == OBJ_SET) {
// 集合:SADD key member1 member2 ...
writeSetMembersCommand(fp, key, val);
} else if (val->type == OBJ_ZSET) {
// 有序集合:ZADD key score1 member1 score2 member2 ...
writeZsetCommand(fp, key, val);
} else if (val->type == OBJ_HASH) {
// 哈希:HMSET key field1 value1 field2 value2 ...
writeHashCommand(fp, key, val);
}
}
dictReleaseIterator(di);
}
// 5. 写入 EOF 标记和校验和(可选)
writeEofAndChecksum(fp);
// 6. 关闭临时文件
fclose(fp);
}
-
子进程遍历的是
fork()那一刻的内存快照(利用了写时复制技术),所以数据是一致的。 -
生成的是
RESP协议格式的命令,与普通 AOF 文件格式完全兼容。
3.3 收尾阶段:父进程处理增量数据
在子进程工作期间,父进程继续处理客户端请求,新的写命令被同时写入两个地方:
-
旧的 AOF 缓冲区(保持旧文件持续更新)
-
AOF 重写缓冲区(aof_rewrite_buf)
子进程完成工作后,通知父进程进行最后的合并:
cpp
// 伪代码:父进程处理重写完成信号
void handleRewriteCompletion(pid_t child_pid) {
// 1. 等待子进程结束,获取退出状态
int status;
waitpid(child_pid, &status, 0);
// 2. 检查子进程是否成功
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
// 子进程失败,清理资源
clearRewriteResources();
return;
}
// 3. 将重写缓冲区(增量数据)追加到临时文件
// 这是关键步骤:保证新文件包含子进程工作期间的所有新写命令
FILE* temp_fp = fopen("temp-rewrite.aof", "a");
fwrite(server.aof_rewrite_buf, len, 1, temp_fp);
fclose(temp_fp);
// 4. 重命名临时文件为目标 AOF 文件(原子操作)
// rename 在 Unix 中是原子的
if (rename("temp-rewrite.aof", "appendonly.aof") != 0) {
// 重命名失败,记录错误
logError("Failed to rename AOF file");
return;
}
// 5. 清空重写缓冲区,重置状态
sdsfree(server.aof_rewrite_buf);
server.aof_rewrite_buf = NULL;
server.aof_rewrite_child_pid = -1;
// 6. 如果新的 AOF 文件大于旧文件,可能需要调整自动重写阈值
updateAofRewriteThreshold();
}
收尾阶段的时序图:
cpp
时间线 ──────────────────────────────────────────────────────────────>
主进程 │ fork() │ 继续处理请求,写入旧AOF + 重写缓冲区 │ 信号处理
│ │ │
子进程 │ │ 遍历内存,生成新AOF │ 退出
│ │ │
│<───────┤ 重写缓冲区积累增量命令 │
│ │ │
│ │ │ 追加增量
│ │ │ 原子替换
四、关键机制深度解析
4.1 写时复制(Copy-on-Write)
fork() 创建子进程时,子进程与父进程共享同一份物理内存,只有当某一方修改时才会复制内存页。这使得 AOF 重写几乎不消耗额外内存,是高效的异步处理基础。
4.2 重写缓冲区的必要性
为什么需要单独的重写缓冲区,而不能用现有的 AOF 缓冲区?
因为子进程生成的新 AOF 文件是基于 fork() 时刻的数据快照。如果在子进程运行期间,父进程的新命令只写入旧 AOF 缓冲区,而子进程生成的临时文件没有这些命令,替换后就会导致增量数据丢失。
4.3 为什么重写能减少文件体积?
| 场景 | 旧 AOF 记录 | 重写后记录 |
|---|---|---|
| 键反复修改 | SET k 1 → SET k 2 → ... → SET k 100 |
SET k 100(只保留最终值) |
| 集合频繁增删 | SADD s a b → SREM s a → SADD s c |
SADD s b c(只保留最终成员) |
| 列表大量增删 | RPUSH l a → LPUSH l b → RPOP l |
RPUSH l b a(只保留最终元素) |
| 过期键 | 键已过期,但旧文件中仍有记录 | 直接忽略,不写入新文件 |
| 键被删除 | SET k 1 → DEL k |
直接忽略,最终状态是"不存在" |
4.4 自动触发的条件
Redis 配置了两个参数控制自动重写:
redis.conf
auto-aof-rewrite-min-size 64mb # AOF 文件至少达到 64MB
auto-aof-rewrite-percentage 100 # 比上次重写后增长 100%
触发逻辑的伪代码:
cpp
// 伪代码:检查是否需要触发自动重写
void checkAndTriggerAofRewrite() {
// 获取当前 AOF 文件大小
long long current_size = getAofCurrentSize();
long long base_size = server.aof_rewrite_base_size;
// 检查条件
if (current_size > server.aof_rewrite_min_size &&
current_size > base_size * (1 + server.aof_rewrite_percentage / 100.0)) {
// 触发重写
rewriteAppendOnlyFileBackground();
server.aof_rewrite_base_size = current_size; // 更新基准大小
}
}
4.5 重写对性能的影响
| 方面 | 影响 | 缓解措施 |
|---|---|---|
| CPU | 子进程遍历数据库,CPU 密集 | 后台执行,不阻塞主进程 |
| 内存 | fork() 时复制页表,写时复制增加内存 |
控制重写触发频率 |
| I/O | 写入新 AOF 文件 | 可配置 aof-rewrite-incremental-fsync 降低磁盘压力 |
| 主线程 | 仅在信号处理时短暂阻塞(毫秒级) | 几乎无感知 |
五、总结与最佳实践
5.1 AOF 重写本质
AOF 重写 = 后台子进程 × (遍历内存 + 生成命令) + 重写缓冲区 × (记录增量) + 原子替换
5.2 核心工作清单
| 步骤 | 执行主体 | 关键操作 |
|---|---|---|
| 1. 触发重写 | 主进程 | 检查条件,fork() |
| 2. 生成新 AOF | 子进程 | 遍历数据库,为每个键生成重建命令 |
| 3. 缓存增量命令 | 主进程 | 新写命令存入 aof_rewrite_buf |
| 4. 追加增量 | 主进程 | 重写缓冲区数据追加到临时文件 |
| 5. 原子替换 | 主进程 | rename() 替换旧 AOF 文件 |
AOF重写和生成RDB快照在执行机制上确实很相似,都利用了Linux的"写时复制"(Copy-On-Write, COW)技术来创建一个子进程处理任务,避免阻塞主进程。不过,它们诞生的目的和最终的结果完全不同。你可以这样理解它们的关系:
它们就像同一位厨师(Redis主进程)使用的两种不同菜谱:
RDB快照 是每隔一段时间,拍一张厨房全貌的照片 存起来。这张照片(RDB文件)记录的是某个瞬间所有食材(数据)的最终样子 ,是全量备份。
AOF重写 则是当记录做菜步骤的本子(AOF文件) 变得太厚时,厨师会回顾本子上的所有步骤,重新梳理成一份精简版的"最终操作指南" 。它只记录让食材变成当前状态所需的最小必要步骤,目的是给AOF文件"减肥"。
| 对比维度 | RDB 快照 | AOF 重写 |
|---|---|---|
| 核心目的 | 全量备份:生成一个压缩的二进制文件,用于快速恢复数据和灾难备份。 | 日志瘦身:压缩AOF文件体积,避免其无限膨胀,同时不影响持久化的实时性。 |
| 产出内容 | 数据本身:某个时间点的所有键值对,按Redis的二进制格式存储。 | 写命令集合:能还原当前数据集的最小、最精简的命令集合。 |
| 数据完整性 | 可能丢失数据:因为是定时备份,两次快照间的数据在故障时可能会丢失。 | 保证完整性:重写期间的新命令会被额外记录,重写完成后会追加到新文件中,确保数据不丢。 |
| 恢复速度 | 快:直接加载数据文件到内存,不执行任何命令。 | 慢:需要逐条重新执行文件中的所有写命令来重建数据 |
推荐一个零声教育学习教程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习:链接