虚拟内存(已弃用)

Redis 虚拟内存系统的描述,该系统已在 2.4 中弃用并在 2.6 中删除。本文档仅供历史参考。

Redis 开源

注意:本文档由 Redis 的创建者 Salvatore Sanfilippo 在 Redis 开发初期(大约 2010 年)编写。虚拟内存已在 Redis 2.4 中弃用并在 Redis 2.6 中删除,因此本文档仅供历史参考。

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

键 vs 值:什么被换出?

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 的样子

/* 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:关联的值已交换,哈希表的值条目只是设置为 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 将对象写入磁盘上的指定位置。

您可以猜到,一旦对象被正确写入交换文件,它就会从内存中释放,关联键中的 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 函数中。此函数过去每秒调用一次,而在 git 上的最新 Redis 版本中,它每 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)

年龄是键未被请求的秒数,而 size_in_memory 是对象在内存中使用的内存量(以字节为单位)的快速估计。因此,我们尝试交换很少访问的对象,并且尝试交换更大的对象而不是更小的对象,但后者是一个不太重要的因素(因为使用了对数函数)。这是因为我们不希望更大的对象过于频繁地被交换出和交换入,因为对象越大,传输它所需的 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 使用的内存超过最大内存参数规定的,当子进程保存时,也不能将新值传输到交换文件。这通常不是问题,因为后台保存将在短时间内终止,如果仍然需要,则会尽快将一部分值交换到磁盘。

这种场景的替代方法是启用 Append Only File,它仅在使用 BGREWRITEAOF 命令执行日志重写时才会有此问题。

阻塞 VM 的问题

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

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

所有这些限制都需要一个非阻塞 VM 实现。

线程 VM

基本上有三种主要方法可以将阻塞 VM 转换为非阻塞 VM。

  • 1:一种方法很明显,而且在我看来,根本不是一个好主意,即将 Redis 本身变成一个线程服务器:如果每个请求都由不同的线程提供服务,则其他客户端会自动地不必等待被阻止的客户端。Redis 速度快,导出原子操作,没有锁,而且只有 10k 行代码,因为它是单线程的,所以这不是我的选择。
  • 2:使用非阻塞 I/O 对交换文件。毕竟,您可以认为 Redis 已经是基于事件循环的,为什么不以非阻塞方式处理磁盘 I/O?我也放弃了这种可能性,因为有两个主要原因。一是,与套接字不同,非阻塞文件操作是一个不兼容的噩梦。不仅仅是调用 select,你需要使用特定于 OS 的东西。另一个问题是,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;   /* 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,这就是它的作用
  • 如果作业位于 newjobs 队列中,这很简单,从队列中删除 iojob 结构就足够了,因为没有线程仍在执行任何操作。
  • 如果作业在处理队列中,则线程正在处理我们的作业(并可能正在处理关联的对象!)。 我们唯一能做的就是等待项目以阻塞方式移动到下一个队列。 幸运的是,这种情况很少发生,因此这不是性能问题。
  • 如果作业在已处理队列中,我们只需将其标记为已取消,在 iojob 结构中将 canceled 字段设置为 1。 处理已完成作业的函数将忽略并释放作业,而不是真正处理它。

有问题吗?

本文档并非完整,了解全局的唯一方法是阅读源代码,但这应该是一个很好的介绍,以便使代码审查/理解变得更加简单。

对此页面有任何疑问? 请留下评论,我将尝试解决该问题,并可能将答案集成到本文档中。

评价此页面
返回顶部 ↑