《Redis 设计与实现》观后总结

《Redis 设计与实现》更多的是讲解 Redis 的底层,对于实践讲解的较少,适合想深入了解 Redis 的读者。这里总结一下要点,方便随时温习。

数据库部分

服务器中的数据库

Redis 服务器的状态通过 redisServer 结构体来维护,其中有一个 db 数组用来维护所有的数据库,每个数据库的状态通过 redisDb 来维护。

1
2
3
4
5
6
7
8
9
10
11
struct redisServer {
// 数组,保存所有的数据库
redisDb *db;
// 数据库的个数
int dbnum;
// 距离上一次执行 save 之后数据库修改的次数
long long dirty;
// 最后一次执行 save 成功时的时间戳
time_t lastsave;
...
};

数据库切换

数据库的切换通过 select 命令进行。在服务器内部,客户端的状态通过 client 结构体来维护,db 属性是一个指向 redisDb 结构的指针,指向客户端当前使用的数据库。

1
2
3
4
typedef struct client {
// 指向当前选择的数据库
redisDb *db;
} client;

数据库键空间

Redis 服务器中的每个数据库都是通过 redisDb 结构体来维护的,其中 dict 属性保存的是数据库的所有键值对,这个字典被称为键空间。

1
2
3
4
5
6
7
typedef struct redisDb {
// 键空间
dict *dict;
// 过期字典
dict *expires;
...
} redisDb;

键空间的键都是字符串对象,值可以是字符串对象、列表对象、集合对象、哈希表对象等任意一种 Redis 对象。

键空间维护

在使用 Redis 命令对数据库进行读写时,服务器不仅会对键空间进行读写,还会执行一些额外的操作。

  • 读取键后(读和写都要先进行读),服务器会根据键是否存在来更新服务器键空间的命中(hit)和不命中(miss)次数,可以通过 INFO stats 命令查看。
  • 读取键后,服务器会更新键的 LRU(最后一次使用)时间,使用 OBJECT IDLETIME 命令查看。
  • 如果在读取键时,发现键已经过期,则会删除过期键。
  • 如果有客户端使用 WATCH 命令监视了某个键,那么服务器会在被监视键被修改时标记这个键为 dirty,从而让事务注意。
  • 服务器每次修改键都会对属性 dirty 增加 1,这个计数器用来触发自动执行 save 持久化。
  • 如果服务器开启了数据库通知,那么在对键进行修改后,服务器会发送相应的数据库通知。

过期键

使用 EXPIREPEXPIREEXPIREATPEXPIREAT 等命令可以为键设置过期时间,当过期时间到达时服务器会自动删除该键。过期时间保存在 redisDb 的过期字典里,这个字典中的键与键空间中的键都指向同一个键对象,值保存的是该键的过期时间(一个 long long 类型的整数,毫秒精度的 UNIX 时间戳)。

使用 PERSIST 命令可以移除一个键的过期时间,使用 TTLPTTL 命令可以返回一个键的剩余生存时间。

过期键的删除策略

Redis 采用惰性删除和定期删除两种策略,惰性删除对 CPU 时间友好,但会浪费大量内存;定期删除需要占用大量的 CPU 时间,但能够节省内存。

惰性删除由 db.c/expireIfNeeded 函数实现,在所有的读写数据库命令执行前,都会调用该函数检查,如果键已经过期,则直接删除(根据 lazyfree_lazy_expire 的参数不同执行不同的删除函数,默认采用同步删除,会阻塞)。

定期删除由 activeExpireCycle 函数实现,每当 Redis 服务器的周期性操作 serverCron 函数执行时,该函数就会被调用。它会在规定的时间内,分多次遍历服务器中的各个数据库,从 expire 字典中随机检查部分键的过期时间,并删除其中的过期键(默认采用同步删除,会阻塞)。该函数使用一个全局变量 current_db 记录每次该函数的检查进度,并在下一次调用该函数时,接着上次的进度继续进行,当所有的数据库都被检查过之后,该全局变量会重置为 0,然后继续下一轮的检查。

