Redis 开了 AOF 就安全了?我在 26G 实例上被 fork 卡了 380 毫秒
凌晨两点十七分,手机响了
告警是 P99 延迟 1200ms,持续了 40 秒后自己恢复。我爬起来连上跳板机,第一件事是 redis-cli info persistence,输出里两行比较扎眼:aof_rewrite_in_progress:1,latest_fork_usec:382741。下面一行 aof_last_bgrewrite_status:ok,说明重写本身是成功的,没人挂。
382741 微秒,382 毫秒。这个数字就是主线程被 fork 系统调用卡住的时长。当时实例的 used_memory_human 是 26.4G,maxmemory 设的 32G,appendonly yes,appendfsync everysec,auto-aof-rewrite-percentage 100、auto-aof-rewrite-min-size 64mb。也就是说 AOF 文件每翻一倍大小就触发一次重写,这个实例一天大概触发两到三次。
那 382 毫秒里所有命令都在排队。这个实例平时的 QPS 大概 8000 左右(读写比差不多 7:3,读多),算下来积压了三千条上下,但因为客户端连接池的 socketTimeout 我设的是 1000ms,反而没触发大面积超时,只有几个跑批任务报错了。真正难受的是重写那几分钟:used_memory 从 26.4G 一路涨到 31.8G,离 maxmemory 只剩 200M,那段时间我盯着监控手心是出汗的,生怕哪个业务突然灌一批 key 进来直接把淘汰策略打穿。
fork 到底卡在哪,为什么内存越大越慢
很多人对 fork 的理解停留在「写时复制,开销很小」,这话对,也不对。fork 本身确实不复制数据,但它要复制页表和虚拟内存区域(VMA)结构。26.4G 数据如果按 4KB 页算,差不多 690 万个页表项,每个 8 字节,光页表本身就有 50 多 MB 要拷,再加上进程的 VMA 链表、各种内核结构。CPU 在这 382 毫秒里就是埋头干这个。
业内有个粗略的经验值:fork 耗时大约每 GB 内存 10 到 20 毫秒。26.4G 乘以这个系数,落在 260ms 到 530ms 之间,我这次 382ms 正好在范围里。这也是为什么我一直不建议单实例内存超过 16G——不是说不能跑,是你得清楚一旦超过这个量级,任何一次 BGSAVE 或者 BGREWRITEAOF 都会变成一次几百毫秒的抖动,而且这个抖动是主线程的,不是后台的。
更坑的是 COW(写时复制)。重写子进程启动后,父进程任何一次写操作,碰到同一个内存页,内核就得先把这一页复制一份出来。重写期间业务还在不停写,被复制的页就越来越多。极端情况下内存能翻倍——26.4G 加上副本,理论上能顶到 50G 以上。我这次涨到 31.8G 算运气好,因为后面几分钟写入的 key 比较分散,没有集中怼在某几个大 hash 上。如果业务刚好在写一个几百万字段的大 hash,那 COW 复制起来就是一整片一整片地来。
顺带说一句,网上流传的「关掉 THP」和「设置 vm.overcommit_memory=1」真不是玄学。THP 会让内核以 2MB 为单位管理内存页,fork 时复制页表的粒度变大,COW 的时候一次复制 2MB,延迟会更难看。overcommit 那个更直接:不留 1 的话,fork 在内存吃紧时可能直接失败,日志里会出现 Can't fork, error 12,而 Redis 官方文档里明确写了这一点。
AOF 那「最多丢 2 秒」的安全感,其实是打折的
我一开始也是奔着「AOF 比 RDB 安全」去开 AOF 的。RDB 的 save 900 1 最坏丢 15 分钟,AOF everysec 最坏丢 2 秒,账面上确实划算太多。但这个 2 秒有个前提条件经常被忽略:磁盘的 fsync 必须能在 2 秒内跑完。
Redis 的写入流程是这样的:主线程把命令写进 AOF 缓冲区(这一步是内存操作,很快),然后有后台 bio 线程负责 fsync 落盘。如果上一次 fsync 还卡着没返回,主线程会怎么处理?它会检查距离上次 fsync 过去了多久,一旦超过 2 秒,主线程就得停下来等——INFO persistence 里那个 aof_delayed_fsync 计数器统计的就是这种情况发生的次数。所以「最多丢 2 秒」的另一面是「可能阻塞主线程」,这一条在官方文档的 latency 章节里写着,但很多人配 AOF 的时候不会去看。
我自己那块云盘标称 IOPS 有限,AOF 重写的时候监控上 IOPS 直接顶到 4800 多,基本是那块盘的配额上限了。重写要顺序写一个几十 G 的新文件,业务同时在往旧 AOF 文件和重写缓冲区里写,加上每隔一秒还要 fsync 一次,磁盘队列一深,fsync 就开始拖。这就是为什么 AOF 重写期间往往是最容易出问题的时间窗口。
再补一个容易踩的坑:no-appendfsync-on-rewrite。这个参数默认是 no,意思是重写期间照常 fsync。有人为了减轻磁盘压力把它改成 yes,觉得反正重写完了 AOF 是完整的——但代价是重写那几分钟里 fsync 等于停了,一旦这时候实例崩掉,丢的数据就不是 2 秒,可能是几分钟。这是个典型的用可用性换数据安全性的开关,改之前想清楚。
我最后改成了什么样
折腾了大半个月,我把这个实例拆成了三个 8G 左右的实例,走 Cluster 或者干脆业务侧分片(看你们团队运维成本怎么算)。改完之后的数字很直观:fork 耗时从 382ms 降到 120ms 上下,重写期间的内存峰值涨幅从 5G 多降到 1.5G 以内,aof_delayed_fsync 从每天十几次变成基本不涨。
具体配置上我做了这么几件事:主库保持 RDB + appendfsync everysec,把 AOF 挪到从库上开——从库 fork 慢一点、延迟高一点,业务感知不到,主从断了重连走部分重同步或者全量,影响面比主库直接抖动小得多。内核参数老老实实按官方来:vm.overcommit_memory=1,THP 关掉。Redis 版本升到 7.x,multi-part AOF 之后重写期间的 AOF 缓冲区管理比 6.x 之前省心不少,aof-use-rdb-preamble yes 也打开了,重写出来的文件前面是 RDB 格式,恢复速度快一截。
还有个我自己的判断,可能有点反直觉:AOF 带来的安全感,心理层面的成分比技术层面大。真正决定你丢多少数据的是 fsync 策略、主从链路、还有业务侧有没有做幂等重放,而不是「有没有开 AOF」这一个开关。我见过开 AOF 但 appendfsync no 的,那跟裸奔差不多;也见过不开 AOF 但每台机器都挂了从库、业务侧还有消息队列兜底的,人家反而睡得更香。
所以我现在跟人聊 Redis 持久化,一般不推荐具体参数,而是先问三句话:你能接受丢多少数据?你的实例多大内存?你的磁盘 IOPS 顶得住吗?这三个问题的答案基本就决定了配置长什么样。单实例超过 16G 还想开 AOF 的,先准备好接受几百毫秒的周期性抖动,或者干脆把实例切小——把一次大停顿摊成多次小停顿,这是我这次凌晨爬起来最大的收获。