第 13 章

RDB 快照:fork、COW 与文件格式

第13章 RDB 快照:fork、COW 与文件格式

13.1 RDB 的设计定位

RDB(Redis Database Backup)是 Redis 的二进制快照持久化机制。它将某一时刻的内存数据序列化为紧凑的二进制文件,具有以下特点:

代价是:快照之间如果 Redis 崩溃,会丢失最后一次快照以来的所有写入。


13.2 触发方式

13.2.1 手动触发

# 前台生成(阻塞主线程,生产环境慎用)
SAVE

# 后台生成(推荐)
BGSAVE

# 查看最后一次 BGSAVE 结果
LASTSAVE     # 返回 Unix 时间戳
BGSAVE       # 已有子进程时返回 "Background saving already in progress"

13.2.2 自动触发

配置文件 redis.conf

# 格式:save <秒数> <写操作次数>
# 在 <秒数> 内累计 <写操作次数> 次写入,则触发 BGSAVE
save 3600 1      # 1小时内至少1次写入
save 300 100     # 5分钟内至少100次写入
save 60 10000    # 1分钟内至少10000次写入

# 关闭自动 RDB
save ""

# RDB 文件名和目录
dbfilename dump.rdb
dir /var/lib/redis

Redis 在事件循环的 serverCron() 中(每100ms执行一次)检查是否满足 save 条件:

// server.c: serverCron()
for (j = 0; j < server.saveparamslen; j++) {
    struct saveparam *sp = server.saveparams + j;
    if (server.dirty >= sp->changes &&
        server.unixtime - server.lastsave > sp->seconds &&
        (server.unixtime - server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY ||
         server.lastbgsave_status == C_OK)) {
        rdbSaveBackground(server.rdb_filename, NULL);
        break;
    }
}

13.2.3 其他触发场景

SHUTDOWN [NOSAVE|SAVE]   # 关机时默认执行 BGSAVE(如果开启了 RDB)
FLUSHALL                 # 清空数据库后触发 RDB(保存空文件)
主从同步                  # 从库请求全量同步时,主库触发 BGSAVE

13.3 fork() 与 Copy-On-Write 机制

13.3.1 fork() 基础

// rdb.c: rdbSaveBackground()
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
    pid_t childpid;

    if ((childpid = redisFork(CHILD_TYPE_RDB)) == 0) {
        // 子进程
        int retval = rdbSave(filename, rsi);
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        // 父进程(主线程)
        server.rdb_child_pid = childpid;
        updateDictResizePolicy(); // 禁止 dict rehash,避免 COW 页面爆炸
        return C_OK;
    }
}

fork() 系统调用:

13.3.2 Copy-On-Write 原理

fork() 之后的内存状态:

物理内存页:
  Page A [key1:val1] ←── 父进程虚拟地址 0x1000
                    ←── 子进程虚拟地址 0x1000
  (共享,只读)

当父进程执行 SET key2 new_val 时:
  1. CPU 检测到写操作,触发页错误(Page Fault)
  2. 内核复制 Page A → 新 Page A'
  3. 父进程虚拟地址 0x1000 → 新 Page A'(可写)
  4. 子进程虚拟地址 0x1000 → 原 Page A(不变,快照时刻的数据)

结果:子进程看到的始终是 fork() 时刻的数据

13.3.3 COW 内存开销计算

基线:Redis 使用 10GB 内存
BGSAVE 期间写入量:30%(即 3GB 数据被修改)

COW 复制的物理内存页大小:
  被修改的数据量 = 3GB
  每次修改可能触及整页(4KB),按字节均匀分布假设:
  COW 额外占用 ≈ 3GB(最坏情况:每个写操作在不同页)

实际峰值内存 = 10GB(原有)+ 3GB(COW副本)= 13GB

注意:Redis 修改内存时不是逐字节的,而是按数据结构操作,
      每次操作可能只触及少量页。实测通常比最坏情况好一些。

生产建议

# 监控 COW 内存使用(Redis 4.0+)
INFO persistence
# 关注:rdb_last_cow_size(上次 BGSAVE 的 COW 复制量,字节)
# 关注:aof_last_cow_size(上次 AOF rewrite 的 COW 复制量)

# 禁止子进程期间 rehash(Redis 自动处理)
# activerehashing yes/no 影响的是空闲时的 rehash,不影响 BGSAVE 期间

13.3.4 BGSAVE 对父进程性能的影响

测试:10GB Redis,BGSAVE 耗时约60秒
期间主进程写入 TPS 降低约 5-15%(COW 触发页错误开销)

hugepages(大页)问题:
  如果启用了透明大页(THP),每次 COW 复制的是 2MB 而非 4KB
  导致 BGSAVE 期间内存开销和延迟剧增

