AOF日志:宕机了,Redis如何避免数据丢失?

你会把 Redis 用在什么业务场景下?

我想你大概率会说:

  • “我会把它当作缓存使用,因为它把后端数据库中的数据存储在内存中,然后直接从内存中读取数据,响应速度会非常快。
  • ”没错,这确实是 Redis 的一个普遍使用场景
  • 但是,这里也有一个绝对不能忽略的问题:
    • 一旦服务器宕机,内存中的数据将全部丢失。
服务器宕机,内存数据丢失我们很容易想到的一个解决方案:

从后端数据库恢复这些数据,但这种方式存在两个问题:

  • 一是,需要频繁访问数据库,会给数据库带来巨大的压力;
  • 二是,这些数据是从慢速数据库中读取出来的,性能肯定比不上从 Redis 中读取,导致使用这些数据的应用程序响应变慢。

所以,对 Redis 来说,实现数据的持久化,避免从后端数据库中进行恢复,是至关重要的。

Redis 的持久化主要有两大机制
  • AOF(Append Only File)日志
  • RDB 快照
AOF日志是如何实现的?

说到日志,我们比较熟悉的是数据库的写前日志(Write Ahead Log, WAL),也就是说,在实际写数据前,先把修改的数据记到日志文件中,以便故障时进行恢复。不过,AOF 日志正好相反,它是写后日志,“写后”的意思是 Redis 是先执行命令,把数据写入内存,然后才记录日志,如下图所示:

img

那 AOF 为什么要先执行命令再记日志呢?要回答这个问题,我们要先知道 AOF 里记录了什么内容。
  • 可以避免出现记录错误命令的情况。
  • 它是在命令执行后才记录日志,所以不会阻塞当前的写操作。

传统数据库的日志,例如 redo log(重做日志),记录的是修改后的数据,而 AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。

我们以 Redis 收到“set testkey testvalue”命令后记录的日志为例,看看 AOF 日志的内容。其中,“*3”表示当前命令有三个部分,每部分都是由“$+数字”开头,后面紧跟着具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例如,“$3 set”表示这部分有 3 个字节,也就是“set”命令。

img

但是,为了避免额外的检查开销,Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。

而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。所以,Redis 使用写后日志这一方式的一大好处是,可以避免出现记录错误命令的情况。

除此之外,AOF 还有一个好处:它是在命令执行后才记录日志,所以不会阻塞当前的写操作。

AOF 也有两个潜在的风险
  • 首先,如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。
    • 如果此时 Redis 是用作缓存,还可以从后端数据库重新读入数据进行恢复
    • 但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。
  • 其次,AOF 虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。
    • 这是因为,AOF 日志也是在主线程中执行的
    • 如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。
    • 仔细分析的话,你就会发现,这两个风险都是和 AOF 写回磁盘的时机相关的。
      • 这也就意味着,如果我们能够控制一个写命令执行完后 AOF 日志写回磁盘的时机,这两个风险就解除了。
三种写回策略:AOF 配置项 appendfsync 的三个可选值。
  • Always,同步写回:

    • 每个写命令执行完,立马同步地将日志写回磁盘
  • Everysec,每秒写回:

    • 每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘
  • No,操作系统控制的写回:

    • 每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美。我们来分析下其中的原因。
  • 同步写回可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能;
  • 虽然“操作系统控制的写回”在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在 Redis 手中了,只要 AOF 记录没有写回磁盘,一旦宕机对应的数据就丢失了
  • “每秒写回”采用一秒写回一次的频率,避免了“同步写回”的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中。
我们把这三种策略的写回时机,以及优缺点汇

img

我们可以根据系统对高性能和高可靠性的要求,来选择使用哪种写回策略了。

总结一下就是:

  • 想要获得高性能,就选择 No 策略;
  • 如果想要得到高可靠性保证,就选择 Always 策略;
  • 如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。

但是,按照系统的性能需求选定了写回策略,并不是“高枕无忧”了。

毕竟,AOF 是以文件的形式在记录接收到的所有写命令。

随着接收的写命令越来越多,AOF 文件会越来越大。这也就意味着,我们一定要小心 AOF 文件过大带来的性能问题。

