rdb 数据持久化

redis 为了在能在宕机后,数据不丢失,提供了 rdb 和 aof 两种方式持久化

rdb 介绍

原理:在指定的时间间隔内,将内存里的所有数据以快照的形式保存到一个二进制文件中,在重启 redis 时,通过加载这个二进制文件里的数据到内存中,实现数据恢复。rdb 默认使用的文件名为 dump.rdb。

触发时机

手动触发

redis 提供的命令有:bgsave,save

  1. bgsave 会fork一个子进程,然后在子进程里做持久化数据,生成rdb文件,主进程则可以继续处理客户端请求。
  2. save 则是在 redis 主线程里做持久化数据,并生成rdb文件,这段时间会造成整个 Redis 不可用。

定时自动触发

我们可以在 redis.conf 配置文件配置一组条件,即多长时间,有多少个 key 的值发生改变了,就执行同 bgsave 命令一样,fork 一个子进程来做持久化。

# Unless specified otherwise, by default Redis will save the DB:
#   * After 3600 seconds (an hour) if at least 1 change was performed
#   * After 300 seconds (5 minutes) if at least 100 changes were performed
#   * After 60 seconds if at least 10000 changes were performed
#
# You can set these explicitly by uncommenting the following line.
#
# save 3600 1 300 100 60 10000

如果redis.conf没有配置条件,redis 也会在代码中默认配置一组触发条件:

void initServerConfig(void) {
    ...
    appendServerSaveParams(60*60,1);  /* save after 1 hour and 1 change */
    appendServerSaveParams(300,100);  /* save after 5 minutes and 100 changes */
    appendServerSaveParams(60,10000); /* save after 1 minute and 10000 changes */
    ...
}

距离上次 rdb 持久化超过 1 小时,且有3个 Key 被修改过,就会触发 rdb 持久化。或者5分钟,有100个 key 发生变化,又或者1分钟,有1000个 key 发生变化,都会触发 rdb 持久化。

信号触发

redis 会注册 SIGTERM,以及 SIGINT 信号,在收到任意一个信号时,会触发 rdb 持久化一次,保证 redis 在停服后,数据不丢失。

// 处理信号
void setupSignalHandlers(void) {
    ...
    act.sa_handler = sigShutdownHandler;
    sigaction(SIGTERM, &act, NULL);
    sigaction(SIGINT, &act, NULL);
    ...
}

// 设置server.shutdown_asap标记
static void sigShutdownHandler(int sig) {
    ...
    server.shutdown_asap = 1;
    ...
}

// 调用 prepareForShutdown()
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    ...
    if (prepareForShutdown(shutdownFlags) == C_OK) exit(0); 
}

int prepareForShutdown(int flags) {
    ...
    return finishShutdown();
}

