RDB持久化
2023-4-1
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property

 
Redis是一个键值对数据库服务器,服务器中通常包含着任意个非空数据库,而每个非空数据库中又可以包含任意个键值对:
notion image
因为Redis 是内存数据库,它将自己的数据库状态存储在内存里,所以如果办法将存储在内存中的数据库状态保存到磁盘里,那么一旦服务器进程退出,服务器中的数据库状态也会消失不见。为了解决这个问题,Redis提供了RDB持久化功能,这个功能可以将Redis在内存中的数据库状态保存到磁盘里,避免数据意外丢失。
RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,可以将某个时间点上的数据库状态保存到一个RDB文件中:
notion image
notion image
RDB持久化所生成的RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。
 

RDB 文件的创建与载入

Redis使用 SAVE 和 BGSAVE 命令生成 RDB 文件;
  • SAVE:会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕为止,阻塞期间服务器不能处理任何命令请求
    • BGSAVE:会派生一个子进程,由指进程负责创建 RDB 文件,这样可以避免主线程的阻塞,父进程继续处理命令请求:
      • BGSAVE 执行期间,会发生以下特殊情况:
      • 在 BGSAVE 命令执行期间,客户端发送 SAVE 和 BGSAVE 命令会被服务器拒绝,防止产生竞争条件。客户端发送 BGREWRITEAOF 命令会被延迟;
      • 在 BGREWRITEAOF 命令执行期间,客户端发送 BGSAVE 命令会被服务器拒绝;
     
    notion image
    创建 RDB 文件由rdb.c/rdbSave函数完成,save命令和bgsave命令会以不同的方式调用这个函数。
    载入RDB文件由rdb.c/rdbLoad函数完成,RDB文件的载入工作是在服务器启动时自动执行,Redis没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,就会自动载入RDB文件;
     
    注:AOF 文件的更新频率通常比 RDB 文件更新频率高:
    notion image
    • 当服务器开启了AOF持久化功能时,会优先使用AOF文件还原数据库状态
    • 当服务器关闭了AOF持久化功能时,才会使用RDB文件来还原数据库状态
    服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止;
     

    自动间隔性保存

    因为bgsave命令可以在不阻塞服务器进程的状况下执行,所以Redis允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次bgsave命令。可以有多个条件,当其中一个被满足了,就被执行bgsave 操作:

    设置保存条件

    Redis服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save选项,如果没有主动设置save选项,那么服务器会为save选项设置默认条件。
    服务器会根据 save 选项所设置的保存值,设置服务器状态 redisServer结构的 saveparams 属性;
    notion image

    dirty计数器和lastsave属性

    除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性
    • dirty计数器记录距离上一次成功执行SAVE命令或者BGNAME命令之后,服务器对数据库状态进行了多少次修改;当服务器成功执行一个数据库修改命令之后,程序就会对dirty计数器进行更新
    • lastsave属性是一个UNIX时间戳,记录了服务器上一次成功执行SAVE命令或BGSAVE命令的时间;
     

    检查保存条件是否满足

    Redis的服务器周期性操作函数serverCron默认每隔100ms会执行一次,其中包括检查save选项所设置的保存条件是否满足(遍历并检查saveparams数组中的所有保存条件),满足则执行BGSAVE命令;
     
     

    执行快照时,数据能被修改吗

    执行bgsave过程中,由于是交给子进程来构建 RDB 文件,主线程还是可以继续工作的,此时主线程可以修改数据吗?如果不可以修改数据的话,那这样性能一下就降低了很多。如果可以修改数据,又是如何做到到呢?
    执行bgsave过程中,Redis依然可以继续处理操作命令的,也就是数据是能被修改的。关键的技术就在于写时复制技术(Copy-On-Write, COW)。
    执行bgsave命令的时候,会通过fork()创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。
    notion image
    notion image
    只有在发生修改内存数据的情况时,物理内存才会被复制一份。这样的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。
    所以,创建bgsave子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程(父进程)里的内存数据,并将数据写入到RDB文件。
    • 当主线程(父进程)对这些共享的内存数据也都是只读操作,那么,主线程(父进程)和bgsave子进程相互不影响。
    • 如果主线程(父进程)要修改共享数据里的某一块数据(比如键值对A)时,就会发生写时复制,于是这块数据的物理内存就会被复制一份(键值对A,然后主线程在这个数据副本(键值对A)进行修改操作。与此同时,bgsave子进程可以继续把原来的数据(键值对 A)写入到 RDB 文件
    就是这样,Redis使用bgsave对当前内存中的所有数据做快照,这个操作是由bgsave子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。
    bgsave快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的bgsave快照。
    所以 Redis 在使用bgsave快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程(父进程)的内存数据和子进程的内存数据已经分离了,子进程写入到 RDB 文件的内存数据只能是原本的内存数据。
    如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。
    另外,写时复制的时候会出现这么个极端的情况:在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。那么极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。所以,针对写操作多的场景,要留意下快照过程中内存的变化,防止内存被占满了。
     

    RDB文件结构

    一个完整 RDB 文件所包含的各个部分:
    notion image
    • RDB文件的最开头是REDIS部分, 这个部分的长度为5字节, 保存着"REDIS"五个字符。 通过这五个字符, 程序可以在载入文件时, 快速检查所载入的文件是否RDB文件。注:因为 RDB 文件保存的是二进制数据, 而不是 C 字符串, 为了简便起见, 用 "REDIS" 符号代表 'R' 、 'E' 、 'D' 、 'I' 、 'S'五个字符, 而不是带 '\0' 结尾符号的 C 字符串 'R' 、 'E' 、 'D' 、 'I' 、 'S' 、 '\0' 。
    • db_version长度为4字节, 它的值是一个字符串表示的整数, 记录了RDB件的版本号, 比如"0006"就代表 RDB 文件的版本为第六版
    • databases部分包含着零个或任意多个数据库, 以及各个数据库中的键值对数据:
      • 如果服务器的数据库状态为空(所有数据库都是空的), 那么这个部分也为空, 长度为0字节
      • 如果服务器的数据库状态为非空(有至少一个数据库非空), 那么这个部分也为非空, 根据数据库所保存键值对的数量、类型和内容不同, 这个部分的长度也会有所不同
    • EOF常量的长度为1字节, 这个常量标志着RDB文件正文内容的结束, 当读入程序遇到这个值的时候, 它知道所有数据库的所有键值对都已经载入完毕了。
    • check_sum是一个8字节长的无符号整数, 保存着一个校验和, 这个校验和是程序通过对REDISdb_version、 databasesEOF四个部分的内容进行计算得出的。 服务器在载入RDB文件时, 会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比, 以此来检查RDB 文件是否有出错或者损坏的情况出现。
     
    一个 databases 部分为空的 RDB 文件:
    notion image

    databases 部分

    一个 RDB 文件的 databases 部分可以保存任意多个非空数据库。比如说, 如果服务器的 0 号数据库和 3 号数据库非空:
    notion image
    那么database 0 代表 0 号数据库中的所有键值对数据,  database 3 代表 3 号数据库中的所有键值对数据。
    每个非空数据库在RDB文件中都可以保存为SELECTDB 、db_numberkey_value_pairs三个部分:
    notion image
    • SELECTDB常量的长度为 1 字节, 当读入程序遇到这个值的时候, 它知道接下来要读入的将是一个数据库号码。
    • db_number保存着一个数据库号码, 根据号码的大小不同, 这个部分的长度可以是1字节、2字节或者5字节。 当程序读入db_number部分之后, 服务器会调用SELECT命令, 根据读入的数据库号码进行数据库切换, 使得之后读入的键值对可以载入到正确的数据库中。
    • key_value_pairs部分保存了数据库中的所有键值对数据, 如果键值对带有过期时间, 那么过期时间也会和键值对保存在一起。 根据键值对的数量、类型、内容、以及是否有过期时间等条件的不同,key_value_pairs部分的长度也会有所不同。
     
    一个完整的 RDB 文件, 文件中包含了 0 号数据库和 3 号数据库:
    notion image
     

    key_value_pairs部分

    RDB 文件中的每个 key_value_pairs 部分都保存了一个或以上数量的键值对, 如果键值对带有过期时间的话, 那么键值对的过期时间也会被保存在内。
    不带过期时间的键值对在 RDB 文件中对由 TYPE 、 key 、 value 三部分组成:
    notion image
    • TYPE 记录了 value 的类型, 长度为 1 字节, 值可以是以下常量的其中一个:
      • REDIS_RDB_TYPE_STRING
      • REDIS_RDB_TYPE_LIST
      • REDIS_RDB_TYPE_SET
      • REDIS_RDB_TYPE_ZSET
      • REDIS_RDB_TYPE_HASH
      • REDIS_RDB_TYPE_LIST_ZIPLIST
      • REDIS_RDB_TYPE_SET_INTSET
      • REDIS_RDB_TYPE_ZSET_ZIPLIST
      • REDIS_RDB_TYPE_HASH_ZIPLIST
      • 当服务器读入 RDB 文件中的键值对数据时, 程序会根据 TYPE 的值来决定如何读入和解释 value 的数据。
    • key 和 value 分别保存了键值对的键对象和值对象:
      •  key总是一个字符串对象, 它的编码方式和REDIS_RDB_TYPE_STRING类型的value一样。 根据内容长度的不同,key的长度也会有所不同
      • 根据TYPE类型的不同, 以及保存内容长度的不同, 保存value的结构和长度也会有所不同, 本节稍后会详细说明每种TYPE类型的value结构保存方式
     
    带有过期时间的键值对在 RDB 文件中的结构:
    notion image
    • EXPIRETIME_MS 常量的长度为 1 字节, 它告知读入程序, 接下来要读入的将是一个以毫秒为单位的过期时间。
    • ms 是一个 8 字节长的带符号整数, 记录着一个以毫秒为单位的 UNIX 时间戳, 这个时间戳就是键值对的过期时间。
     
     

    value 的编码

    RDB 文件中的每个 value 部分都保存了一个值对象, 每个值对象的类型都由与之对应的 TYPE 记录, 根据类型的不同, value 部分的结构、长度也会有所不同。
    字符串对象
    如果 TYPE 的值为 REDIS_RDB_TYPE_STRING , 那么 value 保存的就是一个字符串对象, 字符串对象的编码可以是 REDIS_ENCODING_INT 或者REDIS_ENCODING_RAW 。
    如果字符串对象的编码为 REDIS_ENCODING_INT , 那么说明对象中保存的是长度不超过 32 位的整数, 这种编码的对象:
    notion image
     ENCODING 的值可以是 REDIS_RDB_ENC_INT8 、 REDIS_RDB_ENC_INT16 或者 REDIS_RDB_ENC_INT32 三个常量的其中一个, 它们分别代表 RDB 文件使用 8 位(bit)、 16 位或者 32 位来保存整数值 integer 。
     
    如果字符串对象的编码为 REDIS_ENCODING_RAW , 那么说明对象所保存的是一个字符串值, 根据字符串长度的不同, 有压缩和不压缩两种方法来保存这个字符串:
    • 如果字符串的长度小于等于 20 字节, 那么这个字符串会直接被原样保存。
    • 如果字符串的长度大于 20 字节, 那么这个字符串会被压缩之后再保存。
    注:以上两个条件是在假设服务器打开了 RDB 文件压缩功能的情况下进行的, 如果服务器关闭了 RDB 文件压缩功能, 那么 RDB 程序总以无压缩的方式保存字符串值。
    具体信息可以参考 redis.conf 文件中关于 rdbcompression 选项的说明。
    没有被压缩的字符串:
    notion image
    其中, string 部分保存了字符串值本身,而 len 保存了字符串值的长度。
     
    压缩后的字符串:
    notion image
    REDIS_RDB_ENC_LZF 常量标志着字符串已经被 LZF 算法压缩过了, 读入程序在碰到这个常量时, 会根据之后的 compressed_len 、 origin_len 和 compressed_string 三部分, 对字符串进行解压缩: 其中 compressed_len 记录的是字符串被压缩之后的长度, 而 origin_len 记录的是字符串原来的长度, compressed_string 记录的则是被压缩之后的字符串。
     
     
    列表对象
    如果 TYPE 的值为 REDIS_RDB_TYPE_LIST , 那么 value 保存的就是一个 REDIS_ENCODING_LINKEDLIST 编码的列表对象:
    notion image
    list_length 记录了列表的长度, 它记录列表保存了多少个项(item), 读入程序可以通过这个长度知道自己应该读入多少个列表项。
    图中以 item 开头的部分代表列表的项, 因为每个列表项都是一个字符串对象, 所以程序会以处理字符串对象的方式来保存和读入列表项。
     
    一个包含三个元素的列表:
    notion image
    结构中的第一个数字 3 是列表的长度, 之后跟着的分别是第一个列表项、第二个列表项和第三个列表项, 其中:
    • 第一个列表项的长度为 5 , 内容为字符串 "hello" 
    • 第二个列表项的长度也为 5 , 内容为字符串 "world" 
    • 第三个列表项的长度为 1 , 内容为字符串 "!" 
     
     
    集合对象
    如果 TYPE 的值为 REDIS_RDB_TYPE_SET , 那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象:
    notion image
    set_size 是集合的大小, 它记录集合保存了多少个元素, 读入程序可以通过这个大小知道自己应该读入多少个集合元素。
    图中以 elem 开头的部分代表集合的元素, 因为每个集合元素都是一个字符串对象, 所以程序会以处理字符串对象的方式来保存和读入集合元素。
     
    一个包含四个元素的集合:
    notion image
    结构中的第一个数字 4 记录了集合的大小, 之后跟着的是集合的四个元素:
    • 第一个元素的长度为 5 ,值为 "apple" 。
    • 第二个元素的长度为 6 ,值为 "banana" 。
    • 第三个元素的长度为 3 ,值为 "cat" 。
    • 第四个元素的长度为 3 ,值为 "dog" 。
     
     
    哈希表对象
    如果 TYPE 的值为 REDIS_RDB_TYPE_HASH , 那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象:
    notion image
    • hash_size 记录了哈希表的大小, 也即是这个哈希表保存了多少键值对, 读入程序可以通过这个大小知道自己应该读入多少个键值对。
    • 以 key_value_pair 开头的部分代表哈希表中的键值对, 键值对的键和值都是字符串对象, 所以程序会以处理字符串对象的方式来保存和读入键值对。
     
    结构中的每个键值对都以键紧挨着值的方式排列在一起:
    notion image
    因此, 从更详细的角度看,的结构可以进一步修改为:
    notion image
    一个包含两个键值对的哈希表:
    notion image
    在这个示例结构中, 第一个数字 2 记录了哈希表的键值对数量, 之后跟着的是两个键值对:
    • 第一个键值对的键是长度为 1 的字符串 "a" , 值是长度为 5 的字符串 "apple" 。
    • 第二个键值对的键是长度为 1 的字符串 "b" , 值是长度为 6 的字符串 "banana" 。
     
     
    有序集合对象
    如果 TYPE 的值为 REDIS_RDB_TYPE_ZSET , 那么 value 保存的就是一个 REDIS_ENCODING_SKIPLIST 编码的有序集合对象:
    notion image
    sorted_set_size 记录了有序集合的大小, 也即是这个有序集合保存了多少元素, 读入程序需要根据这个值来决定应该读入多少有序集合元素。
    以 element 开头的部分代表有序集合中的元素, 每个元素又分为成员(member)和分值(score)两部分, 成员是一个字符串对象, 分值则是一个 double 类型的浮点数, 程序在保存 RDB 文件时会先将分值转换成字符串对象, 然后再用保存字符串对象的方法将分值保存起来。
     
    有序集合中的每个元素都以成员紧挨着分值的方式排列:
    notion image
    因此, 从更详细的角度看,结构可以进一步修改为:
    notion image
     
    一个带有两个元素的有序集合:
    notion image
    第一个数字 2 记录了有序集合的元素数量, 之后跟着的是两个有序集合元素:
    • 第一个元素的成员是长度为 2 的字符串 "pi" , 分值被转换成字符串之后变成了长度为 4 的字符串 "3.14" 。
    • 第二个元素的成员是长度为 1 的字符串 "e" , 分值被转换成字符串之后变成了长度为 3 的字符串 "2.7" 。
     
     
    INTSET 编码的集合
    如果 TYPE 的值为 REDIS_RDB_TYPE_SET_INTSET , 那么 value 保存的就是一个整数集合对象, RDB 文件保存这种对象的方法是, 先将整数集合转换为字符串对象, 然后将这个字符串对象保存到 RDB 文件里面。
    如果程序在读入 RDB 文件的过程中, 碰到由整数集合对象转换成的字符串对象, 那么程序会根据 TYPE 值的指示, 先读入字符串对象, 再将这个字符串对象转换成原来的整数集合对象。
     
    ZIPLIST 编码的列表、哈希表或者有序集合
    如果 TYPE 的值为 REDIS_RDB_TYPE_LIST_ZIPLIST 、 REDIS_RDB_TYPE_HASH_ZIPLIST 或者 REDIS_RDB_TYPE_ZSET_ZIPLIST , 那么 value 保存的就是一个压缩列表对象, RDB 文件保存这种对象的方法是:
    1. 将压缩列表转换成一个字符串对象。
    1. 将转换所得的字符串对象保存到 RDB 文件。
    如果程序在读入 RDB 文件的过程中, 碰到由压缩列表对象转换成的字符串对象, 那么程序会根据 TYPE 值的指示, 执行以下操作:
    1. 读入字符串对象,并将它转换成原来的压缩列表对象。
    1. 根据 TYPE 的值,设置压缩列表对象的类型: 如果 TYPE 的值为 REDIS_RDB_TYPE_LIST_ZIPLIST , 那么压缩列表对象的类型为列表; 如果TYPE 的值为 REDIS_RDB_TYPE_HASH_ZIPLIST , 那么压缩列表对象的类型为哈希表; 如果 TYPE 的值为 REDIS_RDB_TYPE_ZSET_ZIPLIST , 那么压缩列表对象的类型为有序集合。
    从步骤 2 可以看出, 由于 TYPE 的存在, 即使列表、哈希表和有序集合三种类型都使用压缩列表来保存, RDB 读入程序也总可以将读入并转换之后得出的压缩列表设置成原来的类型。
  • Redis
  • 数据库AOF持久化
    目录