redis设计与实现之持久篇

Redis有两种持久化方式:RDB持久化和AOF持久化,前者是通过把数据库状态的键值对保存起来,而AOF则是把命令保存起来,那么服务器是使用哪种持久化呢?因为AOF文件更新频率更高,所以优先AOF,接下来将分别介绍两种持久化方式

RDB持久化

服务器可以通过save和bgsave来实现rdb持久化,前者会阻塞服务器进程,导致在进行save操作期间,服务器无法继续处理客户端请求;bgsave则会派生一个子进程,让子进程去进行持久化操作,而服务器进程继续响应客户端,这里要说明的是bgsave和save这两个操作无法同时进行,因为会出现竞争条件,由于bgsave会创建子进程来进行持久化操作,并不阻碍服务器进程处理其他事,所以我们可以让服务器每隔一段时间执行一次bgsave,因此可以通过设置保存条件来让服务器执行bgsave操作

1
2
3
4
5
6
7
8
9
10
struct redisServer{
struct saveparam *saveparam;//记录了保存条件的数组
sds aof_buf //AOF缓冲区,用来存放写命令的协议请求内容
};
//条件:当满足条件(在time_t秒内,修改次数达到changes)时,服务器执行bgsave
struct saveparam{
time_t seconds;//秒数
int changes//修改数
};

Redis服务器有个周期性函数serverCron,默认每隔100ms执行一次,Redis就是通过它来检测保存条件

1
2
3
4
5
6
7
8
def serverCron():
//遍历所有的保存条件
for saveparam in serverparams:
//计算距离上次执行保存操作有多少秒
save_interval = unixtime_now() - server.lastsave
//如果时间和次数都符合则执行bgsave
if server.dirty >= saveparam.changes and save_interval > saveparam.seconds:
BGSAVE()

RDB文件结构如下

  • REDIS长度5字节,保存着’R’ ‘E’ ‘D’ ‘I’ ‘S’ 五个字符,可用来判断文件是否是rdb文件(RDB 文件保存的是二进制数据, 而不是 C 字符串)
  • db_version长度为4字节,值为整数型字符串,代表rdb文件的版本号.比如0006
  • databases 数据库状态,包含一个或多个数据库,以及各个数据库中的键值对(1、如果服务器的数据库状态为空(所有数据库都是空的), 那么这个部分也为空, 长度为 0 字节。2、如果服务器的数据库状态为非空(有至少一个数据库非空), 那么这个部分也为非空, 根据数据库所保存键值对的数量、类型和内容不同, 这个部分的长度也会有所不同)
  • EOF常量,1字节,标志着rdb文件的结束
  • check_sum:8字节的校验和,程序通过对REDIS,db_version,databases,EOF四部分计算得来,服务器载入rdb文件时,会将文件计算得来的校验和与该值对比,依次来检测rdb文件正确性

databases为空的结构图如下


databases不为空的结构图如下

TYPE 记录了 value 的类型, 长度为 1 字节, 值可以是以下常量的其中一个,每个 TYPE 常量都代表了一种对象类型或者底层编码, 当服务器读入 RDB 文件中的键值对数据时, 程序会根据 TYPE 的值来决定如何读入和解释 value 的数据
1
2
3
4
5
6
7
8
9
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

key 和 value 分别保存了键值对的键对象和值对象

  • 其中 key 总是一个字符串对象, 它的编码方式和 REDIS_RDB_TYPE_STRING 类型的 value 一样。 根据内容长度的不同, key 的长度也会有所不同
  • 根据 TYPE 类型的不同, 以及保存内容长度的不同, 保存 value 的结构和长度也会有所不同
value编码-字符串对象

1、如果 TYPE 的值为 REDIS_RDB_TYPE_STRING , 那么 value 保存的就是一个字符串对象, 字符串对象的编码可以是 REDIS_ENCODING_INT 或者 REDIS_ENCODING_RAW,如果字符串对象的编码为 REDIS_ENCODING_INT , 那么说明对象中保存的是长度不超过 32 位的整数,结构如下
| ENCODING | integer |
| ————- |:————-:|

ENCODING 的值可以是 REDIS_RDB_ENC_INT8 、 REDIS_RDB_ENC_INT16 或者 REDIS_RDB_ENC_INT32 三个常量的其中一个, 它们分别代表 RDB 文件使用 8 位(bit)、 16 位或者 32 位来保存整数值 integer,如果字符串对象中保存的是可以用 8 位来保存的整数 123 , 结构如下
| REDIS_RDB_ENC_INT8 | 123 |
| ————- |:————-:|

