redis-源码带读-04-持久化-RDB与AOF
Redis 源码带读 第4篇:持久化 - RDB与AOF
本篇目标
理解Redis的两种持久化机制:RDB快照和AOF追加日志,以及它们的优缺点。
前置知识
- 了解fork()系统调用
- 了解Copy-on-Write机制
- 阅读过第1-3篇
1. RDB快照(rdb.c)
1.1 触发方式
| 命令 | 说明 |
|---|---|
SAVE |
阻塞式保存,主进程停止响应 |
BGSAVE |
后台保存,fork子进程执行 |
| 自动保存 | 配置 save 3600 1 表示3600秒内至少1次修改则保存 |
1.2 BGSAVE流程
int rdbSaveBackground(int reqid, char *filename) {
// 1. 检查是否有子进程在执行
if (hasActiveChildProcess()) return C_ERR;
// 2. fork子进程
server.child_pid = fork();
if (server.child_pid == 0) {
// 子进程
closeListeningSockets(0);
serverSetProcTitle("redis-rdb-bgsave");
retval = rdbSave(filename); // 执行RDB保存
exitFromChild((retval == C_OK) ? 0 : 1);
} else {
// 父进程
server.rdb_child_time = time(NULL);
server.rdb_bgsave_child_pid = server.child_pid;
updateChildSaveRDB();
}
return C_OK;
}
1.3 rdbSave() 核心实现
int rdbSave(int reqid, char *filename) {
rioInitWithFile(&rdb, fp);
// 1. 写入Redis版本号
if (rdbSaveMagic(&rdb) == -1) goto werr;
// 2. 遍历所有数据库
for (j = 0; j < server.dbnum; j++) {
// 2.1 写入数据库选择命令
if (rdbSaveDBSelect(&rdb, j) == -1) goto werr;
// 2.2 遍历数据库中的所有key
di = dbGetSafeIterator(db);
while((de = dictNext(di)) != NULL) {
sds keystr = dictGetKey(de);
robj *key = createStringObject(keystr, sdslen(keystr));
robj *o = dbFindWithTTL(db, keystr, &expiretime);
// 2.3 写入单个键值对
if (rdbSaveKeyValuePair(&rdb, key, o, expiretime) == -1) goto werr;
}
releaseIterator(di);
}
// 3. 写入EOF标记和校验和
if (rdbSaveRio(&rdb) == -1) goto werr;
// 4. 同步到磁盘
fflush(fp);
fsync(fileno(fp));
return C_OK;
}
1.4 rdbSaveKeyValuePair() —— 保存单个键值对
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val, long long expiretime) {
// 1. 写入过期时间(如果有)
if (expiretime != -1) {
if (rdbSaveType(rdb, RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveTime(rdb, expiretime) == -1) return -1;
}
// 2. 写入对象类型(string/list/hash/zset/set/stream)
if (rdbSaveObjectType(rdb, val) == -1) return -1;
// 3. 写入key
if (rdbSaveStringObject(rdb, key) == -1) return -1;
// 4. 写入value
if (rdbSaveObject(rdb, val) == -1) return -1;
return 1;
}
1.5 Copy-on-Write 详解
fork()后子进程与父进程共享内存页。只有在父进程写入时,才会复制被修改的页。这使得fork的代价极低(只需复制页表)。
但如果在BGSAVE期间有大量写操作,COW会导致内存使用翻倍。
COW机制:
- 页表复制:fork()时只复制页表(page table),不复制实际数据
- 写时复制:当父进程或子进程修改内存页时,内核会拦截这次写操作
- 页复制:内核为被修改的页创建一个副本,更新页表指向新页
- 脏页标记:修改过的页被标记为"脏页"(dirty)
性能影响:
- fork()开销:与内存大小成正比(页表大小)
- COW开销:与写操作量成正比(修改的页数)
- 内存占用:最坏情况下翻倍
优化建议:
- 监控
used_memory_rss和used_memory的差值 - 如果差值接近
used_memory,说明COW开销很大 - 考虑减少写操作或使用更频繁的RDB间隔
1.6 RDB文件格式详解
+--------+--------+--------+--------+--------+
| REDIS | 版本号(5字节) | | |
+--------+--------+--------+--------+--------+
| 数据库选择(OPCODE_SELECTDB + db编号) |
+--------------------------------------------+
| 键值对列表: |
| [过期时间(可选)] [类型] [键] [值] |
| ... |
+--------------------------------------------+
| EOF标记(0xFF) |
+--------------------------------------------+
| 校验和(8字节,CRC64) |
+--------------------------------------------+
编码类型:
| 类型 | 编码 | 说明 |
|---|---|---|
| RDB_TYPE_STRING | 0 | 简单字符串 |
| RDB_TYPE_LIST | 1 | 列表(旧版ziplist) |
| RDB_TYPE_SET | 2 | 集合 |
| RDB_TYPE_ZSET | 3 | 有序集合 |
| RDB_TYPE_HASH | 4 | 哈希表 |
| RDB_TYPE_ZSET_2 | 5 | 有序集合(新版double存储) |
| RDB_TYPE_HASH_METADATA | 12 | 哈希表(元数据) |
| RDB_TYPE_STREAM_LISTPACKS | 15 | Stream(listpack存储) |
2. AOF追加日志(aof.c)
2.1 写入流程
void feedAppendOnlyFile(robj *cmdstring, char *key, int argc, robj **argv) {
// 1. 构建RESP格式的命令
sds buf = sdscatfmt(sdsempty(), "*%d\r\n$", argc);
for (j = 0; j < argc; j++) {
buf = sdscatlen(buf, argv[j]->ptr, sdslen(argv[j]->ptr));
buf = sdscatlen(buf, "\r\n", 2);
}
// 2. 追加到AOF缓冲区
if (server.aof_buf) {
server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));
}
// 3. 根据配置刷盘
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
fsync(server.aof_fd); // 每次写都fsync
} else if (server.aof_fsync == AOF_FSYNC_EVERYSEC) {
// 每秒fsync(默认)
server.aof_fsync_always_cnt++;
}
// AOF_FSYNC_NO: 由OS决定何时刷盘
}
2.2 AOF文件格式
AOF文件就是Redis命令的文本记录:
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nhello\r\n
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$6\r\nworld\r\n
命令类型:
- SELECT:数据库选择
- SET/GET/DEL:基本操作
- EXPIRE/PEXPIRE:设置过期时间
- LPUSH/RPUSH:列表操作
- SADD:集合操作
- ZADD:有序集合操作
2.3 AOF重写
随着运行时间增长,AOF文件会变得很大(同一个key被修改多次)。AOF重写会生成一个只包含当前数据库状态的最小命令集。
int rewriteAppendOnlyFileBackground(void) {
// 1. fork子进程
server.child_pid = fork();
if (server.child_pid == 0) {
// 子进程:执行AOF重写
if (rewriteAppendOnlyFile(server.aof_filename) == C_OK) {
// 2. 完成后通知父进程
sendChildCowInfo(CHILD_INFO_TYPE_AOF_COW, "AOF rewrite");
exitFromChild(0);
} else {
exitFromChild(1);
}
} else {
// 父进程:记录子进程信息
server.aof_rewrite_child_pid = server.child_pid;
server.aof_rewrite_time_start = time(NULL);
}
return C_OK;
}
2.4 rewriteAppendOnlyFile() 核心实现
int rewriteAppendOnlyFile(char *filename) {
rio aof;
FILE *fp;
// 1. 创建临时AOF文件
fp = fopen(tmpfilename, "w");
rioInitWithFile(&aof, fp);
// 2. 遍历所有数据库
for (j = 0; j < server.dbnum; j++) {
// 2.1 写入SELECT命令
char selectcmd[] = "*2\r\n$6\r\nSELECT\r\n";
rioWrite(&aof, selectcmd, sizeof(selectcmd)-1);
// 2.2 遍历数据库中的所有key
di = dbGetSafeIterator(db);
while((de = dictNext(di)) != NULL) {
sds keystr = dictGetKey(de);
robj *key = createStringObject(keystr, sdslen(keystr));
robj *o = dbFind(db, keystr);
// 2.3 为每个key生成一条完整命令
rewriteObj(&aof, key, o);
}
releaseIterator(di);
}
// 3. 同步到磁盘
fflush(fp);
fsync(fileno(fp));
fclose(fp);
return C_OK;
}
2.5 AOF重写期间的增量数据
重写期间的新命令会写入AOF缓冲区和重写缓冲区(aof_rewrite_buf),重写完成后合并到新AOF文件。
void backgroundRewriteCommand(client *c) {
// 1. 检查是否有子进程在执行
if (hasActiveChildProcess()) {
addReplyError(c, "Background rewrite already in progress");
return;
}
// 2. 开始AOF重写
if (rewriteAppendOnlyFileBackground() == C_OK) {
addReply(c, shared.ok);
} else {
addReplyError(c, "Can't rewrite AOF");
}
}
增量数据处理:
- 重写期间,父进程继续处理写请求
- 新的写命令同时写入:
- AOF缓冲区(用于旧AOF文件)
- 重写缓冲区(用于新AOF文件)
- 重写完成后,父进程将重写缓冲区的数据追加到新AOF文件
- 原子替换旧AOF文件
3. 混合持久化(Redis 4.0+)
开启混合持久化(aof-use-rdb-preamble yes)后,AOF文件结构变为:
RDB文件头(包含全量数据)
+ 增量AOF命令(重写期间的新命令)
这样恢复时先加载RDB部分(快速),再重放AOF部分(增量)。
混合持久化的优势:
- 恢复速度快:RDB部分是二进制格式,加载比AOF快很多
- 数据完整性:增量AOF部分保留了重写期间的新数据
- 向后兼容:旧版本Redis可以只读取RDB部分
代码实现:
int rewriteAppendOnlyFile(char *filename) {
// 1. 如果启用混合持久化,先写RDB头
if (server.aof_use_rdb_preamble) {
// 写入RDB文件头
rioWrite(&aof, "REDIS", 5);
// ... RDB数据
}
// 2. 写入增量AOF命令
// ... SELECT, SET等命令
return C_OK;
}
4. bio.c 后台I/O线程
4.1 三种后台任务
| 任务 | 说明 |
|---|---|
BIO_CLOSE_FILE |
关闭文件描述符 |
BIO_AOF_FSYNC |
AOF文件刷盘 |
BIO_LAZY_FREE |
异步释放大对象 |
4.2 线程池实现
// bio.c
static pthread_t bio_threads[BIO_NUM_OPS]; // 3个线程
static bio_job *bio_jobs[BIO_NUM_OPS]; // 每种任务一个队列
static pthread_mutex_t bio_mutex[BIO_NUM_OPS]; // 互斥锁
static pthread_cond_t bio_condvar[BIO_NUM_OPS]; // 条件变量
void bioCreateBackgroundJob(int type, ...) {
bio_job *job = zmalloc(sizeof(bio_job));
job->type = type;
// 1. 分配job,放入队列
pthread_mutex_lock(&bio_mutex[type]);
listAddNodeTail(bio_jobs[type], job);
pthread_mutex_unlock(&bio_mutex[type]);
// 2. 唤醒对应线程
pthread_cond_signal(&bio_condvar[type]);
}
static void *bioProcessBackgroundJobs(void *arg) {
int type = (int)arg;
while (1) {
// 1. 等待任务
pthread_mutex_lock(&bio_mutex[type]);
while (listLength(bio_jobs[type]) == 0) {
pthread_cond_wait(&bio_condvar[type], &bio_mutex[type]);
}
// 2. 取出任务
bio_job *job = listFirst(bio_jobs[type])->value;
listDelNode(bio_jobs[type], listFirst(bio_jobs[type]));
pthread_mutex_unlock(&bio_mutex[type]);
// 3. 执行任务
switch (job->type) {
case BIO_CLOSE_FILE:
close(job->fd);
break;
case BIO_AOF_FSYNC:
fsync(job->fd);
break;
case BIO_LAZY_FREE:
lazyfreeObject(job->obj);
break;
}
zfree(job);
}
return NULL;
}
4.3 bio_init() —— 初始化
void bioInit(void) {
for (int j = 0; j < BIO_NUM_OPS; j++) {
pthread_mutex_init(&bio_mutex[j], NULL);
pthread_cond_init(&bio_condvar[j], NULL);
bio_jobs[j] = listCreate();
// 创建线程
pthread_create(&bio_threads[j], NULL,
bioProcessBackgroundJobs, (void*)j);
}
}
5. RDB vs AOF对比
| 特性 | RDB | AOF |
|---|---|---|
| 持久化方式 | 定期快照 | 每次写命令追加 |
| 文件大小 | 小(二进制压缩) | 大(文本命令) |
| 恢复速度 | 快 | 慢(需重放命令) |
| 数据安全性 | 可能丢失两次快照间的数据 | 最多丢失1秒数据(everysec) |
| fork开销 | 每次BGSAVE都需要 | 重写时需要 |
混合持久化:
- 结合了RDB和AOF的优点
- 恢复速度快(RDB部分)
- 数据完整(增量AOF部分)
6. 本篇小结
| 概念 | 要点 |
|---|---|
| RDB | fork+COW二进制快照,恢复快 |
| AOF | 文本命令追加,数据安全 |
| 混合 | RDB头+增量AOF,兼顾两者 |
| bio | 简单线程池处理后台I/O |
思考题
- 如果Redis内存占用10GB,fork()时COW最多额外占用多少内存?
- 为什么AOF重写选择fork子进程而不是在主进程中执行?
- always策略和everysec策略在什么场景下各适用?
- 如何在不丢失数据的情况下将RDB切换为AOF?
思考题解答
1. 如果Redis内存占用10GB,fork()时COW机制会额外占用多少内存?
COW机制分析:
-
fork()本身:fork()创建子进程时,子进程与父进程共享所有内存页。fork()本身几乎不占用额外内存(只复制页表)。
-
COW开销:当父进程或子进程修改内存页时,会被OS拦截并复制该页(4KB)。
-
RDB fork的情况:
- fork()后,子进程负责生成RDB文件
- 父进程继续处理写请求,可能修改内存
- 最坏情况:父进程修改了所有页面,需要额外10GB内存
- 实际情况:通常只修改部分页面,额外内存远小于10GB
-
AOF rewrite的情况:
- fork()后,子进程重写AOF文件
- 父进程继续处理写请求
- 与RDB类似,额外内存取决于写操作量
建议:
- 监控
INFO memory中的used_memory_rss和used_memory的差值 - 如果差值接近
used_memory,说明COW开销很大 - 考虑减少写操作或使用更频繁的RDB间隔
2. 为什么AOF重写选择fork子进程而不是线程?
fork的优势:
- 内存隔离:子进程有独立的地址空间,不会干扰父进程的正常运行
- 无锁需求:子进程只需要读取父进程的内存快照,不需要加锁
- 简化实现:fork后子进程可以独立工作,不需要复杂的同步机制
线程的问题:
- 锁竞争:线程共享地址空间,需要加锁保护共享数据
- 内存屏障:需要使用内存屏障保证线程间数据一致性
- 死锁风险:多线程环境下死锁难以调试和恢复
COW的优势:
- fork()后,子进程与父进程共享内存页
- 只有修改的页面才会被复制,节省内存
- 子进程只需要读取内存快照,不会触发COW
3. always策略和everysec策略有什么区别,什么时候用?
always策略:
- 每次写操作都同步到AOF文件
- 数据安全性最高:最多丢失1次写操作
- 性能最差:每次写操作都需要fsync()
everysec策略(默认):
- 每秒同步一次到AOF文件
- 数据安全性:最多丢失1秒的数据
- 性能较好:fsync()在后台线程执行,不阻塞主线程
no策略:
- 由OS决定何时同步
- 数据安全性最差:可能丢失大量数据
- 性能最好
使用场景:
- always:金融、支付等对数据安全性要求极高的场景
- everysec:大多数场景的默认选择
- no:对数据安全性要求不高的场景(如缓存)
4. 如果不慎丢失数据,如何从RDB恢复?
恢复步骤:
- 停止Redis:
redis-cli SHUTDOWN NOSAVE - 备份当前数据目录:
cp -r /var/lib/redis /var/lib/redis.backup - 替换RDB文件:将备份的RDB文件复制到数据目录
- 启动Redis:
redis-server /etc/redis.conf - 验证数据:检查数据是否恢复
注意事项:
- RDB文件可能不是最新的,会丢失最后一次快照后的数据
- 如果启用了AOF,优先使用AOF恢复(数据更完整)
- 恢复前务必备份当前数据目录
预防措施:
- 定期备份RDB文件
- 启用AOF(everysec策略)
- 使用Redis Sentinel或Cluster实现高可用

浙公网安备 33010602011771号