在运行的情况下,redis将数据以数据结构的形式保存在内存中,为了让数据在redis重启之后仍然可用,Redis提供了RDB和AOF两种持久化方式,本篇讲解RDB持久化的工作方式
在运行时,redis将内存中的数据库以快照的方式存储到磁盘中,在重启后读取磁盘中RDB文件来将redis恢复到原来的状态
RDB的优缺点
优点
·RDB是一个非常紧凑的文件,它保存了redis在某个时间点上的数据集,非常适于备份
·RDB适用于灾难恢复,因为其只有一个文件,而且内容紧凑,可以将其传送到其他的数据中心用于保存
·RDB可以最大化redis的性能,执行RDB持久化时只需要fork一个子进程,并由子进程进行持久化工作,父进程不需要处理任何磁盘I/O操作
·RDB在恢复大数据集时比AOF要快
缺点
·如果你尽量避免服务器故障时丢失数据,那么RDB并不适合。因为RDB持久化要保存整个数据集的数据,这不是一个轻松的操作,所以不适合频繁操作,一旦服务器发生故障有可能会丢失几分钟的数据
·每次保存RDB时,父进程都需要fork一个子进程进行持久化的操作,如果数据集非常的庞大,fork所需的时间可能非常长,这样可能造成暂停处理客户端的请求
如果数据集非常巨大,并且 CPU 时间非常紧张的话,那么这种停止时间甚至可能会长达整整一秒。虽然 AOF 重写也需要进行 fork() ,但无论 AOF 重写的执行间隔有多长,数据的耐久性都不会有任何损失
RDB快照
在默认情况下,Redis 将数据库快照保存在名字为 dump.rdb 的二进制文件中。你可以对 Redis 进行设置,让它在“N 秒内数据集至少有 M 个改动”这一条件被满足时,自动保存一次数据集。你也可以通过调用SAVE 或者BGSAVE ,手动让 Redis 进行数据集保存操作
比如说,以下设置会让 Redis 在满足“60 秒内有至少有 1000 个键被改动”这一条件时,自动保存一次数据集:
save 60 1000
这种持久化方式被称为快照(snapshot)
格式
REDIS: 文件开头保存着 REDIS 5个字符,标示这一个RDB文件的开始
RDB-VERSION:一个四字节长的以字符表示的整数,记录了该文件所使用的 RDB 版本号
DB-DATA:这一部分会在一个RDB文件中出现不止一次,这个部分保存了redis中非空数据库的所有数据
SELECT-DB:这个字段保存着后面键值对所属的数据库序号
KEY-VALUE-PAIRS:因为空的数据库不会被保存到 RDB 文件,所以这个部分至少会包含一个键值对的数据。
每个键值对的数据使用以下结构来保存:
OPTIONAL-EXPIRE-TIME 域是可选的,如果键没有设置过期时间,那么这个域就不会出现;反之,如果这个域出现的话,那么它记录着键的过期时间,在当前版本的 RDB 中,过期时间是一个以毫秒为单位的 UNIX 时间戳
KEY 域保存着键,格式和 REDIS_ENCODING_RAW 编码的字符串对象一样
TYPE-OF-VALUE 域记录着 VALUE 域的值所使用的编码,根据这个域的指示,程序会使用不同的方式来保存和读取 VALUE 的值
EOF:标示数据库内容的结尾,EDIS_RDB_OPCODE_EOF
CHECK-SUM:RDB 文件所有内容的校验和,一个 uint_64t 类型值
REDIS 在写入 RDB 文件时将校验和保存在 RDB 文件的末尾,当读取时,根据它的值对内容进行校验
RDB持久化命令
SAVE
当SAVE命令执行时,Redis服务器是阻塞的,所以当一个SAVE命令执行时,不会处理另外的SAVE、BGSAVE或者BGREWRITEAOF命令
只有在上一个 SAVE 执行完毕、Redis 重新开始接受请求之后,新的 SAVE 、BGSAVE 或BGREWRITEAOF 命令才会被处理
另外,因为 AOF 写入由后台线程完成,而 BGREWRITEAOF 则由子进程完成,所以在 SAVE执行的过程中,AOF 写入和 BGREWRITEAOF 可以同时进行
BGSAVE
在执行 SAVE 命令之前,服务器会检查 BGSAVE 是否正在执行当中,如果是的话,服务器就不调用 rdbSave ,而是向客户端返回一个出错信息,告知在 BGSAVE 执行期间,不能执行SAVE
这样做可以避免 SAVE 和 BGSAVE 调用的两个 rdbSave 交叉执行,造成竞争条件
另一方面,当 BGSAVE 正在执行时,调用新 BGSAVE 命令的客户端会收到一个出错信息,告
知 BGSAVE 已经在执行当中
BGREWRITEAOF 和 BGSAVE 不能同时执行:
• 如果 BGSAVE 正在执行,那么 BGREWRITEAOF 的重写请求会被延迟到 BGSAVE 执行完毕之后进行,执行 BGREWRITEAOF 命令的客户端会收到请求被延迟的回复。
• 如果 BGREWRITEAOF 正在执行,那么调用 BGSAVE 的客户端将收到出错信息,表示这两个命令不能同时执行。
BGREWRITEAOF 和 BGSAVE 两个命令在操作方面并没有什么冲突的地方,不能同时执行它们只是一个性能方面的考虑:并发出两个子进程,并且两个子进程都同时进行大量的磁盘写入操作,这怎么想都不会是一个好主意
RDB的实现
RDB 功能最核心的是 rdbSave 和 rdbLoad 两个函数
下面具体分析rdbSave函数的实现
redis 支持两种方式进行 RDB:
当前进程执行和后台执行(BGSAVE)
RDB BGSAVE 策略是 fork 出一个子进程,把内存中的数据集整个 dump 到硬盘上。两个场景举例
- redis 服务器初始化过程中,设定了定时事件,每隔一段时间就会触发持久化操作;进入定时事件处理程序中,就会 fork 产生子进程执行持久化操作
- redis 服务器预设了 save 指令,客户端可要求服务器进程中断服务,执行持久化操作
rdbSave函数实现如下:
1: /* Save the DB on disk. Return REDIS_ERR on error, REDIS_OK on success */
2: /*
3: * 将数据库保存到磁盘上。成功返回 REDIS_OK ,失败返回 REDIS_ERR 。4: */5: int rdbSave(char *filename) {6: dictIterator *di = NULL;7: dictEntry *de;8: char tmpfile[256];
9: char magic[10];
10: int j;
11: long long now = mstime();12: FILE *fp;13: rio rdb;14: uint64_t cksum;15:16: // 以 "temp-<pid>.rdb" 格式创建临时文件名
17: snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());18: fp = fopen(tmpfile,"w");
19: if (!fp) {
20: redisLog(REDIS_WARNING, "Failed opening .rdb for saving: %s",
21: strerror(errno));22: return REDIS_ERR;
23: }24:25: // 初始化 rio 文件
26: rioInitWithFile(&rdb,fp);27: // 如果有需要的话,设置校验和计算函数
28: if (server.rdb_checksum)
29: rdb.update_cksum = rioGenericUpdateChecksum;30: // 以 "REDIS <VERSION>" 格式写入文件头,以及 RDB 的版本
31: snprintf(magic,sizeof(magic),"REDIS%04d",REDIS_RDB_VERSION);32: if (rdbWriteRaw(&rdb,magic,9) == -1) goto werr;33:34: // 遍历所有数据库,保存它们的数据
35: for (j = 0; j < server.dbnum; j++) {
36: // 指向数据库
37: redisDb *db = server.db+j;38: // 指向数据库 key space
39: dict *d = db->dict;40: // 数据库为空, pass ,处理下个数据库
41: if (dictSize(d) == 0) continue;42:43: // 创建迭代器
44: di = dictGetSafeIterator(d);45: if (!di) {
46: fclose(fp);47: return REDIS_ERR;
48: }49:50: /* Write the SELECT DB opcode */
51: // 记录正在使用的数据库的号码
52: if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_SELECTDB) == -1) goto werr;53: if (rdbSaveLen(&rdb,j) == -1) goto werr;54:55: /* Iterate this DB writing every entry */
56: // 将数据库中的所有节点保存到 RDB 文件
57: while((de = dictNext(di)) != NULL) {
58: // 取出键
59: sds keystr = dictGetKey(de);60: // 取出值
61: robj key,62: *o = dictGetVal(de);63: long long expire;64:65: initStaticStringObject(key,keystr);66: // 取出过期时间
67: expire = getExpire(db,&key);68: if (rdbSaveKeyValuePair(&rdb,&key,o,expire,now) == -1) goto werr;69: }70: dictReleaseIterator(di);71: }72: di = NULL; /* So that we don't release it again on error. */
73:74: /* EOF opcode */
75: if (rdbSaveType(&rdb,REDIS_RDB_OPCODE_EOF) == -1) goto werr;76:77: /* CRC64 checksum. It will be zero if checksum computation is disabled, the
78: * loading code skips the check in this case. */79: cksum = rdb.cksum;80: memrev64ifbe(&cksum);81: rioWrite(&rdb,&cksum,8);82:83: /* Make sure data will not remain on the OS's output buffers */
84: fflush(fp);85: fsync(fileno(fp));86: fclose(fp);87:88: /* Use RENAME to make sure the DB file is changed atomically only
89: * if the generate DB file is ok. */90: // 将临时文件 tmpfile 改名为 filename
91: if (rename(tmpfile,filename) == -1) {92: redisLog(REDIS_WARNING,"Error moving temp DB file on the final destination: %s", strerror(errno));
93: unlink(tmpfile);94: return REDIS_ERR;
95: }96:97: redisLog(REDIS_NOTICE,"DB saved on disk");
98:99: // 初始化数据库数据
100: server.dirty = 0;101: server.lastsave = time(NULL);
102: server.lastbgsave_status = REDIS_OK;103:104: return REDIS_OK;
105:106: werr:107: fclose(fp);108: unlink(tmpfile);109: redisLog(REDIS_WARNING,"Write error saving DB on disk: %s", strerror(errno));
110: if (di) dictReleaseIterator(di);
111: return REDIS_ERR;
112: }
在rdbSave()中,首先创建一个临时文件,在成功save后会改名,然后初始化rio(robust IO,是对IO操作的包装,在Redis中专门用于ddb save),然后在文件中首先记录REDIS MAGIC NUMBER。这里的rdb_checksum是校验和,每当向硬盘写时,都会更新校验和,用于后面读取rdb文件时校验
dictGetSafeIterator()是得到dict数据结构的一个迭代器,用于遍历Redis Instance中的数据库,首先写入REDIS_RDB_OPCODE_SELECTDB前缀和DB编号。这里的rdbSaveLen()就是实现长度压缩编码的函数,用6bits、14bits或者32bits保存长度。然后遍历DB,获得key和value,还有可能具有的expire time。因为在DB层,key是ads结构的字符串,需要先包装为robj对象,再根据String类型的保存方式保存。调用rdbSaveKeyValuePair()解析value类型,根据类型保存格式
最后写入校验和,同步内容到硬盘,然后重命名文件
以下为bgsave,fork生成一个子进程
1:2: /*
3: * 使用子进程保存数据库数据,不阻塞主进程4: */5: int rdbSaveBackground(char *filename) {6: pid_t childpid;7: long long start;8:9: if (server.rdb_child_pid != -1) return REDIS_ERR;10:11: // 修改服务器状态
12: server.dirty_before_bgsave = server.dirty;13:14: // 开始时间
15: start = ustime();16: // 创建子进程
17: if ((childpid = fork()) == 0) {
18: int retval;
19:20: /* Child */
21: // 子进程不接收网络数据
22: if (server.ipfd > 0) close(server.ipfd);23: if (server.sofd > 0) close(server.sofd);24:25: // 保存数据
26: retval = rdbSave(filename);27: if (retval == REDIS_OK) {
28: size_t private_dirty = zmalloc_get_private_dirty();29:30: if (private_dirty) {
31: redisLog(REDIS_NOTICE,32: "RDB: %lu MB of memory used by copy-on-write",
33: private_dirty/(1024*1024));34: }35: }36:37: // 退出子进程
38: exitFromChild((retval == REDIS_OK) ? 0 : 1);39: } else {
40: /* Parent */
41: // 记录最后一次 fork 的时间
42: server.stat_fork_time = ustime()-start;43:44: // 创建子进程失败时进行错误报告
45: if (childpid == -1) {
46: redisLog(REDIS_WARNING,"Can't save in background: fork: %s",
47: strerror(errno));48: return REDIS_ERR;
49: }50:51: redisLog(REDIS_NOTICE,"Background saving started by pid %d",childpid);
52:53: // 记录保存开始的时间
54: server.rdb_save_time_start = time(NULL);
55: // 记录子进程的 id
56: server.rdb_child_pid = childpid;57: // 在执行时关闭对数据库的 rehash
58: // 避免 copy-on-write
59: updateDictResizePolicy();60:61: return REDIS_OK;
62: }63:64: return REDIS_OK; /* unreached */65: }66:
如果采用 BGSAVE 策略,且内存中的数据集很大,fork() 会因为要为子进程产生一份虚拟空间表而花费较长的时间;如果此时客户端请求数量非常大的话,会导致较多的写时拷贝操作;在 RDB 持久化操作过程中,每一个数据都会导致 write() 系统调用,CPU 资源很紧张。因此,如果在一台物理机上部署多个 redis,应该避免同时持久化操作。
那如何知道 BGSAVE 占用了多少内存?子进程在结束之前,读取了自身私有脏数据 Private_Dirty 的大小,这样做是为了让用户看到 redis 的持久化进程所占用了有多少的空间。在父进程 fork 产生子进程过后,父子进程虽然有不同的虚拟空间,但物理空间上是共存的,直至父进程或者子进程修改内存数据为止,所以脏数据 Private_Dirty 可以近似的认为是子进程,即持久化进程占用的空间
一个函数是zmalloc_get_private_dirty,这是进程fork之后子进程多占用的内存,从/proc/self/smaps中读取Private_Dirty字段的值。这里要说一下Private_Dirty和Private_Clean,进程fork之后,开始内存是共享的,即从父进程那里继承的内存空间都是Private_Clean,运行一段时间之后,子进程对继承的内存空间做了修改,这部分内存就不能与父进程共享了,需要多占用,这部分就是Private_Dirty
RDB写入时,会连内存一起Fork出一个新进程,遍历新进程内存中的数据写文件,这样就解决了些Snapshot过程中又有新的写入请求进来的问题
redis采用了操作系统的Copy-On-Write策略(子进程与父进程共享Page。如果父进程的Page-每页4K有修改,父进程自己创建那个Page的副本,不会影响到子进程,父爱如山)
对于每一个键值对都会调用 rdbSaveKeyValuePair(),如下:
1: /* Save a key-value pair, with expire time, type, key, value.
2: * 保存键值对,值的类型,以及它的过期时间(如果有的话)。3: *4: * On error -1 is returned.5: * 出错返回 -1 。6: *7: * On success if the key was actaully saved 1 is returned, otherwise 08: * is returned (the key was already expired).9: *10: * 如果 key 已经过期,放弃保存,返回 0 。11: * 如果 key 保存成功,返回 1 。12: */13: int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
14: long long expiretime, long long now)15: {16: /* Save the expire time */
17: // 保存过期时间
18: if (expiretime != -1) {
19: /* If this key is already expired skip it */
20: // key 已过期,直接跳过
21: if (expiretime < now) return 0;22:23: if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;24: if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;25: }26:27: /* Save type, key, value */
28: // 保存值类型
29: if (rdbSaveObjectType(rdb,val) == -1) return -1;30: // 保存 key
31: if (rdbSaveStringObject(rdb,key) == -1) return -1;32: // 保存 value
33: if (rdbSaveObject(rdb,val) == -1) return -1;34:35: return 1;
36: }