更新:虚拟内存自 Redis 2.6 起已弃用,因此本文档仅用于历史参考。

*虚拟内存技术规范

本文档详细介绍了 Redis 虚拟内存子系统的内部实现。目标受众不是最终用户,而是希望理解或修改虚拟内存实现的程序员。

*键与值:什么被交换出去了?

VM 子系统的目标是通过将 Redis 对象从内存传输到磁盘来释放内存。这是一个非常通用的命令,但具体来说,Redis 只交换与 关联的对象。为了更好地理解这个概念,我们将使用 DEBUG 命令展示一个持有值的键在 Redis 内部看来是什么样子:

redis> set foo bar
OK
redis> debug object foo
Key at:0x100101d00 refcount:1, value at:0x100101ce0 refcount:1 encoding:raw serializedlength:4

从上面的输出可以看出,Redis 顶层哈希表将 Redis 对象(键)映射到其他 Redis 对象(值)。虚拟内存只能将 交换到磁盘,与 关联的对象始终保留在内存中:这种权衡保证了非常好的查找性能,因为 Redis VM 的主要设计目标之一是当频繁使用的数据集部分适合 RAM 时,性能与禁用 VM 的 Redis 相似。

*交换出去的值在内部看起来如何

当一个对象被交换出去时,哈希表条目中会发生以下情况:

  • 键继续持有一个代表键的 Redis 对象。
  • 值被设置为 NULL

所以你可能会想,我们将给定值(与给定键关联)被交换出去的信息存储在哪里。就在键对象中!

这就是 Redis 对象结构 robj 的样子:

/* 实际的 Redis 对象 */
typedef struct redisObject {
    void *ptr;
    unsigned char type;
    unsigned char encoding;
    unsigned char storage;  /* 如果此对象是键,值在哪里?
                             * REDIS_VM_MEMORY, REDIS_VM_SWAPPED, ... */
    unsigned char vtype; /* 如果此对象是键,且值被交换出去,
                          * 这是被交换出去对象的类型。 */
    int refcount;
    /* VM 字段,这些只在 VM 激活时分配,否则
     * 对象分配函数将只分配
     * sizeof(redisObject) 减去 sizeof(redisObjectVM),因此
     * 在不激活 VM 的情况下使用 Redis 不会有任何开销。 */
    struct redisObjectVM vm;
} robj;

如你所见,有几个关于 VM 的字段。最重要的是 storage,它可以是以下值之一:

  • REDIS_VM_MEMORY:关联的值在内存中。
  • REDIS_VM_SWAPPED:关联的值被交换出去,哈希表的值条目只是设置为 NULL。
  • REDIS_VM_LOADING:值在磁盘上被交换出去,条目为 NULL,但有一个作业将对象从交换区加载到内存中(此字段仅在线程化 VM 激活时使用)。
  • REDIS_VM_SWAPPING:值在内存中,条目指向实际的 Redis 对象,但有一个 I/O 作业将此值传输到交换文件。

如果一个对象在磁盘上被交换出去(REDIS_VM_SWAPPEDREDIS_VM_LOADING),我们如何知道它存储在哪里、它是什么类型等等?这很简单:vtype 字段被设置为被交换出去的 Redis 对象的原始类型,而 vm 字段(是一个 redisObjectVM 结构)保存有关对象位置的信息。这是这个附加结构的定义:

/* VM 对象结构 */
struct redisObjectVM {
    off_t page;         /* 对象存储在磁盘上的页 */
    off_t usedpages;    /* 磁盘上使用的页数 */
    time_t atime;       /* 最后访问时间 */
} vm;

如你所见,该结构包含对象在交换文件中所在的页、使用的页数以及对象的最后访问时间(这对选择适合交换的候选对象的算法非常有用,因为我们希望将很少访问的对象传输到磁盘上)。

如你所见,虽然所有其他字段都使用了旧 Redis 对象结构中未使用的字节(由于自然内存对齐问题我们有一些空闲位),但 vm 字段是新的,确实使用了额外的内存。即使 VM 被禁用,我们也应该支付这样的内存成本吗?不!这是创建新 Redis 对象的代码:

... 一些代码 ...
        if (server.vm_enabled) {
            pthread_mutex_unlock(&server.obj_freelist_mutex);
            o = zmalloc(sizeof(*o));
        } else {
            o = zmalloc(sizeof(*o)-sizeof(struct redisObjectVM));
        }
... 一些代码 ...

