Redis中虚拟内存的技术实现介绍

本文是Redis官方wiki文章的翻译,本人英文不是很好,如有看不懂的地方这里查看原文

这篇文档介绍Redis中虚拟内存子系统(以下简称VM系统)的内部实现细节,本文的目标受众是希望理解后者修改虚拟内存实现的开发者,而不是最终用户。

keys 和 values:什么会被交换出内存?

VM系统设计的目的在于通过将Redis中的object从内存中交换到硬盘以释放内存的使用。但是比较特别的是,Redis只会把关联到values的object交换到硬盘中。为了更好的理解这个概念,下面使用Redis内部的DEBUG命令来展示一个Object在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的最顶层的hash表中,记录的是从Redis Objects(keys)到Redis Objects(values)的映射关系。VM系统只能把vlaue所对应的objects交换到硬盘中,而把所有关联到keys的objects放在内存中。这样的做法很好的保证了查询性能,这也是Redis的VM系统主要设计目的:让开启了VM系统的Redis和完全使用内存的Redis保持基本接近的性能。

被交换的值的内部结构

当一个对象被交换到磁盘时,在哈希表中会发生下面两件事情

  • key部分继续持有代表了key部分的Redis Object
  • value被设置为NULL

你肯定会想知道我们把跟这个key相对应的value信息存储在哪里了。我们把value信息直接保存在了key所持有的这个Redis Object当中!

 

下面就是就是Redis Object的结构:

/* 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(redisObjct) minus sizeof(redisObjectVM), so using
     * Redis without VM active will not have any overhead. */
    struct redisObjectVM vm;
} robj;

我们可以看到,这里有一些字段是跟VM有关的。其中最重要的就是storage字段了,这个字段可能的值有:

  • REDIS_VM_MEMORY:代表该key所关联的value是存储在内存里面的
  • REDIS_VM_SWAPPED:value已经被交换出去了,在哈希表中的value值已被设置为NULL
  • REDIS_VM_LOADING:value已经被交换至磁盘当中,但是现在有一个任务正在把它从磁盘中加载到内存中(这个值只会在threaded VM开启的时候用到)。
  • REDIS_VM_SWAPPING:value是在内存中的,但是有一个任务正在把它从内存中写入到磁盘中

如果一个对象已经被交换在磁盘中(REDIS_VM_SWAPPED 或者 REDIS_VM_LOADING),我们怎么知道它存储在哪里,类型是什么等等?其实很简单:vtype字段被设置成交换出去的对象的类型,而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;

在这个结构中,记录了这个对象在交换文件中是从哪个页开始的,总共包含了几个页,以及这个对象最近的被访问的时间。其中最近的访问时间用在决定到底把哪个对象交换至磁盘的算法中,因为我们尽量把最少被访问的数据交换到磁盘。

 

但是当你关闭了VM的时候,redis并不会因为vm字段而消耗而外的内存!我们可以看一下创建Redis Object的代码:

... 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没有被开启时,redis只申请了sizeof(*o)-sizeof(struct redisObjectVM)大小的内存。因为vm字段时这个结构体中的最后一个字段,加上当VM没有开启时,这个字段从来都不会被访问,这么做是非常安全的,同时Redis又不需要为VM系统付出额外的内存消耗。

SWAP文件

为了理解VM子系统是如何工作的,我们需要了解一下对象在swap文件中是如何存储的。我们在swap文件中并没有使用什么特殊格式,而是采用跟.rdb文件中一样的存储格式,当执行SAVE命令时,就可以产生一个.rdb的备份文件。

交换文件被分割成固定数量的页(pages),每一个page占用了指定数量的字节空间。这两个参数可以在redis.conf中进行配置,这样就可以根据自己业务需求中的数据大小来进行配置。默认配置为 vm-page-size=32 vm-pages=134217728

Redis在内存中保存了一个bitmap来映射这些页是否被占用,每一个bit代表了相对应的磁盘空间的这个page是否已经被使用。在内存中保存这样一份映射表极大的增强了redis的性能,同时,对内存的使用又是非常小的。

将内存中的对象交换至swap文件

在单线程的VM环境下,这个过程可以分为以下三个步骤

  • 计算保存这个对象需要占用交换文件中的多少个页。在代码实现中,我们通过调用函数rdbSavedObjectPages就可以返回一个对象需要占用的磁盘页大小了。这个函数在实现时,并没有直接复制保存.rdb文件的代码来计算长度,而是把这个对象写入到/dev/null中,然后调用ftello函数来检查需要使用多少个字节。
  • 在交换文件中寻找一段连续的页空间来保存这个对象。这个任务是通过调用函数vmFindContiguousPages来实现的。正如你可能会想到过的,当swap文件已经用完了或者空闲空间过于碎片化,这个函数就回查找失败。当这种情况发生时,redis会放弃将该对象保存到交换文件,这个对象也就会在内存中
  • 最后通过调用vmWriteObjectOnSwap把对象写入到交换文件。

