多线程redis项目之rdb

目录

疑惑

为什么rdb采用压缩,而不是直接存储010101到磁盘当中?

rdb是采用什么序列化方案?

为什么叫二进制文件?

对于redis,形成序列化之后可能还会采用压缩算法

什么叫数据一致性?

redis为何采用fork子进程?

redis是如何做到rdb的(源码讲解)

我们的做法

代码中用到的函数讲解

测试结果

总结

[1. 空间效率](#1. 空间效率)

[2. 解析速度](#2. 解析速度)

[3. 数据类型表达](#3. 数据类型表达)

[4. 无需转义](#4. 无需转义)

[5. 压缩友好](#5. 压缩友好)


这篇博客是我写的持久化策略,里面包含了rdb的基础讲解,可以看一下,接下来我将细谈,然后实现

疑惑

为什么rdb采用压缩,而不是直接存储010101到磁盘当中?

1:首先平时往磁盘当中写文件,本身就是写010101的数据,为什么能看到的是中文或者英文,本质是因为你的编辑器采用的编码解析,如果是utf-8去解析出来就是中文,但是如果是乱码,可能是你本身采用的解码跟一开始存储的不一样

2:这有点类似网络当中的知识点,我们必须要把结构化的数据序列化,然后接收端接收后反序列化才能拿到正确的消息,如果我们直接把结构化的数据010101写入网络当中,那接收端怎么知道哪里的01对应什么?并且两边的机器都不一样,位数等等都不一样(类似int大小都不一样),同样的二进制数据可能被错误解析

3:所以rdb必须像网络一样采取序列化方案,否则你下次如果换别的机器,就算是相同的机器,你都不知道原始的结构化数据长啥样

简单来说就是要标明数据是啥类型,是string呢还是hash呢,边界在哪呢?

rdb是采用什么序列化方案?

Redis RDB 格式是一个紧凑的、自描述的二进制流,主要由以下部分组成:

部分 说明
魔数 "REDIS" + 4 字节版本号(如 0009
数据区 多个数据库的键值对,每个数据库以 SELECTDB 标记开头,后跟数据库编号,然后是键值对列表
结束标记 EOF 特殊字节
校验和 8 字节 CRC64 校验和(可选)

每个键值对按照"类型 + 键 + 值"的顺序编码:

  • 类型 :用一个字节表示值的类型(如 0x00 表示字符串,0x01 表示列表,0x02 表示集合......)

  • :总是字符串,编码为长度 + 内容

  • :根据类型不同,有不同的编码方式

为什么叫二进制文件?

无论如何所有的文件里面存储的都是二进制,

文本文件按照某种编码,采用记事本等编辑器打开的时候,可以翻译成人类懂的中文或者英文或者其他

但是二进制文件没有约定格式,它的每个字节长度不一定都是ASCII里面的,所以有些翻译不了,你强行用记事本等编辑器打开时有些能看得懂,有些看不懂,因为ASCII里面没有对应的

  • 所有文件本质上都是二进制

  • 文本文件是二进制文件的一种子集,其内容被约定为可打印字符,可以用文本编辑器直接阅读。

  • 二进制文件泛指那些不遵循"所有字节都是可打印字符"约定的文件,它们通常需要专门的程序才能解析,RDB 就是其中之一。

对于redis,形成序列化之后可能还会采用压缩算法

序列化得到的二进制数据会被 LZF(Lempel-Ziv-Fast)算法压缩(默认开启),进一步减小文件体积。压缩后的数据再写入磁盘。

如果用户通过配置 rdbcompression no 关闭了压缩,则序列化后的二进制数据会直接写入文件。

  • 节省磁盘空间:压缩后 RDB 文件通常比未压缩小 30%~50%。

  • 减少网络传输:主从复制时,Master 发送 RDB 给 Slave,压缩能大幅降低带宽占用。

  • 代价:消耗少量 CPU(由子进程承担,不阻塞主线程)。

简化版压缩过程

Redis 在生成 RDB 时(子进程)会创建一个 rio 流(抽象 I/O 层) 。如果启用了压缩,这个流会绑定一个压缩过滤器(LZF 算法)。当序列化器向流中写入数据时:

  1. 数据先被序列化成 RDB 格式的二进制片段(比如一个键值对)。

  2. 流将数据送入压缩器的输入缓冲区。

  3. 当压缩缓冲区达到一定阈值(或手动刷新),压缩器输出压缩后的数据块。

  4. 压缩后的数据块通过系统调用(write)写入磁盘文件。

整个过程是在线的:不需要在内存中缓存整个 RDB 文件,也不会等序列化全部完成再压缩。

什么叫数据一致性?

说的是生成快照的那一时刻,所有的数据应当属于同一时刻,如果采用多线程版本,主改从写,那从写的那一时刻,所有的数据都不是属于同一时刻了

所以什么叫快照,快照是时间抽上的一点,是拍照时定下的某一时刻,那这一个时刻的数据应该是一致的,不会出现新旧数据

redis为何采用fork子进程?

特性 进程 线程(同一进程内)
资源 独立的内存空间、文件描述符等 共享进程的内存空间和资源
创建开销 较大(需要复制页表、分配新内存等) 较小(只需创建线程栈、寄存器上下文)
通信 需要 IPC(管道、共享内存、消息队列等) 直接通过共享内存,但需要同步机制(锁)
隔离性 强,一个进程崩溃不影响其他进程 弱,一个线程崩溃可能导致整个进程退出
上下文切换 较重(切换地址空间) 较轻(共享地址空间)
并发编程难度 相对简单(独立资源,需考虑 IPC 同步) 复杂(共享数据需要精细的锁、原子操作)

核心是为了保证快照,为了保证数据一致性

Redis 的持久化(RDB 快照、AOF 重写)要求生成的数据文件必须反映某个时刻的数据库状态,不能混入不同时刻的修改。

  • 当 Redis 执行 fork 时,操作系统会复制父进程的页表 ,并为子进程创建一份新的内存映射。此时父子进程的虚拟地址空间指向同一块物理内存 ,并且这些物理页面被标记为只读

  • 当父进程(主线程)收到新的写请求,需要修改某个内存页时,操作系统会触发写时复制:将原始页面复制一份给父进程,让父进程修改副本,而子进程继续使用原页面。

  • 这样,子进程从 fork 的那一刻起,看到的永远是那一瞬间的内存状态,无论父进程之后如何修改,子进程的视图都不会改变。子进程将这一瞬间的数据写入 RDB 文件,就能得到一致性快照。

多线程方案要实现同样的效果,要么在持久化期间禁止所有写入(长时间阻塞),要么使用复杂的版本控制或快照隔离技术,这些都会显著增加代码复杂度和性能开销。

假设我们想用多个线程并行写 RDB 来提高速度,同时主线程继续处理请求。会遇到以下问题:

  • 一致性难以保证:持久化线程读取某个键时,主线程可能正在修改它,要么读到旧值要么新值,但不同键的读取时间点不同,最终快照是多个时间点的混合,不可用。

  • 需要复杂的同步:要么在持久化期间暂停写入(阻塞主线程),要么实现类似"快照隔离"的机制(如版本号、复制键值),这都会增加复杂度和内存开销。

  • 锁竞争:遍历数据库时需要加锁防止主线程修改正在遍历的数据结构(如哈希表 rehash),这会严重影响主线程性能。

注意只有bgsave命令会fork子进程,save不会

redis是如何做到rdb的(源码讲解)

简单来说:fork子进程,子进程把内存当中的数据序列化,序列化后压缩写入磁盘

cpp 复制代码
struct rio {
    // 函数指针,实现不同后端的读、写、定位、刷新、校验和更新
    size_t (*read)(struct rio *rio, void *buf, size_t len);
    size_t (*write)(struct riio *rio, const void *buf, size_t len);
    off_t (*tell)(struct rio *rio);
    int (*flush)(struct rio *rio);
    void (*update_cksum)(struct rio *rio, const void *buf, size_t len);

    // 校验和状态(用于计算 CRC64)
    uint64_t cksum;

    // 后端特定数据,用 union 实现多态
    union {
        struct {
            int fd;          // 文件描述符
            off_t offset;    // 当前偏移量(用于 tell)
        } file;
        struct {
            char *ptr;       // 内存缓冲区指针
            size_t size;     // 缓冲区大小
            size_t pos;      // 当前写入位置
        } buffer;
        struct {
            struct rio *rio; // 下层 rio(用于链式包装)
            // 压缩相关字段
            unsigned char *pending;    // 待压缩的缓冲区
            size_t pending_size;       // 缓冲区大小
            size_t pending_len;        // 当前已积累的数据长度
            int compression_level;     // 压缩级别
            size_t threshold;          // 压缩触发阈值
            // 统计信息等
        } compress;
        // 还可以扩展其他后端,如网络等
    } io;
};
cpp 复制代码
// 初始化文件后端 rio
void rioInitWithFile(rio *r, int fd) {
    r->read = rioFileRead;     // 读函数(RDB 加载时使用)
    r->write = rioFileWrite;   // 写函数
    r->tell = rioFileTell;     // 获取当前偏移
    r->flush = rioFileFlush;   // 刷新(对文件后端通常 fsync 或空操作)
    r->update_cksum = NULL;    // 文件后端不单独更新校验和,由上层处理
    r->cksum = 0;
    r->io.file.fd = fd;
    r->io.file.offset = 0;
}

// 文件后端的写实现(简化)
static size_t rioFileWrite(rio *r, const void *buf, size_t len) {
    size_t nwritten = write(r->io.file.fd, buf, len);
    if (nwritten == len) {
        r->io.file.offset += nwritten;
        return nwritten;
    }
    return 0; // 错误
}
cpp 复制代码
// 初始化压缩后端,包装下层 rio
int rioInitWithCompress(rio *r, rio *underlying, int compression_level) {
    r->read = NULL;              // 压缩后端通常只用于写(RDB 保存时),读不实现
    r->write = rioCompressWrite;
    r->tell = rioCompressTell;   // 需要转发给下层 rio 的 tell
    r->flush = rioCompressFlush; // 强制刷新缓冲区
    r->update_cksum = NULL;
    r->cksum = 0;

    // 分配压缩缓冲区(例如 4KB)
    size_t bufsize = 4096;
    unsigned char *buf = zmalloc(bufsize);
    if (!buf) return C_ERR;

    r->io.compress.rio = underlying;
    r->io.compress.pending = buf;
    r->io.compress.pending_size = bufsize;
    r->io.compress.pending_len = 0;
    r->io.compress.compression_level = compression_level;
    r->io.compress.threshold = 2048;  // 超过此大小触发压缩
    return C_OK;
}

这里不同的后端就采用不同的初始化方式,初始化函数

rdbSave入口:这个是总入口,子进程fork之后执行的

cpp 复制代码
int rdbSave(char *filename, ...) {
    FILE *fp = fopen(filename, "w");
    if (!fp) return C_ERR;
    int fd = fileno(fp);      // 获取文件描述符

    // 1. 创建文件后端 rio
    rio rdb;
    rioInitWithFile(&rdb, fd);

    // 2. 如果启用压缩,再包装一层压缩后端
    if (server.rdb_compression) {
        rio compress_rdb;
        if (rioInitWithCompress(&compress_rdb, &rdb, server.rdb_compression_level) == C_OK) {
            // 使用压缩 rio 进行后续写入
            ret = rdbSaveRio(&compress_rdb, ...);
            // 写入完毕后,必须刷新压缩缓冲区
            rioFlush(&compress_rdb);
        } else {
            // 压缩初始化失败,回退到文件后端
            ret = rdbSaveRio(&rdb, ...);
        }
    } else {
        ret = rdbSaveRio(&rdb, ...);
    }

    fclose(fp);
    return ret;
}

注意:这里是不同的后端有不同的rio流,我们创建rio流后,根据后端不同采用不同的初始化方式,把里面的函数指针初始化

序列化:本质就是按照一定的格式写入磁盘

cpp 复制代码
int rdbSaveRio(rio *rdb, int *error, int rdbflags, rdbSaveInfo *rsi) {
    // 1. 写入魔数 "REDIS0009"
    snprintf(magic,sizeof(magic),"REDIS%04d", RDB_VERSION);
    if (rioWrite(rdb,magic,9) == 0) goto werr;
    // 2. 如果有辅助字段(如 replid, repl_offset),写入 AUX 字段
    if (rsi) { ... }
    // 3. 遍历所有数据库
    for (j = 0; j < server.dbnum; j++) {
        redisDb *db = server.db+j;
        // 跳过空数据库
        // 写入 SELECTDB 标记和数据库编号
        if (rioWrite(rdb,"\xFE",1) == 0) goto werr;
        if (rdbSaveLen(rdb, j) == 0) goto werr;
        // 遍历数据库中的所有键
        dictEntry *de;
        while ((de = dictNext(di)) != NULL) {
            sds key = dictGetKey(de);
            robj *o = dictGetVal(de);
            // 写入键值对
            if (rdbSaveKeyValuePair(rdb, db, key, o, expiretime, rdbflags) == -1)
                goto werr;
        }
    }
    // 4. 写入 EOF 标记
    if (rioWrite(rdb,"\xFF",1) == 0) goto werr;
    // 5. 校验和会在调用者处追加
    return C_OK;
}

如果启用了压缩,这就是压缩的write函数,即riowrite,那就会写入缓冲区

cpp 复制代码
size_t rioCompressWrite(rio *r, const void *buf, size_t len) {
    // 1. 用 LZF 压缩输入数据(省略 LZF 细节)
    unsigned char compressed[MAX_COMPRESSED_SIZE];
    size_t compressed_len = lzf_compress(buf, len, compressed, sizeof(compressed), &r->lzf);

    // 2. 【关键】把压缩后的数据写入 pending 缓冲区
    size_t remaining = compressed_len;
    const unsigned char *src = compressed;
    
    while (remaining > 0) {
        // 计算 pending 还能装多少
        size_t avail = r->pending_size - r->pending_used;
        
        if (avail > 0) {
            // pending 还有空间 → 直接往里写
            size_t copy = (avail < remaining) ? avail : remaining;
            memcpy(r->pending + r->pending_used, src, copy);
            r->pending_used += copy;
            src += copy;
            remaining -= copy;
        }

        // 3. 【关键】如果 pending 满了 → 刷到下层
        if (r->pending_used == r->pending_size) {
            // 调用下层 RIO 的 write 方法(比如文件后端)
            if (r->downstream->write(r->downstream, r->pending, r->pending_used) != r->pending_used) {
                return 0; // 写入失败
            }
            r->pending_used = 0; // 清空 pending,继续装新数据
        }
    }

    return len; // 返回成功写入的原始数据长度
}

fork子进程,子进程调用rdbSave

cpp 复制代码
void bgsaveCommand(client *c) {
    if (server.rdb_child_pid != -1) {
        addReplyError(c, "Background save already in progress");
        return;
    }
    if (hasActiveChildProcess()) { /* 其他子进程 */ ... }
    if (rdbSaveBackground(server.rdb_filename, rsi, RDBFLAGS_NONE) == C_OK) {
        addReplyStatus(c, "Background saving started");
    } else {
        addReplyError(c, "Background save failed");
    }
}

int rdbSaveBackground(char *filename, rdbSaveInfo *rsi, int rdbflags) {
    pid_t childpid;
    if (server.rdb_child_pid != -1) return C_ERR; // 已有子进程
    if (hasActiveChildProcess()) return C_ERR;    // 其他子进程(如 AOF 重写)正在运行

    if ((childpid = fork()) == 0) {
        // 子进程
        int retval = rdbSave(filename, rsi, rdbflags);
        if (retval == C_OK) {
            // 可记录成功日志
        }
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        // 父进程
        server.rdb_child_pid = childpid;
        server.rdb_child_type = RDB_CHILD_TYPE_DISK;
        updateDictResizePolicy(); // 禁用哈希表 resize,减少 COW 内存开销
        return C_OK;
    }
}

流程梳理:整的流程在于rio流,如果压缩,我们需要创建压缩后端,然后初始化里面的函数指针,这里是压缩后端的写函数rioCompressWrite,主要是把压缩的数据写入压缩缓冲区,如果满了写入下一层,下一层初始化为文件后端即可,因为所有写入压缩的数据都经过了压缩,是最后的数据了,你当前层压缩缓冲区满了会写入下一层,如果数据很大超过缓冲区大小,直接写入下一层也可以,因为都是压缩完之后的数据

启示:

Redis 作者(antirez)在《Redis 设计与实现》及各种访谈中反复强调:先写出能工作的代码,然后根据实际痛点重构rio 不是凭空造出来的,而是随着需求增长自然演化出的结果。

我们无法做到一下子就能写出这么好的架构,这是一直演化的结果,为什么能想到,是因为出现了痛点,演化到最后,想的是是否能把输入和输出分开,也就是解耦,让上层调用根本不用想写到哪里,直接调用rio即可

一开始redis也是得写ifelse去判断,是写入网络(主从的时候),还是写入磁盘,后面代码越来越多那就需要优化

我们的做法

简单先把数据读取出来然后存到对应的路径,形成rdb文件,然后能够读取rdb文件内容加载到内存,启动服务器

但是此时会阻塞服务器,,并且数据不一致,后期优化点:脏key追加+分片拷贝

原生的rdb序列化方案太麻烦了,我们这里进行了简化,用了文本的形式,这样会比redis方案文件大,读取效率慢,这里先简化,后期跑起来在进行优化

cpp 复制代码
    //  进行序列化
    // FCWRDB0001
    // Header: FCWRDB0001\n
    // Strings: STR count\n then per line: klen key vlen value expire_ms\n
    // Hash: HASH count\n then per hash: klen key expire_ms num_fields\n then num_fields lines: flen field vlen value\n
    // ZSet: ZSET count\n then per zset: klen key expire_ms num_items\n then num_items lines: score member_len member\n

可以自行设计,魔数的作用是一打开这个文件,一校验FCWRDB0001就知道是rdb文件

暂时也不做压缩,但是我们可以保留选项,后期可以添加设计,我们当作先跑起来rdb,看一下是否能够持久化

cpp 复制代码
  // class RdbOptions{
  //   // rdb的持久化配置选项
  //   // RDB 文件保存到哪(文件名 / 目录)
  //   // 要不要压缩
  //   // 要不要校验和
  //   // RDB 版本
  //   // 持久化策略(save 300 100 这种)
  //   // 后台保存相关参数
  // };

这里注意关于过期时间要约定好

对于用户传入负数设定时间:表示立即过期

如果用户没有传入时间:永不过期,我们后台要设定为-1

如果用户传入时间:要进行计算,相对时间和绝对时间

这样进行load导入的时候才不会有歧义

以下是我关于过期时间的测试,如果对于一个已经过期的key,是不能再次设过期时间让他复活的,这就得让我们实现再次设置过期时间的时候要判断key有没有过期

对于过期函数要注意即可

代码中用到的函数讲解

cpp 复制代码
std::filesystem::create_directories()
//来自 C++17 的 <filesystem> 库,用来自动创建多级目录。
//注意编译的时候必须加 -lstdc++fs,不加就报 filesystem 错误!



int fd = ::open(path().c_str(), O_CREAT | O_TRUNC | O_WRONLY, 0644);
//不存在则创建,清空文件内容,只读
//权限0644  0表示8进制  -rw-r--r--   文件所有者读和写,其他都是只能读

read(fd,buf,读几字节)
//成功返回 >0 的整数(实际读到的字节数),返回 0 表示读到文件末尾(EOF),返回 -1 表示失败。

测试结果

这是博主的测试结果,以下是dump文件内容,可以看到本质就是一个文本文件,我们没有像redis一样处理出一个二进制文件,当然后期也可以进行改,这里只是简单实现,我们需要清楚redis的rdb是一个二进制文件,它规定的序列化方案是以某种特殊的二进制作为标识的,比如0x00表示啥,0x10表示啥

总结

但是要注意我们虽然使用了文本,但是要注意二进制更优

1. 空间效率

  • 文本格式中,数字必须用 ASCII 字符表示,如数字 1000 需要 4 字节 "1000";而二进制只需 2 字节(0xE8 0x03 小端)。

  • 文本格式需要额外的分隔符(空格、换行),二进制通过长度前缀天然分隔,无需额外字节。

  • 对于大量小键值对,二进制可节省 30%~50% 空间。

2. 解析速度

  • 文本格式 :你需要读取字符直到遇到空格,然后调用 atoistoi 将数字字符串转换为整数。这个过程涉及循环、字符比较、进制转换。

  • 二进制格式:直接读取 1 个字节(或 2/4/9 字节)作为数值,无需转换。CPU 可以直接使用该数值。

3. 数据类型表达

  • 文本只能存储可打印字符。如果要存储二进制数据(如图片、序列化对象),文本格式需要 base64 等编码,会膨胀 33%。二进制可以直接存储原始字节。

  • 二进制可以存储整数、浮点数等原始类型,无需转换。例如存储整数 12345678,文本需要 8 字节 "12345678",二进制只需 4 字节(小端 0x4E 0x61 0xBC 0x00)。

4. 无需转义

  • 文本格式中,如果字符串本身包含空格或换行符,就需要转义或改变分隔符,否则解析会出错。二进制格式不存在这个问题,因为长度前缀明确边界。

5. 压缩友好

  • 二进制数据更紧凑,重复模式更明显,压缩算法(如 LZF)可以获得更高压缩比。文本格式因为冗余字符多,压缩效果略差。
相关推荐
青柠代码录3 小时前
【Redis】数据类型:Set
redis
zxrhhm3 小时前
Oracle INSERT ALL 多表多行插入语法详解
数据库·oracle
zzhongcy3 小时前
Flyway 数据库版本管理工具使用指南
数据库·人工智能
志栋智能3 小时前
效率革命:超自动化巡检如何将小时压缩为分钟?
运维·数据库·自动化
十年编程老舅3 小时前
Linux NUMA架构深度剖析:内存管理、进程调度与性能优化
linux·数据库·c++·内存管理·numa
fly_over4 小时前
AI Agent 开发实战教程(三):记忆与数据库集成
数据库·人工智能·python·ai agent
_Evan_Yao4 小时前
从 select 到 epoll,再到 Agent 循环:如何用 I/O 多路复用撑起千军万马?
java·数据库·人工智能·后端
鸽芷咕4 小时前
金仓数据库字符集与国际化支持:多语言环境下的编码处理方案
数据库·oracle
m0_702036534 小时前
如何通过SQL视图对比两表差异_利用FULL JOIN构建视图
jvm·数据库·python