性能文章>缓存数据丢了,原来是Redis持久化没玩明白>

缓存数据丢了,原来是Redis持久化没玩明白原创

354645

引言

我们都知道Redis是微服务架构中重要的基础数据库中间件,通过Redis可以将数据库中的数据缓存到内存中,当服务端有数据查询请求的时候,可以直接从内存中获取数据。如此,一方面服务端可以获得比较快的数据请求响应,另一方面降低了后端关系数据库的业务请求压力。但是正所谓尺有所短,寸有所长,Redis最大的优势就是内存数据也是最大的劣势,因为一旦服务器宕机或者服务器重启,内存中缓存的数据也会丢失。针对这样的场景,Redis提供了三种数据持久化机制,分别是AOF、RDB以及混合持久化来应对这种异常情况。本文主要从Redis实现持久化遇到的问题出发,站在设计者的角度思考相关问题的解决思路。

AOF持久化

AOF持久化方式,即Append Only File,Redis通过记录执行修改操作命令这种记小本本的方式进行内存数据持久化。当需要通过AOF日志进行恢复数据时,Redis服务端启动后可以从日志文件中回放执行命令来实现内存数据恢复。当然了,AOF日志中记录的都是修改的命令,查询命令不会修改数据所以不需要进行记录。

可能大家都比较熟悉WAL(Write Ahead Log),即日志预写机制,它是数据库非常常用的确保数据操作原子性以及持久性的技术手段。拿Mysql举栗子,Mysql的WAL体现在undo log以及redo log等这些日志文件中,数据库在执行修改操作的时候并不是立刻将数据更新到磁盘上,而是先记录在日志中,主要目的是如果出现异常,可以直接从redo log中进行数据恢复,也就是说让Mysql知道上次意外发生的时候操作到底有没有成功,另外还可以将Mysql的随机写转换为顺序写,提升IO性能。但是AOF却不同,它是在Redis将数据写入内存之后,再将相关的操作命令写入AOF文件中。

那么问题来了,为什么Redis要采取这种独特的数据记录方式,而不是业界常用的WAL的方式呢?其实可以从以下两个层面思考原因。

(1)AOF文件中保存了执行缓存的命令,以便于保证在需要恢复数据的时候可以进行命令重放恢复数据,因此需要保证执行命令的合法性,而通过先缓存数据再进行命令追加日志的方式可以确保追加到AOF文件中的的命令都是合法有效的,redis在恢复数据的时候不需要再去检查命令是否有效,进一步提升内存数据恢复的效率。

(2)另外由于是在修改操作命令之后进行日志记录,日志记录的时候需要进行磁盘IO操作,因此不会阻塞当前的修改命令。

AOF文件内容是什么?

在搞清楚Redis为什么采用AOF文件记录修改命令之后,我们再来看看AOF文件中到底包含了些内容。
redis> SET mufeng handsome
OK

Redis客户端与服务端之间采用RESP协议进行通信,它是一种应用层协议,对于Redis这种以效率为追求目标的中间件,通信协议必定要简单高效。就上面一条缓存操作命令来说:set mufeng handsome 对应的RESP报文就是*3$3set$6mufeng$8handsome,为了方便查看进行了手动换行。

我们来拆解下报文中各个属性的含义,“*3”代表本次操作命令将由三个分布组成,每一部分都是通过"$数字"的形式作为起始,后面为对应的命令、键或者值。如此处的"$6"就表示后面的命令是一个6个字节的键值。所以,appenonly.aof文件中实际保存的就是这种格式的内容。

AOF有没有丢数据的风险?

上文说到Redis通过AOF文件实现内存数据持久化,那么是不是就代表缓存数据保存就万无一失了?这样的持久化方式还有没有数据丢失的风险呢?大家可以设想一下假设在操作完Redis之后,还没来得及将命令写入AOF文件就宕机了,那么这个操作命令就会丢失,对应的缓存数据最新值也会丢失。因为即便宕机异常恢复之后,也没办法从AOF文件中执行丢失的操作命令了。因此,写入AOF缓冲区的数据什么时候进行持久化落盘,直接决定着AOF持久化方式缓存数据丢失的风险大小。

三种AOF落盘策略

针对AOF缓存中的数据在什么时机写入磁盘,Redis提供了三种AOF日志写入策略供用户进行选择,通过后台线程执行不同时机的AOF文件数据同步操作,在redis.conf配置文件中的配置项appendfsync可以进行配置。

