之前的文章说了 memcached源码学习-线程框架 和 get操作处理代码, 其实最重要的是他的缓存过期策略,以及内存管理机制, 篇幅过长分几篇文章写,这里写一下memcached的缓存过期策略。
说到缓存过期策略,网上有一大堆帖子会介绍说“LRU, 最近最少使用 ” 算法,而且源码中间也是大量的含lru字样的函数,变量,比如lru_crawler_crawl 等,很多人也就信了,可事实却不是这样的。
零、前言
实际上memcached的缓存策略是LU, 没有R, 也就是没有最少使用这回事,能想到,也就是说memcached没有记录一个key最近被访问过几次这回事,他只是记录了最近访问时间,以及过期时间(如果设置了的话)。来看一下一个key-value的内存表示形式:
1 | typedef struct _stritem { |
4 | struct _stritem *h_next; |
5 | rel_time_t time ; //最后一次访问时间 |
6 | rel_time_t exptime; //主动设置的过期时间 |
8 | unsigned short refcount; |
上面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秒后过期的,用的时候注意,否则就玩死人了。可以看下如下代码:
1 | static rel_time_t realtime( const time_t exptime) { |
8 | if (exptime == 0) return 0; |
10 | if (exptime > REALTIME_MAXDELTA) { |
17 | if (exptime <= process_started) |
19 | return (rel_time_t)(exptime - process_started); |
21 | 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 进行后台爬虫扫描过期。
下面一个个介绍其实现原理。
一、 查询时检查过期
这个最简单了,因为设置的时候记录了访问时间,所以查询的时候,只要判断一下时间就行了。如下代码:
1 | item *do_item_get( const char *key, const size_t nkey, const uint32_t hv) { |
2 | item *it = assoc_find(key, nkey, hv); |
5 | if (settings.oldest_live != 0 && settings.oldest_live <= current_time && |
6 | it-> time <= settings.oldest_live) { |
8 | do_item_unlink(it, hv); |
12 | fprintf (stderr, " -nuked by flush" ); |
14 | } else if (it->exptime != 0 && it->exptime <= current_time) { |
15 | do_item_unlink(it, hv); |
19 | fprintf (stderr, " -nuked by expire" ); |
22 | it->it_flags |= ITEM_FETCHED; |
23 | 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,
1 | static void process_update_command(conn *c, token_t *tokens, const size_t ntokens, int comm, bool handle_cas) { |
5 | it = item_alloc(key, nkey, flags, realtime(exptime), vlen); |
8 | item *item_alloc( char *key, size_t nkey, int flags, rel_time_t exptime, int nbytes) { |
11 | it = do_item_alloc(key, nkey, flags, exptime, nbytes, 0); |
而do_item_alloc去分配新item的时候,为了效率,这里会有一些优化措施。它首先计算存进来的数据需要多大的内存空间,从而用slabs_clsid(ntotal) 函数计算应该放到哪个slab类里面;
然后顺便进行了最多5次扫描,从该类slab的尾部倒序扫描,看有没有要过期的数据,如果有,那就果断复用这个过期的item,从而既达到了懒惰清除过期key的效果,又搞笑的使用了已经申请的内存。看如下代码:
1 | item *do_item_alloc( char *key, const size_t nkey, const int flags, |
2 | const rel_time_t exptime, const int nbytes, |
3 | const uint32_t cur_hv) { |
13 | size_t ntotal = item_make_header(nkey + 1, flags, nbytes, suffix, &nsuffix); |
14 | if (settings.use_cas) { |
15 | ntotal += sizeof (uint64_t); |
18 | unsigned int id = slabs_clsid(ntotal); |
23 | for (; tries > 0 && search != NULL; tries--, search=search->prev) { |
26 | if ((search->exptime != 0 && search->exptime < current_time) |
27 | || (search-> time <= oldest_live && oldest_live <= current_time)) { |
28 | itemstats[id].reclaimed++; |
29 | if ((search->it_flags & ITEM_FETCHED) == 0) { |
30 | itemstats[id].expired_unfetched++; |
33 | slabs_adjust_mem_requested(it->slabs_clsid, ITEM_ntotal(it), ntotal); |
34 | do_item_unlink_nolock(it, hv); |
37 | } else if ((it = slabs_alloc(ntotal, id)) == NULL) { |
40 | if (settings.evict_to_free == 0) { |
41 | itemstats[id].outofmemory++; |
43 | itemstats[id].evicted++; |
44 | 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 。
1 | void item_flush_expired() { |
3 | mutex_lock(&cache_lock); |
4 | do_item_flush_expired(); |
5 | mutex_unlock(&cache_lock); |
8 | void do_item_flush_expired( void ) { |
16 | if (settings.oldest_live == 0) |
18 | for (i = 0; i < LARGEST_ID; i++) { |
24 | for (iter = heads[i]; iter != NULL; iter = next) { |
26 | if (iter-> time != 0 && iter-> time >= settings.oldest_live) { |
28 | if ((iter->it_flags & ITEM_SLABBED) == 0) { |
29 | do_item_unlink_nolock(iter, hash(ITEM_key(iter), iter->nkey)); |
从上面的代码中可以看出,实际上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 函数:
1 | enum crawler_result_type lru_crawler_crawl( char *slabs) { |
5 | uint8_t tocrawl[POWER_LARGEST]; |
7 | if (pthread_mutex_trylock(&lru_crawler_lock) != 0) { |
8 | return CRAWLER_RUNNING; |
10 | pthread_mutex_lock(&cache_lock); |
12 | if ( strcmp (slabs, "all" ) == 0) { |
13 | for (sid = 0; sid < LARGEST_ID; sid++) { |
17 | for ( char *p = strtok_r(slabs, "," , &b); |
19 | p = strtok_r(NULL, "," , &b)) { |
21 | if (!safe_strtoul(p, &sid) || sid < POWER_SMALLEST |
22 | || sid > POWER_LARGEST) { |
23 | pthread_mutex_unlock(&cache_lock); |
24 | pthread_mutex_unlock(&lru_crawler_lock); |
25 | return CRAWLER_BADCLASS; |
31 | for (sid = 0; sid < LARGEST_ID; sid++) { |
32 | if (tocrawl[sid] != 0 && tails[sid] != NULL) { |
33 | if (settings.verbose > 2) |
34 | fprintf (stderr, "Kicking LRU crawler off for slab %d\n" , sid); |
35 | crawlers[sid].nbytes = 0; |
36 | crawlers[sid].nkey = 0; |
37 | crawlers[sid].it_flags = 1; |
38 | crawlers[sid].next = 0; |
39 | crawlers[sid].prev = 0; |
40 | crawlers[sid]. time = 0; |
41 | crawlers[sid].remaining = settings.lru_crawler_tocrawl; |
42 | crawlers[sid].slabs_clsid = sid; |
43 | crawler_link_q((item *)&crawlers[sid]); |
47 | pthread_mutex_unlock(&cache_lock); |
48 | pthread_cond_signal(&lru_crawler_cond); |
50 | stats.lru_crawler_running = true ; |
52 | pthread_mutex_unlock(&lru_crawler_lock); |
这里实际上使用信号量进行线程间通信的,用来告诉爬虫线程,你要爬去的数据在crawlers[sid]结构里面,因此爬虫线程便会按照参数去扫描是否有过期的item。执行函数是item_crawler_thread 。
1 | static void *item_crawler_thread( void *arg) { |
6 | pthread_mutex_lock(&lru_crawler_lock); |
7 | if (settings.verbose > 2) |
8 | fprintf (stderr, "Starting LRU crawler background thread\n" ); |
9 | while (do_run_lru_crawler_thread) { |
10 | pthread_cond_wait(&lru_crawler_cond, &lru_crawler_lock); |
12 | while (crawler_count) { |
14 | void *hold_lock = NULL; |
16 | for (i = 0; i < LARGEST_ID; i++) { |
17 | if (crawlers[i].it_flags != 1) { |
20 | pthread_mutex_lock(&cache_lock); |
21 | search = crawler_crawl_q((item *)&crawlers[i]); |
23 | (crawlers[i].remaining && --crawlers[i].remaining < 1)) { |
24 | if (settings.verbose > 2) |
25 | fprintf (stderr, "Nothing left to crawl for %d\n" , i); |
26 | crawlers[i].it_flags = 0; |
28 | crawler_unlink_q((item *)&crawlers[i]); |
29 | pthread_mutex_unlock(&cache_lock); |
32 | uint32_t hv = hash(ITEM_key(search), search->nkey); |
34 | 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管理器。
1 | static void item_crawler_evaluate(item *search, uint32_t hv, int i) { |
3 | rel_time_t oldest_live = settings.oldest_live; |
4 | if ((search->exptime != 0 && search->exptime < current_time) |
5 | || (search-> time <= oldest_live && oldest_live <= current_time)) { |
6 | itemstats[i].crawler_reclaimed++; |
8 | if (settings.verbose > 1) { |
10 | char *key = ITEM_key(search); |
11 | fprintf (stderr, "LRU crawler found an expired item (flags: %d, slab: %d): " , |
12 | search->it_flags, search->slabs_clsid); |
13 | for (ii = 0; ii < search->nkey; ++ii) { |
14 | fprintf (stderr, "%c" , key[ii]); |
16 | fprintf (stderr, "\n" ); |
18 | if ((search->it_flags & ITEM_FETCHED) == 0) { |
19 | itemstats[i].expired_unfetched++; |
21 | do_item_unlink_nolock(search, hv); |
22 | do_item_remove(search); |
23 | assert (search->slabs_clsid == 0); |
25 | refcount_decr(&search->refcount); |
由上可知,memcached的key过期机制不是LRU,而是LU而已, 并且其懒惰过期机制性能高效,基本能做到按需过期,懒惰过期,并且提供flush_all和crawl 爬虫的指令让管理员可以手动强制的进行过期清理。
不过需要注意的是,memcached到此为止实际上清理的内存并没有返回给操作系统,而是由slabs_free 函数返回给了slab管理器的可用item列表中了,以备后续使用。后面再介绍下返回过去的内存,memcached是怎么管理的,如何回收和整理内存的。
近期评论