RDB 持久化

RDB(Redis DataBase)持久化是把当前进程数据生成快照保存到硬盘的过程,RDB 持久化既可以手动执行,也可以根据服务器配置选项定期执行,产生的 RDB 文件是一个经过压缩的二进制文件,通过该文件可以还原生成 RDB 文件时的数据库状态。但由于生成 RDB 的开销较大,因此无法做到实时持久化,一般用于数据冷备和复制传输。

1. RDB 文件的创建与载入

有两个 Redis 命令可以用于生成 RDB 文件,一个是 SAVE,另一个是 BGSAVE。SAVE 命令会阻塞 Redis 服务器进程,直到 RDB 文件创建完成为止,在阻塞期间,Redis 不能处理任何命令请求。而 BGSAVE 命令则会 fork 出一个子进程,然后由子进程负责去创建 RDB 文件,服务器进程则继续处理命令请求,阻塞阶段只发生在 fork 阶段,一般时间很短,但由于内核需要给子进程拷贝主进程的页表。如果主进程的内存很大,拷贝其页表的耗时也会变长,进而导致阻塞主进程。

因为 BGSAVE 命令的保存工作是由子进程执行的,所以在子进程创建 RDB 文件的过程中,Redis 客户端发送的 SAVE 命令和 BGSAVE 命令会被服务器拒绝执行,这主要是为了避免产生竞争条件。此外,如果 BGSAVE 命令正在执行,那么客户端发送的 BGREWRITEAOF 命令会被延迟到 BGSAVE 命令执行完毕后执行。

因为 BGSAVE 命令可以在不阻塞服务器进程的情况下执行,所以 Redis 允许用户通过设置服务器配置的 save 选项,让服务器每隔一段时间自动执行一次 BGSAVE 命令。用户可以为 save 选项设置多个保存条件,只要其中任意一个条件被满足,服务器就会执行 BGSAVE 命令。

  1. # 表示在m秒内数据集存在n次修改时,自动触发BGSAVE
  2. save [m] [n]

redis.conf 中的默认配置为:
image.png
RDB 文件的载入工作是在 Redis 启动时自动执行的,只要 Redis 服务器在启动时检测到 RDB 文件存在,它就会自动载入 RDB 文件,并且在载入期间服务器会一直处于阻塞状态,直到载入完成为止。但由于 AOF 文件的更新频率通常比 RDB 文件的更新频率高,所以如果服务器开启了 AOF 持久化,那服务器会优先使用 AOF 文件来还原数据库状态。只有在 AOF 持久化处于关闭状态时,服务器才会使用 RDB 文件来还原数据库状态。

2. save 选项实现原理

Redis 会根据 save 选项所设置的保存条件,设置服务器状态 redisServer 结构的 saveparams 属性里:

  1. struct redisServer {
  2. ......
  3. struct saveparam *saveparams; /* Save points array for RDB */
  4. long long dirty; /* Changes to DB from the last save */
  5. time_t lastsave; /* Unix time of last successful save */
  6. }

saveparams 属性是一个数组,数组中的每个元素都是一个 saveparam 结构,每个 saveparam 结构都保存了一个 save 选项设置的保存条件。

  1. struct saveparam {
  2. // 秒数
  3. time_t seconds;
  4. // 修改数
  5. int changes;
  6. }

除了 saveparams 数组外,服务器还维持着一个 dirty 计数器,以及一个 lastsave 属性。其中 dirty 计数器记录距离上一次成功执行 save 或 bgsave 后,服务器对数据库状态进行了多少次修改;lastsave 则记录上一次成功执行 save 或 bgsave 的时间。

Redis 的服务器周期性操作函数 serverCron 默认每隔 100 毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,其中一项工作就是检查 save 选项所设置的保存条件是否满足(遍历并检查 saveparams 数组保存的条件),满足则执行 BGSAVE 命令(执行完后更新 dirty 计数器和 lastsave 属性)。

3. BGSAVE 实现原理

对于 BGSAVE 命令有一个常见的误区,即主进程的确没有阻塞,可以正常接收请求,但是为了保证快照的完整性,它只能处理读操作,因为不能修改正在执行快照的数据。但为了快照而暂停写操作,肯定是不能接受的。所以 Redis 借助了操作系统提供的写时复制技术(Copy-On-Write),在执行快照的同时,正常处理写操作。

如下图所示,当主进程进行读取操作(例如图中的键值对 A)时,主进程和 BGSAVE 子进程相互不影响。但如果主进程要修改数据(例如图中的键值对 C),那这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主进程在这个数据副本上进行修改。同时,BGSAVE 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。
a2e5a3571e200cb771ed8a1cd14d5558.jpg
这既保证了快照的完整性,也允许 Redis 主进程同时对数据进行修改,避免对正常业务的影响。Redis 会使用 BGSAVE 对当前内存中的所有数据做快照,这个操作是子进程在后台完成的,这就允许主进程同时可以修改数据。