【appendfsync:no】
Redis不用管AOF缓冲区的数据什么时候写入磁盘,将AOF缓冲区同步数据的操作权交给操作系统,操作系统决定什么时候将缓冲区的数据写入磁盘中。
【appendfsync:everysec】
当Redis将数据写入AOF缓冲区后,每隔1s将缓冲区的数据进行磁盘写入。
【appendfsync:always】

每执行一个修改命令,都需要修改的命令进行落盘操作。

虽然Redis提供了这三种AOF日志落盘策略供用户进行选择,但是这三种策略实际上各有优缺点。
【appendfsync:no】如果设置了由操作系统进行AOF缓冲区数据写入,那么就相当于写数据的时机完全交由操作系统来决定,此时redis对于缓冲区数据并不可以控制。
【appendfsync:everysec】如果设置成每隔一秒进行缓存数据写入,虽然不会像同步写入那样存在一定的性能消耗,但是由于存在一秒的时间间隔,如果在此期间出现服务器宕机,那么就会损失这一秒的缓存数据。
【appendfsync:always】虽然可以基本实现数据不丢失,但是由于每次进行内存数据修改都要进行落盘操作,因此在一定程度上影响主线程性能。

具体采取怎样的配置策略还是要根据实际的业务场景来决定,一般推荐使用第二种配置策略【appendfsync:everysec】,在可靠性以及性能方面相对平衡一点。

AOF文件会越来越大吗?

在了解了AOF日志磁盘写入时机之后,我们继续来思考下一个问题。无论采取什么样的同步数据策略,最终都是要将修改命令写入AOF文件中,因此随着时间的推移,这个文件必定会越来越大。那么如果文件变得很大之后,无论是文件数据新写入还是Redis通过AOF文件进行数据恢复,大文件的操作都会造成IO性能损耗。假如你是Redis的设计者,如果遇到这种情况你会怎么进行设计优化呢?我想无非有两个优化思路,一个是化整为零,一个是想办法缩小大文件。

化整为零

当单个文件过大时,我们很容易想到的优化方法就是将这个大文件拆分为若干个小文件。这就好比系统中一旦出现过千万数据库表的时候,我们就要结合实际的业务场景考虑要不要进行分库分表了。所以如果单个AOF文件太大,那么是不是可以考虑将其按照固定大小进行拆分,这样可以避免单个AOF文件过大的问题。那么Redis小于7.0版本为什么没有采用这种方案呢?主要是这种方案并不符合Redis追求简单高效的设计思想。假设采用了这种数据分块的方式,那必定需要实现文件大小检测、文件创建、文件索引维护等等一系列技术细节问题,对于低版本的Redis来说这些都太繁琐了,还不如一个AOF文件来的爽快。

PS: 在最新的Redis 7.0版本中,Redis已经支持多AOF文件分片机制,原始的单个AOF文件会被拆分为一个基础文件以及多个增量文件。新版本中之所以开始支持多文件存储,我想也是随着业务发展内存数据可能会很庞大,Redis设计者发现如果还是使用单文件存储,大AOF文件操作以及数据恢复都是一个挑战。

 

AOF重写

既然进行文件切割太繁琐了,那么就单个AOF文件来说怎么才能减小文件大小呢?那就要从AOF文件的记录内容入手,通过上文我们了解到AOF文件中实际存储了修改内存数据的操作命令,因此我们在分析完这些操作命令之后发现,当多条命令操作同一个key的时候,实际我们需要的是最新的一条操作命令,除此之外的历史操作命令我们并不需要关心。比如【set mufeng handsome】、【set mufeng cool】,如果先后执行了这两个命令,那么在最终恢复数据的时候,只要恢复【set mufeng cool】即可。因此AOF重写的本质就是合并命令,也就是说将多条对同一key进行操作的命令进行合并,实际就是使用最新的key值操作命令来代替之前所有关于这个key值的命令。
Redis通过fork子进程来完成AOF文件重写,因此在讲AOF重写过程之前,我们需要先了解下什么是fork子进程的原理,这样更加有利于我们后面了解AOF文件重写的过程。

什么是fork?

fork函数是linux内核提供给用户创建进程的API,应用程序通过调用fork函数创建子进程,这个子进程可以和原来父进程干同样的事情,也可以和原来主进程干不同的事情,这主要取决于对应的参数。这个过程就好比孙悟空拔了一根自己的猴毛变出来一个和自己一模一样的孙悟空。

