持久化原理以及fork子进程导致的潜在风险

面试过程中,面试官可能会让你聊一聊Redis持久化机制,直接背八股:持久化有两种,一种是AOF,一种是RDB,其中AOF是。。。

俗了,要知道,聊持久化的时候,AOF和RDB可是可以和操作系统和底层IO扯上关系的,而且RDB也能再哨兵机制中使用。

回答Redis的任何问题,都要把高性能考虑进去,造出那么多数据结构是为了快,多路复用IO也是为了快,那有人在想了,持久化是高可用的内容,怎么和高性能扯上关系的呢?因为Redis的诞生是为了高性能,在添加功能的时候如果没办法维持高性能,那这个功能Redis就宁肯不要,所以我们详细分析持久化机制可以发现,里面采用的很多方案都是为了在维持高性能的前提下,来保证持久化,所以我建议在学习持久化的时候可以以此为切入点。

正式回答:其实AOF是一个和操作系统fork等系统调用联系比较密切的一个机制,有很多很细的,值得细细品味的知识点,并且在学习的过程中,我也思考了两个触及灵魂的问题,为什么会有AOF重写缓冲区和AOF缓冲区?fork方案维持了Redis高性能,但真的就万事大吉了吗?如果您感兴趣一会儿可以跟您分享一下,我先讲讲原理,AOF是一种持久化策略,将写命令以文本协议格式追加到AOF缓冲区然后以文件的形式同步到磁盘中,重启后通过文件中的命令执行即可复原。 为了让Redis更快,不同于MySQL,redis采用了写后日志的方式,这样做的同时也避免了记录错误指令的情况,并且不会阻塞当前的写操作。但是这会带来两个风险,第一个风险是redis刚执行完一个命令后,还没来得及记日志就宕机了,如果redis做缓存,可以从后端数据库重新读入数据进行恢复无伤大雅,如果redis作数据库,就无法恢复了。第二是风险是,可能会阻塞下一个操作,因为Redis的AOF是在主线程中进行的,所以一旦写入大实例数据,AOF日志写入磁盘的时候会造成磁盘写压力过大,导致写盘很慢,进而导致后续的操作无法执行。解决上述的两个问题的根源是我们需要一条指令控制AOF文件写回磁盘的时机,所以有了三种同步策略,通过控制写命令执行完后AOF日志写回磁盘的时机解决了上述的问题。分别是everysec、always和no。但是由于AOF文件会一直追加,所以一旦AOF文件很大的时候,追加的效率会降低,并且有可能超出文件系统本身堆文件大小的限制,最重要的是,由于AOF恢复的机制,宕机后重启,他会一条一条执行AOF中的命令,文件太大,恢复时间会过长,所以有了重写机制。重写机制可以总结为一处拷贝,两处日志,一处拷贝指的是父进程fork出子进程进行重写,两处日志一个是AOF就日志,一个是AOF新日志,新日志完成后替代旧日志。

写入append

当 AOF功能打开的时候,服务器在执行完一个写命令之后,会以文本协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾,类似

*3\r\n3\r\nset\r\n5\r\nhello\r\n$5\r\nworld\r\n

  1. 为什么AOF直接采用文本协议格式?

    1. 因为文本协议有更好的兼容性,
    2. 开启AOF后所有写入命令都包含追加操作,直接采用协议格式,避免二次处理开销
    3. 文本协议有更好的可读性,方便直接修改和处理
  2. AOF为什么要把命令追加到aof_buf而不是硬盘中? 因为Redis使用单线程响应命令,AOF是在主线程中执行的,如果每次写AOF文件命令都直接追加到硬盘,那么性能完全取决于当前硬盘负载,如果某个日志文件过大,导致文件写入磁盘时,磁盘写压力过大,导致写磁盘很慢,进而阻塞下一条写指令。先写入缓冲区的话,可防止AOF写入到磁盘的操作带来给下一个写指令带来阻塞的风险。所以提供了三种AOF缓冲区同步文件策略,由参数appendfsync控制,在性能和安全性方面做出平衡,建议使用everysec。

