虚拟内存(已弃用)

Redis 虚拟内存系统的说明,该系统在 2.6 中已弃用。保留此文档是为了历史兴趣。

注意:此文档由 Redis 的创建者 Salvatore Sanfilippo 在 Redis 开发早期(约 2010 年)编写。虚拟内存自 Redis 2.6 起已弃用,因此此文档仅出于历史兴趣而保留。

此文档详细介绍了 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 的主要设计目标之一是在禁用 VM 时拥有与 Redis 相似的性能,而数据集经常使用的那部分适合在 RAM 中。

交换的值在内部看起来是什么样的

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

  • 键继续保存一个表示键的 Redis 对象。
  • 值被设置为 NULL

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

Redis 对象结构robj 如下所示

/* The actual Redis Object */
typedef struct redisObject {
    void *ptr;
    unsigned char type;
    unsigned char encoding;
    unsigned char storage;  /* If this object is a key, where is the value?
                             * REDIS_VM_MEMORY, REDIS_VM_SWAPPED, ... */
    unsigned char vtype; /* If this object is a key, and value is swapped out,
                          * this is the type of the swapped out object. */
    int refcount;
    /* VM fields, this are only allocated if VM is active, otherwise the
     * object allocation function will just allocate
     * sizeof(redisObject) minus sizeof(redisObjectVM), so using
     * Redis without VM active will not have any overhead. */
    struct redisObjectVM vm;
} robj;

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

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

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

/* The VM object structure */
struct redisObjectVM {
    off_t page;         /* the page at which the object is stored on disk */
    off_t usedpages;    /* number of pages used on disk */
    time_t atime;       /* Last access time */
} vm;

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

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

... some code ...
        if (server.vm_enabled) {
            pthread_mutex_unlock(&server.obj_freelist_mutex);
            o = zmalloc(sizeof(*o));
        } else {
            o = zmalloc(sizeof(*o)-sizeof(struct redisObjectVM));
        }
... some code ...

如您所见,如果未启用 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 即可。

正如你所猜想的,一旦对象正确写入交换文件,它就会从内存中释放,关联键中的存储字段设置为 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 函数中。此函数过去每秒调用一次,而在 git 上的最新 Redis 版本中,它每 100 毫秒(即每秒 10 次)调用一次。如果此函数检测到我们内存不足,即使用的内存大于 vm-max-memory 设置,它将开始在循环中将对象从内存传输到磁盘,调用函数 vmSwapOneObect。此函数只接受一个参数,如果为 0,它将以阻塞方式交换对象,否则如果为 1,则使用 I/O 线程。在阻塞场景中,我们只需将其作为参数调用为零即可。

vmSwapOneObject 执行以下步骤

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

该函数将反复调用,直到发生以下情况之一:无法再交换更多对象,因为交换文件已满或几乎所有对象已传输到磁盘,或者内存使用率已低于 vm-max-memory 参数。

内存不足时交换哪些值?

了解哪些对象适合交换并不难。随机抽取一些对象,并计算每个对象的可交换性,如下所示

swappability = age*log(size_in_memory)

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

阻塞 VM 加载

如果请求了与已交换出的对象关联的键的操作,会发生什么?例如,Redis 可能恰好处理以下命令

GET foo

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

因此,以下是发生的情况

  • 用户调用某些命令,其中参数为已交换的键
  • 命令实现调用查找函数
  • 查找函数在顶级哈希表中搜索键。如果与请求的键关联的值已交换(我们可以通过检查键对象的存储字段来查看),我们在返回给用户之前以阻塞方式将其加载回内存。

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

虚拟机处于活动状态时的后台保存

Redis 存储到磁盘的默认方式是使用子进程创建 .rdb 文件。Redis 调用 fork() 系统调用来创建子进程,该子进程具有内存数据集的确切副本,因为 fork 复制了整个程序内存空间(实际上,由于采用了一种称为写时复制的技术,父进程和子进程共享内存页面,因此 fork() 调用不会占用太多内存)。

