Redis 持久化相关源码阅读笔记,源码文件 rdb.h & rdb.c & aof.c


Redis 提供两种持久化方式供选择:RDB 和 AOF,其详细介绍可阅读 Redis Persistence

RDB

RDB 为数据集在某一时间点的 snapshot,可通过 BGSAVESAVE 命令主动触发,也同通过配置 save 相关参数被动触发达到备份数据的目的。除此之外,Redis 的主从全量同步时也会生成 rdb 文件以供同步使用。

RDB 编码格式

在 RDB 文件中,每种数据类型都会按照 <type> <len> <data> 的顺序编码,当 type 为某些类型时,会省略 data 字段。type 字段长度固定为 1 byte (0-255),值得注意的是,其不仅涵盖不同的数据对象类型,还包含多种 RDB opcodes。这些 RDB opcodes 类型用于在编码(解码)时存储(加载)除了数据对象内容外的额外信息,以备份(恢复)数据库的完整状态。

Object types

rdbSaveObjectType 函数中我们可以知道不同的数据对象类型与 RDB type 的对应关系如下表所示:

robj->type robj->encoding RDB type
OBJ_STRING All RDB_TYPE_STRING
OBJ_LIST OBJ_ENCODING_QUICKLIST RDB_TYPE_LIST_QUICKLIST
OBJ_SET OBJ_ENCODING_INTSET RDB_TYPE_SET_INTSET
OBJ_ENCODING_HT RDB_TYPE_SET
OBJ_ZSET OBJ_ENCODING_ZIPLIST RDB_TYPE_ZSET_ZIPLIST
OBJ_ENCODING_SKIPLIST RDB_TYPE_ZSET_2
OBJ_HASH OBJ_ENCODING_ZIPLIST RDB_TYPE_HASH_ZIPLIST
OBJ_ENCODING_HT RDB_TYPE_HASH
OBJ_STREAM All RDB_TYPE_STREAM_LISTPACKS
OBJ_MODULE All RDB_TYPE_MODULE_2

RDB_TYPE_ZSETRDB_TYPE_MODULE 目前在编码时已不再使用,仅供解码时使用,以便能兼容之前版本的 RDB。容易看出高版本的 Redis 可以解码低版本的 RDB 文件,而低版本的 Redis 不可解码高版本的 RDB 文件。

rdbSaveKeyValuePair 函数中我们可以看到,在存储数据对象内容时会按照 <RDB type> <key> <value> 的顺序排布,其中 key 和字符串对象值的存储格式一致,均调用 rdbSaveStringObject 进行存储。

rdbSaveObject 中我们可以发现 value 的存储格式与数据对象的类型(RDB type)有关,其对应关系如下所示:

RDB type RDB content (不考虑压缩)
RDB_TYPE_STRING <len> [data]
RDB_TYPE_LIST_QUICKLIST <ql->len> <zlbytes> [zdata] ......
RDB_TYPE_SET_INTSET <intsetBytes> [intset.data]
RDB_TYPE_SET <dictSize> <ele.len> [ele.data] ......
RDB_TYPE_ZSET_ZIPLIST <zlbytes> [zdata]
RDB_TYPE_ZSET 兼容旧版 RDB,已弃用
RDB_TYPE_ZSET_2 <zsl->length> <member.len> [member.data] <score> ......
RDB_TYPE_HASH_ZIPLIST <zlbytes> [zdata]
RDB_TYPE_HASH <dictSize> <field.len> [field.data] <value.len> [value.data] ......
RDB_TYPE_STREAM_LISTPACKS TODO
RDB_TYPE_MODULE 兼容旧版 RDB,已弃用
RDB_TYPE_MODULE_2 TODO

<> 表示该项必定存在,[] 表示该项可能不存在

RDB_TYPE_STRING

1
ssize_t rdbSaveStringObject(rio *rdb, robj *obj);

字符串对象调用 rdbSaveStringObject 进行存储,其编码会在以下情况下省略 data 字段:

  • robj->encodingOBJ_ENCODING_INT 且数值范围为 $[2^{-31}, 2^{31}-1]$ (int32_t)
  • 字符串长度小于 11 且可转化为 int32_t 范围内的整型

通过阅读 rdbSaveRawString 可以发现,当使用 <len> <data> 存储字符串对象时,若 rdbcompression 配置为 yes,且字符串长度大于 20,则会尝试对字符串进行 LZF 压缩,若压缩后最小可节省 4 byte,则调用 rdbSaveLzfBlob 存储压缩后的内容:

1
2
3
4
+----------+--------------+--------------+---------------+
| 11000011 | compress_len | original_len | compress_data |
+----------+--------------+--------------+---------------+
   1 byte

OBJ_ENCODING_QUICKLIST