推荐:
  echo never > /sys/kernel/mm/transparent_hugepage/enabled

13.4 rdbSave() 流程详解

文件:rdb.c,函数:rdbSave()

int rdbSave(char *filename, rdbSaveInfo *rsi) {
    char tmpfile[256];
    FILE *fp = NULL;
    rio rdb;

    // 写入临时文件,完成后原子 rename
    snprintf(tmpfile, 256, "temp-%d.rdb", (int)getpid());
    fp = fopen(tmpfile, "w");

    rioInitWithFile(&rdb, fp);

    // 调用核心序列化函数
    if (rdbSaveRio(&rdb, &error, RDB_SAVE_NONE, rsi) == C_ERR) {
        fclose(fp);
        unlink(tmpfile);
        return C_ERR;
    }
    fflush(fp);
    fsync(fileno(fp)); // 确保数据落盘
    fclose(fp);
    rename(tmpfile, filename); // 原子替换
    return C_OK;
}

rdbSaveRio() 序列化流程:

int rdbSaveRio(rio *rdb, int *error, int rdbflags, rdbSaveInfo *rsi) {
    char magic[10];
    uint64_t cksum;
    int j;

    // 1. 写文件头:9字节魔数 "REDIS" + 4字节版本号
    snprintf(magic, sizeof(magic), "REDIS%04d", RDB_VERSION); // e.g. "REDIS0011"
    rdbWriteRaw(rdb, magic, 9);

    // 2. 写 AUX 字段(辅助信息)
    rdbSaveAuxField(rdb, "redis-ver",  REDIS_VERSION);    // "7.2.0"
    rdbSaveAuxField(rdb, "redis-bits", sizeof(long)*8);   // 64
    rdbSaveAuxField(rdb, "ctime",      time(NULL));        // 创建时间
    rdbSaveAuxField(rdb, "used-mem",   server.used_memory); // 内存使用量
    rdbSaveAuxField(rdb, "aof-base",   rsi ? rsi->aof_base_size : 0);

    // 3. 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) {
        redisDb *db = server.db + j;
        dict *d = db->dict;
        if (dictSize(d) == 0) continue; // 跳过空库

        // 3a. 写 SELECTDB opcode
        rdbSaveType(rdb, RDB_OPCODE_SELECTDB); // 0xFE
        rdbSaveLen(rdb, j);                    // db编号

        // 3b. 写 RESIZEDB opcode(帮助加载时预分配 dict)
        rdbSaveType(rdb, RDB_OPCODE_RESIZEDB); // 0xFB
        rdbSaveLen(rdb, dictSize(d));           // key-value 总数
        rdbSaveLen(rdb, dictSize(db->expires)); // 有过期时间的key数量

        // 3c. 遍历每个 key-value
        dictIterator *di = dictGetSafeIterator(d);
        dictEntry *de;
        while ((de = dictNext(di)) != NULL) {
            sds keystr = dictGetKey(de);
            robj *key = ...;
            robj *o = dictGetVal(de);
            long long expiretime = getExpire(db, key); // -1 表示无过期时间

            // 写过期时间(毫秒精度)
            if (expiretime != -1) {
                rdbSaveType(rdb, RDB_OPCODE_EXPIRETIME_MS); // 0xFC
                rdbSaveMillisecondTime(rdb, expiretime);
            }

            // 写 LRU/LFU 信息(如启用了 maxmemory-policy)
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
                rdbSaveType(rdb, RDB_OPCODE_IDLE);
                rdbSaveLen(rdb, estimateObjectIdleTime(o)/1000);
            }

            // 写 value 类型 opcode
            rdbSaveObjectType(rdb, o); // OBJ_STRING=0, OBJ_LIST=1, etc.

            // 写 key(字符串编码)
            rdbSaveStringObject(rdb, key);

            // 写 value(根据类型和编码序列化)
            rdbSaveObject(rdb, o, key);
        }
        dictReleaseIterator(di);
    }

    // 4. 写 EOF opcode
    rdbSaveType(rdb, RDB_OPCODE_EOF); // 0xFF

    // 5. 写 CRC64 校验和(8字节)
    cksum = rdb->cksum;
    memrev64ifbe(&cksum);
    rdbWriteRaw(rdb, &cksum, 8);
    return C_OK;
}

13.5 RDB 编码规则

13.5.1 整数编码(Length-prefixed)

RDB 使用一种特殊的长度编码来节省空间:

编码规则(首字节高2位决定类型):