同步sync

AOF不像数据库WAL,AOF是写后日志,先执行命令,把数据写入内存中,然后才记录日志。

原因

  • 传统数据库的日志记录的是修改后的数据,AOF记录的是Redis收到的每一条指令,这些指令以文本形式保存。比方说一个简单的set语句,就分为三部分,每部分由$+数字开头,紧跟着具体的命令。先执行后写日志主要是为了避免 语法检查的开销,命令能执行成功,才被记录到日志,否则系统就会直接向客户端报错。
  • 不会阻塞当前的写操作。总而言之就是不需要进行额外的语法检查的情况下保证日志的语句都是对的,并且不会阻塞当前的写操作,尽可能提升Redis的效率。

但是AOF持久化有两个风险:

  1. 执行完一个命令,还没来得及记录日志就宕机了,但这对缓存数据库来说,影响不是很大,再从后端数据库重新读取数据就行。
  2. AOF虽然避免对当前命令的阻塞,但是可能会带来下一个操作的阻塞:因为AOF是在主线程中执行的,如果日志文件写入磁盘时,磁盘写压力过大,就会导致写磁盘很慢,进而导致后续操作无法进行。

以上两个风险都是和AOF写回磁盘的时机有关,这也就以为这如果我们能够控制一个写命令执行完后AOF日志写回磁盘的时机,这两个问题就解决了。

写回策略也就是同步策略有三种:

配置项 写回时机 优点 缺点
Always 同步写回 可靠性高,数据基本不丢失 每个写命令都要落盘,性能影响大
EverySec 每秒写回 性能适中 宕机时丢失1秒内的数据
No 操作系统控制写回 性能好 宕机时丢失数据较多

其中always写入aof_buf后调用系统调用fsync,他是针对单个文件操作(比如AOF文件),做强制硬盘同步,fsync将阻塞直到写入硬盘完成后返回,保证了数据持久化。

而everysec和no采用系统调用write操作。会触发延迟写机制。Linux在内核提供页缓冲区来提高硬盘IO性能。write操作在写入系统缓冲区后直接返回。同步硬盘操作依赖于操作系统调度机制。例如:缓冲区页空间写满或达到特定时间周期。

数据还原

image.png

AOF持久化开启且存在AOF文件时,优先加载AOF文件;AOF如果关闭或者AOF文件不存在,加载RDB文件,加载AOF/RDB文件成功后,Redis启动成功。如果AOF/RDB文件存在错误时,Redis启动失败并打印错误信息

服务器只需要读取并重新执行一边AOF文件中保存的写命令,就可以还原服务器关闭之前的数据库状态。

  1. 创建一个不带网络的伪客户端(因为redis的命令只能在客户端上下文中执行,而载入AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端执行AOF保存的写命令)
  2. 从AOF文件中读取一条写指令
  3. 使用伪客户端执行这条写指令

重复上述2、3过程,直到所有写命令都处理完毕

问题

随着AOF的增大,会带来性能问题,因为AOF是以文件形式接受所有的写命令,那么接收的文件越来越多后,AOF越来越大,恢复的时间就会越来越久。

重写Rewrite

重写就是为了解决AOF增加带来的性能问题。降低文件占用空间,存储更小,而且会使复原的时候更快。本质是以Redis进程内的数据为依据,将数据转化为写命令同步到新AOF文件的过程。并不需要对现有的AOF文件进行任何读取、分析或者写入操作,这个功能是通过读取服务器当前的数据库状态来实现的。首先从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令。

为什么重写后的AOF文件可以变小?

  1. 进程中已经超时的数据不再写入文件
  2. 旧的AOF文件含有无效命令例如del key1...重写使用进程内数据直接生成,这样新的AOF文件只保留最终数据的写入命令。
  3. 因为以最终的数据为依据,所以针对同一个数据的多条命令合并成一条。

