Redis5设计与源码分析 (第20章 持久化)

Redis有两种持久化方式:一种为RDB方式,RDB保存某一个时间点之前的数据;另一种为AOF方式,AOF保存的是Redis服务器端执行的每一条命令。

127.0.0.1:6379>info

# Persistence

loading:0 //是否正在加载RDB文件内容

rdb_changes_since_last_save:2 //最后一次保存之后改变的键的个数

rdb_bgsave_in_progress:0 //是否正在后台执行RDB保存任务

rdb_last_save_time:1540371552 //最后一次执行RDB保存任务的时间

rdb_last_bgsave_status:ok //最后一次执行RDB保存任务的状态

rdb_last_bgsave_time_sec:0 //最后一次执行RDB保存任务消耗的时间

rdb_current_bgsave_time_sec:-1 //如果正在执行RDB保存任务,则为当前RDB任务已经消耗的时间,否则为-1

rdb_last_cow_size:6631424 //最后一次执行RDB保存任务消耗的内存

aof_enabled:0 //是否开启了AOF功能

aof_rewrite_in_progress:0 //是否正在后台执行AOF重写任务(重写在后续的章节介绍)

aof_rewrite_scheduled:0 //是否等待调度一次AOF重写任务。如果触发了一次AOF重写,

但是后台正在执行RDB保存任务时会将该状态置为1

aof_last_rewrite_time_sec:-1 //最后一次执行AOF重写任务消耗的时间

aof_current_rewrite_time_sec:-1 //如果正在执行AOF重写任务,则为当前该任务已经消耗的时间,否则为-1

aof_last_bgrewrite_status:ok //最后一次执行AOF重写任务的状态

aof_last_write_status:ok //最后一次执行AOF缓冲区写入的状态(服务端执行命令时会开

辟一段内存空间将命令放入其中,然后从该缓冲区中同步到文件。该状态标记最后一次同步到文件的状态)

aof_last_cow_size:0 //最后一次执行AOF重写任务消耗的内存

20.1 RDB

RDB的触发方式,重点介绍执行bgsave命令时的执行流程,然后详细介绍RDB文件的结构

20.1.1 RDB执行流程

RDB快照有两种触发方式,

其一为通过配置参数,例如在配置文件中写入如下配置:

save 60 1000

则在60秒内如果有1000个key发生变化,就会触发一次RDB快照的执行。

其二是通过在客户端执行bgsave命令显式触发一次RDB快照的执行。

bgsave执行流程如图20-1所示。

图20-1 bgsave执行流程

在客户端输入bgsave命令后,Redis调用bgsaveCommand函数,该函数fork一个子进程执行rdbSave函数进行实际的快照存储工作,而父进程可以继续处理客户端请求。当子进程退出后,父进程调用相关回调函数进行后续处理。

20.1.2 RDB文件结构 (重点)

整体文件结构,RDB键的保存形式(即字符串的保存)和值的保存形式(根据数据类型有不同的保存方法)。

1.整体文件结构

图20-2 RDB文件结构

各个部分按顺序介绍如下。

1)头部5字节固定为"REDIS"字符串。

2)4字节的RDB版本号(RDB_VERSION,不是Redis的版本号),当前RDB版本号为9,填充为4字节之后为0008。

3)辅助字段(AUX_FIELD_KEY_VALUE_PAIRS,见表20-1)。

表20-1 辅助字段AUX_FIELD_KEY_VALUE_PAIRS

辅助字段可以标明以下信息。

·数据库序号:指明数据需要存放到哪个数据库。

·当前数据库键值对散列表的大小。Redis的每个数据库是一个散列表,这个字段指明当前数据库散列表的大小。在加载时可以直接将散列表扩展到指定大小,提升加载速度。

·当前数据库过期时间散列表的大小。Redis的过期时间也是保存为一个散列表,该字段指明当前数据库过期时间散列表的大小。

·Redis中具体键值对的存储。

·RDB文件结束标志。

·8字节的校验码。

 

