发布于 

Redis:AOF日志、快照和数据同步

本站字数:106.9k    本文字数:6.2k    预计阅读时长:21min    访问次数:

这一节主要讨论 Redis 可靠性相关的问题,避免 Redis 数据丢失,保证 Redis 能够快速恢复是高可靠的两个重要方面。Redis 通过两种持久化机制 AOF 日志内存快照机制,保证了 Redis 的可靠性。上面可以保证单机的可靠性,如果多实例的话,Redis 可以有更高的可靠性,但是也会带了更多的问题,本文还会讨论关于数据同步相关的内容。

AOF 日志:避免数据丢失

Redis 数据库场景有很多,但是最常见的场景就是将 Redis 作为缓存来使用。作为一个普遍存在的场景,但是,有一个不能忽略的问题:一旦服务器宕机,内存中的数据将会全部丢失

其实对于一个缓存来说,丢失一点数据没什么大不了的。可以通过数据库来恢复,但是这样会出现两个问题:一是,需要频繁访问数据库,会给数据库带来巨大的压力; 二是,这些数据是从慢速数据库中读取出来的,导致使用这些数据的应用程序响应变慢。所以,对Redis来说,实现数据的持久化,避从后端数据库中进行恢复,是至关重要的。

Redis 目前有两大持久化机制,也就是 AOF (Append Only File) 日志和 RDB (edis Database) 快照,这一部分讲解 AOF 日志。

AOF 日志如何实现?

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

AOF 日志的记录过程
AOF 日志的记录过程

其实,对于 AOF 日志最像 MySQL 的 binlog 日志,在命令执行完成,或者事务提交以后记录日志。但是,两者的原因和目的其实是有一定差别的。对于 MySQL 来说,binlog 日志主要负责数据的一致性,主要用于数据备份,主备、主主、主从一致性保证,从逻辑上来说,数据库数据修改后,不管什么数据引擎都需要记录binlog,因此保在数据提交后保存数据。而 MySQL 的 redolog 日志主要用于 MySQL 服务器宕机以后数据恢复,保证数据的持久性和完整性,因此需要写前日志。

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

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

但是,AOF 也有两个潜在的风险:

  • 如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。如果此时Redis是用作缓存,还可以从后端数据库重新读入数据进行恢复,但是,如果 Redis 是直接用作数据库的话,此时,因为命令没有记入日志,所以就无法用日志进行恢复了。
  • AOF虽然避免了对当前命令的阻塞,但可能会给下一个操作带来阻塞风险。这是因为,AOF日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

对于第一种风险,在使用 Redis 的时候,应该避免 Redis 直接作为数据库使用,可能会造成数据的丢失。但是对于第二种问题,就需要有不同的回写策略来去解决这种问题。

磁盘的回写策略是有哪些?

对于回写策略,AOF 的配置项提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值。

  • Always:同步写回。每次执行完命令,立刻同步写回磁盘中。这种做法可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能;
  • Everysec:每秒写回。每个命令执行完,先把日志写到 AOF 文件内存缓冲区,每隔一秒把缓冲区中的数据写入到磁盘中。“每秒写回”采用一秒写回一次的频事,避免了“同步写回”的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中
  • No:操作系统控制的写回。每个命令执行完成以后,先把 AOF 日志保存到文件的内存缓冲区中,由操作系统决定何时将文件写回磁盘。虽然“操作系统控制的写回”在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在Redis手中了,只要AOF记录没有写回磁盘,一旦宕机对应的数据就丢失了
AOF 回写策略
AOF 回写策略

根据不同的回写策略性质,可以选择不同的回写策略,得到比较适合业务的策略。

但是紧接着,又迎来了另一个问题:Redis 每秒会接受很多的读写命令,随着时间的推移,AOF 文件也会越来越大。所以,还是需要小心,AOF 文件过大带来的性能问题。

对于文件过大的性能问题,主要体现在3个方面:

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

这个时候,就需要另外的大杀器,AOF 重写机制