这里需要注意的是,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键锁包含的元素数量,如果元素数量超过了redis.h/redis_aof_rewrite_items_per_cmd常量的值,那么重写程序将使用多条命令来记录键的值,而不是单单使用一条命令。当前版本是64,也就是说一个集合键包含超过64个元素,那么重写程序就会用多条sadd命令来记录这个集合。每条命令设置的元素数量也为64个。

另一方面,如果一个列表键包含了超过64个项,那么会用多条rpush命令来保存这个列表,并且每条命令设置的项数量也为64个

重写可以手动触发(bgrewriteaof)也可以自动触发(auto-aof-rewrite-min-size、auto-aof-rewrite-percentage参数确定自动触发的时机)。

一个是重写时候最小体积,一个是当前AOF文件空间和上一次重写后AOF文件空间的比值。

image.png

  1. 在执行AOF重写请求时,如果当前进程正在执行AOF重写,就不执行;如果当前进程在进行bgsave,就等bgsave完成之后再执行。
  2. 父进程fork创建子进程,开销等同于bgsave
  3. 父进程依然写入AOF缓冲区并且根据appendfsync策略同步到硬盘,保证所有AOF机制正确性;AOF重写缓冲区用于保存新写入的数据
  4. 子进程写入新的AOF文件,每次批量写入硬盘的数据由配置aof-rewrite-incremental-fsync控制,默认32MB,防止单词刷盘数据过多造成硬盘阻塞。
  5. 子进程完成AOF重写工作,给父进程发送一个信号,父进程接收到该信号会调用一个信号处理函数,将AOF重写缓冲区的数据写入到新的AOF文件中,保证新AOF文件所保存的数据库状态和服务器的数据库状态一致,并且对新的AOF文件改名,原子地覆盖现有的AOF文件

整个AOF后重写过程中,只有信号处理函数执行时会对服务器进程(父进程)造成阻塞,其他时候AOF后台重写都不会阻塞父进程,这将AOF重写对服务器性能造成的影响降到最低。

原理:从数据库中读取键现在的值,用一条命令去记录键值对,代替之前记录这个键值对的多条命令。也就是调用aof_rewrite函数

但是这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间阻塞,因为Redis服务器使用单个线程来处理命令,所以服务器直接调用aof_rewrite函数的话,那么重写AOF期间,服务器无法处理客户端发来的命令请求,所以AOF重写是在子进程中进行的,主要有两个目的:

  1. 子进程进行AOF重写期间,父进程可以继续处理命令
  2. 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免使用锁的情况下保证数据的安全性。

但是还有个问题,可能会导致服务器当前数据库状态和重写后AOF文件所保存的数据库状态不一致。为了解决数据不一致问题,Redis设置了AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当redis执行完一个命令后,他会同时将这个写命令发送到AOF缓冲区和AOF重写缓冲区。

重写过程中的潜在风险

  1. Redis 主线程 fork 创建 bgrewriteaof 子进程时,内核需要创建用于管理子进程的相关数据结构,这些数据结构在操作系统中通常叫作进程控制块(Process Control Block,简称为 PCB)。内核要把主线程的 PCB 内容拷贝给子进程。这个创建和拷贝过程由内核执行,是会阻塞主线程的。而且,在拷贝过程中,子进程要拷贝父进程的页表,这个过程的耗时和 Redis 实例的内存大小有关。例如10G的Redis进程,需要复制大约20MB的内存页表,如果采用虚拟化技术,fork操作更加耗时,这就会给主线程带来阻塞风险。
  2. bgrewriteaof 子进程会和主线程共享内存。当主线程收到新写或修改的操作时,主线程会申请新的内存空间,用来保存新写或修改的数据,如果操作的是 bigkey,也就是数据量大的集合类型数据,那么,主线程会因为申请大空间而面临阻塞风险。因为操作系统在分配内存空间时,有查找和锁的开销,这就会导致阻塞。

