虚拟内存 (已弃用)
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_SWAPPED
或REDIS_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
如果foo
key 被交换,我们需要在处理作之前将其加载回内存中。在 Redis 中,键查找过程集中在lookupKeyRead
和lookupKeyWrite
函数,这两个函数用于实现所有访问密钥空间的 Redis 命令,因此我们在代码中只有一个点来处理密钥从交换文件到内存的加载。
所以这是发生的事情:
- 用户调用某个命令,其参数是交换的键
- 命令实现调用 lookup 函数
- 查找函数在顶级哈希表中搜索键。如果与请求的 key 关联的值被交换(我们可以看到检查 key 对象的 storage 字段),我们会在返回给用户之前以阻塞的方式将其加载回内存。
这很简单,但随着线程的出现,事情会变得更加有趣。从阻塞 VM 的角度来看,唯一真正的问题是使用另一个进程保存数据集,即处理BGSAVE
和BGREWRITEAOF
命令。
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_newjobs
queue(即,只是一个链表)。如果没有活动的 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
field 的 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
命令与GET
或BY
options 中,事先知道将请求哪些 key 并非易事,因此至少在第一个实现中,SORT BY/GET
求助于阻塞 VM 实现。
在交换密钥上阻止客户端
如何阻止客户端?在基于事件循环的服务器中挂起客户端非常简单。我们所做的只是取消它的 read 处理程序。有时我们会做一些不同的事情(例如对于 BLPOP),只是将客户端标记为阻塞,但不处理新数据(只是将新数据累积到输入缓冲区中)。
中止 I/O 作业
关于我们的阻塞和非阻塞 VM 之间的交互,有一些问题很难解决,也就是说,如果阻塞作同时围绕一个键开始,而该键也对非阻塞作“感兴趣”,会发生什么情况?
例如,在执行 SORT BY 时,sort 命令会以阻塞方式加载一些键。同时,另一个客户端可能会使用简单的 GET 键盘命令请求相同的密钥,这将触发 I/O 作业的创建,以在后台加载密钥。
解决这个问题的唯一简单方法是能够杀死主线程中的 I/O 作业,这样,如果我们想要以阻塞方式加载或交换的 key 位于REDIS_VM_LOADING
或REDIS_VM_SWAPPING
state(即,存在关于此键的 I/O 作业)时,我们可以只终止有关此键的 I/O 作业,并继续执行我们想要执行的阻塞作。
这并不像它那么微不足道。在给定时刻,I/O 作业可以位于以下三个队列之一中:
- server.io_newjobs:作业已排队,但没有线程正在处理它。
- server.io_processing:作业正在由 I/O 线程处理。
- server.io_processed:作业已处理。
能够终止 I/O 作业的函数是
vmCancelThreadedIOJob
,这就是它的作用: - 如果作业在 newjobs 队列中,这很简单,从队列中删除 iojob 结构就足够了,因为没有线程仍在执行任何作。
- 如果作业在处理队列中,则线程正在干扰我们的作业(可能还会干扰关联的对象!我们唯一能做的就是等待项目以阻塞方式移动到下一个队列。幸运的是,这种情况很少发生,因此这不是性能问题。
- 如果作业在已处理的队列中,我们只需将其标记为已取消标记,将
canceled
字段设置为 1。处理已完成作业的函数只会忽略并释放作业,而不是真正处理它。
问题?
本文档绝不完整,了解全貌的唯一方法是阅读源代码,但它应该是一个很好的介绍,以使代码审查/理解变得更加简单。
这个页面有什么不清楚的地方吗?请发表评论,我将尝试解决问题,可能会将答案整合到本文档中。