基于memcached原理实现的单机轻量级通用缓存库

一、前言

  之前拜读过淘宝子柳的《淘宝技术这十年》之大作,深知缓存技术在系统优化中起着一个举足轻重的作用。无论是文件系统静态文件,数据库的访问,乃至网络数据的请求,只要是与内存访问速度相差较大的,都能显著减少IO操作,提高系统的响应速度和吞吐量。
  在企业环境中,memcached和Redis算是最成熟的缓存解决方案,而国内外大型企业将其修改扩展成分布式结构的案例也是相当之很多,memcached出现的事件比较早,相对简单但是方案成熟,而Redis算是在memcached之后开发的后起之秀,改进优化了很多方面。

二、memcached工作原理

  目前Redis还没有涉及了解过,memcached之前涉及过一点。memcached通常是作为一个单独服务进程启动的,监听来自TCP/UDP/Socket的请求,一般采用ascii协议进行通信。无可厚非,采用网络的方式进行进程间通信,其扩充简单灵活,但是总感觉相对于一般的单机小型程序来说,还是有点杀鸡用牛刀的感觉。如果能有一个微小型的通用缓存库,就可以联合编译链接到应用程序内部,进程间的通信效率提高了,部分数据都可以做到零拷贝了,数据通信协议、同步问题也都很好解决,岂不乐哉?
minicached
  当然,自己在一个小项目中,用的是libmicrohttpd接收POST请求数据,在不用默认的posthandler的时候,就需要自己手动申请内存,然后供接收数据使用。于是就通过建立一个postbuff的对象池,每当请求来的时候,就阻塞申请对象,用完之后在request_complete回调函数中标记为空闲,供下次使用,这样应该可以大大降低内存的碎片化。不过这个不通用,也没有缓存对象的生命周期、淘汰等特性,这也是本项目的实现目的。

  如果使用set/get命令追踪一下memcached的执行流程,就会对memcached的内部工作流程清晰很多。

1
2
3
4
5
6
7
8
9
10
11
➜  ~ telnet localhost 11211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
set keyname 0 3600 5
taozj
STORED
get keyname
VALUE keyname 0 5
taozj
END

  之前说过,由于memcached是用Libevent来侦听网络请求的事件的,整个项目很多的代码都是处理网络方面,以及用户命令行交互输入方面的。上面两条数据存取,追踪代码,可以归纳出如下的函数调用路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// set keyname 0 3600 5
(1)static int try_read_command(conn *c);
(2)static void process_command(conn *c, char *command);
(3)static void process_update_command(conn *c, token_t *tokens, const size_t ntokens, int comm, bool handle_cas);
(4)item *item_alloc(char *key, size_t nkey, int flags, rel_time_t exptime, int nbytes);
(5)item *do_item_alloc(char *key, const size_t nkey, const unsigned int flags, const rel_time_t exptime, const int nbytes, const uint32_t cur_hv);

// taozj
(1)static void complete_nread(conn *c);
(2)static void complete_nread_ascii(conn *c);
(3)enum store_item_type do_store_item(item *it, int comm, conn *c, const uint32_t hv);

// get keyname
(1)static int try_read_command(conn *c);
(2)static void process_command(conn *c, char *command);
(3)static inline void process_get_command(conn *c, token_t *tokens, size_t ntokens, bool return_cas);
(4)item *item_get(const char *key, const size_t nkey, conn *c);
(5)item *do_item_get(const char *key, const size_t nkey, const uint32_t hv, conn *c);

三、minicached设计实现

  对于实现单机使用的通用内存缓存库的设计需求,上面的命令行处理方面不需要关注,其实底层就是item_alloc、do_store_item、do_item_get这几个函数的作用。缓存可以实现的很简单,也可以做的很精妙复杂。虽然不如Redis现在这么红火,但memcached屹立这么久,还是有很多值得感受和借鉴的地方,那就循着memcached的路子,先慢慢模仿吧。

3.1 内存分配器 slab

  在Linux内核中slab/slub比较常见。查看了下资料,slab最早是Solaris开发出来的,最初产生的缘由是他们发现,在申请对象的时候,对象的初始化(构造)和销毁(析构)的时间很长,所以开发出slab分配器来缓存对象使用的。而Linux中后来移植过来,但其主要是用于防止内存碎片化,降低操作系统内存回收整理的负担的。
  所以,如果在缓存直接用malloc和free,功能上是没有什么问题的,而且实现也会非常简便容易,但是不断反复malloc和free带来的内存碎片,对长久运行的服务端是很不利的。而slab分配器等于实现了一套自己的内存管理,涉及到对象的分配、回收、同步等操作,自然也大大增加了开发调试的难度了。

