前言
我们知道 Redis 之所以快,很大程度是因为它的数据直接放在内存里,而内存是易失性存储器,只有通电才存储数据,断电数据就会丢失。
这个时候就要看你的应用场景了,如果你只是拿 Redis 做关系型数据库的缓存,来加速数据的访问效率,那么 Redis 数据即使丢了也不影响,可以重新从关系型数据库中重新加载一遍。但如果你直接拿 Redis 当做数据库来用,在上面存储业务数据,那么你就要重点关注下 Redis 的持久化机制了。
RDB 是 Redis DataBase 的缩写,它是 Redis 提供的最简单的持久化机制,也叫内存快照。它会把某一时刻的数据全量写入磁盘,Redis 重启后会加载 RDB 文件来恢复数据。
触发
首先你要配置持久化的文件名,默认是dump.rdb
dbfilename dump.rdb
然后你可以通过命令save 和bgsave 来手动触发 RDB 持久化,它俩的区别是前者会阻塞主线程,后者会 fork 一个子进程异步处理
127.0.0.1:6379[1]> save
OK
127.0.0.1:6379[1]> bgsave
Background saving started
你还可以通过配置,在多长时间内有多少次写入变更就主动触发一次 RDB 持久化
save 3600 1
save 300 100
save 60 10000
除此之外,其它一些情况 Redis 也会触发 RDB 持久化,比如 Redis 正常关闭、副本节点全量数据同步等等。
需要注意的是,RDB 持久化是有代价的,不宜频繁触发。以save 命令为例,它会阻塞主线程,假设数据有 4G,磁盘的写入带宽是 100MB/s,RDB 持久化阻塞的时间最少是 40 秒,期间 Redis 不能处理任何其它命令,这显然是不可接受的。其次,就算是bgsave 异步持久化,主进程 fork 子进程也是要阻塞的,数据量越大阻塞的时间越长,子进程持久化期间,如果有大量写入就会导致大量的写时复制,也会严重影响 Redis 性能。
RDB文件格式
转储后的 RDB 文件由三部分组成:
- 文件头:魔数、RDB 版本、Redis 版本,创建时间等信息
- 数据库数据:各个数据库的所有键值对
- 文件尾:结尾符、校验和
Redis 首先会写入文件头信息,主要包含 Redis 的版本号、Redis 运行的架构信息、创建时间、使用内存大小等等。
属性 | 示例值 | 说明 |
---|---|---|
Magic | REDIS0009 | 魔数 |
redis-ver | 6.2.13 | Redis 版本号 |
redis-bits | 64 | 架构信息 32or64 |
ctime | 1607211828 | 创建时间 |
used-mem | 82718 | 使用内存大小 |
aof-preamble | 1 | 是否写入AOF前导 |
再接着开始写入各个数据库的所有键值对数据,由两部分组成:
- RDB_OPCODE_SELECTDB:SELECT-DB 操作码,确定后续键值对属于哪个数据库
- 若干个键值对
键值对本身还有一些额外信息,例如:过期时间,LRU/LFU 信息等等,Redis 定义了一批操作码来标识这些信息,键值对的信息如下:
属性 | 操作码 | 说明 |
---|---|---|
RDB_OPCODE_EXPIRETIME_MS | 252 | 过期时间(可选) |
RDB_OPCODE_IDLE | 248 | LRU 闲置时间(可选) |
RDB_OPCODE_FREQ | 249 | LFU 访问频率(可选) |
ObjectType | 0~7 | 对象类型 |
Key Length | - | Key 长度 |
Key | - | Key 值 |
Value | - | Value 值(不同类型存储方式不一样) |
最后写入文件尾,包含两部分:
- EOF:RDB 文件结尾符
- checksum:校验和,防止文件篡改/损坏
眼见为实,下面来测试一下,看看 RDB 文件到底长啥样。首先清空数据库,然后写入一个字符串:
127.0.0.1:6379[1]> flushall
OK
127.0.0.1:6379[1]> set name jackson
OK
127.0.0.1:6379[1]> save
OK
然后查看 RDB 文件,因为是二进制的,无法直接查看,这里把对应的 ASCII 码打印出来。可以看到前面依次是:魔数、Redis 版本号、Redis 运行架构信息、创建时间、内存使用量、AOF 前导标志,然后再是键值对数据。
od -A x -t x1c -v dump.rdb
0000000 52 45 44 49 53 30 30 30 39 fa 09 72 65 64 69 73
R E D I S 0 0 0 9 372 \t r e d i s
0000010 2d 76 65 72 06 36 2e 32 2e 31 33 fa 0a 72 65 64
- v e r 006 6 . 2 . 1 3 372 \n r e d
0000020 69 73 2d 62 69 74 73 c0 40 fa 05 63 74 69 6d 65
i s - b i t s 300 @ 372 005 c t i m e
0000030 c2 a3 05 22 65 fa 08 75 73 65 64 2d 6d 65 6d c2
£ ** 005 " e 372 \b u s e d - m e m 302
0000040 30 00 11 00 fa 0c 61 6f 66 2d 70 72 65 61 6d 62
0 \0 021 \0 372 \f a o f - p r e a m b
0000050 6c 65 c0 00 fe 01 fb 01 00 00 04 6e 61 6d 65 07
l e 300 \0 376 001 373 001 \0 \0 004 n a m e \a
0000060 6a 61 63 6b 73 6f 6e ff a1 ce 0b 3e b5 94 1c 18
j a c k s o n 377 241 316 \v > 265 224 034 030
0000070
源码
Redis 在src/rdb.h
文件下定义了一批操作码,用来区分写入 RDB 文件的命令和属性,比如:选择数据库、记录对象过期时间、写入结尾符等等。
#define RDB_OPCODE_MODULE_AUX 247 /* Module auxiliary data. */
#define RDB_OPCODE_IDLE 248 /* LRU idle time. */
#define RDB_OPCODE_FREQ 249 /* LFU frequency. */
#define RDB_OPCODE_AUX 250 /* RDB aux field. */
#define RDB_OPCODE_RESIZEDB 251 /* Hash table resize hint. */
#define RDB_OPCODE_EXPIRETIME_MS 252 /* Expire time in milliseconds. */
#define RDB_OPCODE_EXPIRETIME 253 /* Old expire time in seconds. */
#define RDB_OPCODE_SELECTDB 254 /* DB number of the following keys. */
#define RDB_OPCODE_EOF 255 /* End of the RDB file. */
同时还定义了一批对象类型,用于将 Redis 对象类型映射到 RDB 对象类型:
#define RDB_TYPE_STRING 0
#define RDB_TYPE_LIST 1
#define RDB_TYPE_SET 2
#define RDB_TYPE_ZSET 3
#define RDB_TYPE_HASH 4
#define RDB_TYPE_ZSET_2 5
#define RDB_TYPE_MODULE 6
#define RDB_TYPE_MODULE_2 7
RDB 持久化的入口方法是rdbSave()
,源码在src/rdb.c
文件下。
Redis 首先会根据进程 ID 生成一个临时文件,然后开始执行 RDB 持久化写入临时文件,最后替换旧文件。
int rdbSave(char *filename, rdbSaveInfo *rsi) {
char tmpfile[256];
// 生成一个临时文件 通过进程ID命名
snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
fp = fopen(tmpfile,"w");
// RDB持久化写入
if (rdbSaveRio(&rdb,&error,RDBFLAGS_NONE,rsi) == C_ERR) {
errno = error;
goto werr;
}
// 刷新到磁盘,关闭文件
if (fflush(fp)) goto werr;
if (fsync(fileno(fp))) goto werr;
if (fclose(fp)) { fp = NULL; goto werr; }
// 替换旧的rdb文件
if (rename(tmpfile,filename) == -1) {
return C_ERR;
}
return C_OK;
}
持久化的核心方法是rdbSaveRio()
,主要步骤:
- 写入文件头信息
- 遍历数据库
- 写入 SELECTDB 操作码、数据库编号
- 写入RESIZEDB操作码、设置全局哈希表和过期时间 Key 的哈希表大小
- 遍历哈希表,写入每个键值对信息
- 写入 EOF 操作码和 checksum
int rdbSaveRio(rio *rdb, int *error, int rdbflags, rdbSaveInfo *rsi) {
// 哈希表迭代器,每个数据库有一个全局哈希表记录键值对
dictIterator *di = NULL;
// 哈希表节点指针,遍历键值对
dictEntry *de;
// 魔数 REDIS+4位RDB版本+结尾符
char magic[10];
// 文件校验和 防止被篡改
uint64_t cksum;
size_t processed = 0;
int j;
long key_count = 0;
long long info_updated_time = 0;
char *pname = (rdbflags & RDBFLAGS_AOF_PREAMBLE) ? "AOF rewrite" : "RDB";
if (server.rdb_checksum)
rdb->update_cksum = rioGenericUpdateChecksum;
// 生成魔数写入到缓冲区
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION);
// 魔数写入RDB文件
if (rdbWriteRaw(rdb,magic,9) == -1) goto werr;
// 写入其它头信息 Redis版本、创建时间、内存大小等
if (rdbSaveInfoAuxFields(rdb,rdbflags,rsi) == -1) goto werr;
if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_BEFORE_RDB) == -1) goto werr;
// 遍历数据库
for (j = 0; j < server.dbnum; j++) {
redisDb *db = server.db+j;
// 数据库的全局哈希表,即所有键值对
dict *d = db->dict;
if (dictSize(d) == 0) continue;// 没有数据,跳过
// 哈希表迭代器
di = dictGetSafeIterator(d);
// 先写入SELECTDB操作码
if (rdbSaveType(rdb,RDB_OPCODE_SELECTDB) == -1) goto werr;
// 再写入数据库编号值
if (rdbSaveLen(rdb,j) == -1) goto werr;
// 写入RESIZEDB操作码,全局哈希表、过期Key哈希表的大小
uint64_t db_size, expires_size;
db_size = dictSize(db->dict);
expires_size = dictSize(db->expires);
if (rdbSaveType(rdb,RDB_OPCODE_RESIZEDB) == -1) goto werr;
if (rdbSaveLen(rdb,db_size) == -1) goto werr;
if (rdbSaveLen(rdb,expires_size) == -1) goto werr;
// 遍历哈希表,写入每个键值对
while((de = dictNext(di)) != NULL) {
// 取出Key和Value
sds keystr = dictGetKey(de);
robj key, *o = dictGetVal(de);
long long expire;
initStaticStringObject(key,keystr);
expire = getExpire(db,&key);
// 写入键值对
if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr;
if (rdbflags & RDBFLAGS_AOF_PREAMBLE &&
rdb->processed_bytes > processed+AOF_READ_DIFF_INTERVAL_BYTES)
{
processed = rdb->processed_bytes;
aofReadDiffFromParent();
}
if ((key_count++ & 1023) == 0) {
long long now = mstime();
if (now - info_updated_time >= 1000) {
sendChildInfo(CHILD_INFO_TYPE_CURRENT_INFO, key_count, pname);
info_updated_time = now;
}
}
}
dictReleaseIterator(di);
di = NULL; /* So that we don't release it again on error. */
}
if (rsi && dictSize(server.lua_scripts)) {
di = dictGetIterator(server.lua_scripts);
while((de = dictNext(di)) != NULL) {
robj *body = dictGetVal(de);
if (rdbSaveAuxField(rdb,"lua",3,body->ptr,sdslen(body->ptr)) == -1)
goto werr;
}
dictReleaseIterator(di);
di = NULL;
}
if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_AFTER_RDB) == -1) goto werr;
// 写入EOF操作码 0xff
if (rdbSaveType(rdb,RDB_OPCODE_EOF) == -1) goto werr;
// 写入校验和
cksum = rdb->cksum;
memrev64ifbe(&cksum);
if (rioWrite(rdb,&cksum,8) == 0) goto werr;
return C_OK;
werr:
if (error) *error = errno;
if (di) dictReleaseIterator(di);
return C_ERR;
}
写入键值对的方法是rdbSaveKeyValuePair()
,主要步骤:
- 写入过期时间操作码和值(可选)
- 写入 LRU 操作码和闲置时间(可选)
- 写入 LFU 操作码和访问频率信息(可选)
- 依次写入键值对的类型、Key值、Value值
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val, long long expiretime) {
int savelru = server.maxmemory_policy & MAXMEMORY_FLAG_LRU;
int savelfu = server.maxmemory_policy & MAXMEMORY_FLAG_LFU;
// 写入过期时间
if (expiretime != -1) {
if (rdbSaveType(rdb,RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
}
// 写入LRU操作码和闲置时间
if (savelru) {
uint64_t idletime = estimateObjectIdleTime(val);
idletime /= 1000; /* Using seconds is enough and requires less space.*/
if (rdbSaveType(rdb,RDB_OPCODE_IDLE) == -1) return -1;
if (rdbSaveLen(rdb,idletime) == -1) return -1;
}
// 写入LFU操作码和访问频率信息
if (savelfu) {
uint8_t buf[1];
buf[0] = LFUDecrAndReturn(val);
if (rdbSaveType(rdb,RDB_OPCODE_FREQ) == -1) return -1;
if (rdbWriteRaw(rdb,buf,1) == -1) return -1;
}
// 依次写入 键值对类型、Key值、Value值
if (rdbSaveObjectType(rdb,val) == -1) return -1;
if (rdbSaveStringObject(rdb,key) == -1) return -1;
if (rdbSaveObject(rdb,val,key) == -1) return -1;
if (server.rdb_key_save_delay)
debugDelay(server.rdb_key_save_delay);
return 1;
}
rdbSaveObjectType()
用来写入对象类型,Redis 会把 RedisObject 类型映射成 RDB 对象类型,对应的是一个数字。接着开始写入键值对,因为 Key 是字符串,所以会调用rdbSaveStringObject()
方法写入,Redis 还会判断 Key 是否能用整型编码,如果可以会直接写入整形,否则写入字符串。
ssize_t rdbSaveStringObject(rio *rdb, robj *obj) {
// 优先尝试整型编码写入
if (obj->encoding == OBJ_ENCODING_INT) {
return rdbSaveLongLongAsStringObject(rdb,(long)obj->ptr);
} else { // 写入字符串
serverAssertWithInfo(NULL,obj,sdsEncodedObject(obj));
return rdbSaveRawString(rdb,obj->ptr,sdslen(obj->ptr));
}
}
Value 则会根据对象类型有不同的序列化方式写入,方法是rdbSaveObject()
,代码这里就不贴了。
尾巴
RDB 持久化可以把 Redis 数据库某一时刻的所有键值对写入磁盘文件,因为是二进制格式,所以恢复速度很快,非常适合数据备份、主从复制场景。转储后的 RDB 文件由文件头、数据库数据、文件尾组成,文件头主要记录 Redis 版本、运行架构信息等等;然后 Redis 会遍历数据库,先写入 SELECT-DB 操作码,再遍历哈希表写入所有键值对;最后写入结尾符和checksum,checksum 的主要作用是确保 RDB 文件没有被篡改或发生损坏。