RDB、AOF 和复制对过期键的处理

  • 在生成 RDB 文件时,已经过期的键不会被持久化。在 RDB 文件加载时,如果 Redis 服务器以主服务器模式启动,则过期键不会被载入;如果 Redis 服务器以从服务器模式启动,则所有的键都会被载入。
  • 当 Redis 服务器以 AOF 持久化模式运行时,如果数据库中的某个键已经过期,但是还没有执行删除操作,此时 AOF 文件不会有任何变化,只有当该键执行惰性删除或者定期删除后,程序才会向 AOF 文件中追加一条 DEL 命令来显式的记录该键已被删除。
  • 和生成 RDB 文件类似,在执行 AOF 重写的过程中,过期键不会被保存到重写后的 AOF 文件中。
  • 当 Redis 服务器运行在复制模式下时,从服务器过期键的删除由主服务器控制。主服务器在删除一个过期键时,会向从服务器发送一个删除命令,通知从服务器删除该键。从服务器在收到主服务器删除该键的命令之前,所有对该键的读写操作都会像该键没有过期一样被执行,通过这种方式来保证主从的数据一致性。

数据库通知

数据库通知功能是通过 Redis 的发布订阅功能实现的,通知具体分为键空间通知(key-space notification)和键事件通知(key-event notification),键空间通知关注的是某个键执行了什么命令,键事件通知关注的是某个命令被什么键执行了。因为通知需要耗费一定的 CPU 时间,所以默认情况下该功能是关闭的,可以通过修改配置文件或者通过 CONFIG SET 命令设置 notify-keyspace-events 选项来启用该功能,具体的设置参数可以参考 Redis 配置文件。

这里举几个简单的例子:

1
2
3
4
:: language 键是列表键,所以要加参数 l
CONFIG SET notify-keyspace-events Kl
:: 订阅数据库 0 上的 language 键执行的所有命令
subscribe __keyspace@0__:language
1
2
3
CONFIG SET notify-keyspace-events Elg
:: 订阅数据库 0 上的所有执行 del 命令的所有键
subscribe __keyevent@0__:del

RDB 持久化

RDB 持久化是一种镜像全量持久化,持久化的是当前内存中所有数据的快照

RDB 文件的创建与载入

通过 SAVEBGSAVE 两个命令来生成 RDB 文件,其中 SAVE 命令会阻塞服务器进程,BGSAVE 命令会 fork 出一个子进程来生成 RDB 文件,父进程继续处理客户端命令而不会阻塞。

Redis 服务器会在启动时检测 RDB 文件是否存在,如果存在则会自动载入。由于 AOF 文件的更新频率一般比 RDB 文件的更新频率高,所以如果服务器开启了 AOF 持久化,服务器会优先使用 AOF 文件来还原数据库。

BGSAVE 命令执行时服务器的状态

BGSAVE 命令执行期间,客户端的 SAVEBGSAVE 命令会被拒绝,目的是防止同时调用 rdbSave 函数而产生竞态条件。虽然 BGREWRITEAOF 命令和 BGSAVE 命令没有冲突的地方,但是 Redis 不会同时执行它们,目的是防止出现多个子进程同时执行大量的磁盘写入操作。

自动间隔保存

因为 BGSAVE 不会阻塞主进程,所以可以通过配置,让 Redis 服务器每隔一段时间自动执行 BGSAVE 命令,该功能是通过 dirty 计数器和 lastsave 属性来实现的。Redis 默认的配置如下:

1
2
3
4
5
6
:: 服务器在 900 秒之内,对数据库进行了至少 1 次修改
save 900 1
:: 服务器在 300 秒之内,对数据库进行了至少 10 次修改
save 300 10
:: 服务器在 60 秒之内,对数据库进行了至少 10000 次修改
save 60 10000

AOF 持久化

AOF 持久化是一种增量持久化,通过向文件中追加服务器所执行的写命令来记录数据库的状态。

AOF 持久化的实现可以分为命令追加(append)、文件写入和文件同步(sync)三个步骤。

AOF 持久化之命令追加

命令追加是在服务器执行完一个写命令后,以协议方式将执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾。

1
2
3
4
5
struct redisServer {
// 缓冲区
sds aof_buf;
...
};

AOF 持久化之文件写入和同步

