【Redis】来看看Redis浅显易懂的RDB持久化原理

RDB持久化

1.持久化背景

​ Redis是一个纯内存数据库,内存中的数据会随着机器的断电或宕机等问题丢失,为了解决这个缺点,Redis提供了将内存数据持久化到硬盘,以及用持久化文件来恢复数据库数据的功能。
​ Redis 支持两种形式的持久化,一种是RDB快照(snapshotting),另外一种是AOF(append-only-file),Redis4.0之后支持混合持久化的方式。

2.RDB简介

​ RDB是把当前内存中的数据集快照写入磁盘,也就是Snapshot快照(数据库中所有键值对数据),RDB持久化生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。

2.1.创建方式

​ RDB持久化既可以通过命令手动执行,也可以根据服务器配置项定期执行。

2.1.1.自动创建

​ 通过配置文件,开启RDB持久化方式,位置在在 redis.conf 配置文件中的 SNAPSHOTTING模块下,默认配置:

save 900 1 
save 300 10
save 60 10000

上述配置表示:只要以下三个条件中的一个,就会自动执行持久化操作(实际上是执行BGSAVE命令):
服务器在900秒之内对数据库进行了至少1次修改;
服务器在300秒之内对数据库进行了至少10次修改;
服务器在60秒之内对数据库进行了至少10000次修改;

举例:以下是服务器在60秒内执行了10000次修改,服务器自动执行BGSAVE打印的日志:

2.2.2.手动创建

手动触发Redis进行RDB持久化的命令有两种:
SAVE:
  该命令会阻塞当前Redis服务器,执行SAVE命令期间,Redis不能处理其他命令,直到RDB过程完成为止。显然该命令对于内存比较大的实例会造成长时间阻塞,这是致命的缺陷,为了解决此问题,Redis提供了第二种方式。
BGSAVE:
  执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上Redis内部所有的RDB操作都是采用BGSAVE命令。
创建RDB文件的实际工作由rdb. c/rdbSave函数完成,SAVE命令和BGSAVE命令会以不同的方式调用这个函数,通过以下伪代码可以明显地看出这两个命令之间的区别:

  def SAVE():
      #创建RDB文件
      rdbSave()
  def BGSAVE():
      #创建子进程
      pid = fork()
      if pid == 0:
          #子进程负责创建RDB文件
          rdbSave()
          #完成之后向父进程发送信号
          signal_parent()
      elif pid > 0:
          #父进程继续处理命令请求,并通过轮询等待子进程的信号
          handle_request_and_wait_signal()
      else:
          #处理出错情况
          handle_fork_error()

SAVE和BGSAVE如何避免竞争关系?
(1)因为SAVE命令执行时,Redis服务器会阻塞,其他所有命令及请求都需要等SAVE执行完之后才会被处理,所以SAVE命令执行时,不会与BGSAVE存在竞争关系。
(2)因为BGSAVE命令的保存工作是由子进程执行的,所以在子进程创建RDB文件的过程中,Redis 服务器仍然可以继续处理客户端的命令请求,为了避免父进程和子进程同时持久化(即发发生竞争关系),在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式会和平时有所不同:
<1> 首先,在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件。
<2> 其次,在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件。
<3> 最后,BGREWRITEAOF和BGSAVE两个命令不能同时执行:
···如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行。
···如果BGREWRITEAOF命令正在执行,那么客户端发送的BGSAVE命令会被服务器拒绝。

2.2.载入方式

​ 和使用SAVE命令或者BGSAVE命令创建RDB文件不同,RDB文件的载人工作是在服务器启动时自动执行的,所以Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载人RDB文件。另外值得一提的是,因为AOF文件的更新频率通常比RDB文件的更新频率高,所以:
​ (1)如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
​ (2)只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

​ 载人RDB文件的实际工作由rdb.c/rdbLoad函数完成,这个函数和rdbSave函数之间的关系如下:

​ 如果需要将数据恢复至新的Redis服务器上,只需要把RDB文件拷贝至新的Redis服务器的安装目录(配置文件中通过配置项“dir”配置)中,再启动Redis服务器即可。

3.RDB实现

​ 本节讲的是自动触发RDB持久化的实现方式

3.1.设置触发条件

​ 当Redis服务器启动时,用户可以通过指定配置文件或者传人启动参数的方式设置save选项,如果用户没有主动设置save选项,那么服务器会为save选项设置默认条件。

​ 接着,服务器程序会根据save选项所设置的保存条件,设置服务器状态redisServer结构的saveparams属性:

struct redisServer {
		//...
  
		//记录了保存条件的数组,
		struct saveparam *saveparams;
  
		//...
};

saveparams属性是一个数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构都保存了一个save选项设置的保存条件:

struct saveparam {

    //秒数
    time_t seconds;

    //修改数
    int changes;

};

比如说,如果save选项的值为以下条件:

save 900 1
save 300 10
save 60 10000

那么服务器状态中的saveparams数组将会是下图的样子:

3.2.记录变更数

