原文出处:Memcached的存储原理解析

最近工作上的需要,需要做一个LRU形式管理内存的分配器,首先想到的就是Memcached这个项目。早些年粗略的看过一些,有个大体的了解,这一次看下来发现其LRU算法做了不少的改动。

本文解析Memcached内存管理这部分的内容,基于Memcached 1.6.9版本。

Memcached将单个KV数据的存储,都放在item这个结构体中,每个item数据同时存在于这几个数据结构之中:

hash表自不必多说,Memcached中将item组织成一个名为primary_hashtable的hash数组,根据键值查找元素时,首先计算出键值的hash值,再到对应的数组元素中遍历查找数据。

slabclass_t结构体以分级的方式分配内存给item,这样做有以下几个好处:

slabclass_t

定义

slabs.c中定义了类型为slabclass_t、大小为MAX_NUMBER_OF_SLAB_CLASSES的数组slabclass,用于分级存储。

数组中的每个slabclass_t元素,其能分配出去的内存大小递增,由如下的规则决定:

slabclass的分级存储

当需要分配一块大小的内存时,首先需要根据其大小,计算出该尺寸最终对应到上面的哪个元素,这个数组索引在Memcached中被称为clsid,这个计算索引的过程参见函数slabs_clsid

比如:

每一个slab中,需要维持两类空间:

slabclass_t结构体中,以下几个成员用来维护该class的内存信息:

slabclass结构体示意图

即:

内存分配

既然Memcached是一个LRU形式的内存分配器,所以其内存是有限制的,系统中定义了如下几个全局变量来保存当前系统的内存分配信息:

在初始化时,系统首先会根据mem_limit分配一大块内存出来。

后续各个slabclass需要内存时,如下操作:

LRU算法

旧的LRU算法及其问题

以往的LRU算法,基本做法都是这样的:

这个算法有如下几个问题:

经典的LRU链表实现

综上,经典的LRU链表问题的核心在于:

从Memcached 1.5版本开始,引入了所谓的分段LRU算法(Segmented LRU)来解决这些问题。

改进的分段LRU算法(Segmented LRU)

分段LRU算法中将LRU链表根据活跃度分成了三类:

需要说明的是:热(参数settings.hot_lru_pct)和暖(参数settings.warm_lru_pct)数据的占总体内存的比例有限制,而冷数据则无限。

#define HOT_LRU 0
#define WARM_LRU 64
#define COLD_LRU 128

同时,使用了headstails两个数组用来保存LRU链表:

#define POWER_LARGEST  256 /* actual cap is 255 */
#define LARGEST_ID POWER_LARGEST
static item *heads[LARGEST_ID];
static item *tails[LARGEST_ID];

上面分析slabclass的时候提到过,首先会根据被分配内存大小计算出来一个slabclass数组的索引。在需要从LRU链表中淘汰数据时,由于LRU链表分为了上面三类,那么就还需要再进行一次slabid | lru id计算(其实就是slabid + lru id),到对应的LRU链表中查找元素:

将slabclass数组索引映射到LRU队列数组

有了这三种LRU队列的初步印象,可以接下来解释这个分段LRU算法了。

前面提到,原有LRU算法最大的问题是:只要元素被访问过一次,就马上会被移动到LRU链表的前面,影响了后面对这个元素的淘汰。

首先,改进的算法中,加入了一个机制:只有当元素被访问两次以后,才会标记成活跃元素。

代码中引入了两个标志位,其置位的规则如下:

ITEM_ACTIVE标志位清除的规则如下:

这样,有效避免了只访问一次就变成活跃元素的问题(见函数do_item_bump)。

以下的讨论中,元素变成活跃就意指“至少被访问两次以上”。

其次,由于从链表尾部往前查找可以淘汰的元素,中间可能会经历很多不能被淘汰的元素,影响了淘汰的速度,因此前面的分级LRU链表就能帮助程序快速识别出哪些元素可以被淘汰。三个分级的LRU链表之间的转换规则如下:

以上需要注意的是:任何操作都不能将一个元素从WARM和COLD队列中移动回去HOT队列了,也就是从HOT队列中移动元素出去的操作是单向操作。

上述算法的状态机转换过程,可以参考下图。使用了这些规则来维护着三个队列之后,基本能保证COLD队列中的元素是不活跃的,这样查找起被淘汰元素也更快了。

三级LRU队列转换状态图

综述起来,改进的分段LRU算法做了如下的优化:

参考资料


原文出处:Memcached的存储原理解析(续)

在前面的Memcached的存储原理解析一文中,简单分析了memcached的存储原理,但是最近在照搬memcached的实现原理到项目中时,发现前面的梳理还不够细致,有一些细节没有谈及,因此重新整理一篇文章。

slab

