以下这个RDB文件概况就可以清晰看到RDB文件的样子,真实的文件与这个有细微差别,因为RDB是个二进制文件。

----------------------------# RDB文件是二进制的,所以并不存在回车换行来分隔一行一行.
52 45 44 49 53              # 以字符串 "REDIS" 开头
30 30 30 33                 # RDB 的版本号,大端存储,比如左边这个表示版本号为0003
----------------------------
FE 00                       # FE = FE表示数据库编号,Redis支持多个库,以数字编号,这里00表示第0个数据库
----------------------------# Key-Value 对存储开始了
FD $length-encoding         # FD 表示过期时间,过期时间是用 length encoding 编码存储的,后面会讲到
$value-type                 # 1 个字节用于表示value的类型,比如set,hash,list,zset等
$string-encoded-key         # Key 值,通过string encoding 编码,同样后面会讲到
$encoded-value              # Value值,根据不同的Value类型采用不同的编码方式
----------------------------
FC $length-encoding         # FC 表示毫秒级的过期时间,后面的具体时间用length encoding编码存储
$value-type                 # 同上,也是一个字节的value类型
$string-encoded-key         # 同样是以 string encoding 编码的 Key值
$encoded-value              # 同样是以对应的数据类型编码的 Value 值
----------------------------
$value-type                 # 下面是没有过期时间设置的 Key-Value对,为防止冲突,数据类型不会以 FD, FC, FE, FF 开头
$string-encoded-key
$encoded-value
----------------------------
FE $length-encoding         # 下一个库开始,库的编号用 length encoding 编码
----------------------------
...                         # 继续存储这个数据库的 Key-Value 对
FF                          ## FF:RDB文件结束的标志

 

相关配置

经过多少秒且多少个key有改变就进行,可以配置多个,只要有一个满足就进行保存数据快照到磁盘:
save <seconds> <changes>
保存数据到rdb文件时是否进行压缩,如果不想可以配置成’no’,默认是’yes’,因为压缩可以减少I/O,当然,压缩需要消耗一些cpu资源:
rdbcompression yes
 快照文件名:
dbfilename dump.rdb
快照文件所在的目录,同时也是AOF文件所在的目录:
dir ./

Rdb文件的整体格式
文件签名 | 版本号 | 类型 | 值 | 类型 | 值 | … | 类型 | 值
[注:竖线和空格是为了便于阅读而加入的,rdb文件中是没有竖线和空格分隔的]

把这REDIS_SELECTDB类型和REDIS_EOF类型代入到上边的rdb文件的格式中,那么rdb文件的整体格式变成为:
文件签名 | 版本号 | REDIS_SELECTDB类型 | db编号 | 类型 | 值 | … | REDIS_SELECTD 类型 | db编号 | 类型 | 值 | … | REDIS_EOF类型

REDIS_EXPIRETIME类型
?如果一个key被expire设置过,那么在该key与value的前面会有一个REDIS_EXPIRETIME类型与其对应的值。
?REDIS_EXPIRETIME类型对应的值是过期时间点的timestamp
?REDIS_EXPIRETIME类型与其值是可选的,不是必须的,只有被expire设置过的key才有这个值

假设有一个key被expire命令设置过,把这REDIS_EXPIRETIME类型代入到上边的rdb文件的格式中,那么rdb文件的整体格式变成为:
文件签名 | 版本号 | REDIS_SELECTDB类型 | db编号 | REDIS_EXPIRETIME类型 | timestamp | 类型 | 值 | … | REDIS_SELECTD 类型 | db编号 | 类型 | 值 | … | REDIS_EOF类型

 

数据类型

数据类型主要有以下类型:
?REDIS_STRING类型
?REDIS_LIST类型
?REDIS_SET类型
?REDIS_ZSET类型
?REDIS_HASH类型
?REDIS_VMPOINTER类型
?REDIS_HASH_ZIPMAP类型
?REDIS_LIST_ZIPLIST类型
?REDIS_SET_INTSET类型
?REDIS_ZSET_ZIPLIST类型

其中REDIS_HASH_ZIPMAP,REDIS_LIST_ZIPLIST,REDIS_SET_INTSET和REDIS_ZSET_ZIPLIST这四种数据类型都是只在rdb文件中才有的类型,其他的数据类型其实就是val对象中type字段存储的值。

 

快照保存

我们接下来看看具体实现细节

不管是触发条件满足后通过fork子进程来保存快照还是通过save命令来触发,其实都是调用的同一个函数rdbSave(rdb.c:394)。

先来看看触发条件满足后通过fork子进程的实现保存快照的的实现