如你所见,如果 VM 系统未启用,我们只分配 sizeof(*o)-sizeof(struct redisObjectVM) 的内存。鉴于 vm 字段是对象结构中的最后一个字段,并且如果 VM 被禁用则永远不会访问这些字段,我们是安全的,不启用 VM 的 Redis 不会支付内存开销。

*交换文件

为了理解 VM 子系统如何工作,下一步是理解对象如何存储在交换文件内部。好消息是这不是某种特殊格式,我们只是使用与存储 .rdb 文件中对象相同的格式,这些是 Redis 使用 SAVE 命令生成的常用转储文件。

交换文件由给定数量的页组成,每页大小是给定的字节数。这些参数可以在 redis.conf 中更改,因为不同的 Redis 实例可能更适合不同的值:这取决于你实际存储在其中的数据。以下是默认值:

vm-page-size 32
vm-pages 134217728

Redis 在内存中获取一个"位图"(一个连续设置为零或一的位数组),每一位代表磁盘上交换文件的一页:如果给定位设置为 1,它表示一个已经被使用的页(某个 Redis 对象存储在那里),而如果相应位为零,则该页是空闲的。

在内存中获取这个位图(我们称之为页表)在性能方面是一个巨大的胜利,使用的内存很小:我们只需要为磁盘上的每一页使用 1 位。例如,在下面的示例中,134217728 个 32 字节的页(4GB 交换文件)仅为页表使用了 16 MB 的 RAM。

*将对象从内存传输到交换区

为了将对象从内存传输到磁盘,我们需要执行以下步骤(假设非线程化 VM,只是一个简单的阻塞方法):

  • 找出在交换文件中存储此对象需要多少页。这只需调用函数 rdbSavedObjectPages 即可完成,它返回对象在磁盘上使用的页数。注意,这个函数不会复制 .rdb 保存代码只是为了了解对象保存到磁盘后长度会是多少,我们使用的技巧是打开 /dev/null 并将对象写入其中,最后调用 ftello 来检查所需的字节数。我们基本上所做的就是在虚拟的非常快的文件(即 /dev/null)上保存对象。
  • 现在我们知道了交换文件中需要多少页,我们需要在交换文件内部找到这个数量的连续空闲页。这个任务由 vmFindContiguousPages 函数完成。如你所料,如果交换区已满,或者碎片化严重以至于我们无法轻松找到所需数量的连续空闲页,这个函数可能会失败。当这种情况发生时,我们只是中止该对象的交换,它将继续留在内存中。
  • 最后我们可以在指定位置将对象写入磁盘,只需调用函数 vmWriteObjectOnSwap

如你所料,一旦对象被正确写入交换文件,它就从内存中释放了,关联键中的 storage 字段被设置为 REDIS_VM_SWAPPED,使用的页在位图中被标记为已使用。

*将对象加载回内存

从交换区将对象加载到内存更简单,因为我们已经知道对象在哪里以及它使用了多少页。我们还知道对象的类型(加载函数需要这些信息,因为磁盘上没有关于对象类型的头部或任何其他信息),但这已经存储在上面已经看到的关联键的 vtype 字段中。

调用函数 vmLoadObject 并传递与我们想要加载回内存的值对象关联的键对象就足够了。该函数还将负责修复键的存储类型(将是 REDIS_VM_MEMORY),在位图中将页标记为空闲等。

该函数的返回值是加载的 Redis 对象本身,我们将不得不再次将其设置为主哈希表中的值(代替当值最初被交换出去时我们放在对象指针位置的 NULL 值)。

*阻塞式 VM 如何工作

现在我们已经掌握了所有构建模块来描述阻塞式 VM 的工作原理。首先,一个重要的配置细节。为了在 Redis 中启用阻塞式 VM,server.vm_max_threads 必须设置为零。 我们稍后会看到在线程化 VM 中如何使用这个最大线程数信息,现在只需要知道当设置为零时 Redis 会恢复到完全阻塞式 VM。

我们还需要介绍另一个重要的 VM 参数,即 server.vm_max_memory。这个参数非常重要,因为它用于触发交换:Redis 只有在使用的内存超过最大内存设置时才会尝试交换对象,否则不需要交换,因为我们符合用户请求的内存使用量。

*阻塞式 VM 交换

