Redis系列之持久化机制
需求背景
Redis是内存数据库,数据都是存储在内存中,为避免进程意外退出导致数据的永久丢失,需要定期将Redis中的数据以某种形式(数据或命令)从内存保存到硬盘;当下次Redis重启时,利用持久化文件实现数据恢复。此外,为了灾备,可将持久化文件拷贝到一个远程位置。
Redis支持四种持久化方式:
- RDB
- AOF
- 虚拟内存
- Diskstore
在设计思路上,前两种是基于全部数据都在内存中,即小数据量存储;而后两种方式则是作者在尝试存储数据超过物理内存时,即大数据量存储。后两种仍然是在实验阶段,vm 方式基本已经被作者放弃,海量数据存储方面并不是 Redis 所擅长的领域。
虚拟内存
即visual memory方式,Redis用来进行用户空间的数据换入换出的一个策略,此种方式在实现的效果上比较差,主要问题是代码复杂,重启慢,复制慢等,目前已经被作者放弃。
当key很小而value很大时,使用VM的效果会比较好,因为这样节约的内存比较大。
当key不小时,可以考虑使用一些非常方法将很大的key变成很大的value,可考虑将key,value组合成一个新的value。
vm-max-threads
参数设置访问swap文件的线程数,设置最好不要超过机器的核数;设置为0,则所有对swap文件的操作都是串行的,可能会造成比较长时间的延迟,但是对数据完整性有很好的保证。
Diskstore
Diskstore方式是作者放弃虚拟内存方式后选择的一种新的实现方式,即传统的B-tree方式,目前仍在实验阶段。
RDB
概述
即定时快照方式(snapshot),将当前进程中的数据生成快照保存到硬盘,因此也称作快照持久化,文件后缀是rdb,RDB文件是经过压缩的二进制文件。
由于AOF优先级更高,当AOF开启时,Redis会优先加载AOF文件来恢复数据,其次自动执行RDB文件的读取实现数据恢复。服务器载入RDB文件期间处于阻塞状态,直到载入完成为止。另外若开启RDB文件校验,且文件损坏,则Redis启动失败,启动失败原因输出到日志中。
该持久化方式实际是在 Redis 内部一个定时器事件,每隔固定时间去检查当前数据发生的改变次数与时间是否满足配置的持久化触发的条件,如果满足则通过操作系统 fork 调用来创建出一个子进程,子进程默认会与父进程共享相同的地址空间,通过子进程来遍历整个内存来进行存储操作,而主进程则仍然可以提供服务,当有写入时由操作系统按照内存页为单位来进行 copy-on-write
保证父子进程之间不会互相影响。当子进程完成写RDB文件,用新文件替换老文件。缺点:定时快照只是代表一段时间内的内存映像,所以系统重启会丢失上次快照与重启之间所有的数据。
触发方式
- 手动触发
save和bgsave命令都可以生成二进制文件dump.rdb
。save命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止;在Redis服务器阻塞期间,服务器不能处理任何命令请求。而bgsave命令会创建一个子进程,由子进程来负责创建RDB文件,父进程(即Redis主进程)则继续处理请求。save会阻塞,已废弃,线上生产环境不推荐使用。故而,自动触发使用的是bgsave命令。 - 自动触发
有3种情况自动触发RDB文件的bgsave:save m n
:配置文件,指定当m秒内发生n次变化时,会触发bgsave。- 在主从复制场景下,如果从节点执行全量复制操作,则主节点会执行bgsave命令,并将RDB文件发送给从节点
- 执行shutdown命令时
配置
save m n
:没有此配置,相当于自动的RDB持久化关闭
stop-writes-on-bgsave-error yes
:当bgsave出现错误时,Redis是否停止执行写命令;设置为yes,则当硬盘出现问题时,可以及时发现,避免数据丢失;设置为no,则Redis无视bgsave的错误继续执行写命令,当对Redis服务器的系统(尤其是硬盘)使用监控时,该选项考虑设置为no
rdbcompression yes
:是否开启RDB文件压缩
rdbchecksum yes
:是否开启RDB文件校验,在写入文件和读取文件时都起作用;关闭此配置,在写入文件和读取文件时大约能带来10%的性能提升,但无法监测到数据损坏
dbfilename dump.rdb
:RDB文件名
dir ./
:RDB、AOF文件所在目录
实现原理
save m n
是通过serverCron函数、dirty计数器、和lastsave时间戳来实现的。serverCron是Redis服务器的周期性操作函数,默认每隔100ms执行一次;该函数对服务器的状态进行维护,其中一项工作就是检查 save m n 配置的条件是否满足,如果满足就执行bgsave。
dirty计数器是Redis服务器维持的一个状态,记录上一次执行bgsave/save命令后,服务器状态进行多少次修改(包括增删改);而当save/bgsave执行完成后,会将dirty重新置为0。dirty记录的是服务器进行多少次修改,而不是客户端执行多少修改数据的命令。
lastsave时间戳也是Redis服务器维持的一个状态,记录的是上一次成功执行save/bgsave的时间。
save m n的原理如下:每隔100ms,执行serverCron函数;在serverCron函数中,遍历save m n配置的保存条件,只要有一个条件满足,就进行bgsave。对于每一个save m n条件,只有下面两条同时满足时才算满足:
- 当前时间-lastsave > m
- dirty >= n
执行流程
- Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof的子进程,如果在执行则bgsave命令直接返回。bgsave/bgrewriteaof 的子进程不能同时执行,主要是基于性能方面的考虑:两个并发的子进程同时执行大量的磁盘写操作,可能引起严重的性能问题。
- 父进程执行fork操作创建子进程,这个过程中父进程是阻塞的,Redis不能执行来自客户端的任何命令
- 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响应其他命令
- 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换
- 子进程发送信号给父进程表示完成,父进程更新统计信息
文件结构
RDB文件的概况图:
----------------------------# RDB文件是二进制的,所以并不存在回车换行来分隔一行一行.
52 45 44 49 53 # 以字符串 "REDIS" 开头
30 30 30 33 # RDB 的版本号,大端存储,比如左边这个表示版本号为0003
----------------------------
FE 00 # FE = FE表示数据库编号,Redis支持多个库,以数字编号,这里00表示第0个数据库
----------------------------# Key-Value 对存储开始
FD $length-encoding # FD 表示过期时间,过期时间是用 length encoding 编码存储
$value-type # 1 个字节用于表示value的类型,比如set,hash,list,zset等
$string-encoded-key # Key 值,通过string encoding 编码,同样后面会讲到
$encoded-value # Value值,根据不同的Value类型采用不同的编码方式
----------------------------
FC $length-encoding # FC 表示毫秒级的过期时间,后面的具体时间用length encoding编码存储
$value-type # 同上,也是一个字节的value类型
$string-encoded-key # 同样是以 string encoding 编码的 Key值
$encoded-value # 同样是以对应的数据类型编码的 Value 值
----------------------------
$value-type # 下面是没有过期时间设置的 Key-Value对,为防止冲突,数据类型不会以 FD, FC, FE, FF 开头
$string-encoded-key
$encoded-value
----------------------------
FE $length-encoding # 下一个库开始,库的编号用 length encoding 编码
----------------------------
... # 继续存储这个数据库的 Key-Value 对
FF ## FF:RDB文件结束的标志
首先存储一个REDIS字符串(验证作用,表示是RDB文件),redis的版本信息(用4个字节,以大端big endian方式存储和读取),具体的数据库,结束符EOF,检验和。关键是databases,存储多个数据库,数据库按照编号顺序存储,0号数据库存储完,才轮到1,一直到最后一个数据库。
每一个数据库存储方式如下,首先一个1字节的常量SELECTDB,表示切换db,下一个数据库的编号,它的长度是可变的,db里面具体的key-value对的数据。
key_value_pairs
则存储具体的键值对信息,包括key、value值,及其数据类型、内部编码、过期时间、压缩信息等。
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val, long long expiretime, long long now) {
/* Save the expire time */
if (expiretime != -1) {
/* If this key is already expired skip it */
if (expiretime < now) return 0;
if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
}
/* Save type, key, value */
if (rdbSaveObjectType(rdb,val) == -1) return -1;
if (rdbSaveStringObject(rdb,key) == -1) return -1;
if (rdbSaveObject(rdb,val) == -1) return -1;
return 1;
}
存储时先检查expire time,如果已经过期则不存储,否则将expire time存下来。即使是存储expire time,也是先存储它的类型为REDIS_RDB_OPCODE_EXPIRETIME_MS,然后再存储具体过期时间。接下来存储真正的key-value对,首先存储value的类型,然后存储key(它按照字符串存储),然后存储value
在rdbsaveobject中,会根据val的不同类型,按照不同的方式存储,不过从根本上来看,最终都是转换成字符串存储,比如val是一个linklist,那么先存储整个list的字节数,然后遍历这个list,把数据取出来,依次按照string写入文件。对于hash table,也是先计算字节数,然后依次取出hash table中的dictEntry,按照string的方式存储它的key和value,然后存储下一个dictEntry。 总之,RDB的存储方式,对一个key-value对,会先存储expire time(如果有的话),然后是value的类型,然后存储key(字符串方式),然后根据value的类型和底层实现方式,将value转换成字符串存储。
当redis再启动时,RDB文件中保存数据库的号码,以及它包含的key-value对,以及每个key-value对中value的具体类型,实现方式,和数据,redis只要顺序读取文件,然后恢复object即可。由于保存有expire time,发现当前时间晚于expire time,即数据已经过期,则不恢复这个key-value对。
子进程复制父进程的地址空间,即子进程拥有父进程fork时的数据库,子进程执行save的操作,把它从父进程那儿继承来的数据库写入一个temp文件即可。在子进程复制期间,redis会记录数据库的修改次数(dirty)。当子进程完成时,发送给父进程SIGUSR1信号,父进程捕捉到这个信号,就知道子进程完成复制,然后父进程将子进程保存的temp文件改名为真正的RDB文件(即真正保存成功了才改成目标文件,这才是保险的做法)。然后记录下这一次save的结束时间。
问题
- 执行bgsave耗费较长,不够实时:在子进程保存期间,父进程的数据库已经被修改,而父进程只是记录修改的次数(dirty),被没有进行修正操作。使得RDB保存的不是实时的数据库。
- redis在serve cron的时候,会根据dirty数目和上次保存的时间,来判断是否符合条件,符合条件就进行bgsave,任意时刻只能有一个子进程来进行后台保存,因为保存是个很费I/O的操作,多个进程大量I/O效率不高,而且不好管理。
- 当系统停止,或无意中Redis被kill掉,写入Redis的数据会发生丢失。
压缩
Redis默认采用LZF算法对RDB文件进行压缩。RDB文件的压缩并不是针对整个文件进行的,而是对数据库中的字符串进行的,且只有在字符串达到一定长度(20字节)时才会进行。压缩比较耗时,但能大大减小RDB文件的体积,因此压缩默认开启;可通过命令关闭:config set rdbcompression no
。
AOF
即Append Only File,将Redis执行的每次写命令记录到单独的日志文件中。类似MySQL基于statement的binlog方式,即每条会使Redis内存数据发生改变的命令都会追加到一个log文件中。
配置
Redis默认开启RDB关闭AOF。
appendonly no # 是否开启AOF
appendfilename "appendonly.aof" # AOF文件名
dir ./ # RDB文件和AOF文件所在目录
appendfsync everysec # fsync持久化策略
no-appendfsync-on-rewrite no # AOF重写期间是否禁止fsync;如果开启该选项,可以减轻文件重写时CPU和硬盘的负载(尤其是硬盘),但是可能会丢失AOF重写期间的数据;需要在负载和安全性之间进行平衡
auto-aof-rewrite-percentage 100 # 文件重写触发条件之一,aof_current_size和aof_base_size的比值
auto-aof-rewrite-min-size 64mb # 文件重写触发提交之一,文件的最小体积
aof-load-truncated yes:如果AOF文件结尾损坏,Redis启动时是否仍载入AOF文件,默认开启
aof_current_size # 当前AOF大小
aof_base_size # 上一次重写时AOF大小
加载
当AOF开启,但AOF文件不存在时,即使RDB文件存在也不会加载(更早的一些版本可能会加载,但3.0不会)。
文件校验:与RDB类似,如果文件损坏,则日志中会打印错误,Redis启动失败。但若AOF文件结尾不完整(机器突然宕机等导致),且aof-load-truncated
参数开启,则日志中会输出警告,Redis忽略掉AOF文件的尾部,启动成功。
伪客户端:Redis的命令只能在客户端上下文中执行,而载入AOF文件时命令是直接从文件中读取的,并不是由客户端发送。故Redis服务器在载入AOF文件之前,会创建一个没有网络连接的客户端,之后用它来执行AOF文件中的命令,命令执行的效果与带网络连接的客户端完全一样。
核心流程
主要包括三个步骤:
- 命令追加append:将Redis的写命令追加到缓冲区
aof_buf
,不是直接写入文件,主要是为了避免每次有写命令都直接写入硬盘,导致硬盘IO成为Redis负载的瓶颈。命令追加的格式是Redis命令请求的协议格式,一种纯文本格式,兼容性好、可读性强、容易处理、操作简单避免二次开销等优点。在AOF文件中,除了用于指定数据库的select命令是由Redis添加的,其他都是客户端发送来的写命令。 - 文件写入write和文件同步sync:根据不同的同步策略将
aof_buf
中的内容同步到硬盘;Redis提供多种AOF缓存区的同步文件策略,策略涉及到操作系统的write函数和fsync函数。为了提高文件写入效率,在现代操作系统中,当用户调用write函数将数据写入文件时,操作系统通常会将数据暂存到一个内存缓冲区里,当缓冲区被填满或超过指定时限后,才真正将缓冲区的数据写入到硬盘里。可以提高效率,但也有安全问题:如果计算机停机,内存缓冲区中的数据会丢失;因此系统同时提供fsync
、fdatasync
等同步函数,可以强制操作系统立刻将缓冲区中的数据写入到硬盘里,从而确保数据的安全性。AOF缓存区的同步文件策略由参数appendfsync
控制:- always:命令写入
aof_buf
后立即调用系统fsync操作同步到AOF文件,fsync完成后线程返回。每次有写命令都要同步到AOF文件,硬盘IO成为性能瓶颈,Redis只能支持大约几百TPS写入。即便是使用固态硬盘(SSD),每秒大约也只能处理几万个命令,而且会大大降低SSD的寿命。 - no:命令写入
aof_buf
后调用系统write操作,不对AOF文件做fsync同步;同步由操作系统负责,通常同步周期为30秒。文件同步的时间不可控,且缓冲区中堆积的数据会很多,数据安全性无法保证。 - everysec:命令写入
aof_buf
后调用系统write操作,write完成后线程返回;fsync同步文件操作由专门的线程每秒调用一次。everysec是前述两种策略的折中,是性能和数据安全性的平衡,因此是Redis的默认配置,也是推荐配置。
- always:命令写入
- 文件重写(rewrite):定期重写AOF文件,达到压缩的目的。
重写
rewrite,Redis服务器执行的写命令越来越多,AOF文件也会越来越大;过大的AOF文件影响服务器的正常运行,也会导致数据恢复需要的时间过长。文件重写是指定期重写AOF文件,减小AOF文件的体积。把Redis进程内的数据转化为写命令,同步到新的AOF文件;不会对旧的AOF文件进行任何读取、写入操作。
对于AOF持久化来说,文件重写虽然是强烈推荐的,但并不是必须的;即使没有文件重写,数据也可以被持久化并在Redis启动的时候导入;因此在一些实现中,会关闭自动的文件重写,然后通过定时任务在每天的某一时刻定时执行。
文件重写可以压缩AOF文件:
- 删除过期的数据、无效的命令
- 合并重复的命令:为了防止单条命令过大造成客户端缓冲区溢出,对于list、set、hash、zset类型的key,并不一定只使用一条命令;而是以某个常量为界将命令拆分为多条。这个常量在
redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD
定义,不可更改,3.0+版本中值是64。
重写流程
- Redis父进程首先判断当前是否存在正在执行 bgsave/bgrewriteaof的子进程,如果存在则bgrewriteaof命令直接返回,如果存在bgsave命令则等bgsave执行完成后再执行。主要是基于性能方面考虑。
- 父进程执行fork操作创建子进程,这个过程中父进程是阻塞的。
- 3.1) 父进程fork后,bgrewriteaof命令返回
Background append only file rewrite started
信息并不再阻塞父进程,并可以响应其他命令。Redis的所有写命令依然写入AOF缓冲区,并根据appendfsync策略同步到硬盘,保证原有AOF机制的正确。
3.2) 由于fork操作使用写时复制技术,子进程只能共享fork操作时的内存数据。由于父进程依然在响应命令,因此Redis使用AOF重写缓冲区aof_rewrite_buf
保存这部分数据,防止新AOF文件生成期间丢失这部分数据。也就是说,bgrewriteaof执行期间,Redis的写命令同时追加到aof_buf
和aof_rewirte_buf
两个缓冲区。 - 子进程根据内存快照,按照命令合并规则写入到新的AOF文件。
- 5.1) 子进程写完新的AOF文件后,向父进程发信号,父进程更新统计信息,具体可以通过
info persistence
查看。
5.2) 父进程把AOF重写缓冲区的数据写入到新的AOF文件,保证新AOF文件所保存的数据库状态和服务器当前状态一致。
5.3) 使用新的AOF文件替换老文件,完成AOF重写。
触发
rewrite触发,分为手动触发和自动触发:
- 手动触发:直接调用
bgrewriteaof
命令,与bgsave有些类似:都是fork子进程进行具体的工作,且都只有在fork时阻塞。 - 自动触发:根据
auto-aof-rewrite-min-size
和auto-aof-rewrite-percentage
两个参数确定触发时机,同时满足时才会自动触发AOF重写。另外,auto-aof-rewrite-percentage
参数由aof_current_size
、aof_base_size
两个决定。注:可通过info persistence
命令查看参数值。
注意:
- 重写由父进程fork子进程进行
- 重写期间Redis执行的写命令,需要追加到新的AOF文件中,为此Redis引入了aof_rewrite_buf缓存。
缺点
追加 log 文件可能导致体积过大,当系统重启恢复数据时如果是AOF的方式则加载数据会非常慢,几十G的数据可能需要几小时才能加载完,当然这个耗时并不是因为磁盘文件读取速度慢,而是由于读取的所有命令都要在内存中执行一遍。由于每条命令都要写 log,所以使用AOF的方式,Redis 的读写性能也会有所下降。
AOF文件格式,保存的是一条条命令,首先存储命令长度,然后存储命令,分隔符。
redis server中有一个sds aof_buf
,如果AOF持久化打开的话,每个修改数据库的命令都会存入这个aof_buf
(保存的是AOF文件中命令格式的字符串),然后event loop每循环一次,在server cron
中调用flushaofbuf
,把aof_buf
中的命令写入AOF文件(其实是write,真正写入的是内核缓冲区),再清空aof_buf
,进入下一次loop。数据库的所有变化,都可以通过AOF文件中的命令来还原,达到保存数据库的效果。
flushaofbuf
调用write,它只是把数据写入内核缓冲区,真正写入文件时内核自己决定的,可能需要延后一段时间。redis支持配置,可以配置每次写入后sync,则在redis里面调用sync,将内核中的数据写入文件,这不过这要耗费一次系统调用,耗费时间而已。还可以配置策略为1秒钟sync一次,则redis会开启一个后台线程(所以说redis不是单线程,只是单eventloop而已),这个后台线程会每一秒调用一次sync。RDB的时候为什么没有考虑sync的事情呢?因为RDB是一次性存储的,不像AOF这样多次存储,RDB的时候调用一次sync也没什么影响,而且使用bg save的时候,子进程会自己退出(exit),这时候exit函数内会冲刷缓冲区,自动写入文件中。
如果不想使用aof_buf保存每次的修改命令,也可以使用aof持久化。redis提供aof_rewrite
,即根据现有的数据库生成命令,然后把命令写入aof文件中。进行aof_rewrite的时候,redis变量每个数据库,然后根据key-value对中value的具体类型,生成不同的命令,比如是list,则它生成一个保存list的命令,这个命令里包含了保存该list所需要的的数据,如果这个list数据过长,还会分成多条命令,先创建这个list,然后往list里面添加元素,总之,就是根据数据反向生成保存数据的命令。然后将这些命令存储aof文件,这样不就和aof append达到同样的效果了么?
AOF格式也支持后台模式。执行aof_bgrewrite
时,也是先fork一个子进程,让子进程进行aof_rewrite,把它复制的数据库写入一个临时文件,然后写完后用信号通知父进程。父进程判断子进程的退出信息是否正确,然后将临时文件更名成最终的AOF文件。
在子进程持久化期间,父进程的数据库可能有更新,怎么把这个更新通知子进程呢?进程间通信?
在子进程执行aof_bgrewrite期间,父进程会保存所有对数据库有更改的操作的命令(增,删除,改等),把他们保存在aof_rewrite_buf_blocks
链表中,每个block都可以保存命令,存不下时,新申请block,然后放入链表后面即可。当子进程通知完成保存后,父进程将aof_rewrite_buf_blocks
的命令append进AOF文件就可以。
至于AOF文件的载入,也就是一条一条的执行AOF文件里面的命令而已。不过考虑到这些命令就是客户端发送给redis的命令,所以redis干脆生成一个假的客户端,它没有和redis建立网络连接,而是直接执行命令即可。首先搞清楚,这里的假的客户端,并不是真正的客户端,而是存储在redis里面的客户端的信息,里面有写和读的缓冲区,它是存在于redis服务器中的。直接读入AOF的命令,放入客户端的读缓冲区中,然后执行这个客户端的命令即可完成AOF文件的载入。
// 创建伪客户端
fakeClient = createFakeClient();
while(命令不为空) {
// 获取一条命令的参数信息 argc, argv
// 执行
fakeClient->argc = argc;
fakeClient->argv = argv;
cmd->proc(fakeClient);
}
RDB vs AOF
bgsave做镜像全量持久化,AOF做增量持久化。
AOF持久化的实时性更好,即当进程意外退出时丢失的数据更少,因此AOF是目前主流的持久化方式,不过RDB持久化仍然有其用武之地。
RDB保存的只是最终的数据库,它是一个结果。AOF不同于RDB保存db的数据,它保存的是一条条建立数据库的命令。
综合
在redis实例重启时,优先使用aof来恢复内存的状态,如果没有aof日志,就会使用rdb文件来恢复。
aof文件过大恢复时间过长怎么办?定期做aof重写,压缩aof文件日志大小。Redis4.0之后有了混合持久化的功能,将bgsave的全量和aof的增量做融合处理,这样既保证了恢复的效率又兼顾了数据的安全性。
如果对方追问那如果突然机器掉电会怎样?取决于aof日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。
RDB适合灾难恢复
AOF:把所有的对 Redis 的服务器进行修改的命令都存到一个文件里,命令的集合。
每一个写命令都通过 write 函数追加到 appendonly.aof
中。
缺点是对于相同的数据集来说,AOF 的文件体积通常要大于 RDB 文件的体积。根据所使用的 fsync 策略,AOF 的速度可能会慢于 RDB。