00xxxxxx  → 6位整数(0-63),1字节
01xxxxxx xxxxxxxx → 14位整数(0-16383),2字节
10000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx → 32位整数,5字节
10000001 → 64位整数,9字节

特殊编码(11xxxxxx):
11000000 → 后跟1字节有符号整数(int8)
11000001 → 后跟2字节有符号整数(int16)
11000010 → 后跟4字节有符号整数(int32)
11000011 → 后跟LZF压缩字符串

示例:长度值 127

十六进制:7F
二进制:0111 1111
高2位 = 01 → 14位编码
实际值:0x7F = 127(只需第一字节,因为不超过63)
等等——127 > 63,所以用14位:
  01000000 01111111 = 0x40 0x7F

13.5.2 String 编码

int rdbSaveRawString(rio *rdb, unsigned char *s, size_t len) {
    // 1. 尝试整数编码(如果字符串内容是整数)
    if (len <= 11) {
        long long value;
        if (string2ll((char*)s, len, &value)) {
            return rdbSaveLongLongAsStringObject(rdb, value);
        }
    }

    // 2. 尝试 LZF 压缩(仅当 rdbcompression=yes 且 len > 20)
    if (server.rdb_compression && len > 20) {
        int retval = rdbSaveLzfStringObject(rdb, s, len);
        if (retval != 0) return retval; // 压缩成功
        // 压缩率不高时回退到原始存储
    }

    // 3. 原始存储:长度前缀 + 数据
    if (rdbSaveLen(rdb, len) == -1) return -1;
    return rdbWriteRaw(rdb, s, len);
}

13.5.3 各类型序列化策略

String(OBJ_ENCODING_INT):  直接存储整数
String(OBJ_ENCODING_EMBSTR/RAW):长度前缀 + 字节数据

List(OBJ_ENCODING_LISTPACK):  listpack 字节序列
List(OBJ_ENCODING_QUICKLIST):  节点数量 + 各节点 listpack + 压缩信息

Hash(OBJ_ENCODING_LISTPACK):  listpack 字节序列
Hash(OBJ_ENCODING_HT):  键值对数量 + 逐对写入

Set(OBJ_ENCODING_LISTPACK):  listpack 字节序列
Set(OBJ_ENCODING_INTSET):  intset 字节序列
Set(OBJ_ENCODING_HT):  元素数量 + 逐个写入

ZSet(OBJ_ENCODING_LISTPACK):  listpack 字节序列
ZSet(OBJ_ENCODING_SKIPLIST):  元素数量 + [score(double) + member] 对

13.6 RDB 文件结构(十六进制分析)

以下是一个最小化 RDB 文件(Redis 7.x,包含1个key)的十六进制转储:

偏移  十六进制                          说明
0000  52 45 44 49 53 30 30 31 31       "REDIS0011" 魔数+版本
0009  FA                               RDB_OPCODE_AUX (0xFA)
000A  09 72 65 64 69 73 2D 76 65 72   AUX key: "redis-ver" (长9)
0013  05 37 2E 32 2E 30               AUX val: "7.2.0" (长5)
0019  FA                               RDB_OPCODE_AUX
001A  0A 72 65 64 69 73 2D 62 69 74 73 AUX key: "redis-bits" (长10)
...
00XX  FA 05 63 74 69 6D 65 ...        AUX: ctime
...
00XX  FE 00                           SELECTDB 0 (0xFE=254, db=0)
00XX  FB 01 00                        RESIZEDB: dict_size=1, expires_size=0
00XX  FC 00 D0 A4 6E A2 01 00 00      RDB_OPCODE_EXPIRETIME_MS + 8字节毫秒时间戳
                                       (若无过期则无此行)
00XX  00                               类型: OBJ_STRING (0x00)
00XX  03 6B 65 79                     key: "key" (长度3 + "key")
00XX  05 76 61 6C 75 65               value: "value" (长度5 + "value")
00XX  FF                               RDB_OPCODE_EOF (0xFF)
00XX  XX XX XX XX XX XX XX XX         CRC64校验和(8字节,小端序)

RDB Opcode 表:

Opcode 十六进制 含义
RDB_OPCODE_EXPIRETIME 0xFD 过期时间(秒精度,旧版)
RDB_OPCODE_EXPIRETIME_MS 0xFC 过期时间(毫秒精度)
RDB_OPCODE_FREQ 0xF5 LFU 频率
RDB_OPCODE_IDLE 0xF4 LRU 空闲时间
RDB_OPCODE_EOF 0xFF 文件结束
RDB_OPCODE_SELECTDB 0xFE 选择数据库
RDB_OPCODE_RESIZEDB 0xFB 预分配大小
RDB_OPCODE_AUX 0xFA 辅助字段

13.7 加载流程(rdbLoad)