在每100ms调用一次的serverCron函数中会对快照保存的条件进行检查,如果满足了则进行快照保存
?如果后端有写rdb的子进程或者写aof的子进程,则检查rdb子进程是否退出了,如果退出了则进行一些收尾处理,比如更新脏数据计数server.dirty和最近快照保存时间server.lastsave。
?如果后端没有写rdb的子进程且没有写aof的子进程,则判断下是否有触发写rdb的条件满足了,如果有条件满足,则通过调用rdbSaveBackground函数进行快照保存。
?对是否已经有写rdb的子进程进行了判断,如果已经有保存快照的子进程,则返回错误。
?如果启动了虚拟内存,则等待所有处理换出换入的任务线程退出,如果还有vm任务在处理就会一直循环等待。一直到所有换入换出任务都完成且所有vm线程退出。
?保存当前的脏数据计数,当快照保存完后用于更新当前的脏数据计数(见函数backgroundSaveDoneHandler,rdb.c:1062)
?记下当前时间,用于统计fork一个进程需要的时间
?Fork一个字进程,子进程调用rdbSave进行快照保存
?父进程统计fork一个子进程消耗的时间: server.stat_fork_time = ustime()-start,这个统计可以通过info命令获得。
?保存子进程ID和更新增量重哈希的策略,即此时不应该再进行增量重哈希,不然大量key的改变可能导致fork的copy-on-write进行大量的写。
?对是否有vm线程进行再次判断,因为如果是通过save命令过来的是没有判断过vm线程的。
?创建并打开临时文件
?写入文件签名“REDIS”和版本号“0002”
?遍历所有db中的所有key
?对每个key,先判断是否设置了expireTime, 如果设置了,则保存expireTime到rdb文件中。然后判断该key对应的value是否则内存中,如果是在内存中,则取出来写入到rdb文件中保存,如果被换出到虚拟内存了,则从虚拟内存读取然后写入到rdb文件中。
?不同类型有有不同的存储格式,详细见rdb文件格式
?最后写入rdb文件的结束符
?关闭文件并重命名临时文件名到正式文件名
?更新脏数据计数server.dirty为0和最近写rdb文件的时间server.lastsave为当前时间,这个只是在通过save命令触发的情况下有用。因为如果是通过fork一个子进程来写rdb文件的,更新无效,因为更新的是子进程的数据。

如果是通过fork一个子进程来写rdb文件(即不是通过save命令触发的),在写rdb文件的过程中,可能又有一些数据被更改了,那此时的脏数据计数server.dirty怎么更新呢? redis是怎样处理的呢?

如果捕捉到写rdb文件的子进程退出,则调用backgroundSaveDoneHandler进行处理
?更新脏数据计数server.dirty为0和最近写rdb文件的时间server.lastsave为当前时间
?唤醒因为正在保存快照而等待的slave,关于slave的具体内容,见replication

 

快照导入

当redis因为停电或者某些原因挂掉了,此时重启redis时,我们就需要从rdb文件中读取快照文件,把保存到rdb文件中的数据重新导入到内存中。

先来看看启动时对快照导入的处理

if (server.appendonly) {
         if (loadAppendOnlyFile(server.appendfilename) == REDIS_OK)
             redisLog(REDIS_NOTICE,"DB loaded from append only file: %ld seconds",time(NULL)-start);
     } else {
         if (rdbLoad(server.dbfilename) == REDIS_OK) {
             redisLog(REDIS_NOTICE,"DB loaded from disk: %ld seconds",
                 time(NULL)-start);
         } else if (errno != ENOENT) {
             redisLog(REDIS_WARNING,"Fatal error loading the DB. Exiting.");
             exit(1);
         }


    }?如果保存了AOF文件,则使用AOF文件来恢复数据,AOF的具体内容见AOF
?如果没有AOF,则使用rdb文件恢复数据,调用rdbLoad函数
?打开rdb文件
?读取rdb文件的签名和版本号
?开始进入 类型 | 值 | 类型 | 值 的循环读取,可参考rdb文件格式
?作者还做了导入的进度条,是有人反馈说rdb文件很大时导入时要很久,但又不知道进度,所以作者就加了导入的进度条,改善用户体验
?读取类型
?如果类型是过期时间类型REDIS_EXPIRETIME,则读取过期时间
?如果类型是文件结束类型REDIS_EOF,则跳出 类型 | 值 | 类型 | 值 的循环读取
?如果类型是选择db类型REDIS_SELECTDB,则读取db索引并把当前db转成该db,然后继续 类型 | 值 | 类型 | 值 的循环读取。
?如果不是以上类型,则表明该类型是数据类型,读取作为key的字符串,即读取字符串类型的值,然后接着读取作为value的字符串。不同类型的编码不一样,根据写入时得规则解释读取到的值即可
?读取到key和value后,判断下该key是否过期,如果过期则丢弃,不再导入,然后继续 类型 | 值 | 类型 | 值 的循环读取。
?如果读取成功,则导入到内存,如果有过期时间则设置过期时间
?如果配置了虚拟内存并且内存的使用比虚拟内存配置的大32M时,开始随机的取一些数据换出到虚拟内存中。
?从上边我们也可以看到,如果没有配置虚拟内存,rdb文件导入时会尽可能地占用操作系统的内存,甚至可能全部用完。

总结

落地存储是数据设计的一大重点也是难点。原理很简单,定义某种协议,然后按照某种协议写入读出。Redis为了节省空间和读写时的I/O操作,做了很多很细致的工作来压缩数据。另外redis的丰富的数据类型也加大了落地的实现难度。作者也曾经在他的博客说过,redis的丰富的数据类型导致了很多经典的优化办法无法在redis上实现。