日志文件太大了咋办?

简单来说,AOF重写机制就是在重写时,Redis根据数据库的现状创建一个新的AOF文件,也就是说,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。重写机制将多个操作最后的结果,“压缩”为一条记录,所以重写机制将日志文件变小了。

AOF 日志的重写
AOF 日志的重写

不过,虽然AOF重写后,日志文件会缩小,但是,要把整个数据库的最新数据的操作日志都写回磁盘,仍然是一个非常耗时的过程。这时,我们就要继续关注另一个问题了:重写会不会阻塞主线程?

AOF 重写会发生阻塞问题吗?

和 AOF 的主线程写回不同,重写过程是通过后台线程 bgrewriteaof 实现的,这也是为了避免阻塞主线程,导致数据库性能下降。

重写的过程可以简单总结为:“一个拷贝,两处日志”。

“一个拷贝”就是指,每次执行重写时,主线程fork出后台的bgrewriteaof子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志

“两处日志”又是什么呢?

因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第一处日志就是指正在使用的AOF日志,Redis会把这个操作写到它的缓冲区。这样一来,即使宕机了,这个AOF日志的操作仍然是齐全的,可以用于恢复。

而第二处日志,就是指新的AOF重写日志。这个操作也会被写到重写日志的缓冲区。这样,重写日志也不会丢失最新的操作。等到拷贝数据的所有操作记录重写完成后,重写日志记录的这些最新操作也会写入新的AOF文件,以保证数据库最新状态的记录。此时,我们就可以用新的AOF文件替代旧文件了。

AOF 重写的过程
AOF 重写的过程

可以看到,后台进程在不停的写入拷贝的内存数据。而另一边,新的请求也不会因为这个重写过程阻塞住。新的请求进来以后,两份日志记录都会同步记录。即使 Redis 宕机以后,也有一份正在使用的 AOF 日志来恢复原来的数据,设计很巧妙了。

RDB 内存快照:实现快速恢复

AOF 通过记录日志的方式保证数据的可靠性,而且每次只是记录数据的操作命令,需要持久化的数据并不是很多。一般的话,只要不设置 Always 的回写策略,一般就不会对性能造成太大的影响。

如果发生宕机,恢复数据的时候,就需要回放一遍所有的日志记录。如果操作日志非常多的话,非常影响 Redis 的恢复效率。因此,需要一种更好的回复方式,保证数据能够快速恢复。

当然有了,这就是我们今天要一起学习的另一种持久化方法:内存快照。所谓内存快照,就是指内存中的数据在某一个时刻的状态记录。对Redis来说,它实现类似照片记录效果的方式人就是把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这样一来,即使宕机,快照文件也不会丢失,数据的可靠性也就得到了保证。这个快照文件就称为RDB文件,其中,RDB就是Redis DataBase的缩写。

这个时候就有两个关键的问题:

  • 那些数据需要快照?这个问题关系到执行的效率
  • 做快照的时候,数据可以被增删嘛?这关系到做快照的时候 Redis 是不是需要被阻塞,是否在快照的时候能够处理正常请求

需要给那些内存做快照?

Redis的数据都在内存中,为了提供所有数据的可靠性保证,它执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中,这就类似于给100个人拍合影,把每一个人都拍进照片里。这样做的好处是,一次性记录了所有数据,一个都不少。

但是这样也会带来额外的开销,给全量数据做快照的时候,把他们全部写入磁盘也会花费很多时间。而且,全量数据越多,RDB 文件越大,往磁盘上写数据的时间开销就越大。

由于 Redis 是单线程模型,任何操作都要考虑是否会阻塞线程,对于快照机制,两种都有:

  • save:在主线程中执行
  • bgsave:创建一个子线程,专门用于写入 RDB 文件,避免了主线程的阻塞(默认配置)

这个时候,就可以通过 bgsave 命令来执行全景快照,这样既保证了数据的可靠性,也能避免对主线程的阻塞。

