更新:虚拟内存自 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 的样子:
/* 实际的 Redis 对象 */
typedef struct redisObject {
void *ptr;
unsigned char type;
unsigned char encoding;
unsigned char storage; /* 如果此对象是键,值在哪里?
* REDIS_VM_MEMORY, REDIS_VM_SWAPPED, ... */
unsigned char vtype; /* 如果此对象是键,且值被交换出去,
* 这是被交换出去对象的类型。 */
int refcount;
/* VM 字段,这些只在 VM 激活时分配,否则
* 对象分配函数将只分配
* sizeof(redisObject) 减去 sizeof(redisObjectVM),因此
* 在不激活 VM 的情况下使用 Redis 不会有任何开销。 */
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 结构)保存有关对象位置的信息。这是这个附加结构的定义:
/* VM 对象结构 */
struct redisObjectVM {
off_t page; /* 对象存储在磁盘上的页 */
off_t usedpages; /* 磁盘上使用的页数 */
time_t atime; /* 最后访问时间 */
} vm;
如你所见,该结构包含对象在交换文件中所在的页、使用的页数以及对象的最后访问时间(这对选择适合交换的候选对象的算法非常有用,因为我们希望将很少访问的对象传输到磁盘上)。
如你所见,虽然所有其他字段都使用了旧 Redis 对象结构中未使用的字节(由于自然内存对齐问题我们有一些空闲位),但 vm 字段是新的,确实使用了额外的内存。即使 VM 被禁用,我们也应该支付这样的内存成本吗?不!这是创建新 Redis 对象的代码:
... 一些代码 ...
if (server.vm_enabled) {
pthread_mutex_unlock(&server.obj_freelist_mutex);
o = zmalloc(sizeof(*o));
} else {
o = zmalloc(sizeof(*o)-sizeof(struct redisObjectVM));
}
... 一些代码 ...
如你所见,如果 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 函数中。这个函数过去每秒调用一次,而在最近的 Redis git 版本中每 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)
age 是键未被请求的秒数,而 sizeinmemory 是对象在内存中使用内存量(以字节为单位)的快速估算。因此我们尝试交换很少访问的对象,并且我们尝试优先交换较大的对象而不是较小的对象,但后者是一个较不重要的因素(因为使用的对数函数)。这是因为我们不希望较大的对象被过于频繁地交换出去和交换进来,因为对象越大,传输它所需的 I/O 和 CPU 就越多。
*阻塞式 VM 加载
如果请求对与被交换出去对象关联的键进行操作会发生什么?例如 Redis 可能碰巧处理以下命令:
GET foo
如果 foo 键的值对象被交换出去,我们需要在处理操作之前将其加载回内存。在 Redis 中,键查找过程集中在 lookupKeyRead 和 lookupKeyWrite 函数中,这两个函数用于实现所有访问键空间的 Redis 命令,所以我们在代码中只有一个点来处理从交换文件将键加载到内存。
所以会发生以下情况:
- 用户调用某个以被交换键为参数的命令
- 命令实现调用查找函数
- 查找函数在顶层哈希表中搜索键。如果与请求键关联的值被交换出去(我们可以通过检查键对象的 storage 字段看到这一点),我们以阻塞方式将其加载回内存,然后返回给用户。
这相当简单,但使用线程时事情会变得更有趣。从阻塞式 VM 的角度来看,唯一真正的问题是使用另一个进程保存数据集,即处理 BGSAVE 和 BGREWRITEAOF 命令。
*VM 激活时的后台保存
Redis 默认的磁盘持久化方式是使用子进程创建 .rdb 文件。Redis 调用 fork() 系统调用来创建子进程,该子进程拥有内存中数据集的确切副本,因为 fork 复制了整个程序内存空间(实际上由于一种称为写时复制的技术,内存页在父进程和子进程之间共享,所以 fork() 调用不需要太多内存)。
在子进程中,我们在时间上的某个点拥有数据集的副本。其他客户端发出的命令将由父进程处理,不会修改子进程的数据。
子进程只是将整个数据集存储到 dump.rdb 文件中,最后退出。但是当 VM 激活时会发生什么?值可以被交换出去,所以我们并非所有数据都在内存中,我们需要访问交换文件来检索被交换的值。当子进程正在保存时,交换文件在父进程和子进程之间共享,因为:
- 父进程需要访问交换文件,以便在对被交换出去的值执行操作时将其加载回内存。
- 子进程需要访问交换文件,以便在将数据集保存到磁盘时检索完整数据集。
为了避免两个进程访问同一交换文件时出现问题,我们做了一件简单的事情,即不允许父进程在后台保存进行期间将值交换出去。这样两个进程都将以只读方式访问交换文件。这种方法有一个问题,即当子进程正在保存时,即使 Redis 使用的内存超过了最大内存参数的规定,也不能将新值传输到交换文件。这通常不是问题,因为后台保存会在短时间内终止,如果仍然需要,一部分值将尽快交换到磁盘。
此场景的替代方案是启用仅追加文件,这样只有在执行 BGREWRITEAOF 命令进行日志重写时才会出现此问题。
*阻塞式 VM 的问题
阻塞式 VM 的问题是……它是阻塞的 :) 当 Redis 用于批处理活动时这不是问题,但对于实时使用来说,Redis 的优点之一是低延迟。阻塞式 VM 会有糟糕的延迟行为,因为当客户端访问被交换出去的值时,或者当 Redis 需要交换出去值时,在此期间不会为其他客户端提供服务。
交换键应该在后台进行。同样,当客户端访问被交换出去的值时,其他访问内存中值的客户端应该能够像 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; /* 请求类型, REDIS_IOJOB_* */
redisDb *db;/* Redis 数据库 */
robj *key; /* 此 I/O 请求是关于交换此键 */
robj *val; /* 对于 REDIS_IOREQ_*_SWAP 是要交换的值,否则对于 REDIS_IOREQ_LOAD 此字段由 I/O 线程填充。 */
off_t page; /* 在交换文件上读取/写入对象的页 */
off_t pages; /* 保存对象所需的交换页数。PREPARE_SWAP 返回值 */
int canceled; /* 如果此命令被 VM 阻塞端取消则为真 */
pthread_t thread; /* 处理此条目的线程 ID */
} 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 对象的 storage 字段以反映值的当前状态。
*非阻塞式 VM 作为阻塞式 VM 的概率增强
现在我们有一种方法来请求处理慢速 VM 操作的后台作业。如何将其添加到主线程完成的其余工作组合中?虽然阻塞式 VM 只是在查找对象时才知道对象被交换出去了,但对我们来说这太晚了:在 C 语言中,在命令中间启动后台作业、离开函数、然后在 I/O 线程完成我们请求的工作时重新进入同一点进行计算,这并不简单(即没有协程或延续之类)。
幸运的是有一种更简单得多的方法。我们喜欢简单的东西:基本上将 VM 实现视为阻塞式的,但添加一个优化(使用我们能够执行的非阻塞 VM 操作)使阻塞 非常 不可能发生。
这就是我们所做的:
- 每次客户端向我们发送命令时,在命令执行 之前,我们检查命令的参数向量以搜索被交换的键。毕竟我们知道对于每个命令哪些参数是键,因为 Redis 命令格式相当简单。
- 如果我们检测到请求命令中至少有一个键在磁盘上被交换出去,我们就阻塞该客户端而不是真正发出命令。对于与请求键关联的每个被交换的值,创建一个 I/O 作业以将值加载回内存。主线程继续执行事件循环,不关心被阻塞的客户端。
- 与此同时,I/O 线程正在将值加载到内存中。每次 I/O 线程完成加载一个值时,它使用 UNIX 管道向主线程发送一个字节。管道文件描述符在主线程事件循环中关联了一个可读事件,即函数
vmThreadedIOCompletedJob。如果此函数检测到被阻塞客户端所需的所有值都已加载,客户端就会被重启并调用原始命令。
所以你可以将此视为阻塞式 VM,只是它几乎总是碰巧在内存中有正确的键,因为我们暂停将要对被交换出去的值发出命令的客户端,直到这些值被加载。
如果检查哪个参数是键的函数以某种方式失败,也没有问题:查找函数会看到给定键与被交换出去的值关联,并阻塞加载它。因此当无法预知会触及哪些键时,我们的非阻塞式 VM 会回退为阻塞式 VM。
例如,在使用 GET 或 BY 选项的情况下使用 SORT 命令时,事先知道会请求哪些键并不简单,所以至少在第一个实现中,SORT BY/GET 回退到阻塞式 VM 实现。
*在被交换键上阻塞客户端
如何阻塞客户端?在基于事件循环的服务器中暂停客户端非常简单。我们所做的就是取消其读取处理程序。有时我们做一些不同的事情(例如对于 BLPOP),即只是将客户端标记为被阻塞,但不处理新数据(只是将新数据累积到输入缓冲区中)。
*中止 I/O 作业
关于阻塞式和非阻塞式 VM 之间的交互,有一些难以解决的问题,即如果关于某个键的阻塞操作开始时,某个非阻塞操作也对该键"感兴趣",会发生什么?
例如,在执行 SORT BY 时,sort 命令正在以阻塞方式加载几个键。同时,另一个客户端可能用简单的 GET key 命令请求相同的键,这将触发创建一个 I/O 作业以在后台加载该键。
处理这个问题的唯一简单方法是能够在主线程中终止 I/O 作业,这样如果我们想要以阻塞方式加载或交换的键处于 REDIS_VM_LOADING 或 REDIS_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 来将其标记为 已取消。处理已完成作业的函数将只是忽略并释放该作业,而不是真正处理它。
*有问题?
本文档绝不完整,获得全貌的唯一方法是阅读源代码,但它应该是一个良好的入门介绍,以使代码审查/理解变得更加简单。
关于本页有什么不清楚的地方吗?请留下评论,我会尽力解决问题,可能将答案整合到本文档中。