特立独行是对的,融入圈子也是对的,重点是要想清楚自己向往怎样的生活,为此愿意付出怎样的代价。

我们通常将 Redis 作为缓存使用,提高读取响应性能,一旦 Redis 宕机,内存中的数据全部丢失,假如现在直接访问数据库大量流量打到 MySQL 可能会带来更加严重的问题。

另外慢慢的从数据库读取放到 Redis 性能必然比不过从 Redis 获取快,也会导致响应变慢。

Redis 为了实现无畏宕机快速恢复,设计了两大杀手锏,分别是 AOF(Append Only FIle)日志和 RDB 快照。

学习一个技术,通常只接触了零散的技术点,没有在脑海里建立一个完整的知识框架和架构体系,没有系统观。这样会很吃力,而且会出现一看好像自己会,过后就忘记,一脸懵逼。

跟着「码哥字节」一起吃透 Redis,深层次的掌握 Redis 核心原理以及实战技巧。搭建一套完整的知识框架,学会全局观去整理整个知识体系。

本文硬核,建议收藏点赞,静下心来阅读,我相信都会有很多收获。

上一篇《Redis 核心篇:唯快不破的秘密》分析了 Redis 的核心数据结构、IO 模型、线程模型、根据不同数据使用合适的数据编码。深层次掌握真正快的原因!

本篇将围绕如下几点展开:

  • 宕机后,如何快速恢复?
  • 宕机了,Redis 如何避免数据丢失?
  • 什么是 RDB 内存快照?
  • AOF 日志实现机制
  • 什么是 写时复制技术?
  • ….

涉及的知识点如图所示:

Redis 日志篇:无畏宕机与快速恢复的杀手锏

Redis 全景图

全景图可以围绕两个维度展开,分别是:

应用维度:缓存使用、集群运用、数据结构的巧妙使用

系统维度:可以归类为三高

  1. 高性能:线程模型、网络 IO 模型、数据结构、持久化机制;
  2. 高可用:主从复制、哨兵集群、Cluster 分片集群;
  3. 高拓展:负载均衡

Redis 系列篇章围绕如下思维导图展开,这次一起探索 Redis 的高性能、持久化机制的秘密。

吃透Redis

拥有全景图,掌握系统观。

系统观其实是至关重要的,从某种程度上说,在解决问题时,拥有了系统观,就意味着你能有依据、有章法地定位和解决问题。

RDB 内存快照,让宕机快速恢复

65 哥:Redis 因为某些原因宕机了,会导致所有的流量会打到后端 MySQL,我立马重启 Redis,可是它的数据存在内存里面,重启后如何还是没有任何数据,如何防止重启数据丢失呢?

65 哥别急,「码哥字节」带你一步步深入理解到底 Redis 宕机后如何快速恢复的。

Redis 数据存储在内存中,是否可以考虑将内存中的数据写到磁盘上呢?当 Redis 重启的时候就把保存在磁盘上的数据快速恢复到内存中,这样就能实现重启后正常提供服务了。

65 哥:我想到一个方案,每次执行「写」操作操作内存的同时写入到磁盘

这个方案有一个致命问题:每次写指令不仅写内存还是写入磁盘,磁盘的性能相对内存太慢,会导致 Redis 性能大大降低。

内存快照

65 哥:那如何规避这个同时写入的问题呢?

我们通常将 Redis 当作缓存使用,所以即使 Redis 没有保存全部数据,还可以通过数据库获取,所以 Redis 不会保存所有的数据, Redis 的数据持久化使用了「RDB 数据快照」的方式来实现宕机快速恢复。

65 哥:那什么是 RDB 内存快照呢?

在 Redis 执行「写」指令过程中,内存数据会一直变化。所谓的内存快照,指的就是 Redis 内存中的数据在某一刻的状态数据。

好比时间定格在某一刻,当我们拍照的,通过照片就能把某一刻的瞬间画面完全记录下来。