通过上述结构的描述,请思考:加载RDB文件的时候怎么区分加载的是辅助字段还是数据库序号或者是其他类型呢?其实,在RDB每一部分之前都有一个类型字节,在Redis中称为opcodes。如下所示:

#define RDB_OPCODE_MODULE_AUX 247 /*module相关辅助字段. */

#define RDB_OPCODE_IDLE 248 /* lru空闲时间. */

#define RDB_OPCODE_FREQ 249 /* lfu频率. */

#define RDB_OPCODE_AUX 250 /* 辅助字段类型 */

#define RDB_OPCODE_RESIZEDB 251 /* RESIZEDB,即上文中介绍的5和6两项 */

#define RDB_OPCODE_EXPIRETIME_MS 252 /*毫秒级别过期时间. */

#define RDB_OPCODE_EXPIRETIME 253 /* 秒级别过期时间. */

#define RDB_OPCODE_SELECTDB 254 /* 数据库序号,即第4项. */

#define RDB_OPCODE_EOF 255 /* 结束标志,即第8项 */

 

RDB带opcodes的整体文件结构如图20-3所示。

图20-3 RDB带opcodes表示形式

第2项辅助字段涉及字符串类型在RDB文件中的保存方式,可以参考本节后面将要讲解的内容。为了叙述方便,接下来对RDB结构的介绍中不会特意加上opcodes。

下面具体介绍一下第7项——键值对的结构,如图20-4所示。

图20-4 RDB键值对存储结构

各部分介绍如下。

·EXPIRE_TIME:可选。根据具体的键是否有过期时间决定,该字段固定为8个字节。

·LRU或者LFU:可选。根据配置的内存淘汰算法决定。LRU算法保存秒级别的时间戳,LFU算法只保存counter的计数(0~255,1字节)。

·VALUE_TYPE:值类型。Redis数据类型和底层编码结构,值类型对应关系见表20-2。

表20-2 Redis数据类型/底层编码结构和值类型对应关系

表20-2中字符串在Redis中都是宏,括号中即为该宏对应的值。

·KEY:键。键保存为字符串,下文会详细介绍字符串的保存形式。

·VALUE:值。值根据数据类型和编码结构保存为不同的形式,下文详细介绍。

2.键的保存形式

键都是字符串,本节介绍的就是RDB中如何保存一个字符串,其实也是一个比较常见的方法,如图20-5所示。

图20-5 RDB字符串保存形式

前边LENGTH字段表示字符串长度,后边STRING即具体的字符串内容。LENGTH为了通用可以使用8个字节保存,但这样很明显会导致空间的浪费。Redis中的LENGTH是个变长字段,通过首字节能够知道LENGTH字段有多长,然后读取LENGTH字段可以知道具体的STRING长度。LENGTH字段类型如下:

00xxxxxx 字符串长度<64

01xxxxxx xxxxxxxx 字符串长度<16384

10000000 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 字符串长度<=UINT32_MAX

10000001 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx 字符串长度>UINT32_MAX

首字节头两个比特是00时,表示LENGTH字段占用1个字节,

STRING的长度保存在后6个比特中,最长为63。

同理当首字节头两个比特是01时,表示LENGTH字段占用2个字节,而STRING的长度保存在后14个字节中,最长为16383。

如果首字节为10000000,则表示LENGTH字段共占用5个字节,正好是一个无符号整型,STRING的长度最长为UINT32_MAX。

如果STRING长度大于UINT32_MAX,则首字节表示为10000001,LENGTH字段共占用9个字节。后8字节表示实际长度,为一个LONG类型。

RDB中对字符串的保存还有两种优化形式:

一种是尝试将字符串按整型保存,一种是通过将字符串进行LZF压缩之后保存。

整型保存:

图20-6 字符串按整型保存

TYPE字段其实类似图20-5中的LENGTH字段,LENGTH字段首字节头两个比特取值为00、01、10这种类型,

TYPE字段首字节头两个比特取值为11,后6个比特表明存储的整型类型,如下:

