虚拟内存(已弃用)

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 的主要设计目标之一是在频繁使用的数据集部分适合 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不会支付内存开销。

交换文件

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

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

vm-page-size 32
vm-pages 134217728

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

在内存中使用这个位图(我们将称之为页表)在性能方面是一个巨大的优势,而且使用的内存很小:我们只需要为磁盘上的每个页面分配1位。例如,在下面的例子中,134217728个页面,每个页面32字节(4GB交换文件),仅使用16 MB的RAM来存储页表。

将对象从内存转移到交换区

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

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

正如你所猜测的,一旦对象被正确地写入交换文件,它就会从内存中释放,关联键中的存储字段被设置为REDIS_VM_SWAPPED,并且使用的页面在页表中被标记为已使用。

将对象加载回内存

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

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

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

阻塞虚拟机的工作原理

现在我们有了所有的基础构件来描述阻塞虚拟机是如何工作的。首先,关于配置的一个重要细节。为了在Redis中启用阻塞虚拟机,必须将server.vm_max_threads设置为零。 我们稍后会看到这个最大线程数信息在线程化虚拟机中是如何使用的,现在只需要知道当这个值设置为零时,Redis会恢复到完全阻塞的虚拟机。

我们还需要引入另一个重要的虚拟机参数,即server.vm_max_memory。这个参数非常重要,因为它用于触发交换:只有当Redis使用的内存超过最大内存设置时,它才会尝试交换对象,否则就没有必要交换,因为我们正在满足用户请求的内存使用。

阻止虚拟机交换

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

vmSwapOneObject 执行以下步骤:

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

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

当内存不足时,应该交换哪些值?

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

swappability = age*log(size_in_memory)

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

阻止虚拟机加载

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

GET foo

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

所以这是发生的事情:

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

这相当直接,但随着线程的加入,事情会变得更加有趣。从阻塞虚拟机的角度来看,唯一真正的问题是使用另一个进程保存数据集,即处理BGSAVEBGREWRITEAOF命令。

虚拟机活动时的后台保存

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

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

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

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

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

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

阻塞虚拟机的问题

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

键的交换应该在后台进行。同样,当一个客户端正在访问一个被交换出去的值时,其他访问内存中值的客户端应该几乎与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系统或多或少完全与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对象的存储字段以反映值的当前状态。

非阻塞虚拟机作为阻塞虚拟机的概率增强

所以现在我们有一种方法来处理慢速虚拟机操作的背景作业。如何将其添加到主线程完成的其他工作中呢?虽然阻塞虚拟机知道在查找对象时对象被换出,但这对我们来说太晚了:在C语言中,在命令中间启动背景作业、离开函数并在I/O线程完成我们请求的操作时重新进入计算点并不简单(也就是说,没有协程或延续等类似功能)。

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

这是我们做的:

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

因此,你可以将其视为一个被阻塞的虚拟机,几乎总是在内存中拥有正确的键,因为我们会暂停那些将要发出关于被交换出去的值的命令的客户端,直到这些值被加载。

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

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

在交换键上阻塞客户端

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

中止I/O作业

关于我们的阻塞和非阻塞虚拟机之间的交互,有一个难以解决的问题,即如果一个阻塞操作开始于一个同时被非阻塞操作“感兴趣”的键,会发生什么?

例如,在执行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。处理已完成任务的函数将忽略并释放该任务,而不是真正处理它。

有问题吗?

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

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

RATE THIS PAGE
Back to top ↑