对象从内存到磁盘的交换发生在 cron 函数中。这个函数过去每秒调用一次,而在最近的 Redis git 版本中每 100 毫秒调用一次(即每秒 10 次)。 如果此函数检测到内存不足,即使用的内存大于 vm-max-memory 设置,它会开始以循环方式将对象从内存传输到磁盘,调用函数 vmSwapOneObect。该函数只接受一个参数,如果为 0 则以阻塞方式交换对象,否则如果为 1,则使用 I/O 线程。在阻塞场景下,我们只是用零作为参数调用它。

vmSwapOneObject 执行以下步骤:

  • 检查键空间以找到适合交换的候选对象(我们稍后会看到什么是适合交换的候选对象)。
  • 以阻塞方式将关联值传输到磁盘。
  • 键的 storage 字段被设置为 REDIS_VM_SWAPPED,而对象的 vm 字段被设置为正确的值(对象被交换的页索引,以及用于交换它的页数)。
  • 最后值对象被释放,哈希表的值条目被设置为 NULL。

该函数被反复调用,直到发生以下情况之一:由于交换文件已满或几乎所有对象都已传输到磁盘,无法再交换更多对象,或者简单地说内存使用量已经在 vm-max-memory 参数之下。

*内存不足时交换什么值?

理解什么是适合交换的候选对象并不太困难。随机采样几个对象,并为每个对象计算其 可交换性

swappability = age*log(size_in_memory)

age 是键未被请求的秒数,而 sizeinmemory 是对象在内存中使用内存量(以字节为单位)的快速估算。因此我们尝试交换很少访问的对象,并且我们尝试优先交换较大的对象而不是较小的对象,但后者是一个较不重要的因素(因为使用的对数函数)。这是因为我们不希望较大的对象被过于频繁地交换出去和交换进来,因为对象越大,传输它所需的 I/O 和 CPU 就越多。

*阻塞式 VM 加载

如果请求对与被交换出去对象关联的键进行操作会发生什么?例如 Redis 可能碰巧处理以下命令:

GET foo

如果 foo 键的值对象被交换出去,我们需要在处理操作之前将其加载回内存。在 Redis 中,键查找过程集中在 lookupKeyReadlookupKeyWrite 函数中,这两个函数用于实现所有访问键空间的 Redis 命令,所以我们在代码中只有一个点来处理从交换文件将键加载到内存。

所以会发生以下情况:

  • 用户调用某个以被交换键为参数的命令
  • 命令实现调用查找函数
  • 查找函数在顶层哈希表中搜索键。如果与请求键关联的值被交换出去(我们可以通过检查键对象的 storage 字段看到这一点),我们以阻塞方式将其加载回内存,然后返回给用户。

这相当简单,但使用线程时事情会变得更有趣。从阻塞式 VM 的角度来看,唯一真正的问题是使用另一个进程保存数据集,即处理 BGSAVEBGREWRITEAOF 命令。

*VM 激活时的后台保存

Redis 默认的磁盘持久化方式是使用子进程创建 .rdb 文件。Redis 调用 fork() 系统调用来创建子进程,该子进程拥有内存中数据集的确切副本,因为 fork 复制了整个程序内存空间(实际上由于一种称为写时复制的技术,内存页在父进程和子进程之间共享,所以 fork() 调用不需要太多内存)。

在子进程中,我们在时间上的某个点拥有数据集的副本。其他客户端发出的命令将由父进程处理,不会修改子进程的数据。

子进程只是将整个数据集存储到 dump.rdb 文件中,最后退出。但是当 VM 激活时会发生什么?值可以被交换出去,所以我们并非所有数据都在内存中,我们需要访问交换文件来检索被交换的值。当子进程正在保存时,交换文件在父进程和子进程之间共享,因为:

  • 父进程需要访问交换文件,以便在对被交换出去的值执行操作时将其加载回内存。
  • 子进程需要访问交换文件,以便在将数据集保存到磁盘时检索完整数据集。

为了避免两个进程访问同一交换文件时出现问题,我们做了一件简单的事情,即不允许父进程在后台保存进行期间将值交换出去。这样两个进程都将以只读方式访问交换文件。这种方法有一个问题,即当子进程正在保存时,即使 Redis 使用的内存超过了最大内存参数的规定,也不能将新值传输到交换文件。这通常不是问题,因为后台保存会在短时间内终止,如果仍然需要,一部分值将尽快交换到磁盘。

此场景的替代方案是启用仅追加文件,这样只有在执行 BGREWRITEAOF 命令进行日志重写时才会出现此问题。

*阻塞式 VM 的问题

