AIGC标识 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机制

  1. 页表复制:fork()时只复制页表(page table),不复制实际数据
  2. 写时复制:当父进程或子进程修改内存页时,内核会拦截这次写操作
  3. 页复制:内核为被修改的页创建一个副本,更新页表指向新页
  4. 脏页标记:修改过的页被标记为"脏页"(dirty)

性能影响

  • fork()开销:与内存大小成正比(页表大小)
  • COW开销:与写操作量成正比(修改的页数)
  • 内存占用:最坏情况下翻倍

优化建议

  • 监控 used_memory_rssused_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");
    }
}

增量数据处理

  1. 重写期间,父进程继续处理写请求
  2. 新的写命令同时写入:
    • AOF缓冲区(用于旧AOF文件)
    • 重写缓冲区(用于新AOF文件)
  3. 重写完成后,父进程将重写缓冲区的数据追加到新AOF文件
  4. 原子替换旧AOF文件

3. 混合持久化(Redis 4.0+)

开启混合持久化(aof-use-rdb-preamble yes)后,AOF文件结构变为:

RDB文件头(包含全量数据)
+ 增量AOF命令(重写期间的新命令)

这样恢复时先加载RDB部分(快速),再重放AOF部分(增量)。

混合持久化的优势

  1. 恢复速度快:RDB部分是二进制格式,加载比AOF快很多
  2. 数据完整性:增量AOF部分保留了重写期间的新数据
  3. 向后兼容:旧版本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

思考题

  1. 如果Redis内存占用10GB,fork()时COW最多额外占用多少内存?
  2. 为什么AOF重写选择fork子进程而不是在主进程中执行?
  3. always策略和everysec策略在什么场景下各适用?
  4. 如何在不丢失数据的情况下将RDB切换为AOF?

思考题解答

1. 如果Redis内存占用10GB,fork()时COW机制会额外占用多少内存?

COW机制分析

  1. fork()本身:fork()创建子进程时,子进程与父进程共享所有内存页。fork()本身几乎不占用额外内存(只复制页表)。

  2. COW开销:当父进程或子进程修改内存页时,会被OS拦截并复制该页(4KB)。

  3. RDB fork的情况

    • fork()后,子进程负责生成RDB文件
    • 父进程继续处理写请求,可能修改内存
    • 最坏情况:父进程修改了所有页面,需要额外10GB内存
    • 实际情况:通常只修改部分页面,额外内存远小于10GB
  4. AOF rewrite的情况

    • fork()后,子进程重写AOF文件
    • 父进程继续处理写请求
    • 与RDB类似,额外内存取决于写操作量

建议

  • 监控 INFO memory 中的 used_memory_rssused_memory 的差值
  • 如果差值接近 used_memory,说明COW开销很大
  • 考虑减少写操作或使用更频繁的RDB间隔

2. 为什么AOF重写选择fork子进程而不是线程?

fork的优势

  1. 内存隔离:子进程有独立的地址空间,不会干扰父进程的正常运行
  2. 无锁需求:子进程只需要读取父进程的内存快照,不需要加锁
  3. 简化实现:fork后子进程可以独立工作,不需要复杂的同步机制

线程的问题

  1. 锁竞争:线程共享地址空间,需要加锁保护共享数据
  2. 内存屏障:需要使用内存屏障保证线程间数据一致性
  3. 死锁风险:多线程环境下死锁难以调试和恢复

COW的优势

  • fork()后,子进程与父进程共享内存页
  • 只有修改的页面才会被复制,节省内存
  • 子进程只需要读取内存快照,不会触发COW

3. always策略和everysec策略有什么区别,什么时候用?

always策略

  • 每次写操作都同步到AOF文件
  • 数据安全性最高:最多丢失1次写操作
  • 性能最差:每次写操作都需要fsync()

everysec策略(默认):

  • 每秒同步一次到AOF文件
  • 数据安全性:最多丢失1秒的数据
  • 性能较好:fsync()在后台线程执行,不阻塞主线程

no策略

  • 由OS决定何时同步
  • 数据安全性最差:可能丢失大量数据
  • 性能最好

使用场景

  • always:金融、支付等对数据安全性要求极高的场景
  • everysec:大多数场景的默认选择
  • no:对数据安全性要求不高的场景(如缓存)

4. 如果不慎丢失数据,如何从RDB恢复?

恢复步骤

  1. 停止Redisredis-cli SHUTDOWN NOSAVE
  2. 备份当前数据目录cp -r /var/lib/redis /var/lib/redis.backup
  3. 替换RDB文件:将备份的RDB文件复制到数据目录
  4. 启动Redisredis-server /etc/redis.conf
  5. 验证数据:检查数据是否恢复

注意事项

  • RDB文件可能不是最新的,会丢失最后一次快照后的数据
  • 如果启用了AOF,优先使用AOF恢复(数据更完整)
  • 恢复前务必备份当前数据目录

预防措施

  • 定期备份RDB文件
  • 启用AOF(everysec策略)
  • 使用Redis Sentinel或Cluster实现高可用
posted @ 2026-09-04 17:26  IcarusLee  阅读(2)  评论(0)    收藏  举报