在讲解Redis持久化相关的话题之前,我们需要了解的是Redis为什么这么快?也就是Redis的IO模型 – 多路复用。
我们一句话概括为什么Redis这么快:
Redis是单线程的,使用多路复用的IO模型。(当然,这只是很重要的一个方面,Redis优秀的数据结构设计,Redis的数据存放在内存中,这些都会成为Redis速度快的理由)
好了,到此为止,上面的话题我们有时间会单独的写一篇文章。
我们只需要记住一点:Redis是单线程的,这就为我们提出了一个要求,对于Redis来说,阻塞是致命的,因为一旦阻塞,整个Redis相当于是暂停工作的,对业务影响很大。所以任何一个会对Redis进行阻塞的地方,都是我们需要去注意和研究的地方。
接下来进入本篇文章的整体,浅谈Redis的持久化。
Redis的数据是放在内存中的,因此有一个问题不可被忽略:一旦服务器宕机,内存中的数据将全部丢失。为了解决这个问题,Redis有它的持久化方式:AOF日志和RDB快照。我们首先来对AOF日志进行学习。
AOF日志
AOF日志功能打开需要修改配置文件:
appendonly yes
说到日志,我们可能会想到MySQL的写前日志,也就是在实际写数据之前,先把修改的数据记到日志文件中。而AOF日志正好相反,AOF日志是写后日志,并且它记录的不是结果,而是会对Redis产生插入效果的指令。如下:
*2
$6
SELECT
$1
0
*3
$3
set
$3
k11
$3
v11
*3
$3
set
$3
k12
$3
v12
*3
$3
set
$3
k13
$3
v13
*3
$3
set
$3
k14
$3
v14
这个规则其实很容易发现:*2代表这个指令有2个部分,$6表示这个部分有6个字节。
那么写后日志有什么好处呢?
- 可以确保命令是正确的,可以被执行。
- 不会阻塞当前的写操作。
但是它也有弊端:
- 如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。
- AOF避免了对当前命令的阻塞,但是可能会给下一个操作带来阻塞风险。这是因为,AOF日志是在主线程中执行的,如果把日志写入磁盘的过程中磁盘写压力太大,那么会导致写盘很慢,后续的操作被影响了。
每当服务执行完一个命令之后,会以上面协议的格式追加到aof_buf缓冲区,这个缓冲区在内存中。
struct redisServer {
...
// AOF缓冲区
sds aof_buf
...
}
可以看到是SDS类型的结构(简单动态字符串)。
那么接下来,矛头就已经对准了一个点了,首先AOF日志是先记录到了内存缓冲区中,其次AOF的两个风险都跟磁盘的写入有关,所以我们接下来要研究的点就是磁盘的写回策略问题了。
在了解写回磁盘之前,我们需要先了解Redis服务器进程的本质。
Redis服务器进程的本质是一个事件循环(event loop)。
这个事件循环中的文件事件负责接收客户端的命令请求,以及向客户端发送命令回复,而时间事件则负责执行像serverCron函数这种需要定时运行的函数。
跟AOF写回有关的伪代码逻辑如下:
def eventLoop():
while True:
# 处理文件事件,接收命令请求以及发送命令回复
# 处理命令请求的时候可能会有新内容被追加到aof_buf缓冲区中
processFileEvents()
# 处理时间事件
processTimeEvents()
# 考虑是否要将aof_buf中的内容写入和保存到AOF文件里面
flushAppendOnlyFile()
事件循环我们了解完毕了,接下来我们需要了解一些操作系统的知识,为了提高文件的写入效率,在现代操作系统中,当用户调用write函数,其本质实际上是把数据拷贝到Linux的内核缓冲区中,此时并没有刷到磁盘里面,当缓冲区的空间被填满,或者超过了指定的时限之后,才真正的将缓冲区的数据写入到磁盘中去。这样做虽然提高了效率,但是也带来了数据的安全问题,如果计算机突然宕机了,那么保存在内存缓冲区中的写入数据将会丢失。因此系统提供了fsync和fdatasync两个同步函数,强制性进行刷盘。
三种写回策略
对于刷盘的时机,Redis给我们提供了3种策略:
- Always,每一次事件循环都把数据写入到aof_buf中,并且使用write系统调用将aof_buf缓冲区中的内容,拷贝到Linux内核缓冲区,然后马上强制性刷盘。因此可靠性高,数据基本不丢失,但是性能影响很大。
- Everysec,每一次事件循环都把数据写入到aof_buf中,并且使用write系统调用将aof_buf缓冲区中的内容,拷贝到Linux内核缓冲区,然后每隔一秒就进行强制性刷盘。折中方案。
- No,每一次事件循环都把数据写入到aof_buf中,并且使用write系统调用将aof_buf缓冲区中的内容,拷贝到Linux内核缓冲区,然后不强制性刷盘,由操作系统自己决定。可靠性没有那么高,宕机时可能数据损失的比较多,但是性能是最优的。
但是不要以为我根据业务需求和其他情况选择了最适应的写回策略就高枕无忧了。毕竟AOF是以文件的形式在记录收到的所有写命令。随着接收的写命令越来越多,AOF文件会越来越大,这会带来性能问题,主要体现在以下3个方面:
- 文件系统本身对文件大小有限制,无法保存过大的文件。
- 如果文件太大,再继续往里面追加内容的话,那么效率会逐渐变低。
- 故障恢复过程需要把AOF解析出来,一条一条命令的执行,中途设计到大量的IO和系统调用内核切换,非常占用资源。
因为我们需要控制AOF文件的大小,于是引出了下面的技术:AOF重写机制。
AOF重写机制
什么是AOF重写机制?
例如:
LPUSH LXY a
LPUSH LXY b
LPUSH LXY c
我们的重写机制会进行优化,把这三条命令变成:
LPUSH LXY a b c
那么这样的优化是怎么做到的呢?是通过读取和分析AOF文件吗?显然不是,如果是通过一套算法读取并分析AOF文件的话,效率太过于低效,而且没有重复利用Redis已有的资源。Redis源代码中,Redis直接读取键list的值,然后用一条命令来代替。
我们来看一下源码:
int rewriteAppendOnlyFileRio(rio *aof) {
dictIterator *di = NULL;
dictEntry *de;
size_t processed = 0;
int j;
long key_count = 0;
long long updated_time = 0;
// 遍历数据库
for (j = 0; j < server.dbnum; j++) {
// 写入SELECT命令,指定数据库号码
char selectcmd[] = "*2\r\n$6\r\nSELECT\r\n";
redisDb *db = server.db+j;
dict *d = db->dict;
// 忽略空的数据库
if (dictSize(d) == 0) continue;
di = dictGetSafeIterator(d);
/* SELECT the new DB */
if (rioWrite(aof,selectcmd,sizeof(selectcmd)-1) == 0) goto werr;
if (rioWriteBulkLongLong(aof,j) == 0) goto werr;
// 遍历数据库中所有的键
while((de = dictNext(di)) != NULL) {
sds keystr;
robj key, *o;
long long expiretime;
keystr = dictGetKey(de);
o = dictGetVal(de);
initStaticStringObject(key,keystr);
expiretime = getExpire(db,&key);
// 根据键的类型进行重写
if (o->type == OBJ_STRING) {
/* Emit a SET command */
char cmd[]="*3\r\n$3\r\nSET\r\n";
if (rioWrite(aof,cmd,sizeof(cmd)-1) == 0) goto werr;
/* Key and value */
if (rioWriteBulkObject(aof,&key) == 0) goto werr;
if (rioWriteBulkObject(aof,o) == 0) goto werr;
} else if (o->type == OBJ_LIST) {
if (rewriteListObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_SET) {
if (rewriteSetObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_ZSET) {
if (rewriteSortedSetObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_HASH) {
if (rewriteHashObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_STREAM) {
if (rewriteStreamObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_MODULE) {
if (rewriteModuleObject(aof,&key,o) == 0) goto werr;
} else {
serverPanic("Unknown object type");
}
// 如果键带有过期时间,那么过期时间也需要被重写
if (expiretime != -1) {
char cmd[]="*3\r\n$9\r\nPEXPIREAT\r\n";
if (rioWrite(aof,cmd,sizeof(cmd)-1) == 0) goto werr;
if (rioWriteBulkObject(aof,&key) == 0) goto werr;
if (rioWriteBulkLongLong(aof,expiretime) == 0) goto werr;
}
/* Read some diff from the parent process from time to time. */
if (aof->processed_bytes > processed+AOF_READ_DIFF_INTERVAL_BYTES) {
processed = aof->processed_bytes;
aofReadDiffFromParent();
}
/* Update info every 1 second (approximately).
* in order to avoid calling mstime() on each iteration, we will
* check the diff every 1024 keys */
if ((key_count++ & 1023) == 0) {
long long now = mstime();
if (now - updated_time >= 1000) {
sendChildInfo(CHILD_INFO_TYPE_CURRENT_INFO, key_count, "AOF rewrite");
updated_time = now;
}
}
}
dictReleaseIterator(di);
di = NULL;
}
return C_OK;
werr:
if (di) dictReleaseIterator(di);
return C_ERR;
}
上面的方法会将AOF日志文件缩小,但是把整个数据库的最新数据都写回磁盘,仍然是一个非常耗时的过程,这时,我们记住我们开头讲的,由于Redis是单线程的,所以任何有阻塞的点,我们都需要格外注意。
因此AOF的重写操作是由后台线程bgrewriteaof
来完成的,这是为了避免阻塞主线程,导致数据库的性能下降。
为了在重写过程中父进程不阻塞,所以我们fork出了一个子进程,然后父进程在子进程进行AOF重写的期间仍然可以正常的处理写指令。但是这会导致的问题就是,当我AOF重写完成之后,父进程又处理了几个写指令,AOF文件里面的内容和实际不符合了。为了解决这个问题,Redis引入了一个AOF重写缓冲区。当子进程开始重写的时候,父进程会开辟一个AOF重写缓冲区,父进程接收到的写指令同时发送给AOF缓存区和AOF重写缓冲区。当AOF重写完毕之后会发送一个进程信号给父进程,然后父进程会把AOF重写缓存区的内容追加到AOF文件中,并释放AOF缓冲区中的内存。
上述就是AOF日志的所有内容,这个时候我们思考一个问题:如果这个Redis数据库里面的数据只要几百个键还好说,如果上万呢?那么执行上万条指令无疑恢复的很缓慢,影响到正常使用(AOF加载的原理是Redis创建一个没有网络功能的伪客户端,然后在这个伪客户端里面一条一条的执行指令,这太慢了,蕾姆~)。有没有既可以保证可靠性又可以在宕机的时候快速恢复的方法呢?
当然有,我们的RDB内存快照此时就要上场啦!
RDB内存快照
RDB内存快照,顾名思义,就像是照照片一样,把内存中的数据在某一个时刻的状态记录。这就类似于照片,当你给朋友拍照时,一张照片就能把朋友一瞬间的形象完全记录下来。
RDB文件记录的是状态,是二进制的,不是命令,所以可以直接读入,速度非常快。
我们照相的时候经常有两个步骤:
- 哎哎,过来点儿,你没有站进来。
- 别动,我要照了,动了就糊了。
这两个步骤描述了两个问题:
- 我在给谁做快照? 给所以的数据做全量快照
- 快照时数据可以修改吗?
那么这个问题很关键,快照时数据可以修改吗?
在这之前我们要先了解两个命令:
- save:在主线程中执行,会导致阻塞
- bgsave:创建一个子进程,我们就可以通过bgsave命令来执行全量快照,这是Redis RDB文件生成的默认配置。
我们可以通过bgsave命令来执行全量快照,这不仅提供了数据的可靠性,也避免了对Redis性能的影响。
那么回到刚才问题,快照时数据可以被修改吗?
这个时候有同学就会很疑惑了,你不是说bgsave可以避免阻塞吗?这里就是一个常见的误区了:避免阻塞和正常处理写操作并不是一回事。此时主线程的确没有阻塞,可以正常接收请求,但是为了保证快照的完整性,它只能处理读操作,因为不能修改正在执行快照的数据。为了快照而暂停写操作我们是无法接收的。
那么怎么解决这个问题呢?
实际上在我们fork的时候,操作系统在内核层面已经自动的帮我们解决了这个问题,fork使用了写时拷贝技术。如果主线程对这些数据都是读操作,那么主线程和bgsave子进程互相不影响。但是如果主线程要修改一块数据,那么这份数据会先被复制一份,生成该数据的副本。然后bgsave子进程会把这个副本写入RDB文件,在这个过程中,父进程仍然可以直接修改原来的数据。
快照的频率
和AOF的写回策略一样,RDB快照也需要有进行快照的频率。
- 如果我们频率很低,那么一旦宕机数据损失严重。
- 如果我们的频率很高,那么会出现以下两个问题:
- 频繁讲全量数据写入磁盘,会给磁盘带来巨大的压力,多个快照竞争有限的磁盘带宽,前一个还没有结束后面的一个就开始了,容易造成恶性循环。
- bgsave需要fork创建出来,fork创建过程本身会阻塞,主线程内存越大,阻塞时间越长。
因此我们可以使用AOF+RDB混合使用。简单来说就是内存快照以一定的频率执行,在两次快照期间,使用AOF日志记录期间所有的命令操作。这样就兼顾了这两者的优点了,是一个非常好的方案。
那么RDB如果以一定的频率进行呢?
这个我们可以修改配置文件里面的内容:
// 900秒内,对数据库至少进行了一次修改操作
save 900 1
// 300秒内,对数据库至少进行了10次修改操作
save 300 10
那么在Redis中,是怎么实现的呢?
struct redisServer {
// 记录了保存条件的数组
struct saveparam *saveparams
}
struct saveparam {
// 秒数
time_t seconds;
// 修改数
int ehanges;
}
这个修改数就记录了进行了几次修改,秒数记录的是过去了多少秒。
同时服务器状态里面还维护了一个dirty计数器和lastsave属性:
struct redisServer {
// 修改计数器
long long dirty;
// 上一次执行保存的时间
time_t lastsave;
}
当数据库成功执行一个修改命令之后,程序就会对dirty计数器进行更新,然后通过saveparam里面的值和dirty以及lastsave进行对比可以知道保存条件是否满足。