阻塞式 VM 的问题是……它是阻塞的 :) 当 Redis 用于批处理活动时这不是问题,但对于实时使用来说,Redis 的优点之一是低延迟。阻塞式 VM 会有糟糕的延迟行为,因为当客户端访问被交换出去的值时,或者当 Redis 需要交换出去值时,在此期间不会为其他客户端提供服务。

交换键应该在后台进行。同样,当客户端访问被交换出去的值时,其他访问内存中值的客户端应该能够像 VM 被禁用时一样快速得到服务。只有处理被交换出去键的客户端才会被延迟。

所有这些限制促使实现非阻塞式 VM。

*线程化 VM

基本上有三种主要方式可以将阻塞式 VM 转变为非阻塞式。 * 1:一种方式是显而易见的,在我看来根本不是一个好主意,即将 Redis 本身转变为线程化服务器:如果每个请求都由不同的线程自动处理,其他客户端就不需要等待被阻塞的客户端。Redis 很快,导出原子操作,没有锁,而且只有 10k 行代码,因为它是单线程的,所以这不是我的选择。 * 2:对交换文件使用非阻塞 I/O。毕竟你可以认为 Redis 已经是基于事件循环的,为什么不以非阻塞方式处理磁盘 I/O 呢?我也因为两个主要原因排除了这种可能性。一是非阻塞文件操作与套接字不同,是一场兼容性噩梦。不仅仅是调用 select,你需要使用操作系统特定的功能。另一个问题是 I/O 只是处理 VM 所消耗时间的一部分,另一大部分 CPU 用于编码/解码交换文件的数据。所以我选择了第三种方案,即…… * 3:使用 I/O 线程,即一个处理交换 I/O 操作的线程池。这就是 Redis VM 所使用的,所以让我们详细介绍一下这是如何工作的。

*I/O 线程

线程化 VM 的设计目标如下,按重要性排序:

  • 实现简单,很少出现竞争条件,锁定简单,VM 系统与 Redis 代码的其余部分或多或少完全解耦。
  • 良好的性能,访问内存中值的客户端不需要锁。
  • 能够在 I/O 线程中解码/编码对象。

上述目标导致了这样一种实现:Redis 主线程(服务实际客户端的线程)和 I/O 线程使用作业队列进行通信,使用单个互斥锁。 基本上当主线程需要一些 I/O 线程在后台完成的工作时,它将一个 I/O 作业结构推入 server.io_newjobs 队列(即只是一个链表)。如果没有活动的 I/O 线程,就会启动一个。此时某个 I/O 线程将处理 I/O 作业,处理结果被推入 server.io_processed 队列。I/O 线程将使用 UNIX 管道向主线程发送一个字节,以通知新作业已处理且结果已准备好被处理。

这就是 iojob 结构的样子:

typedef struct iojob {
    int type;   /* 请求类型, REDIS_IOJOB_* */
    redisDb *db;/* Redis 数据库 */
    robj *key;  /* 此 I/O 请求是关于交换此键 */
    robj *val;  /* 对于 REDIS_IOREQ_*_SWAP 是要交换的值,否则对于 REDIS_IOREQ_LOAD 此字段由 I/O 线程填充。 */
    off_t page; /* 在交换文件上读取/写入对象的页 */
    off_t pages; /* 保存对象所需的交换页数。PREPARE_SWAP 返回值 */
    int canceled; /* 如果此命令被 VM 阻塞端取消则为真 */
    pthread_t thread; /* 处理此条目的线程 ID */
} iojob;

I/O 线程只能执行三种类型的作业(类型由结构的 type 字段指定):

  • REDIS_IOJOB_LOAD:将给定键关联的值从交换区加载到内存。对象在交换文件内的偏移量是 page,对象类型是 key->vtype。此操作的结果将填充结构的 val 字段。
  • REDIS_IOJOB_PREPARE_SWAP:计算在交换中保存 val 指向的对象所需的页数。此操作的结果将填充 pages 字段。
  • REDIS_IOJOB_DO_SWAP:将 val 指向的对象传输到交换文件,页偏移量为 page

主线程只委托上述三项任务。其余所有事情都由 I/O 线程处理,例如在交换文件页表中查找合适的空闲页范围(这是一个快速操作)、决定交换什么对象、更改 Redis 对象的 storage 字段以反映值的当前状态。

*非阻塞式 VM 作为阻塞式 VM 的概率增强