Redis 跟这个类似,就是把某一刻的数据以文件的形式拍下来,写到磁盘上。这个快照文件叫做 RDB 文件,RDB 就是 Redis DataBase 的缩写。

Redis 通过定时执行 RDB 内存快照,这样就不必每次执行「写」指令都写磁盘,只需要在执行内存快照的时候写磁盘。既保证了唯快不破,还实现了持久化,宕机快速恢复。

RDB内存快照

在做数据恢复时,直接将 RDB 文件读入内存完成恢复。

65 哥:对哪些数据做快照呢?或者多久做一次快照呢?这个会影响快照的执行效率。

65 哥不错呀,开始考虑数据效率问题了。在《Redis 核心篇:唯快不破的秘密》中我们知道他的单线程模型决定了我们要尽可能的避免会阻塞主线程的操作,避免 RDB 文件生成阻塞主线程。

生成 RDB 策略

Redis 提供了两个指令用于生成 RDB 文件:

  • save: 主线程执行,会阻塞;
  • bgsave:调用 glibc 的函数fork产生一个子进程用于写入 RDB 文件,快照持久化完全交给子进程来处理,父进程继续处理客户端请求,生成 RDB 文件的默认配置。

65 哥:那在对内存数据做「快照」的时候,内存数据还能修改么?也就是写指令能否正常处理?

首先我们要明确一点,避免阻塞和 RDB 文件生成期间能处理写操作不是一回事。虽然主线程没有阻塞,到那时为了保证快照的数据的一致性,只能处理读操作,不能修改正在执行快照的数据。

很明显,为了生成 RDB 而暂停写操作,Redis 是不答应的。

65 哥:那 Redis 如何实现一边处理写请求,同时生成 RDB 文件呢?

Redis 使用操作系统的多进程写时复制技术 COW(Copy On Write) 来实现快照持久化,这个机制很有意思,也很少人知道。多进程 COW 也是鉴定程序员知识广度的一个重要指标。

Redis 在持久化时会调用 glibc 的函数fork产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。

子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这时你可以将父子进程想像成一个连体婴儿,共享身体。

这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。

bgsave 子进程可以共享主线程的所有内存数据,读取主线程的数据并写入到 RDB 文件。

在执行 SAVE 命令或者BGSAVE命令创建一个新的 RDB 文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的 RDB 文件中。

当主线程执行写指令修改数据的时候,这个数据就会复制一份副本, bgsave 子进程读取这个副本数据写到 RDB 文件,所以主线程就可以直接修改原来的数据。

写时复制技术保证快照期间数据客修改

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。

Redis 会使用 bgsave 对当前内存中的所有数据做快照,这个操作是子进程在后台完成的,这就允许主线程同时可以修改数据。

65 哥:那可以每秒都执行 RDB 文件么,这样即使发生宕机最多丢失 1 秒的数据。

过于频繁的执行全量数据快照,有两个严重性能开销:

  1. 频繁生成 RDB 文件写入磁盘,磁盘压力过大。会出现上一个 RDB 还未执行完,下一个又开始生成,陷入死循环。
  2. fork 出 bgsave 子进程会阻塞主线程,主线程的内存越大,阻塞时间越长。

优缺点

快照的恢复速度快,但是生成 RDB 文件频率不好把握,频率过低宕机丢失的数据就会比较多;太快,又会消耗额外开销。

RDB 采用二进制 + 数据压缩的方式写磁盘,文件体积小,数据恢复速度快。

Redis 除了 RDB 全量快照以外,还设计了 AOF 写后日志,接下来我们一起来聊下什么是 AOF 日志。

AOF 写后日志,避免宕机数据丢失

AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的指令记录。

假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内存数据结构的状态。

写前与写后日志对比

写前日志(Write Ahead Log, WAL): 在实际写数据之前,将修改的数据写到日志文件中,故障恢复得以保证。

比如 MySQL Innodb 存储引擎 中的 redo log(重做日志)便是记录修改的数据日志,在实际修改数据前先记录修改日志在执行修改数据。

