引言
RDB 和 AOF 是 Redis 提供的两种数据持久化方式,其中 RDB 存了数据库的完整快照,比较损耗性能,而 AOF 只是把会产生副作用的命令一条条记下来,相对轻量快速,为了降低丢数据的风险,一般会打开 AOF,为了避免 AOF 文件过大,Redis 也会时不时地对其进行整理。
RDB
手动触发:BGSAVE
我们可以使用命令 SAVE
或者 BGSAVE
来强制触发数据库重写 RDB 文件,SAVE
使用数据库的主进程来重写 RDB,会导致数据库无法处理更多的命令,所以我们一般使用 BGSAVE
创建一个子进程来重写,rdb.c:1083:
// fork() 开始前的时间,记录 fork() 返回耗时用
start = ustime();
if ((childpid = fork()) == 0) {
//...
// 在子进程中重写 rdb
retval = rdbSave(filename);
}
走马观花 RDB 文件格式
继续阅读上面代码中的 rdbSave
函数,它会先创建一个叫做 temp-xx.rdb
的临时文件(其中 xx
是指子进程的进程 id),先把数据写到这个文件中,然后再把它重命名为用户指定的 rdb 文件,rdb.c:938:
// 创建临时文件
snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
fp = fopen(tmpfile,"w");
之后 Redis 首先写入自己的 magic number,其实就是 “REDIS” + 版本号,对于 Redis 3.0 来说,就是 “REDIS6”,rdb.c:954:
// 写入 RDB 版本号 REDIS0006
snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);
if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;
之后遍历所有数据库,遍历所有键值对写入后,写入 EOF
和校验和:
// 遍历所有数据库
for (j = 0; j < server.dbnum; j++) {
//...
}
if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_EOF) == -1) goto werr;
cksum = rdb.cksum;
rioWrite(&rdb,&cksum,8);
到此我们就知道一个 RDB 文件大概的样子了:
下面再粗略看看 databases 部分的结构。在 databases 部分,每一个数据的第一个字节标识自己的类型,可以是 OPCODE
或者具体的数据类型(比如 REDIS_RDB_TYPE_LIST_ZIPLIST
,杂揉了数据结构和其底层的具体编码)rdb.h:97:
#define REDIS_RDB_TYPE_STRING 0
#define REDIS_RDB_TYPE_LIST 1
#define REDIS_RDB_TYPE_SET 2
#define REDIS_RDB_TYPE_ZSET 3
#define REDIS_RDB_TYPE_HASH 4
//...
//...
#define REDIS_RDB_OPCODE_SELECTDB 254
#define REDIS_RDB_OPCODE_EOF 255
databases 部分的开头就是一个 REDIS_RDB_OPCODE_SELECTDB
,后面紧跟着数据库的编号(RDB 对于数字的存储做了专门的优化,后面再聊)。然后就是所有的键值对,每个键值对的存储格式大概是(如图):过期时间(如果有设置的话,没有设置就省略这一部分)+ 类型(就是上面给出的这些宏,占一个字节) + 值。其中值会根据不同的类型,采用不同的编码,因为这里只是走马观花,就不细看每种类型值的编码了,如果想要了解的话,可以很容易在代码中找到相关部分并阅读。
RDB 中对数字存储的优化
RDB中会存储很多数字信息,主要是长度相关信息,比如字符串的长度,字典,集合等的长度等等,所以 Redis 专门对数字的存储进行了优化,如果是6位就能表示的数字,那么就只用一个字节存该数字,以 00 开头,剩下 6 位存数字;如果 6 位放不下,但是 14 位能放得下,那么就用两个字节存储该数字,以 01 开头,剩下的 14 个字节存数字;如果 14 个字节还存不下的话,那么就用 5 个字节存数字,第一个字节固定就是 11 000000,然后剩下的 4 个字节放该整型数字。
范围 | 字节数 | 开头 |
---|---|---|
x < 64 | 1 | 00 |
64<= x < 16384 | 2 | 01 |
16384 <= x < 4294967295(uint最大值) | 5 | 11000000 |
数据库自动触发
如果我们不手动触发,数据库会在什么时机重写呢?
在本系列的第三篇文章(网络层实现)中我们提到,在 Redis 网络层中唯一的定时事件就是 serverCron
,它默认每隔100ms执行一次, 在 serverCron
会进行是否需要重写 RDB 文件的判定,redis.c:1467:
// 遍历所有保存条件,看是否需要执行 BGSAVE 命令
for (j = 0; j < server.saveparamslen; j++) {
struct saveparam *sp = server.saveparams+j;
// 检查是否有某个保存条件已经满足了
if (server.dirty >= sp->changes &&
server.unixtime-server.lastsave > sp->seconds &&
// 有可能是上次 bgsave 执行失败, 需要重试
(server.unixtime-server.lastbgsave_try >
REDIS_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == REDIS_OK))
{
redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
sp->changes, (int)sp->seconds);
// 执行 BGSAVE
rdbSaveBackground(server.rdb_filename);
break;
}
}
可以看到里面遍历了 server
结构体的 saveparams
字段保存的一些列条件,在服务器的初始化代码中可以找到这些条件:
appendServerSaveParams(60*60,1); /* 1 小时内的修改大于等于 1 次 */
appendServerSaveParams(300,100); /* 5 分钟内的修改大于等于 100 次 */
appendServerSaveParams(60,10000); /* 1 分钟内的修改大于等于 10000 次 */
从 RDB 恢复数据
在 Redis 服务器启动时,如果开启了 AOF,会优先从 AOF 数据,没有开启 AOF 时,才从 RDB 读取,redis.c:3892:
// AOF 持久化已打开?
if (server.aof_state == REDIS_AOF_ON) {
// 尝试载入 AOF 文件
if (loadAppendOnlyFile(server.aof_filename) == REDIS_OK)
// 打印载入信息,并计算载入耗时长度
redisLog(REDIS_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
// AOF 持久化未打开
} else {
// 尝试载入 RDB 文件
if (rdbLoad(server.rdb_filename) == REDIS_OK) {
// 打印载入信息,并计算载入耗时长度
redisLog(REDIS_NOTICE,"DB loaded from disk: %.3f seconds",
(float)(ustime()-start)/1000000);
} else if (errno != ENOENT) {
redisLog(REDIS_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
exit(1);
}
}
AOF
AOF 存储格式走马观花
AOF 文件里存的其实就是纯文件格式的一条一条命令,但是和我们在 Redis 客户端写的命令不太一样,是用于 Redis 客户端与服务器交互的格式,,比如查看 list 长度的命令 LLEN mylist
,会被存储成:
*2\r\n
$4\r\n
LLEN\r\n
$6\r\n
mylist\r\n
逐行翻译一下:
*2
: 长度为 2 的数组$4
:下一行是一个长度为 4 的字符串LLEN
:和上一行一起构成了一个字符串 LLEN$6
mylist
:同上,代表一个长度为 6 的字符串 mylist
整体翻译一遍,就相当于["LLEN", "mylist"]
,所有 Redis 命令都会先被 Redis 客户端翻译成类似的格式后传输给服务器。
更加详细的格式见官方文档:Redis 协议文档
命令的及时存储
AOF 和 RDB 不同,它按序存储每条修改数据库的命令,所以每次执行更新数据命令时,它都必须将他们保存下来,但是又不能每次都写到文件里,因为这样会大大降低性能,所以 Redis 提供了一个叫做 appendfsync
的选项用于配置将缓存刷到文件的频率,Redis 三种可选项:
no
:从不主动刷数据,有操作系统决定什么时候将数据写入,因为操作系统什么时候写入数据是不可控的,所以这种方式很容易丢数据always
:每条命令都主动刷到文件中,最不容易丢数据,但是影响性能everysec
:每秒刷一次,一种折衷选项,一般都推荐设置成这个
这里的”刷”其实是指调用 linux 的系统库函数 fsync
(从配置的名称 appendfsync
就能看出来),因为虽然每当一条命令执行结束时,Redis 都会使用 write 函数将其写入 AOF 文件中,但是系统的 write 函数并不会真的将其写入文件中,而是先写入操作系统的缓存(叫做”Page Cache”),等到积累到一定的数据量再统一写入文件,除非用户通过 fsync
函数强制让操作系统写入:
下面我们来梳理一下源码中在一条命令执行的过程中和 AOF 相关的流程。
当一条命令完成后,通过检查 server.dirty
值来判断数据库有没有被修改(对数据库有修改的命令,会主动进行 server.dirty++
),如果发现被修改,说明这是一条会更新数据库的命令,需要记下来,redis.c:2488:
// 保留旧 dirty 计数器值
dirty = server.dirty;
// 计算命令开始执行的时间
start = ustime();
//... 执行 Redis 命令
// 计算命令执行之后的 dirty 值
dirty = server.dirty-dirty;
// 将命令复制到 AOF 和 slave 节点
if (flags & REDIS_CALL_PROPAGATE) {
//...
// 如果数据库有被修改,那么启用 REPL 和 AOF 传播
if (dirty) // 数据库中数据被修改
flags |= (REDIS_PROPAGATE_REPL | REDIS_PROPAGATE_AOF);
if (flags != REDIS_PROPAGATE_NONE)
// 在这个函数里会记录下这条命令
propagate(c->cmd,c->db->id,c->argv,c->argc,flags);
}
再继续深入 propagate
函数,我们发现它会调用 feedAppendOnlyFile
函数,最终将命令写入 server.aof_buf
中,aof.c:743:
if (server.aof_state == REDIS_AOF_ON)
server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf));
// 如果此时刚好有子线程在重写(前面提到当 AOF 膨胀得太大时,Redis会启子线程来整理)
// 那么数据还会同时写到 server.aof_rewrite_buf_blocks 中
// 不用担心数据被重复写,因为当重写结束时,会自动清空 server.aof_buf
if (server.aof_child_pid != -1)
aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));
当命令执行结束后,程序又一次进入 eventLoop 的事件等待之前(网络层将这一段逻辑称为 beforeSleep
),会使用 write 函数将 server.aof_buf
的内容写入 AOF 文件,redis.c:1604:
// 将 AOF 缓冲区的内容写入到 AOF 文件
flushAppendOnlyFile(0);
在 flushAppendOnlyFile
中你可以找到 write 函数的调用,aof.c:441:
nwritten = write(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
AOF 文件重写
和 RDB 类似,AOF 也可以通过 BGREWRITEAOF
来强制重写 AOF 文件,同时 Redis 也会自动判断是否需要重写,redis.c:1505:
// 上一次完成 AOF 写入之后,AOF 文件的大小
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
// AOF 文件当前的体积相对于 base 的体积的百分比
long long growth = (server.aof_current_size*100/base) - 100;
// 如果增长体积的百分比超过了 growth ,那么执行 BGREWRITEAOF
// 默认是增长超过一倍
if (growth >= server.aof_rewrite_perc) {
redisLog(REDIS_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
// 执行 BGREWRITEAOF
rewriteAppendOnlyFileBackground();
}
server.aof_rewrite_perc
的默认值是 100,即 AOF 增长大于等于 1 倍才会出发重写。
AOF 文件重写的过程中还可能有新的命令需要写入,所以相比 RDB 会更复杂一些,需要父子进程合作来完成。
子线程做的事情类似于 RDB 中子进程做的事情,先创建一个临时文件 temp-rewriteaof-xx.aof
(其中xx
是指进程的 ID),遍历 Redis 中所有的数据库,以及数据库中所有的键值对,根据键值对的类型和内容在 AOF 中写下能产生该键值的命令,aof.c:1328:
int rewriteAppendOnlyFile(char *filename) {
//...
snprintf(tmpfile,256,"temp-rewriteaof-%d.aof", (int) getpid());
fp = fopen(tmpfile,"w");
//...
// 遍历所有数据库
for (j = 0; j < server.dbnum; j++) {
//...遍历所有键值对
}
}
但是和 RDB 不同的是,AOF 需要考虑在重写的过程中被执行的命令(比如子线程正在重写 AOF 文件的时候,我用 SET 命令又把一个键的值给更新了),此时这条命令肯定不能只往 AOF 文件中写,因为这个 AOF 文件不久之后就会被重写的临时文件给替换掉,所以之前提到,当有子线程正在重写时,命令不仅会被写到 server.aof_buf
中,还会被写到 server.aof_rewrite_buf_blocks
中,而父进程在得知子进程重写结束后,就会将 aof_rewrite_buf_blocks
中的内容 append 到子进程刚刚创建的临时文件中(父进程其实就是 Redis 的主进程,此时 Redis 停止处理一切命令专门来写这个aof_rewrite_buf_blocks
,这样就保证了 AOF 中的数据和数据库中一致),然后再把它重命名为用户指定的 AOF 文件,整个流程如下:
需要说明的是,父进程是在每一个 serverCron
定时事件中通过非阻塞的 wait 来查看子进程是否结束,如果发现子进程已经结束,那么父进程就会进行收尾工作,redis.c:1451:
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
//...
if (pid == server.aof_child_pid) {
// 父进程的后续收尾工作,
// 其实就是将 server.aof_rewrite_buf_blocks 的内容 append 上去
backgroundRewriteDoneHandler(exitcode,bysignal);
}
}