现在我们有一种方法来请求处理慢速 VM 操作的后台作业。如何将其添加到主线程完成的其余工作组合中?虽然阻塞式 VM 只是在查找对象时才知道对象被交换出去了,但对我们来说这太晚了:在 C 语言中,在命令中间启动后台作业、离开函数、然后在 I/O 线程完成我们请求的工作时重新进入同一点进行计算,这并不简单(即没有协程或延续之类)。

幸运的是有一种更简单得多的方法。我们喜欢简单的东西:基本上将 VM 实现视为阻塞式的,但添加一个优化(使用我们能够执行的非阻塞 VM 操作)使阻塞 非常 不可能发生。

这就是我们所做的:

  • 每次客户端向我们发送命令时,在命令执行 之前,我们检查命令的参数向量以搜索被交换的键。毕竟我们知道对于每个命令哪些参数是键,因为 Redis 命令格式相当简单。
  • 如果我们检测到请求命令中至少有一个键在磁盘上被交换出去,我们就阻塞该客户端而不是真正发出命令。对于与请求键关联的每个被交换的值,创建一个 I/O 作业以将值加载回内存。主线程继续执行事件循环,不关心被阻塞的客户端。
  • 与此同时,I/O 线程正在将值加载到内存中。每次 I/O 线程完成加载一个值时,它使用 UNIX 管道向主线程发送一个字节。管道文件描述符在主线程事件循环中关联了一个可读事件,即函数 vmThreadedIOCompletedJob。如果此函数检测到被阻塞客户端所需的所有值都已加载,客户端就会被重启并调用原始命令。

所以你可以将此视为阻塞式 VM,只是它几乎总是碰巧在内存中有正确的键,因为我们暂停将要对被交换出去的值发出命令的客户端,直到这些值被加载。

如果检查哪个参数是键的函数以某种方式失败,也没有问题:查找函数会看到给定键与被交换出去的值关联,并阻塞加载它。因此当无法预知会触及哪些键时,我们的非阻塞式 VM 会回退为阻塞式 VM。

例如,在使用 GETBY 选项的情况下使用 SORT 命令时,事先知道会请求哪些键并不简单,所以至少在第一个实现中,SORT BY/GET 回退到阻塞式 VM 实现。

*在被交换键上阻塞客户端

如何阻塞客户端?在基于事件循环的服务器中暂停客户端非常简单。我们所做的就是取消其读取处理程序。有时我们做一些不同的事情(例如对于 BLPOP),即只是将客户端标记为被阻塞,但不处理新数据(只是将新数据累积到输入缓冲区中)。

*中止 I/O 作业

关于阻塞式和非阻塞式 VM 之间的交互,有一些难以解决的问题,即如果关于某个键的阻塞操作开始时,某个非阻塞操作也对该键"感兴趣",会发生什么?

例如,在执行 SORT BY 时,sort 命令正在以阻塞方式加载几个键。同时,另一个客户端可能用简单的 GET key 命令请求相同的键,这将触发创建一个 I/O 作业以在后台加载该键。

处理这个问题的唯一简单方法是能够在主线程中终止 I/O 作业,这样如果我们想要以阻塞方式加载或交换的键处于 REDIS_VM_LOADINGREDIS_VM_SWAPPING 状态(即关于此键有一个 I/O 作业),我们可以直接终止关于此键的 I/O 作业,并继续执行我们想要执行的阻塞操作。

这并不像听起来那么简单。在给定时刻,I/O 作业可能位于以下三个队列之一中:

  • server.io_newjobs:作业已排队但尚无线程处理它。
  • server.io_processing:作业正在被 I/O 线程处理。
  • server.io_processed:作业已经被处理。 能够终止 I/O 作业的函数是 vmCancelThreadedIOJob,它的工作如下:
  • 如果作业在 newjobs 队列中,这很简单,将 iojob 结构从队列中移除就足够了,因为还没有线程在执行任何操作。
  • 如果作业在 processing 队列中,某个线程正在处理我们的作业(并可能处理关联的对象!)。我们唯一能做的就是以阻塞方式等待该项目移动到下一个队列。幸运的是这种情况很少发生,所以这不是性能问题。
  • 如果作业在 processed 队列中,我们只是通过在 iojob 结构的 canceled 字段设置为 1 来将其标记为 已取消。处理已完成作业的函数将只是忽略并释放该作业,而不是真正处理它。

*有问题?

本文档绝不完整,获得全貌的唯一方法是阅读源代码,但它应该是一个良好的入门介绍,以使代码审查/理解变得更加简单。

关于本页有什么不清楚的地方吗?请留下评论,我会尽力解决问题,可能将答案整合到本文档中。