首先,我们来说说redis(5.0.8)为何需要持久化机制
如果有人问你:“你会把 Redis 用在什么业务场景下?”我想你大概率会说:“我会把它当作缓存使用,因为它把后端数据库中的数据存储在内存中,然后直接从内存中读取数据,响应速度会非常快。”没错,这确实是 Redis 的一个普遍使用场景,但是,这里也有一个绝对不能忽略的问题:一旦服务器宕机,内存中的数据将全部丢失。
我们很容易想到的一个解决方案是,从后端数据库恢复这些数据,但这种方式存在两个问题:一是,需要频繁访问数据库,会给数据库带来巨大的压力;二是,这些数据是从慢速数据库中读取出来的,性能肯定比不上从 Redis 中读取,导致使用这些数据的应用程序响应变慢。所以,对 Redis 来说,实现数据的持久化,避免从后端数据库中进行恢复,是至关重要的。
这里顺带说下为何会给数据库带来巨大的压力,一是可能含有大量的请求,造成数据库连接池占满,甚至其中可能还会有慢sql,导致数据库一时间压力过多;二是如果这些redis数据并非热数据,可能造成mysql的缓存被冷数据覆盖,降低缓存覆盖率,此时用户可能透过redis请求,或某些直接进行的DB操作,导致线上数据访问性能降低。
redis持久化的两种方式
- RDB(内存快照):在指定时间间隔内对内存中的redis进行快照并持久化到磁盘中,默认持久化到文件名称为dump.rdb,可在redis.conf配置文件中配置,redis.conf文件默认位于redis到安装包下
- AOF(写后日志):记录每次redis服务的执行操作命令,以追加的方式的记录到日志中,默认生成的文件为appendonly.aof,可在redis.conf配置文件中配置
AOF
AOF日志是如何实现的?
说到日志,我们比较熟悉的是数据库的写前日志(Write Ahead Log, WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志。
那 AOF 为什么要先执行命令再记日志呢?要回答这个问题,我们要先知道 AOF 里记录了什么内容。
传统数据库的日志,例如 redo log(重做日志),记录的是修改后的数据,而 AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。
我们以 Redis 收到“set key cxy”命令后记录的日志为例,看看 AOF 日志的内容。其中,“*3”表示当前命令有三个部分,每部分都是由“3 set”表示这部分有 3 个字节,也就是“set”命令。
cat appendonly.aof
$3
set
$4
name
$18
爱玩德州的cxy
但是,为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。
PS: redis不像mysql那样有语法分析、词法分析可以检测出命令是否错误,而使用执行结果作为命令是否正确的依据,加快了速度,提升性能。除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操作。(可能会阻塞后续的命令)
不过,AOF也存在两个风险:
- 首先,如果刚执行完一个命令,还没来得及记录日志就宕机了,那个该命令就会有丢失的风险
- 其次,AOF虽然避免了阻塞当前命令,但是会给下一个操作带来风险,因为aof写日志是在主线程中执行的,在写日志文件时,如果此时磁盘的IO压力大,导致写盘很慢,进而导致后续的操作无法执行
上述的两个风险都和aof写回磁盘相关,意味着如果能有合适的写回磁盘策略,上述问题可以得到缓解。
三种回写策略
其实,对于这个问题,AOF 机制给我们提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值。
- Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
- Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
- No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美。我们来分析下其中的原因。
- “同步写回”可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能;
- 虽然“操作系统控制的写回”在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在 Redis 手中了,只要 AOF 记录没有写回磁盘,一旦宕机对应的数据就丢失了;( 异步刷盘也会阻塞主线程;主线程写到缓冲区之后会判断一下距离上次fsync成功的时间有没有超过2秒 (即当前fsync是否在持续执行) ,有就阻塞一下主线程等待fsync完成,因为这个时候磁盘压力可能比较大了。)
- “每秒写回”采用一秒写回一次的频率,避免了“同步写回”的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中。
虽然制定了一部分的回写策略,随着aof文件的越来越大,依旧存在部分性能问题:
- 文件系统本身对文件大小有限制,无法保存过大的文件;
- 如果文件太大,之后再往里面追加命令记录的话,效率也会变低;
- 如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用。
所以,我们就要采取一定的控制手段,这个时候,AOF 重写机制就登场了。
AOF重写机制
我们知道,aof文件是以追加的方式的写入命令,就存在一个键值被多条命令反复修改,此时,aof文件中就会记录多条命令。但是在重写时,是根据这个键值当前的最新状态,为它生成对应的写入命令。这样以来,一个键值对只需要一条命令即可。即只会为最终存在的数据生成命令,不考虑之前的改动。
我们通过以下例子来更好的理解下:
当我们对一个列表先后做了 6 次修改操作后,列表的最后状态是[“D”, “C”, “N”],此时,只用 LPUSH u:list “N”, “C”, "D"这一条命令就能实现该数据的恢复,这就节省了五条命令的空间。对于被修改过成百上千次的键值对来说,重写能节省的空间当然就更大了。
AOF重写触发方式配置
auto-aof-rewrite-min-size: 表示运行AOF重写时文件的最小大小,默认为64MB
auto-aof-rewrite-percentage: 这个值的计算方法是:当前AOF文件大小和上一次重写后AOF文件大小的差值,再除以上一次重写后AOF文件大小。也就是当前AOF文件比上一次重写后AOF文件的增量大小,和上一次重写后AOF文件大小的比值。
#默认配置
auto-aof-rewrite-min-size 64MB
auto-aof-rewrite-percentage 100
AOF文件大小同时超出上面这两个配置项时,会触发AOF重写。
AOF重写原理
和AOF日志由主线程写回不同,重写过程是由后台子进程bgrewriteaof来完成的,避免阻塞主线程操作,也可以使用redis的cli尝试执行下该命令。
每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了redis内存中的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。 ( fork子进程时,子进程是会拷贝父进程的页表,即虚实映射关系,而不会拷贝物理内存。子进程复制了父进程页表,也能共享访问父进程的内存数据了,此时,类似于有了父进程的所有内存数据。当父进程进行修改时,会开辟一份新的内存空间,修改映射表信息,就是操作系统的写时复制,下面会说到。)
因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。这样一来,即使宕机了,这个 AOF 日志的操作仍然是齐全的,可以用于恢复。即在重写期间,会生成一个新的aof文件,旧的aof文件依旧在进行写入。
重写操作会产生一个AOF重写缓存,重写过程中操作命令同时会在AOF重写缓存中记录一份。这样,重写日志也不会丢失最新的操作,即内存的后续更改也能被记录下来,待拷贝数据的所有记录重写完成后,重写日志记录的这些最新操作也会写入新的 AOF 文件,以保证redis数据最新状态的记录。此时,我们就可以用新的 AOF 文件替代旧文件了。
总结来说,每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;然后,使用aof缓存和aof重写缓存保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。
到这里,AOF的使用原理差不多就说完了,接下来放出两个思考题
1、AOF 日志重写的时候,是由 bgrewriteaof 子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?
2、AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?
RDB
之前有Redis 避免数据丢失的 AOF 方法。这个方法的好处,是每次执行只需要记录操作命令,需要持久化的数据量不大。一般而言,只要你采用的不是 always 的持久化策略,就不会对性能造成太大影响。
但是AOF的缺点就在于文件可能过大,数据恢复慢。
所以和AOF相比,RDB的优势就在于记录某一时刻的数据,生成当前时刻的快照。RDB存储的是压缩后的二进制文件,文件小,对不同的数据类型有针对性的优化,通过解析可以还原数据。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。
- save:在主线程中执行,会导致阻塞;
- bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。
#配置文件
save 900 1 #表示900s内如果有1条是写入命令,就触发产生一次快照,可以理解为就进行一次备份
save 300 10 #表示300s内有10条写入,就产生快照
save 60 10000 #表示60s内有10000条写入,就产生快照
写时复制
在进行内存快照的情况下,是需要内存中的值保持不变的,才能将当时值写到RDB文件中,最终形成快照文件,但是如果内存的值不允许修改,无疑会给业务服务造成巨大影响,而上述aof持久化是将命令进行缓存然后进行刷盘,aof文件重写也是首先拷贝一份内存地址映射,虽然在重写期间可能造成修改,但是拥有aof重写缓存来解决这个问题,那么内存快照如何解决快照时内存数据的更改呢?
所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。
这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。
PS:写时复制在实际过程中,是子进程复制了主线程的页表,所以通过页表映射,能读到主线程的原始数据,而当有新数据写入或数据修改时,主线程会把新数据或修改后的数据写到一个新的物理内存地址上,并修改主线程自己的页表映射。所以,子进程读到的类似于原始数据的一个副本,而主线程也可以正常进行修改,和上述aof重写类似
增量快照
对于快照来说,如果设置的间隔时间较长,并且此时发生了服务器宕机,那么可能导致这段时间内修改的数据全部丢失,无法进行恢复
所以,要想尽可能的保证数据的完整性,需要缩小快照的间隔时间t,但是t越小也就意味着快照触发的频率越高,主进程会不断的fork子进程,fork是会阻塞主进程并且频繁的大量数据写入磁盘也就造成IO压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个快照已经开始做了。 (快照持久化和磁盘写入是两个过程,并非都完全由redis控制)
为了解决频繁进行快照的问题,可以进行增量快照,不需要每次都同步全量数据。就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。
在第一次做完全量快照后,T1 和 T2 时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。但是,这么做的前提是,我们需要记住哪些数据被修改了。你可不要小瞧这个“记住”功能,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。如下图所示:
但是增量快照的缺点就是空间成本较大。如果我们对每一个键值对的修改,都做个记录,那么,如果有 1 万个被修改的键值对,我们就需要有 1 万条额外的记录。而且,有的时候,键值对非常小,比如只有 32 字节,而记录它被修改的元数据信息,可能就需要 8 字节,这样的画,为了“记住”修改,引入的额外空间开销比较大。这对于内存资源宝贵的 Redis 来说,有些得不偿失。
到这里,你可以发现,虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销,那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做到尽量少丢数据呢?
混合持久化方式
Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
混合模式原理
混合模式需要开启AOF持久化模式和混合持久化模式
我这边把配置也贴一下
#开启AOF持久化
appendonly yes
#每秒写回
appendfsync everysec
# 触发重写的文件增长百分比幅度
auto-aof-rewrite-percentage 100
# 触发重写的最小文件大小
auto-aof-rewrite-min-size 64mb
# 开启混合持久化
aof-use-rdb-preamble yes
# aof重写期间是否同步
no-appendfsync-on-rewrite no
在上面的aof和rdb方式中,我已经详细的说明了原理,在这里也同时做一个总结。
正常情况下,redis接收到服务器数据指令后,进行逻辑处理,按照配置[appendfsync everysec]将处理指令每秒对磁盘aof文件进行追加,此时触发了aof重写逻辑,redis fork出子进程,将aof重写逻辑放到子进程中异步执行,主线程继续处理请求,但是所有请求都会写入aof到重写缓冲区,待aof重写完成后,对父进程发送一个完成信号,将AOF重写缓冲区中的内容全部写入到新的AOF文件中,之后清空重写缓冲区的内容。注意,在“主进程写入命令到AOF缓存”和“对新的AOF文件进行改名,覆盖原有的AOF文件”两个步骤会阻塞主进程,但此时已经将AOF重写对性能的影响降到了最低。若[no-appendfsync-on-rewrite yes]则代表set操作会被阻塞,需要等待重写成功后才会执行,该时刻就不会造成数据丢失的可能,而上述描述中是建立在[no-appendfsync-on-rewrite no]的情况下,若此时发生宕机,则缓冲区的数据会丢失。
混合模式下的aof文件大家也可以去看下,我这边也放一个截图出来
可以看到,当我使用cat浏览文件时,前部分是看不懂的一些乱码字符,后部分则是aof模式的命令行。
三种方式的对比
对于持久化的文件大小及恢复速度来看:
- aof模式文件最大,且恢复最慢
- rdb模式下最快且文件最小
- 混合模式适中
对于可靠性来看:
- aof模式最可靠,但是如果设置always同步写回,会造成性能问题
- rdb需要依赖间隔时间,若间隔时间长丢失数据多,间隔短则会造成主线程阻塞和磁盘压力
- 混合模式依旧适中,不会造成性能压力且恢复速度快
在线上我们到底该怎么做?我提供一些自己的实践经验。
- 如果Redis中的数据并不是特别敏感或者可以通过其它方式重写生成数据,可以关闭持久化,如果丢失数据可以通过其它途径补回;
- 自己制定策略定期检查Redis的情况,然后可以手动触发备份、重写数据;
- 单机如果部署多个实例,要防止多个机器同时运行持久化、重写操作,防止出现内存、CPU、IO资源竞争,让持久化变为串行;
- 可以加入主从机器,利用一台从机器进行备份处理,其它机器正常响应客户端的命令;
- RDB持久化与AOF持久化可以同时存在,配合使用。
对于上面的思考题,我这边也给出了一部分了参考:
问题1:
Redis采用fork子进程重写AOF文件时,潜在的阻塞风险包括:fork子进程和AOF重写过程中父进程产生写入的场景。
fork子进程,fork这个瞬间一定是会阻塞主线程的,这个我们在上面也说过了,但fork子进程需要拷贝进程必要的数据结构,其中有一项就是拷贝内存页表(虚拟内存和物理内存的映射索引表),这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的,阻塞时间取决于整个实例的内存大小,实例越大,内存页表越大,fork阻塞时间越久。拷贝内存页表完成后,子进程与父进程指向相同的内存地址空间,也就是说此时虽然产生了子进程,但是并没有申请与父进程相同的内存大小。那什么时候父子进程才会真正内存分离呢?“写实复制”顾名思义,就是在写发生时,才真正拷贝内存真正的数据,这个过程中,父进程也可能会产生阻塞的风险。
fork出的子进程指向与父进程相同的内存地址空间,此时子进程就可以执行AOF重写,把内存中的所有数据写入到AOF文件中。但是此时父进程依旧是会有流量写入的,如果父进程操作的是一个已经存在的key,那么这个时候父进程就会真正拷贝这个key对应的内存数据,申请新的内存空间,这样逐渐地,父子进程内存数据开始分离,父子进程逐渐拥有各自独立的内存空间。因为内存分配是以页为单位进行分配的,默认4k,如果父进程此时操作的是一个bigkey,重新申请大块内存耗时会变长,可能会产阻塞风险。另外,如果操作系统开启了内存大页机制(Huge Page,页面大小2M),那么父进程申请内存时阻塞的概率将会大大提高,所以在Redis机器上需要关闭Huge Page机制。Redis每次fork生成RDB或AOF重写完成后,都可以在Redis log中看到父进程重新申请了多大的内存空间。
问题2:
AOF重写不复用AOF本身的日志,一个原因是父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。二是如果AOF重写过程中失败了,那么原本的AOF文件相当于被污染了,无法做恢复使用。所以Redis AOF重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的AOF文件产生影响。等重写完成之后,直接替换旧文件即可。
参考
极客时间 -《Redis核心技术与实战》