​ 除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:
dirty计数器:记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写人、删除、更新等操作);
lastsave属性:是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。

struct redisServer {
  
  	//...

		//修改计数器
		1ong long dirty;
  
		//上一次执行保存的时间
		time_t lastsave ;
  
  	//...

};

当服务器成功执行一个数据库修改命令之后,程序就会对dirty计数器进行更新:命令修改了多少次数据库,dirty计数器的值就增加多少。

3.3.判断是否满足触发条件

​ Redis的服务器周期性操作函数serverCron默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。以下伪代码展示了serverCron函数检查保存条件的过程:

def serverCron() :
	//...

    #遍历所有保存条件
    for saveparam in server.saveparams :

        #计算距离上次执行保存操作有多少秒
        save_interval = unixtime_now()-server.lastsave

        #如果数据库状态的修改次数超过条件所设置的次数,并且距离上次保存的时间超过条件所设置的时间,那么执行保存操作
        if server.dirty >= saveparam.changes and save_interval > saveparam.seconds:
        	 BGSAVE()
      
  //...

​ 程序会遍历并检查saveparams数组中的所有保存条件,只要有任意一个条件被满足,那么服务器就会执行BGSAVE命令。举个例子,如果Redis服务器的当前状态如下图所示:

那么当时间来到1378271101,也即是1378270800的301秒之后,服务器将自动执行一次BGSAVE命令,因为saveparams数组的第二个保存条件——300 秒之内有至少10次修改已经被满足。

注:每次BGSAVE完成之后,dirty计数器会被清零,lastsave时间戳会更新为当前时间戳。

4.RDB文件

4.1.整体结构

​ 一个完整的RDB文件包含以下几个部分,(为了方便理解,之后的图示中,大写表示常量,小写表示常量或数据),由于RDB文件是二进制存储,以下结构均为逻辑结构:

REDIS:RDB文件的最开头是REDIS部分,这个部分长度为5字节,保存着“REDIS”5个字符,通过这5个字符,程序可以在文件载入时,快速检查所载入的文件是否为RDB文件;
db_version:长度为4字节,它的值是一个字符串表示的整数,这个整数记录了整个RDB文件的版本号,比如“0006”就表示文件的版本为第6版;
databases:包含着0个或多个数据库,以及各个数据库的键值数据,这个部分的长度受数据库个数、数据库所保存的键值对数量、类型等因素影响,如果所有数据库都是空的,那这个部分占用字节为0;
EOF:常量,长度为1字节,标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,告知它所有数据库的所有键值对已经载入完毕;
check_sum:是一个8字节长度的无符号整数,保存着一个校验和,这个校验和是程序通过前面四个部分计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和对比,以检查RDB文件是否有出错或者损坏的情况。

4.2.databases部分

​ 一个RDB文件的databases部分可以保存任意多个非空数据库,下图表示Redis的0号数据库和3号数据库非空,database 0代表0号数据库中的所有键值对数据:

每个非空数据库在RDB文件中由三个部分组成:

SELECTDB:常量,1字节,告知读入程序接下来将要读取到的,是一个数据库号码;
db_number:数据库号码,根据数据库的不同,长度会不同,当程序读到该字段时,会调用SELECT命令,根据读到的数据库号码,切换数据库;
key_value_pairs:保存了当前数据库中所有的键值对数据,如果键值带有过期时间,过期时间也会保存在这个部分,具体结构在后面介绍。

下图展示了一个0号数据库和3号数据库非空的完整的RDB文件结构:

4.3.key_value_pairs部分

key_value_pairs部分有两种情况,一种是不带过期时间的,另一种是带过期时间的,不带过期时间的结构如下:

带过期时间的结构如下:

TYPE:记录了value的类型,TYPE的取值如下:

名称取值说明
REDIS_RDB_TYPE_STRING0字符串
REDIS_RDB_TYPE_LIST1列表
REDIS_RDB_TYPE_SET2集合
REDIS_RDB_TYPE_ZSET3有序集合
REDIS_RDB_TYPE_HASH4字典
REDIS_RDB_TYPE_HASH_ZIPMAP9
REDIS_RDB_TYPE_LIST_ZIPLIST10
REDIS_RDB_TYPE_SET_INTSET11
REDIS_RDB_TYPE_ZSET_ZIPLIST12
REDIS_RDB_TYPE_HASH_ZIPLIST13
REDIS_RDB_TYPE_LIST_QUICKLIST14

EXPIRETIME_MS:常量,1字节,告知读入程序,接下来读取的是一个以毫秒为单位的过期时间;
ms:8字节带符号整数,记录一个以毫秒为单位的UNIX时间戳,这个时间戳就是键值对的过期时间。

4.4.value部分

​ 不同类型的值对象,在RDB文件中保存的结构不同。

4.4.1.字符串对象

