上节课,我们学习了 Redis 避免数据丢失的 AOF 方法。
AOF 的好处是,每次执行只需要记录操作命令。
一般而言,只要你采用的不是 always 的持久化策略,就不会对性能造成太大影响。

但是,也正因为记录的是操作命令,而不是实际的数据,
所以,用 AOF 方法进行故障恢复的时候,需要逐一把操作日志都执行一遍。
如果操作日志非常多,Redis 就会恢复得很慢,影响到正常使用。

那么,有没有既可以保证可靠性,还能在宕机时实现快速恢复的其他方法呢?
这就是我们今天要一起学习的另一种持久化方法:内存快照。
内存快照,就是指内存中的数据在某一个时刻的状态记录。
这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记下来。

对 Redis 来说,它实现类似照片记录效果的方式,
就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。
这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。
这个快照文件就称为 RDB 文件,其中,RDB 就是 Redis DataBase 的缩写。

和 AOF 相比,RDB 记录的是某一时刻的数据,并不是操作,
所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。

听起来好像很不错,但内存快照也并不是最优选项。
我们还要考虑两个关键问题:

  • 对哪些数据做快照?这关系到快照的执行效率问题
  • 做快照时,数据还能被增删改吗?这关系到 Redis 的主线程是否被阻塞,能否同时正常处理请求。

这么说可能不太好理解,我还是拿拍照片来举例子。
我们在拍照时,通常要关注两个问题:

  • 如何取景?也就是说,我们打算把哪些人、哪些物拍到照片中
  • 在按快门前,要提醒朋友不要乱动,否则拍出来的照片就模糊了。

    给哪些内存数据做快照

    先说“取景”问题,也就是我们对哪些数据做快照。
    Redis 的数据都在内存中,为了提供所有数据的可靠性保证,RDB 执行的是全量快照
    也就是说,把内存中的所有数据都记录到磁盘中,

这就类似于给100个人拍合影,把每一个人都拍进照片里。
这样做的好处是,一次性记录了所有数据,一个都不少。
当你给一个人拍照时,只用协调一个人就够了,但是,拍 100 人的大合影,却需要协调 100 个人的位置、状态,等等,这当然会更费时费力。

同样,给内存的全量数据做快照,把它们全部写入磁盘也会花费很多时间。
而且,全量数据越多,RDB 文件就越大,往磁盘上写数据的时间开销就越大。
对于 Redis 而言,它的单线程模型就决定了,我们要尽量避免所有会阻塞主线程的操作,
所以,针对任何操作,我们都会提一个灵魂之问:“它会阻塞主线程吗?”
RDB 文件的生成是否会阻塞主线程,这就关系到是否会降低 Redis 的性能。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。

  • save:在主线程中执行,会导致阻塞
  • bgsave:fork 一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,

bgsave 是 RDB 文件生成的默认配置。
通过 bgsave 命令来执行全量快照,这既提供了数据的可靠性保证,也避免了对 Redis 的性能影响。

快照时数据能修改吗

在对内存数据做快照时,这些数据还能“动”吗? 也就是说,这些数据还能被修改吗?
这个问题非常重要,这是因为,如果数据能被修改,那就意味着 Redis 还能正常处理写操作。
否则,所有写操作都得等到快照完了才能执行,性能一下子就降低了。

在给别人拍照时,一旦对方动了,那么这张照片就拍糊了,我们就需要重拍,所以我们当然希望对方保持不动。

举个例子。
假设内存数据量是 4GB,磁盘的写入带宽是 0.2GB/s,
我们在时刻 t 给内存做快照,简单来说,至少需要 20s 才能做完。
如果在时刻 t+5s 时,一个还没有被写入磁盘的内存数据 A,被修改成了 A1,那么就会破坏快照的完整性,因为 A1 不是时刻 t 时的状态。

因此,和拍照类似,我们在做快照时也不希望数据“动”,也就是不能被修改。
但是,如果快照执行期间数据不能被修改,是会有潜在问题的。
对于刚刚的例子来说,在做快照的 20s 时间里,如果这 4GB 的数据都不能被修改,
Redis就不能处理对这些数据的写操作,那无疑就会给业务服务造成巨大的影响。
你可能会想到,可以用 bgsave 避免阻塞。
这里就要说到一个常见的误区了,避免阻塞和正常处理写操作并不是一回事
此时,主线程的确没有阻塞,可以正常接收请求,
但是,为了保证快照完整性,它只能处理读操作,因为不能修改正在执行快照的数据。

为了快照而暂停写操作,肯定是不能接受的。
所以 Redis 就会借助操作系统提供的写时复制技术 (Copy-On-Write, COW),
在执行快照的同时,正常处理写操作。
fork 之后父子进程的内存关系
简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。
bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),主线程和 bgsave 子进程相互不影响。
但是,如果主线程要修改一块数据(例如图中的键值对 C),这块数据就会被复制一份,生成该数据的副本。

以下摘抄自 专栏 评论区作者的回答 “这块数据就会被复制一份,生成该数据的副本”, 这个操作在实际执行过程中,是子进程复制了主线程的页表,所以通过页表映射,能读到主线程的原始数据,而当有新数据写入或数据修改时,主线程会把新数据或修改后的数据写到一个新的物理内存地址上,并修改主线程自己的页表映射。 所以,子进程读到的类似于原始数据的一个副本,而主线程也可以正常进行修改。

然后,bgsave 子进程会把这个副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。
4dc5fb99a1c94f70957cce1ffef419cc.webp
这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。
到这里,我们就解决了对“哪些数据做快照”以及“做快照时数据能否能修改”这两大问题:
Redis 使用 bgsave 对内存中的所有数据做快照,这个操作是子进程在后台完成的,允许主线程同时修改数据。

