Redis 持久化原理和实现

2,164 阅读11分钟

Redis 所有的数据和状态存储在内存中,为了避免进程退出而导致数据丢失,需要将数据和状态保存到硬盘上。

为了达到这一目的,通常有两种实现方式:

  1. 将 Redis 当作一个状态机,记录每一次的对 Redis 的操作,也就是状态转移。需要恢复时再从初始状态开始,依次重放记录的操作,这样的方式称作逻辑备份
  2. 将 Redis 完整的状态保存下来,待必要时原样恢复,这样的方式称作物理备份

Redis 也实现了这两种持久化方式,分别时 AOF 和 RDB

AOF

AOF 通过保存 Redis 服务器执行的写命令记录数据库状态。

AOF 配置

Redis 源码中的配置文件示例: redis.conf

# AOF 配置示例
# https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/redis.conf#L489

# 重要参数:
appendonly yes # 是否开启 AOF,如果开启了 AOF,后续恢复数据库时会优先使用 AOF,跳过 RDB
appendfsync everysec # 持久化判断规则
appendfilename appendonly.aof # AOF 文件位置

命令执行完成后才会写入 AOF 日志

AOF 是写后日志,与写前日志(Write Ahead Log, WAL)相反,写入命令执行完成后才会记录到 AOF 日志。这样设计是因为 AOF 记录的是接收到的命令,并且记录时不会进行语法检查(保证性能),使用写后日志有 2 个优点

  1. 可以保证日志中记录的命令都是正确的
  2. 命令执行后才记录到日志,不会阻塞当前写操作

风险:

  1. 刚执行完命令,还没写入,此时宕机,这个命令和相应的数据有丢失的风险
  2. 避免了当前命令的阻塞,但是可能阻塞下一个命令