11000000 xxxxxxxx INT8 取值范围[-128,127]

11000001 xxxxxxxx xxxxxxxx INT16 取值范围[-32768,32767]

11000010 xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx INT32 取值范围[-2147483648, 2147483647]

后6个比特是000000,表明存储的是一个有符号INT8类型,000001表明存储的是一个有符号INT16类型,000010表明存储的是一个有符号INT32类型。更长的长度直接按字符串类型保存。

LZF压缩之后保存:

图20-7 RDB LZF保存形式

TYPE首字节头两个比特仍然为11,后六个比特是000011。

COMPRESS_LEN表明压缩之后的长度,该字段保存形式同图20-5中LENGTH字段的保存。ORIGINAL_LEN字段,该字段记录压缩之前原始字符串的长度,保存形式也与图20-5中LENGTH字段的保存相同。

DATA字段保存具体的LZF压缩之后的数据,数据长度从COMPRESS_LEN字段取得。

3.值的保存形式

值类型共有9种。其中字符串类型与20.1.2下的第2节"键的保存"相同。依次介绍余下的8种值类型如何在RDB中保存。

(1)列表类型的保存

列表在Redis中编码为quicklist结构,从整体看是一个双向链表,但链表的每个节点在Redis中编码为zipList结构,ziplist结构在一块连续的内存中保存,并且保存时可以选择进行LZF压缩或者不压缩。据此,RDB保存列表类型的结构如图20-8所示

图20-8 列表类型的保存

首先保存quicklist的节点个数,保存方式同图20-5中LENGTH字段的保存。如果ziplist未进行压缩,则将每个ziplist按字符串保存。如果ziplist进行了压缩,则将每个ziplist按图20-7进行保存。

除了多保存一个quicklist节点个数,其实每个节点的保存与20.1.2节"键的保存"方式相同。

(2)集合类型的保存

两种编码方式:一种为intset,另一种为Hash

intset在Redis中也是一块连续的内存,直接将intset按字符串保存。

如果编码为Hash,则保存结构如图20-9所示。

图20-9 集合类型编码为Hash

第一个字段为字典的大小,接下来逐字段保存字典的键。

为什么只保存键呢?其实集合类型使用散列表保存时只使用了键,所有的值都保存为NULL,键的保存详见20.1.1节。

(3)有序集合类型的保存

两种编码方式:一种为ziplist,另一种为skiplist。

如果编码为ziplist,即将ziplist整体作为一个字符串保存

编码为skiplist的保存方式:

图20-10 有序集合编码为skiplist

skiplist元素个数+接着元素和元素的分值依次保存。

元素保存为字符串,元素分值保存为一个双精度浮点数类型(固定为8个字节)。

(4)散列类型的保存

两种编码方式:一种为ziplist,一种为Hash。

ziplist编码方式的保存同有序集合。

按Hash编码时的保存方式,如图20-11所示。

图20-11 散列类型编码为Hash

散列表的大小+保存键值对,键值都保存为字符串类型

(5)Stream类型的保存

Stream保存为RDB文件时整体格式如图20-12所示。

图20-12 Stream类型

其中具体的结构体介绍,如listpack,消费组等的介绍参考第8章。

其中保存消费组的PEL时并没有保存相关消费者的信息,而是在加载完消费者之后,从消费者的PEL中查找并更新消费组PEL的相关信息。

至此,RDB值类型的保存介绍完毕。

20.2 AOF 

简单来说,AOF就是将Redis服务端执行过的每一条命令都保存到一个文件,这样当Redis重启时只要按顺序回放这些命令就会恢复到原始状态。

AOF和RDB文件的加载过程。

RDB只需要把相应数据加载到内存并生成相应的数据结构(有些结构如intset、ziplist,保存时直接按字符串保存,所以加载时速度会更快),

而AOF文件的加载需要先创建一个伪客户端,然后把命令一条条发送给Redis服务端,服务端再完整执行一遍相应的命令。

