ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Redis 持久化机制深度对比:RDB fork 代价、AOF 重写与混合持久化的恢复时延

Redis 持久化机制深度对比:RDB fork 代价、AOF 重写与混合持久化的恢复时延 Redis 持久化机制深度对比RDB fork 代价、AOF 重写与混合持久化的恢复时延1. 从一次主从切换说起为什么持久化一直在被低估假设你负责一个电商详情页缓存集群Redis 里存了 3 亿个商品摘要实例内存 32GB。某天凌晨机房网络抖动主节点触发哨兵切换从节点提升为主。切换完成后监控报警这个新主节点的写入 QPS 掉了一半P99 延迟从 2ms 涨到 40ms持续了将近 90 秒才恢复。你登上机器看INFO发现切换过程中从节点正在做全量同步同时触发了 RDB 落盘而它内存里因为长期开启 AOF、appendfsync everysec在切换瞬间又赶上了 AOF 缓冲区刷盘。三个动作挤在一起fork 复制页表、磁盘写入快照、AOF 缓冲区 fsync。CPU 没满磁盘 util 打满主线程被 fsync 阻塞。这类事故的根因往往不是“Redis 慢”而是持久化策略和业务写入模式不匹配。持久化看起来只是三个配置项实际决定了三件事故障时丢多少数据、重启要等多久、正常运行要付出多少延迟。本文就把这三件事拆开讲清楚。先记住一个最小模型RDB是把某一刻的整份数据写成一个二进制快照文件像给内存拍照片。AOF是把每条写命令按顺序追加到一个日志文件像录像。混合持久化是重启基线用 RDB 快照快照之后的增量用 AOF 追加照片加录像。这三个名字背后的关键差异不在文件格式而在什么时候写、谁去写、写的时候主线程要不要停下来。2. 整体框架谁在什么时候写什么文件先把角色和连接关系理清楚后面所有细节都挂在这张图上。客户端写命令 | v --------------------------- | Redis 主线程 | | 1. 执行命令改内存 | | 2. 追加到 AOF 缓冲区 | | 3. 返回客户端 | --------------------------- | | | | fork() v v ---------------- -------------------- | AOF 缓冲区 | | 子进程 | | everysec 刷盘 | | 遍历内存写 RDB | ---------------- -------------------- | | v v appendonly.aof dump.rdb从这张图能看到三条独立的写路径主线程写内存并追加 AOF 缓冲区这是同步路径直接影响命令延迟。fork()出子进程写 RDB这是异步路径但fork那一刻主线程要短暂停顿。AOF 缓冲区按策略刷盘always每条都 fsynceverysec每秒一次no交给操作系统。再看一次完整的“写入到落盘”时序把两者叠起来时间轴 --- 主线程: [写命令]--[写命令]--[写命令]--[写命令]------------ AOF缓冲: \ \ \ \ -------------------------------- [fsync 每秒] RDB子进程: | -- fork --[遍历页表]--[写文件]--[退出] | -- 期间主线程写入的页被复制COW这里出现了后面要反复讲的两个关键词fork 的写时复制Copy-On-WriteCOW和AOF 重写。前者决定 RDB 生成过程中内存会不会膨胀后者决定 AOF 文件会不会无限增长。3. RDB快照到底“快”在哪里代价又在哪里3.1 一个具体场景每天凌晨的定时快照很多团队配置是这样的# redis.conf 简化节选 save 900 1 # 900 秒内至少 1 个 key 变化 save 300 10 # 300 秒内至少 10 个 key 变化 save 60 10000 # 60 秒内至少 10000 个 key 变化 dbfilename dump.rdb dir /data/redis/6379 rdbcompression yes三个save是“或”关系任意一条满足就触发一次 RDB。凌晨低峰时save 60 10000很容易被满足于是持续有 fork。RDB 的优势很直接文件是紧凑的二进制加载时比逐条重放 AOF 快得多适合做备份和跨机房传输。代价集中在两点fork 的停顿和生成期间的 COW 内存膨胀。3.2 fork 到底做了什么fork()是操作系统调用Linux 上它给子进程复制一份父进程的页表把内存页标记为只读共享。子进程开始遍历内存写 RDB 文件父进程继续处理客户端请求。任何一方要修改某个内存页时内核才真正复制那一页这就是写时复制。关键工程结论fork 本身耗时和页表大小正相关和实际数据量只间接相关。32GB 实例、开启大页或使用大量小对象时页表项多fork 更慢。COW 会产生额外内存占用。生成快照期间主线程写入越激烈被复制的页越多最坏情况下接近翻倍。fork 是阻塞主线程的阻塞时长可以用INFO stats里的latest_fork_usec观察。3.3 用数据验证 fork 代价下面这个 Java 示例用 JedisRedis 官方 Java 客户端做一次可复现的观测实验。目标在高写入压力下触发一次BGSAVE记录latest_fork_usec和内存变化。前置环境Redis 7.x 单实例本地或测试环境JDK 17Maven 依赖redis.clients:jedis:5.1.0。importredis.clients.jedis.Jedis;importredis.clients.jedis.JedisPool;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.ExecutorService;importjava.util.concurrent.Executors;importjava.util.concurrent.TimeUnit;publicclassRdbForkObservation{publicstaticvoidmain(String[]args)throwsException{JedisPoolpoolnewJedisPool(127.0.0.1,6379);intwriters4;intopsPerWriter20000;ExecutorServiceexecutorExecutors.newFixedThreadPool(writers);CountDownLatchdonenewCountDownLatch(writers);try{// 1. 制造持续写入让内存处于变动状态for(intw0;wwriters;w){finalintidw;executor.submit(()-{try(Jedisjedispool.getResource()){for(inti0;iopsPerWriter;i){Stringkeyfork:test:id:i;jedis.set(key,payload-payload-payload-payload);}}finally{done.countDown();}});}// 2. 等写入进行到一半时触发 BGSAVEThread.sleep(500);longbeforeForkreadForkUsec(pool);try(Jedisjedispool.getResource()){System.out.println(BGSAVE 结果: jedis.bgsave());}longafterForkreadForkUsec(pool);System.out.println(本次 fork 耗时(us): afterFork);System.out.println(触发前 fork 耗时(us): beforeFork);done.await(60,TimeUnit.SECONDS);System.out.println(used_memory_human: readInfoField(pool,used_memory_human));System.out.println(rdb_last_cow_size: readInfoField(pool,rdb_last_cow_size));}finally{executor.shutdownNow();pool.close();}}privatestaticlongreadForkUsec(JedisPoolpool){try(Jedisjedispool.getResource()){Stringvaluejedis.info(stats);returnparseLong(value,latest_fork_usec);}}privatestaticStringreadInfoField(JedisPoolpool,Stringfield){try(Jedisjedispool.getResource()){// used_memory_human 在 memory 段rdb_last_cow_size 在 stats 段Stringalljedis.info();returnString.valueOf(parseRaw(all,field));}}privatestaticlongparseLong(Stringinfo,Stringfield){returnLong.parseLong(String.valueOf(parseRaw(info,field)));}privatestaticObjectparseRaw(Stringinfo,Stringfield){for(Stringline:info.split(\n)){if(line.startsWith(field:)){returnline.substring(field.length()1).trim();}}return-1L;}}怎么读结果latest_fork_usec是最近一次 fork 的微秒数通常几百到几千如果达到几万以上说明页表复制已经很重。rdb_last_cow_size是上一次 RDB 期间被 COW 复制的字节数它直接告诉你“快照期间主线程写入造成了多少额外内存复制”。写入压力越大这个值越接近内存的很大一部分。容易改错的地方很多同学直接把latest_fork_usec当成 RDB 总耗时这是误解。fork 只是“复制页表”真正写文件是子进程慢慢做的不阻塞主线程。真正需要警惕的是 fork 停顿叠加 COW 内存膨胀。3.4 RDB 的适用边界适合数据可以接受分钟级丢失、需要快速重启、需要定期异地备份。不适合要求几乎不丢数据的交易类场景纯 RDB 无法满足。注意save 关闭自动 RDB 后仍可手动BGSAVE或依赖主从复制触发。4. AOF录像机为什么会越录越厚4.1 三条刷盘策略的本质区别AOF 把每条写命令追加到文件。问题在于命令写进文件后是否立刻刷到磁盘由appendfsync决定。策略行为丢数据窗口对主线程延迟影响适用场景always每条命令都 fsync几乎不丢最大受磁盘 IOPS 限制强一致要求、低写入量everysec每秒 fsync 一次最多丢 1 秒较小但可能被后台 fsync 阻塞绝大多数生产场景no交给操作系统决定可能丢几十秒最小对丢失不敏感、追求吞吐这里最容易误解的是everysec的“每秒一次”。它并不是主线程主动每秒刷盘而是后台线程每秒执行 fsync。如果上一次 fsync 还没完成、磁盘又慢主线程在写 AOF 缓冲区时可能被拖延这就是很多“偶发毛刺”的来源。4.2 AOF 重写为什么要重写录像越录越长但很多命令是重复覆盖的。比如对同一个 key 先SET a 1再SET a 2再SET a 3最终只需要SET a 3。AOF 重写就是启动一个子进程把当前内存状态“翻译”成一批等效的最小命令集合替换掉旧文件。注意重写不是读取旧 AOF 再压缩而是直接遍历内存生成新 AOF。所以它同样依赖fork()同样有 COW 代价。重写期间主线程还在接收写命令这批新命令必须被记录否则会丢。Redis 的做法是主线程把重写期间的新命令同时写入一个 AOF 重写缓冲区子进程写完后主线程把这个缓冲区追加到新文件末尾再原子替换。AOF 重写时序简化 主线程: [接收写命令]--[写命令]--[写命令]--------[追加重写缓冲]--[替换文件] | | | v v v 旧 AOF: append... 重写缓冲: ---------------------------------- 子进程: | -- fork --[遍历内存生成新 AOF]--[完成]4.3 一个可复现的重写观测实验目标制造大量会被覆盖的写命令观察重写前后 AOF 文件大小变化。步骤在测试 Redis 上执行务必不要在生产执行# 1. 开启 AOF redis-cli CONFIG SET appendonly yes redis-cli CONFIG SET appendfsync everysec # 2. 写入 10 万个同 key 的覆盖写 for i in $(seq 1 100000); do redis-cli SET hot:key $i /dev/null; done # 3. 查看当前 AOF 大小 redis-cli INFO persistence | grep -E aof_current_size|aof_base_size # 4. 手动触发重写 redis-cli BGREWRITEAOF # 5. 等重写完成后再看 sleep 5 redis-cli INFO persistence | grep -E aof_current_size|aof_base_size预期结果重写前aof_current_size是几十 MB 级别重写后可能缩小到几百 KB因为 10 万次覆盖只保留最后一次。边界与常见错误重写完成后文件变小是正常现象如果重写后反而更大检查是否有大量未被覆盖的独立 key。BGREWRITEAOF在已有子进程重写时会返回错误或排队不要反复触发。重写期间如果磁盘写满可能导致重写失败甚至数据不一致务必监控磁盘水位。5. 混合持久化为什么要“照片加录像”纯 AOF 的问题是重启慢。假设 AOF 有 20GB重启时 Redis 要逐条重放这 20GB 命令可能几分钟才能对外服务。RDB 加载快但生成间隔内会丢数据。混合持久化就是折中重启时先加载 RDB 快照拿到基线再重放快照之后的 AOF 增量。开启方式# redis.conf appendonly yes aof-use-rdb-preamble yes混合持久化下AOF 文件的前半段是 RDB 格式的二进制快照后半段是文本命令。重写时子进程直接以 RDB 格式写基线主线程再把增量命令追加在后面。重启流程变成加载 aof 文件 | -- 检测到 RDB preamble -- 用 RDB 加载器快速还原基线 | -- 后续文本命令 -- 逐条重放 | v 恢复完成开始服务这样既保留了 AOF 秒级丢数据上限又避免了重放整份历史命令。代价是 AOF 文件不再能被普通文本工具直接阅读排障时要先解析 RDB 段。5.1 三种方案对比维度纯 RDB纯 AOF混合持久化数据丢失窗口分钟级秒级everysec秒级重启恢复速度快慢与文件大小线性相关快接近 RDB文件可读性差二进制好文本命令前段二进制后段文本fork 代价有有重写时有重写时磁盘占用小大需要重写压缩中等适用场景备份、容灾接受慢重启、要求低丢失生产默认推荐6. 恢复时延一次重启完整走一遍把重启过程按顺序走一遍理解时延花在哪里。Redis 进程启动 | -- 1. 读取配置决定加载哪个文件 | -- 2. 若 appendonly yes 且有 aof -- 加载 AOF | | | -- 有 RDB preamble ? 先加载 RDB 段 : 直接重放文本 | -- 重放剩余命令 | -- 3. 若 appendonly no 且有 dump.rdb -- 加载 RDB | -- 4. 构建内存数据结构、重建过期时间 | -- 5. 开始监听端口对外服务影响恢复时延的要素文件大小AOF 越大重放越久RDB 加载是顺序反序列化快很多。命令复杂度一条ZADD和一条SET的重放成本不同大 key 的加载更慢。key 数量重建哈希表和过期字典的耗时与 key 数量相关。磁盘 IO冷启动从机械盘读取比 SSD 慢一个数量级。6.1 用 Java 测量恢复时延的思路恢复时延不发生在客户端代码里但可以用客户端做端到端观测重启实例记录从进程启动到PING成功、且INFO persistence显示loading:0的时间。importredis.clients.jedis.Jedis;importredis.clients.jedis.JedisPool;publicclassRestartLatencyProbe{publicstaticvoidmain(String[]args)throwsException{JedisPoolpoolnewJedisPool(127.0.0.1,6379);longstartSystem.currentTimeMillis();booleanreadyfalse;while(System.currentTimeMillis()-start300_000){try(Jedisjedispool.getResource()){jedis.ping();StringloadingreadField(jedis.info(persistence),loading);if(0.equals(loading)){readytrue;break;}}catch(Exceptione){// 连接被拒绝说明还没起来继续等}Thread.sleep(500);}System.out.println(恢复就绪: ready耗时(ms): (System.currentTimeMillis()-start));pool.close();}privatestaticStringreadField(Stringinfo,Stringfield){for(Stringline:info.split(\n)){if(line.startsWith(field:)){returnline.substring(field.length()1).trim();}}returnunknown;}}说明loading:1表示正在加载持久化文件此时 Redis 可能已经接受 TCP 连接但拒绝大多数命令。判断“真正可服务”要同时看PING成功和loading:0。7. 设计取舍什么时候选哪种把上面的机制收束成一组条件化结论当你能接受分钟级数据丢失、且更关注重启速度和备份成本时选纯 RDB。当你需要秒级丢失上限、能接受较长重启时间、且会定期重写时选纯 AOF。当你既要秒级丢失上限、又要快速重启时选混合持久化这也是大多数生产集群的默认。当写入压力极大、fork 停顿已经影响 P99 时减少自动 RDB 频率改为低峰定时 BGSAVE 或只做从节点备份。一个常被忽略的点持久化策略应该由从节点承担。主节点负责写入和复制从节点上开启 RDB 或 AOF 用于备份和恢复可以避免 fork 直接冲击主节点延迟。8. 生产实践建议8.1 参数配置清单# 主节点优先低延迟 appendonly yes aof-use-rdb-preamble yes appendfsync everysec no-appendfsync-on-rewrite yes # 重写期间不 fsync降低阻塞但可能丢更多 save # 关闭自动 RDB避免与重写叠加 # 复制积压与重写触发 auto-aof-rewrite-percentage 100 # 文件比上次大一倍时重写 auto-aof-rewrite-min-size 64mb # 至少 64MB 才重写8.2 内存与 COW 的容量规划生成快照或重写期间预留至少 50% 空闲内存极端写入下 COW 会明显膨胀。使用INFO stats的rdb_last_cow_size、aof_last_cow_size建立长期监控。避免在业务高峰触发BGSAVE或BGREWRITEAOF。8.3 大 key 与持久化的关系一个大 key比如含百万元素的 Hash在 AOF 重写、RDB 生成、恢复加载时都会成为瓶颈。它可能在重放时一次性占用大量内存和 CPU。建议对大 key 做拆分或至少单独监控其加载耗时。9. 排障清单遇到持久化相关异常时按以下顺序检查现象优先检查常见原因周期性延迟毛刺latest_fork_usec、COW 大小自动 RDB 与高写入叠加重启特别慢AOF 文件大小、是否混合持久化未开启混合持久化磁盘持续增长aof_current_size、重写是否失败重写触发条件未满足重写反复失败磁盘水位、aof_last_bgrewrite_status磁盘写满或权限问题丢数据超过预期appendfsync、no-appendfsync-on-rewrite策略放宽导致窗口变大内存异常上涨COW 大小、是否在重写快照期间写入过猛10. 常见误区误区一fork 会复制整个内存。实际上只复制页表数据页靠 COW 按需复制。但写入激烈时复制的页可能接近全量。误区二AOF 重写是压缩旧文件。它是遍历内存重新生成不读旧文件。误区三混合持久化的 AOF 可以当纯文本看。前段是 RDB 二进制必须用工具解析。误区四关掉持久化就不影响延迟。主从全量同步同样会 fork 生成 RDB。误区五everysec绝不会丢超过 1 秒。磁盘卡顿或 fsync 堆积时丢失窗口可能更大。11. 面试/复盘问题RDB 的fork为什么是阻塞的阻塞时长和什么相关AOF 重写期间的新写命令如何保证不丢混合持久化如何兼顾恢复速度和丢失窗口为什么建议把持久化压力放到从节点COW 在什么写入模式下最危险如何量化no-appendfsync-on-rewrite的取舍是什么12. 总结回到开头的切换事故从节点在提升瞬间同时承受全量同步 RDB、AOF 刷盘和客户端写入延迟自然飙升。如果主节点关闭自动 RDB、从节点负责备份并开启混合持久化控制恢复时延这类毛刺会明显减少。一张决策框架收尾要低丢失开启 AOF用everysec。要快恢复开启aof-use-rdb-preamble走混合持久化。要控延迟主节点减少 fork把快照压力转移到从节点。要容量安全长期监控 COW 大小预留 50% 内存。要可排障把 AOF 大小、重写状态、fork 耗时纳入监控面板。持久化不是“开或关”的开关而是一组关于丢失窗口、恢复时间、运行延迟的权衡。理解 fork、重写和恢复加载这三条路径你就能在监控告警出现之前把问题挡住。13. 参考资料Redis 官方文档Persistencehttps://redis.io/docs/management/persistence/Redis 官方文档INFO commandhttps://redis.io/commands/info/Redis 源码src/rdb.c、src/aof.cLinux man-pagesfork(2)、copy-on-write 相关说明《Redis 设计与实现》黄健宏
返回列表