文件:rdb.c,函数:rdbLoad()

int rdbLoad(char *filename, rdbSaveInfo *rsi, int rdbflags) {
    // 1. 打开文件,读取并验证魔数 "REDIS"
    // 2. 检查 RDB 版本,判断兼容性
    // 3. 循环读取 opcode,按类型处理:

    while (1) {
        type = rdbLoadType(&rdb); // 读1字节 opcode

        if (type == RDB_OPCODE_EXPIRETIME_MS) {
            expiretime = rdbLoadMillisecondTime(&rdb, rdbver);
            type = rdbLoadType(&rdb); // 继续读实际类型
        }
        if (type == RDB_OPCODE_SELECTDB) {
            dbid = rdbLoadLen(&rdb, NULL);
            db = server.db + dbid;
            continue;
        }
        if (type == RDB_OPCODE_RESIZEDB) {
            db_size = rdbLoadLen(&rdb, NULL);
            expires_size = rdbLoadLen(&rdb, NULL);
            dictExpand(db->dict, db_size);     // 预分配,避免多次 rehash
            dictExpand(db->expires, expires_size);
            continue;
        }
        if (type == RDB_OPCODE_AUX) {
            auxkey = rdbLoadStringObject(&rdb);
            auxval = rdbLoadStringObject(&rdb);
            // 解析 redis-ver, ctime 等辅助字段
            continue;
        }
        if (type == RDB_OPCODE_EOF) break;

        // 普通 key-value
        key = rdbLoadStringObject(&rdb);
        val = rdbLoadObject(type, &rdb, key->ptr, db, &error);

        // 写入数据库
        dbAdd(db, key, val);
        if (expiretime != -1) {
            // 过期时间小于当前时间则跳过(已过期的key不加载)
            if (expiretime > now) setExpire(NULL, db, key, expiretime);
        }
        expiretime = -1;
    }

    // 4. 读取并验证 CRC64 校验和
    cksum = rdbLoadRaw(&rdb, 8);
    // 与计算出的校验和比较,不匹配则报错
}

加载性能(实测数据):

数据量 RDB 文件大小 加载时间 AOF 重放时间(对比)
100万 key(String) 80MB ~2秒 ~15秒
1000万 key(String) 800MB ~20秒 ~150秒
100万 key(Hash) 200MB ~8秒 ~40秒

13.8 RDB 文件检查与修复

# 检查 RDB 文件是否损坏
redis-check-rdb /var/lib/redis/dump.rdb

# 正常输出:
# [offset 0] Checking RDB file dump.rdb
# [offset 26] AUX FIELD redis-ver = '7.2.0'
# [offset 40] AUX FIELD redis-bits = '64'
# ...
# [offset 1024] Selecting DB ID 0
# [offset 2048] Checksum OK

# 损坏时的输出示例:
# [offset 2048] FATAL: RDB CRC error
# CRC error: 0x12345678 != 0xdeadbeef

常见损坏类型:

损坏类型 症状 处理方式
CRC 不匹配 文件末尾校验和错误 需判断数据本身是否完整,无法自动修复
文件截断 写入中途崩溃 redis-check-rdb 报告截断位置,数据部分丢失
魔数错误 非 RDB 文件 文件路径错误,无法修复
版本过新 RDB 版本高于当前 Redis 升级 Redis 版本

13.9 生产最佳实践

# 1. 配置多个 save 条件(层次化保护)
save 3600 1
save 300 100
save 60 10000

# 2. 启用 RDB 压缩
rdbcompression yes

# 3. 启用 CRC64 校验
rdbchecksum yes

# 4. 禁用透明大页(THP),减少 COW 开销
# 在 /etc/rc.local 或系统启动脚本中:
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 5. 监控 BGSAVE 状态
redis-cli INFO persistence | grep rdb
# rdb_bgsave_in_progress: 0/1
# rdb_last_save_time: 1704067200
# rdb_last_bgsave_status: ok
# rdb_last_bgsave_time_sec: 12
# rdb_current_bgsave_time_sec: -1
# rdb_last_cow_size: 3145728  ← COW 复制了3MB

# 6. BGSAVE 失败时 Redis 默认停止接受写命令
# 可以关闭此保护(不推荐):
stop-writes-on-bgsave-error yes

内存预留建议

生产环境的 Redis 服务器应预留 至少 50% 的 Redis 内存作为 COW 冗余。若 Redis 用了 10GB,服务器应有 15GB+ 可用内存。否则 BGSAVE 期间可能触发 Linux OOM Killer,杀死 Redis 子进程或主进程。

本章评分
4.7  / 5  (26 评分)

💬 留言讨论