快照时数据能修改嘛?

如果看过短视频的话,如果在拍照过程中通过种种动作,让照片更炫酷,但是这也并不都是好事。就像拍照的时候乱动,也会照出来让人啼笑皆非的照片。快照机制也是如此,对于内存快照而言,肯定我们不希望他的值改变的。

如果快照期间,阻塞所有的 Redis 功能,就可以保证快照的完整性。但是这样做,在快照的时间内,业务数据没办法修改,无疑会给业务线带来巨大的影响。

bgsave 确实可以避免阻塞,但是没办法改变上面的现状。避免阻塞和正常处理写操作并不是一回事。此时,主线程并没有阻塞,但是,为了保证快照完整性,只能处理读操作。

为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的**写时复制技术(Copy-On-Write, COW)**,在执行快照的同时,正常处理写操作。

简单来说,bgsave子进程是由主线程fork生成的,可以共享主线程的所有内存数据。bgsave子进程运行后,开始读取主线程的内存数据,并把它们写入RDB文件。

此时,如果主线程对这些数据也都是读操作,那么,主线程和bgsave子进程不会相互影响。但是,如果主线程要修改一块数据,那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave子进程会把这个副本数据写入RDB文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

RDB 快照工作流程
RDB 快照工作流程

这样既保证了数据的完整性,允许主线程同时对数据进行修改,避免对业务产生影响。

可以一秒做一次快照嘛?

每秒一次快照不太现实,虽然 bgsave 不会阻塞主线程的执行,但是,如果频繁去进行全量快照的操作,也会带带来额外的开销:

  • 频繁将全量数据写入磁盘,会给磁盘带来很大压力。多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
  • bgsave 子进程需要通过fork操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了。

此时,如果做增量快照就会好很多,所谓增量快照,就是指做了一次全量快照以后,后续的操作只需要对修改的地方做一个记录就好了,可以避免全量的开销。

这样,在做完一次全量的快照以后,后面就可以做增量快照了。也就是说,我们只需要记录那些地方被修改就好了,但是这样的额外记录数据,又会占用不小的空间开销。

RDB 增量快照
RDB 增量快照

可以发现 RDB 也不是最理想的解决方案,虽然说,内存恢复的时间快了很多,但是,快照的刷新频率也就成了一个问题,如果频率太低,两次快照之间如果宕机,就会丢失数据,如果刷新频率过高,又会产生额外的开销。

Redis 4.0 采用了一种新的方法:混合使用 AOF 日志和内存快照。简单来说就是,内存快照按照一定的频率去执行,在两次快照之间,使用 AOF 日志记录。这样,快照之间只需要记录少量的操作,而 AOF 日志也不会出现文件过大的问题。

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

数据同步:主从数据库一致

那我们总说的Redis具有高可靠性,又是什么意思呢?其实,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断。AOF 和 RDB 保证了前者。而对于后者,Redis 的做法就是增加副本冗余量,将一份数据同时保存在多个实例上。即使有一个实例出现了故障,需要过一段时间才能恢复,其他实例也可以对外提供服务,不会影响业务使用。

Redis 提供了主从库模式,以保证数据副本一致性,主从库之间采用的是读写分离的方式。

  • 读操作:主库、从库都可以接受
  • 写操作:首先到主库执行,然后主库将写操作同步到从库
Redis 主-从 读写分离
Redis 主-从 读写分离

如果上图中,主库和从库都可以接受写操作,那么最大的问题就是:如果客户端对于同一个数据操作,但是写操作落到了不同实例,那么获取这个数据的时候,就会得到不一致的结果。如果一定要保证数据的一致性,就需要加锁,或者复杂的协商工作,带来了巨额的开销。如果使用主从分离的话,只需要在主数据库保证更新后,更新到其他的从机就可以了,这样主从数据就是一致的。

那么,就会不难遇到下面几个问题:

  • 主从库同步是如何完成的呢?
  • 主库数据是一次性传给从库,还是分批同步?
  • 要是主从库间的网络断连了,数据还能保持一致吗?