根据Redis作者做的测试,RDB 10s~20s能加载1GB的文件,AOF的速度是RDB速度的一半(如果做了AOF重写会加快)。因为AOF和RDB各有优缺点,因此Redis一般会同时开启AOF和RDB。

20.2.1 AOF执行流程

1.AOF命令同步

通过第6章命令的执行流程,我们看到每一条命令的执行都会调用call函数,AOF命令的同步就是在call命令中实现的,如图20-13所示。

图20-13 AOF命令同步

如果开启了AOF,则每条命令执行完毕后都会同步写入aof_buf中,aof_buf是个全局的SDS类型的缓冲区

那么命令是按什么格式写入缓冲区中的呢?

Redis通过catAppendOnlyGenericCommand函数将命令转换为保存在缓冲区中的数据结构,我们通过在该函数处设置断点,打印出转换后的格式。首先使用gdb调试Redis,并在catAppendOnly-GenericCommand函数处设置断点,然后在客户端执行一条命令:

127.0.0.1:6379> set key aof

gdb处执行该函数,然后打印出返回值,如下:

(gdb) p dst

$1 = (sds) 0x7f2050834463 "*3\r\n$3\r\nset\r\n$3\r\nkey\r\n$3\r\naof\r\n"

即命令"set key aof"保存在缓冲区中的格式为 "*3\r\n$3\r\nset\r\n$3\r\nkey\r\n$3\r\naof\r\n"。

"\r\n"为分隔符,去掉分隔符之后为如图20-14所示结构。

图20-14 AOF保存格式

首先以*3开始,表示命令共有3个参数。$3表示接下来的第1个参数长度为3,顺序读取第3个字符set,第1个参数就解析完毕。以此类推,第2个$3表示第2个参数的长度,读取为key,第3个$3表示第3个参数的长度aof。

至此,该条命令解析完毕。读取到下一个*时即表明开启了其他命令的解析。

2.AOF文件写入

AOF持久化最终需要将缓冲区中的内容写入一个文件,写文件通过操作系统提供的write函数执行。但是write之后数据只是保存在kernel的缓冲区中,真正写入磁盘还需要调用fsync函数。fsync是一个阻塞并且缓慢的操作,所以Redis通过appendfsync配置控制执行fsync的频次。

具体有如下3种模式。

·no:不执行fsync,由操作系统负责数据的刷盘。数据安全性最低Redis性能最高。

·always:每执行一次写入就会执行一次fsync。数据安全性最高但Redis性能降低。

·everysec:每1秒执行一次fsync操作。折中方案,在数据安全性和性能之间达到一个平衡。

生产环境一般配置为appendfsync everysec,即每秒执行一次fsync操作。

20.2.2 AOF重写 (重点)

AOF重写通过fork出一个子进程来执行,重写不会对原有文件进行任何修改和读取,子进程对所有数据库中所有的键各自生成一条相应的执行命令,最后将重写开始后父进程继续执行的命令进行回放,生成一个新的AOF文件。示例如下:

  1. AOF重写触发方式

AOF重写有两种触发方式:一种为通过配置自动触发,一种为手动执行bgrewriteaof命令显式触发。

自动触发方式:

auto-aof-rewrite-percentage 100

auto-aof-rewrite-min-size 64mb

当AOF文件大于64MB时,并且AOF文件当前大小比基准大小增长了100%时会触发一次AOF重写。那么基准大小如何确定呢?

起始的基准大小为Redis重启并加载完AOF文件之后,aof_buf的大小。当执行完一次AOF重写之后,基准大小相应更新为重写之后AOF文件的大小。

做如上配置之后,Redis服务器会根据配置自动触发AOF重写。

 

手动触发AOF重写 (重点):

即通过AOF客户端输入bgrewriteaof之后的执行流程。如图20-15所示。

图20-15 bgrewriteaof命令执行流程图

通过在客户端输入bgrewriteaof命令,该命令调用bgrewriteaofCommand,然后创建管道(管道的作用下文介绍),fork进程,子进程调用rewriteAppendOnlyFile执行AOF重写操作,父进程记录一些统计指标后继续进入主循环处理客户端请求。当子进程执行完毕后,父进程调用回调函数做一些后续的处理操作。

 