AOF文件过大导致的“性能问题”,主要在于以下三个方面:
  • 一是,文件系统本身对文件大小有限制,无法保存过大的文件;
  • 二是,如果文件太大,之后再往里面追加命令记录的话,效率也会变低;
  • 三是,如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复
    • 如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用。

所以,我们就要采取一定的控制手段,这个时候,AOF 重写机制就登场了。

日志文件太大了怎么办?
  • 简单来说,AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件
  • 也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。
    • 比如说,当读取了键值对“testkey”: “testvalue”之后,重写机制会记录 set testkey testvalue 这条命令。
  • 这样,当需要恢复时,可以重新执行该命令,实现“testkey”: “testvalue”的写入。
为什么重写机制可以把日志文件变小呢?

实际上,重写机制具有“多变一”功能。所谓的“多变一”,也就是说,旧日志文件中的多条命令,在重写后的新日志中变成了一条命令

  • AOF 文件是以追加的方式,逐一记录接收到的写命令的。

  • 当一个键值对被多条写命令反复修改时,AOF 文件会记录相应的多条命令。

  • 但是,在重写的时候,是根据这个键值对当前的最新状态,为它生成对应的写入命令。

  • 这样一来,一个键值对在重写日志中只用一条命令就行了,而且,在日志恢复时,只用执行这条命令,就可以直接完成这个键值对的写入了。

下面这张图就是一个例子:

img

当我们对一个列表先后做了 6 次修改操作后,列表的最后状态是[“D”, “C”, “N”],此时,只用 LPUSH u:list “N”, “C”, "D"这一条命令就能实现该数据的恢复,这就节省了五条命令的空间。对于被修改过成百上千次的键值对来说,重写能节省的空间当然就更大了。

不过,虽然 AOF 重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志都写回磁盘,仍然是一个非常耗时的过程。

这时,我们就要继续关注另一个问题了:重写会不会阻塞主线程?

AOF 重写会阻塞吗?
  • 和 AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。
重写的过程总结为“一个拷贝,两处日志”。
  • 一个拷贝
    • 每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。
    • 此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。
      • fork子进程时,子进程是会拷贝父进程的页表,即虚实映射关系,而不会拷贝物理内存。子进程复制了父进程页表,也能共享访问父进程的内存数据了,此时,类似于有了父进程的所有内存数据。
    • 然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。
  • 两处日志
    • 第一处日志
      • 因为主线程未阻塞,仍然可以处理新来的操作。
      • 此时,如果有写操作,第一处日志就是指正在使用的 AOF 日志,Redis 会把这个操作写到它的缓冲区。
      • 这样一来,即使宕机了,这个 AOF 日志的操作仍然是齐全的,可以用于恢复。
    • 而第二处日志,就是指
      • 新的 AOF 重写日志。这个操作也会被写到重写日志的缓冲区。
      • 这样,重写日志也不会丢失最新的操作。
    • 等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会写入新的 AOF 文件,以保证数据库最新状态的记录。
      • 此时,我们就可以用新的 AOF 文件替代旧文件了。

img

AOF重写总结来说
  • 每次 AOF 重写时,Redis 会先执行一个内存拷贝,用于重写;
  • 然后,使用两个日志保证在重写过程中,新写入的数据不会丢失。
  • 而且,因为 Redis 采用额外的线程进行数据重写,所以,这个过程并不会阻塞主线程。
小结
  • Redis 用于避免数据丢失的 AOF 方法。
    • 通过逐一记录操作命令,在恢复时再逐一执行命令的方式,保证了数据的可靠性。
    • 看似“简单”,但也是充分考虑了对 Redis 性能的影响。
    • 总结来说,它提供了 AOF 日志的三种写回策略,分别是
      • Always、Everysec 和 No,这三种策略在可靠性上是从高到低,而在性能上则是从低到高。
  • 为了避免日志文件过大,Redis 还提供了 AOF 重写机制,直接根据数据库里数据的最新状态,生成这些数据的插入命令,作为新日志。
    • 这个过程通过后台线程完成,避免了对主线程的阻塞。
  • 三种写回策略体现了系统设计中的一个重要原则 ,即 trade-off,或者称为“取舍”,指的就是在性能和可靠性保证之间做取舍
    • 这是做系统设计和开发的一个关键哲学,我也非常希望,你能充分地理解这个原则,并在日常开发中加以应用。
  • 落盘时机和重写机制都是在“记日志”这一过程中发挥作用的。
    • 例如,落盘时机的选择可以避免记日志时阻塞主线程
    • 重写可以避免日志文件过大
    • 但是,在“用日志”的过程中,也就是使用 AOF 进行故障恢复时,我们仍然需要把所有的操作记录都运行一遍。
    • 再加上 Redis 的单线程设计,这些命令操作只能一条一条按顺序执行,这个“重放”的过程就会很慢了。
  • 那么,有没有既能避免数据丢失,又能更快地恢复的方法呢?
    • 当然有,那就是 RDB 快照了
AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?
  • 一个原因是父子进程写同一个文件必然会产生竞争问题,控制竞争就意味着会影响父进程的性能。
  • 二是如果AOF重写过程中失败了,那么原本的AOF文件相当于被污染了,无法做恢复使用。
    • 所以Redis AOF重写一个新文件,重写失败的话,直接删除这个文件就好了,不会对原先的AOF文件产生影响。
    • 等重写完成之后,直接替换旧文件即可。
AOF 日志重写的时候,是由 bgrewriteaof 子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?
  • fork子进程,fork这个瞬间一定是会阻塞主线程的(注意,fork时并不会一次性拷贝所有内存数据给子进程,老师文章写的是拷贝所有内存数据给子进程,我个人认为是有歧义的)
    • fork采用操作系统提供的写时复制(Copy On Write)机制,就是为了避免一次性拷贝大量内存数据给子进程造成的长时间阻塞问题
    • 但fork子进程需要拷贝进程必要的数据结构,其中有一项就是拷贝内存页表(虚拟内存和物理内存的映射索引表)
      • 这个拷贝过程会消耗大量CPU资源,拷贝完成之前整个进程是会阻塞的,阻塞时间取决于整个实例的内存大小
      • 实例越大,内存页表越大,fork阻塞时间越久。
    • 拷贝内存页表完成后,子进程与父进程指向相同的内存地址空间
    • 也就是说此时虽然产生了子进程,但是并没有申请与父进程相同的内存大小。
    • 那什么时候父子进程才会真正内存分离呢?
      • “写实复制”顾名思义,就是在写发生时,才真正拷贝内存真正的数据,
      • 这个过程中,父进程也可能会产生阻塞的风险,就是下面介绍的场景。
  • fork出的子进程指向与父进程相同的内存地址空间,此时子进程就可以执行AOF重写,把内存中的所有数据写入到AOF文件中。
    • 但是此时父进程依旧是会有流量写入的
      • 如果父进程操作的是一个已经存在的key,那么这个时候父进程就会真正拷贝这个key对应的内存数据,申请新的内存空间
      • 这样逐渐地,父子进程内存数据开始分离,父子进程逐渐拥有各自独立的内存空间。
    • 因为内存分配是以页为单位进行分配的,默认4k
      • 如果父进程此时操作的是一个bigkey,重新申请大块内存耗时会变长,可能会产阻塞风险。
      • 另外,如果操作系统开启了内存大页机制(Huge Page,页面大小2M),那么父进程申请内存时阻塞的概率将会大大提高
        • 所以在Redis机器上需要关闭Huge Page机制。
      • Redis每次fork生成RDB或AOF重写完成后,都可以在Redis log中看到父进程重新申请了多大的内存空间。
Huge page
  • Huge page对提升TLB命中率比较友好,因为在相同的内存容量下,使用huge page可以减少页表项,TLB就可以缓存更多的页表项,能减少TLB miss的开销。
  • 但是,这个机制对于Redis这种喜欢用fork的系统来说,的确不太友好,尤其是在Redis的写入请求比较多的情况下。
    • 因为fork后,父进程修改数据采用写时复制,复制的粒度为一个内存页。
    • 如果只是修改一个256B的数据,父进程需要读原来的内存页,然后再映射到新的物理地址写入。
    • 一读一写会造成读写放大。如果内存页越大(例如2MB的大页),那么读写放大也就越严重,对Redis性能造成影响。
  • Huge page在实际使用Redis时是建议关掉的。

