Redis 的多数据库模式

Redis Server

Redis 是一个将数据保存在内存中的 key-value 数据库。很多使用 Redis 的人或许不知道,Redis 在单机数据库的实现上将总容量切分成多个「数据库」,就好像在规定的内存容量中建立多个「Storage Bucket」。它给每一个 Bucket 都标号,默认情况下有16个 Bucket。不同 Bucket 是相互隔离的,在一个 Bucket 内的操作不会影响另一个。

一个单机的 Redis 数据库,即一个 Redis Server 的实例的数据结构如下:

1
2
3
4
struct redisServer {
    redisDb *db;
    int dbnum;                      /* Total number of configured DBs */
}

其中 db 是一个指针类型的成员,它会指向一个存有 redisDb 类型对象的数组。根据前面描述的「单机数据库分成多个 Bucket 来存储数据」的事实,我们猜测这个 redisDb 对象就相当于每一个 Bucket。至于要构造多少个 Bucket,是根据 dbnum 这个成员的值来确定的。

Redis Client

既然 Redis Server 是依靠在内存中构造多个 Storage Bucket 来实现一个数据库实例的,相应的 Redis Client 在访问 Redis Server 的时候也可以选择要访问哪一个 Storage Bucket。这也正是 Redis 中 Select 命令做的事情。

1
2
3
struct redisClient {
    redisDb *db;
}

在 Redis Client 的数据结构中,我们也发现了同样的类型为 redisDb 的成员。它指向的是该 Client 实例访问的 Bucket。通过 Select 命令,我们可以切换这个成员指向的 Bucket。默认情况下,客户端访问的是编号为 0 的 Bucket。

redisDb

既然 Redis Server 是分为多个 redisDb 来实现的,且 Redis Client 实际操作的也是某一个具体的 redisDb。那么看起来这个类型为 redisDb 的成员应该就是实际存储数据的地方。

1
2
3
4
5
typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    int id;                     /* Database ID */
	  ...
} redisDb;

除了 id 成员标识了它是第几个 Bucket 之外,dict 成员看起来是引导了一个字典对象,存放 Client 写入的 key-value 数据。

我们通过客户端对 Redis Server 执行的写入,更新,删除,其实都是对目标 redisDb 的 dict 成员进行操作。你可以想象一下,在一个用 golang 语言编写 demo 中,你构造了一个 Map。你是怎么对这个 Map 进行增删改查的,Redis Server 在内部就是怎样对这个 redisDb .dict 操作的。唯一不同的地方在于,Redis Server 构造的 redisDb 的 dict 成员,其 key 必须是字符串对象,value 需要是 Redis 承认的合法的数据类型的对象。

除了可以正常的读写 redisDb 对象,在这些操作执行期间,Redis Server 还会做一些标记和维护性的工作:

  1. 若成功读取某个 key,那么给这个 key 的命中次数+1。否则对这个 key 的 miss 次数+1
  2. 更新这个 key 最新被访问的时间,以便用于之后的淘汰策略(如 LRU 等)

Key 的生命周期的管理

默认情况下, 如果我们通过 Redis Client 向 Server 写入一个 Key-Value,这个 Key 及其对应的 Value 是不会过期的,因为没有为其设置过期时间。通过ttl keyname 即可查看这个 Key 有效的剩余时间。由于大多数用户在使用 Redis 的时候都是将它作为缓存,所以一般来说,我们都会给写入 Redis 的 Key 设置一个过期时间,并将它保存起来。如果这个 Key 已经达到了它的过期时间,那么Redis 就会将它删除。

上面叙述的过程可以认为是 Redis 自身对 Key 的生命周期的管理,它基本可以分为以下几个部分:

  • 设置 Key 的过期时间
  • 保存 Key 的过期时间
  • 判定键已经过期
  • 自动删除过期的 Key

设置 Key 的过期时间

设置某个 Key 的过期时间,可以通过 PEXPIREAT 命令来实现,该命令接受两个参数,第一个为 keyName,第二个为过期的时间点,用 Unix 毫秒时间戳来表示。

保存 Key 的过期时间

1
2
3
4
5
6
typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
	  dict *expires;              /* Timeout of keys with a timeout set */
    int id;                     /* Database ID */
    ....
} redisDb;

在标识每一个数据库(即每一个 Bucket) 的 redisDb 类型的对象中,有一个名为 expires 的对象,它也是 dict 类型的。其中 Key 为 数据库中 key 的名称,value 则为该 Key 的过期时间,即一个毫秒的 Unix 时间戳。

判定键的过期

判定一个 Key 是否已经过期,是计算了 expires 内该 Key 的过期时间和当前时间的差值。这也就是 TTL 和 PTTL 命令返回的结果。两个命令唯一的区别就是输出前做了单位上面的转化。

自动删除过期的 Key

定时删除

定时删除的实现方式,是在对一个 Key 设置过期时间的同时为这个 Key 创建一个定时器。等到定时器计时结束的时候删除这个 Key。定时器的好处就是删除及时,不会浪费内存空间。缺点也很明显:创建定时器,就需要额外的消耗。并且,当有大量写入和读取请求的时候,Redis 到底是优先处理外来的任务呢,还是先删除这些过期的 Key 呢?