3.2 元素查找

  通常快速查找方式有hash方式和二叉树方式查找。在对象比较多的时候hash有着最佳的查找性能,而二叉树在数目有限的元素中也有着很好的性能,这也是这货在当前Linux内核中被大量使用的原因。在memcached中,采用的是hash+链表的典型数据结构,在hash条目少的时候,产生碰撞的元素需要在链表中查询,效率自然会降低,所以memcached设计在一定条件下,会自动进行hash的扩容以保证hash查找的效率。暂时我只用了固定的2^10的hash容量,大多数情况应该都不会是什么严重问题吧。

3.3 元素生命时间

  memcached的元素在创建后可以指定其expire生命周期,是一种lazy的模式:就是不会主动扫描查看哪些元素是否过期,而是在(1)访问元素的时候,检查该元素是否超过了生命周期,如果超过了会释放掉;(2)分配新对象而遇到空闲资源紧张的时候,会扫描对象列表,找出过期的对象删除以释放资源。
  LRU是管理了一个链表,在link、store及update操作的时候,会更新对象的time参数,并将其从LRU列表中先删除后再插入到头部,表示最近访问了。当后续申请新的slab对象而不得的时候,会考虑从链表的尾部删除最没被访问的元素用以满足新对象的需求。

3.4 接口操作支持

  由于是单机的操作,所以数据通信格式会灵活很多,不用考虑字节序、主机间的ascii通信协议等,而key也可以任何的数据类型,只需要在hash计算的时候传递其对应地址和长度就可以了。
  主要支持的缓存操作接口有:
  (1) new: 创建对象,从slab中取出ITEM_SLABBED空闲对象,对象成为ITEM_PENDING的悬垂状态;
  (2) link: 将对象添加到缓存,变为ITEM_LINKED状态;
  (3) unlink:从缓存中撤出,成为ITEM_PENDING的悬垂状态;
  (4) store: 保存或者更新缓存中指定key对于的value,此时如果对象为ITEM_PENDING状态,则挂入链表中,否则就直接更新其值。由于新的值长度可能大于原来分配slab容量大小,所以这里可能会有slab重新分配的过程;
  (5) remove:释放被unlink对象的资源,对象重新变为ITEM_SLABBED空闲状态;
  (6) update:更新对象的访问时间戳,主要关系涉及到LRU淘汰计算;
  (7) destroy: 删除所有的缓存,重新初始化;
  算是基本覆盖满足了的缓存操作需求了。

3.5 新对象请求的流程

  内存就是一个空间换时间的操作,理论上内存越大越好,但现实是不可能的。前面说过,缓存对象是通过slab机制管理的,所以请求新对象实际就是请求空闲(ITEM_SLABBED)的slab对象。根据当前slab状态和系统内存状态,请求机制如下:
  (1) 如果请求对应的slab_class的slots上有空闲对象,那么取出后直接返回;
  (2) 否则,遍历当前slab_class对应的LRU链表,查看是否有expired,如果有就释放掉它,然后返回被释放的空闲对象;
  (3) 否则,如果没有超过内存使用限制,则申请一块大(perslab*size)的内存blk,然后创建perslab个空闲的对象,并加入到空闲链表中,取出一个空闲对象返回;
  (4) 否则,遍历其余的slab_class,看是否可以整理已有对象,释放出blk内存供分配使用,如果释放成功就转入(3)步;
  (5) 否则,查看本slab_class的LRU链表,释放最不常用的对象,然后将释放得到的空闲对象返回;
  (6) 否则,返回NULL表示失败。

3.6 线程安全

  memcached本身是线程安全的,其后端用的线程池模型工作的,所以这点是毫无疑问的,与此同时还提供了CAS机制,保证了某些修改在多个进程/主机间也类似原子操作的。缓存中某个对象同时处于hash表,LRU队列,slab队列中,正所谓加锁容易解锁难,对象的申请,释放,移动等操作交迭,很容易就会有死锁的隐患。为此,memcached为hash容器每个对象分配了一个锁,用于hash中元素的增删,修改操作,为每个slab_class生成一个锁,用于LRU队列的访问和修改操作,而对于slabs本身的操作,则用了一个全局的大颗粒锁来保护,这肯定会影响性能,但却是也没想出什么简洁优化的好办法。不过想想也是,缓存最适宜的工作状态是读多修改少才算好的么,如果程序运行一段时间后仍然频繁有slab的修改操作,是不是考虑要增加内存了。
  多个锁的协同作用,其“黄金法则”就是:加锁的顺序要一致,否则就会导致死锁的发生,同时对于非关键部分(比如资源回收),采用try_lock非阻塞的方式获取锁,增加效率的同时也降低了死锁的概率(我觉得不会死锁,谁能确信地告诉我)。

四、结语

  虽然memcached在众多流行的开源项目中算是比较简单的,但是如果需要真正了解,还是需要一定的时间和精力。自己的minicached走着memcached的路子,目前算是简单搭建了出来,但是要稳定可靠运行,还需要大量的测试和验证。
  做他的原因,是本人感觉到确实是一个很有意义的工具库,当然也可能自己重新造轮子了。希望后续能不断完善,在Redis或者其他项目中好的东西也能扩展引入进来。

本文完!

参考