memcached是根据slab为基础单位来管理空闲空间的。slab的大体原理如下图:

slabclass的分级存储

slabs.c中定义了类型为slabclass_t、大小为MAX_NUMBER_OF_SLAB_CLASSES的数组slabclass,用于分级 存储。

数组中的每个slabclass_t元素,其能分配出去的内存大小递增,由如下的规则决定:

以上图为例,假设第一级存储的item大小不超过56字节,每个slab之间的增长因子是1.2,那么下一个slab存储的item内存大小就是56*1.2=72字节。

在当前还有空闲可用内存的情况下,每一次分配新的空间,都是以page(page=1MB)为单位的,然后再根据该slab的item大小划分为多个空闲可用item。

slabclass_t类型中最重要的是以下两个成员:

当需要分配一块大小的内存时,首先需要根据其大小,计算出该尺寸最终对应到上面的哪个元素,这个数组索引在Memcached中被称为clsid,这个计算索引的过程参见函数slabs_clsid

比如:

从上面可以看到,slabclass_t用于管理空闲内存,当需要分配新item时,会依次做如下的检查:

讲到item的淘汰,就涉及到下面的LRU算法了。

LRU算法

旧的LRU算法及其问题

以往的LRU算法,基本做法都是这样的:

这个算法有如下几个问题:

经典的LRU链表实现

综上,经典的LRU链表问题的核心在于:

从Memcached 1.5版本开始,引入了所谓的分段LRU算法(Segmented LRU)来解决这些问题。

改进的分段LRU算法(Segmented LRU)

分段LRU算法中将LRU链表根据活跃度分成了三类:

需要说明的是:热(参数settings.hot_lru_pct)和暖(参数settings.warm_lru_pct)数据的占总体内存的比例有限制,而冷数据则无限。

#define HOT_LRU 0
#define WARM_LRU 64
#define COLD_LRU 128

同时,使用了headstails两个数组用来保存LRU链表:

#define POWER_LARGEST  256 /* actual cap is 255 */
#define LARGEST_ID POWER_LARGEST
static item *heads[LARGEST_ID];
static item *tails[LARGEST_ID];

上面分析slabclass的时候提到过,首先会根据被分配内存大小计算出来一个slabclass数组的索引。在需要从LRU链表中淘汰数据时,由于LRU链表分为了上面三类,那么就还需要再进行一次slabid | lru id计算(其实就是slabid + lru id),到对应的LRU链表中查找元素:

将slabclass数组索引映射到LRU队列数组

有了这三种LRU队列的初步印象,可以接下来解释这个分段LRU算法了。

前面提到,原有LRU算法最大的问题是:只要元素被访问过一次,就马上会被移动到LRU链表的前面,影响了后面对这个元素的淘汰。

首先,改进的算法中,加入了一个机制:只有当元素被访问两次以后,才会标记成活跃元素。

代码中引入了两个标志位,其置位的规则如下:

ITEM_ACTIVE标志位清除的规则如下:

这样,有效避免了只访问一次就变成活跃元素的问题(见函数do_item_bump)。

以下的讨论中,元素变成活跃就意指“至少被访问两次以上”。

其次,由于从链表尾部往前查找可以淘汰的元素,中间可能会经历很多不能被淘汰的元素,影响了淘汰的速度,因此前面的分级LRU链表就能帮助程序快速识别出哪些元素可以被淘汰。三个分级的LRU链表之间的转换规则如下:

以上需要注意的是:任何操作都不能将一个元素从WARM和COLD队列中移动回去HOT队列了,也就是从HOT队列中移动元素出去的操作是单向操作。

上述算法的状态机转换过程,可以参考下图。使用了这些规则来维护着三个队列之后,基本能保证COLD队列中的元素是不活跃的,这样查找起被淘汰元素也更快了。

三级LRU队列转换状态图

综述起来,改进的分段LRU算法做了如下的优化:

读写操作的实现

从以上的分析里,可以看出memcached中主要有这么几种数据结构:

一个item,必然处于空闲和在使用这两种互斥状态之一,即:

使用中的item数据组织结构

对item的数据组织有了大体的概念之后,下面展开来说读写操作的具体实现。

读操作

由于被使用数据存储在hash表中,所以查询操作相对简单,其伪代码是:

读操作
  加锁
    在hash表中查询数据
  释放锁
  返回查询的结果

虽然简单,但是其中有一个值得注意的细节。这里的加锁操作,并不是一个全局锁,否则系统的并发性会大大折扣;同时,也不是给hash数组中的某一个hash桶进行加锁,实际上hash表本身并不存在锁操作。

