AOF 持久化是怎么实现的? | 小林coding
Redis 7.0 Multi Part AOF的设计和实现-阿里云开发者社区 (aliyun.com)
AOF日志
保存写操作命令到日志的持久化方式,注意只会记录写操作命令,读操作命令是不会被记录的,因为没意义。
在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf
配置文件中的以下参数:
可以看到,Redis 是先执行写操作命令后,再将该命令记录到 AOF 日志里的。
- 避免额外的检查开销。
因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。
而如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。
- 不会阻塞当前写操作命令的执行,因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。
AOF 持久化功能也存在潜在风险。
- 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。
- 前面说道,由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前写操作命令的执行,但是可能会给「下一个」命令带来阻塞风险。
将命令写入到日志的这个操作也是在主进程完成的(执行命令也是在主进程),也就是说这两个操作是同步的。
如果在将日志内容写入到硬盘时,服务器的硬盘的 I/O 压力太大,就会导致写硬盘的速度很慢,进而阻塞住了,也就会导致后续的命令无法执行。
AOF写回策略
Redis写入AOF日志包括以下步骤:
- 执行完写操作命令后,将命令追加到server.aof_buf缓冲区
- I/O系统调用wirte()将 aof_buf 缓冲区的数据写入到 AOF 文件,此时只是拷贝到内核缓冲区 page cache,等待内核将数据写入硬盘
- 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定
具体何时写入硬盘,有三种策略,由appendsync配置项决定
- Always,每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘
- Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
以上三种写回策略都无法完美解决「主进程阻塞」和「减少数据丢失」的问题,因为两个问题是对立的:
- Always 策略的话,可以最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免会影响主进程的性能;
- No 策略的话,是交由操作系统来决定何时将 AOF 日志内容写回硬盘,相比于 Always 策略性能较好,但是操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。
- Everysec 策略的话,是折中的一种方式,避免了 Always 策略的性能开销,也比 No 策略更能避免数据丢失,当然如果上一秒的写操作命令日志没有写回到硬盘,发生了宕机,这一秒内的数据自然也会丢失。
需要根据自己的业务场景进行选择:
- 如果要高性能,就选择 No 策略;
- 如果要高可靠,就选择 Always 策略;
- 如果允许数据丢失一点,但又想性能高,就选择 Everysec 策略。
实际上三种策略的区别在于fsync()
函数的调用时机不同。如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync()
函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
- Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
- Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
- No 策略就是永不执行 fsync() 函数;
AOF重写机制
当 AOF 日志文件过大会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。
所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
两个关于重写的简单问题:
- 为什么重写可以压缩AOF文件
尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,
- 为什么要全部重写玩再替换现有AOF文件
如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。
Redis在AOF大小超过一定阈值时支持自动执行AOFRW,当出现磁盘故障或者触发了代码bug导致AOFRW失败时,Redis将不停的重复执行AOFRW直到成功为止
AOF后台重写
AOF文件触发重写时,此时文件是较大的,重写过程是很耗时的。如果在主进程里重写会对主进程造成阻塞
所以,Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的,这么做可以达到两个好处:
- 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
- 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
主进程在通过 fork
系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程
这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。
当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」(防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。)
需要注意:这里只会复制主进程修改的物理内存数据,没修改物理内存还是与子进程共享的。
这样的话还存在一个问题:主进程修改了已经存在的key-value,这时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,如果直接用重写完的AOF替换现有AOF会产生不一致的问题
为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。
在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。
要注意到因为写入的是缓冲区,所以此时宕机是会损失数据的
补充:no-appendfsync-on-rewrite参数
该参数设置为no时,在AOF重写时,主进程也会写AOF文件,不会丢失数据,但会阻塞主进程
如果设置为yes,这就相当于将appendfsync设置为no,这说明并没有执行磁盘操作,只是写入了缓冲区,因此这样并不会造成阻塞(因为没有竞争磁盘),但是如果这个时候redis挂掉,就会丢失数据
这样的话,整个AOF重写过程如下图所示:
当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。
主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:
- 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
- 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。
信号函数执行完后,主进程继续像往常一样处理命令。
在整个 AOF 后台重写过程中,三种情况会阻塞主进程:
- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
- 发生写时复制,这期间会拷贝物理内存,内存越大,阻塞的时间也越长;
- 信号处理函数执行时
AOF后台重写存在的问题
- 内存开销
在重写期间,主进程会将fork之后的数据变化写进aof_rewrite_buf中,aof_rewrite_buf和aof_buf中的内容绝大部分都是重复的,因此这将带来额外的内存冗余开销。
主进程和子进程之间传输数据,子进程在通过pipe读取这些数据时也会有内部读buffer的内存开销
AOFRW带来的内存开销有可能导致Redis内存突然达到maxmemory限制,从而影响正常命令的写入,甚至会触发操作系统限制被OOM Killer杀死,导致Redis不可服务。
- CPU开销
COU开销主要有三个地方:
1. 主进程需要花费CPU时间向aof_rewrite_buf写数据,并使用eventloop事件循环向子进程发送aof_rewrite_buf中的数据
2. 在子进程执行重写操作的后期,会循环读取pipe中主进程发送来的增量数据,然后追加写入到临时AOF文件
3. 在子进程完成重写操作后,主进程会在backgroundRewriteDoneHandler 中进行收尾工作。其中一个任务就是将在重写期间aof_rewrite_buf中没有消费完成的数据写入临时AOF文件。如果aof_rewrite_buf中遗留的数据很多,这里也将消耗CPU时间
AOFRW带来的CPU开销可能会造成Redis在执行命令时出现RT上的抖动,甚至造成客户端超时的问题。
- 磁盘IO开销
aof_buf和aof_rewrite_buf中的数据都会被写入新AOF文件中,而这两个缓存中的内容绝大部分数重复的,同一份数据会产生两次磁盘IO
- 代码复杂度
Redis使用下面所示的六个pipe进行主进程和子进程之间的数据传输和控制交互,这使得整个AOFRW逻辑变得更为复杂和难以理解。
MP-AOF
为解决AOF的重写问题,Redis7.0引入了Multi-part AOF
MP-AOF就是将原来的单个AOF文件拆分成多个AOF文件。在MP-AOF中,将AOF分为三种类型,分别为:
- BASE:表示基础AOF,它一般由子进程通过重写产生,该文件最多只有一个。
- INCR:表示增量AOF,它一般会在AOFRW开始执行时被创建,该文件可能存在多个。
- HISTORY:表示历史AOF,它由BASE和INCR AOF变化而来,每次AOFRW成功完成时,本次AOFRW之前对应的BASE和INCR AOF都将变为HISTORY,HISTORY类型的AOF会被Redis自动异步删除。
为了管理这些AOF文件,引入了一个manifest(清单)文件来跟踪、管理这些AOF。同时,为了便于AOF备份和拷贝,将所有的AOF文件和manifest文件放入一个单独的文件目录中,目录名由appenddirname配置(Redis 7.0新增配置项)决定。
上图展示了在MP-AOF中执行一次AOFRW的大致流程。
在开始时依然会fork一个子进程进行重写操作。在主进程中,会同时打开一个新的INCR类型的AOF文件,在子进程重写操作期间,所有的数据变化都会被写入到这个新打开的INCR AOF中。子进程的重写操作完全是独立的,重写期间不会与主进程进行任何的数据和控制交互,最终重写操作会产生一个BASE AOF。新生成的BASE AOF和新打开的INCR AOF就代表了当前时刻Redis的全部数据。AOFRW结束时,主进程会负责更新manifest文件,将新生成的BASE AOF和INCR AOF信息加入进去,并将之前的BASE AOF和INCR AOF标记为HISTORY(这些HISTORY AOF会被Redis异步删除)。一旦manifest文件更新完毕,就标志整个AOFRW流程结束。
可以看到,MP-AOF在AOFRW期间不再需要aof_rewrite_buf,因此去掉了对应的内存消耗和磁盘开销。同时,主进程和子进程之间也不再有数据传输和控制交互,因此对应的CPU开销也全部去掉。对应的,前文提及的六个pipe及其对应的代码也全部删除,使得AOFRW逻辑更加简单清晰。