现在,我们再来看另一个问题:多久做一次快照?
我们在拍照的时候,还有项技术叫“连拍”,可以记录人或物连续多个瞬间的状态。
那么,快照也适合“连拍”吗?

可以一秒做一次快照吗

对于快照来说,所谓“连拍”就是指连续地做快照。
快照的间隔时间变得很短,即使某一时刻发生宕机了,因为上一时刻快照刚执行,丢失的数据也不会太多。
但是,这其中的快照间隔时间就很关键了。

如下图所示,我们先在 T0 时刻做了一次快照,然后又在 T0+t 时刻做了一次快照,
在这期间,数据块 5 和数据块 9 被修改了。
如果在 t 这段时间内,机器宕机了,那么,只能按照 T0 时刻的快照进行恢复。
此时,数据块 5 和数据块 9 的修改值因为没有快照记录,就无法恢复了。
711c873a61bafde79b25c110735289ab.webp
所以,要想尽可能恢复数据,时间间隔 t 值就要尽可能小,t 越小,就越像“连拍”。
t 值可以小到什么程度呢,比如说:是否可以每秒做一次快照?
毕竟,每次快照都是由 bgsave 子进程在后台执行,也不会阻塞主线程。
这种想法是错误的。
虽然 bgsave 执行时不阻塞主线程,但是,如果频繁地执行全量快照,会带来两方面的开销

  • 一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
  • 另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。

虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,
而且主线程的内存越大,阻塞时间越长。
如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了。


那么,有什么其他好方法可以减少频繁执行全量快照带来的开销吗?
我们可以做增量快照,增量快照就是指,做了一次全量快照后,
后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。
在第一次做完全量快照后,T1 和 T2 时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。
但是,做增量快照前提是:我们需要记住哪些数据被修改了。
这需要我们使用额外的元数据信息记录哪些数据被修改了,这会带来额外的空间开销问题。
8a1d515269cd23595ee1813e8dff28a5.webp
如果我们对每一个键值对的修改,都做个记录,
那么,如果有 1 万个被修改的键值对,我们就需要有 1 万条额外的记录。
而且,有的时候,键值对非常小,比如只有 32 字节,而记录它被修改的元数据信息,可能就需要 8 字节,
这样的画,为了“记住”修改,引入的额外空间开销比较大。
这对于内存资源宝贵的 Redis 来说,有些得不偿失。

到这里,可以发现,虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,
如果快照的频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。
如果快照的频率太高,又会产生额外开销。
那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做到尽量少丢数据呢?

Redis 4.0 提出了混合使用 AOF 日志 和 内存快照 的方法。
简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。
而且,AOF 日志也只用记录两次快照间的操作,不需要记录所有操作了,
因此,也就不会出现文件过大的情况了,可以避免恢复过慢问题,以及 AOF 重写的开销。

如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,
因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
e4c5846616c19fe03dbf528437beb320.webp
这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,
颇有点“鱼和熊掌可以兼得”的感觉,建议在实践中用起来。

小结

这节课,我们学习了 Redis 用于避免数据丢失的 RDB 内存快照方法。

这个方法的优势在于,可以快速恢复数据库,只需要把 RDB 文件直接读入内存,这就避免了 AOF 需要顺序、逐一重新执行操作命令带来的低效性能问题。

内存快照也有它的局限性。它拍的是一张内存的“大合影”,不可避免地会耗时耗力。
虽然,Redis 设计了 bgsave 和写时复制方式,尽可能减少了内存快照对正常读写的影响,但是,频繁快照仍会影响 Redis 的性能。
而混合使用 RDB 和 AOF,正好取两者之长,避两者之短,以较小的性能开销保证数据可靠性和性能。

最后,关于 AOF 和 RDB 的选择问题,我想再给你提三点建议:

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

    每课一问

    我曾碰到过这么一个场景:我们使用一个 2 核 CPU、4GB 内存、500GB 磁盘的云主机运行 Redis,
    Redis 数据库的数据量大小差不多是 2GB,我们使用了 RDB 做持久化保证。
    当时 Redis 的运行负载以修改操作为主,写读比例差不多在 8:2 左右,也就是说,如果有 100 个请求,80 个请求执行的是修改操作。
    你觉得,在这个场景下,用 RDB 做持久化有什么风险吗?

内存不足的风险:
Redis fork 一个 bgsave 子进程进行 RDB 写入,如果主线程再接收到写操作,就会采用写时复制。
写时复制需要给写操作的数据分配新的内存空间。
本问题中写的比例为 80%,那么,在持久化过程中,为了保存 80% 写操作涉及的数据,写时复制机制会在实例内存中,为这些数据再分配新内存空间,分配的内存量相当于整个实例数据量的 80%,大约是 1.6GB,
这样一来,整个系统内存的使用量就接近饱和了。
此时,如果实例还有大量的新 key 写入或 key 修改,云主机内存很快就会被吃光。
如果云主机开启了 Swap 机制,就会有一部分数据被换到磁盘上,当访问磁盘上的这部分数据时,性能会下降。
如果云主机没有开启 Swap,会直接触发 OOM,整个 Redis 实例会面临被系统 kill 掉的风险。

主线程和子进程竞争使用 CPU 的风险:生成 RDB 的子进程需要 CPU 核运行,主线程本身也需要 CPU 核运行,
而且,如果 Redis 还启用了后台线程,此时,主线程、子进程和后台线程都会竞争 CPU 资源。
由于云主机只有 2 核 CPU,这就会影响到主线程处理请求的速度。