写时复制的底层实现机制

对 Redis 来说,主线程 fork 出 BGSAVE 子进程后,BGSAVE 子进程实际是复制了主线程的页表。在这些页表中保存了在执行 BGSAVE 命令时,主线程的所有数据块在内存中的物理地址。这样 BGSAVE 子进程生成 RDB 时,就可以根据页表读取这些数据,再写入磁盘中。如果此时,主线程接收到了新写或修改操作,那么,主线程会使用写时复制机制。具体来说,写时复制就是指,主线程在有写操作时,才会把这个新写或修改后的数据写入到一个新的物理地址中,并修改自己的页表映射。

如下图所示:BGSAVE 子进程复制主线程的页表以后,假如主线程需要修改虚页 7 里的数据,那么,主线程就需要新分配一个物理页(假设是物理页 53),然后把修改后的虚页 7 里的数据写到物理页 53 上,而虚页 7 里原来的数据仍然保存在物理页 33 上。这时,虚页 7 到物理页 33 的映射关系,仍然保留在 BGSAVE 子进程中。所以,BGSAVE 子进程可以无误地把虚页 7 的原始数据写入 RDB 文件。
cc98dc9f65a1079f3638158aacf81aeb.webp

4. 内存大页的影响

Linux 内核从 2.6.38 版本开始支持内存大页机制,该机制支持 2MB 大小的内存页分配,而常规的内存页分配是按 4KB 的粒度来执行的。很多人会觉得:Redis 是内存数据库,内存大页不正好可以满足 Redis 的需求吗?而且在分配相同的内存量时,内存大页还能减少分配次数,不也是对 Redis 友好吗?

虽然内存大页可以给 Redis 带来内存分配方面的收益,但是 Redis 为了提供数据可靠性保证,需要将数据做持久化保存。这个写入过程由额外的线程执行,所以,此时,Redis 主线程仍然可以接收客户端写请求。客户端的写请求可能会修改正在进行持久化的数据。在这一过程中,Redis 就会采用写时复制机制,也就是说,一旦有数据要被修改,Redis 并不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。

如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。两者相比,你可以看到,当客户端请求修改或新写入数据较多时,内存大页机制将导致大量的拷贝,这就会影响 Redis 正常的访存操作,最终导致性能变慢。

因此,在实际生产环境中部署时,我建议你不要使用内存大页机制。查看是否开启了内存大页的方法是:在 Redis 实例运行的机器上执行如下命令:

  1. cat /sys/kernel/mm/transparent_hugepage/enabled

如果执行结果是 always 就表明内存大页机制被启动了;如果是 never 就表示内存大页机制被禁止。

AOF 持久化

除了 RDB 持久化外,Redis 还提供了 AOF(Append Only File)持久化。AOF 持久化是以独立日志的方式记录每次执行的写命令,重启时再重新执行 AOF 文件中的命令达到恢复数据的目的。被写入 AOF 文件的所有命令都是以 Redis 的命令请求协议格式保存的,因为 Redis 的命令请求协议格式是纯文本格式,所以可以直接打开一个 AOF 文件,观察里面的内容。

通过在 redis.conf 中设置如下配置项来开启 AOF 持久化功能:

  1. appendonly yes

1. AOF 持久化的实现

AOF 持久化的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。

1.1 命令追加

当 AOF 持久化功能处于打开状态时,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾:

  1. struct redisServer {
  2. ......
  3. sds aof_buf; /* AOF buffer, written before entering the event loop */
  4. }

思考一下 AOF 为什么要先执行命令再记录日志呢?

我们以 Redis 收到 “set testkey testvalue” 命令后记录的日志为例,看看 AOF 日志的内容。其中,”*3” 表示当前命令有三个部分,每部分都是由 “$+数字” 开头,后面紧跟具体的命令、键或值。其中的 “数字” 表示这部分中的命令、键或值一共有多少字节。例如,”$3 set” 表示这部分有 3 个字节,也就是 “set” 命令。
4d120bee623642e75fdf1c0700623a9f.webp
但是,为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法上的检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时就可能会出错。所以,Redis 会先执行命令再写日志,避免出现记录错误命令的情况。

1.2 文件写入与同步

Redis 的服务器进程就是一个事件循环,这个循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件负责执行像 serverCron 函数这样需要定时运行的函数。因为服务器在处理文件事件时可能会执行写命令,使得一些内容被追加到 aof_buf 缓冲区里面,所以在服务器每次结束一个事件循环之前,都会调用 flushAppendOnlyFile 函数,考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里。