​ 如果TYPE为REDIS_RDB_TYPE_STRING,则value保存的为一个字符串对象,字符串对象有两种编码:REDIS_ENCODING_INT或者REDIS_ENCODING_ROW。
​ 如果编码为REDIS_ENCODING_INT,说明对象中保存的是一个不超过32位的整数,根据对象长度的不同,REDIS_ENCODING_INT分为:REDIS_ENCODING_INT8、REDIS_ENCODING_INT16、REDIS_ENCODING_INT32,分别代表RDB文件使用8位(bit)、16位、32位来保存对象值。

下图表示用8位来保存整数:

​ 如果编码为REDIS_ENCODING_ROW,说明对象保存的是一个字符串值,如果服务端配置开启了文件压缩功能,会根据字符串的长度,采用压缩和非压缩两种方法来保存字符串:
​ 长度<=20字节,非压缩;
​ 长度>20字节,压缩。

无压缩字符串的结构:

压缩后的字符串保存结构:

REDIS_RDB_ENC_LZF:常量,表示字符串经过了LZF算法压缩;
compressed_len:字符串被压缩后的长度;
origin_len:字符串原始长度;
compressed_string:被压缩后的字符串。

4.4.2.列表对象

​ 如果TYPE为REDIS_RDB_TYPE_LIST,那么value保存的为一个REDIS_ENCODING_LINKEDLIST编码的列表对象,结构如下所示:

​ list_length表示列表的长度,记录列表保存了多少个项,示例:

​ 上图表示一个长度为3的列表,存放了三个字符串对象,第一个是长度为5的“hello”,第二个是长度为5的“world”,第三个是长度为1的“!”。

4.4.3.集合对象

​ 如果TYPE为REDIS_RDB_TYPE_SET,那么value保存的是一个REDIS_ENCODING_HT编码的集合对象,结构和列表对象类似,就不过多做解释:

4.4.4.有序集合对象

​ 如果TYPE为REDIS_RDB_TYPE_ZSET,那么value保存的是一个REDIS_ENCODING_SKIPLIST编码的集合对象,结构如下:

​ sorted_set_size记录了有序集合的长度,每个element又分为成员(member)和分值(score)两部分,member是一个字符串对象,score是一个double类型浮点数,保存时会将score转成字符串对象,完整的结构如下:

示例:

表示保存了两个元素的有序集合:
​ 第一个元素的成员是长度为2的字符串"pi",分值被转换成字符串之后变成了长度为4的字符串"3.14"。
​ 第二个元素的成员是长度为1的字符串"e",分值被转换成字符串之后变成了长度为3的字符串"2.7"。

4.4.5.Hash对象

​ 如果TYPE为REDIS_RDB_TYPE_HASH,那么value保存的是一个REDIS_ENCODING_HT编码的集合对象,结构如下:

​ hash_size表示hash键值对的个数,之后是所有的键值对,由于键值对是连续存储,故结构可进一步修改如下:

示例:

在这个示例结构中,第一个数字2记录了哈希表的键值对数量,之后跟着的是两个键值对:
第一个键值对的键是长度为1的字符串"a",值是长度为5的字符串"apple";
第二个键值对的键是长度为1的字符串"b",值是长度为6的字符串"banana"。

4.4.6.INTSET

​ 如果TYPE的值为REDIS_ RDB_ TYPE SET_ INTSET, 那么value保存的就是一个整数集合对象,RDB文件保存这种对象的方法是,先将整数集合转换为字符串对象,然后将这个字符串对象保存到RDB文件里面。
​ 如果程序在读人RDB文件的过程中,碰到由整数集合对象转换成的字符串对象,那么程序会根据TYPE值的指示,先读入字符串对象,再将这个字符串对象转换成原来的整数集合对象。

4.4.7.ZIPLIST

​ 如果TYPE的值为REDIS_ RDB_ TYPE LIST ZIPLIST、 REDIS_ RDB_ TYPE HASHZIPLIST或者REDIS_ RDB_ TYPE ZSET ZIPLIST, 那么value保存的就是-一个压缩列表对象,RDB文件保存这种对象的方法是:
​ 1)将压缩列表转换成一个字符串对象。
​ 2)将转换所得的字符串对象保存到RDB文件。
​ 如果程序在读人RDB文件的过程中,碰到由压缩列表对象转换成的字符串对象,那么程序会根据TYPE值的指示,执行以下操作:
​ 1)读入字符串对象,并将它转换成原来的压缩列表对象。
​ 2)根据TYPE的值,设置压缩列表对象的类型:如果TYPE的值为REDIS_ RDB_TYPE LIST_ ZIPLIST, 那么压缩列表对象的类型为列表;如果TYPE的值为REDIS_RDB_TYPE_HASH_ZIPLIST, 那么压缩列表对象的类型为哈希表;如果TYPE的值为REDIS_RDB_TYPE_ZSET_ ZIPLIST, 那么压缩列表对象的类型为有序集合
​ 从步骤2可以看出,由于TYPE的存在,即使列表、哈希表和有序集合三种类型都使用压缩列表来保存,RDB读入程序也总可以将读入并转换之后得出的压缩列表设置成原来的类型。

参考:《Redis设计与实现》

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值