在子进程中,我们有一个给定时间点的数据集副本。客户端发出的其他命令将仅由父进程提供,并且不会修改子进程数据。

子进程只会将整个数据集存储到 dump.rdb 文件中,最后退出。但是当虚拟机处于活动状态时会发生什么?值可能会被换出,因此我们不会在内存中拥有所有数据,并且我们需要访问交换文件以检索被换出的值。当子进程正在保存时,交换文件在父进程和子进程之间共享,因为

  • 如果对换出的值执行操作,则父进程需要访问交换文件以将值重新加载到内存中。
  • 子进程需要访问交换文件以在将数据集保存到磁盘时检索完整数据集。

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

这种情况的替代方法是启用仅附加文件,当使用 BGREWRITEAOF 命令执行日志重写时,仅会出现此问题。

阻塞虚拟机的问题

阻塞虚拟机的问题在于... 它会阻塞 :) 当 Redis 用于批处理活动时,这不是问题,但对于实时使用,Redis 的优点之一是低延迟。阻塞虚拟机将具有较差的延迟行为,因为当客户端访问换出的值或 Redis 需要换出值时,在此期间不会为其他客户端提供服务。

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

所有这些限制都需要一个非阻塞 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 线程将处理 I/O 作业,处理结果将推送到 server.io_processed 队列中。I/O 线程将使用 UNIX 管道向主线程发送一个字节,以表示新作业已处理并且结果已准备好进行处理。

iojob 结构如下所示

typedef struct iojob {
    int type;   /* Request type, REDIS_IOJOB_* */
    redisDb *db;/* Redis database */
    robj *key;  /* This I/O request is about swapping this key */
    robj *val;  /* the value to swap for REDIS_IOREQ_*_SWAP, otherwise this
                 * field is populated by the I/O thread for REDIS_IOREQ_LOAD. */
    off_t page; /* Swap page where to read/write the object */
    off_t pages; /* Swap pages needed to save object. PREPARE_SWAP return val */
    int canceled; /* True if this command was canceled by blocking side of VM */
    pthread_t thread; /* ID of the thread processing this entry */
} 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 对象的存储字段以反映当前状态值。

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

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

幸运的是,有一种更简单的方法来做到这一点。我们喜欢简单的东西:基本上将 VM 实现视为阻塞的,但添加一个优化(使用我们能够执行的非阻塞 VM 操作)以使阻塞不太可能。

我们这样做

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

因此,你可以将此视为一个被阻止的 VM,它几乎总是有正确的键在内存中,因为我们在暂停客户端,这些客户端将发出有关交换出的值命令,直到这些值被加载。

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

例如,在 SORT 命令与 GETBY 选项一起使用的情况下,事先了解将请求哪些键并非易事,因此至少在第一个实现中,SORT BY/GET 诉诸于阻塞 VM 实现。

阻止交换键上的客户端

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

中止 I/O 作业

在我们的阻塞和非阻塞 VM 之间的交互中,有一个难以解决的问题,即,如果一个阻塞操作从一个键开始,而这个键同时也被一个非阻塞操作“感兴趣”,会发生什么?

例如,在执行 SORT BY 时,排序命令正在以阻塞方式加载一些键。与此同时,另一个客户端可能会使用简单的 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,它执行以下操作
  • 如果作业在新作业队列中,则很简单,从队列中删除 iojob 结构就足够了,因为没有线程仍在执行任何操作。
  • 如果作业在处理队列中,则线程正在处理我们的作业(可能还有关联的对象!)。我们唯一能做的是以阻塞方式等待项目移至下一个队列。幸运的是,这种情况很少发生,因此不会出现性能问题。
  • 如果作业在已处理队列中,我们只需将其标记为已取消,将 iojob 结构中的 canceled 字段设置为 1。处理已完成作业的函数将忽略并释放作业,而不是真正处理它。

问题?

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

此页面有什么不清楚的地方?请留言,我会尝试解决问题,并可能将答案整合到此文档中。

给此页面评分