redis中AOF 重写机制解析

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 核心原则

  1. 最小命令集原则:只生成重建当前数据集所需的最少命令。

  2. 后台执行原则:通过子进程完成,不阻塞主进程。

  3. 原子替换原则:新旧文件切换是原子的,不会丢失数据。

三、重写工作的三阶段

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 收尾阶段:父进程处理增量数据

在子进程工作期间,父进程继续处理客户端请求,新的写命令被同时写入两个地方:

  1. 旧的 AOF 缓冲区(保持旧文件持续更新)

  2. 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 1SET k 2 → ... → SET k 100 SET k 100(只保留最终值)
集合频繁增删 SADD s a bSREM s aSADD s c SADD s b c(只保留最终成员)
列表大量增删 RPUSH l aLPUSH l bRPOP l RPUSH l b a(只保留最终元素)
过期键 键已过期,但旧文件中仍有记录 直接忽略,不写入新文件
键被删除 SET k 1DEL 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等技术内容,点击立即学习:链接

相关推荐
Minxinbb1 小时前
TDSQL for MySQL修改统计信息参数
数据库·dba
阿米亚波1 小时前
【C++ 异常处理】try-catch
开发语言·c++·笔记·try-catch
2501_937860941 小时前
MySQL数据库入门|从零搞懂数据库基础、架构与存储引擎
数据库·mysql·架构
旖旎夜光1 小时前
C++(内存管理)
开发语言·c++·学习
皓月斯语1 小时前
P2858 [USACO06FEB] Treats for the Cows G/S
数据结构·c++·算法·动态规划
whn19771 小时前
达梦连接串JDBC测试程序
java·数据库
让头发掉下来1 小时前
Flink SQL 中两个频繁变化 Topic 的 Join 行为分析
大数据·数据库·sql·flink
旖旎夜光1 小时前
LeetCode 397:整数替换(贪心问题) —— 题解
数据结构·c++·算法·leetcode·贪心算法
别动我齐刘海2 小时前
“三层同步审计”判定掉帧缺失
c语言·c++·人工智能·深度学习·学习·机器学习·机器人