惰性删除

惰性删除的原理也很简单:虽然我们在 Key 被写入到 Redis 的时候为其设置了超时时间,但是我们并不去主动检查它。而是等它要被读取的时候,计算一下当前时间和其设置的过期时间的差值,来决定是否要删除这个 Key。

惰性删除的好处是,不会让删除操作影响用户正常的读写操作,而且不用忍受创建定时器带来的额外的消耗。 惰性删除的坏处是,如果一直没有对某个过期的 Key 执行读取操作,那么这个过期的 Key 就会一直占用内存的空间。

定期删除

既然定时删除的缺点是会占用 CPU 的时间妨碍正常写入和读取操作的执行,而惰性删除操作是会引起「内存泄漏」。那么,一个折中的方案就是,定期主动的去删除过期的 Key。这样一来,既避免了长期不清理导致的内存浪费,也通过减少删除操作的执行频率和执行时间,降低了对 Redis 正常操作的影响。

Redis 在实现的时候,实际上是配合使用定期删除和惰性删除两种策略。前者可以认为是一种「缓慢版」的定时删除策略。

删除过期 Key 的策略的实现

惰性删除

由于我们已经在每个 redisDb 的对象内存储了当前数据库的 Key 过期时间的 dict。所以,每当我们在对某个 Key 执行一个命令的时候,都会先通过一个函数来检查一下该 Key 的过期情况,如果过期了或者 Key 不存在,就按照不存在的情况统一处理。否则正常执行相应的操作。因为这些数据都是存在 Map 数据结构内的,所以读取操作相对来说是非常迅速的,不会对 Redis 造成什么性能上的压力。

定期删除

定期删除过期 Key 的逻辑,放在 Redis 一个名为 serverCron 的函数中执行。这个函数中放的都是 Redis 想周期执行的逻辑。定期删除的策略如下:

  1. 为本次删除操作先设定一个计时器,a 秒
  2. 遍历所有的数据库,即我们前面说的 redisServer.redisDb 指向的数组内保存的所有的 redisDb 的实例
  3. 对每个数据库检查一定数量的 Key,如每个数据库挑20个。每次随机挑选一个 Key 检查它的过期时间,如果过期就直接删除

默认情况下,Redis 会在 a 秒内不断重复第三步。要么等到计时器终止退出此次删除操作,要么对每个数据库都检查了一遍,正常退出此次操作。如果是在计时器中止的情况下退出的,Redis 还会记录下上次处理到了第几个数据库。下次定期删除操作再执行的时候,会接着上次的现场继续处理。

过期的 Key 对于 RDB 和 AOF 以及 Replication 功能的影响

RDB

主动备份

当我们通过 Redis Client 执行 Save 或者 BGSave 命令的时候,Redis Server 会根据当前内存的内容,生成一个 Snapshot,它就是 RDB 文件。但是在生成这个 RDB 文件内容的时候,Redis 会对写入的 Key 进行检查,如果某个 Key 已经过期的话,那么它不会被写入到 RDB 文件中。

RDB 文件载入

若一个 Redis Server 开启了 RDB 的备份功能,那么在 Redis Server 启动的时候,它会在指定的目录下寻找 RDB 文件,并将文件内容载入到内存中。但是,此时 RDB 文件内部的某一部分 Key 可能已经过期了。在对这些过期的 Key 的处理上,主从服务器会采取不同的措施:

Master

在读取 RDB 文件的时候,它会主动检测是否有过期的 Key。如果有,那么不会被载入到内存中。

Slave

在读取 RDB 文件的时候,它不会检测 Key 是否过期,而是都加载到内存中。Slave 服务器中的过期的 Key 会通过 Master 和 Slave 之间的数据同步操作被覆盖掉。从而达到清理的效果。

AOF

AOF 文件内记录的是我们对数据库的写入命令。当我们上面提到的「惰性删除」和「定期删除」执行的时候,Redis 会向 AOF 文件内对过期的 Key 写入一个 DEL 操作。而这个 DEL 操作也会在 AOF 文件发生重写的时候,与同一个 Key 的写入操作进行抵消。最终 AOF 文件内可能都找不到和这个过期的 Key 有关的写入记录。

即使没有执行「清理」操作,AOF 文件在重写的时候也会检查每一个 Key 的过期时间,如果发现有过期的 Key 的话,那么这个 Key 的写入操作不会被记录到新的 AOF 文件中。

Replication

Replication 操作发生在 Master 节点和 Slave 节点之间。无论是以何种方式部署 Redis 集群,写入操作通常来讲都是对 Master 节点执行的,而读取操作,可以在 Master 节点执行,也可以通过 Slave 节点执行。但是由于主从节点之间是依靠 Replication 操作做数据同步的,所以,某一时间段内,主从数据可能会有一些不一致。比如,我们在 Master 节点上触发了惰性删除操作,清理了一个过期的 Key。此时 Master 节点会向其 Slave 节点发送 DEL 命令,通知 Slave 节点删除这个 Key。但是,在 Slave 节点上我们是无法触发惰性删除操作的,也不会执行定期清理的操作。Redis 为了保持数据的一致性,所有对数据的修改操作,都必须由 Master 来完成。修改的部分,要依靠其他机制传递给 Slave 节点。