type
status
date
slug
summary
tags
category
icon
password
Property
Redis
是一个键值对数据库服务器,服务器中通常包含着任意个非空数据库,而每个非空数据库中又可以包含任意个键值对:因为
Redis
是内存数据库,它将自己的数据库状态存储在内存里,所以如果办法将存储在内存中的数据库状态保存到磁盘里,那么一旦服务器进程退出,服务器中的数据库状态也会消失不见。为了解决这个问题,Redis
提供了RDB
持久化功能,这个功能可以将Redis
在内存中的数据库状态保存到磁盘里,避免数据意外丢失。RDB
持久化既可以手动执行,也可以根据服务器配置选项定期执行,可以将某个时间点上的数据库状态保存到一个RDB
文件中:RDB
持久化所生成的RDB
文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB
文件时的数据库状态。RDB 文件的创建与载入
Redis使用 SAVE 和 BGSAVE 命令生成 RDB 文件;
- SAVE:会阻塞 Redis 服务器进程,直到 RDB 文件创建完毕为止,阻塞期间服务器不能处理任何命令请求
- BGSAVE:会派生一个子进程,由指进程负责创建 RDB 文件,这样可以避免主线程的阻塞,父进程继续处理命令请求:
- 在 BGSAVE 命令执行期间,客户端发送 SAVE 和 BGSAVE 命令会被服务器拒绝,防止产生竞争条件。客户端发送 BGREWRITEAOF 命令会被延迟;
- 在 BGREWRITEAOF 命令执行期间,客户端发送 BGSAVE 命令会被服务器拒绝;
BGSAVE 执行期间,会发生以下特殊情况:
创建 RDB 文件由
rdb.c/rdbSave
函数完成,save
命令和bgsave
命令会以不同的方式调用这个函数。载入
RDB
文件由rdb.c/rdbLoad
函数完成,RDB
文件的载入工作是在服务器启动时自动执行,Redis
没有专门用于载入RDB
文件的命令,只要Redis
服务器在启动时检测到RDB文件存在,就会自动载入RDB
文件;注:AOF 文件的更新频率通常比 RDB 文件更新频率高:
- 当服务器开启了
AOF
持久化功能时,会优先使用AOF
文件还原数据库状态
- 当服务器关闭了
AOF
持久化功能时,才会使用RDB
文件来还原数据库状态
服务器在载入
RDB
文件期间,会一直处于阻塞状态,直到载入工作完成为止;
自动间隔性保存
因为
bgsave
命令可以在不阻塞服务器进程的状况下执行,所以Redis
允许用户通过设置服务器配置的save
选项,让服务器每隔一段时间自动执行一次bgsave
命令。可以有多个条件,当其中一个被满足了,就被执行bgsave
操作:设置保存条件
当
Redis
服务器启动时,用户可以通过指定配置文件或者传入启动参数的方式设置save
选项,如果没有主动设置save
选项,那么服务器会为save
选项设置默认条件。服务器会根据
save
选项所设置的保存值,设置服务器状态 redisServer
结构的 saveparams
属性;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()
创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。只有在发生修改内存数据的情况时,物理内存才会被复制一份。这样的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。
所以,创建
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 文件所包含的各个部分:
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
字节长的无符号整数, 保存着一个校验和, 这个校验和是程序通过对REDIS
、db_version
、databases
、EOF
四个部分的内容进行计算得出的。 服务器在载入RDB文件时, 会将载入数据所计算出的校验和与check_sum
所记录的校验和进行对比, 以此来检查RDB 文件是否有出错或者损坏的情况出现。
一个
databases
部分为空的 RDB 文件:databases 部分
一个 RDB 文件的
databases
部分可以保存任意多个非空数据库。比如说, 如果服务器的 0
号数据库和 3
号数据库非空:那么
database 0
代表 0
号数据库中的所有键值对数据, database 3
代表 3
号数据库中的所有键值对数据。每个非空数据库在RDB文件中都可以保存为
SELECTDB
、db_number
、key_value_pairs
三个部分:SELECTDB
常量的长度为1
字节, 当读入程序遇到这个值的时候, 它知道接下来要读入的将是一个数据库号码。
db_number
保存着一个数据库号码, 根据号码的大小不同, 这个部分的长度可以是1
字节、2
字节或者5
字节。 当程序读入db_number
部分之后, 服务器会调用SELECT命令, 根据读入的数据库号码进行数据库切换, 使得之后读入的键值对可以载入到正确的数据库中。
key_value_pairs
部分保存了数据库中的所有键值对数据, 如果键值对带有过期时间, 那么过期时间也会和键值对保存在一起。 根据键值对的数量、类型、内容、以及是否有过期时间等条件的不同,key_value_pairs
部分的长度也会有所不同。
一个完整的 RDB 文件, 文件中包含了
0
号数据库和 3
号数据库:key_value_pairs部分
RDB 文件中的每个
key_value_pairs
部分都保存了一个或以上数量的键值对, 如果键值对带有过期时间的话, 那么键值对的过期时间也会被保存在内。不带过期时间的键值对在 RDB 文件中对由
TYPE
、 key
、 value
三部分组成: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 文件中的结构:
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
位的整数, 这种编码的对象: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
选项的说明。没有被压缩的字符串:
其中,
string
部分保存了字符串值本身,而 len
保存了字符串值的长度。压缩后的字符串:
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
编码的列表对象:list_length
记录了列表的长度, 它记录列表保存了多少个项(item), 读入程序可以通过这个长度知道自己应该读入多少个列表项。图中以
item
开头的部分代表列表的项, 因为每个列表项都是一个字符串对象, 所以程序会以处理字符串对象的方式来保存和读入列表项。一个包含三个元素的列表:
结构中的第一个数字
3
是列表的长度, 之后跟着的分别是第一个列表项、第二个列表项和第三个列表项, 其中:- 第一个列表项的长度为
5
, 内容为字符串"hello"
- 第二个列表项的长度也为
5
, 内容为字符串"world"
- 第三个列表项的长度为
1
, 内容为字符串"!"
集合对象
如果
TYPE
的值为 REDIS_RDB_TYPE_SET
, 那么 value
保存的就是一个 REDIS_ENCODING_HT
编码的集合对象:set_size
是集合的大小, 它记录集合保存了多少个元素, 读入程序可以通过这个大小知道自己应该读入多少个集合元素。图中以
elem
开头的部分代表集合的元素, 因为每个集合元素都是一个字符串对象, 所以程序会以处理字符串对象的方式来保存和读入集合元素。一个包含四个元素的集合:
结构中的第一个数字
4
记录了集合的大小, 之后跟着的是集合的四个元素:- 第一个元素的长度为
5
,值为"apple"
。
- 第二个元素的长度为
6
,值为"banana"
。
- 第三个元素的长度为
3
,值为"cat"
。
- 第四个元素的长度为
3
,值为"dog"
。
哈希表对象
如果
TYPE
的值为 REDIS_RDB_TYPE_HASH
, 那么 value
保存的就是一个 REDIS_ENCODING_HT
编码的集合对象:hash_size
记录了哈希表的大小, 也即是这个哈希表保存了多少键值对, 读入程序可以通过这个大小知道自己应该读入多少个键值对。
- 以
key_value_pair
开头的部分代表哈希表中的键值对, 键值对的键和值都是字符串对象, 所以程序会以处理字符串对象的方式来保存和读入键值对。
结构中的每个键值对都以键紧挨着值的方式排列在一起:
因此, 从更详细的角度看,的结构可以进一步修改为:
一个包含两个键值对的哈希表:
在这个示例结构中, 第一个数字
2
记录了哈希表的键值对数量, 之后跟着的是两个键值对:- 第一个键值对的键是长度为
1
的字符串"a"
, 值是长度为5
的字符串"apple"
。
- 第二个键值对的键是长度为
1
的字符串"b"
, 值是长度为6
的字符串"banana"
。
有序集合对象
如果
TYPE
的值为 REDIS_RDB_TYPE_ZSET
, 那么 value
保存的就是一个 REDIS_ENCODING_SKIPLIST
编码的有序集合对象:sorted_set_size
记录了有序集合的大小, 也即是这个有序集合保存了多少元素, 读入程序需要根据这个值来决定应该读入多少有序集合元素。以
element
开头的部分代表有序集合中的元素, 每个元素又分为成员(member)和分值(score)两部分, 成员是一个字符串对象, 分值则是一个 double
类型的浮点数, 程序在保存 RDB 文件时会先将分值转换成字符串对象, 然后再用保存字符串对象的方法将分值保存起来。有序集合中的每个元素都以成员紧挨着分值的方式排列:
因此, 从更详细的角度看,结构可以进一步修改为:
一个带有两个元素的有序集合:
第一个数字
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 文件保存这种对象的方法是:- 将压缩列表转换成一个字符串对象。
- 将转换所得的字符串对象保存到 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 读入程序也总可以将读入并转换之后得出的压缩列表设置成原来的类型。