rdb 数据持久化
redis 为了在能在宕机后,数据不丢失,提供了 rdb 和 aof 两种方式持久化
rdb 介绍
原理:在指定的时间间隔内,将内存里的所有数据以快照的形式保存到一个二进制文件中,在重启 redis 时,通过加载这个二进制文件里的数据到内存中,实现数据恢复。rdb 默认使用的文件名为 dump.rdb。
触发时机
手动触发
redis 提供的命令有:bgsave,save
- bgsave 会fork一个子进程,然后在子进程里做持久化数据,生成rdb文件,主进程则可以继续处理客户端请求。
- 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 持久化,把内存数据写入到磁盘关键步骤:
- 调用 fopen 创建一个临时文件,文件名格式为
temp-进程pid.rdb - 调用 rioInitWithFile 创建一个 rio 实例,专门负责文件读写操作。
- 调用 rio 相应的读写接口,将 redis 内存中的数据写入到
tmp-进程pid.rdb临时文件中。 - 在写完数据后,调用 fflush() 和 fsync() 完成刷新数据到磁盘中。
- 最后,将临时文件 .rdb 改名为配置文件指定的文件名,默认叫 dump.rdb。
- IO 完成后,做一些善后工作,比如清除 dirty 标记,更新 lastsave 时间,以及 lastbgsave_status 状态
额外提下 fsync ,fdatasync,sync_file_range 三者关系
- fsync 用于将打开文件的所有修改操作同步到设备,确保数据持久化。它会同步文件的元数据(大小、修改时间等)和 文件数据,执行两次IO操作,保证了数据的绝对一致性和完整性。
- fdatasync 类似 fsync,但它只同步文件的数据部分,仅在必要的情况下才会同步元数据,因此可以减少一次IO写操作。那么什么是“必要的情况”呢?
- 举例来说,文件的尺寸(st_size)如果变化,是需要立即同步的,否则OS一旦崩溃,即使文件的数据部分已同步,由于元数据没有同步,依然读不到修改的内容。而最后访问时间(atime)/修改时间(mtime)是不需要每次都同步的,只要应用程序对这两个时间戳没有苛刻的要求,基本无伤大雅。
- 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含义:私有脏页大小,指的是私有内存中被修改的页大小。
参考:

浙公网安备 33010602011771号