当对象保存到swap文件之后,便会从内存中释放掉。同时,redisObject 中的 storage 字段被置为 REDIS_VM_SWAPPED 。

从swap文件中加载对象回内存

从交换文件中加载对象就更简单了,因为我们已经知道了对象在swap文件中的页起始地址和所占的页个数。我们只要调用 vmLoadObject 就可以了。

阻塞式VM

要开启阻塞式VM,需要在配置文件中设置server.vm_max_threads为0。在配置文件中,还有一个设置比较重要:server.vm_max_memory。只有当内存占用超过该设定值时,Redis才会触发VM交换。

将对象从内存交换置swap文件发生在cron任务当中,该任务会每秒执行一次,但是在最新的git上面,我们改成了每100毫秒执行一次。如果发现内存使用超过了设定值,Redis便会循环调用vmSwapOneObect来把对象交换至swap文件。这个函数接受一个参数,如果传入0,那么就会采取阻塞式交换方式,否则便会使用IO线程。

vmSwapOneObject操作可分解为以下4个步骤:

  • 首先找到一个较优的被用来交换至swap分区的候选对象,稍后我们可以看到什么样的对象是一个好的换出的候选
  • 将对象所关联的value值传输至硬盘中
  • 设置redisObject中的storage字段为REDIS_VM_SWAPPED ,同时vm字段也设置成相应的值
  • 释放对象所关联的value占用的内存

这个函数会一直被反复调用,直到swap文件已经占满了或者内存占用降低至vm-max-memory所设置的值之下。

当内存使用超过vm-max-memory时,什么样的对象会被换出

要理解什么样的对象应该被换出至swap文件时非常难的。我们随机取出一些对象进行采样,按照下面的公式计算他们的swappability值

swappability = age*log(size_in_memory)

其中age代表这个对象距离上一次被访问的时间,size_in_memory是这个对象在内存中占用的字节数。我们采取的策略是要把那些很少被访问的,占用内存又比较大的对象换出至磁盘,但是第二个因素所占的权重更低,从公式中也可以看到,我们取了log值。因为交换大对象时,又需要占用更多的IO和CPU资源。

阻塞式VM的对象加载

当我们执行GET foo时,假设foo所对应的value对象已经被交换至磁盘,在执行这条指令时,我们需要把对象加载回内存中。在Redis中,对key的所有查询操作都在lookupKeyRead 和lookupKeyWrite 这两个函数中,这两个函数被用来处理所有Redis命令的访问key空间的需要,所以我们就有了用来处理从swap文件中加载对象至内存的一个唯一的代码入口。

  • 用户使用了某个命令要访问某个已经被交换到swap文件中的对象
  • 该命令在实现中调用了lookup函数
  • lookup函数查询顶级的哈希表,发现对象已经被交换至swap文件,于是在返回给用户之前,先把对象加载到内存中。

这看起来比较直接,但是当采用了多线程方式时,就会有趣多了。对于阻塞式VM的来说,唯一真正的难点在于对数据库做快照时,采用了另外一个进程,即BGSAVE和BGREWRITEAOF这两个命令。

当VM开启时的后台保存操作

Redis默认的持久化方式是开启一个子进程来创建.rdb文件。Redis调用系统的fork函数来创建一个子进程,这样就可以得到一个当前在内存中的数据库的一个完全一致的拷贝,因为fork函数复制了当前进程的整个编程内存空间(因为fork中采用了Copy on Write机制,所以这里调用fork并不会消耗太多的内存)。

在子进程当中,我们持有了数据库在某一个时刻的数据备份,其他客户端提交的命令都将由父进行提供服务,所以并不会修改子进程的数据。

在子进程中,我们仅仅是把整个数据写入到dump.rdb文件中,然后便退出了。但是当VM开启的时候,values是有可能被交换出内存的,我们并不是在内存中保存了所有的数据。当子进程在执行保存的时候,跟父进程共享了同一个交换文件,因为:

  • 当有命令需要请求交换出内存的数据时,父进程需要把这些对象换回到内存中
  • 同时,子进程也需要访问交换文件来获得完整的数据集

为了避免两个进程访问同一个swap文件,我们采取了一个很简单的方式:当有后台子进程在做快照保存时,父进程不允许把内存中的对象交换到swap文件中。这样,两个进程都将采取只读的模式对交换文件进行访问。这种方式下,存在一个问题:当有后台保存子子进程时,因为不能将新的对象换出,Redis就会申请更多的内存,虽然已经超过了vm-max-memory所设置的。但是这通常情况下都不会成为一个问题,因为后台保存进程能够在很短的时间内就执行结束。

我们也可以通过开启Append Only File功能来避免这个问题,这样就只会在调用BGREWRITEAOF命令来重写log文件时才会发生这种情况。

阻塞式VM存在的问题

The problem of blocking VM is that… it’s blocking :) (这种英文幽默还真不知道怎么来翻译)

