前言
本文参考源码版本为
redis6.2
我们先来看看演化这个事儿。
软件都是迭代中越做越复杂。redis 也是这样,刚开始做了一个简单功能,通过一定的数据结构组织数据,让你能够相当快的拿到这些数据;
慢慢地,场景越来越多,想要的功能支持越来越强烈;好,redis 慢慢给你实现。
当然,这是功能层面的迭代演进,性能上呢?
首先,我要处理客户端的请求,按照服务端 socket 编程几个步子写,相当容易;
写好之后,你说不行,QPS 至少 10W+;于是,继续改造,写了一套 Reactor 模型来应对网络请求,10W+ 的 QPS 目标达到了。
用了一段时间你又说,这服务一断电,所有数据就没了,我的心血… 能不能靠谱点,想个办法恢复!
于是,又接着写了一个拷贝进程,满足一定条件,就把整个内存快照同步至磁盘,出现断电或者重启,直接从磁盘加载,数据就恢复了,完美!这就是 RDB。
你用了段时间,服务故障崩溃了,重启时 RDB 给你进行了数据恢复,你依然很生气,怎么丢了最近很多数据?
我又想了个办法,干脆把所有的命令都记录下来,下次恢复数据直接重放不就行了?balbala… 很快就写了出来,并且支持三种落盘策略(always, everysec, non)以提升效率,这就是 AOF。
交付的时候,我特意嘱咐道,RDB 和 AOF 要结合使用,数据恢复时,会先加载 RDB 全量数据,然后再重放 AOF 增量就能恢复了,这种情况下能尽可能将损失降到最低。生产上,我们也经常采用这种方式。
一天,你因为线上访问量剧增,redis 服务崩了,准备重启,但花了好长时间才完全恢复,你怒火冲冲质问道,怎么要重启这么久??? 因为 AOF 文件太大,重放了很久。
很好奇,前有 RDB 全备,AOF 增量备份能有多少数据。排查后发现,主要是针对一批相同的 keys 做了大量修改操作;突然间意识到,其实存最新的 value 即可。
于是又写了一个叫 AOF 重写的操作,本质是从拿到这些 key 的最新 value,生成 AOF 指令,来达到压缩 AOF 文件的目的。
终于,开始稳定了。
当然,redis 发展肯定没有这么波折,以上流程只是为了形象化展示 redis 发展历程。
一、持久化?
持久化是什么?就 redis 而言,本质就是将内存中不稳定的数据,通过一些刷盘策略写入磁盘,从而达到断电等故障能恢复数据的效果。
说到持久化,我们一般关心以下几个问题:
- 落盘策略(频率、时机、格式等)
- 恢复策略(自动、手动等)
等等,其实我们可能更关心的是:
- 是否会丢数据?
- 是否造成节点对外不可用?
- 性能会不会受影响?
- 能否手动执行?
- 生产环境我们该如何使用?
带着问题,我们一起往下寻找答案~
二、RDB
RDB 全称 redis database,是以时间为轴线的全量内存快照,存在磁盘上。快照,就是那个时间点内存数据库的全景图,就像拍照一样,把那个瞬间的所有状态“咔”的一声记录下来。
实际过程中,我们可以定期执行 RDB 操作,要进行数据恢复的时候,找到最近的一个快照点进行恢复,可以将数据丢失风险尽可能的降低。
1. 落盘策略:
很慢?
这个主要取决于你的数据量,比如,你的配置是 16Gb redis 内存,目前已占用 80%,相当于 10多个Gb的数据了,执行一次是需要花一定的时间。
redis 提供了两种方式,一种是 save,另一种是 bgsave。两者底层处理原理都一样,最大的区别点在于 SAVE 由主线程执行,会阻塞客户端命令;BGSAVE 由 fork 的子进程处理,不会阻塞执行。
1)SAVE:
在命令行输入:
127.0.0.1:6379> save
OK
全程会阻塞,直到 RDB 文件创建完毕。
2)BGSAVE:
在命令行输入:
127.0.0.1:6379> bgsave
Background saving started
这个过程会派生出子进程来创建 RDB 文件,父进程继续处理命令请求。
手动执行?
可以在命令行通过 SAVE 或者 BGSAVE 命令手动执行,生成 RDB 文件。
自动执行?
相比于手动执行,通常情况下,我们更希望通过一些配置,达到某些策略条件之后,自动执行 RDB 持久化。
用户可以通过 save 选项设置多个保存条件,但只要其中任意一个条件被满足,服务器就会执行 BGSAVE 命令。
save 900 1
save 300 10
save 60 10000
那么只要满足以下三个条件中的任意一个,BGSAVE 命令就会被执行:
- 服务器在900秒之内,对数据库进行了至少1次修改。
- 服务器在300秒之内,对数据库进行了至少10次修改。
- 服务器在60秒之内,对数据库进行了至少10000次修改。
占用额外内存?
你可能会问,BGSAVE 操作会 fork 一个进程处理,而子进程会拥有父进程完整的数据集,这个过程相当于占用了双倍的内存空间?
不会。fork 子进程使用了一种叫做写时拷贝的技术(copy-on-write)。
写时拷贝
是指子进程的页表项指向与父进程相同的物理内存页,这样只拷贝父进程的页表项就可以了,当然要把这些页面标记成只读。如果父子进程都不修改内存的内容,大家便相安无事,共用一份物理内存页。但是一旦父子进程中有任何一方尝试修改,就会引发缺页异常(page fault)。
此时,内核会尝试为该页面创建一个新的物理页面,并将内容真正地复制到新的物理页面中,让父子进程真正地各自拥有自己的物理内存页,然后将页表中相应的表项标记为可写:
从上面的描述可以看出,对于没有修改的页面,内核并没有真正地复制物理内存页,仅仅是复制了父进程的页表。这种机制的引入提升了fork的性能,从而使内核可以快速地创建一个新的进程。
2. 恢复策略:
RDB 的载入策略是自动加载,没有额外提供载入命令。redis 默认使用 RDB 持久化策略,如果你想采用这种模式, 就不需要再去手动开启了。
在该策略下,redis 在启动时,会自动检测是否存在 RDB 文件,存在的话就进行载入。
服务器在载入RDB文件期间,会一直处于阻塞状态,直到载入工作完成为止。所以,如果你的文件比较大,还是需要花费一定的时间来加载,在这期间,服务对外不可用。
三、AOF
1. 落盘(同步)策略:
首先:
写入
:指通过 write 系统调用,将数据从 aof_buf 写入 内核缓冲区。
落盘(同步)
:指的是将数据从内核缓冲区同步至磁盘中。因为,由于操作系统自身的优化策略,我们通过 write 写入的数据,都是直接进入内核缓冲区,然后会根据内核将数据同步至磁盘。
aof_buf 缓冲区:
redis 服务端提供的缓冲区,请求命令会写入 aof_buf 缓冲区,然后由 aof_buf 缓冲区写入内核缓冲区或者同步至磁盘。
落盘时机:
在主事件循环,通过 beforeSleep 方法触发 flushAppendOnlyFile 调用,这便是入口。那,一条命令,经过哪些过程最终追加到 AOF 文件呢?
- redis 接收请求命令 cmd,并执行。
- cmd 执行成功后,通过 feedAppendOnlyFile 方法将命令写入 aof_buf 缓冲区。
- 下一轮主循环中,通过 flushAppendOnlyFile 尝试写入或者同步。
来看看同步
相关代码块:
try_fsync:
// 如果设置了 aof_no_fsync_on_rewrite = yes 并且 有子进程在活动(rdb, aof rewrite, module)
if (server.aof_no_fsync_on_rewrite && hasActiveChildProcess())
return;
// 如果是 AOF_FSYNC_ALWAYS 策略,直接 fsync 落盘
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
...
redis_fsync(server.aof_fd); /* Let's try to get this data on the disk */
...
} else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
server.unixtime > server.aof_last_fsync)) {
if (!sync_in_progress) {
// 通过后台线程落盘
aof_background_fsync(server.aof_fd);
server.aof_fsync_offset = server.aof_current_size;
}
server.aof_last_fsync = server.unixtime;
}
- 首先,如果设置了 aof_no_fsync_on_rewrite = yes 并且 有子进程在活动(rdb, aof rewrite),将暂时不进行刷盘,避免大量磁盘操作(Linux 这种情况下,最多可能丢失30s 数据)。
- 其次,如果设置了 AOF_FSYNC_ALWAYS 策略,将直接进行 fsync 操作。
- 最后,如果设置的是 AOF_FSYNC_EVERYSEC 策略,将提交给后台线程来完成 fsync 操作。
2. 恢复策略:
恢复一般是指,经历故障或其他行为,通过重启 redis 服务并加载数据文件恢复内存状态的过程。
redis 默认通过 RDB 操作进行持久化,如果需要采用 AOF 持久化可以配置参数:
appendonly yes
服务在重启的过程中会自动检测 AOF 文件是否存在,如果存在的话,就开始通过指令重放来恢复内存数据库状态。
指令重放?redis 通过伪客户端的方式,将命令一条条取出来,走正常的命令处理逻辑,直到处理处理完成。
你可能会问,如果一个 key 出现了多次,也要处理多次吗?
> set key1 value1
> set key1 value11
> set key1 value 111
没错,这种也要执行多次,虽然内存中肯定只会保留最新的一次操作状态。是不是很臃肿?
接下来,我们通过 AOF 重写来看看怎么解决这个问题。
3. 重写
上文提到,AOF 实实在在的记录了每一条命令,尤其是出现大量重复 key 的操作,会使得 AOF 看起来冗余且臃肿;因此,AOF 重写就相当于对 AOF 文件进行压缩,同一个 key 只保留最新的操作记录。
值得注意的是,AOF 重写并不需要从原 AOF 文件中进行压缩,而是直接扫描整个库,把每一个 key / value 转变成写命令,然后追加至临时文件;待重写操作完成后,将临时文件原子性重命名即可。
重写时机?
redis 提供了灵活的重写机制,可以自动触发,也可以手动触发。
首先,需要确定是否开启了 AOF 策略,即:
appendonly yes
你可以在命令行输入 BGREWRITEAOF 手动触发重写:
127.0.0.1:6379> BGREWRITEAOF
Background append only file rewriting started
另外,redis 也提供了自动触发重写条件:
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
每次重写之后都会记录文件大小,作为下一次重写的触发条件:当前文件大小超过上次重写的百分比后,触发重写操作。
同时,为避免 AOF 文件过小而触发重写操作,提供了 auto-aof-rewrite-min-size 参数控制重写条件的下限。
子进程?
为避免阻塞主线程,AOF 重写仍然采用 fork 子进程的方式
重写流程:
1)触发重写条件(自动 or 手动)
2)redis 调用 aof.c#rewriteAppendOnlyFileBackground 方法,并 fork 子进程进行处理
- 2a)子进程重写 AOF 到临时文件
- 2b)父进程继续接收新命令并累加到 server.aof_rewrite_buf 缓冲区
3)直到 2a 完成后
4)父进程尝试将 server.aof_rewrite_buf 数据写入临时文件,并用临时文件替换原 AOF 文件。
四、RDB-AOF混合持久化
我们先看看 redis 重启自动加载持久化文件的片段:
void loadDataFromDisk(void) {
...
if (server.aof_state == AOF_ON) {
// 加载 aof
if (loadAppendOnlyFile(server.aof_filename) == C_OK)
...
} else {
// 加载 rdb
if (rdbLoad(server.rdb_filename,&rsi,RDBFLAGS_NONE) == C_OK) {
...
}
可以看到,要么是加载 AOF 文件,要么加载 RDB 文件;RDB - AOF 混合文件又是如何来的?
随着 redis 的普及使用,大家发现,不管是 RDB 还是 AOF 都有各自缺点,而两者结合使用可以大大改善这种情况。
于是,redis 4.0 推出了 RDB-AOF 这种混合模式,可以通过下面配置开启:
appendonly yes
aof-use-rdb-preamble yes
可以看到,使用混合模式的前提是,需要先开启 AOF;而 aof-use-rdb-preamble
参数则控制是否使用混合模式。
使用这种模式,当数据重写后,AOF 文件前半部分是 RDB 数据(采用 RDB 数据格式),AOF 文件后半部分继续追加 AOF 数据(AOF 数据格式)。
因此,我们回到加载部分来看,当采用 RDB-AOF 混合模式时,数据加载也是直接走 AOF 文件加载,由于 AOF 文件存储了 RDB 和 AOF 数据,也就达到了 全量加载RDB数据 和 增量加载AOF数据
的策略。
五、总结
redis 提供了两种持久化的方式,分别是 RDB
和 AOF
:
- RDB 是内存快照,以二进制的形式存储,加载时按照约定的格式解析即可。
- AOF 则记录真实的执行命令,加载 AOF 文件时,将命令逐条进行重放。
RDB 快照的主要优点:
- 数据文件相对 AOF 文件更小
- 数据加载时相对 加载 AOF 文件更快
AOF 文件主要优点:
- 相对于 RDB 数据丢失可能性更小
- 提供多种策略及时将数据刷盘
实际很多情况下,我们可能需要 性能优势 + 数据持久性,因此 redis 也提供了 RDB-AOF 的混用模式,在 AOF 文件中前半部分采用 RDB 数据格式,后部分增量数据采用 AOF 格式存储,这也叫做 全量RDB持久化 + 增量AOF持久化模式
。
由于 AOF 记录的是命令,就有可能出现同一个 key 记录多次的情况而导致 AOF 过度膨胀,因此,引入 AOF 重写
机制来压缩 AOF 文件;当然,重写也并不需要真正读取原 AOF 文件,而是直接读取内存数据库,生成对应的指令即可。
生成 RDB 文件 和 AOF 文件重写都是通过 fork 子进程
的方式来处理。主要利用了子进程拥有父进程的内存页表项,以及其他从父进程继承的数据项;从而利用子进程的写时复制特性来提升处理效率。
参考文献:
- Redis persistence
- Redis persistence demystified
- redis.config - 6.2
- 为什么 Redis 快照使用子进程
- 《Linux环境编程 从应用到内核》「高峰、李彬」