AOF 持久化执行步骤

  1. 服务器在执行完命令后,会将命令写入到 struct redisServersds aof_buf` 缓冲区末尾
  2. Redis 进程每一次事件循环(处理客户端请求的循环)末尾都会调用 void flushAppendOnlyFile 检查时候需要将缓冲区中的命令写入 AOF 文件

AOF 写入条件判断规则

flushAppendOnlyFile 中根据配置文件中的 appendfsync 参数判断是否写入 AOF 文件。将 aof_buf 中的命令写入 AOF 文件分为两个步骤:

  1. 调用 OS 的 write 函数,将 aof_buf 中的命令保存到内存缓冲区
  2. OS 将 内存缓冲区中的写入磁盘

如果只执行了第一步,从 redis 的视角来看,数据已经写入了文件,但实际上并没有写入,如果此时停机,数据仍然会丢失,因此可以使用 OS 提供的 fsyncfdatasync 强制将缓冲区中的数据写入磁盘

flushAppendOnlyFile 行为appndfsync 选项
总是将 aof_buf 缓冲区中的内容写入内存缓冲区,并同步到 AOF 文件always
将 aof_buf 缓冲区中的内容写入内存缓冲区,如果距离上一次同步超过一秒,则同步到 AOF 文件everysec
只写入到内存缓冲区,由 OS 后续决定何时同步到 AOF 文件no

AOF 判断过程如下:

void flushAppendOnlyFile(int force) {
    ssize_t nwritten;
    ...
		// 调用 write 写入文件
    nwritten = write(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
    ...
    // 成功写入后
    server.aof_current_size += nwritten;
    ...
    // 根据 appndfsync 条件判断是否同步到 AOF 文件
    if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
        ...
        // 这里强制执行同步用的是 aof_fsync,是因为 aof_fsync 已经被定义成了 fsync
				// 具体位置在 config.h:https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/src/config.h#L89
        aof_fsync(server.aof_fd);
				...
        // 成功后记录下时间,用于下一次同步条件检查
        server.aof_last_fsync = server.unixtime;
    } else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
                server.unixtime > server.aof_last_fsync)) {
        // 在另一个线程中后台执行
        if (!sync_in_progress) aof_background_fsync(server.aof_fd);
        server.aof_last_fsync = server.unixtime;
    }
}

AOF 文件载入

  1. Redis 创建一个不带网络连接的伪客户端
  2. 从 AOF 文件中依次读出命令并交给伪客户端执行。这个过程和正常的 Redis 客户端从网络中依次读取命令然后执行效果一致

AOF 重写

由于 AOF 文件是依次记录客户端发来的写入命令,在写入较多的情况下,AOF 文件会快速膨胀,因此需要 AOF 重写精简其中的命令。

AOF 重写的过程中并不会读取原有的 AOF 文件,而是直接根据数据库当前的状态生成一份新的 AOF 文件,类似于 SQL 导出数据时直接生成 INSERT 语句。

对于有多个元素的 key,例如大列表、大集合,简单的将所有元素的写入合并到一条语句中可能会形成一条过大的写入语句,在后续执行命令时导致客户端输入缓冲区溢出。因此 Redis 配置了一个 REDIS_AOF_REWRITE_ITEMS_PER_CMD 常量,当一条命令中的元素超过这个数量时,会被拆分成多条语句

AOF 缓冲

AOF 重写过程中,Redis 服务器仍然要接收客户端的写入请求,为了保证数据安全,使用了子进程执行 AOF 重写,此时如果执行写入命令,子进程并不知道父进程所做的修改,AOF 完成之后会出现 AOF 文件中的数据与实际数据库中的数据不一致的情况。因此在 AOF 重写期间,客户端接收到的命令除了写入 AOF 缓冲区,还要写入 AOF 重写缓冲区

AOF 重写完成后,子进程会向父进程发送一个完成信号。父进程收到后将 AOF 重写区的内容追加到新 AOF 文件中,然后将 AOF 改名,覆盖原来的 AOF 文件

RDB

手动执行持久化

Redis 的 RDB 持久化功能通过 SAVEBGSAVE 两个命令可以生成压缩的二进制 RDB 文件,通过这个文件可以还原生成文件时数据库的状态。

其中 SAVE 阻塞主线程,在 RDB 文件生成完之前不能处理任何请求。而BGSAVE 则会 fork 一个子进程,在子进程中创建 RDB 文件,父进程仍然能够处理客户端的命令。但是 BGSAVE 执行过程中,新的 SAVEBGSAVE 命令会被拒绝,因为会产生竞争条件,BGWRITEAOF 命令会被延迟到 BGSAVE 结束之后。作为对比,BGWRITEAOF 执行过程中,BGSAVE 命令会被拒绝,这里拒绝 BGSAVE 是出于性能考虑,两者实际上不存在竞争冲突

在 Redis 6.0 以前,虽然 Redis 处理处理请求是单线程的,但 Redis Server 还有其他线程在后台工作,例如 AOF 每秒刷盘、异步关闭文件描述符这些操作

SAVEBGSAVE 都会调用 rdb.c/rdbSave 执行真正的持久化过程。

Redis 启动时,会根据 /etc/redis/redis.conf 配置文件中的 dirdbfilename 加载 RDB 文件。如果已经开启了 AOF 持久化,Redis 会优先使用 AOF 来恢复数据库,配置文件例如:

# RDB 配置示例
# https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/redis.conf#L125

# 重要参数:
dbfilename dump.rdb
dir /var/lib/redis

载入 RDB 文件时实际工作由 rdb.c/rdbLoad 完成,载入期间主线程处于阻塞状态。

自动执行持久化

Redis 启动式根据用户设定的保存条件开启自动保存。在/etc/redis/redis.conf 配置文件中加上 save <seconds> <changes> 表示在 seconds 秒内对数据库进行了 changes 次修改,BGSAVE 命令就会执行。这个配置会被加载到 struct redisServerstruct saveparam 参数中。saveparam 是一个链表,当配置多个 save 条件时,这个条件都会被加入链表中。

如何判断是否满足自动保存的条件?

struct redisServerlong long dirty 用来保存从上一次 RDB 持久化之后数据库修改的次数,set <key> <value> 会对 dirty 加一,而 sadd <set-name> <value1> <value2> <value3> 会对 dirty 加 3。time_t lastsave 记录了上一次完成 RDB 持久化的时间

Redis 使用 int serverCron 函数执行定时任务,这些任务包括自动保存条件检查、更新时间戳、更新 LRU 时钟等。serverCron 每隔 100 ms 执行一次,其中检查自动保存条件的代码如下:

// https://github.com/redis/redis/blob/48e24d54b736b162617112ce27ec724b9290592e/src/redis.c#L1199

// 开始检查自动保存条件前会先检查是否有正在后台执行的 RDB 和 AOF 进程
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1) {
	// 已有后台的 RDB 或 AOF 进程
} else {
  // 遍历 saveparams 链表中所有的配置条件
	for (j = 0; j < server.saveparamslen; j++) {
    struct saveparam *sp = server.saveparams+j;

    /* 满足自动保存的标准:
    1. 从上次完成 RDB 到现在的数据库修改次数(dirty)已经达到了 save 配置中 changes 的值
    2. 距上一次完成 RDB 的时间(lastsave)已经达到了 save 配置中 seconds 的值
    3. 上一次 RDB 已经成功,或者距上一次尝试 RDB 的时间(lastbgsave_try)已经达到了配置的超时时间(REDIS_BGSAVE_RETRY_DELAY)
		*/
    if (server.dirty >= sp->changes &&
        server.unixtime-server.lastsave > sp->seconds &&
        (server.unixtime-server.lastbgsave_try >
         REDIS_BGSAVE_RETRY_DELAY ||
         server.lastbgsave_status == REDIS_OK))
    {
        redisLog(REDIS_NOTICE,"%d changes in %d seconds. Saving...",
            sp->changes, (int)sp->seconds);
        rdbSaveBackground(server.rdb_filename);
        break;
    }
  }
}

RDB 文件格式(以版本“0006”为例)

RDB 文件主要由五个部分构成:

数据文件中存储了所有的数据。开头的 SELECTDB 常量(值为 376)和紧接着的编号,指示了读取 RDB 文件时,后续加载的数据将会被写入哪个数据库中。

key_values 中保存了所有的键值对,主要包括 key,value 和 value 的类型,对于设置了过期时间的 key,还有 EXPIRETIME_MS 常量(值为 374)和用 unix 时间戳表示的过期时间。其中类型可以是下表中的值,分别对应了 Redis 数据结构的类型:

数据结构类型编码常量
字符串REDIS_RDB_TYPE_STRING,值为 0
列表REDIS_RDB_TYPE_LIST,值为 1
集合REDIS_RDB_TYPE_SET,值为 2
有序集和REDIS_RDB_TYPE_ZSET,值为 3
哈希REDIS_RDB_TYPE_HASH,值为 4
使用压缩列表实现的列表REDIS_RDB_TYPE_LIST_ZIPLIST
使用整数集合实现的集合REDIS_RDB_TYPE_SET_INTSET
使用压缩列表实现的有序集合REDIS_RDB_TYPE_ZSET_ZIPLIST
使用压缩列表实现的哈希REDIS_RDB_TYPE_HASH_ZIPLIST

这些编码常量所对应的值都可以在 rdb.h 中查看

这个类型会影响读取数据时如何解释后面 value 代表的值,而 key 则总是被当作 REDIS_RDB_TYPE_STRING 类型

各类型对应的 value 结构如下:

value 结构备注示例类型
编码,值表示可以用 8 位整数表示的字符串REDIS_RDB_ENC_INT8,123REDIS_RDB_TYPE_STRING
表示字符串REDIS_ENCODING_RAW, 5, hello
元素个数,列表元素其中会记录每个元素的长度3, 5, "hello", 5, "world"REDIS_RDB_TYPE_LIST
元素个数,集合元素其中会记录每个元素的长度3, 5, "hello", 5, "world"REDIS_RDB_TYPE_SET
键值对个数,键值对其中会记录每个键值对 key, value 的长度2, 1, "a", 5, "apple", 1, "b", 6, "banana"REDIS_RDB_TYPE_HASH
元素个数,member 和 score 对其中会记录 member 的长度,member 在 score 前面2, 2, "pi", 4, "3.14", 1, "e", 3, "2.7"REDIS_RDB_TYPE_ZSET
转化成字符串对象的整数集合读取 RDB 时需要将字符串对象转化回整数集合REDIS_RDB_TYPE_SET_INTSET
转化成字符串对象的压缩列表读取时需要转化成列表REDIS_RDB_TYPE_LIST_ZIPLIST
转化成字符串对象的压缩列表读取时需要转化成哈希REDIS_RDB_TYPE_HASH_ZIPLIST
转化成字符串对象的压缩列表读取时需要转化成有序集合REDIS_RDB_TYPE_ZSET_ZIPLIST

如何保证写操作正常执行

利用 COW 机制,fork 出子进程共享主线程的内存数据。在主线程修改数据时把这块数据复制一份,此时子进程将副本写入 rdb,主线程仍然修改原来的数据

频繁执行全量快照的问题

  1. 全量数据写入磁盘,磁盘压力大。快照太频繁,前一个任务还未执行完,快照任务之间竞争磁盘带宽,恶性循环
  2. fork 操作本身阻塞主线程,主线程内存越大,阻塞时间越长,因为要拷贝内存页表

**解决方法:**全量快照后只做增量快照,但是需要记住修改的数据,下次全量快照时再写入,但这需要在内存中记录修改的数据。因此 Redis 4.0 提出了混合使用 AOF 和全量快照,用 aof-use-rdb-preamble yes 设置。这样,两次全量快照间的修改会记录到 AOF 文件

写多读少的场景下,使用 RDB 备份的风险

  1. 内存资源风险:Redis fork子进程做RDB持久化,如果修改命令很多,COW 机制需要重新分配大量内存副本,如果此时父进程又有大量新 key 写入,很快机器内存就会被吃光,如果机器开启了 Swap 机制,那么 Redis 会有一部分数据被换到磁盘上,当Redis访问这部分在磁盘上的数据时性能很差。如果机器没有开启Swap,会直接触发OOM,父子进程可能会被系统 kill。
  2. CPU资源风险:虽然子进程在做RDB持久化,但生成RDB快照过程会消耗大量的CPU资源。可能会与后台进程产生 CPU 竞争,导致父进程处理请求延迟增大,子进程生成RDB快照的时间也会变长,Redis Server 性能下降。
  3. 如果 Redis 进程绑定了CPU,那么子进程会继承父进程的CPU亲和性属性,子进程必然会与父进程争夺同一个CPU资源,整个Redis Server 的性能爱将,所以如果 Redis 需要开启定时 RDB 和 AOF 重写,进程一定不要绑定CPU。

Ref

  1. Redis-RDB-Dump-File-Format
  2. Redis 设计与实现