首先存储 ql->len,即 quicklist 中节点的个数;然后遍历 quicklist,如果当前 quicklistNode 已被压缩,则调用 rdbSaveLzfBlob 存储压缩后的 ziplist,否则调用 rdbSaveRawString 存储 ziplist。可以看出 ziplist 被当成字符串直接存储,便于存取。

RDB_TYPE_SET

当集合类型使用 dict 作为底层实现时,其对应的 RDB type 为 RDB_TYPE_SET。在 RDB_TYPE_SET 中,首先存储集合大小 dictSize;随后遍历 dict,调用 rdbSaveRawString 存储 dictEntry->key (集合类型中 dictEntry->val 为空)。

RDB_TYPE_SET_INTSET

当集合类型使用 intset 作为底层实现时,其对应的 RDB type 为 RDB_TYPE_SET_INTSET,调用 rdbSaveRawString 存储整个 intset

RDB_TYPE_ZSET_ZIPLIST

当 ZSET 使用 ziplist 作为底层实现时,其对应的 RDB type 为 RDB_TYPE_ZSET_ZIPLIST,调用 rdbSaveRawString 存储整个 ziplist

RDB_TYPE_ZSET_2

当 ZSET 使用 skiplist 作为底层实现时,其对应的 RDB type 为 RDB_TYPE_ZSET_2。首先存储 skiplist 中的节点个数 zskiplist->length,随后逆序遍历 skiplist,调用 rdbSaveRawString 存储 zskiplistNode->ele;调用 rdbSaveBinaryDoubleValue 存储 zskiplistNode->score。逆序遍历的目的是为了在 RDB load 阶段便于 skiplist 的插入操作,由于每个将要插入的节点都小于当前最小的节点,因此只需往头部插入即可。

RDB_TYPE_HASH_ZIPLIST

当 Hash 使用 ziplist 作为底层实现时,其对应的 RDB type 为 RDB_TYPE_HASH_ZIPLIST,调用 rdbSaveRawString 存储整个 ziplist

RDB_TYPE_HASH

当 Hash 使用 dict 作为底层实现时,其对应的 RDB type 为 RDB_TYPE_HASH。首先存储哈希表大小 dictSize;随后遍历 dict,调用两次 rdbSaveRawString 按序存储 dictEntry->keydictEntry->v.val

RDB_TYPE_STREAM_LISTPACKS

TODO

RDB_TYPE_MODULE_2

TODO

RDB opcodes

RDB type Meaning RDB content
RDB_OPCODE_MODULE_AUX Module auxiliary data TODO
RDB_OPCODE_IDLE LRU idle time len: <idletime> (seconds)
RDB_OPCODE_FREQ LFU frequency Raw: <LFUCounter> (8-bit)
RDB_OPCODE_AUX RDB aux field Str: <key.len> [key.data] <val.len> [val.data]
RDB_OPCODE_RESIZEDB Hash table resize hint len: <db_size> <expires_size>
RDB_OPCODE_EXPIRETIME_MS Expire time in milliseconds Raw: <expiretime>
RDB_OPCODE_EXPIRETIME Old expire time in seconds 兼容旧版 RDB,已弃用
RDB_OPCODE_SELECTDB DB number of the following keys len: <current_db_id>
RDB_OPCODE_EOF End of the RDB file Raw: <cksum>
  • 省略 redis module sub opcodes
  • len 表示该字段内容与 len 字段编码规则相同,将在下节讲述
  • Raw 表示该字段内容定长,直接写入存储
  • Str 表示该字段调用 rdbSaveRawString 写入

length 编码

Redis 为了节省存储空间,不仅会对 data 字段进行压缩,而且也会使用不定长空间存储 len 字段。通过读取 len 字段首个字节可知道此时的编码格式。

长度信息

通过阅读 rdbSaveLen 可以发现,在使用 len 字段存储长度信息时,其原生值为整型,根据整型值的大小可用以下四种编码方式之一进行存储:

  • 00|XXXXXX: the len is the 6 bits of this byte
  • 01|XXXXXX XXXXXXXX: the len is 14 byes, 6 bits + 8 bits of next byte
  • 10|000000 <32 bit integer>: A full 32 bit len in net byte order will follow
  • 10|000001 <64 bit integer>: A full 64 bit len in net byte order will follow

字符串对象编码

正如之前所说,在存储字符串对象时,一般会使用 len 字段存储字符串长度,data 字段存储字符串内容,但在字符串对象可用整型表示时或可压缩节省空间时,len 字段所存储的信息便不是字符串长度信息,通过阅读 rdbEncodeInteger,可以发现根据 len 字段首字节我们可以知道字符串对象此时的编码方式:

  • 11|000000 <8 bit integer>: 字符串对象可用 8 bit 整型表示
  • 11|000001 <16 bit integer>: 字符串对象可用 16 bit 整型表示
  • 11|000010 <32 bit integer>: 字符串对象可用 32 bit 整型表示
  • 11|000011 <compress_len> <original_len> <compress_data>: 字符串对象可压缩

