虚拟内存 (已弃用)

2.6 中已弃用的 Redis 虚拟内存系统的描述。本文档的存在是为了历史兴趣。

Redis 堆栈 Redis 社区版

注意:本文档由 Redis 的创建者 Salvatore Sanfilippo 在 Redis 开发初期(约 2010 年)编写。虚拟内存从 Redis 2.6 开始已被弃用,因此本文档 这里只是为了历史兴趣。

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

键与值:换出什么?

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 的性能。

交换的值在内部是什么样子的

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

  • 该 key 继续持有表示该 key 的 Redis Object。
  • 该值设置为 NULL

因此,您可能想知道我们将给定值(与给定键相关联)交换的信息存储在哪里。就在关键对象中!

这是 Redis Object 结构 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 Object 结构中未使用的字节(由于自然内存对齐问题,我们有一些空闲位),但 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 在内存中取一个“位图”(设置为 0 或 1 的连续位数组),每个位代表磁盘上交换文件的一页:如果给定位设置为 1,则表示已使用的页面(那里存储了一些 Redis 对象),而如果相应的位为零, 该页面是免费的。

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

将对象从内存传输到交换

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

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

正如您可以猜到的那样,一旦对象被正确写入交换文件,它就会从内存中释放出来,关联键中的 storage 字段将设置为REDIS_VM_SWAPPED,并且使用的页面将在 page 表中标记为 used。

将对象加载回内存

将对象从 swap 加载到内存更简单,因为我们已经知道对象的位置以及它使用的页数。我们还知道对象的类型(需要加载函数才能知道此信息,因为磁盘上没有 header 或任何其他关于对象类型的信息),但这存储在关联键的 vtype 字段中,如上所示。

调用函数vmLoadObject传递与要加载回的 Value 对象关联的 Key 对象就足够了。该函数还将负责修复密钥的存储类型(即REDIS_VM_MEMORY)、在页表中将页面标记为已释放,依此类推。

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

阻止 VM 的工作原理

现在,我们有了所有构建块,以便描述阻塞 VM 的工作原理。首先,关于配置的重要细节。为了在 Redis 中启用阻止 VMserver.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 线程。在阻塞场景中,我们只用 0 作为参数来调用它。

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

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

所以这是发生的事情:

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

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

VM 处于活动状态时进行后台保存

在磁盘上保留的默认 Redis 方法是使用子进程创建 .rdb 文件。Redis 调用 fork() 系统调用是为了创建一个子进程,该子进程具有内存中数据集的精确副本,因为 fork 会复制整个程序内存空间(实际上,由于父进程和子进程之间共享了一种称为“写入时复制”的技术,内存页面在父进程和子进程之间共享,因此 fork() 调用不需要太多内存)。

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

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

  • 如果对 swapd out 值执行作,则父进程需要访问 swap 文件,以便将值加载回内存。
  • 子进程需要访问交换文件,以便在将数据集保存在磁盘上时检索完整数据集。

为了避免在两个进程访问同一个交换文件时出现问题,我们做了一件简单的事情,即不允许在后台保存过程中交换父进程中的值。这样,两个进程都将以只读方式访问交换文件。这种方法有一个问题,即当子进程保存时,即使 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 呢?我也放弃了这种可能性,主要有两个原因。一个是,与 sockets 不同,非阻塞文件作是不兼容的噩梦。这不仅仅是调用 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 线程在后台完成一些工作时,它会在server.io_newjobsqueue(即,只是一个链表)。如果没有活动的 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 线程只能执行三种类型的作业(类型由typefield 的 Structure) 中。

  • REDIS_IOJOB_LOAD:将与给定键关联的值从 swap 加载到内存。交换文件内的对象偏移量为page,则对象类型为key->vtype.此作的结果将填充val字段。
  • REDIS_IOJOB_PREPARE_SWAP:计算保存对象所需的页数val进入交换。此作的结果将填充pages田。
  • REDIS_IOJOB_DO_SWAP:传输val拖动到交换文件,位于 Page Offset 处page.

主线程仅委派上述三个任务。其余的都由 I/O 线程本身处理,例如在交换文件页表中找到合适的空闲页面范围(这是一个快速作),决定要交换的对象,更改 Redis 对象的存储字段以反映值的当前状态。

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

因此,现在我们有一种方法可以请求处理缓慢的 VM作的后台作业。如何将其添加到主线程完成的其余工作的组合中?虽然阻塞 VM 知道在查找对象时该对象被换出,但这对我们来说为时已晚:在 C 语言中,在 I/O 线程完成我们请求的内容(即 I/O 线程完成我们请求的内容)时,在命令中间启动后台作业,离开该函数,并在同一点重新输入计算并非易事。 没有协程或延续或类似的东西)。

幸运的是,有一种非常简单的方法可以做到这一点。我们喜欢简单的事情:基本上认为 VM 实现是一个阻塞性实现,但添加一个优化(使用我们能够执行的非阻塞 VM作)以使阻塞非常不可能。

以下是我们的工作:

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

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

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

例如,在SORT命令与GETBYoptions 中,事先知道将请求哪些 key 并非易事,因此至少在第一个实现中,SORT BY/GET求助于阻塞 VM 实现。

在交换密钥上阻止客户端

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

中止 I/O 作业

关于我们的阻塞和非阻塞 VM 之间的交互,有一些问题很难解决,也就是说,如果阻塞作同时围绕一个键开始,而该键也对非阻塞作“感兴趣”,会发生什么情况?

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

解决这个问题的唯一简单方法是能够杀死主线程中的 I/O 作业,这样,如果我们想要以阻塞方式加载或交换的 key 位于REDIS_VM_LOADINGREDIS_VM_SWAPPINGstate(即,存在关于此键的 I/O 作业)时,我们可以只终止有关此键的 I/O 作业,并继续执行我们想要执行的阻塞作。

这并不像它那么微不足道。在给定时刻,I/O 作业可以位于以下三个队列之一中:

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

问题?

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

这个页面有什么不清楚的地方吗?请发表评论,我将尝试解决问题,可能会将答案整合到本文档中。

为本页评分
返回顶部 ↑