在这里,加的锁是首先根据所查询数据的键进行hash计算,再得到对应的锁,在memcached里被称为“item lock”(见全局变量static pthread_mutex_t *item_locks)。这个锁虽然与数据的键值相关,但是如果hash数组数量与item_locks不相等,那么就不是一一对应的关系,所以才说不是针对hash桶进行加锁。如果hash桶的数量大于item lock的数量,那么这就是一对多的关系,也就是对一个item lock加锁之后,获得锁的线程可以访问多个hash桶。

item锁与hash桶的对应关系

上图中,索引为N的item锁,管理着索引为M、P这两个hash桶,因此拿到该item锁的线程可以同时访问这两个hash桶。

因此,上面的读操作更准确的描述应该是:

读操作
  根据查询键值加item锁
    在hash表中查询数据
  根据查询键值释放item锁
  返回查询的结果

写操作

与前面非常简单的读操作相比,写操作会更加复杂,因为当内存不足时需要淘汰在LRU数组中的item。

写操作  
  根据查询键值加item锁
    分配足够内存的item写入新的数据
    向hash表中写入数据
  根据查询键值释放item锁

在上面的步骤中,分配足够内存的item这一步,暂不考虑分析内存足够下的情况,因为这种情况相对简单,只分析内存不足时需要淘汰的情况。将这部分代码展开来讨论,则伪代码如下:

写操作  
1:根据查询键值加item锁
  2:内存不足情况下分配足够内存的item写入新的数据
    2.1:计算满足所需内存所在的LRU数组元素对该LRU链表加锁
    2.2:从后往前遍历所要求内存的LRU数组
      2.2.1:找到一个item如果尝试对该item的键值进行加锁失败则继续尝试下一个item
      2.2.3:否则对该item的键值进行加锁成功如果符合回收条件
        2.2.4:从item所在的hash表中删除item
      2.2.5:释放2.2.1中加的item锁
    2.3:对该LRU链表解锁
  3:向hash表中写入数据
4:根据查询键值释放item锁

需要注意的是,步骤2.2.1中,是尝试对当前item的键值所在的item锁加锁,这一步是可能失败的,因为在第一步中已经加上了item锁,两者有可能相同,如果这里不是尝试而是直接等待解锁,则可能造成死锁。

但是仅有上面的步骤仍然是不够的,因为即便找到了一个可以被回收的item,也要确定该item没有被其他线程引用,判断的标准是根据item中的引用计数:首先将引用计数加1,如果为2的情况下(使用中的item默认引用计数为1)说明当前只有本线程引用了这个item,后面就可以安全的回收该item。

在memcached代码中,如果上一步增加引用计数之后不为2时,有可能是item泄露了,如果打开tail_repair_time开关且满足时间的情况下,可以进行强制回收,但是作者也提醒了这样可能会造成程序core掉,也就是出现上面提到的被引用的item被释放的情况:

int lru_pull_tail(const int orig_id, const int cur_lru,
        const uint64_t total_bytes, const uint8_t flags, const rel_time_t max_age,
        struct lru_pull_tail_return *ret_it) {
        // ...
        if (refcount_incr(search) != 2) {
            /* Note pathological case with ref'ed items in tail.
             * Can still unlink the item, but it won't be reusable yet */
            itemstats[id].lrutail_reflocked++;
            /* In case of refcount leaks, enable for quick workaround. */
            /* WARNING: This can cause terrible corruption */
            if (settings.tail_repair_time &&
                    search->time + settings.tail_repair_time < current_time) {
                itemstats[id].tailrepairs++;
                search->refcount = 1;
                /* This will call item_remove -> item_free since refcnt is 1 */
                STORAGE_delete(ext_storage, search);
                do_item_unlink_nolock(search, hv);
                item_trylock_unlock(hold_lock);
                continue;
            }
        }
        // ...
}

步骤2,内存不足情况下分配足够内存的item,其完整实现在函数lru_pull_tail中,读者可以自行结合上面的伪代码以及前面提及的LRU算法自行分析。

以上的整个流程如下图所示:

需淘汰item时的写入数据流程图

解释完毕了读写操作流程之后,需要回答这个问题:为什么针对键值的锁加在了item锁上,而不是hash桶?

原因在于:当写入的元素过多时,hash表需要进行扩容操作,即可以认为hash桶的数量是有可能发生变化的。因此,如果锁在hash桶上,在容量发生变化的时候就难以处理。而item锁数组,其大小则是固定的,不存在这个问题。

hash数组的扩容操作

hash数组的数量必须是2的次方,每次存储的数据总量超过数组数量的1.5倍时,就需要扩容一倍,最多到2^32。

扩容流程示意图如下:

hashtable扩容示意图

扩容步骤为:

综述

从以上分析可以看出,实现一个完备的LRU cache库,需要考虑的细节问题其实不少的,尤其memcached需要应对的是多线程情况下cache的读写,比之redis单进程单线程的情况还是要复杂不少,主要包括以下方面:

参考资料