改善措施有:

  1. 优先使用物理机或者高效支持fork操作的虚拟化技术
  2. 控制Redis实例的最大可用内存,每个Redis实例内存控制在10GB以内,关闭Huge Page机制
  3. 合理配置Linux内存分配策略,避免物理内存不足导致fork失败。降低fork操作的频率,放款AOF自动触发时机避免不必要的全量复制

RDB

首先,RDB就是给数据进行快照,是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时数据库的状态,它的还原速度非常

触发机制

手动触发

save命令:阻塞当前Redis服务器进程,服务器不能处理任何命令请求,直到RDB过程完成,因为内存较大的实例会造成长时间阻塞,所以线上环境不建议使用

bgsave命令是对前面save命令的优化,fork创建子进程,RDB过程由子进程负责,完成后自动结束,阻塞只会发生在fork阶段,一般时间很短

两个命令的联系与区别:创建RDB文件的实际工作是有 rdb.c/rdbSave 函数完成,savebgsave 命令会以不同方式调用这个函数。

  • save 直接调用函数生成RDB文件

  • bgsave 有以下步骤

    1. 父进程fork子进程
    2. 父进程继续响应其他命令,并且轮训等待子进程的信号,子进程调用rdbSave函数创建RDB文件
    3. 子进程完成RDB后发送信号通知父进程

bgsave执行时服务器的状态:服务器正常接受命令,但是处理 savebgsavebgrewriteaof 三个命令的方式不同

  • save: 会被拒绝,不能同时执行这俩命令,主要是为了避免父进程和子进程同时执行两个rdbSave 调用,产生竞争条件
  • bgsave:也会被拒绝,也是因为两个命令同时调用 rdbSave,会产生竞争条件
  • bgrewriteaof: 如果 bgsave 正在执行,那么客户端发送的 bgrewriteaof将会被延迟到bgsave执行完毕后执行;如果 bgrewriteaof 正在执行,那么 bgsave 会被拒绝,他们的实际工作都是由子进程来完成的,所以操作层面无冲突,只是在性能方面,两个子进程都要同时执行大量的磁盘写入操作,影响性能。

自动触发

有以下场景

  • 使用 save相关配置,例如 save m n表示m秒内数据存在n次修改的时候,自动出发basave
  • 如果从节点执行全量复制,主节点自动执行 bgsave 生成RDB文件并发送给从节点
  • 执行 debug reload 命令重新加载Redis的时候,会自动出发save操作
  • 默认情况下执行 shutdown 命令时,如果没有开启AOF持久化功能,则自动执行 bgsave

自动间隔性保存

通过 save选项设置多个保存条件,只要满足任意一个条件,服务器就会执行 Bgsave 命令。

格式:

save 900 1
save 300 1
save 60 10000
复制代码

意思就是服务器在900秒之内对数据库至少进行一次修改就会执行 bgsave

原理

  1. 参数

    • 服务器根据这个条件设置服务器状态 redisServer 结构的 saveparams 属性, saveparams 属性是一个数组。每个元素都保存了秒数以及修改数,类似如图
    • dirty 计数器:记录距离上一次成功执行 Save 命令或者 Bgsave命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除和更新操作)。当服务器成功执行一个数据库修改命令后,程序就会对该计数器进行更新,命令修改了多少次数据库,dirty 计数器的值就增加多少
    • lastsave 属性是一个UNIX 时间戳,记录了服务器上一次成功执行 save 命令或者 bgsave 命令的时间
  2. 周期性操作函数 serverCron 检查save选项所设置的保存条件是否已经满足,如果满足了就执行 Bgsave 命令他会遍历并检查 saveparams 数组中的所有保存条件,伪代码如下def serverCron();​

def serverCron();
#... 

#遍历所有保存条件
for saveparam in server.saveparams

    #计算距离上次执行保存操作有多少秒
    save_interval = unixtime_now() - server.lastsave

    #如果数据库状态的修改次数超过条件设置的次数
    #并且距离上次保存的时间超过条件所设置的时间
    #执行bgsave
    if server.dirty >= saveparam.changes and \
        save_interval > saveparam.seconds:

        BGSAVE()
