3.1 宕机恢复,不丢数据稳如山
我是一个基于内存的数据库,名字叫 Redis
。我对数据读写操作的速度快到令人发指,很多程序员把我当做缓存使用系统,用于提高系统读取响应性能。
然而,快是需要付出代价的:内存无法持久化,一旦断电或者宕机,我保存在内存中的数据将全部丢失。
此时此刻,MySQL 失去了我这道高性能缓存大佬支撑,大量流量会打到 MySQL
, 可能带来更严重的问题。
MySQL:“你赶紧重启从我这里获取恢复数据呀。”
Redis:“不行呀,如果是大量数据需要恢复,会给你造成更大的压力。“
MySQL:“那怎么办?”
Redis:“别怕,我有两大杀手锏,实现了数据持久化,做到宕机快速恢复,不丢数据稳如狗,避免从数据库中慢慢恢复数据,他们分别是 RDB 快照和 AOF(Append Only File)。“
MySQL 说道:“别墨迹,赶紧开搞吧,我快扛不住了。”
3.1.1 RDB 快照
数据存储在内存中,我把内存中的数据写到磁盘上就实现了持久化,当重启的时候就把保存在磁盘的快照数据加载快速恢复到内存中,这样就能实现重启后正常提供服务。
MySQL:“我有一个建议,每次执行写指令操作内存的同时写到磁盘。”
“你的建议很好,下次不要再建议了。
这个方案有个致命问题:每次写指令不仅写内存还写磁盘,磁盘的性能相对内存而言太慢,会导致我的性能大大降低,让我快不起来了。”
MySQL:那你如何规避这个问题呢?
程序员通常把我当做缓存使用,一致性要求没那么高,我不需要把你所有的数据都保存下来,数据持久化使用了RDB 内存快照的方式来实现宕机快速恢复。
我在快速执行大量写指令过程中,内存数据会一直变化。持久化的数据并不需要时刻与内存中数据一致,RDB 内存快照,指的就是 Redis 内存中的某一刻的数据。
好比时间定格在某一刻,当我们拍照时,把某一刻的瞬间画面定格记录下来。
我跟这个类似,就是把某一刻的数据以文件的形式「拍」下来,写到磁盘上。这个文件叫做 RDB 文件,是 Redis Database 的缩写。
我只需要定时执行 RDB 内存快照,就不必每次执行写指令都写磁盘,既实现了快,还实现了持久化。
图3-1
当在进行宕机后重启数据恢复时,直接将磁盘的 RDB 文件读入内存即可。
1. RDB 生成策略
MySQL:”什么时候触发生成 RDB 快照呢?“
我用的是单线程模型执行读写指令,所以需要尽可能避免阻塞 RDB 文件生成阻塞主线程,先看看有哪些情况会触发 RDB 快照持久化操作。
有两种情况会触发 RDB 持久化。
- 手动触发:执行
save
或bgsave
命令。 - 自动触发:一共有四种情况会自动触发执行
bgsave
命令生成 RDB 文件,后文细说。
手动触发
我提供了两个指令用于手动生成 RDB 文件。
- save:主线程执行,会阻塞。
- bgsave:调用 glibc 的函数
fork
产生一个子进程用于写入临时 RDB 文件,快照持久化完全交给子进程来处理,完成后自动结束,父进程可以继续处理客户端请求,阻塞只发生在fork
阶段,时间很短,生成 RDB 文件的默认配置使用的就是该指令。当子进程写完新的 RDB 文件后,它会替换旧的 RDB 文件。
自动触发
程序员总不能半夜起来时不时去手动执行命令生成 RDB,为了让他们安稳的过夜生活,我会在以下 4 种情况自动触发执行 bgsave
生成 RDB 文件:
- redis.conf 中配置
save m n
,在 m 秒内至少有 n 个 key 更改,自动触发bgsave
生成 RDB 文件; - 主从复制,从节点需要从主节点进行全量复制时会触发
bgsave
操作,把生成的 RDB 文件发送给从节点; - 执行
debug reload
命令重新加载 Redis 会触发bgsave
执行; - 默认情况下执行
shutsown
命令,如果没有开启 AOF 持久化,我也会触发bgsave
操作。
如果配置成save ""
,则表示关闭 RDB 快照功能。聪明的程序员可根据实际请求压力调整快照周期执行策略。
其他配置
我还提供了其他用于控制生成 RDB 文件的配置。
# 文件名称
dbfilename dump.rdb
# 文件保存路径
dir /opt/app/redis/data/
# 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes
# 是否压缩
rdbcompression yes
# 导入时是否检查
rdbchecksum yes
stop-writes-on-bgsave-error
上边我提到在执行快照生成的过程中,主线程依然可以接收客户端的写指令,是在快照操作正常情况下。
如果生成快照期间出现异常,比如操作系统权限不够,磁盘已满。该配置配置成 yes,我就会禁止执行写操作。
反之,出现快照错误也允许执行写操作。
rdbcompression
启用 LZF 压缩算法对字符串类型的数据进行压缩生成 RDB 快照文件,则设置成 yes
。
rdbchecksum
从 Redis 5.0 开始,在 RDB 的末尾会有一个 64 位的 CRC 荣誉校验码,用于验证整个 RDB 文件的完整性。这个功能大概会损失 10% 左右的性能,但是能获得更高的数据可靠性,追求我极致性能的程序员可将这个配置成 no
。
2. 写时复制
MySQL:“实际生产环境中,程序员通常给你配置 6GB 的内存,将这么大的内存数据生成 RDB 快照文件落到磁盘的过程会持续比较长的时间。
你如何做到继续处理「写」指令请求,又保证 RDB 与内存中的数据的一致性呢?”
作为唯快不破的 NoSQL 数据库扛把子,我在对内存数据做快照的时候,并不会暂停写操作(读操作不会造成数据的不一致)。
我使用了操作系统的多进程写时复制技术 COW(Copy On Write) 来实现快照持久化。
在持久化时我会调用 glibc 的函数fork
产生一个子进程,快照持久化完全交给子进程来处理,主进程继续处理客户端请求。
子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段,你可以将父子进程想像成一个连体婴儿,共享身体。
这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。
bgsave
子进程可以共享主线程的所有内存数据,就能读取主线程的数据并写入 RDB 文件。
如果主线程对这些数据是读操作,那么主线程和 bgsave
子进程互不影响。
当主线程要修改某个键值对时,这个数据会把发生变化的数据复制一份,生成副本。
接着,bgsave
子进程会把这个副本数据写到 RDB 文件,从而保证了数据一致性。
图3-2
MySQL:“在执行快照期间,你崩溃了怎么办?”
数据没有全部写到磁盘中,这次快照操作就不算成功,崩溃恢复的时候只能将上次一完整的 RDB 快照文件作为恢复文件。
MySQL:“那我建议你每秒执行一次快照,这样宕机最多丢失一秒的数据。”
你的建议很好,下次真的不要再建议了。
这个方法是错误的,bgsave 执行时不阻塞主线程,但是,如果频繁地执行全量快照,也会带来两方面的开销:
- 频繁生成 RDB 文件写入磁盘,磁盘压力过大。会出现上一个 RDB 还未执行完,下一个又开始生成的情况,陷入死循环。
bgsave
子进程通过主线程fork
出来的,虽然创建后不会阻塞主线程,但是fork
本身会阻塞主线程。内存越大,阻塞时间越长,导致频繁阻塞主线程。
3. 优缺点
- 优点
- RDB 采用二进制 + 数据压缩的方式写磁盘,文件体积远小于内存大小,适用于备份和全量复制。
- RDB 加载恢复数据速度远远快于 AOF 文件。
- 缺点
- 实时性不够,无法做到秒级持久化。
- 调用
bgsave
需要 fork 子进程,子进程属于重量级操作,频繁操作执行成本高。
针对 RDB 不适合实时持久化等问题,我提供 AOF 持久化方式来解决。
3.1.2 AOF
AOF (Append Only File)持久化记录的是服务器接受的每个写操作,在服务器启动重放的时候还原数据集。
AOF 采用的是写后日志模式,即先写内存,后写日志。
图 3-3
还有一个叫做写前日志(Write Ahead Log)相反方式: 在实际写数据之前,将修改的数据写到日志文件中,故障恢复得以保证。
例如 MySQL Innodb 存储引擎 中的 redo log(重做日志)便是记录修改的数据日志,在实际修改数据前先记录修改日志,再执行修改数据。
在默认情况,我并不会开启 AOF,程序员可以通过配置 redis.conf
文件来开启 AOF 持久化。
# yes 开启AOF持久化,默认是 no
appendonly yes
# AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"
# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./
1.日志格式
当我接受到 「set key MageByte」命令将数据写到内存后, 会按照如下格式写入 AOF 文件。
- 「*3」:表示当前指令分为三个部分,每个部分都是 「$ + 数字」开头,紧跟后面是该部分具体的「指令、键、值」。
- 「数字」:表示这部分的命令、键、值多占用的字节大小。比如 「$3」表示这部分包含 3 个字节,也就是 「set」指令。
图3-4
2.写回策略
为了提高文件的写入效率,当用户调用 write
函数,把数据写入到文件的时候,操作系统通常会将待写入的数据暂存在一个内存缓冲区里面,等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。
这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失。
为此,系统提供了fsync
和fdatasync
两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。
Redis 提供的 AOF 配置项appendfsync
写回策略直接决定 AOF 持久化功能的效率和安全性。
- always:同步写回,写指令执行完毕立马将
aof_buf
缓冲区中的内容刷写到 AOF 文件。 - everysec:每秒写回,写指令执行完,日志只会写到 AOF 文件缓冲区,每隔一秒就把缓冲区内容同步到磁盘。
- no: 操作系统控制,写执行执行完毕,把日志写到 AOF 文件内存缓冲区,由操作系统决定何时刷写到磁盘。
没有两全其美的策略,我们需要在性能和可靠性上做一个取舍。
always
同步写回可以做到数据不丢失,但是每个「写」指令都需要写入磁盘,性能最差。
everysec
每秒写回,避免了同步写回的性能开销,发生宕机可能有一秒位写入磁盘的数据丢失,在性能和可靠性之间做了折中。
no
操作系统控制,执行写指令后就写入 AOF 文件缓冲就可以执行后续的「写」指令,性能最好,但是有可能丢失很多的数据。
3. AOF 重写瘦身
MySQL:“随着写入操作的执行,AOF 日志过大怎么办?文件越大,数据恢复恢复就越慢。”
为了解决 AOF 文件体积膨胀的问题,创造我的 antirez 老哥设计了一个杀手锏——AOF 重写机制,对文件进行瘦身。
例如,使用 INCR counter
实现一个自增计数器,初始值 1,递增 1000 次的最终目标是 1000,但是在 AOF 中保存着 1000 次指令。
在重写的时候并不需要其中的 999 个写操作,重写机制有多变一功能,将旧日志中的多条指令,重写后就变成了一条指令。
其原理就是开辟一个子进程将内存中的数据转换成一系列 Redis 的写操作指令,写到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
图 3-5
我提供了 bgrewriteaof
指令用于对 AOF 日志进行瘦身。程序员不可能随时随地使用该指令去重写文件,这样的话都没有时间谈恋爱。
所以,我还提供了以下两个配置,实现自动重写策略,解放程序员的双手。
# 触发重写 AOF 配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
auto-aof-rewrite-percentage
:如果当前 AOF 文件的大小超过了上次重写后的 AOF 文件大小的百分比后,则开始重写 AOF。比如例子中设置为 100,当 AOF 文件的大小超过上次 AOF 文件重写后的 1 倍,就执行重写。auto-aof-rewrite-min-size
:表示触发 AOF 文件重写的最小值。如果 AOF 文件大小低于这个值,则不触发重写操作。
注意的是,程序员手动执行 bgrewriteaof
命令并不受这两个条件限制。
MySQL:“AOF 重写会阻塞主线程么?”
重写是通过主线程 fork 出后台 bgrewriteaof 子进程执行,会把主线程的内存拷贝一份给 bgrewriteaof 子进程,子进程就能在不影响主线程的情况下,将拷贝的数据的写操作记录到重写日志。
因此,在 AOF 重写时,阻塞主线程只发生在主线程 fork 子进程那一刻 。
重写过程
MySQL:“在重写日志时,有新数据写入内存怎么办?”
总的来说,重写过程一共出现 两个日志和一次拷内存数据拷贝。分别是旧的 AOF 日志和新的 AOF 重写日志, Redis 数据拷贝。
Redis 会将重写过程中的接收到的写操作同时记录到旧的 AOF 缓冲区和 AOF 重写缓冲区。
这样新的重写日志也保存最新的操作。等到拷贝数据的所有操作记录重写完成后,重写缓冲区记录的最新操作也会写到新的 AOF 文件中。
每次 AOF 重写时,Redis 会先执行一个内存拷贝,让 bgrewriteaof 子进程遍历拷贝的数据生成重写记录。
使用两个日志保证在重写过程中,新写入的数据不会丢失,并且保持数据一致性。
图 3-6
MySQL:”为什么 AOF 重写不复用原 AOF 日志?“
这个问题问得好,有以下两个原因:
- 一个原因是父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。
- 如果 AOF 重写过程中失败了,那么原本的 AOF 文件相当于被污染了,无法做恢复使用。所以 Redis AOF 重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的 AOF 文件产生影响。等重写完成之后,直接替换旧文件即可。
4. 优缺点
优点
- 持久化实时性高:在使用 fsync 每秒持久化的写入性能依然很棒。
- 是一种追加日志,不会出现磁盘寻道问题。也不会在断电时出现损坏问题。即使由于某种原因(磁盘已满或其他原因)日志以写一半的命令结束,redis-check-aof 工具也能够轻松修复它。
- 易于理解和解析的格式依次包含所有操作的日志。
- 写操作执行成功才记录日志,避免了指令语法检查开销,同时,不会阻塞当前「写」指令。
缺点
- 由于 AOF 记录的是一个个指令内容,故障恢复的时候需要执行每一个指令,如果日志文件太大,整个恢复过程就会非常缓慢。
- 另外文件系统对文件大小也有限制,不能保存过大文件,文件变大,追加效率也会变低。
- 指令执行完成,写日志之前宕机了,会丢失数据。
- AOF 避免了当前命令的阻塞,但是可能会给下一个命令带来阻塞的风险。AOF 日志是主线程执行,将日志写入磁盘过程中,如果磁盘压力大就会导致写磁盘很慢,导致后续的「写」指令阻塞。
MySQL:“两种持久化方式都有优缺点,可不可以结合下做到更好呢?”
重启 Redis 时,我们很少使用 RDB 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。
antirez 在 4.0 版本中给我提供了一个混合使用 AOF 日志和 RDB 内存快照的方法。简单来说,RDB 内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有写操作。
如此一来,快照就不需要频繁执行,避免了 fork 对主线程的性能影响,AOF 不再是全量日志,而是生成 RDB 快照时间的增量 AOF 日志,这个日志就会很小,都不需要重写了。
等到,第二次做 RDB 全量快照,就可以清空旧的 AOF 日志,恢复数据的时候就不需要使用 AOF 日志了。
MySQL:“RDB 和 AOF 持久化搞定了,如何从这些持久化文件中恢复数据呢?”
如果一台服务器上既有 RDB 文件,又有 AOF 文件,当我重新启动的时候,将优先选择 AOF 文件来恢复数据,因为它能保证数据更完整。
如果 AOF 文件不存在,则去加载 RDB 文件。恢复流程图 3-7 所示。
图 3-7