因此在fork子进程的过程之中,子进程复制了父进程的代码段、数据段、堆栈、页表等,同时子进程拥有独立的虚拟内存空间(当然是从父进程那里复制过来的)。如下所示,实际上fork()最终调用的是 内核copy_process方法复制进程。
 

父进程fork子进程的时候,子进程拥有独立的虚拟内存空间,那么对应的物理内存空间是不是也是独立的呢?我们都知道在计算机中,内存属于非常宝贵的系统资源,所以大佬们在设计的时候都尽可能的减少内存空间占用从而提高系统资源利用率。fork子进程过程中用到的Copy-On-Write就是典型的内存资源管理优化机制,如果子进程只是读取数据不进行任何的数据写入,那么就和父进程公用内存空间。当子进程需要进行数据写入的时候,发现没有内控空间可以写入,此时会触发一个系统中断来分配内存空间给子进程进行数据写入。

 

什么时机触发AOF重写?
执行bgrewriteaof 命令
当我们在客户端手动执行bgrewriteaof 命令后,可以触发AOF文件进行重写,对应Redis源码中进行重写的bgrewriteaofCommand 函数会检测检测是否满足进行重写的条件,主要检测以下两个条件:

【Condition1】:检测当前是否存在已经在执行的AOF重写子进程,如果存在的话Redis将不再执行AOF文件重写。

【Condition2】:检测当前是否存在已经在创建RDB文件的子进程,如果存在的话Redis将AOF文件重写任务置为待调度状态,后续如果满足了重写条件,则继续执行AOF文件重写任务。

也就是说,Redis检测到当前既没有AOF重写子进程也没有RDB文件创建子进程,那么就可以进行AOF文件重写。对应源码如下:

//of_child_pid(aof rewrite进程pid)、rdb_child_pid(rdb dump进程pid)
void bgrewriteaofCommand(redisClient *c) {
    if (server.aof_child_pid != -1) {
        //如果正在aof rewrite,返回错误信息
        addReplyError(c,"Background append only file rewriting already in progress");
    } else if (server.rdb_child_pid != -1) {
        //如果正在rdb dump,为了避免磁盘压力,将aof重写计划状态置为1,后期再进行rewrite;
        server.aof_rewrite_scheduled = 1;
        addReplyStatus(c,"Background append only file rewriting scheduled");
    }
    //如果当前没有aof rewrite和rdb dump在进行,则调用rewriteAppendOnlyFileBackground开始aof rewrite。
    else if (rewriteAppendOnlyFileBackground() == REDIS_OK) {
        addReplyStatus(c,"Background append only file rewriting started");
    } else {
        //出现异常返回错误。
        addReply(c,shared.err);
    }
}

超出配置阈值

如果Redis实例开启了AOF配置,同时配置了auto-aof-rewrite-percentage以及auto-aof-rewrite-min-size,如果超出了阈值会触发AOF重写。

  //没有rdb子进程、没有aof重写子进程、aof文件设定了阈值以及aof文件大小绝对值超过阈值
  if (server.rdb_child_pid == -1 &&
         server.aof_child_pid == -1 &&
         server.aof_rewrite_perc &&
         server.aof_current_size > server.aof_rewrite_min_size)
     {
        long long base = server.aof_rewrite_base_size ?
                        server.aof_rewrite_base_size : 1;
        long long growth = (server.aof_current_size*100/base) - 100;
        //超过阈值则进行重写
        if (growth >= server.aof_rewrite_perc) {
            serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
            rewriteAppendOnlyFileBackground();
        }
     }

aof_rewrite_scheduled被设置为待调度状态

bgrewriteaofCommand函数中,如果当前正在执行RDB dump操作,那么对应的aof待调度 aof_rewrite_scheduled状态就会被置为1,当前RDB dump完成之后,会继续执行AOF重写操作。
AOF重写过程是怎样的?
通过上文的描述,我们知道了Redis触发AOF重写的时机,那么当触发重写之后的具体业务是怎样的呢?我们一起看下AOF重写的大致流程:

(1)Redis主进程首先检查是不是存在rdb dump进程或者aof重写进程正在运行,如果不存在Redis主进程fork子进程进行aof文件重写;

(2)fork出来的子进程和原来的Redis主进程拥有同样的内存数据,子进程遍历此时的内存数据同时将内存数据写入到临时的AOF文件中;

(3)主进程此时仍然可以接收客户端请求,同时将新的缓存操作写入aof_buf以及aof_rewrite_buf中,根据对应的同步策略,将buf中的数据分别写入旧AOF文件以及临时AOF文件中;