复制代码

在这里的例子中,当执行到1378271101,也就是301秒后,服务器自动执行一次 bgsave,因为 saveparams数组保存的第二个条件已经被满足。

载入

RDB文件的载入是在服务器启动时自动启动的,实际工作是由rdb.c/rdbLoad函数完成的,服务器在载入期间会一直处于阻塞状态直到载入工作完后。但是同时有RDB和AOF的时候,是有先后顺序的

先后顺序:服务器启动的时候,会先查看是否开启了AOF持久化功能,如果开启了,会优先使用AOF还原数据库状态,只有AOF关闭的时候,才会使用RDB文件恢复

RDB文件处理

  • 保存:保存在dir配置指定的目录下,文件名通过dbfilename配置指定。可以通过执行config set dir{newDir}和config set dbfilename{newFileName}运行期动态执行,当下次运行时RDB文件会保存到新的目录中当磁盘写满或者坏掉了,可以通过config set dir{newDir}在线修改文件路径到可用的磁盘路径,之后执行bgsave进行磁盘切换,同样适用于AOF文件
  • 压缩:Redis默认采用LZF算法对生成的RDB文件进行压缩处理,压缩后的文件远远小于内存大小,默认打开,可以通过参数config set rdbcompression{yes/no}动态修改虽然压缩RDB消耗内存,但可以大幅度降低文件的体积,线上建议开启
  • 如果Redis加载损坏的RDB文件时拒绝启动,可以使用redis-check-dump工具检测RDB文件并且获取对应的错误报告

写时复制

在RDB快照过程中,数据不能被修改,也就是此时主线程不能执行写操作,只能执行读操作,这是不被允许的,也正是因此,Redis采用了写时复制的思想,如果某个数据正在被RDB快照,此时主线程要修改这个数据,那么这个数据就会被复制一份,生成该数据的副本,然后bgsave子进程会把这个副本数据写入RDB文件中,而这个过程中,主线程依然可以修改原来的数据。

优缺点

  • 优点

    1. RDB存储的是某个时间点上的数据快照,非常适合全量复制的场景,比如每6小时bgsave备份,并把RDB文件拷贝到远程机器或者文件系统中,用于灾难恢复。
    2. RDB恢复速度远快于AOF。因为RDB是对数据直接进行快照,而AOF存储的是命令,恢复时候还多了一步执行
  • 缺点

    1. RDB无法做到实时持久化(秒级),因为bgsave每次运行都要执行一次fork创建子进程,频繁fork会阻塞主进程
    2. 老版本Redis无法兼容新版本RDB格式

潜在问题

假假设在写操作为主的场景下,RDB做持久化有潜在风险,因为RDB采用的是写时复制的方法,系统需要复制数据,给他们分配内存,所以就可能出现整个系统内存的使用量接近饱和的情况。

混合持久化

重启Redis时,我们很少使用RDB来恢复内存状态,因为会丢失大量数据,我们通常使用AOF,但是AOF相对于RDB慢很多,如果AOF文件较大的时候,启动Redis需要花费很长时间

因此,Redis 4.0 引入了混合持久化,也就是内存快照以一定频率执行,在两次快照之间,使用AOF日志记录这期间的所有命令操作。 这样子既可以避免RDB快照中频繁fork对主进程的影响,同时,AOF只需要记录两次快照之间的操作即可,也就是说不需要记录所有操作,因此不会出现文件过大的情况,也避免了重写。于是重启的时候,先加载RDB的内容,然后执行增量AOF文件,重启效率大大提升。

使用建议

  1. 如果数据不能丢,建议使用混合持久化
  2. 如果允许分钟级别的数据丢失,可以只使用RDB
  3. 如果只使用AOF,优先使用everysec

参考

《Redis设计与实现》

《Redis开发与运维》

《Redis 核心技术与实战》(极客时间专栏)

《Redis深度历险》

猜你喜欢

转载自juejin.im/post/7101875399432339492