主从数据库如何进行第一次同步?

主从数据同步主要分为三个阶段:

  • 建立连接,协商同步
  • 主库数据同步到从库
  • 主库发送新的命令给从库
1
2
# 使用下面的 Redis 命令就可以让自己的机器变为从机
> replicaof 192.168.1.123 6379
第一次主从同步
第一次主从同步

第一阶段:建立连接,协商同步

在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了主库的runID复制进度offset两个参数。

  • runID:是每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的runID,所以将runID设为“?”。
  • salve_repl_offset:此时设为-1,表示第一次复制。

主库收到psync命令后,会用 FULLRESYNC 响应命令带上两个参数:主库runID主库目前的复制进度offset (master_repl_offset),返回给从库。从库收到响应后,会记录下这两个参数。这里有个地方需要注意,FULLRESYNC响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库

第二阶段:主库数据同步到从库

这个阶段,主库将所有的数据同步到从库。从库收到后,解析 RDB 文件完成本地数据加载。这个过程依赖内存快照 RDB。这个过程主库执行 bgsave 生成 RDB 文件,然后从库收到文件以后,清空自己的数据库,然后加载 RDB 文件。为了保证主从数据库的一致性,主库在从库同步的过程中,记录 RDB 生成后的所有写操作,方便后续的数据同步。

第三阶段:主库发送新的命令给从库

把第二阶段,主库收到的写操作,发送到从库。然后从库重新执行这些操作,就可以保证主从数据最终一致性了。

主从级联模式分担主数据库压力

在第一次连接的时候,有一个非常耗时耗力的操作,主库生成 RDB 快照和传输 RDB 快照。如果主库从库很多的话,主库需要和每个从库建立连接,而且还需要将同步数据同步到从库中,这消耗了大量的资源。为了解决这个问题,Redis 使用了 “主-从-从” 模式

在刚才介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过“主-从-从”模式将主库生成RDB和传输RDB的压力,以级联的方式分散到从库上。这个过程类似于组播过程,将同步记录传播到每台从库上,不仅降低了主数据库的磁盘压力,而且还有效降低了整个集群的网络带宽资源占用。

主-从-从 复制模式
主-从-从 复制模式

一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

风险点:最常见的就是网络断连或阻塞。如果网络断连,主从库之间就无法进行命令传播了,从库的数据自然也就没办法和主库保持一致了,客户端就可能从从库读到旧数据。

主从库之间网络连接断开怎么办?

最简单的就是,重新连接以后,再次建立数据同步,开始进行全量复制过程,最后完成复制。但是,这样全量复制非常消耗性能。(Redis 2.8)

在 Redis 2.8 以后主从库会使用增量复制的方式继续进行同步。那么增量复制的时候,如何保证主从同步?这里就需要引入一个环形缓存 repl_backlog_buffer。在这个缓冲区中,主库会记录自己写到的位置,从库则会记录自己已经读到的位置

repl_backlog_buffer 数据同步过程

刚开始的时候,主库和从库的写读位置在一起,这算是它们的起始位置。随着主库不断接收新的写操作,在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,应的偏移量就是master_repl_offset。主库接收的新写操作越多,这个值就会越大。

同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量slave_repl_offset也在不断增加。正常情况下,这两个偏移量基本相等。

repl_backlog_buffer 同步过程
repl_backlog_buffer 同步过程

主从连接恢复以后,从库会发送 psync 命令,将自己的 slave_repl_offset 发送过去。主库检查 master_repl_offsetslave_repl_offset 之间的差距,然后将 master_repl_offsetslave_repl_offset 之间数据发送到从库就可以了。具体过程可以参考下面的图。

从库 恢复连接过程
从库 恢复连接过程

不过,有一个地方我要强调一下,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。

为了避免这种情况,可以根据主库的资源分配缓冲区大小。另外,还可以使用切片集群来分担单个主库的请求压力。

参考资料