很明显,首先需要在父进程中将重写过程中执行的命令进行保存,其次需要将这些命令在重写后的文件中进行回放。Redis为了尽量减少主进程的阻塞时间,通过管道按批次将父进程累积的命令发送给子进程,由子进程重写完成后进行回放。因此子进程退出后只会有少量的命令还累积在父进程中,父进程只需回放这些命令即可。

 

重写时父进程用来累积命令使用的结构体。

在图20-13中,如果服务端执行一条命令时正在执行AOF重写,命令还会同步到aof_rewrite_buf_blocks中,这是一个list类型的缓冲区,每个节点中保存一个aofrwblock类型的数据,代码如下:

#define AOF_RW_BUF_BLOCK_SIZE (1024*1024*10) /* 10 MB per block */

typedef struct aofrwblock {

unsigned long used, free;

char buf[AOF_RW_BUF_BLOCK_SIZE];

} aofrwblock;

该结构体中会保存10MB大小的缓冲区内容,并且有缓冲区使用和空闲长度的记录。

当一个节点缓冲区写满之后,会开辟一个新的节点继续保存执行过的命令。

数据通过该结构体保存到父进程中后,那么如何通过管道同步给子进程呢?

来看图20-16。

图20-16 AOF重写管道

父进程在fork之前会建立3对管道:fd0/fd1、fd2/fd3、fd4/fd5,它们各自配对执行。

父进程通过fd1将执行aof重写时累积的命令发送给子进程,子进程通过fd0进行接收并保存。

当子进程执行完重写之后,向fd3写入一个"!"号通知父进程不需要继续通过管道发送累积命令,父进程通过fd2接收到"!"号之后向fd5也写入一个"!"号进行确认。

子进程通过fd4同步阻塞接收到"!"号后才可进行后续的退出操作。

退出时首先会将接收到的累积命令进行回放,然后执行fsync

2.混合持久化(重点)

混合持久化指进行AOF重写时子进程将当前时间点的数据快照保存为RDB文件格式,而后将父进程累积命令保存为AOF格式

最终生成的格式如图20-17所示。

图20-17 混合持久化

加载时,首先会识别AOF文件是否以REDIS字符串开头,如果是,就按RDB格式加载,加载完RDB后继续按AOF格式加载剩余部分。

是否开启混合持久化由如下配置设置:

aof-use-rdb-preamble yes

图20-15中,子进程执行rewriteAppendOnlyFile函数时会判断该配置是否开启,如果开启,则首先按RDB的保存方式保存当前数据快照。保存完毕后回放累积命令到文件末尾即可。

20.3 RDB与AOF相关配置指令

表20-3 RDB和AOF相关配置指令

说明:

1)stop-writes-on-bgsave-error:开启该参数后,如果开启了RDB快照(即配置了save指令),并且最近一次快照执行失败,则Redis将停止接收写相关的请求。

2)rdb-save-incremental-fsync:开启该参数后,生成RDB文件时每产生32MB数据就执行一次fsync。

3)no-appendfsync-on-rewrite:开启该参数后,如果后台正在执行一次RDB快照或者AOF重写,则主进程不再进行fsync操作(即使将appendfsync配置为always或者everysec)。

4)aof-load-truncated:AOF文件以追加日志的方式生成,所以服务端发生故障时可能会有尾部命令不完整的情况。开启该参数后,在此种情况下,AOF文件会截断尾部不完整的命令然后继续加载,并且会在日志中进行提示。如果不开启该参数,则加载AOF文件时会打印错误日志,然后直接退出。

20.4 本章小结

RDB的实现方法及RDB文件的具体格式。

AOF的实现方法及AOF重写的实现。

Redis混合持久化的实现。

 

posted @ 2020-12-26 16:20  将军上座  阅读(190)  评论(0编辑  收藏  举报