ly7689头像
关注
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

从这张图能看到三条独立的写路径:

  1. 主线程写内存并追加 AOF 缓冲区,这是同步路径,直接影响命令延迟。
  2. fork() 出子进程写 RDB,这是异步路径,但 fork 那一刻主线程要短暂停顿。
  3. AOF 缓冲区按策略刷盘,always 每条都 fsync,everysec 每秒一次,no 交给操作系统。

再看一次完整的“写入到落盘”时序,把两者叠起来:

时间轴 --->

主线程:  [写命令]--[写命令]--[写命令]--[写命令]------------
AOF缓冲:    \          \          \          \
             +----------+----------+----------+--> [fsync 每秒]
RDB子进程:              |
                        +-- fork --[遍历页表]--[写文件]--[退出]
                                    |
                                    +-- 期间主线程写入的页被复制(COW)

这里出现了后面要反复讲的两个关键词:fork 的写时复制(Copy-On-Write,COW) 和 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 示例用 Jedis(Redis 官方 Java 客户端)做一次可复现的观测实验。目标:在高写入压力下触发一次 BGSAVE,记录 latest_fork_usec 和内存变化。

前置环境:Redis 7.x 单实例,本地或测试环境;JDK 17;Maven 依赖 redis.clients:jedis:5.1.0。

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class RdbForkObservation {

    public static void main(String[] args) throws Exception {
        JedisPool pool = new JedisPool("127.0.0.1", 6379);
        int writers = 4;
        int opsPerWriter = 20000;
        ExecutorService executor = Executors.newFixedThreadPool(writers);
        CountDownLatch done = new CountDownLatch(writers);

        try {
            // 1. 制造持续写入,让内存处于变动状态
            for (int w = 0; w < writers; w++) {
                final int id = w;
                executor.submit(() -> {
                    try (Jedis jedis = pool.getResource()) {
                        for (int i = 0; i < opsPerWriter; i++) {
                            String key = "fork:test:" + id + ":" + i;
                            jedis.set(key, "payload-payload-payload-payload");
                        }
                    } finally {
                        done.countDown();
                    }
                });
            }

            // 2. 等写入进行到一半时触发 BGSAVE
            Thread.sleep(500);
            long beforeFork = readForkUsec(pool);
            try (Jedis jedis = pool.getResource()) {
                System.out.println("BGSAVE 结果: " + jedis.bgsave());
            }
            long afterFork = readForkUsec(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();
        }
    }

    private static long readForkUsec(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {
            String value = jedis.info("stats");
            return parseLong(value, "latest_fork_usec");
        }
    }

    private static String readInfoField(JedisPool pool, String field) {
        try (Jedis jedis = pool.getResource()) {
            // used_memory_human 在 memory 段,rdb_last_cow_size 在 stats 段
            String all = jedis.info();
            return String.valueOf(parseRaw(all, field));
        }
    }

    private static long parseLong(String info, String field) {
        return Long.parseLong(String.valueOf(parseRaw(info, field)));
    }

    private static Object parseRaw(String info, String field) {
        for (String line : info.split("\n")) {
            if (line.startsWith(field + ":")) {
                return line.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 的时间。

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

public class RestartLatencyProbe {

    public static void main(String[] args) throws Exception {
        JedisPool pool = new JedisPool("127.0.0.1", 6379);
        long start = System.currentTimeMillis();
        boolean ready = false;
        while (System.currentTimeMillis() - start < 300_000) {
            try (Jedis jedis = pool.getResource()) {
                jedis.ping();
                String loading = readField(jedis.info("persistence"), "loading");
                if ("0".equals(loading)) {
                    ready = true;
                    break;
                }
            } catch (Exception e) {
                // 连接被拒绝说明还没起来,继续等
            }
            Thread.sleep(500);
        }
        System.out.println("恢复就绪: " + ready + ",耗时(ms): " + (System.currentTimeMillis() - start));
        pool.close();
    }

    private static String readField(String info, String field) {
        for (String line : info.split("\n")) {
            if (line.startsWith(field + ":")) {
                return line.substring(field.length() + 1).trim();
            }
        }
        return "unknown";
    }
}

说明: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. 面试/复盘问题

  1. RDB 的 fork 为什么是阻塞的,阻塞时长和什么相关?
  2. AOF 重写期间的新写命令如何保证不丢?
  3. 混合持久化如何兼顾恢复速度和丢失窗口?
  4. 为什么建议把持久化压力放到从节点?
  5. COW 在什么写入模式下最危险,如何量化?
  6. no-appendfsync-on-rewrite 的取舍是什么?

12. 总结

回到开头的切换事故:从节点在提升瞬间同时承受全量同步 RDB、AOF 刷盘和客户端写入,延迟自然飙升。如果主节点关闭自动 RDB、从节点负责备份,并开启混合持久化控制恢复时延,这类毛刺会明显减少。

一张决策框架收尾:

  • 要低丢失:开启 AOF,用 everysec。
  • 要快恢复:开启 aof-use-rdb-preamble,走混合持久化。
  • 要控延迟:主节点减少 fork,把快照压力转移到从节点。
  • 要容量安全:长期监控 COW 大小,预留 50% 内存。
  • 要可排障:把 AOF 大小、重写状态、fork 耗时纳入监控面板。

持久化不是“开或关”的开关,而是一组关于丢失窗口、恢复时间、运行延迟的权衡。理解 fork、重写和恢复加载这三条路径,你就能在监控告警出现之前把问题挡住。

13. 参考资料

  • Redis 官方文档:Persistence(https://redis.io/docs/management/persistence/)
  • Redis 官方文档:INFO command(https://redis.io/commands/info/)
  • Redis 源码 src/rdb.c、src/aof.c
  • Linux man-pages:fork(2)、copy-on-write 相关说明
  • 《Redis 设计与实现》,黄健宏

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/qq_29029209/article/details/166795143

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--