flushAppendOnlyFile 函数的行为由服务器配置的 appendfsync 选项的值来决定,默认为 everysec。具体可查看 redis.conf 配置文件中的描述:
image.png

Linux 的文件同步
为提高文件写入效率,在现代操作系统中,当用户调用 write 函数将数据写入到文件时,操作系统通常会将写入数据暂存在一个内存缓冲区里,等缓冲区空间被填满或超过了指定时限后,才真正地将缓冲区中的数据写入到磁盘里。这种做法虽然提高了效率,但也为写入数据带来了安全问题,因为如果计算机发生停机,那么保存在内存缓冲区里西的写入数据将会丢失。为此,操作系统提供了 fsync 和 fdatasync 两个同步函数,它们可以强行让操作系统立即将缓冲区中的数据写入到硬盘里,从而确保写入数据的安全性。

AOF 持久化的效率和安全性:

当 appendfsync 的值为 always 时,服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,并且同步(fsync)到 AOF 文件中,所以 always 的效率是最慢的,但也是最安全的,因为即使出现故障停机,AOF 持久化也只会丢失一个事件循环中所产生的命令数据。

当 appendfsync 的值为 everysec 时,服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,并且每隔一秒就要在子线程中对 AOF 文件进行一次同步。从效率上来讲,everysec 模式足够快,因为同步操作是在子线程中进行的,并且就算出现故障停机,数据库也只丢失一秒钟的命令数据。

当 appendfsync 的值为 no 时,服务器在每个事件循环都要将 aof_buf 缓冲区中的所有内容写入到 AOF 文件,至于何时对 AOF 文件进行同步,则由操作系统控制。因为处于 no 模式下的 flushAppendOnlyFile 调用无须执行同步操作,所以该模式下的 AOF 文件写入速度是最快的。
72f547f18dbac788c7d11yy167d7ebf8.webp
总结一下就是:想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择 Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。

1.3 AOF 文件加载

正常情况下,只要开启了 AOF 持久化,并且提供了正常的 appendonly.aof 文件,在 Redis 启动时就会自动加载 AOF 文件并启动。在 AOF 开启的情况下,即使 AOF 文件不存在,也不会加载 RDB 文件。

在 AOF 写入文件时,如果服务器崩溃或者是 AOF 存储已满的情况下,AOF 的最后一条命令可能被截断,这就是异常的 AOF 文件。在 AOF 文件异常的情况下,如果在 redis.conf 配置文件中指定 aof-load-truncated 等于 yes 的配置,Redis 在启动时会尽可能地加载 AOF 文件,忽略部分错误;否则,用户必须手动修复 AOF 文件才能正常启动 Redis 服务。
image.png
此外,AOF 文件可能出现更糟糕的情况,当 AOF 文件不仅被截断,而且中间的命令也被破坏,这个时候再启动 Redis 会提示错误信息并中止运行。出现此类问题的解决方案如下:

  • 首先使用 AOF 修复工具检测出现的问题,在命令行中输入 redis-check-aof 命令,它会跳转到出现问题的命令行,这个时候可以尝试手动修复此文件;
  • 如果无法手动修复,我们可以使用 redis-check-aof —fix 自动修复 AOF 异常文件,不过执行此命令可能会导致异常部分至文件末尾的数据全部被丢弃。

    2. AOF 重写

    因为 AOF 持久化是通过保存被执行的写命令来记录数据库状态的,所以随着 Redis 的运行,AOF 文件中的内容会越来越多,文件的体积也会越来越大。这就会导致以下三个性能问题:
  • 文件系统本身对文件大小有限制,无法保存过大的文件
  • 如果文件太大,之后再往里面追加命令记录的话,效率也会变低
  • 当故障恢复时,AOF 中记录的命令要一个个被重新执行,如果日志文件太大,整个恢复过程会很慢

为了解决 AOF 文件体积膨胀的问题,Redis 提供了 AOF 文件重写功能。AOF 文件重写是把 Redis 进程内的数据转化为写命令同步到新 AOF 文件的过程,该过程不会读取现有 AOF 文件,而是通过读取 Redis 当前的数据库状态来实现的。新旧两个 AOF 文件保存的数据库状态相同,但新 AOF 文件不会包含任何浪费空间的冗余命令。所以新 AOF 文件的体积通常比旧 AOF 文件的体积要小得多。
6528c699fdcf40b404af57040bb8d208.webp
AOF 文件重写有两种触发机制:一种是主动调用 BGREWRITEAOF 命令触发 AOF 重写,另一种是通过在 redis.conf 配置文件中的配置项自动触发 AOF 重写:
image.png

  • auto-aof-rewrite-min-size:AOF 重写时文件的最小体积,默认为 64mb
  • auto-aof-rewrite-percentage:当前 AOF 文件大小和上一次重写后 AOF 文件大小的差值,再除以上一次重写后 AOF 文件大小。

    3. AOF 后台重写

    AOF 的重写过程会进行大量的磁盘写入操作,耗时比较长。因为 Redis 服务器使用单线程处理命令请求,所以如果由 Redis 服务器直接执行 AOF 重写操作的话,那么在 AOF 重写期间,服务器将无法处理客户端发来的命令请求。所以 Redis 决定将 AOF 重写程序放到子进程里执行,这样做可以同时达到两个目的:
  • 子进程进行 AOF 重写期间,服务器进程(父进程)可以继续处理命令请求
  • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以在避免使用锁的情况下保证数据安全