RDB 文件内容排布

通过阅读 rdbSaveRio,我们可以知道 RDB 文件内容的排布方式:

  • RDB 版本号:格式为 REDIS0009,其中 $9$ 为Redis-6.0.8 的 RDB_VERSION,直接写入二进制串无编码

  • Aux Fields: 编码格式 <RDB_OPCODE_AUX> <key.len> [key.data] <val.len> [val.data],key-val 对内容如下,其中:

    • 若传入的 rdbSaveInfo(rsi) 为空,则无须存储 repl-stream-dbrepl-idrepl-offset 信息。
    • aof-preamble 为 1 时,表示该 rdb 文件是 AOF 重写时生成的,可通过配置项 aof-use-rdb-preamble 修改该值。
key value meaning
redis-ver REDIS_VERSION redis 当前版本
redis-bits sizeof(void*) 处理器运算位数
ctime time(NULL) 创建时间
used-mem zmalloc_used_memory() 实际分配内存大小
repl-stream-db rsi->repl_stream_db DB to select in server.master client
repl-id server.replid My current replication ID
repl-offset server.master_repl_offset My current replication offset
aof-preamble 0 or 1 Load/save the RDB as AOF preamble?
  • Module-specific aux values: Iterate over modules, and trigger rdb aux saving for the ones modules types who asked for it. (when is REDISMODULE_AUX_BEFORE_RDB)

  • 遍历及存储每个 redisDb 的具体信息:

    • db 编号:<RDB_OPCODE_SELECTDB> <current_db_id>

    • db 大小:RDB_OPCODE_RESIZEDB <db_size> <expires_size>

    • 遍历 redisDb->dict,存储数据对象相关信息:

      • 如有过期时间,则存储 <RDB_OPCODE_EXPIRETIME_MS> <expiretime>

        • expiretime is the absolute unix time in milliseconds
      • 按照内存管理策略存储 LRU/LFU 信息:

        • LRU: <RDB_OPCODE_IDLE> <idletime>
        • LFU: <RDB_OPCODE_FREQ> <LFUCounter>
      • 数据对象内容:<type> <key> <value>,详细内容可参照 Object types

  • Lua scripts: 若传入的 rdbSaveInfo 不为空,则遍历及存储此时缓存的 lua 脚本信息,每个 lua 脚本的编码格式为 <RDB_OPCODE_AUX> <lua> <lua-script>

  • Module-specific aux values: Iterate over modules, and trigger rdb aux saving for the ones modules types who asked for it. (when is REDISMODULE_AUX_AFTER_RDB)

  • EOF opcode: <RDB_OPCODE_EOF> <cksum>,若未启用计算校验和,则 cksum 值为 0

RDB 相关命令

SAVE

TODO

BGSAVE

TODO

AOF

Redis 除了使用数据集的 snapshot (RDB) 提供持久化保证之外,还会使用 Append Only Flie(AOF) 记录写请求。

写入 AOF 缓冲区

在 Redis 调用 propagate 函数将请求同步至 AOF 和 replications 时,会调用 feedAppendOnlyFile 函数将请求写入 AOF 缓冲区,该函数的接口如下:

1
void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc);

其执行流程大致为:

  1. 如传入的 dictid 和上次记录请求时的 db 不同,则写入 SELECT dictid 请求以切换 db
  2. 命令转换,将过期相对时间改为绝对时间:
    • Translate EXPIRE/PEXPIRE/EXPIREAT into PEXPIREAT
    • Translate SETEX/PSETEX to SET and PEXPIREAT
    • Translate SET [EX seconds][PX milliseconds] to SET and PEXPIREAT
  3. 将内容追加至 server.aof_buf
  4. 如此时有子进程正在执行 AOF rewrite,则会将内容追加之 AOF rewrite buff server.aof_rewrite_buf_blocks

写入 AOF 文件

AOF 缓冲区的数据会在以下几种情况下调用 flushAppendOnlyFile 写入 AOF 文件,同时可选择是否启用强制模式:

函数 flushAppendOnlyFile 的接口如下:

1
void flushAppendOnlyFile(int force);

阅读 Redis persistence demystified 后我们知道,简单来说:

  1. 当调用 write() 后,此时数据在 kernel’s buffer
  2. 继续调用 fsync() 后,数据在 disk cache (也可选择让 OS 自行控制何时 flush)
  3. 随后由 disk controller 将数据写入物理介质

其中 1 2 可控,而 flushAppendOnlyFile 主要也可分为这两部分。

