大家好,我是三叔,很高兴这期又和大家见面了,一个奋斗在互联网的打工人。
前面笔者写了一篇关于Redis 数据结构和数据类型的博客总结,这篇博客总结一下关于 Redis 持久化。部分图片来自作者:小林哥,小林哥yyds ~
序言
Redis 持久化主要使用过 AOF(追加文件) 和 RDB(快照) 来保证 Redis 持久化的,这样是为了实现 Redis 的高可用,避免在服务宕机之后,数据无法恢复的问题。Redis 默认是开启 RDB 快照的,AOF 需要在 Redis 的配置文件 redis.conf 中进行修改。
AOF 记录日志
如下图所示:当客户端发起写的命令时,Redis 首先会执行写的命令,然后再将命令记录日志到磁盘中保存,除非磁盘损坏,日志记录是不会消失的。读的命令不需要进行记录日志,因为读取数据并没有对数据进行修改。
从上图可以直到,Redis 首先执行的是写命令,再执行的是记录日志到磁盘中,那么为什么不先记录日志到磁盘中,再执行写命令呢?其实会出现以下问题:
- 先执行记录日志的命令,如果这个写的命令有错误,Redis 直接记录到了磁盘,等后续从磁盘中恢复数据,就可能会出错,这样就需要再进行写磁盘之前进行校验。如果先执行写命令,这样在业务处理阶段就会进行命令的校验,然后可以直接写道磁盘中,减少校验的开销,从而保证记录到磁盘中的写命令是正确的;
- 先执行写磁盘的操作,会阻塞当前写命令的执行,如果写入磁盘的数据很多,那么执行写命令就会一直阻塞,也就是业务一直会阻塞下去。只有写操作命令执行成功后,才会将命令记录到 AOF 日志中,这样也不会阻塞业务逻辑的处理走向。
但是也不是没有风险的,上面这两步操作都是有潜在风险的:
- 如果写命令执行成功后,进行写磁盘操作的过程中,服务宕机了,那么这段时间执行的操作就会丢失;
- 在服务不宕机的情况下,这两步操作都是由主线程进行的,他们是同步进行的,写入磁盘的操作会阻塞接下来 Redis 写命令的进行。因为写入磁盘的操作就是一次用户态到内核态的一次 I/O 切换,如果在将日志内容写入到硬盘时,服务器的硬盘的 I/O 压力太大,就会导致写硬盘的速度很慢,进而阻塞住了,也就会导致后续的命令无法执行。
所以何时写入磁盘的时机就很重要了,Redis 提供了三种写入磁盘的频率,在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填,效率从低到高,分别是:always、everysec、no。
下面我画个表解释下这三种频率的含义
写入磁盘的时机 | 效果 |
---|---|
always | 字面意思,总是,就是执行一个写操作,就进行一次写入磁盘的操作 |
everysec | 每秒执行一次写入磁盘的操作 |
no | 不由 Redis 自己操作,交给操作系统自己去执行,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘 |
从上表可以看出,前面我讲过,Redis 写命令和写入磁盘是同步进行的,所以这时候就要进行取舍,针对业务对数据的敏感程度进行取舍:
写入磁盘的时机 | 数据取舍 |
---|---|
always | 最大程度保证数据不丢失,最坏情况会丢失最后一次执行的命令,性能开销大 |
everysec | 每秒执行一次写入磁盘的操作,最坏情况会丢失一秒的数据,性能开销相对always会低很多,但是性能开销感觉也还比较大 |
no | 性能开销低,完全交给操作系统去调度,但是宕机容易丢失很多数据 |
深入到源码后,你就会发现这三种策略只是在控制 fsync() 函数的调用时机:当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。Redis 是一种基于 单线程Reactor 模型的高效中间件。【图片来源于网络】
AOF 性能瓶颈
AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
AOF重写
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到新的 AOF 文件,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了,也就相当于新的值覆盖旧的值。在通过 AOF 日志恢复数据时,只用执行这条命令,就可以直接完成这个键值对的写入了。AOF 始终保持这最新的数据,用最新的命令去记录数据,恢复的时候,只需要执行最新的命令就好了。
为什么不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖过去?
因为如果重写的数据有问题,那么直接复用现有的AOF文件,如果重写 AOF 的过程中,这个数据出现了问题,那么 Redis 的 AOF 文件中就会出现类似数据库的脏数据,污染了整个文件,导致在恢复数据的时候出现问题。
AOF重写
在触发 AOF 重写时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。这个过程其实是很耗时的,所以重写的操作不能放在主进程里。Redis 的重写 AOF 过程是由后台子进程 异步来完成的,从而避免阻塞主进程。
子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生写时复制,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
如下图所示:
主进程在通过 fork 系统调用生成子进程时,操作系统会把主进程的页表复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过 fork 创建子进程的时候,阻塞的时间也越久。所以,有两个阶段会导致阻塞父进程:
- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
- 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
- 这个阶段修改的是一个 bigkey,也就是数据量比较大的 key-value 的时候,这时复制的物理内存数据的过程就会比较耗时,有阻塞主进程的风险。也就是第二点所讲,内存越大,阻塞时间越长,所以最好禁止在 Redis 中进行一些大 key 的操作。
重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?
Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。
在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 AOF 缓冲区 和AOF 重写缓冲区。
也就是说,在子进程执行 AOF 重写期间,主进程需要执行以下三个工作:
- 执行客户端发来的命令;
- 将执行后的写命令追加到 AOF 缓冲区;
- 将执行后的写命令追加到 AOF 重写缓冲区
子进程执行操作后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。收到子线程的信号后,主线程会调用函数去执行重写 AOF 的命令,将新的 AOF 文件替换旧的 AOF 文件。这里是同步的,该函数执行完成后,主线程继续处理其它操作。
在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。
RDB 快照
RDB 快照就是记录某一个瞬间的内存中所有的数据,记录的全量数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。所以从恢复数据角度来看,快照的效率是高于 AOF 的,因为 AOF 还需要执行写命令恢复数据,RDB 直接读入内存就好了。同时 RDB 丢失的数据取决于执行 RDB 的频率,一般快照几分钟执行一次,最差的情况会丢失这几分钟的数据。
RDB 有两种执行命令来生成 RDB 快照,一种阻塞式:save;一种非阻塞式:bgsave。bgsave会创建子进程去执行快照工作。
在bgsave快照命令下,RDB 也是可以和 AOF 一样,只有在发生修改内存数据的情况时,物理内存才会被复制一份,可以在快照的时候进行写时复制
RDB 快照式一次记录全量数据,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没有办法在这一时间像 AOF 那样写入 RDB 文件的,只能交由下一次的 bgsave 快照。因为此时主线程的内存数据和子线程的内存数据已经分离了,子线程写入到 RDB 文件的内存数据只能是原本的内存数据。最差的情况,如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。
最后
所以在 Redis 后续的进化中,将两种持久化结合到了一起,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
这样做的好处就是在恢复 Redis 数据的时候,最开始式直接恢复 RDB 快照的数据,相对于直接使用 AOF 来说,效率会更高,并且主线程的写命令也会以 AOF 的形式记录下来,减少了更多数据的丢失。