虚拟内存(已弃用)

Redis 2.6中已弃用的Redis虚拟内存系统的描述。本文档仅出于历史兴趣而存在。

注意:本文件由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 的主要设计目标之一是在数据集经常使用的部分适合 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:关联的值被交换了,哈希表的 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 在内存中取一个“位图”(一个连续的设置为 0 或 1 的位数组),每个位代表磁盘上交换文件的一个页面:如果给定位被设置为 1,它表示一个已被使用的页面(有一些 Redis 对象存储在那里),而如果相应的位为零,则该页面是空闲的。

在内存中使用此位图(称为页面表)在性能方面是一个巨大的优势,并且使用的内存很小:我们每个磁盘页面只需要 1 个位。例如,在以下示例中,每个 32 字节的 134217728 个页面(4GB 交换文件)仅使用 16 MB 的 RAM 用于页面表。

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

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

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

如您所料,一旦对象在交换文件中成功写入,它就会从内存中释放,关联键中的 storage 字段被设置为 REDIS_VM_SWAPPED,并且页面表中使用的页面被标记为已使用。

将对象加载回内存

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

调用函数 vmLoadObject 并传递与我们要加载回的 value 对象关联的 key 对象就足够了。该函数还将负责修复键的 storage 类型(它将为 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* 字段被设置为正确的值(对象被交换到的页面索引以及用于交换它的页面数)。
  • 最后,value 对象被释放,哈希表的 value 条目被设置为 NULL。

该函数会不断被调用,直到发生以下情况之一:由于交换文件已满或几乎所有对象都已转移到磁盘上,或者内存使用量已低于 vm-max-memory 参数,因此无法再交换更多对象。

内存不足时交换哪些值?

理解什么是合适的交换候选对象并不难。随机抽取一些对象,并计算每个对象的*可交换性*,如下所示。

swappability = age*log(size_in_memory)

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

阻塞 VM 加载

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

GET foo

如果foo 键的 value 对象被交换了,我们需要将其加载回内存,然后才能处理操作。在 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 转变为非阻塞 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;   /* 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 和非阻塞 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。处理已完成作业的函数将直接忽略并释放作业,而不是真正处理它。

有问题吗?

本文档并非完整,要获得完整的信息,请阅读源代码。但它可以作为良好的介绍,使代码审查/理解变得更加容易。

关于此页面的任何内容不清楚吗?请留下评论,我会尝试解决问题,并将答案整合到此文档中。

RATE THIS PAGE
Back to top ↑