写后日志: 先执行「写」指令请求,将数据写入内存,再记录日志。

AOF写后日志

日志格式

当 Redis 接受到 「set key MageByte」命令将数据写到内存后,Redis 会按照如下格式写入 AOF 文件。

  • 「*3」:表示当前指令分为三个部分,每个部分都是 「$ + 数字」开头,紧跟后面是该部分具体的「指令、键、值」。
  • 「数字」:表示这部分的命令、键、值多占用的字节大小。比如 「$3」表示这部分包含 3 个字节,也就是 「set」指令。

AOF 日志格式

65 哥:为什么 Redis 使用写后日志这种方式呢?

写后日志避免了额外的检查开销,不需要对执行的命令进行语法检查。如果使用写前日志的话,就需要先检查语法是否有误,否则日志记录了错误的命令,在使用日志恢复的时候就会出错。

另外,写后才记录日志,不会阻塞当前的「写」指令执行。

65 哥:那有了 AOF 就万无一失了么?

傻孩子,可没这么简单。假如 Redis 刚执行完指令,还没记录日志宕机了,就有可能丢失这个命令相关的数据。

还有,AOF 避免了当前命令的阻塞,但是可能会给下一个命令带来阻塞的风险。AOF 日志是主线程执行,将日志写入磁盘过程中,如果磁盘压力大就会导致写磁盘很慢,导致后续的「写」指令阻塞。

发现了没,这两个问题与磁盘写回有关,如果能合理的控制「写」指令执行完后 AOF 日志写回磁盘的时机,问题就迎刃而解。

写回策略

为了提高文件的写入效率,当用户调用 write 函数,将一些数据写入到文件的时候,操作系统通常会将写入数据暂时保存在一个内存缓冲区里面,等到缓冲区的空间被填满、或者超过了指定的时限之后,才真正地将缓冲区中的数据写入到磁盘里面。

这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里面的写入数据将会丢失。

为此,系统提供了fsyncfdatasync两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到硬盘里面,从而确保写入数据的安全性。

Redis 提供的 AOF 配置项appendfsync写回策略直接决定 AOF 持久化功能的效率和安全性。

  • always:同步写回,写指令执行完毕立马将 aof_buf缓冲区中的内容刷写到 AOF 文件。
  • everysec:每秒写回,写指令执行完,日志只会写到 AOF 文件缓冲区,每隔一秒就把缓冲区内容同步到磁盘。
  • no: 操作系统控制,写执行执行完毕,把日志写到 AOF 文件内存缓冲区,由操作系统决定何时刷写到磁盘。

没有两全其美的策略,我们需要在性能和可靠性上做一个取舍。

always同步写回可以做到数据不丢失,但是每个「写」指令都需要写入磁盘,性能最差。

everysec每秒写回,避免了同步写回的性能开销,发生宕机可能有一秒位写入磁盘的数据丢失,在性能和可靠性之间做了折中。

no操作系统控制,执行写指令后就写入 AOF 文件缓冲就可以执行后续的「写」指令,性能最好,但是有可能丢失很多的数据。

65 哥:那我该如何选择策略呢?

我们可以根据系统对高性能和高可靠性的要求,来选择写回策略。总结一下:想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择 Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。

优缺点

优点:执行成功才记录日志,避免了指令语法检查开销。同时,不会阻塞当前「写」指令。

缺点:由于 AOF 记录的是一个个指令内容,具体格式请看上面的日志格式。故障恢复的时候需要执行每一个指令,如果日志文件太大,整个恢复过程就会非常缓慢。

另外文件系统对文件大小也有限制,不能保存过大文件,文件变大,追加效率也会变低。

日志过大:AOF 重写机制

65 哥:AOF 日志文件过大着怎么办?

