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

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

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

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

AOF

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

AOF 配置

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

AOF 配置示例

https://github.com/redis/redi...

重要参数:

appendonly yes # 是否开启 AOF,如果开启了 AOF,后续恢复数据库时会优先使用 AOF,跳过 RDB
appendfsync everysec # 持久化判断规则
appendfilename appendonly.aof # 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;
}
}

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

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

// 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 备份的风险

Ref


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

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

频繁执行全量快照的问题

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

如何保证写操作正常执行

value 结构

备注

示例

类型

编码,值

表示可以用 8 位整数表示的字符串

REDIS_RDB_ENC_INT8,123

REDIS_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

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

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

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

数据结构类型

编码常量

字符串

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

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

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

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

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

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

[](https://github.com/redis/redi...struct redisServer long long dirty 用来保存从上一次 RDB 持久化之后数据库修改的次数,set <key> <value> 会对 dirty 加一,而 sadd <set-name> <value1> <value2> <value3> 会对 dirty 加 3。time_t lastsave 记录了上一次完成 RDB 持久化的时间

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

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

自动执行持久化

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

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

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

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

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

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

手动执行持久化

RDB

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

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

AOF 缓冲

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

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

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

AOF 重写

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

AOF 文件载入

AOF 判断过程如下:

flushAppendOnlyFile 行为

appndfsync 选项

总是将 aof_buf 缓冲区中的内容写入内存缓冲区,并同步到 AOF 文件

always

将 aof_buf 缓冲区中的内容写入内存缓冲区,如果距离上一次同步超过一秒,则同步到 AOF 文件

everysec

只写入到内存缓冲区,由 OS 后续决定何时同步到 AOF 文件

no

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

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

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

AOF 写入条件判断规则

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

AOF 持久化执行步骤

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

风险:

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

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

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


KasheemLew
72 声望5 粉丝

Pythoner || Gopher