redis中AOF 重写机制解析
AOF(Append Only File)持久化机制通过记录所有写命令来保证数据安全,但随之而来的问题是:随着运行时间增长,AOF 文件会不断膨胀。假设你反复对一个 key 执行INCR操作 1000 次,AOF 文件中会记录 1000 条INCR命令,但恢复时只需要一条SET key 1000即可。这就是 AOF 重写的价值所在。AOF 重写的核心目标就是将 AOF 文件重写为包含相同数据集的最小命令集,从而减少文件体积,加速数据恢复。
一、问题背景:AOF 文件的膨胀
1.1 AOF 持久化回顾
AOF 机制以日志形式记录每个写命令。当执行以下操作时:
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" OKAOF 文件会记录三条命令。但实际上,只有最后一条SET user:1000:name "Charlie"是恢复数据所需要的。
1.2 膨胀带来的问题
| 问题 | 影响 |
|---|---|
| 磁盘空间浪费 | 大量冗余命令占用存储 |
| 数据恢复缓慢 | 启动时需回放所有历史命令 |
| 主从复制压力 | 传输大文件占用网络带宽 |
二、核心设计思想
AOF 重写与直接处理旧 AOF 文件不同,它采用了一种巧妙的方式:不是“重写旧文件”,而是“基于当前内存数据生成新文件”。这种方式相当于定期对数据库做一个“快照”,用一系列能够重建当前数据状态的命令替换旧的、冗长的命令序列。
2.1 核心原则
最小命令集原则:只生成重建当前数据集所需的最少命令。
后台执行原则:通过子进程完成,不阻塞主进程。
原子替换原则:新旧文件切换是原子的,不会丢失数据。
三、重写工作的三阶段
3.1 准备阶段:触发与状态检查
在redis中,无论是手动触发还是自动触发,rewriteAppendOnlyFileBackground()函数是入口:
// 伪代码: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 文件:
// 伪代码:子进程重写 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)
子进程完成工作后,通知父进程进行最后的合并:
// 伪代码:父进程处理重写完成信号 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(); }收尾阶段的时序图:
时间线 ──────────────────────────────────────────────────────────────> 主进程 │ 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%
触发逻辑的伪代码:
// 伪代码:检查是否需要触发自动重写 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等技术内容,点击立即学习:链接