不过,使用子进程也有一个问题需要解决,因为子进程在进行 AOF 重写期间,服务器进程还需要继续处理命令请求,而新的命令可能会对现有的数据库状态进行修改,从而使得服务器当前的数据库状态和重写后的 AOF 文件所保存的数据库状态不一致。为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在服务器创建子进程后开始使用,当 Redis 服务器执行完一个写命令后,它会同时将这个写命令发送给 AOF 缓冲区和 AOF 重写缓冲区。

当子进程完成 AOF 重写工作后,它会向父进程发送一个信号,父进程在接到该信号后,会调用一个信号处理函数,并执行以下工作:

  • 将 AOF 重写缓冲区中的所有内容写入到新 AOF 文件中,这时新 AOF 文件所保存的数据库状态将和服务器当前的数据库状态一致。
  • 对新的 AOF 文件进行改名,原子地覆盖现有的 AOF 文件,完成新旧两个 AOF 文件的替换。

这个信号处理函数执行完毕之后,父进程就可以继续像往常一样接受命令请求了。在整个 AOF 后合重写的过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,在其他时候,AOF 后台重写都不会阻塞父进程,这将 AOF 重写对服务器性能造成的影响降到了最低。
6b054eb1aed0734bd81ddab9a31d0be8.webp
总结一下就是,每次 AOF 重写时,Redis 会先 fork 出一个子进程(拷贝的是内存页表,即虚拟内存和物理内存的映射索引表,这样父子进程会指向相同的内存地址空间,如果父进程执行写命令,会通过写时复制技术复制新的数据副本,而不影响子进程数据)用于重写;然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。而且,因为 Redis 采用额外的进程进行数据重写,所以,这个过程并不会阻塞服务器进程。

4. AOF 重写的风险

AOF 重写会对磁盘进行大量 IO 操作,同时,fsync 又需要等到数据写到磁盘后才能返回,所以,当 AOF 重写的压力比较大时,就会导致 fsync 被阻塞。虽然 fsync 是由后台子线程负责执行的,但是,主线程会监控 fsync 的执行进度。

当主线程使用后台子线程执行了一次 fsync,需要再次把新接收的操作记录写回磁盘时,如果主线程发现上一次的 fsync 还没有执行完,那么它就会阻塞。所以,如果后台子线程执行的 fsync 频繁阻塞的话(比如 AOF 重写占用了大量的磁盘 IO 带宽),主线程也会阻塞,导致 Redis 性能变慢。

由于 fsync 后台子线程和 AOF 重写子进程的存在,主 IO 线程一般不会被阻塞。但如果在重写日志时,AOF 重写子进程的写入量比较大,fsync 线程也会被阻塞,进而阻塞主线程,导致延迟增加。如果业务应用对延迟非常敏感,但同时允许一定量的数据丢失,可以把配置项 no-appendfsync-on-rewrite 设置为 yes:
image.png
这个配置项设置为 yes 时,表示在 AOF 重写时,不进行 fsync 操作。也就是说,Redis 实例把写命令写到内存后不调用后台线程进行 fsync 操作,就可以直接返回了。当然,如果此时实例发生宕机,就会导致数据丢失。反之,如果这个配置项设置为 no(默认配置),在 AOF 重写时,Redis 实例仍然会调用后台线程进行 fsync 操作,这就可能会给实例带来阻塞。

如果的确需要高性能,同时也需要高可靠数据保证,我建议你考虑采用高速的固态硬盘作为 AOF 日志的写入设备。高速固态盘的带宽和并发度比传统的机械硬盘的要高出 10 倍及以上。在 AOF 重写和 fsync 后台线程同时执行时,固态硬盘可以提供较为充足的磁盘 IO 资源,让 AOF 重写和 fsync 后台线程的磁盘 IO 资源竞争减少,从而降低对 Redis 的性能影响。

混合持久化

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

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

如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。
e4c5846616c19fe03dbf528437beb320.webp
这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,我们可以在 redis.conf 配置文件中配置如下选项开启混合持久化:
image.png