如果字符串对象的编码为 REDIS_ENCODING_RAW , 那么说明对象所保存的是一个字符串值, 根据字符串长度的不同, 有压缩和不压缩两种方法来保存这个字符串

  • 如果字符串的长度小于等于 20 字节, 那么这个字符串会直接被原样保存。
  • 如果字符串的长度大于 20 字节, 那么这个字符串会被压缩之后再保存。
    对于没有被压缩的字符串结构如下
    | len | string |
    | ————- |:————-:|
5 “hello”

对于压缩后的字符串结构如下
| REDIS_RDB_ENC_LZF | compressed_len | origin_len | compressed_string |
| ————- |:————-:|

REDIS_RDB_ENC_LZF 6 21 “?aa???”
  • REDIS_RDB_ENC_LZF 常量标志着字符串已经被 LZF 算法(http://liblzf.plan9.de)压缩过了
  • compressed_len 记录的是字符串被压缩之后的长度
  • origin_len 记录的是字符串原来的长度
  • compressed_string 记录的则是被压缩之后的字符串
value编码-列表对象

如果 TYPE 的值为 REDIS_RDB_TYPE_LIST , 那么 value 保存的就是一个 REDIS_ENCODING_LINKEDLIST 编码的列表对象,结构如下
| list_length | item1 | item2 | item3 | … | itemN |
| ————- |:————-:|

3 5 “hello” 5 “world” 1 “!”
value编码-集合对象

如果 TYPE 的值为 REDIS_RDB_TYPE_SET , 那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象,结构如下
| set_size | elem1 | elem2 | elem3 | … | elemN |
| ————- |:————-:|

4 5 “apple” 6 “banana” 3 “cat” 3 “pig”
value编码-哈希表对象

如果 TYPE 的值为 REDIS_RDB_TYPE_HASH , 那么 value 保存的就是一个 REDIS_ENCODING_HT 编码的集合对象,结构如下
| hash_size | key_value_pair1 | key_value_pair2 | key_value_pair3 | … | key_value_pairN |
| ————- |:————-:|

2 1 “a” 6 “banana” 1 “b” 3 “pig”
value编码-有序集合对象

如果 TYPE 的值为 REDIS_RDB_TYPE_ZSET , 那么 value 保存的就是一个 REDIS_ENCODING_SKIPLIST 编码的有序集合对象,结构如下
| sorted_set_size | member1 | score1 | member2 | score2 | … | memberN | scoreN |
| ————- |:————-:|

2 2 “pi” 4 “3.14” 1 “e” 3 “2.7”
  • 第一个元素的成员是长度为 2 的字符串 “pi” , 分值被转换成字符串之后变成了长度为 4 的字符串 “3.14”
  • 第二个元素的成员是长度为 1 的字符串 “e” , 分值被转换成字符串之后变成了长度为 3 的字符串 “2.7”
value编码-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 文件保存这种对象的方法是:

  • 将压缩列表转换成一个字符串对象。
  • 将转换所得的字符串对象保存到 RDB 文件。

如果程序在读入 RDB 文件的过程中, 碰到由压缩列表对象转换成的字符串对象, 那么程序会根据 TYPE 值的指示, 执行以下操作:

  • 读入字符串对象,并将它转换成原来的压缩列表对象。
  • 根据 TYPE 的值,设置压缩列表对象的类型: 如果 TYPE 的值为 REDIS_RDB_TYPE_LIST_ZIPLIST , 那么压缩列表对象的类型为列表; 如果 TYPE 的值为 REDIS_RDB_TYPE_HASH_ZIPLIST , 那么压缩列表对象的类型为哈希表; 如果 TYPE 的值为 REDIS_RDB_TYPE_ZSET_ZIPLIST , 那么压缩列表对象的类型为有序集合。

从步骤 2 可以看出, 由于 TYPE 的存在, 即使列表、哈希表和有序集合三种类型都使用压缩列表来保存, RDB 读入程序也总可以将读入并转换之后得出的压缩列表设置成原来的类型。

AOF持久化

当 AOF 持久化功能处于打开状态时, 服务器在执行完一个写命令之后, 会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾,AOF持久化的功能实现可描述为命令追加(append),文件写入,文件同步(sync)三个步骤

aof-命令追加
1
2
3
4
5
6
7
8
9
10
11
12
13
14
//执行命令
redis> SET KEY VALUE
OK
//如下协议内容追加到aof_buf缓冲区中
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
//执行命令
redis> RPUSH NUMBERS ONE TWO THREE
(integer) 3
//
如下协议内容追加到aof_buf缓冲区中
*5\r\n$5\r\nRPUSH\r\n$7\r\nNUMBERS\r\n$3\r\nONE\r\n$3\r\nTWO\r\n$5\r\nTHREE\r\n
aof-文件写入与同步

Redis 的服务器进程就是一个事件循环(loop), 这个循环中的文件事件负责接收客户端的命令请求, 以及向客户端发送命令回复, 而时间事件则负责执行像 serverCron 函数这样需要定时运行的函数。

因为服务器在处理文件事件时可能会执行写命令, 使得一些内容被追加到 aof_buf 缓冲区里面, 所以在服务器每次结束一个事件循环之前, 它都会调用 flushAppendOnlyFile 函数, 考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面, 这个过程可以用以下伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
def eventLoop():
while True:
# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求时可能会有新内容被追加到 aof_buf 缓冲区中
processFileEvents()
# 处理时间事件
processTimeEvents()
# 考虑是否要将 aof_buf 中的内容写入和保存到 AOF 文件里面
flushAppendOnlyFile()
# flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定(always、everysec、no)

文件的写入和同步原理如下

为了提高文件的写入效率, 在现代操作系统中, 当用户调用 write 函数, 将一些数据写入到文件的时候, 操作系统通常会将写入数据暂时保存在一个内存缓冲区里面, 等到缓冲区的空间被填满、或者超过了指定的时限之后, 才真正地将缓冲区中的数据写入到磁盘里面。

这种做法虽然提高了效率, 但也为写入数据带来了安全问题, 因为如果计算机发生停机, 那么保存在内存缓冲区里面的写入数据将会丢失。

为此, 系统提供了 fsync 和 fdatasync 两个同步函数, 它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面, 从而确保写入数据的安全性。

AOF重写原理如下

由于AOF文件更行频率很高,用户会有大量的写命令,如果每次都记录,则会浪费大量空间,所以Redis实现了AOF重写功能:首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令

由于redis会伴随大量的写入操作,如果服务器去执行aof重写,则可能长时间阻塞,于是服务器使用子进程来进行aof重写,子进程持有服务器进程的数据副本.然而在子进程每次重写期间,服务器又会有新的写请求,那么如何解决这个数据不一致问题呢?
为了解决这个问题,Redis服务器设置了一个aof重写缓冲区,在创建了子进程时,开始使用缓冲区,在子进程重写期间,每当Redis服务器有新的写操作,都会把命令同时发给aof缓冲区和重写缓冲区,这样一来

  • AOF缓冲区的内容会定期被写人和同步到AOF文件,对现有AOF文件的处理工作如常进行
  • 从创建子进程开始,服务器执行的所有写命令都会被记录到AOF重写缓冲区里面.

当子进程完成AOF重写工作之后,它会向父进程发送一个信号,父进程在接到该信号后:

  • 将AOF重写缓冲区中的所有内容写入到新AOF文件中,这时新AOF文件所保存的数据库状态将和服务器当前的数据库状态一致
  • 对新的AOF文件进行改名,原子地(atomic)覆盖现有的AOF文件,完成新旧两个AOF文件的替换

AOF 持久化的效率和安全性

服务器配置 appendfsync 选项的值直接决定 AOF 持久化功能的效率和安全性。

当 appendfsync 的值为 always 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 并且同步 AOF 文件, 所以 always 的效率是 appendfsync 选项三个值当中最慢的一个, 但从安全性来说, always 也是最安全的, 因为即使出现故障停机, AOF 持久化也只会丢失一个事件循环中所产生的命令数据。

当 appendfsync 的值为 everysec 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 并且每隔超过一秒就要在子线程中对 AOF 文件进行一次同步: 从效率上来讲, everysec 模式足够快, 并且就算出现故障停机, 数据库也只丢失一秒钟的命令数据。

当 appendfsync 的值为 no 时, 服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件, 至于何时对 AOF 文件进行同步, 则由操作系统控制。

因为处于 no 模式下的 flushAppendOnlyFile 调用无须执行同步操作, 所以该模式下的 AOF 文件写入速度总是最快的, 不过因为这种模式会在系统缓存中积累一段时间的写入数据, 所以该模式的单次同步时长通常是三种模式中时间最长的: 从平摊操作的角度来看, no 模式和 everysec 模式的效率类似, 当出现故障停机时, 使用 no 模式的服务器将丢失上次同步 AOF 文件之后的所有写命令数据。