AOF 写前日志,记录的是每个「写」指令操作。不会像 RDB 全量快照导致性能损耗,但是执行速度没有 RDB 快,同时日志文件过大也会造成性能问题,对于唯快不破的 Redis 这个真男人来说,绝对不能忍受日志过大导致的问题。

所以,Redis 设计了一个杀手锏「AOF 重写机制」,Redis 提供了 bgrewriteaof指令用于对 AOF 日志进行瘦身。

其原理就是开辟一个子进程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。

65 哥:为啥 AOF 重写机制能缩小日志文件呢?

重写机制有「多变一」功能,将旧日志中的多条指令,在重写后就变成了一条指令。

如下所示:

三条 LPUSH 指令,经过 AOF 重写后生成一条,对于多次修改的场景,缩减效果更加明显。

AOF重写机制(纠错:3条变一条)

65 哥:重写后 AOF 日志变小,最后把整个数据库最新数据的操作日志刷写到磁盘了。重写会不会阻塞主线程呢?

「码哥」上文说了,AOF 日志是主线程写回的,AOF 重写的过程实际上后台子进程 bgrewriteaof 完成,防止阻塞主线程。

重写过程

和 AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

总的来说,一共出现 两个日志,一次拷内存数据拷贝,分别是旧的 AOF 日志和新的 AOF 重写日志和 Redis 数据拷贝

Redis 会将重写过程中的接收到的「写」指令操作同时记录到旧的 AOF 缓冲区和 AOF 重写缓冲区,这样重写日志也保存最新的操作。等到拷贝数据的所有操作记录重写完成后,重写缓冲区记录的最新操作也会写到新的 AOF 文件中。

每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于遍历数据生成重写记录;使用两个日志保证在重写过程中,新写入的数据不会丢失,并且保持数据一致性。

AOF 重写过程

65 哥:AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?

这个问题问得好,有以下两个原因:

  1. 一个原因是父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。
  2. 如果 AOF 重写过程中失败了,那么原本的 AOF 文件相当于被污染了,无法做恢复使用。所以 Redis AOF 重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的 AOF 文件产生影响。等重写完成之后,直接替换旧文件即可。

Redis 4.0 混合日志模型

重启 Redis 时,我们很少使用 rdb 来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 rdb 来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。

Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。将 rdb 文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小。

于是在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。

所以 RDB 内存快照以稍微慢一点的频率执行,在两次 RDB 快照期间使用 AOF 日志记录期间发生的所有「写」操作。

这样快照就不用频繁的执行,同时由于 AOF 只需要记录两次快照之间发生的「写」指令,不需要记录所有的操作,避免出现文件过大的情况。

总结

Redis 设计了 bgsave 和写时复制,尽可能避免执行快照期间对读写指令的影响,频繁快照会给磁盘带来压力以及 fork 阻塞主线程。

Redis 设计了两大杀手锏实现了宕机快速恢复,数据不丢失。

避免日志过大,提供了 AOF 重写机制,根据数据库的数据最新状态,生成数据的写操作作为新日志,并且通过后台完成不阻塞主线程。

综合 AOF 和 RDB 在 Redis 4.0 提供了新的持久化策略,混合日志模型。在 Redis 重启的时候,可以先加载 rdb 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重启效率因此大幅得到提升。

最后,关于 AOF 和 RDB 的选择问题,「码 哥 字 节」有三点建议:

  • 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;
  • 如果允许分钟级别的数据丢失,可以只使用 RDB;
  • 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。

经过两篇 Redis 系列文章,读者朋友们对 Redis 应该有一个全局认识。

下一篇「码哥」将带来一个实战,《Redis 高可用篇:主从架构的奥秘》 实战 + 原理呈现给大家!

码哥字节

码哥微信

鸣谢

redis 核心技术与实战: https://time.geekbang.org/column/intro/329 redis 深度历险:核心原理与应用实践: https://juejin.cn/book/6844733724618129422/section/6844733724714614797 redis 设计与实践: https://weread.qq.com/web/reader/d35323e0597db0d35bd957bk73532580243735b90b45ac8