第 13 章
RDB 快照:fork、COW 与文件格式
第13章 RDB 快照:fork、COW 与文件格式
13.1 RDB 的设计定位
RDB(Redis Database Backup)是 Redis 的二进制快照持久化机制。它将某一时刻的内存数据序列化为紧凑的二进制文件,具有以下特点:
- 文件体积小:采用专用二进制编码,整数用变长编码,字符串支持 LZF 压缩
- 加载速度快:直接反序列化到内存,无需重放命令日志
- 不阻塞主线程:通过
fork()+ COW(Copy-On-Write)在子进程中生成快照
代价是:快照之间如果 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() 系统调用:
- 父子进程共享同一份物理内存页
- 内核将所有页标记为只读
- 子进程开始遍历内存并写入 RDB 文件
- 主进程(父进程)继续处理客户端请求
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 子进程或主进程。