img

AOF
  • 说白了就是个日志记录文件。redis会把所有写命令记录到一个设定好的日志文件中
  • 做开发的都知道 存储日志 是需要有 特定的格式的,这样后面你才能快速的定位检索,AOF里存储的是Redis自己定义的RESP协议格式的字符串(resp协议的https://blog.csdn.net/wenmeishuai/article/details/106101762)
  • AOF里记录的是每一次写命令,比如说list数据类型,其中有100条数据,在AOF中可能就有100条RESP协议的记录, 参考RESP的文章就知道,这其中有大量的重复命令
开启AOF持久化功能只需改两个配置参数
  • appendonly yes 开启aof

  • appendfilename mads.aof 日志文件名字,随便起

  • 它是怎么实现的?

    • 上面图中 红色框内是AOF的流程。 主进程接收客户端请求写命令,写入到aof_buf(aof缓冲区)然后主进程就返回了 (redis的优化点)

    • 有专门的子进程去调用fsync()函数把数据从aof_buf写入到aof文件(谁在说redis是单线程就对他说:初级程序员)

    • 结合上面,子进程该什么时候去触发调用fsync()这个同步动作呢,redis已经帮我们提前设置了三种策略:

      appendfsync always
      appendfsync everysec
      appendfsync no
      \# no:不要立刻刷,只有在操作系统需要刷的时候再刷 ,比较快。如果redis重启了拿到的数据将不是很新的数据
      \# always:每次写操作都立刻写入到aof文件。慢,但是最安全。
      \# everysec:每秒写一次。折衷方案。 (默认,如果拿不准就用 "everysec"试水 )
      
    • AOF追加阻塞

      • 结合上面继续深入,如果上面我设置的是每1秒同步一次数据,在线上大批量写请求下aof_buf有大量数据需要同步,此时 就会对磁盘进行高频写入,磁盘IO就变成了瓶颈,就会出现上次的同步动作还没完成
      • 主进程又接收到大批写命令写到了缓冲区,此时redis为了保证aof文件安全性,会阻塞主线程,直到上次fsync同步完成。
      • 主进程把数据写入到aof_buf后会对比上次fsync操作时间,此时有两种情况:
        • 如果距离上次fsync操作时间大于2S则阻塞主进程直到fsync结束
        • 如果距上次操作时间小于2S则主进程直接返回
        • 这里虽然我们配置的是每秒同步一次,但是实际上不是丢失1S的数据,实际上可能丢失2S数据,这里请细品
        • aof_buf同步到文件的流程 执行之前,看总量,依次执行,执行完成了再回收(清空)缓冲区
        • aof_buf里存储的格式是 RESP协议格式,(还没有看源码确认)
AOF重写
  • 通过上面已经知道了AOF记录的是字符串。真正线上环境写操作是很多的,AOF的文件大小增加也是很快的,如果一个 AOF文件有10G了再去追加的时候那是非常慢的了。
  • redis就提供了一种压缩的方式,比如还是上面list数据类型100条数据,经过压缩以后就变成了一条,这就是AOF重写。
  • AOF重写会触发Redis的缓存淘汰策略,可以参考aof_rewrite函数源码,参考这篇文章 https://blog.csdn.net/qq_41453285/article/details/103301715
具体流程:
  • Redis开启了持久化功能,并且达到了重写的条件。

    auto-aof-rewrite-percentage 100
    auto-aof-rewrite-min-size 64mb
    
  • 自动重写AOF文件

    • 如果AOF日志文件大到指定百分比,Redis能够通过 BGREWRITEAOF 自动重写AOF日志文件。
    • 工作原理:Redis记住上次重写时AOF日志的大小(或者重启后没有写操作的话,那就直接用此时的AOF文件),
    • 基准尺寸和当前尺寸做比较。如果当前尺寸超过指定比例,就会触发重写操作。
    • 你还需要指定被重写日志的最小尺寸,这样避免了达到约定百分比但尺寸仍然很小的情况还要重写。
    • 指定百分比为0会禁用AOF自动重写特性。
  • 调用fork系统级别函数,复制出完全一致的一个子进程,和主进程共用同一块内存空间(类似浅复制,redis为了节省内存开 销的优化点)

  • 子进程调用aof_rewrite函数(redis客户端执行bgrewriteaof命令最终也是调用此函数)可以创建新的AOF文件去执行重写操作

    • 根据已有数据进行命令的压缩和过期时间的检测并将压缩后的命令写入到新的AOF文件,直到写完
  • 在AOF重写过程中,主进程是可以继续对外服务的,当接收到写命令,写入到aof_buf后,然后判断此时是否正在执行重写 操作,

    • 如果是再将写命令写入AOF重写缓冲区,主进程返回
  • 当子进程完成对AOF文件重写之后,它会向父进程发送一个完成信号,父进程接到该完成信号之后,会调用一个信号处理函 数,该函数完成以下工作:

    • 将AOF重写缓存中的内容全部写入到新的AOF文件中;这个时候新的AOF文件所保存的数据库状态和服务器当前的数 据库状态一致;
    • 对新的AOF文件进行改名,原子的覆盖原有的AOF文件;完成新旧两个AOF文件的替换。到这里才是一次完整的AOF 重写流程
    • 当这个信号处理函数执行完毕之后,主进程就可以继续像往常一样接收命令请求了。
      • 在整个AOF后台重写过程中,只有 最后的“主进程写入命令到AOF缓存”和“对新的AOF文件进行改名,覆盖原有的 AOF文件。
      • 这两个步骤(信号处理函数 执行期间)会造成主进程阻塞,在其他时候,AOF后台重写都不会对主进程造成阻塞,这将AOF重写对性能造成的影 响降到最低。
AOF后台重写为什么这么干
  • aof_rewrite这个函数会进行大量的写入 操作,所以调用这个函数的线程将被长时间的阻塞,因为Redis服务器使用单线程来处理命令请求;所以如果直接是服务器进 程调用AOF_REWRITE函数的话,那么重写AOF期间,服务器将无法处理客户端发送来的命令请求;
  • Redis不希望AOF重写会造成服务器无法处理请求,所以Redis决定将AOF重写程序放到子进程(后台)里执行。这样处理的最 大好处是:
    • 子进程进行AOF重写期间,主进程可以继续处理命令请求;
    • 子进程带有主进程的数据副本,使用子进程而不是线程,可以避免在锁的情况下,保证数据的安全性。
      使用子进程进行AOF重写的问题
  • 子进程在进行AOF重写期间,服务器进程还要继续处理命令请求,而新的命令可能对现有的数据进行修改,这会让当前数 据库的数据和重写后的AOF文件中的数据不一致
    • 为了解决这种数据不一致的问题,Redis增加了一个AOF重写缓存,这个缓存在fork出子进程之后开始启用,Redis服务器主 进程在执行完写命令之后,会同时将这个写命令追加到AOF缓冲区和AOF重写缓冲区
    • 即子进程在执行AOF重写时,主进程需要执行以下三个工作:
      • 执行client发来的命令请求;
      • 将写命令追加到现有的AOF文件中;
      • 将写命令追加到AOF重写缓存中。
最后记录下我的思考
  • 写文件是很耗时的,数据多时还会出现阻塞。
    • 熟悉操作系统的同学会有个印象,数据从缓冲区到磁盘的过程也要经过内 核态到用户态的转换出现上下文切换,这个过程会很长,(Netty零拷贝可以看看)
  • Redis设计初衷就是快,10W+的QPS 也就是1ms就要处理100个命令,阻塞对于redis无疑是不可接受的
    • 如果你是 redis作者会怎么解决呢,很简单的能想到就是同步转异步嘛,让会产生阻塞或者耗时的操作由子线程去干就好了, 主进程就专注处理客户端请求就好了(重点记忆,主进程只跟客户端打交道,其他全交给子进程)
  • 追加阻塞这里,为什么不采用队列或多线程的方式来让主进程迅速返回?
    • 考虑是 增加额外的处理肯定要增加技术复杂 度和资源的消耗,。。。
  • 其实最后发现并没有什么高大上的解决方案对不对。基础真的很重要呀。并且优化是从各个细节来的,也是个持续的 过程,成熟的架构都是慢慢演进的,
  • 8
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值