目录
memcache简介
memcache是目前主流的一个高性能的分布式内存对象缓存系统,它以key-value形式在内存中存储数据,通常用它来减轻底层压力(如数据库负载),提升系统性能,或作为服务器之间数据共享的存储媒介,比如存储分布式session,相对于操作DB而言,操作内存中的数据,不需要解析SQL、磁盘I/O等开销,效率更高。
这里不讨论单一缓存节点,因为高并发情况下单节点可存储的数据量并不一定能满足需求,而且单节点的0容错率意味着宕机后并发压力将直接再次转移到底层(如DB),很可能导致DB负担加重、响应延迟等严重影响。
但memcache的 Server之间又无法通过广播或其它方式同步数据,这么设计的好处虽然从空间和时间上都节约了成本(空间利用率达到了最大化,可存储的数据量= [内存大小*服务器台数];由于不同步数据所以时间上不需要通信成本 )。但缺点也明显,首先需要客户端来保证存取服务器一致;其次由于数据不同步,所以某个节点Down掉后,对它的操作都将重新转移到底层(相当于缓存击穿),这种情况下必须尽量降低单个节点宕机的影响,防止底层负载过高。
从整体上看memcache可以分成两部分:客户端和服务端。客户端决定如何选择存储或检索的服务器,以及在无法联系服务器时(比如宕机或网络问题)要执行的操作;而服务端则负责数据的存储和检索,及内存的释放或重用。
分布式环境下交互的总体流程大致可以用下图表示
memcache工作原理
(1)memcache内存管理
内存结构(数据存储空间slabclass_t 与 slab、chunk、item之间的关系)
memcache在启动时,会初始化一个slabclass_t [200] 数组,每个slabclass_t结构体元素又由一组slab组成(slabclass_t元素初始化时默认都会分配一个1M大小的slab),默认情况下所有slab大小默认都是1M, memcache会根据slabclass_t的size(表示该class中最大可存储的单个Item的大小),把这个slabclass_t下的每个slab切分成相同大小的chunk,也就是说同一slabclass_t中存放的是一组组chunk大小相同的slab,chunk是最终存放数据的空间,而item表示数据的构成,主要包含缓存的key/key长度、value/value长度、缓存时间、引用记数、双向指针等等信息,memcache内部的HashTable和LRU链表结构都依赖于Item。
默认情况下memcache的增长因子是1.25(早期版本中是2),也就是说slabclass_t[1].size == slabclass_t[0].size * 1.25,比如说[0]可存储的单个数据最大是90字节,[1]就可以存储90*1.25字节。关于slabclass_t、slab、chunk可以用下图来简要描述
从这里可以看出,memcache的内部存储结构就是一个大大的hash表(或者也可以说是一个二维数组),从结构上来看它与Java中的HashTable ,HashMap非常的相似,区别的地方在于它链表结构是双向的,而Java中的是单向链表。
其slabclass_t结构如下:
typedef struct {
unsigned int size; /* 此slabclass中的chunk的大小 */
unsigned int perslab; /* chunk个数 */
void *slots; /* list of item ptrs */
unsigned int sl_curr; /* 剩余空闲item */
unsigned int slabs; /* 分配了多少个slab */
void **slab_list; /* slab数组 */
unsigned int list_size; /* size of prev array */
unsigned int killing; /* index+1 of dying slab, or zero if none */
size_t requested; /* The number of requested bytes */
} slabclass_t;
//一个slabclass数组,最大存储200个的slabclass_t结构
static slabclass_t slabclass[MAX_NUMBER_OF_SLAB_CLASSES];
Item对应的具体结构如下
//item的具体结构
typedef struct _stritem {
struct _stritem *next; /* 指向下一个item的指针,主要用于LRU链和freelist链 */
struct _stritem *prev; /* 指向上一个item的指针 */
struct _stritem *h_next; /* hash表的下一个桶 */
rel_time_t time; /* 最后访问时间,用来判断是否失效 */
rel_time_t exptime; /* 有效时间,0表示永久有效,但空间不存时为0也可能被LRU淘汰 */
int nbytes; /* 数据大小 */
unsigned short refcount; /* 引用记数 */
uint8_t nsuffix; /* length of flags-and-length string */
uint8_t it_flags; /* ITEM_* above */
uint8_t slabs_clsid;/* 所属slabs_class */
uint8_t nkey; /* key长度 */
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;
内存分配
memcach的内存分配以slab为单位,默认情况下采用Slab Allocator的机制分配和管理。在该机制出现以前,内存的分配是通过对所有记录简单地进行malloc和free来进行的,这种方式很容易产生内存碎片,降低操作系统内存管理效率,甚至会导致操作系统比memcached进程本身还慢。
前面说过slabclass_t[]数组初始化的时候,每个slabclass_t都会分配一个1M大小的slab,slab又会被切分为N个小的内存块chunk,这个小的内存块的大小取决于slabclass_t结构上的size的大小。 Slab Allocator的基本原理依赖于这个预先分配的大小,从分配逻辑上可以分成以下几步:
1.根据存放Item的大小决定存储的slabclass_t,如果item>1M则丢弃;
2.顺序查找这个slabclass_t中,是否存在可用chunk;
2.1 查找slabclass_t的slots(代表一个空闲列表freelist)中是否有可用chunk,如果有则占用其中一个chunk。
new_slab、delete或get失效Item时这三种情况下,都会把对应chunk标记到空闲列表中,表示可用。
2.2 如果没有,则分配一个新的slab给当前slabclass_t,并放进freelist(空闲列表中),然后再从slots中取chunk
2.3 如果没有,但分配失败(如内存不够),则执行LRU策略(关于LRU的相关策略下方会有详细描述)。
分配相关的源码如下
/* 分配Item(size表示新item的大小,id表示slabclass_t[]数组下标 */
static void *do_slabs_alloc(const size_t size, unsigned int id) {
slabclass_t *p;
void *ret = NULL;
item *it = NULL;
if (id < POWER_SMALLEST || id > power_largest) {
MEMCACHED_SLABS_ALLOCATE_FAILED(size, 0);
return NULL;
}
p = &slabclass[id]; /* 获取slabclass_t */
assert(p->sl_curr == 0 || ((item *)p->slots)->slabs_clsid == 0);
/* fail unless we have space at the end of a recently allocated page,
we have something on our freelist, or we could allocate a new page */
if (! (p->sl_curr != 0 || do_slabs_newslab(id) != 0)) {/* 如果没有空闲的item,则分配一个新的slab,如果分配失败,则返回Null */
/* We don't have more memory available */
ret = NULL;
} else if (p->sl_curr != 0) { /* 如果有空闲item,则从空闲的列表中取一个Item */
/* return off our freelist */
it = (item *)p->slots;
p->slots = it->next;
if (it->next) it->next->prev = 0;
p->sl_curr--;
ret = (void *)it;
}
if (ret) {
p->requested += size;
MEMCACHED_SLABS_ALLOCATE(size, id, p->size, ret);
} else {
MEMCACHED_SLABS_ALLOCATE_FAILED(size, id);
}
return ret;
}
/* 分配一个新的slab给slabclass_t(id表示slabclass_t[]下标) */
static int do_slabs_newslab(const unsigned int id) {
slabclass_t *p = &slabclass[id];
/* 分配一个slab,大小默认1M,个数根据slabclass_t的size计算出来 */
int len = settings.slab_reassign ? settings.item_size_max
: p->size * p->perslab;
char *ptr;
if ((mem_limit && mem_malloced + len > mem_limit && p->slabs > 0) ||
(grow_slab_list(id) == 0) ||
((ptr = memory_allocate((size_t)len)) == 0)) {
MEMCACHED_SLABS_SLABCLASS_ALLOCATE_FAILED(id);
return 0;
}
memset(ptr, 0, (size_t)len);
/* 分割slab,并放进freelist中(对应slabclass_t->slots) */
split_slab_page_into_freelist(ptr, id);
p->slab_list[p->slabs++] = ptr;
mem_malloced += len;
MEMCACHED_SLABS_SLABCLASS_ALLOCATE(id);
return 1;
}
/* 将slab切争成N个Item,将每个item放入free list 中,*/
static void split_slab_page_into_freelist(char *ptr, const unsigned int id) {
slabclass_t *p = &slabclass[id];
int x;
for (x = 0; x < p->perslab; x++) {
do_slabs_free(ptr, 0, id);
ptr += p->size;
}
}
/* 释放item, 将item放入空闲列表free list中,并不是真正意义上的释放内存块 */
static void do_slabs_free(void *ptr, const size_t size, unsigned int id) {
slabclass_t *p;
item *it;
assert(((item *)ptr)->slabs_clsid == 0);
assert(id >= POWER_SMALLEST && id <= power_largest);
if (id < POWER_SMALLEST || id > power_largest)
return;
MEMCACHED_SLABS_FREE(size, id, ptr);
p = &slabclass[id];
it = (item *)ptr;
it->it_flags |= ITEM_SLABBED;
it->prev = 0;
it->next = p->slots;
if (it->next) it->next->prev = it;
p->slots = it;
p->sl_curr++;
p->requested -= size;
return;
}
最后,虽然Slab Allocator机制解决了内存碎片的问题,但由于chunk大小的固定,所以同时也产生了内存浪费的情况,比如在memcached -p 11212 -m 128m -vv -u root -f 10分配情况下(-m参数用于指定分配内存大小;-f 表示增长因子,早前版本中固定值2,现在默认值为1.25,比较适合存储1kb以下的Item)。 如果存储数据总共是50个字节,它将进入slab class 1,损失46个字节。如果数据总共100个字节,将存入slab class 2,损失860个字节。(这里也看出增长因子的合理设置很重用,它将直接影响内存的使用率)
增长因子-f设置的值越小,slabclass_t之间的chunk差异就越小,内存浪费的情况就越少,但这可能会降低chunk的利用率。所以总结上来说关于内存优化有几个点
1. 为了减少内存浪费,可以通过参数- f 设置较低增长因子,极端情况下可以设置chunk=item。
2. 为了提高chunk的使用率,可以通过参数- n 设置chunk的初始值,极端情况下如slab只存一个数据,则可以设置slab=chunk=1M
内存回收方式
Lazy Expiration(惰性失效)
在1.5.0版本之前,memcached内部不会监视记录是否过期,而是在get时检查记录是否过期。这种技术被称为lazy(惰性)expiration。因此,memcached不会在过期监视上耗费CPU时间,这样可以减轻服务器的负载。从官方描述中看,在1.5.0版本之后,memcache提供了LRU爬虫机制(默认关闭),在一个单独的线程内通过爬虫自动监控并清理失效的item,这类似于Java的垃圾回收机制一样,虽然从时间成本上来说会消耗一定的CPU时间,但却能适时的释放空间,提高内存使用率。
LRU算法(LRU淘汰)
当memcached的内存空间不足时(无法从slab class 获取到新的空间时),memcache采用LRU算法根据设置的失效时间清理失效的缓存数据,或者从最近未被使用的记录中搜索,并将其空间分配给新的记录,需要注意的是,如果禁用LRU(memcache启动时没有指定-M)的情况下内存不够会报出Out Of Memory错误。
上面已经说过一部分关于Item内存分配过程,这里续上,当最终没有足够内存分配给Item时,memcache将执行LRU来获取空间。首先从slab的尾部LRU链表tail[]开始搜索,检查该链表中是否有无效item,如果有则清除掉,将该空间用于存储新的Item,此操作最多尝试50次。如果没有无效Item,则看是否有开启强制淘汰,如果开启了,则无条件清除最后一个Item,即使该Item被设置成永久有效。如果没有开启,则抛出Out Of Memory错误。
对应源码如下(1.5以后)
item *do_item_alloc_pull(const size_t ntotal, const unsigned int id) {
item *it = NULL;
int i;
/* If no memory is available, attempt a direct LRU juggle/eviction */
/* This is a race in order to simplify lru_pull_tail; in cases where
* locked items are on the tail, you want them to fall out and cause
* occasional OOM's, rather than internally work around them.
* This also gives one fewer code path for slab alloc/free
*/
for (i = 0; i < 10; i++) { /* 迭代10次 */
uint64_t total_bytes;
/* 如果没有启用强制淘汰,则首先尝试回收内存 */
if (!settings.lru_segmented) {
lru_pull_tail(id, COLD_LRU, 0, 0, 0, NULL);
}
/* 分配item内存(其中可能会分配新的slab) */
it = slabs_alloc(ntotal, id, &total_bytes, 0);
if (settings.temp_lru)
total_bytes -= temp_lru_size(id);
/* 分配失败 */
if (it == NULL) {
/* 执行LRU淘汰机制 */
if (lru_pull_tail(id, COLD_LRU, total_bytes, LRU_PULL_EVICT, 0, NULL) <= 0) {
if (settings.lru_segmented) {
lru_pull_tail(id, HOT_LRU, total_bytes, 0, 0, NULL);
} else {
break;
}
}
} else {
break;
}
}
if (i > 0) {
pthread_mutex_lock(&lru_locks[id]);
itemstats[id].direct_reclaims += i;
pthread_mutex_unlock(&lru_locks[id]);
}
return it;
}
分配流程大致可以用下图来表示
(2)memcache分布式
分布式实现原理
分布式实现原理在上文其实已经说过了,目前memcache多个Server之间互不通信,各自保存的数据也不同,每个Server只对自己管理数据负责。所以memcache分布式是通过客户端实现的,采用了单进程、单线程、异步I/O,基于事件(event_based)的服务方式,使用libevent作为事件通知实现。
Client端通过IP地址和端口号指定Server端,将需要缓存的数据是以key->value对的形式保存在Server端。key的值通过hash进行转换,根据hash值把value传递到对应的具体的某个Server上。当需要获取对象数据时,也根据key进行。首先对key进行hash,通过获得的值可以确定它被保存在了哪台Server上,然后再向该Server发出请求。Client端只需要知道保存hash(key)的值在哪台服务器上就可以保证存取服务器一致。
分布式路由算法
余数算法:用键的哈希值%服务器台数,根据余数确定存取服务器,这种方法计算简单高效,但在memcached服务器增加或减少时,几乎所有的缓存都无法命中,这是余数算法一个致命问题。这里我用不同端口代表不同Server进行了一个简单的测试,开启了11211 11311 11411三台Server,对应如下
Java测试代码如下
//获取服务器配置,并注册
CacheConfig cfg = new CacheConfig();
cfg.setServerAddress("192.168.80.128:11211,192.168.80.128:11311,192.168.80.128:11411");
TvlCache cache = TvlCacheFactory.getCache(cfg);
//存
cache.add("0", "0"); //48%3=0,对应11211端口服务器
cache.add("1", "1"); //对应11311
cache.add("2", "2"); //对应11411
//休眠3s,停掉11411
Thread.sleep(3000);
//取
System.out.println(cache.get("2")); //50%2=0,对应到11211端口服务器,print:null
不停任何节点的情况下,key"2"存取Server一致,对应11411Server。
如果在Thread.sleep(3000)期间停掉11411,对应于下
Consistent Hashing散列算法(一致性哈希算法):首先求出memcached服务器(节点)的哈希值,并将其配置到0~的圆(continuum)上。然后用同样的方法求出存储数据的键的哈希值,并映射到圆上。然后从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务器上。如果超过仍然找不到服务器,就会保存到第一台memcached服务器上。
如果需要从上图的状态中添加一台memcached服务器。采用余数算法会由于服务器数量的变化而对缓存命中率产生巨大影响,但Consistent Hashing中,只有在continuum上增加服务器的地点逆时针方向的第一台服务器上的键会受到影响。
memcache高级特性及总结
经过相关测试及大量查询memcache官方wiki(https://github.com/memcached/memcached/wiki),这里对相关内容进行一个整理说明,以下内容都可以在官方wiki上找到相关说明。
(1)memcached服务端是不安全的,不应该直接暴露给互联网,或任何不受信任的用户,最好配置到内网环境,从物理上进行隔离。否则在已知某个memcache节点的情况下,完全可以直接telnet,加flush_all命令让所有键值对失效。
虽然在版本1.4.3之后,官方提供了SASL(一种与协议无关的方式向协议添加身份验证机制的标准)来验证客户端身份,但官方建议不应该完全依赖它。
(2)memcache 是典型的单进程多线程模型,采用libevent处理网络请求,主进程负责将新来的连接分配给work线程,work线程负责处理连接,从这点看它更像是nginx而不是apache,通过主进程分发到对应的工作线程。
默认情况下,memcache内部只分配了4个work线程,但由于采用libevent处理网络请求,且Server端高效的Hash算法,所以并发情况下每个线程仍能够处理足够多的请求。除非说4个线程情况下memcache处理起来压力较大,否则官方不建议将值设置得更高,官方测试结果如果设置成80以上会严重影响memcache性能。关于更多memcache网络连接模型可以参考这篇博客:https://www.jianshu.com/p/16295a1f1cd2
(3)memcache默认最大并发连接数为1024(不是线程数)。通过stats命令的“listen_disabled_num” 项可以查看当前memcache实例连接使用情况,该值应为零或接近零,个人认为高并发情况下这值能反应出一定的memcache的处理性能,也应该是memcache监控的一个重要指标。
(4)理论上来说memcache中可以保存的item数据量是没有限制的,只要内存足够。但memcache单进程在32位机中最大使用内存为2G,64位机则没有限制。此外由于内存申请是以slab为单位,slab大小默认为1M,所以单个item最大数据是1MB,超过1MB的数据不予存储(如果需要存储大集合等数据,建议拆分存储但记忆存储的关系)
memcached无法支持在不锁定所有其他操作的情况下安全地遍历密钥的能力,虽然官方说添加索引,多版本化等可以实现这一点,但这种操作会降低内存和CPU效率,违背了memcached的初衷。所以目前的情况是无法遍历memcache中所有的item。
(5)memcached中所有命令都是原子性的,并发执行时只是最后一个覆盖掉前面的值而已。
(6)设置成永不过期,也会在30天之后失效。(版本源码)
简要总结:memcache的高性能源自于两阶段哈希结构:第一阶段在客户端,通过Hash算法根据Key值算出一个节点;第二阶段在服务端,通过一个内部的Hash算法,查找真正的item并返回给客户端。从实现的角度看,MemCache是一个非阻塞的、基于事件的服务器程序。
memcache相关缺陷
1. memcache无法备份和持久化,崩溃或重启之后数据将全部丢失。
2. memcache存在内存空间浪费的情况,且LRU是基于slab进行扫描,而不是全局。
3. 集群环境下,扩容或减少机器会影响缓存命中率,有一定的风险成本。
4. 服务端是不安全的