// 最终 rdbSave 持久化所有内存数据到 rdb 文件中
int finishShutdown(void) {
    ...
    if (rdbSave(SLAVE_REQ_NONE,server.rdb_filename,rsiptr) != C_OK) {
    ...
}

rio 层

持久化包括了 rdb,aof 两种,为了更方便的统一持久化接口的实现,提供了 rio 层,它封装了各个 io 操作相关(读写,刷新文件)的实现细节,以及 buffer 缓冲区实现。

void rioInitWithFile(rio *r, FILE *fp);
void rioInitWithBuffer(rio *r, sds s);
void rioInitWithConn(rio *r, connection *conn, size_t read_limit);
void rioInitWithFd(rio *r, int fd);

rdb 持久化,把内存数据写入到磁盘关键步骤:

  1. 调用 fopen 创建一个临时文件,文件名格式为 temp-进程pid.rdb
  2. 调用 rioInitWithFile 创建一个 rio 实例,专门负责文件读写操作。
  3. 调用 rio 相应的读写接口,将 redis 内存中的数据写入到 tmp-进程pid.rdb 临时文件中。
  4. 在写完数据后,调用 fflush() 和 fsync() 完成刷新数据到磁盘中。
  5. 最后,将临时文件 .rdb 改名为配置文件指定的文件名,默认叫  dump.rdb。
  6. IO 完成后,做一些善后工作,比如清除 dirty 标记,更新 lastsave 时间,以及 lastbgsave_status 状态

额外提下 fsync ,fdatasync,sync_file_range 三者关系

  1. fsync 用于将打开文件的所有修改操作同步到设备,确保数据持久化。它会同步文件的元数据(大小、修改时间等)和 文件数据,执行两次IO操作,保证了数据的绝对一致性和完整性。
  2. fdatasync 类似 fsync,但它只同步文件的数据部分,仅在必要的情况下才会同步元数据,因此可以减少一次IO写操作。那么什么是“必要的情况”呢?
    1. 举例来说,文件的尺寸(st_size)如果变化,是需要立即同步的,否则OS一旦崩溃,即使文件的数据部分已同步,由于元数据没有同步,依然读不到修改的内容。而最后访问时间(atime)/修改时间(mtime)是不需要每次都同步的,只要应用程序对这两个时间戳没有苛刻的要求,基本无伤大雅。
  3. sync_file_range 可以让我们在多个写操作后,执行一次文件局部数据的刷新,大大提高IO的性能。它通过指定文件的偏移位置,以及长度,来细粒度的控制数据刷新到磁盘。

redis 在写 rdb文件时,做了一个优化:默认开启 rdb-save-incremental-fsync 选项,即写入 rdb 文件时,增量刷盘,原理:

redis 在每次写入 4M 数据大小后,就会执行一次 sync_file_range() 增量存盘操作,即将部分数据异步刷新到磁盘中,而且 sync_file_range() 是不会更新元数据的,减少一次写元数据IO操作,而且针对的是文件的某一部分数据刷新,而不是整个文件刷新,等到整个 RDB 文件写入完成之后,最后再调用 fsync 刷新元数据,这样就会很快了。

RDB文件存储格式

rdb 文件存储内容主要分为文件头,元数据,数据区,文件尾四大部分。

图片引用自:https://www.cnblogs.com/Finley/p/16251360.html

头部信息主要包括一个魔数 "redis",以及 rdb 版本号。元数据主要包括 redis 版本号,文件创建时间,内存使用量等。数据区主要包括两部分,一部分是记录过期 keys 时间,另一部分是记录所有的内存数据。尾部信息主要是记录校验码,用来检测文件的完整性。

不同的类型结构存储格式:

图片来源:https://xie.infoq.cn/article/f6062f3e8d9674aa24db68631

Copy On Write 写时复制

redis 之所以使用子进程去生成 dump.rdb 文件,是因为子进程使用了 Copy on Write(cow) 的方式进行内存拷贝,而非全量内存拷贝。Copy on Write 原理是对于同一份物理页资源,父子进程共享,只有当父进程或子进程要进行修改时,才会去拷贝一份物理页出来,然后更新自己的虚拟页表指向,从而避免直接修改共享内存。

redis 创建的子进程只会去读内存数据,不会修改数据,所以,只有当主进程处理写命令时,才会触发 Copy on Write 操作。从另一个角度来说,在写少读多的场景下, Copy on Write 机制才能发挥最大优势。

此外,redis 还对 Copy on Write 做了相关优化,就是子进程会尝试释放不再使用的内存页。比如,如果一个内存页出现了 Copy on Write 的情况,子进程把这一页的数据全部持久化之后,会尝试释放这一页的内存,因为,子进程持久化之后,没有必要在引用这块内存页了。一个内存页的大小(默认是 4K)。

ssize_t rdbSaveDb(rio *rdb, int dbid, int rdbflags, long *key_counter) {
    ... // 省略写入redisDb编号、键值对数据量等信息的逻辑
    while((de = dictNext(di)) != NULL) {
        ... //省略读取键值对的逻辑
        // 调用rdbSaveKeyValuePair()函数,持久化键值对
        if ((res = rdbSaveKeyValuePair(rdb, &key, o, expire, dbid)) < 0) goto werr;
        written += res;
        // 关键在这里!!!这里会计算此次写入键值对大小,然后通过dismissObject来释放子进程中的内存页
        size_t dump_size = rdb->processed_bytes - rdb_bytes_before_key;
        if (server.in_fork_child) dismissObject(o, dump_size);
        ... // 省略子进程给父进程发送统计信息的逻辑
    }
    return written;
}

如果 cow 相关字段值较高,则说明在子进程持久化数据期间,主进程频繁发生了写操作,导致 copy on write 大量发生内存拷贝。这段时间可能会造成一定的内存消耗,因为子进程会进行大量的内存页复制(页中断 page-fault),这个值也是我们可以用来分析当前 redis 性能的一个重要参数。

父子进程通讯

redis 主要采用管道技术来实现父子进程通讯的,即子进程往管道里写入数据,父进程往管道里读数据,类此下图:

子进程写时机

  • 子进程会在写入 1024个 keys,并且两次通知间隔超过1s时,会通知父进程当前持久化的进度。
  • 子进程在持久化完成后,也会通知父进程。

父进程读时机

  • 每个1s读取管道数据,更新子进程持久化状态。

管道传递的数据

子进程向父进程传递的数据主要有,当前写入 rdb 文件有多少个 keys 了,以及发生 Copy on Write (cow)的字节数。

子进程是怎么获取 cow 字节数的呢,在Linux中,这个主要是读取 /procs/{pid}/smaps 文件,遍历每个内存区域并累计其中的 Private_Dirty 值,进而确定发生 Copy on Write 的字节数。

其中 Private_Dirty含义:私有脏页大小,指的是私有内存中被修改的页大小。

参考:

redis-rdb-format.md

Golang 实现 Redis(11): RDB 文件格式

Redis RDB 持久化详解

posted @ 2024-07-26 09:28  墨色山水  阅读(70)  评论(0)    收藏  举报