write

  • 在使用强制模式时,如果此时 AOF 缓冲区非空,则一定会调用 write(),即使可能阻塞 Redis,因此使用 CONFIG 命令关闭 AOF 功能可能会阻塞 Redis
  • 只有当 fsync 的策略为 everysec 时,非强制模式才会在下述情况下阻止调用 write():
    • 当前有 background fsync 进程在运行,且此前无推迟的 write 调用,记录本次推迟的 write 调用时间并返回
    • 当前有 background fsync 进程在运行,且此前推迟的 write 调用在 $2s$ 内,则直接返回

      若超过 2s,则不会阻止调用 write,此时可能会阻塞 Redis

fsync

AOF 调用 fsync() 的频率可通过配置 appendfsync 进行控制,可选项有三:

  • no: don’t fsync, just let the OS flush the data when it wants. Faster.
  • always: fsync after every write to the append only log. Slow, Safest.
  • everysec: fsync only one time every second. Compromise.

通过在 write 阶段的介绍可知,当使用 everysec 策略时,事实上可能会丢失 $2s$ 的写入,而不是 $1s$。

如果有 RDB saving 或 AOF rewriting 子进程,可能会消耗 IO 资源,导致 fsync 操作耗时过长阻塞 Redis,因此为性能考虑可以通过配置 no-appendfsync-on-rewriteyes 阻止调用 fsync

AOF Rewrite

由于 AOF 文件的 Append Only 要求,会使得该文件大小单调递增,因此 Redis 提供了 AOF rewrite 的功能,使用 rewrite 生成的 AOF 文件替换旧的 AOF 文件,以减小 AOF 文件所占用的磁盘空间大小。既可以主动发起 BGREWRITEAOF 请求要求进行 AOF rewrite,亦可以通过配置 auto-aof-rewrite-percentageauto-aof-rewrite-min-size 调整自发进行 AOF rewrite 的条件。当当前 AOF 文件大小 server.aof_current_size 大于 auto-aof-rewrite-min-size 且与上次 rewrite 之后 AOF 文件大小 server.aof_rewrite_base_size 相比增长率超过 auto-aof-rewrite-percentage 时,Redis 会自动触发 AOF rewrite 操作 —— 调用 rewriteAppendOnlyFileBackground,其接口如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* This is how rewriting of the append only file in background works:
 *
 * 1) The user calls BGREWRITEAOF
 * 2) Redis calls this function, that forks():
 *    2a) the child rewrite the append only file in a temp file.
 *    2b) the parent accumulates differences in server.aof_rewrite_buf_blocks.
 * 3) When the child finished '2a' exists.
 * 4) The parent will trap the exit code, if it's OK, will append the
 *    data accumulated into server.aof_rewrite_buf_blocks into the temp file, and
 *    finally will rename(2) the temp file in the actual file name.
 *    The the new file is reopened as the new append only file. Profit! */
int rewriteAppendOnlyFileBackground(void)

AOF Rewrite Buffer

由于在 AOF rewrite 执行过程中,Redis 不会停止服务,因此需要记录这期间数据库的变化。Redis 使用双端链表 aof_rewrite_buf_blocks 实现 AOF Rewirte buffer,每个 listNode 指向一个大小为 $10MB$ 的内存块 aofrwblock

1
2
3
4
5
6
#define AOF_RW_BUF_BLOCK_SIZE (1024*1024*10)    /* 10 MB per block */

typedef struct aofrwblock {
    unsigned long used, free;
    char buf[AOF_RW_BUF_BLOCK_SIZE];
} aofrwblock;

如果此时正在进行 AOF rewrite,则 Redis 主进程除了将写请求写入 AOF 缓冲区之外,还会通过调用 aofRewriteBufferAppend将数据写入 AOF rewrite buffer。在 aofRewriteBufferAppend 中,以 append 的形式写入数据,同时会创建 aofChildWriteDiffData file event,以将 AOF rewrite buffer 中的内容从前往后遍历写入管道 server.aof_pipe_write_data_to_child,以供子进程读取,写入管道成功后会释放该节点所占用的内存块。因此,AOF rewrite buffer 如同一个先进先出的队列,在 append 时(链表尾部)申请内存,在写入管道后(链表头部)释放内存。

Use RDB preamble

因为 RDB 文件小,加载速度快,因此在 AOF rewrite 时,可使用 RDB 保存当前数据库状态。如果配置项 aof-use-rdb-preambleyes,则会调用 rdbSaveRio 保存数据库状态,此时 AOF rewrite 结束之后的文件组织形式如下:

1
2
3
+----------+----------+
| RDB file | AOF tail |
+----------+----------+

Rewrite Objects

当不启用 aof-use-rdb-preamble 时,会调用 rewriteAppendOnlyFileRio 函数将各个数据对象转换为请求的格式存储。