Memcached 缓存过期机制源码分析
之前的文章说了 memcached源码学习-线程框架 和 get操作处理代码, 其实最重要的是他的缓存过期策略,以及内存管理机制, 篇幅过长分几篇文章写,这里写一下memcached的缓存过期策略。
说到缓存过期策略,网上有一大堆帖子会介绍说“LRU, 最近最少使用 ” 算法,而且源码中间也是大量的含lru字样的函数,变量,比如lru_crawler_crawl 等,很多人也就信了,可事实却不是这样的。
零、前言
实际上memcached的缓存策略是LU, 没有R, 也就是没有最少使用这回事,能想到,也就是说memcached没有记录一个key最近被访问过几次这回事,他只是记录了最近访问时间,以及过期时间(如果设置了的话)。来看一下一个key-value的内存表示形式:
typedef struct _stritem {
struct _stritem *next;
struct _stritem *prev;
struct _stritem *h_next; /* hash chain next */
rel_time_t time; /* least recent access *///最后一次访问时间
rel_time_t exptime; /* expire time *///主动设置的过期时间
int nbytes; /* size of data */
unsigned short refcount;
uint8_t nsuffix; /* length of flags-and-length string */
uint8_t it_flags; /* ITEM_* above */
uint8_t slabs_clsid;/* which slab class we're in */
uint8_t nkey; /* key length, w/terminating null and padding */
/* this odd type prevents type-punning issues when we do
* the little shuffle to save space when not using CAS. */
union {
uint64_t cas;
char end;
} data[];
/* if it_flags & ITEM_CAS we have 8 bytes CAS */
/* then null-terminated key */
/* then " flags length\r\n" (no terminating null) */
/* then data with terminating \r\n (no terminating null; it's binary!) */
} item;
上面time是最近访问时间,exptime是主动设置的过期时间, refcount是内部使用的引用计数,data为一个联合体,为了节省内存。 time的更新时机为 do_item_update 函数调用的时候。一共有如下几个:
- 存储的时候设置为current_time;
- 每次get访问的时候,调用item_update 更新时间(process_get_command);
- do_store_item 去存储一个item的时候,发现已经有了,那就更新时间;
- 执行touch命令的时候,更新time时间(process_touch_command);
关于过期时间exptime字段,只会在主动设置的时候才会有效,否则无效,另外有个小坑是如果设置了过期时间超过30天,那么实际上的效果是:这个key是1秒后过期的,用的时候注意,否则就玩死人了。可以看下如下代码:
static rel_time_t realtime(const time_t exptime) {
/*expiretime参数要么是个大于28天的秒数的时间戳,并且大于服务器启动的时间戳,也就是说,确保这个是个时间戳,
* 或者为最大30天的秒数,代表从现在起,多少秒过期
*
* */
/* no. of seconds in 30 days - largest possible delta exptime */
if (exptime == 0) return 0; /* 0 means never expire */
if (exptime > REALTIME_MAXDELTA) {
/* if item expiration is at/before the server started, give it an
expiration time of 1 second after the server started.
(because 0 means don't expire). without this, we'd
underflow and wrap around to some large value way in the
future, effectively making items expiring in the past
really expiring never */
if (exptime <= process_started)
return (rel_time_t)1;//1秒后过期
return (rel_time_t)(exptime - process_started);//返回的是服务器启动后多少秒过期
} else {
return (rel_time_t)(exptime + current_time);//在这个时间戳之后过期
}
}
下面说下其缓存的过期机制,和redis不同,memcached不会记录一个即将过期的key列表,并且其过期机制属于“懒惰过期” , key过期后不会立即删除,而是会在下次访问,或者下次有内存需要分配的时候,等一堆时机上会有过期回收的机会。整体可以分为如下几部分:
- do_item_get 查询的时候,如果过期了,删除,返回空。
- do_item_alloc 分配一个新的大小类似的数据的时候,看setting.oldest_live 和exptime 两部分来过期一部分item;
- 执行flush_all 命令的时候,do_item_flush_expired 清空所有数据的时候;
- lru_crawler enable 进行后台爬虫扫描过期。
下面一个个介绍其实现原理。
一、 查询时检查过期
这个最简单了,因为设置的时候记录了访问时间,所以查询的时候,只要判断一下时间就行了。如下代码:
item *do_item_get(const char *key, const size_t nkey, const uint32_t hv) {
item *it = assoc_find(key, nkey, hv);
//---------
if (it != NULL) {
if (settings.oldest_live != 0 && settings.oldest_live <= current_time &&
it->time <= settings.oldest_live) {
//flush_all指令可以控制要不要刷掉所有时间在这之前的数据, 这算懒惰过期机制的一个
do_item_unlink(it, hv);
do_item_remove(it);
it = NULL;
if (was_found) {
fprintf(stderr, " -nuked by flush");
}
} else if (it->exptime != 0 && it->exptime <= current_time) {
do_item_unlink(it, hv);//进行懒惰过期清空
do_item_remove(it);
it = NULL;
if (was_found) {
fprintf(stderr, " -nuked by expire");
}
} else {
it->it_flags |= ITEM_FETCHED;//搞定,找到了
DEBUG_REFCNT(it, '+');
}
}
//----------
}
注意上面的代码中oldest_live相关的逻辑是跟flush_all指令有关系的,这个后面介绍。
二、申请新item的时候,顺便检查过期
也就是说,当memcached接到新的set类似命令的时候,需要申请一个新的item用来存储新数据,此时他会检查跟这个item大小类似的这个slabs组里面的过期数据。
我们知道memcached对于内存池的管理,是使用注明的slab内存池管理机制。所有的slab大小快都存放在static slabclass_t slabclass[MAX_NUMBER_OF_SLAB_CLASSES]; 的数组里面,具体的实现机制后面的文章再写。
当接到update命令时,process_update_command 函数被调用,进而调用item_alloc 申请一个新的item,
static void process_update_command(conn *c, token_t *tokens, const size_t ntokens, int comm, bool handle_cas) {
//解析 "set aaa 0 0 5" 指令,申请内存用来后续存放数据部分的内容, 注意这里只是申请没存,没做其他操作
//处理完后悔设置连接的状态为conn_nread, 因此继续读取数据到指定地方, 这样节省了内存的拷贝
//----
it = item_alloc(key, nkey, flags, realtime(exptime), vlen);
//-----
}
item *item_alloc(char *key, size_t nkey, int flags, rel_time_t exptime, int nbytes) {
item *it;
/* do_item_alloc handles its own locks */
it = do_item_alloc(key, nkey, flags, exptime, nbytes, 0);
return it;
}
而do_item_alloc去分配新item的时候,为了效率,这里会有一些优化措施。它首先计算存进来的数据需要多大的内存空间,从而用slabs_clsid(ntotal) 函数计算应该放到哪个slab类里面;
然后顺便进行了最多5次扫描,从该类slab的尾部倒序扫描,看有没有要过期的数据,如果有,那就果断复用这个过期的item,从而既达到了懒惰清除过期key的效果,又搞笑的使用了已经申请的内存。看如下代码:
item *do_item_alloc(char *key, const size_t nkey, const int flags,
const rel_time_t exptime, const int nbytes,
const uint32_t cur_hv) {
//申请一个item,方法有:
//1. 从当前这个slabs_clsid里面扫描最旧的item,看有没有过期的;
//2. 用slabs_alloc 申请一个新的;
//3. 如果slabs_alloc 申请失败,且evict_to_free开关打开了(默认打开),就强制删掉一个最旧的
uint8_t nsuffix;
item *it = NULL;
char suffix[40];
//算一下需要多长的内存,用来决定slab的桶. 大小为: sizeof(item) + nkey+1 + " %flags %nbytes-2\r\n" + nbytes
//其实就是把命令转为字符串
size_t ntotal = item_make_header(nkey + 1, flags, nbytes, suffix, &nsuffix);
if (settings.use_cas) {
ntotal += sizeof(uint64_t);
}
unsigned int id = slabs_clsid(ntotal);//根据数据大小,找一个合适的slab槽
int tries = 5;
search = tails[id];
/* We walk up *only* for locked items. Never searching for expired.
* Waste of CPU for almost all deployments */
for (; tries > 0 && search != NULL; tries--, search=search->prev) {//从这个slab链表的尾部扫描,看有没有要过期的,正好用它
//-----------
/* Expired or flushed */
if ((search->exptime != 0 && search->exptime < current_time)
|| (search->time <= oldest_live && oldest_live <= current_time)) {//找到了一个已经过期的it了,准备用它
itemstats[id].reclaimed++;
if ((search->it_flags & ITEM_FETCHED) == 0) {
itemstats[id].expired_unfetched++;
}
it = search;
slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal);//更新统计数据
do_item_unlink_nolock(it, hv);//从hash和slab链表里面都删除这个it
/* Initialize the item block: */
it->slabs_clsid = 0;//临时清空一下,下面还是会设置为id的
} else if ((it = slabs_alloc(ntotal, id)) == NULL) {
//这里很巧妙,如果申请成功了,自然不会进去,如果失败了,就进去用当前这个item,清空他,然后使用
tried_alloc = 1;
if (settings.evict_to_free == 0) {
itemstats[id].outofmemory++;
} else {//干掉一个本slabs_clsid里面最旧的item
itemstats[id].evicted++;
itemstats[id].evicted_time = current_time - search->time;//记录我回收到了几秒之前访问的数据了,用来评估回收的程度
//------------------
}
三、执行flush_all 命令的时候,do_item_flush_expired 懒惰清空所有数据
flush_all 指令专门用来清空所有的数据,最开始这个指令没有参数,由于这个操作比较重,后来就增加了一个时间戳的整数参数,用来控制清空的即时性,或者说提高flush_all的响应时间。这个时间默认是current_time-1秒, 会设置到settings.oldest_live 上面。处理函数是item_flush_expired 。
void item_flush_expired() {
//处理flush_all指令,用懒惰机制清空所有数据
mutex_lock(&cache_lock);
do_item_flush_expired();
mutex_unlock(&cache_lock);
}
/* expires items that are more recent than the oldest_live setting. */
void do_item_flush_expired(void) {
//处理fflush_all 指令,功能是把oldest_live之后访问的数据立即删掉,
//再老的数据不管,因为等到do_item_alloc需要分配新item时,会复用那些老于oldest_live 的数据的
//这样就保证了速度了,所以如果想立即'清空'所有数据, 那就不要带个1或者很小的参数,这样都会调用到do_item_unlink_nolock
//否则设置一个很大的时间戳或者不设置,这样就不会真正去do_item_unlink_nolock所有数据了,而是采用懒惰做法。
//注意就算设置了1的参数,unlink了所有数据,实际上memcache不会真正清空内存给操作系统,知识放到自己的空闲item列表里面
int i;
item *iter, *next;
if (settings.oldest_live == 0)
return;
for (i = 0; i < LARGEST_ID; i++) {
/* The LRU is sorted in decreasing time order, and an item's timestamp
* is never newer than its last access time, so we only need to walk
* back until we hit an item older than the oldest_live time.
* The oldest_live checking will auto-expire the remaining items.
*/
for (iter = heads[i]; iter != NULL; iter = next) {
/* iter->time of 0 are magic objects. */
if (iter->time != 0 && iter->time >= settings.oldest_live) {
next = iter->next;
if ((iter->it_flags & ITEM_SLABBED) == 0) {
do_item_unlink_nolock(iter, hash(ITEM_key(iter), iter->nkey));
}
} else {
/* We've hit the first old item. Continue to the next queue. */
break;
}
}
}
}
从上面的代码中可以看出,实际上do_item_flush_expired 并没有真的清空所有数据,相反,他只是清空过来最近的一部分数据,然后依靠其他时机去用标记的时间oldest_live清理数据。
上的代码是什么意思呢?实际上只清空了访问时间在settings.oldest_live 以后的数据,再老的数据依靠访问时,或者申请内存时进行清理,具体看注释。由此联想到上面第一,二点中,关于 settings.oldest_live 的检查逻辑了。
四、lru_crawler enable 进行后台爬虫扫描过期
有了上面的机制,基本能保证需要申请内存的时候,只要有过期的内存,我能申请到,那么,如果非要立即能清理过期的数据怎么办呢?答案是:lru_crawler指令。
lru_crawler enable 命令或者启动时指定参数,可以让memcached 启动后台crawler线程,也就是所谓的爬虫线程,运行函数是item_crawler_thread,但不会进行爬虫清理;lru_crawler crawl 命令可以让memcached 立即进行爬虫清理,这个命令可以设置我要清理的是哪个slab class;
来看一下crawl 参数触发的lru_crawler_crawl 函数:
enum crawler_result_type lru_crawler_crawl(char *slabs) {
//对参数指定的slabs的类进行爬虫扫描,告诉后台线程item_crawler_thread 去清理过期数据
char *b = NULL;
uint32_t sid = 0;
uint8_t tocrawl[POWER_LARGEST];//需要清理扫描的大小类
//注意上面变了数组没有初始化,下面肯定有问题的。不过1.4.24版本已经修复这问题了. memset(tocrawl, 0, sizeof(uint8_t) * MAX_NUMBER_OF_SLAB_CLASSES);
if (pthread_mutex_trylock(&lru_crawler_lock) != 0) {
return CRAWLER_RUNNING;
}
pthread_mutex_lock(&cache_lock);
if (strcmp(slabs, "all") == 0) {
for (sid = 0; sid < LARGEST_ID; sid++) {
tocrawl[sid] = 1;
}
} else {
for (char *p = strtok_r(slabs, ",", &b);
p != NULL;
p = strtok_r(NULL, ",", &b)) {
if (!safe_strtoul(p, &sid) || sid < POWER_SMALLEST
|| sid > POWER_LARGEST) {
pthread_mutex_unlock(&cache_lock);
pthread_mutex_unlock(&lru_crawler_lock);
return CRAWLER_BADCLASS;
}
tocrawl[sid] = 1;//为什么不初始化?如果不是0怎么办
}
}
for (sid = 0; sid < LARGEST_ID; sid++) {
if (tocrawl[sid] != 0 && tails[sid] != NULL) {
if (settings.verbose > 2)
fprintf(stderr, "Kicking LRU crawler off for slab %d\n", sid);
crawlers[sid].nbytes = 0;
crawlers[sid].nkey = 0;
crawlers[sid].it_flags = 1; /* For a crawler, this means enabled. */
crawlers[sid].next = 0;
crawlers[sid].prev = 0;
crawlers[sid].time = 0;
crawlers[sid].remaining = settings.lru_crawler_tocrawl;//这次要清理这么多
crawlers[sid].slabs_clsid = sid;
crawler_link_q((item *)&crawlers[sid]);
crawler_count++;
}
}
pthread_mutex_unlock(&cache_lock);
pthread_cond_signal(&lru_crawler_cond);//唤起item_crawler_thread
STATS_LOCK();
stats.lru_crawler_running = true;
STATS_UNLOCK();
pthread_mutex_unlock(&lru_crawler_lock);
return CRAWLER_OK;
}
这里实际上使用信号量进行线程间通信的,用来告诉爬虫线程,你要爬去的数据在crawlers[sid]结构里面,因此爬虫线程便会按照参数去扫描是否有过期的item。执行函数是item_crawler_thread 。
static void *item_crawler_thread(void *arg) {
//lru_crawler enable 命令,或者启动时指定crawler
//start_item_crawler_thread创建线程,执行这里,进行后台定期清理过期key
int i;
pthread_mutex_lock(&lru_crawler_lock);
if (settings.verbose > 2)
fprintf(stderr, "Starting LRU crawler background thread\n");
while (do_run_lru_crawler_thread) {
pthread_cond_wait(&lru_crawler_cond, &lru_crawler_lock);//等待lru_crawler_crawl,stop_item_crawler_thread的指令,这里不会自动运行下去,得手动触发
while (crawler_count) {//是tocrawl里面有效的slabs组个数,也就是crawler_link_q 的长度
item *search = NULL;
void *hold_lock = NULL;
for (i = 0; i < LARGEST_ID; i++) {//轮着一个个来,知道轮完所有的,类似归并的感觉
if (crawlers[i].it_flags != 1) {
continue;
}
pthread_mutex_lock(&cache_lock);
search = crawler_crawl_q((item *)&crawlers[i]);//存进去的是一个跟item类似的crawler 结构体
if (search == NULL ||
(crawlers[i].remaining && --crawlers[i].remaining < 1)) {
if (settings.verbose > 2)
fprintf(stderr, "Nothing left to crawl for %d\n", i);
crawlers[i].it_flags = 0;
crawler_count--;//终于轮完一个了
crawler_unlink_q((item *)&crawlers[i]);
pthread_mutex_unlock(&cache_lock);
continue;
}
uint32_t hv = hash(ITEM_key(search), search->nkey);
//----
item_crawler_evaluate(search, hv, i);
//----
}
item_crawler_thread 函数用类似堆排序的轮流扫描的机制来均匀的清理不同class的数据,将其返回到可用的slabs item列表中以备复用内存。
真正的清理函数是item_crawler_evaluate, 函数很简单,判断 oldest_live 和exptime 是否能匹配,如果能,那就调用do_item_unlink_nolock 解除item的关联关系,并且do_item_remove 返回内存给slab管理器。
static void item_crawler_evaluate(item *search, uint32_t hv, int i) {
//item_crawler_thread定期清理过期的key
rel_time_t oldest_live = settings.oldest_live;
if ((search->exptime != 0 && search->exptime < current_time)
|| (search->time <= oldest_live && oldest_live <= current_time)) {
itemstats[i].crawler_reclaimed++;
if (settings.verbose > 1) {
int ii;
char *key = ITEM_key(search);
fprintf(stderr, "LRU crawler found an expired item (flags: %d, slab: %d): ",
search->it_flags, search->slabs_clsid);
for (ii = 0; ii < search->nkey; ++ii) {
fprintf(stderr, "%c", key[ii]);
}
fprintf(stderr, "\n");
}
if ((search->it_flags & ITEM_FETCHED) == 0) {
itemstats[i].expired_unfetched++;
}
do_item_unlink_nolock(search, hv);//减除关联关系
do_item_remove(search);//返回内存给slab管理器
assert(search->slabs_clsid == 0);
} else {
refcount_decr(&search->refcount);
}
}
由上可知,memcached的key过期机制不是LRU,而是LU而已, 并且其懒惰过期机制性能高效,基本能做到按需过期,懒惰过期,并且提供flush_all和crawl 爬虫的指令让管理员可以手动强制的进行过期清理。
不过需要注意的是,memcached到此为止实际上清理的内存并没有返回给操作系统,而是由slabs_free 函数返回给了slab管理器的可用item列表中了,以备后续使用。后面再介绍下返回过去的内存,memcached是怎么管理的,如何回收和整理内存的。

近期评论