当我们把Redis作批处理这样的操作时,不会有什么问题,但是在真实使用条件下,Redis的一个优势就是低延迟。而在阻塞式VM的条件下,当有客户端访问被换出的数据,或者Redis正在将数据换出时,也就意味着Redis将不能处理其他客户端的请求了。

换出操作应该在后台运行,同时,在当有客户端访问已被换出的数据时,其他也在访问的客户端得到数据的速度应该要跟在不开启VM时一样快。只有那些访问已被换出的数据的客户端可以被延迟。这些限制促使了一个非阻塞式的VM实现。

多线程的VM

把阻塞式VM改变成非阻塞式,主要有三种方法:

  1. 这个方法很明显,但是在我看来,并不是一个好办法:把Redis自身变成一个多线程的服务。如果每一个请求都是在一个独立的线程中被执行,那么其他的客户端就不需要等到被阻塞的客户端了。Redis中没有锁,输出的都是原子操作,速度非常快,并且仅仅只有一万行代码,这些都是由单线程模型所带来的,所以这个方法不会是我的选择。
  2. 在swap文件操作中使用NIO。Redis已经是基于时间循环的,为什么不使用当下流行的NIO来处理磁盘IO操作呢?同样,我也否定了这种方法,原因有两条:首先,非阻塞式的文件操作是硬件不兼容的,需要使用操作系统相关的东西;另外一个原因是以为IO只是在VM处理中时间消耗的一部分,还有另外的很大一部分来自于对数据编码或者解码时所消耗的CPU计算时间。所以我选择了下面的第三个方案
  3. 在IO操作中使用IO线程池

IO线程

下面是多线程VM的设计目标,按照重要性排序:

  1. 易于实现,很少的竞争条件,简单的锁机制,VM系统与Redis的其他代码完全解耦
  2. 优异的性能表现,不会阻塞其他客户端对内存中数据的访问请求。
  3. 在IO线程内部实现编码和解码操作。

上面的目标的促使我们使用一个任务队列来进行Redis主线程和IO线程的通讯。通常,当主线程需要在后台中使用IO线程来完成一些任务时,便push一个IO任务到server.io_newjobs队列中。当系统中还不存在活动的IO线程时,便会新建一个。这时候,IO线程便会执行这些IO任务,在任务完成之后把结果push到server.io_processed这个队列中。IO线程会使用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;

IO线程可以执行三种类型的任务:

  • REDIS_IOJOB_LOAD:从swap文件中加载一个对象到内存中。
  • REDIS_IOJOB_PREPARE_SWAP:计算一个对象在保存到swap文件时需要占用多少个页的空间。
  • REDIS_IOJOB_DO_SWAP:把对象从内存中保存到swap文件。

在主线程中,只代理上面的这三个任务。其他的都由主线程自身来处理,如在swap file pagetable中找到一段合适的空闲页,确定那些对象需要进行交换。

非阻塞式VM作为阻塞式VM的一个增强

现在我们可以使用后台任务来处理漫的VM操作了,但是怎么把这个跟主线程中的其他任务混合起来呢?在C语言中,我们并不能在函数中间启动一个后台线程,然后退出函数,在IO线程结束之后又返回到之前线程启动时所在的地方

幸运的是,有一个要简单得多得多得方式来做这件事情。我们喜欢简单得东西:我们依旧把VM的实现看做是一个阻塞式的,只是我们做了一个优化,使其看起来非常的不像是一个阻塞式的了:

  • 当接收到客户端发来的指令时,在指令执行之前,我们先检查客户端发来的参数,得到指令中涉及到的keys用来查询是否存在有被换出的key
  • 当我们找到至少一个key是被换出的时,我们阻塞掉这个客户端而不是立即处理这个命令。对于每一个这些被换出的keys,我们都创建一个IO线程,用于把其所对应的value值换回到内存中。然后主线程继续事件循环,不管那个被阻塞的客户端请求。
  • 同时,IO线程正在把磁盘中的数据加载到内存中。每当有一个IO任务完成时,便通过UNIX管道给主线程发送一个字节的信息。在主线程接受到了这个事件后,会调用VirtualMemorySpecification函数。如果这个函数检查到如果有一个被阻塞的请求所需要的对象都已经加载在内存了,这个请求会被重启,之前的命令被重新调用。

因此,你可以把这个机制想象成是一个所有数据都在内存中的阻塞式的VM系统,因为我们暂停了客户端的命令执行直到所有需要的对象都已经被换回内存了。

如果在命令执行之前,无法通过命令的参数来确定需要用到哪些keys,那么非阻塞式的VM会回退到阻塞式的VM。例如SORT命令和GET或者BY同时使用时。

但是怎样阻塞一个客户端的请求呢?在一个基于事件机制的服务器中,阻塞一个客户端请求时非常简单的,我们做的所有事情就只是取消了他的read handler。有时候,我们也不这么做,如BLPOP,我们只是把这个客户端标记为被阻塞的。

 

 

This entry was posted in Redis and tagged , . Bookmark the permalink.

Leave a Reply

Your email address will not be published.

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>


Verify Code   If you cannot see the CheckCode image,please refresh the page again!