news 2026/8/9 11:27:12

redis中AOF 重写机制解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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" OK

AOF 文件会记录三条命令。但实际上,只有最后一条SET user:1000:name "Charlie"是恢复数据所需要的。

1.2 膨胀带来的问题

问题影响
磁盘空间浪费大量冗余命令占用存储
数据恢复缓慢启动时需回放所有历史命令
主从复制压力传输大文件占用网络带宽

二、核心设计思想

AOF 重写与直接处理旧 AOF 文件不同,它采用了一种巧妙的方式:不是“重写旧文件”,而是“基于当前内存数据生成新文件”这种方式相当于定期对数据库做一个“快照”,用一系列能够重建当前数据状态的命令替换旧的、冗长的命令序列。

2.1 核心原则

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

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

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

三、重写工作的三阶段

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

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

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

  2. 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 1SET k 2→ ... →SET k 100SET k 100(只保留最终值)
集合频繁增删SADD s a bSREM s aSADD s cSADD s b c(只保留最终成员)
列表大量增删RPUSH l aLPUSH l bRPOP lRPUSH 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%

触发逻辑的伪代码:

// 伪代码:检查是否需要触发自动重写 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等技术内容,点击立即学习:链接

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/9 11:26:30

从数据获取到量化分析:AKShare开源财经数据接口库技术解析

从数据获取到量化分析&#xff1a;AKShare开源财经数据接口库技术解析 【免费下载链接】akshare AKShare is an elegant and simple financial data interface library for Python, built for human beings! 开源财经数据接口库 项目地址: https://gitcode.com/gh_mirrors/ak…

作者头像 李华
网站建设 2026/8/9 11:26:10

Java字符串验证器设计与实现:从规则定义到生产实践

在实际开发中&#xff0c;我们经常需要处理字符串的验证、清洗和转换。一个典型的场景是&#xff0c;从用户输入、文件读取或第三方接口获取的原始字符串&#xff0c;往往包含各种非预期的字符&#xff0c;比如多余的空格、不可见的控制字符、甚至是一些特殊符号。如果直接将这…

作者头像 李华
网站建设 2026/8/9 11:25:26

终极多视频同步播放指南:为什么GridPlayer能改变你的工作方式?

终极多视频同步播放指南&#xff1a;为什么GridPlayer能改变你的工作方式&#xff1f; 【免费下载链接】gridplayer Play videos side-by-side 项目地址: https://gitcode.com/gh_mirrors/gr/gridplayer 你是否经常需要在不同视频之间来回切换&#xff0c;浪费大量时间寻…

作者头像 李华
网站建设 2026/8/9 11:24:22

Spark性能优化:RDD宽窄依赖原理与数据倾斜实战

1. 从一次数据倾斜事故说起 去年处理过一个典型的Spark性能问题&#xff1a;某个ETL作业在集群上运行时间从平时的20分钟突然延长到2小时。通过Spark UI观察发现&#xff0c;某个stage的执行时间异常漫长&#xff0c;200个task中有197个在1分钟内完成&#xff0c;但剩下的3个ta…

作者头像 李华
网站建设 2026/8/9 11:23:29

MySQL事务与MVCC核心原理及实战优化

1. MySQL事务与MVCC核心原理剖析 从事数据库开发五年多&#xff0c;处理过上百个事务相关的生产问题后&#xff0c;我深刻理解事务隔离机制对系统稳定性的影响。上周刚解决一个因MVCC机制理解偏差导致的库存超卖事故&#xff0c;这促使我重新梳理这套底层原理。本文将用大量实例…

作者头像 李华
网站建设 2026/8/9 11:23:24

JumpServer堡垒机核心功能与安全配置实战

1. JumpServer核心功能全景解析JumpServer作为一款开源的堡垒机系统&#xff0c;其功能架构设计遵循了运维安全审计的核心需求。根据我在金融和互联网行业的部署经验&#xff0c;这套系统主要包含六大功能模块&#xff1a;资产管理&#xff1a;不仅支持SSH、RDP、VNC等协议的主…

作者头像 李华