缓存数据丢了,原来是Redis持久化没玩明白原创
引言
我们都知道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> 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:everysec】,在可靠性以及性能方面相对平衡一点。
AOF文件会越来越大吗?
在了解了AOF日志磁盘写入时机之后,我们继续来思考下一个问题。无论采取什么样的同步数据策略,最终都是要将修改命令写入AOF文件中,因此随着时间的推移,这个文件必定会越来越大。那么如果文件变得很大之后,无论是文件数据新写入还是Redis通过AOF文件进行数据恢复,大文件的操作都会造成IO性能损耗。假如你是Redis的设计者,如果遇到这种情况你会怎么进行设计优化呢?我想无非有两个优化思路,一个是化整为零,一个是想办法缩小大文件。
化整为零
当单个文件过大时,我们很容易想到的优化方法就是将这个大文件拆分为若干个小文件。这就好比系统中一旦出现过千万数据库表的时候,我们就要结合实际的业务场景考虑要不要进行分库分表了。所以如果单个AOF文件太大,那么是不是可以考虑将其按照固定大小进行拆分,这样可以避免单个AOF文件过大的问题。那么Redis小于7.0版本为什么没有采用这种方案呢?主要是这种方案并不符合Redis追求简单高效的设计思想。假设采用了这种数据分块的方式,那必定需要实现文件大小检测、文件创建、文件索引维护等等一系列技术细节问题,对于低版本的Redis来说这些都太繁琐了,还不如一个AOF文件来的爽快。
AOF重写
什么是fork?
fork函数是linux内核提供给用户创建进程的API,应用程序通过调用fork函数创建子进程,这个子进程可以和原来父进程干同样的事情,也可以和原来主进程干不同的事情,这主要取决于对应的参数。这个过程就好比孙悟空拔了一根自己的猴毛变出来一个和自己一模一样的孙悟空。
父进程fork子进程的时候,子进程拥有独立的虚拟内存空间,那么对应的物理内存空间是不是也是独立的呢?我们都知道在计算机中,内存属于非常宝贵的系统资源,所以大佬们在设计的时候都尽可能的减少内存空间占用从而提高系统资源利用率。fork子进程过程中用到的Copy-On-Write就是典型的内存资源管理优化机制,如果子进程只是读取数据不进行任何的数据写入,那么就和父进程公用内存空间。当子进程需要进行数据写入的时候,发现没有内控空间可以写入,此时会触发一个系统中断来分配内存空间给子进程进行数据写入。
【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被设置为待调度状态
(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重写。
2、在文件恢复场景下,AOF要比DRB恢复数据慢一些。
RDB持久化
RDB(Redis Data Base),所谓的Redis内存数据快照就是某一时刻Redis存于内存中的所有缓存数据,这就好比用手机相机拍照,记录当时的美好画面。Redis可以实现在固定时间间隔后将内存中的缓存数据持久化保存起来。这样即便是服务器宕机或者重启了,只要RDB快照文件还存在,快照文件中对应的缓存数据就不会丢失,Redis重新启动后会重新加载RDB文件到内存中,快速恢复缓存数据,通过这样的方式保障了缓存数据的可靠性。
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 != -1) return 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;
}
(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有没有丢数据的风险?
由于Redis保存RDB快照文件的策略是按照配置的时间间隔进行持久化保存,也就是每隔一个时间间隔Redis就会保存一个RDB文件。因此在内存数据有更新但是RDB保存时间尚未到来的这段时间如果存在服务器宕机或者服务器重启的情况,此时内存的数据就会存在丢失的风险,因为Redis还没来得及将数据持久化到RDB文件中。
场景1中最大的问题就RDB文件持久化存在时间间隔,而这个时间间隔导致了新增的缓存数据存在丢失的风险。那么是不是将时间间隔降低到最小就可以了,比如一秒钟,即使在这一秒钟期间出现异常情况,那缓存数据也只是丢掉这一秒钟的缓存数据,相对来说数据丢失的情况可控一点。但是问题是如果真的每隔1s就保存一个RDB文件到服务器磁盘中,那不论是对Redis本身还是Redis所在的服务器磁盘IO都是一种负担。
RDB模式优点
4、可以根据不同的时间间隔保存RDB文件,在恢复数据的时候可以更加灵活地选择对应版本数据进行恢复。
RDB模式缺点
1、由于RDB数据保存存在一定的时间间隔,因此存在丢失缓存数据的风险;
混合持久化
既然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以及混合持久化的内存数据持久化的机制原理,同时分析了两种持久化方式的优点以及缺点。我想只有理解了中间件的特性机制原理,知道了特性的长处以及不足我们才能设计适合我们平台的缓存数据持久化策略,从而提升平台的稳定性。
另外在一些优秀中间件的学习和使用过程中,我们不能仅仅停留在会用的层面,更应该深入底层领会其架构和实现机制的设计思路,只有搞明白设计思路,时刻站在设计者的角度来看待遇到的问题,那么在我们的实际工作中,如果遇到类似的问题我们可以借鉴这些优秀中间件的解决思路来进行问题分析。