Redis 的服务器进程(主进程)就是一个事件循环(loop),这个循环中的文件事件负责接收客户端的命令,以及向客户端发送回复,而时间事件负责执行像 serverCron 函数这样需要定时运行的函数。Redis 在处理文件事件时可能会执行写命令,使得一些内容被追加到 aof_buf 缓冲区中,所以服务器每次结束一个事件循环之前,都会调用 flushAppendOnlyFile 函数来决定是否要将缓冲区中的内容写入并同步到 AOF 文件中,该函数的同步行为与服务器的配置项 appendfsync 的值有关。

appendfsync flushAppendOnlyFile 函数的行为
always 将 aof_buf 的所有内容写入并同步到 AOF 文件。
everysec 将 aof_buf 的所有内容写入到 AOF 文件,如果上次同步 AOF 的时间距离现在超过一秒,那么会再次进行同步。同步操作由一个线程专门负责。
no 将 aof_buf 的所有内容写入到 AOF 文件中,至于何时进行同步由操作系统决定。

Redis 默认 appendfsync 的值为 everysec。与 AOF 不同的是,RDB 采用的是每次写入都会强制同步。

在现代操作系统中,用户调用 write 函数将数据写入到文件时,操作系统通常会将写入的数据暂时保存在一个内存缓冲区中,等到缓冲区被填满或者超过了指定的时限时才会将缓冲区里的数据真正写入到磁盘上。这种做法虽然提高了写入的效率,但也带来了安全问题,如果计算机意外停机,那么缓冲区里的写入数据将会丢失。为此,操作系统提供了 fsyncfdatasync 两个同步函数,它们可以强制让操作系统立即将缓冲区中的数据写入到磁盘上。

AOF 文件的载入

由于 AOF 文件包含重建数据库状态需要的全部写命令,因此服务器只要读取并重新执行一遍 AOF 文件中的命令即可,由于 Redis 的命令只能在客户端上下文中执行,所以 Redis 会创建一个不带网络连接的伪客户端(fake client)来执行命令。

AOF 重写

AOF 文件会随着 Redis 运行时间的流逝而逐渐增大,为了解决 AOF 文件体积膨胀的问题,Redis 提供了 AOF 文件重写的功能。通过该功能,Redis 服务器可以创建一个新的 AOF 文件替换现有的 AOF 文件,新文件保存的数据库状态与现有的一致,但是不会包含冗余命令。

AOF 重写功能并不会去读取现有的 AOF 文件,其核心思想是从数据库中读取键的状态,并将键的值使用一条写入命令来记录。

为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合等可能会有多个元素的键时,会先检查键所包含的元素数量,如果数量超过 AOF_REWRITE_ITEMS_PER_CMD 常量的值(当前版本中该值为 64),那么重写程序会使用多条命令来记录键的值。

AOF 后台重写

AOF 后台重写可以通过 BGREWRITEAOF 命令使用。由于 AOF 重写会进行大量的写入操作并长期阻塞主进程,所以 Redis 决定将重写程序放到子进程中执行,这样做可以达到两个目的:

  1. 子进程在 AOF 重写期间,服务器父进程仍然可以继续处理客户端命令。
  2. 子进程带有父进程的数据副本,使用子进程而不是线程可以避免使用锁,保证数据安全。

使用子进程需要解决一个问题,如果子进程在进行 AOF 重写期间,Redis 主进程执行的新命令对现有的数据库状态进行了修改,这会导致当前数据库的状态与重写后 AOF 文件中保存的状态不一致。为了解决这个问题,Redis 服务器又维护了一个 AOF 重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当 Redis 服务器执行完一个写命令后,它会同时将这个写命令追加到 AOF 缓冲区和 AOF 重写缓冲区。当子进程完成 AOF 重写工作后,它会向父进程发送一个信号,父进程接收到该信号后会调用一个信号处理函数,将 AOF 重写缓冲区的所有内容写入到新 AOF 文件中,然后对新的 AOF 文件改名,原子地(atomic)覆盖现有的 AOF 文件。这样在整个 AOF 重写过程中,只有信号处理函数执行时会对服务器主进程造成阻塞,把 AOF 重写对服务器性能的影响降到了最小。