我们都知道Redis是内存数据库,它将自己的数据存储的内存中。这样一旦服务器进程退出(断电、重启等原因),那么数据将会丢失。为了解决这个问题,Redis提供两种持久化的方式来将数据持久化到硬盘上,即内存快照(RDB)与AOF日志。
推荐最近最新整理收集的Java后端架构进阶学习笔记:Java从入门到架构成长笔记:JVM+并发+源码+分布式+微服务+大厂实战项目+大厂性能调优解决方案(点击这即可免费领取)
1.什么是内存快照
所谓内存快照,顾名思义就是给内存拍个照,在某个时刻把内存中的数据记录下来,以文件的形式保存到硬盘上,这样即使宕机,数据依然存在。在服务器重启后只需要把“照片”中的数据恢复即可。
RDB持久化就是把当前进程的数据在某个时刻生成快照(一个压缩的二进制文件)保存到硬盘的过程,触发RDB持久化过程分为手动触发和自动触发。
1.1 手动触发
手动触发分别对应save和bgsave命令:
1.1.1 save命令
save命令会阻塞当前Redis服务器,直到RDB过程完成为止。在服务器进程阻塞期间,服务器不能处理任何命令请求。因此,当save命令正在执行时,客户端发送的所有命令都会被拒绝,直到save命令执行完毕。
Copyredis>save //等待,直到RDB文件创建完毕
ok
注意:
Redis的单线程模型就决定了,我们要尽量避免所有会阻塞主线程的操作,由于Save命令执行期间阻塞服务器进程,对于内存比较大的实例会造成长时间阻塞,因此线上环境不建议使用。
1.1.2 bgsave命令
bgsave命令会派生出一个子进程(而不是线程),由子进程进行RDB文件创建,而父进程继续处理命令。
Copyredis>bgsave
Background saving started //直接返回,由子进程进行RDB文件创建
redis> //继续处理其它命令
注意:
- 在bgsave命令执行的时候,为了避免父进程与子进程同时执行两个rdbSave的调用而产生竞争条件,客户端发送的save命令会被服务器拒绝。
- 如果bgsave命令正在执行,bgrewriteaof(aof重写)命令会被延迟到bgsave命令之后执行,如果bgrewriteaof命令正在执行,那么客户端发送的bgsave命令会被服务器拒绝。
- 虽然bgsave命令是由子进程进行RDB文件的生成,但是fork()创建子进程的时候会阻塞父进程(详情请往下看)。
1.2 自动触发
因为bgsave命令可以在不阻塞服务器进程的情况下保存,所以redis可以通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次bgsave命令。如:我们向服务器设置如下配置(这也是redis默认的配置):
Copysave 900 1
save 300 10
save 60 10000
那么只要满足如下条件中的一个bgsave命令就会被执行:
- 服务器在900秒内对数据库进行了至少1次修改
- 服务器在300秒内对数据库进行了至少10次修改
- 服务器在60秒内对数据库进行了至少10000次修改
1.3 RDB文件的载入
在Redis启动的时候,只要检测到RDB文件的存在,就会自动加载RDB文件。需要注意的是
- 因为AOF文件的更新频率通常比RDB文件的更新频率高,所以说如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库状态。
- 只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。
注意:服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止
2.内存快照的问题
了解了什么是Redis的RDB持久化,我们来思考两个问题。
2.1 快照的时候数据可以修改吗
Redis RDB持久化是对某一时刻的内存中的全量数据进行拍照。这让我们不得不思考,快照的时候数据可以修改吗?
首先,如果我们使用save命令做持久化,那么由于Redis单线程模型的原因,在持久化的过程中会阻塞,是不能执行其它命令的。也许有人会说可以使用bgave命令,但使用bgsave就没有问题了吗?
我们在拍照的时候,通常摄影师是不让我们动的,因为一动可能照片就模糊了。在Redis 进行内存快照的时候也会如此。如果我们持久化的过程中,有些数据被修改了。那么就会破坏快照的正确性与完整性。
比如在t时刻,我们对内存进行快照,此时我们希望的是记录下来t时刻内存中所有的数据,假设我们的RDB操作需要10s的时间,而t+2s我们执行了一个修改操作把Key1的值由A修改成了B,而此时RDB操作却还没有把Key1的值写入磁盘。在t+5s的时候读取到key1的值写入磁盘。那么此次快照记录的Key1的值就是B,而不是t时刻的A。这样就破坏了RDB文件的正确性。
RDB文件的生成是需要时间的,如果快照执行期间数据不能被修改,对于业务系统来说不能接受的。那么Redis 是如何解决这个问题的呢?
Redis 借助了操作系统提供的写时复制技术(Copy-On-Write, COW),可以让在执行快照的同时,正常处理写操作。简单来说,bgsave fork子进程的时候,并不会完全复制主进程的内存数据,而是只复制必要的虚拟数据结构,并不为其分配真实的物理空间,它与父进程共享同一个物理内存空间。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。此时,如果主线程对这些数据也都是读操作,那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据,此时会给子进程分配一块物理内存空间,把要修改的数据复制一份,生成该数据的副本到子进程的物理内存空间。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。
2.2 可以频繁进行快照操作吗
假设我们在t 时刻做了一次快照,然后又在 t+n 时刻做了一次快照,而在这期间,发生了数据修改。而此时宕机了,那么,只能按照 t 时刻的快照进行恢复。那么这n秒的数据就彻底丢失无法恢复了。
所以,要想尽可能恢复数据,就只能缩短快照执行的时间间隔,间隔的时间越小,丢失数据也就越少。那么可以频繁地执行快照操作吗?
我们知道bgsave 执行时并不阻塞主线程,但是这不代表可以频繁执行快照操作。
一方面,持久化是一个写入磁盘的过程,频繁将全量数据写入磁盘,会给磁盘带来很大压力,频繁执行快照也容易导致前一个快照还没有执行完,后一个又开始了,这样多个快照竞争有限的磁盘带宽,容易造成恶性循环。
再者,bgsave所fork出来的子进程执行操作虽然并不会阻塞父进程的操作,但是fork出子进程的操作却是由主进程完成的,会阻塞主进程,fork子进程需要拷贝进程必要的数据结构,其中有一项就是拷贝内存页表(虚拟内存和物理内存的映射索引表),这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的,阻塞时间取决于整个实例的内存大小,实例越大,内存页表越大,fork阻塞时间也就越久。
也许有人会想到是否可以做增量快照呢?也就是只对上一次快照之后的数据做快照。
首先思路肯定是可以,但是增量快照要求记住哪些数据上一次快照之后产生的。这就需要额外的元数据来记录这些信息,会引入额外的空间消耗。这对于内存资源宝贵的 Redis 来说,并不是一个很好的方案。
如果不能频繁执行快照操作,那么该如何解决两次快照之间的数据丢失的问题呢?Redis 还提供了另外一种持久化方式——AOF(append to file)日志。
前面我们总结到使用Redis内存快照进行持久化,在t 时刻做了一次快照,然后又在 t+n 时刻做了一次快照,此时如果宕机,则会丢失在此期间内修改的数据。但又不能频繁地进行内存快照,那么有什么办法能够尽可能的减少这种数据丢失呢?Redis提供了另一种持久化的方式——AOF日志(Append Only File)。
3.什么是AOF日志持久化
3.1 执行后写日志
与内存快照保存当前内存中的数据所不同,AOF持久化是通过保存Redis服务器所执行的写命令来记录数据库状态的。即每执行一个命令,就会把该命令写到日志文件里。
需要注意的是写日志的操作在Redis执行命令将数据写入内存之后,如下图所示:
这样做的好处就是不会阻塞当前操作,也可以避免额外的检查开销,如果是在命令执行前进行写日志的操作,一旦命令语法是错误的,不进行检查的话就会导致写入到日志文件中的命令是错误的,在使用日志文件恢复数据的时候就会出错。而在命令执行后再进行日志的写入则不会有这个问题。
但是也存在两个问题,
- AOF 虽然避免了对当前命令的阻塞,但却可能会给下一个操作带来阻塞风险。因为,AOF 日志是在主进程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了
- 如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。
3.2 AOF 缓冲区
针对上面两个问题,Redis提供了缓冲区的方式进行AOF日志的记录,以达到尽可能的避免阻塞和数据丢失的问题。
即Redis在执行完命令进行持久化的时候,并非直接写入磁盘日志文件,而是先写入AOF缓冲区内,之后再通过某种策略写到磁盘。
使用缓存区的方式进行AOF日志的记录,上面提到的两个问题其实就和日志从缓冲区写入磁盘的时机有关系。
3.3 三种回写策略
Redis AOF 机制提供了三种回写磁盘的策略。
- Always(同步写回): 命令写入 AOF缓冲区后调用系统 fsync操作同步到AOF文件, fsync完成后线程返回
- Everysec(每秒写回): 命令写人 AOF缓冲区后调用系统 write操作, write完成后线程返回。fsync同步文件操作由专门线程每秒调用一次
- No(操作系统自动写回): 命令写入 AOF缓冲区后调用系统 write操作,不对AOF文件做fsync同步,同步硬盘操作由操作系统负责,通常同步周期最长30秒
但其实可以看出这三种回写策略都并不能完美地解决问题,
配置为 always时,每次写入都要同步AOF文件,硬盘的写入速度无法与内存相提并论,显然与Redis髙性能特性背道而驰
配置为no,由于操作系统每次同步AOF文件的周期不可控,而且会加大每次同步硬盘的数据量,虽然提升了性能,但数据安全性无法保证。
配置为 everysec,是简易的同步策略,也是默认配置,虽然能做到兼顾性能和数据安全性。但极端情况下一会造成1秒内的数据丢失。
在真正使用中,我们可以根据具体对性能和数据完整性的要求,分析这三种回写策略,选择适合的策略来进行持久化。
| 回写策略 | 优点 | 缺点 |
|---|---|---|
| Always(同步写回) | 可靠性高、数据基本不丢失 | 性能较差 |
| Everysec(每秒写回) | 性能适中 | 宕机时丢失1秒内的数据 |
| No(操作系统自动写回) | 性能好 | 宕机时丢失数据较多 |
3.4 AOF重写
3.4.1 日志文件越来越大怎么办
选择了合适的回写策略,AOF这种持久化的方式还有其它问题吗?
因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,过大的AOF文件不仅追加命令会变慢,而且可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。
这个时候就要用到AOF重写机制了
Copyredis> set testKey testValue
OK
redis> set testKey testValue1
OK
redis> del testKey
OK
redis> set testKey hello
OK
redis> set testKey world
OK
AOF 文件是以追加的方式,逐一记录接收到的写命令的。当一个键值对被多条写命令反复修改时,AOF 文件会记录相应的多条命令。如上示例,我们执行完命令后,Redis会在AOF里面追加5条命令。但实际上只需要set testKey world一条命令就够了。
AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。比如说,当读取了键值对“testkey”: “world”之后,重写机制会记录 set testkey world这条命令。这样,当需要恢复时,可以重新执行该命令,实现“testkey”: “world”的写入。
这样,重写后的日志,从5条变成了1条,而对于可能被修改过成百上千次的键值对来说,重写能节省的空间就更大了。
虽然 AOF重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志都写回磁盘,仍然是一个非常耗时的过程。这时,我们不得不关注:重写会不会导致阻塞?这就要看看AOF重写的过程是怎么样的
3.4.2 AOF 重写过程
因为AOF重写也是一个非常耗时的过程,又因为Redis单线程的特性,同内存快照一样,AOF重写的过程也是由父进程fork出bgrewriteaof子进程来完成的.
使用子进程(而不是开启一个线程)进行AOF重写虽然可以避免使用锁的情况下,保证数据安全性,但是会带来子进程和父进程一致性问题。 例如在开始重写之后父进程又接收了新的键值对此时子进程是无法知晓的,当子进程重写完成后的数据库和父进程的数据库状态是不一致的。
如下表:
| 时间 | 服务器进程(父进程) | 子进程 |
|---|---|---|
| T1 | 执行命令 SET K1 V1 | |
| T2 | 执行命令 SET K1 V1 | |
| T3 | 创建子进程,执行AOF文件重写 | 开始AOF重写 |
| T4 | 执行命令 SET K2 V2 | 执行重写 |
| T5 | 执行命令 SET K3 V3 | 执行重写 |
| T6 | 执行命令 SET K4 V4 | 完成AOF重写 |
在T6时刻服务器进程有了4个键,而子进程却只有1个键
为了解决这种不一致性,Redis设置了一个AOF重写缓冲区。
在子进程执行AOF重写期间。服务器进程需要执行以下3个动作:
- 执行客户端命令
- 执行后追加到AOF缓冲区
- 执行后追加到AOF重写缓冲区
子进程完成AOF重写后,它向父进程发送一个信号,父进程收到信号后会调用一个信号处理函数,该函数把AOF重写缓冲区的命令追加到新AOF文件中然后替换掉现有AOF文件。父进程处理完毕后可以继续接受客户端命令调用,可以看出在AOF后台重写过程中只有这个信号处理函数会阻塞服务器进程。 下表是完整的AOF后台重写过程:
| 时间 | 服务器进程(父进程) | 子进程 |
|---|---|---|
| T1 | 执行命令 SET K1 V1 | |
| T2 | 执行命令 SET K1 V1 | |
| T3 | 创建子进程,执行AOF文件重写 | 开始AOF重写 |
| T4 | 执行命令 SET K2 V2 | 执行重写 |
| T5 | 执行命令 SET K3 V3 | 执行重写 |
| T6 | 执行命令 SET K4 V4 | 完成AOF重写,向父进程发送信号 |
| T7 | 接收到信号,将T5 T6 T7 服务器的写命令追加到新的AOF文件末尾 | |
| T8 | 用新的AOF替换旧的AOF |
这样就可以保证重写日志期间的所有操作也都会写入新的AOF文件。
需要注意的是, T7 T8执行的任务会阻塞服务器处理命令。
总的来说,就是每次 AOF 重写时,Redis 会先fork出一个子进程用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。
3.4.3 AOF文件恢复
在Redis 服务器重启后,会优先去载入AOF日志文件。因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。
而由于Redis命令只能在客户端上下文中执行,Redis会创建一个没有网络连接的伪客户端来执行AOF文件中的内容。
4. 小结
本文主要总结了Redis AOF 持久化的方式,介绍了它同步磁盘的三种策略,以及日志文件过大时如何进行重写。我们知道Redis持久化方式有AOF和RDB两种,那么这两种持久化方式各自有什么优点和缺点?真正使用中我们应该如何去选择合适的持久化方式,又可能遇到哪些问题呢?