RDB全称是 Redis Database
RDB 持久化就像是给 Redis 的整个内存做了一个快照,然后把这个快照持久化到一个 .rdb 文件中。Redis 在重启时,可以通过加载 RDB 文件快速恢复 Redis 内存数据,但是需要说明的是,由于 RDB 持久化的快照特性,Redis 会丢失最后一次 RDB 文件到重启之间的数据,如下图所示,蓝色部分的数据在 RDB 持久化的时候已经被保存下来了,但是红色部分的数据,会因为宕机而丢失。所以需要后面介绍的另一种持久化方式,也就是 AOF,来辅助实现 Redis 崩溃后的完整数据恢复。
触发 RDB 持久化
1、手动触发
主要是通过 SAVE 和 BGSAVE 两个命令;
-
SAVE 命令
会使用 Redis 主线程进行 RDB 持久化,这段时间会造成整个 Redis 不可用,所以生产环境中是绝不能使用 SAVE 命令的; -
BGSAVE 命令
则是 fork 出一个子进程来进行 RDB 持久化,并不会阻塞主线程,也被称为“后台 RDB 持久化”。 -
CONFIG SET SAVE命令
: 例如 CONFIG SET SAVE “3600 3”, 这条命令,就可以把 RDB 持久化的触发条件设置为:距离上次 RDB 持久化超过 1 小时 且 有 3 个 Key 被修改过,就会触发 RDB 持久化
bgsave流程图如下所示
SAVE 和 BGSAVE 的对比
对比项 | SAVE | BGSAVE |
---|---|---|
执行方式 | 同步(主线程执行) | 异步(fork 子进程) |
是否阻塞 Redis | 是(阻塞所有操作) | 否(主线程继续处理请求) |
性能影响 | 高(影响线上性能) | 低(但 fork 时会短暂消耗 CPU 和内存) |
适用场景 | 小规模数据、紧急备份 | 大规模数据、定期备份 |
2、自动触发
-
配置 bgsave 规则, 满足 任意一条 规则,Redis 就会执行 BGSAVE, 触发 BGSAVE 后,计数器会重置
save 900 1 # 900秒(15分钟)内至少有1次写操作 save 300 10 # 300秒(5分钟)内至少有10次写操作 save 60 10000 # 60秒(1分钟)内至少有10000次写操作 save "" # 禁用自动 RDB
-
SHUTDOWN命令关闭redis服务
- 如果 AOF 关闭,Redis 会触发 SAVE,将数据写入 RDB
- 如果 AOF 开启,Redis 仅依赖 AOF,不会执行 RDB。
-
复制(主从同步)
在 Redis 主从复制(Replication)过程中,如果从节点(slave)执行 FULL SYNC,主节点(master)会触发 BGSAVE,然后将 RDB 文件发送给从节点: 命令: slaveof <master_ip> <master_port>
- DEBUG RELOAD: 在调试 Redis 时,可以使用 DEBUG RELOAD 命令触发 BGSAVE
2、持久化关键
无论是手动触发还是自动触发, 都会在磁盘上生成一个 .rdb 后缀的文件,这也是 RDB 持久化的核心逻辑所在。
- 首先, 打开一个名为"temp-进程号.rdb"的文件,后续 Redis 产生的整个快照数据,都会写到这个文件中。
- 接下来, 将全部 Redis 中的全部数据,按照 RDB 的格式写入到 .rdb 临时文件中
- 在数据写入完成之后, 会将数据完全刷到磁盘上,防止数据残存在应用缓冲区或者 OS 缓冲区中,然后才会关闭这个 .rdb 临时文件。
- 最后,就是把临时文件变成正式的 rdb 文件, 将 .rdb 临时文件重命名为 dbfilename 配置项指定的文件名,默认是 dump.rdb。
- IO 操作完成之后, 还要做一些善后操作, 清理计算RDB周期性触发条件的字段
刷盘优化
在每次写入数据的时候, 会判断是否开启了sync上限(对应 rdb-save-incremental-fsync 配置项,默认开启)如果设置了的话,会开启增量刷盘的功能。 具体原理是:在每次写入数据的时候, 会先添加到缓存buffered中, 当buffered超过了sync上限时, 就好会触发一次刷盘动作(fsync/fflush), 防止整个 RDB 文件进行刷盘导致磁盘 IO 出现尖刺。buffered的大小不能通过配置调整, redis7中是4M, redis6及以前是32M。
换句话就是, 将rdb一次的全量的刷盘动作替换成多次小数据量的刷盘动作(默认4M), 对于IOPS高的磁盘, 优化效果不明显哈。
RDB文件格式
**RDB 文件中最基本的格式就是:一个 OpCode 字节,然后紧跟一段负载数据。如下图所示:
OpCode | Desc |
---|---|
0xFA | 在 0xFA 后面紧跟的是一个 AUX 键值对,用来在 RDB 文件头中记录一些元数据信息 |
0xFE | 在 0xFE 后面紧跟的是数据库的编号,用来标记后续数据归属的 redisDb |
0xFB | 在 0xFB 后面紧跟的是数据库中 Key 的个数以及设置了过期时间的 Key 的个数 |
0xFD、0xFC | 这两个 OpCode 后面紧跟的是 Key 的过期时间,0xFD 后面紧跟的是秒级时间戳,0xFC 后面紧跟的是毫秒级时间戳 |
0xF8、0xF9 | 0xF8 后面紧跟的是 LRU 内存淘汰策略下的秒级空闲时间,0xF9 后面紧跟的是 LFU 内存淘汰策略下的访问频率 |
0xFF | RDB 文件的结束符 |
0xF5 | 在 0xF5 后面紧跟的是一个 Function 的内容 |
RDB 文件的格式大致可以分为文件头部分、数据部分、文件结尾部分。
RDB 文件头
RDB 的文件头部分可以大致分为魔数、AUX 元数据两部分。
魔数默认是 “REDIS” 字符串,紧跟其后的是 RDB 的版本号,是一个四位的字符串,Redis 7 中使用的RDB 版本是 “0010”。写入到 RDB 文件中的格式如下:
RDB 文件头的第二部分是 AUX 键值对部分, AUX 键值对其实就是一些辅助字段,主要包含如下
- redis-ver:Redis 的准确版本号,例如我们分析的 7.0.0 版本。
- redis-bits:当前生成的 RDB 文件的机器是 64 位还是 32 位机器。
- ctime:RDB 文件的创建时间。
- used-mem:当前 Redis 实例使用内存大小。
- aof-base:当前这个 RDB 文件是不是混合持久化的一部分。
- 还有一些主从复制相关的内容,这里就不展开一一介绍了。
写入单个 AUX 键值对会先写入 0xFA 这个 OpCode,然后再写入 AUX Key 和 AUX Value 值,写入之后的效果如下图所示:
说明: 其中redis-ver的key值
, redis-ver的值
是对下面一行内容的解释说明, redis-bits的key值
、ctime的key值
等都是, 真正写入的数据是0xFA处在的那行数据。
内容编码
redis对rdb文件写入的长度值和字符串有一套编码方式
RDB 采用变长的方式来编码长度值
,具体实现位于 rdbSaveLen() 函数中,它使用第一个 byte 作为标识,具体规则如下
- 如果一个长度值在 [0, 63] 这个范围,RDB 会用一个字节来存储,其中高 2 位作为标识位,填充 00。
- 如果一个长度值在 [64, 16383] 这个范围,RDB 会用两个字节来进行存储,其中第一个字节的高两位填充 01 作为标识,第一个字节的剩余 6 位以及第二个字节,一起表示一个整数。
- 如果长度值在 [16384, 2^32-1] 这个范围,RDB 会用 5 个字节来进行存储,其中第一个字节填充 0x80 作为标识,使用接下来的 4 个字节表示一个 32 位的整数。
- 如果长度值在 [2^32, 2^64-1] 这个范围,RDB 会用 9 个字节来进行存储,其中第一个字节填充 0x81 作为标识,使用接下来的 8 个字节表示一个 64 位的整数。
RDB 中的长度编码方式主要是用来表示字符串长度、元素个数等等,而这些值大概率不会特别大,所以通过上述变长的编码方式,可以有效减小整数所占字节数。
接下来看 RDB 对字符串
的编码方式,目前有三种编码方式,分别是:长度前缀编码方式、整型编码方式、LZF 压缩编码方式。这三种编码方式有个相同点,就是都先以整数来表示字符串长度,然后紧跟具体存储的字符串。
-
长度前缀编码方式的特点是第一个字节最高 2 位为 00、01、10(0x80 和 0x81 的高两位就是 10),也就是上面介绍长度编码方式,然后根据长度编码方式确定字符串长度之后,后面才是字符串的真正内容。例如上面 AUX 中 redis-ver 这个 Key,在 0xFA 这个 OpCode 之后,紧跟的是 0x09,最高 2 位为 00,也就是当前剩余 6 位表示整数,那么后面紧跟的 9 个字节就是字符串的真正内容。
-
整型编码方式的特点是用来存储一个整型的字符串,它第一个字节的最高 2 位为 11 ,这样就和长度值区分开了。整型编码中第一字节剩余 6 位的可选值有 0、1、2,分别表示它之后紧跟一个 8、16、32 位的整数,分别使用一字节、两字节、四字节进行存储。例如,上面 AUX 中 redis-bits 对应的 Value 值 64,64 这个值本身用一个字节存储就可以了,所以这个 Value 在 RDB 中总共占 2 个字节,第一个字节是 0xC0(1100 0000)标识字节,第二个字节是 64 这个值本身。
-
LZF 压缩编码方式的特点是第一个字节中高 2 位是 11 ,剩余 6 位构成的数字为 3,然后使用长度编码方式存储两个整数,一个是压缩后的字符串长度(compress_len),一个是压缩前的字符长度(original_len),最后紧跟 compress_len 个字节来存储压缩后的字符串。在读取的时候,就可以通过压缩前后的长度以及压缩后的字符串内容,还原压缩前的字符串。
RDB 数据部分
紧跟在 RDB 文件头之后的部分是 RDB 数据区域的部分。
遍历 Redis 中的全部数据库, 将每个数据库中的键值对数据,全部写入到 RDB 文件的数据部分
-
写入 Redis 中第一个数据库的编号,以我们最常用的 0 号 redisDb 为例,在这里就会先写入 0xFE 这个 OpCode ,然后按照长度编码方式,写入数字 0。
-
写入第一个数据库的容量信息0xFB ,然后按照长度编码方式写入该 redisDb 中 Key 的个数以及设置了过期时间的 Key 的个数。下图展示了编号为 0 的 redisDb 中,总共有 10 Key,其中有 5 个 Key 设置了过期时间的场景下,持久化到 RDB 文件时的格式:
-
完成的数据库编号和容量的写入之后, 然后把 redisDb->dict 集合中的全部键值对数据,写入到 RDB 文件中。在 RDB 文件中,一个键值对由 5 部分组成,按照写入顺序依次如下。
-
第一部分,Key 过期时间。如果 Key 设置了过期时间,则需要在 RDB 中存储其过期时间戳。在 Redis 7 版本中,写入过期时间都是以毫秒为单位,所以这里是以 0xFC 开头,之后的 8 个字节用来存储 Key 的毫秒级过期时间。
-
第二部分,LRU/LFU 信息。如果当前开启了内存淘汰策略,则需要将 Value 中 lru 字段记录的信息持久化到 RDB 中。如果当前使用的是 LRU 淘汰策略,则先写入 0xF8 这个 OpCode 作为开头,紧跟在其后的就是长度编码方式得到的秒级空闲时间戳;如果当前使用的是 LFU 淘汰算法,则先写入 0xF9 这个 OpCode 作为开头,紧跟在其后的就是 lru 字段中存储的访问频率。如果未开启内存淘汰,则没有这部分信息。
-
第三部分,Value 类型。这里写入一个 1 字节来作为 Value 类型的标识符,下表展示了所有 Redis Value 类型对应的标识符:
value 类型标识 宏 对应的 value 类型 0 RDB_TYPE_STRING String 类型 18(旧版本用 14) RDB_TYPE_LIST_QUICKLIST_2(旧版本对应 RDB_TYPE_LIST_QUICKLIST) Quicklist 类型 2 RDB_TYPE_SET Set 类型 11 RDB_TYPE_SET_INTSET Intset 类型 5(旧版本用 3) RDB_TYPE_ZSET_2(旧版本对应 RDB_TYPE_ZSET) ZSet 类型 17(旧版本用 12) RDB_TYPE_ZSET_LISTPACK(旧版本对应 RDB_TYPE_ZSET_ZIPLIST) ZSet in listpack 类型(旧版本对应 ZSet in Ziplist 类型) 4 RDB_TYPE_HASH Hash 类型 16(旧版本用 13) RDB_TYPE_HASH_LISTPACK(旧版本对应 RDB_TYPE_HASH_ZIPLIST) Hash in listpack 类型(旧版本对应 Hash in Ziplist 类型) 19(旧版本用 15) RDB_TYPE_STREAM_LISTPACKS_2(旧版本对应 RDB_TYPE_STREAM_LISTPACKS) Stream 类型 7(旧版本用 6) RDB_TYPE_MODULE_2(旧版本对应 RDB_TYPE_MODULE) Module 类型 -
第四部分,Key 值。使用字符串编码方式存储 Key 值。
-
第五部分,Value 值。依据上面的 Value 类型,使用不同的编码方式存储 Value 类型。
-
其它类型的编码方式
listpack类型
listpack 是多个 Redis 类型的底层结构,所以这里先来看 listpack
数据结构的编码方式。,Redis 会按照字符串的方式编码写入一个 listpack。上表中的ZSet in listpack 类型、Hash in listpack 类型以及 Quicklist 类型的底层都可能使用 listpack 作为底层存储结构。
下面以 quicklist 为例简单分析一下。Redis 会先按照长度编码方式写入 quicklist 中的节点个数,然后写入每个节点的 listpack,这里会写入两部分,第一部分是当前节点是否为压缩节点,第二部分才是按照字符串编码方式写入节点中的 listpack。需要注意的是,如果遇到压缩节点,RDB 不会解压,而是直接按照 LZF 压缩字符串的编码方式将整个压缩节点写入到 RDB 文件中;如果是普通节点,则按照正常的字符串编码方式处理整个 quicklistNode 节点中的数据并写入到 RDB 文件中,如果普通节点中的 listpack 超过了 20 个字节,且当前开启了 rdbcompression 配置项,写入的时候就需要进行 LZF 压缩.
Set 类型
Set 类型(RDB_TYPE_SET)的编码方式与 Quicklist 类型的编码方式基本类似,也是先写入 dict 中键值对的个数,然后迭代 dict 将 Key 按照字符串编码方式写入到 RDB 文件中。如果 Set 底层结构是 intset,对应的是 Intset 类型(OBJ_ENCODING_INTSET)的编码方式,它会直接将 intset 按照字符串的编码方式进行写入。
ZSet 类型
ZSet 底层使用 listpack 进行存储时(RDB_TYPE_ZSET_LISTPACK),会直接按照上述 listpack 的方式进行编码并存储。ZSet 底层使用 skiplist 结构的时候(RDB_TYPE_ZSET_2),会先按照长度编码方式写入 skiplist 中的元素个数,然后写入 skiplist 中的每个元素,其中会按照字符串编码方式写入元素值,后面紧跟其对应的 score 值(double 值)。注意,这里写入 skiplist 的时候是从后向前写入的,这样在读取的时候,每次读取到的值都比之前的大,使用头插法完成插入,就可以得到一个有序的 skiplist 了。
Hash类型
Hash 底层使用 listpack 结构时(RDB_TYPE_HASH_ZIPLIST),会直接按照 listpack 方式进行编码并存储。Hash 底层使用 dict 存储的时候(RDB_TYPE_HASH),会先按照长度编码方式写入 dict 中的键值对个数,然后按照字符串编码方式写入 dict 中的 Key 和 Value。
Stream 的编码方式是先写入到 rax 树的节点树,然后使用字符串编码方式,写入每个节点的 Key 和对应的 listpack
RDB 文件结尾部分
在将全部数据库都写入完成之后,会开始写入 RDB 文件的结尾部分,其中包含了 RDB 文件结束符,也就是前文介绍的 0xFF OpCode,以及一个 8 字节的校验和
最后,我们通过一张图来看一下 RDB 文件的完整格式:
image-20250403095304622.png
持久化过程
使用 fork() 调用创建一个子进程,并在子进程中调用rdbSave() 函数,完成后台的 RDB 持久化操作, 这个子进程可以是用来进行 RDB 持久化的,也可以用来做 AOF Rewrite 操作的,或是其他 Module 需要的后台操作。但是,Redis 同一时刻只能有一个子进程。
通过 fork 创建出来的子进程与父进程运行在不同的内存空间中,在子进程刚刚创建出来时(即 fork() 调用结束时),父子进程的内存数据完全相同,宛如拷贝了一份,后续父子线程对内存操作以及文件的映射都是在各自的内存中完成的,两者不会相互影响。正是由于这一“宛如拷贝”
的特性,Redis 可以在子进程的内存空间中,安全地把数据写入到 RDB 文件中,与此同时,父进程也可以继续对自己的内存进行读写操作,做到不阻塞父进程的命令执行,也不干扰子进程的 RDB 文件生成。
虽然父子进程内存中的数据互不影响,就像拷贝了一份一样,但是如果 fork() 调用真的拷贝了整个父进程的内存,对于 Redis 来说是不可接受的,主要体现在两个方面:一个是 Redis 服务占用的内存将会翻倍,另一个是 fork() 调用要拷贝整个内存,耗时会很长,阻塞主线程执行其他命令,整个 Redis 就不可用了。
Redis 之所以还是使用 fork() 来创建子进程,是因为 fork 出来的子进程使用了 Copy-on-Write 的方式进行内存拷贝,而非全量内存拷贝。Copy-on-Write 的原理是将内存拷贝操作推迟到内存真正发生修改时再进行,那些父子进程完全相同的内存页,实际上只有一份,这也就避免了无意义的拷贝操作。通过前文介绍的 RDB 文件写入过程我们也知道,这里启动的子进程只会读取内存,不会进行任何修改,所以只有在父进程执行修改命令的时候,才会触发 Copy-on-Write 操作。从另一个角度看,Redis 多数被应用到读多写少的场景中,也就使得 Copy-on-Write 机制更大程度地发挥作用。注意,Copy-On-Write 的最小单位内存页,而不是键值对。
Copy-On-Write 相关优化
Redis 使用子进程进行 RDB 持久化不会造成整个内存占用量翻倍的原因,是系统使用 Copy-On-Write 技术。但是,如果 Redis 是使用在写非常多的场景里面,Copy-On-Write 带来的好处就有所减少,因为父进程修改的内存页,在内存中会有两份,写操作多了,这种页面也就多了,也就会导致 Redis 的内存占用量增加
这个时候,Redis 从另一个角度去优化子进程中的 RDB 持久化操作:释放一些子进程不要再使用的内存页。举个例子,在子进程进行 RDB 持久化的时候,一个内存页出现了 Copy-On-Write 的情况,在子进程把这个内存页中全部的键值对都持久化到了 RDB 文件之后,子进程其实就没有必要继续持有这个内存页了,只需主进程保留该内存页的最新拷贝即可
父子进程通信
在fork过程中, 会打开一个父子进程通信的管道, 管道创建后得到的两个文件描述符会记录到长度为2的数组child_info_pipe中, 子线程会向child_info_pipe[1]中记录的文件描述符写入数据, 父进程从 child_info_pipe[0] 中记录的文件描述符读取数据,这样就实现了父子进程的交互。大概的模型如下图所示:
rdb时 子线程每持久化 1024 个 Key 时,通知主进程 RDB 持久化的进度(两次通知间隔超过1s), 主线程每隔1s读取子线程发来的信息,并更新相应统计字段。
那父子进程中传递的数据是什么呢?
1、下面是子进程传递给父进程数据的结构体
typedef struct {
size_t keys; // 写入Key个数
size_t cow; // COW字节数
monotime cow_updated;
double progress;
childInfoType information_type;
} child_info_data;
其中的 keys 字段记录了当前已经写入到 RDB 文件的 Key 个数,cow 字段记录了发生 Copy-on-Write 的字节数。
这里我们重点展开介绍一下 cow 字段值的获取过程,Linux 系统会为每个进程维护了一个 /proc/{pid}/smaps
文件,通过这个文件,我们可以看到一个进程映射的内存区域以及这个区域的使用情况,其中会记录下面的内容。
- size:是进程使用内存空间,并不一定实际分配了物理内存。
- Rss:实际驻留“在内存中”的内存数,不包括已经交换出去的内存页。RSS 还包括了与其他进程共享的内存区域,通常用于共享库。
- Pss:Private Rss, Rss 中私有的内存页。
- Shared_Clean:Rss 中和其他进程共享的未改写内存页。
- Shared_Dirty:Rss 中和其他进程共享的已改写内存页。
- Private_Clean:Rss 中改写的私有内存页。
- Private_Dirty:Rss 中已改写的私有内存页,如果出现换页,该页的内容需要写回磁盘。
子进程就是通过读取 /proc/{pid}/smaps 文件,遍历每个内存区域并累计其中的 Private_Dirty 值,进而确定发生 Copy-on-Write 的字节数
2、父进程从管道读取到数据之后都做了什么?
父进程定时读取子进程发来的统计信息,还会定时检查子进程是否已经结束
RDB 持久化有两种情况,一种是我们前面一直说的,把 RDB 数据写入到本地磁盘,还有一种就是把 RDB 数据写入到 Socket 连接里面; 在这两种情况结束的时候, 会更新 记录最近一次 RDB 持久化的时间戳 和 结果(是否持久化成功),还会更新 dirty 字段。如果 RDB 子进程是被 SIGUSR1 等信号主动结束的, 会向后台线程提交一个文件删除任务,将 RDB 子进程写了一半的 rdb 临时文件删除掉
个人公众号: 行云代码
参考文章
https://www.pdai.tech/md/db/nosql-redis/db-redis-x-rdb-aof.html
说透 Redis 7