(4)重写完成之后,临时AOF文件将替换原有的老的AOF文件,从而完成整个AOF重写。

 

AOF模式优点
1、AOF的持久化策略更加丰富些,可以根据实际业务需要进行配置,因此相对来说在数据可靠性方面要更加有优势一点。
2、AOF文件内容比较好理解,更加方便理解业务缓存数据。
AOF模式缺点
1、通常情况下,同样的缓存数据,AOF文件比RDB文件大小要大一些。

2、在文件恢复场景下,AOF要比DRB恢复数据慢一些。

RDB持久化

RDB(Redis Data Base),所谓的Redis内存数据快照就是某一时刻Redis存于内存中的所有缓存数据,这就好比用手机相机拍照,记录当时的美好画面。Redis可以实现在固定时间间隔后将内存中的缓存数据持久化保存起来。这样即便是服务器宕机或者重启了,只要RDB快照文件还存在,快照文件中对应的缓存数据就不会丢失,Redis重新启动后会重新加载RDB文件到内存中,快速恢复缓存数据,通过这样的方式保障了缓存数据的可靠性。

RDB文件生成过程

我们以bgsave为例子来看下Redis生成RDB文件的大致过程是怎样的。
int rdbSaveBackground(char *filename, rdbSaveInfo *rsi) {
    pid_t childpid;
    long long start;

 // 如果已经存在aof重写子进程以及rdb生成子进程则直接返回错误
    if (server.aof_child_pid != -1 || server.rdb_child_pid != -1return C_ERR;
    ...
    // fork子进程进行RDB文件生成
    if ((childpid = fork()) == 0) {
        ...
        // 生成RDB文件
        retval = rdbSave(filename,rsi);
        if (retval == C_OK) {
            size_t private_dirty = zmalloc_get_private_dirty(-1);

            if (private_dirty) {
                serverLog(LL_NOTICE,
                    "RDB: %zu MB of memory used by copy-on-write",
                    private_dirty/(1024*1024));
            }

            server.child_info_data.cow_size = private_dirty;
            // 通知父进程RDB文件生成完毕
            sendChildInfo(CHILD_INFO_TYPE_RDB);
        }
        //子进程退出
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
       //父进程业务逻辑
        ...
    }
    return C_OK; 
}
(1)Redis主进程首先判断当前是否存在已经在执行的aof重写子进程以及rdb文件生成子进程,如果存在的话则直接进行返回。为什么要进行这样的判断呢?主要还是从服务器性能方面进行考量,如果服务器有多个子线程在进行RDB持久化操作,那么必定会对磁盘造成比较大的IO压力,如果服务器中还部署了其他服务甚至会影响其他服务的正常运行。

(2)Redis主进程fork子进程进行RDB文件生成操作,在fork的过程中,此时的Redis主进程是阻塞的,不能响应客户端请求,子进程fork完成之后可以继续响应客户端请求。

(3)fork出来的子进程遍历内存数据进行RDB文件生成操作。

(4)如果此时客户端的请求需要修改缓存数据,那么如上面fork子进程的原理,通过COW机制,操作系统会开辟新的内存空间给Redis主进程进行新的缓存数据写入。

(5)子进程快照数据生成完成之后,替换原来老的RDB文件。

 

RDB触发时机

Redis主要支持两种持久化操作来生成RDB文件,分别是save、bsave命令方式手动生成以及在配置文件中配置时间间隔自动进行RDB文件生成。

手动命令触发

客户端连接到redis之后我们可以通过save以及bsave命令进行RDB文件的立即创建,两者的区别如下:

save:通过主线程触发,会阻塞Redis业务,如果内存数据比较多的话,会导致长时间不能响应外部请求;

bsave:客户端执行bsave命令进行RDB持久化,Redis主线程会fork子线程出来进行RDB文件持久化操作,这样避免了主线程的阻塞即便正在持久化操作依然可以响应外部数据缓存请求。

不过这里值得注意的是,虽然fork子进程之后不会阻塞主进程,但是在fork的过程中会阻塞主进程,尤其是在内存数据比较大的时候,阻塞主进程的时间会更长。

配置自动触发

另外在Redis的配置文件redis.conf中,我们可以配置按照一定的时间间隔来进行RDB持久化操作。如下配置:

save 900 1
save 300 10
save 60 10000

其他的触发RDB文件生成的操作这里不再赘述了,像从节点执行全量数据同步的时候,也会触发主节点生成RDB文件发送给从节点。

RDB有没有丢数据的风险?

大家不妨思考下通过RDB文件进行缓存数据持久化会有什么问题?存不存在丢失缓存数据的风险?这种方式看上去是个还不错的持久化解决方案,但是实际上隐藏着一些丢失缓存数据的风险。为什么这么说呢?通过分析RDB文件生成的机制我们可以发现有两个地方存在缓存数据丢失的可能性。
场景1:

由于Redis保存RDB快照文件的策略是按照配置的时间间隔进行持久化保存,也就是每隔一个时间间隔Redis就会保存一个RDB文件。因此在内存数据有更新但是RDB保存时间尚未到来的这段时间如果存在服务器宕机或者服务器重启的情况,此时内存的数据就会存在丢失的风险,因为Redis还没来得及将数据持久化到RDB文件中。

场景1中最大的问题就RDB文件持久化存在时间间隔,而这个时间间隔导致了新增的缓存数据在丢失的风险。那么是不是将时间间隔降低到最小就可以了,比如一秒钟,即使在这一秒钟期间出现异常情况,那缓存数据也只是丢掉这一秒钟的缓存数据,相对来说数据丢失的情况可控一点。但是问题是如果真的每隔1s就保存一个RDB文件到服务器磁盘中,那不论是对Redis本身还是Redis所在的服务器磁盘IO都是一种负担。

 

场景2:
随着业务的不断发展,内存中的数据必定会越来越大,因此在fork子进程来生成RDB文件的过程中,需要复制的数据会同样越来越多,耗费的时间也会越来越多,进而阻塞主进程的时间也会越来越多。如果出现时间阻塞主进程的情况,那么Redis实例必定无法响应客户端的数据操作请求,最终导致内存数据没有进行及时更新,从而出现丢失缓存数据的风险。

RDB模式优点

1、相比AOF在恢复数据的时候需要一条条回放操作命令,通过RDB文件恢复数据效率更高;
2、适合全量备份内存数据场景。
3、同样规模的内存数据,RDB文件数据更加紧凑,磁盘空间占用更小。

4、可以根据不同的时间间隔保存RDB文件,在恢复数据的时候可以更加灵活地选择对应版本数据进行恢复。

RDB模式缺点

1、由于RDB数据保存存在一定的时间间隔,因此存在丢失缓存数据的风险;

2、fork子进程进行RDB文件生成,由于是一次性生成一个内存快照文件,对于服务器磁盘IO以及Redis本身来说都属于重操作,可能会对服务器的磁盘IO造成压力。

混合持久化

既然AOF以及RDB持久化都有这样或者那样的不足,那么有没有一种持久化方案可以兼顾二者的优点来扬长避短呢?从4.0版本开始,Redis支持混合持久化的方式来兼顾效率以及数据可靠性。在Redis配置文件redis.conf中配置混合持久化:

aof‐use‐rdb‐preamble yes

如果配置了混合持久化,那么Redis主进程在fork子进程进行持久化操作的时候,原先的将内存数据转换为操作命令的过程将替换为使用进行AOF重写时对应的RDB文件内容直接放入到重写后的临时文件中,后面再有新的操作命令,都追加到临时aof文件中,重写完成后使用临时aof文件替换旧的文件。

混合持久化模式优点

1、同时拥有RDB以及AOF机制的点,在数据可靠性以及数据恢复效率上面达到了很好的平衡。

混合持久化模式缺点

1、由于Redis从4.0版本才开始支持混合持久化,如果当前平台中的Redis版本低于4.0,那么就无法使用这个持久化机制,因此兼容性不够友好;

总结

本文主要分析了Redis AOF、RDB以及混合持久化的内存数据持久化的机制原理,同时分析了两种持久化方式的优点以及缺点。我想只有理解了中间件的特性机制原理,知道了特性的长处以及不足我们才能设计适合我们平台的缓存数据持久化策略,从而提升平台的稳定性。

另外在一些优秀中间件的学习和使用过程中,我们不能仅仅停留在会用的层面,更应该深入底层领会其架构和实现机制的设计思路,只有搞明白设计思路,时刻站在设计者的角度来看待遇到的问题,那么在我们的实际工作中,如果遇到类似的问题我们可以借鉴这些优秀中间件的解决思路来进行问题分析。

 

点赞收藏
慕枫技术笔记
请先登录,查看4条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
4