1. AOF文件膨胀的真实代价:为什么重写是必然刚需
1.1 AOF的写入链路决定了文件只会越用越大
我在生产环境维护过不少Redis实例,每次看到appendonly.aof的体积数据往上涨,心里都会咯噔一下。AOF的原理很多人张口就来:把每个写操作追加到日志文件末尾。但仔细想想,这个词的重点在"追加"——不做回写、不覆盖、不清空。这意味着每个写命令的完整历史都会永久留在文件里。
比如同一个keyuser:9527,业务侧一天内反复set了500次,AOF里就躺着500条set命令。再比如某个列表,先rpush了10万条数据,后来业务下线又把整个key删了,文件里依然留着那10万条rpush记录。我见过一个极端案例:实例当前数据量其实只有2GB,但AOF文件已经膨胀到18GB——因为上游一个批量导入脚本反复写同一个大集合,每次全量覆盖。
膨胀的本质是:AOF体积跟"累计写操作总量"走,而不是跟"当前数据集大小"走。这就是为什么重写不是可选优化,而是AOF长期运行下去必须解决的问题。
1.2 膨胀带来的代价远不止磁盘多占几GB
先看看磁盘,18GB和2GB差出16GB,如果机器磁盘总容量有限,这一项就够喝一壶。更麻烦的是下面这几个连锁反应:
- 启动恢复变慢。Redis用AOF恢复数据的逻辑是"按顺序重放所有命令"。文件翻倍,恢复时间基本也翻倍。一次意外重启,业务方可能等十几分钟才能恢复,这在线上是无法接受的。
- 每个写入周期都更吃力。AOF写入虽然用的是write系统调用追加,但文件越大,涉及的文件元数据、页缓存、伙伴系统层面的成本都会增加,磁盘IO压力也更大。
- 排查问题更难。文件大了以后,想手动翻AOF确认某个key的历史变更基本不可能,只能靠脚本离线分析。
- 主从复制也可能被拖累。虽然主从同步走的是RDB和复制流,但如果实例频繁重启,全量同步时RDB或AOF的处理压力会传导到从节点。
一句话总结:AOF膨胀不是"多花点磁盘钱"的小事,它会直接拉低整个Redis集群的可用性。
1.3 重写到底删掉了哪些"垃圾"
初次接触AOF重写的人,很容易把它理解成"拿旧AOF文件做一遍压缩去重"。实际上这是误解,而且这个误解会让你在排查问题时走弯路。AOF重写完全不读旧AOF文件,它是直接读当前内存里的数据,根据现有key和value重新生成一套最小化的写命令集合。
那它到底清掉了什么?我列一下:
- 同一个key的重复写入只保留最后一条。前面500次set,最后只留一条。
- 已经过期或已删除的key直接消失。重写时Redis遍历所有数据库,发现key不存在了,自然一条命令都不生成。
- 多个Redis命令被批量合并。同一个hash的所有字段合并成一条hmset,同一个list的所有元素合并成一条rpush,同一个set的所有成员合并成一条sadd。命令条数从几万降到几百。
- 带有过期时间的key会重新计算TTL,过期时间点不变,但记录方式更精简。
重写出来的文件体积,理论上约等于"当前数据集的最小完整副本"。数据本身越紧凑、碎片越少,重写后的文件就越小。
提示:正因为重写基于内存快照,所以如果旧AOF里曾经发生过命令覆盖(比如某个key后来被del了),重写后这条key就不会出现在新文件里。这跟"备份还原"的直觉不一样,但逻辑是完全正确的。
2. 重写的执行内核:fork子进程、快照和双缓冲机制
2.1 为什么必须fork子进程来处理
重写整个数据集是个大工程,如果让主进程一条条遍历处理,业务请求全被卡死,这是绝对不可接受的。Redis的解法是fork出一个子进程来干重活。
fork瞬间,子进程获得一份父进程内存页表的副本。父进程继续接收客户端请求、处理命令、写AOF;子进程则拿到fork那一刻数据集的内存镜像,开始遍历生成新AOF文件。由于Linux的写时复制(Copy On Write)机制,父子进程在fork后共享物理内存页,只有父进程后续修改到的内存页才会被复制,因此fork本身的开销大约等于复制页表的时间,而不是复制全部内存的时间。
这里有个很关键的直觉:子进程拿到的是fork时刻的"快照",而不是"最新数据"。重写期间新写入的命令,主进程需要另想办法兜住,这就是下面的双缓冲机制要解决的问题。
2.2 子进程如何生成最小命令集合
子进程遍历完所有数据库后,会对每个key根据类型做序列化输出。如果aof-use-rdb-preamble是开启的(Redis 4.0之后默认开启),子进程会先用RDB格式把当前数据集的核心内容写进临时文件——这一步就像做了一轮紧凑快照,然后才把重写期间差量命令(来自重写缓冲区)以AOF格式追加到文件末尾。Redis 7.0里这个机制更自然,基础文件直接就是RDB格式的内容。
这种"RDB打底 + AOF增量"的混合设计,好处显而易见:RDB格式加载速度远快于逐条重放命令,文件体积也远小于全量命令集。实测下来,同样一份数据,混合格式的AOF加载耗时可以比纯命令格式少一个数量级。代价是格式不再是纯文本,不能再用cat直接看内容,但对运维来说这个代价完全值得。
2.3 双缓冲机制:重写期间的新写入怎么接住
这是AOF重写最容易讲不清楚、也最值得吃透的一块。场景是:fork完成、子进程开始动手之后,Redis还在持续处理线上写入。假设没有额外的缓冲,那么子进程生成的新AOF文件里就不包含这些新命令,重写完成后一切换,数据就丢了。这肯定不行。
Redis的解决办法是双缓冲。在fork子进程之前,主进程往server.aof_buf(AOF写入缓冲)里写数据;fork之后,主进程除了继续写aof_buf,还会把每一条新命令同时追加到server.aof_rewrite_buf(AOF重写缓冲)。也就是说,重写期间产生的所有写入,会被同时记到两个地方:
- 旧AOF文件继续按原逻辑追加,保证当前持久化状态完好;
- 新AOF临时文件之外,主进程本地额外攒一份"重写期间增量"。
等子进程完成新文件的生成,主进程会收到一个信号,然后把aof_rewrite_buf里的全部内容追加到临时文件末尾,最后执行原子改名,完成新旧文件切换。
你可能会问:那如果重写期间有大量写入,aof_rewrite_buf会不会撑爆?会。这也是我在监控里特别关注aof_rewrite_buffer_length字段的原因。如果这个缓冲区一直涨得很快,说明写入压力非常大,重写完成时追补的数据量也不小,甚至可能导致持久化延迟。
2.4 文件切换的原子替换与崩溃安全
最后一步替换动作,Redis用的是rename,这是文件系统层面的原子操作。子进程从头到尾写的都是临时文件(比如temp-rewriteaof-bg-xxx.aof),只有等它完整写完、并且主进程把重写缓冲区的数据补进去之后,这个临时文件才会被正式改名为appendonly.aof。
这个设计保证了:即使重写过程中主进程或子进程崩溃,旧AOF文件始终完整可用。崩溃恢复时最多丢失一小段aof_rewrite_buf里的数据,而这一段数据本身就还没写入旧AOF,恢复时旧AOF重放的结果是"重写开始前"的状态,语境上是自洽的。
所以从数据安全角度看,AOF重写不是"边写边覆盖",而是"全部写完再一步切换"。我在帮同事review配置时,经常看到有人担心"重写会不会把正在写的文件写坏",其实完全不会,你可以放心。
3. 触发机制拆解:手动bgrewriteaof与自动阈值的完整链路
3.1 手动触发前后这一秒内部发生了什么
执行bgrewriteaof命令的那一瞬,Redis会先做几个检查:当前是否已经有另一个AOF重写子进程在跑?如果有,直接返回错误,提示Background append only file rewriting already in progress。还会检查是否正在生成RDB快照,如果正在生成,重写请求会被标记为scheduled,等RDB子进程跑完再自动启动。
检查通过后,主进程调用fork创建子进程,然后立即返回字符串Background append only file rewriting started。这里要注意:命令返回"started"不代表重写完成,它只是告诉你子进程已经起来了。很多运维第一次看到这个输出以为完事了,结果一查info persistence发现aof_rewrite_in_progress还是1,就误以为自己搞错了。实际上,重写需要持续十几秒到几分钟都很正常,取决于数据集大小和磁盘速度。
手动触发最适合的场景是:已知接下来有低峰期,或者系统刚部署完、准备让AOF从初始状态就开始保持精简。我习惯在大促前主动做一次重写,把文件压到最小,给后面的写入留出空间和时间。
3.2 auto-aof-rewrite-percentage的计算逻辑
自动重写有两个开关,auto-aof-rewrite-percentage和auto-aof-rewrite-min-size,默认是100和64mb。Redis判定是否需要重写的逻辑是两条同时满足:
- 当前AOF体积
aof_current_size大于等于auto-aof-rewrite-min-size; (aof_current_size - aof_base_size) / aof_base_size >= auto-aof-rewrite-percentage / 100。
这里有个容易忽略的变量:aof_base_size是上次重写完成之后的基础大小,它其实记录了"上次重写结束时的文件体积"。所以percentage的含义是:相对上次重写的结果,文件又增长了百分之多少。
举一个具体例子。假设你把min-size设为64MB,percentage设为100。第一次重写完成时aof_base_size是80MB,之后随着业务写入,当前文件涨到160MB,此时差值比例是100%,触发重写。重写完成后aof_base_size更新为新的体积(比如85MB)。如果后续写入量不大,文件一直没超过170MB,就不会再次触发。
很多同学配了自动重写但发现"它不触发",原因多半是这几点:
- min-size设太高,比如配成了5GB,小实例永远够不到门槛;
- 刚做过手动重写,base_size被刷新得很小,百分比差没到触发线;
- Redis 7.0里如果开启了多部分AOF,
aof_base_size的含义变成了base文件的大小,需要结合manifest看。
3.3 fork瞬间为什么会出现毫秒级卡顿
重写过程中唯一需要主进程"同步等待"的地方就是fork。Linux fork需要复制页表,页表大小跟进程占用内存(RSS)成正比。一个内存占用10GB的Redis实例,fork复制页表可能要几百毫秒;如果机器内存带宽又紧张,这个时间还会更长。这就是为什么我从来不建议在业务高峰期手动触发重写——你点一下bgrewriteaof,可能就要用几百毫秒的延迟尖刺来买单。
缓解办法有三个方向。一是控制单实例内存,尽量不超过8~12GB,fork开销可控;二是把自动重写的阈值调大,降低频繁fork的概率;三是如果Redis实例确实很大,考虑改造为主从架构,在从节点上做重写,把fork压力转移到从机。第三种方案需要额外验证从节点重写后的文件是否完全一致,但思路是成立的。
3.4 自动重写不触发的排查思路
我之前在另一个项目里被问过一个问题:"我们Redis配置了自动重写,为什么AOF都涨到2GB了还不触发?"排查链路是这样的:
第一步,执行redis-cli info persistence,看aof_base_size和aof_current_size各是多少。如果base_size已经是1.9GB,current_size是2GB,那增长比例只有大约5%,离100%差得远,自然不触发。第二步,确认auto-aof-rewrite-min-size配置,如果设了1GB,那算法第二个条件虽然满足,但第一个条件可能不满足。第三步,检查aof_rewrite_scheduled字段是否为1,如果之前有RDB在做,重写一直在排队。第四步,确认appendonly确实是yes,不要只看redis.conf,要看运行时config get appendonly。
这套排查思路基本能覆盖90%的"不触发"场景。剩下10%大概率是版本差异——Redis 7.0的多部分AOF机制里,base文件和增量文件是分离的,aof_current_size的含义不同,必须对着manifest看。
4. 重写期间最容易踩的性能坑与参数调优
4.1 三大阻塞点:fork阻塞、fsync阻塞、write阻塞
重写期间主进程不是完全无感,它有至少三个地方可能出现延迟尖刺。
第一个是fork阻塞,上面已经说过,页表复制耗时跟内存大小强相关。第二个是fsync阻塞,即使配置了appendfsync everysec,Redis每秒执行一次fsync把缓冲刷到磁盘,碰上磁盘性能差或者系统IO繁忙,一次fsync可能卡几十毫秒甚至更久。第三个是write阻塞,主进程写AOF文件要走系统调用,如果磁盘队列塞满,write一样会卡。
真正影响体验的组合场景是:重写子进程正在疯狂写磁盘,同时主进程每秒还要fsync旧AOF,两边抢磁盘带宽。我见过一个现象:开启自动重写后,本来平均延迟0.5ms的实例,重写那几十秒内P99直接飙到几十毫秒。这不是重写逻辑有bug,而是"重写子进程和主进程同时抢同一块磁盘"导致的资源竞争。
4.2 appendfsync与no-appendfsync-on-rewrite的组合方案
这里有个配置值得单独拎出来:no-appendfsync-on-rewrite。默认值是no,翻译成操作行为就是:即使正在重写,主进程依然严格按照appendfsync策略来刷盘。
如果把它设为yes,那么在整个AOF重写期间,主进程会暂停主动fsync,把刷盘动作完全交给操作系统。这个配置的收益和风险都很清晰:
| 配置组合 | 重写期间延迟 | 断电丢失窗口 | 适用场景 |
|---|---|---|---|
| appendfsync everysec + no-appendfsync-on-rewrite no | 重写时延迟可能升高 | 最多丢1秒 | 默认安全配置 |
| appendfsync everysec + no-appendfsync-on-rewrite yes | 延迟明显更平稳 | 最多丢1秒,但极端情况下可能更多 | 磁盘性能弱的机器 |
| appendfsync always | 不允许丢失任何已确认写入 | 几乎不丢 | 对持久化要求极高的场景 |
| appendfsync no | 延迟最优,操作系统定刷 | 可能丢数秒 | 对丢数据容忍度高的缓存场景 |
我个人的倾向是:如果没有严格的"每秒最多丢多少数据"的SLA,重写期间把no-appendfsync-on-rewrite设为yes是合算的,因为重写本来就是要花时间做磁盘密集操作,没必要让主进程在这段时间里跟子进程抢fsync。但如果你的业务方明确要求"最多丢1秒以内",那就要保持no,并且要把磁盘性能提前压测好。
4.3 大key与写时复制导致的内存翻倍风险
这是重写相关故障里最容易出大问题的点,但常规资料很少讲透。前面说过fork后父子进程共享物理页,父进程修改的内存页会被复制。问题在于:如果父进程持续修改一个很大的key,比如一个包含百万成员的hash,业务侧高频更新它的局部字段,那么每次更新涉及的hash底层结构页都会触发COW复制。大数据量大key在场时,COW复制的内存量可能远超预期,极端情况下实例RSS会在重写期间接近翻倍。
为了防止这个坑,我在运维规范里会做几件事:
- 监控重写期间的内存指标,观察RSS曲线有没有异常爬升;
- 上线大key拆分规范,单个value超过10MB的key一律拆成多个分区key;
- 如果发现某个大key是重写期间内存飙升的元凶,先跟业务方协调把写入频率降下来,再触发重写。
内存翻倍的后果不只是服务器压力大,还可能是cgroup OOM Kill直接把Redis进程干掉。这个风险必须认真对待。
4.4 一套实测下来比较省心的参数组合
分享一组我在中小型实例(数据量约6GB)上长期使用的组合:
appendonly yes appendfilename "appendonly.aof" appendfsync everysec auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 1gb no-appendfsync-on-rewrite yes aof-use-rdb-preamble yes aof-load-truncated yes为什么min-size设到1GB而不是默认的64MB?因为小于1GB的AOF文件重写收益不高,频繁小规模重写反而会引入更多fork和磁盘IO。当文件真正涨到GB级别,做一次重写的性价比就很高。percent保持100表示翻倍才重写,这样大部分实例一天最多触发一两次,甚至一两天一次,频率完全可控。
注意:如果你管理的实例写入量极大,比如每秒几万次写,重写缓冲区可能会持续增长。这种情况下建议调高内存水位告警,并在架构层面考虑给Redis开启AOF重写专用的-remove-on-fail等保护机制,尽量避免重写失败。
5. 用日志和INFO字段监控重写健康状况
5.1 INFO persistence里每个关键字段都在告诉你什么
redis-cli info persistence是观察AOF重写的第一入口。我每次排查AOF问题,上来就刷这个命令。核心字段我整理成了一张表:
| 字段 | 含义 | 重点关注场景 |
|---|---|---|
| aof_enabled | AOF功能是否开启 | 确认开关没被误关 |
| aof_rewrite_in_progress | 当前是否正在重写 | 长期为1且不结束要警惕 |
| aof_rewrite_scheduled | 是否有重写在排队 | RDB子进程拖后腿时会看到 |
| aof_last_rewrite_time_sec | 上次重写总耗时 | 突然暴涨说明磁盘异常或数据膨胀 |
| aof_current_rewrite_time_sec | 当前这轮重写已耗时 | 超过last字段多倍即为异常 |
| aof_last_bgrewrite_status | 上次重写结果 | 非ok要立刻查日志 |
| aof_current_size | 当前AOF体积 | 跟base_size对比判断触发时机 |
| aof_base_size | 上次重写后的基础体积 | 重写后会自动更新 |
| aof_rewrite_buffer_length | 重写缓冲区当前积压字节 | 持续上升是性能隐患 |
| aof_pending_bio_fsync | 等待后台刷盘的请求数 | 积压说明磁盘IO吃紧 |
| aof_delayed_fsync | 主进程延迟fsync的次数 | 数值一直涨说明磁盘跟不上 |
5.2 从日志读懂一次重写的完整生命周期
重写期间Redis的日志会输出几条关键记录,我用一个模拟示例说明:
M ... * Background append only file rewriting started by pid 19431 M ... * Fork CoW for AOF rewrite: 512 MB M ... * Background AOF rewrite finished successfully第一条是fork前的预告,告诉你子进程pid。第二条是关键:Fork期间写时复制了多少内存。这个数字如果接近实例内存总量,说明父进程在fork后写入了大量页,COW压力大。第三条是成功标志。如果失败,你会看到Background AOF rewrite terminated with error,同时日志里一般会附带具体错误码,比如磁盘已满、权限不对、文件系统只读等。
我还会特别留意中间那句的耗时差异。正常情况下,CoW的数据量跟重写期间实际写入的数据量正相关;但如果相同的写入量下CoW明显变大,就要检查是否出现了大量随机覆盖写,这会让内存页复制成倍增加。
5.3 结合latency监控确认重写没有拖垮延迟
日志只能看到结果,看不到用户请求是否被卡顿。我自己有个习惯:在重写测试或者故障演练时,开一个独立的窗口跑redis-cli --latency,观察实时延迟分布。
实际操作示例:
redis-cli --latency -h 127.0.0.1 -p 6379 -i 1然后在另一个终端手动触发:
redis-cli bgrewriteaof观察--latency输出中是否出现明显尖刺。正常情况下,如果实例数据量不大,尖刺就一个点(对应fork),后续保持平稳;如果尖刺持续存在,说明磁盘IO竞争或COW内存复制对主线程产生了持续影响,那时再回头调no-appendfsync-on-rewrite等参数就更有针对性了。
另外可以在配置里开启延迟监控:
latency-monitor-threshold 100然后通过redis-cli latency history command查看耗时超过100ms的事件分布,确认重写是否上榜。
6. 重写故障的大坑复盘:从卡死到AOF损坏恢复
6.1 磁盘满导致重写失败:排查链路
这是我真实踩过的一个坑。某次自动重写触发后,实例日志开始疯狂报No space left on device,重写子进程反复失败。我当时先看了info persistence,发现aof_last_bgrewrite_status是err,然后翻日志确认是磁盘已满。排查链路其实很直接:
- 第一步,
df -h看磁盘分区使用率,确认是否真的满了; - 第二步,
du -sh /data/redis/appendonly*看AOF相关文件占用; - 第三步,检查临时文件是否残留。重写失败或中断会留下类似
temp-rewriteaof-bg-*.aof的文件,占用不少空间。
处理上我是这么干的:先清理掉残留的临时文件,再跟业务方确认是否可以清理旧备份,最后调大auto-aof-rewrite-min-size避免它频繁重写,等磁盘空间稳定后再手动做一次完整重写。
这里提醒一下:AOF重写需要临时磁盘空间大概是"新文件体积 + 旧文件体积"的量级。因为重写完成前新旧文件是同时存在的,所以磁盘剩余空间最好留够旧AOF体积的2倍,否则重写大概率在中途失败。
6.2 AOF文件尾部截断的恢复:redis-check-aof实测
AOF文件因为崩溃或者意外断电,文件尾部可能出现截断或损坏。Redis启动时会做load检查,如果尾部数据不完整,默认策略(aof-load-truncated yes)是忽略尾部错误、只加载完整部分,然后正常启动。
如果aof-load-truncated是no,或者你想手工修复文件,可以用Redis自带的工具:
# 先备份原文件 cp appendonly.aof appendonly.aof.bak # 执行修复,--fix 会自动把损坏/截断的部分截掉 redis-check-aof --fix appendonly.aof工具执行完成后会告诉你修复了多少字节。但我要强调:修复意味着"丢失损坏之后的那部分数据",不是一个完全无损的操作。所以在修复前一定要确认,你更在意的是"尽快让Redis可用",还是"尽可能找回最后一段数据"。如果磁盘空间充足,可以先把原文件完整备份再修复,给自己留后悔药。
6.3 aof-load-truncated的取舍
这个参数很多人不重视,但它决定了Redis遇到损坏AOF时是"直接罢工"还是"带伤启动"。生产环境的取舍我建议分场景:
- 如果这台Redis是缓存性质,丢几秒数据无所谓,
aof-load-truncated yes是最省心的; - 如果是核心数据存储,任何一段数据都不愿意丢,那最好保持no,让Redis拒绝启动,然后人工介入检查备份和做数据恢复,而不是默默丢掉尾部。
我见过一个案例:某团队配置了yes,AOF尾部损坏后Redis启动后静默丢了最后几秒钟的关键订单写入,业务方一直到第二天对账才发现数据对不上。所以这个配置本质上是一个"可用性优先"还是"一致性优先"的选择题,没有绝对正确答案,但一定要是团队里明确讨论过的决定。
6.4 Redis 7.0多部分AOF机制带来的变化
Redis 7.0把AOF从"单一文件"改成了"多个文件+清单文件"的结构。简单说,现在AOF由三类文件组成:
- base文件:存储基础快照数据,通常是RDB格式;
- incr文件:存储增量命令日志;
- manifest清单文件:记录当前有哪些base文件和incr文件、它们的顺序和状态。
AOF重写在这个新结构下的行为是:生成一个新的base文件,同时把旧的incr文件清空或轮替掉,manifest同步更新。好处是文件管理和恢复逻辑更清晰,加载时可以并行处理多个文件,速度更快;代价是排查问题时不能只盯一个appendonly.aof,要去appendonlydir目录下看多个文件。
如果你还在用Redis 6.x,升级到7.0后要注意:老版本的单个AOF文件会被迁移为多部分结构,auto-aof-rewrite-min-size的语义仍然是全局的,但aof_base_size现在指的是base文件的大小,不再是整个AOF目录的体积。网上有些旧教程里的字段解读,放到新版本上会失真。
关于重写这块,我最后想说的一个体会是:AOF重写本身不是越频繁越好,也不是一直不触发就好,它本质上是在"文件体积"和"重写成本"之间找平衡。真正考验运维功力的不是会用bgrewriteaof,而是能根据实例的内存大小、写入吞吐、磁盘性能、业务低峰期,把阈值和监控调到刚好合适。我在实际运维里踩过不少跟磁盘、COW、缓冲区、截断相关的坑之后,最大的心得就是:给每个Redis实例都配上AOF体积和重写耗时的告警,比背多少理论知识点都管用。先把监控铺好,再谈调优,这条顺序一定不要反过来。