Memcached分析特点

Memcached是一种分布式内存对象缓存系统,主要用于数据库前端cache,提供高并发读写性能。它使用自定义内存管理方式,每个进程可管理2GB内存,通过内存块(chunk)管理数据,适用于非持久化的数据存储。性能瓶颈主要在网络连接,而非内存或CPU。 Memcached的工作方式包括守护进程、自定义协议,并采用NewHash算法进行哈希表操作。内存管理中,chunk大小根据初始值和factor动态计算,以优化空间利用率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Memcached是danga.com(运营LiveJournal的技术团队)开发的一套分布式内存对象缓存系统,用于在动态系统中减少数据库负载,提升性能。 

1、介绍

(1)说明

Memcached是分布式的,基于网络连接(当然它也可以使用localhost)方式完成服务,本身它是一个独立于应用的程序。

Memcached使用libevent库实现网络连接服务,理论上可以处理无限多的连接,但是它和Apache不同,它更多的时候是面向稳定的持续连接的,所以它实际的并发能力是有限制的。在保守情况下memcached的最大同时连接数为200,这和Linux线程能力有关系,这个数值是可以调整的。

memcachd有自己的内存分配算法和管理方式,通常情况下,每个memcached进程可以管理2GB的内存空间,如果需要更多的空间,可以增加进程数。

(2)本地缓存与memcached

Memcached是“分布式”的内存对象缓存系统,那么就是说,那些不需要“分布”的,不需要共享的,或者干脆规模小到只有一台服务器的应用,memcached不会带来任何好处,相反还会拖慢系统效率,因为网络连接同样需要资源,即使是UNIX本地连接也一样。 

在之前的测试数据中显示,memcached本地读写速度要比直接PHP内存数组慢几十倍,而APC、共享内存方式都和直接数组差不多。可见,如果只是本地级缓存,使用memcached是非常不划算的。

(3)作为cache

Memcached在很多时候都是作为数据库前端cache使用的。因为它比数据库少了很多SQL解析、磁盘操作等开销,而且它是使用内存来管理数据的,所以它可以提供比直接读取数据库更好的性能,在大型系统中,访问同样的数据是很频繁的,memcached可以大大降低数据库压力,使系统执行效率提升。

memcached使用内存管理数据,所以它是易失的,当服务器重启,或者memcached进程中止,数据便会丢失,所以memcached不能用来持久保存数据。

应用上,memcached可作为服务器之间数据共享的存储媒介,例如在SSO系统中保存系统单点登陆状态的数据就可以保存在memcached中,被多个应用共享。

(4)性能

memcached使用内存并不会得到成百上千的读写速度提高(跟硬盘相比)。它和使用磁盘的数据库系统相比,好处在于没有过多的io开销,进程本身也不占用多少cpu资源,利于处理非常大的数据交换量。

实际瓶颈在于网络连接,所以经常会出现两条千兆网络带宽都满负荷了。

2、工作方式

(1)守护进程

Memcached是传统的网络服务程序,如果启动的时候使用了-d参数,它会以守护进程的方式执行。

创建守护进程,代码如下(daemon.c,版本1.2.1):

 fork 子进程,退出父进程,重定向 到空设备文件(STDIN 、 STDOUT 、 STDERR )

#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h> 
int daemon(int nochdir, int noclose)
{   
    int fd; 
    switch (fork()) {
    case -1://fork错误
        return (-1);
    case 0: //子进程
        break;  
    default://父进程
        _exit(0);
    } 
   //调用setsid函数的进程成为新的会话的领头进程,并与其父进程的会话组和进程组脱离。
   //由于会话对控制终端的独占性,进程同时与控制终端脱离
    if (setsid() == -1)
        return (-1); 
    if (!nochdir)//设置当前目录到根目录
        (void)chdir("/"); 
    if (!noclose && (fd = open("/dev/null", O_RDWR, 0)) != -1) {//获取空设备文件句柄
        (void)dup2(fd, STDIN_FILENO);//重定向STDIN到空设备文件
        (void)dup2(fd, STDOUT_FILENO);//重定向STDOUT到空设备文件
        (void)dup2(fd, STDERR_FILENO);//重定向STDERR到空设备文件
        if (fd > STDERR_FILENO)
            (void)close(fd);
    }
    return (0);
} 

(2)启动过程

main 函数中执行步骤(memcached.c):

1>调用 settings_init() 设定初始化参数
2 >从启动命令中读取参数来设置 setting 值
3 >设定 LIMIT 参数
4 >开始网络 socket 监听(如果非 socketpath 存在)(1.2 之后支持 UDP 方式)
5 >检查用户身份( Memcached 不允许 root 身份启动)
6 >如果有 socketpath 存在,开启 UNIX 本地连接(Sock 管道)
7 >如果以 -d 方式启动,创建守护进程(如上调用 daemon 函数)
8 >初始化 item 、 event 、状态信息、 hash 、连接、 slab
9 >如设置 managed 生效,创建 bucket 数组
10> 检查是否需要锁定内存页
11 >初始化信号、连接、删除队列
12 >如果 daemon 方式,处理进程 ID

13 >event 开始,启动过程结束, main 函数进入循环。

启动方式

# /usr/local/bin/memcached -d -m 10 -u root -l 192.168.0.200 -p 12000 -c 256 -P /tmp/memcached.pid 
-d选项是启动一个守护进程, 
-m是分配给Memcache使用的内存数量,单位是MB,这里是10MB, 
-u是运行Memcache的用户,这里是root, 
-l是监听的服务器IP地址,如果有多个地址的话,这里指定了服务器的IP地址192.168.0.200, 
-p是设置Memcache监听的端口,这里设置了12000,最好是1024以上的端口, 
-c选项是最大运行的并发连接数,默认是1024,这里设置了256,按照服务器的负载量来设定, 
-P是设置保存Memcache的进程id文件,这里是保存在 /tmp/memcached.pid, 

结束方式 
# kill `cat /tmp/memcached.pid` 

(3)自定义协议

Memcached 使用一套自定义的协议通信

在API中,换行符号统一为\r\n

3、内存管理方式

Memcache使用了Slab Allocator的内存分配机制:按照预先规定的大小,将分配的内存分割成特定长度的块,以完全解决内存碎片问题。
Memcache的存储涉及到slab,page,chunk三个概念 
1.Chunk为固定大小的内存空间,默认为96Byte。 
2.page对应实际的物理空间,1个page为1M。 
3.同样大小的chunk又称为slab。 
Memcached启动的时候根据-n和-f参数,产生若干slab。具体应用中Memcache每次申请1page,并将这1M空间分割成若干个chunk,这些chunk有着同样的大小,属于同一个slab。 

(1)内存设计

1)添加内存块Chunk
通过memcache添加item的时候: 
1. Memcache计算item的大小(key+value+flags),选取合适的slab(刚好能放下该item的slab) 
2. 如果这个item对应的slab未出现过,则申请1个page(注意,这1M空间不论是否达到memcached使用内存都可以申请成功)并加该item存入slab中的chunk 
3. 如果item对应的slab出现过,则在该slab中优先选择expired(free_chunks)和delete(在1.2.2中delete的chunk存在着不能被重复利用的问题)的chunk进行存储,其次将选择未使用过的chunk(free_chunks_end)进行存储。 
4. 如果item对应的slab出现过,但是对应的slab已经存储满了,那么会申请一个新的page,这个page被分为对应大小的chunk,继续存储。 
5. 如果item对应的slab出现过,但是对应的slab已经存储满了并且memcache也达到了最大内存使用。将使用lru算法,清除item(可能将未过期的item清除) 

2)删除内存块Chunk 
1. Delete操作只是将该chunk置为删除状态,这样在下次使用将优先利用这样的chunk。 
3)内存失效
1. Flush操作相当于将所有的item失效的一个动作。并不会改变memcache内存分配情况。
注意: 
1. memcache已经分配的内存不会再主动清理。 
2. memcache分配给某个slab的内存页不能再分配给其他slab。 
3. flush_all不能重置memcache分配内存页的格局,只是给所有的item置为过期。 
4. memcache最大存储的item(key+value)大小限制为1M,这由page大小1M限制 
5.由于memcache的分布式是客户端程序通过hash算法得到的key取模来实现,不同的语言可能会采用不同的hash算法,同样的客户端程序也有可能使用相异的方法,因此在多语言、多模块共用同一组memcached服务时,一定要注意在客户端选择相同的hash算法 
6.启动memcached时可以通过-M参数禁止LRU替换,在内存用尽时add和set会返回失败 
7.memcached启动时指定的是数据存储量,没有包括本身占用的内存、以及为了保存数据而设置的管理空间。因此它占用的内存量会多于启动时指定的内存分配量,这点需要注意。 
8.memcache存储的时候对key的长度有限制,php和C的最大长度都是250

(2)内存块

1)内存申请

memcached一次申请内存的最小单位为一个slab,一个slab的大小默认为1048576字节(1MB)。

每一个slab被划分为若干个chunk,每个chunk里保存一个item,每个item同时包含了item结构体、key和value(注意在memcached中的value是只有字符串的)。

slab按照自己的id分别组成链表,链表按id挂在一个slabclass数组上(slabclass的长度在1.1中是21,在1.2中是200)。

2)内存块大小

slab有一个初始块 ,1.1中是1字节,1.2中是80字节,1.2中有一个factor值,默认为1.25。

3)内存块初始化

在1.1中,chunk大小表示为初始大小*2^n,n为classid,即:id为0的slab,每chunk大小1字节,id为1的slab,每chunk大小2字节,id为2的slab,每chunk大小4字节……id为20的slab,每chunk大小为1MB,就是说id为20的slab里只有一个chunk。

void slabs_init(size_t limit) {
    int i;
    int size=1;
 
    mem_limit = limit;
    for(i=0; i<=POWER_LARGEST; i++, size*=2) {
        slabclass[i].size = size;
        slabclass[i].perslab = POWER_BLOCK / size;
        slabclass[i].slots = 0;
        slabclass[i].sl_curr = slabclass[i].sl_total = slabclass[i].slabs = 0;
        slabclass[i].end_page_ptr = 0;
        slabclass[i].end_page_free = 0;
        slabclass[i].slab_list = 0;
        slabclass[i].list_size = 0;
        slabclass[i].killing = 0;
    }
 
    /* for the test suite:  faking of how much we've already malloc'd */
    {
        char *t_initial_malloc = getenv("T_MEMD_INITIAL_MALLOC");
        if (t_initial_malloc) {
            mem_malloced = atol(getenv("T_MEMD_INITIAL_MALLOC"));
        }
    }
 
    /* pre-allocate slabs by default, unless the environment variable
       for testing is set to something non-zero */
    {
        char *pre_alloc = getenv("T_MEMD_SLABS_ALLOC");
        if (!pre_alloc || atoi(pre_alloc)) {
            slabs_preallocate(limit / POWER_BLOCK);
        }
    }
}

在1.2中,chunk大小表示为初始大小*f^n,f为factor,在memcached.c中定义,n为classid,201个头不是全部都要初始化的,因为factor可变,初始化只循环到计算出的大小达到slab大小的一半为止,而且它是从id1开始的,即:id为1的slab,每chunk大小80字节,id为2的slab,每chunk大小80*f,id为3的slab,每chunk大小80*f^2,初始化大小有一个修正值CHUNK_ALIGN_BYTES,用来保证n-byte排列 (保证结果是CHUNK_ALIGN_BYTES的整倍数)。

memcached1.2会初始化到id40的slab,每个slab中有两个chunk,其中每个chunk大小为504692,最后再补足一个id41,它是整块的,也就是这个slab中只有一个1MB大的chunk。

初始化如下:

void slabs_init(size_t limit, double factor) {
    int i = POWER_SMALLEST - 1;
    unsigned int size = sizeof(item) + settings.chunk_size;
 
    /* Factor of 2.0 means use the default memcached behavior */
    if (factor == 2.0 && size < 128)
        size = 128;
 
    mem_limit = limit;
    memset(slabclass, 0, sizeof(slabclass));
 
    while (++i < POWER_LARGEST && size <= POWER_BLOCK / 2) {
        /* Make sure items are always n-byte aligned */
        if (size % CHUNK_ALIGN_BYTES)
            size += CHUNK_ALIGN_BYTES - (size % CHUNK_ALIGN_BYTES);
 
        slabclass[i].size = size; 
        slabclass[i].perslab = POWER_BLOCK / slabclass[i].size;
        size *= factor; 
        if (settings.verbose > 1) {
            fprintf(stderr, "slab class %3d: chunk size %6d perslab %5d\n",
                    i, slabclass[i].size, slabclass[i].perslab);
        }       
    }
 
    power_largest = i;
    slabclass[power_largest].size = POWER_BLOCK;
    slabclass[power_largest].perslab = 1;
 
    /* for the test suite:  faking of how much we've already malloc'd */
    {
        char *t_initial_malloc = getenv("T_MEMD_INITIAL_MALLOC");
        if (t_initial_malloc) {
            mem_malloced = atol(getenv("T_MEMD_INITIAL_MALLOC"));
        }       
 
    }
 
#ifndef DONT_PREALLOC_SLABS
    {
        char *pre_alloc = getenv("T_MEMD_SLABS_ALLOC");
        if (!pre_alloc || atoi(pre_alloc)) {
            slabs_preallocate(limit / POWER_BLOCK);
        }
    }
#endif
}

由上可以看出,memcached的内存分配是有冗余的,当一个slab不能被它所拥有的chunk大小整除时,slab尾部剩余的空间就被丢弃了,如id40中,两个chunk占用了1009384字节,这个slab一共有1MB,那么就有39192字节被浪费了。

Memcached使用这种方式来分配内存,是为了可以快速的通过item长度定位出slab的classid,有一点类似hash,因为item的长度是可以计算的,比如一个item的长度是300字节,在1.2中就可以得到它应该保存在id7的slab中,因为按照上面的计算方法,id6的chunk大小是252字节,id7的chunk大小是316字节,id8的chunk大小是396字节,表示所有252到316字节的item都应该保存在id7中。同理,在1.1中,也可以计算得到它出于256和512之间,应该放在chunk_size为512的id9中(32位系统)。

Memcached初始化的时候,会初始化slab(在main函数中调用了的函数slabs_init())。

函数slabs_init()中的所有已经定义过的slabclass中,每一个id创建一个slab。

1.2在默认的环境中启动进程后要分配41MB的slab空间,因为有可能一个id根本没有被使用过,但是它也默认申请了一个slab,每个slab会用掉1MB内存

当一个slab用光后,又有新的item要插入这个id,那么它就会重新申请新的slab,申请新的slab时,对应id的slab链表就要增长,这个链表是成倍增长的,在函数grow_slab_list函数中,这个链的长度从1变成2,从2变成4,从4变成8……:

在定位item时,使用slabs_clsid函数,传入参数为item大小,返回值为classid。

item总是小于或等于chunk大小的,当item小于chunk大小时,会发生了空间浪费。


(3)内存管理结构

Memcached的数据保存基于一个hash表(primary_hashtable),其成员为数据项item。

数据项的地址是slab中的chunk偏移,其定位是依靠对key做hash的结果。(在assoc.c和items.c中定义了hash和item操作)

item是数据项(items.c),包含部分:
key:键
nkey:键长
flags:用户定义的flag(其实这个flag在memcached中没有启用)
nbytes:值长(包括换行符号\r\n)
suffix:后缀Buffer
nsuffix:后缀长

item长度为:键长+值长+后缀长+item结构大小(32字节),根据item长度来计算slab的classid。


其哈希表使用NewHash算法,效率很高。(1.1和1.2的NewHash有一些不同,主要的实现方式还是一样的,1.2的hash函数是经过整理优化的。NewHash的原型参考:http://burtleburtle.net/bob/hash/evahash.html)


 hashtable表长为hashsize(HASHPOWER),就是4MB(hashsize是一个宏,表示1右移n位),1.2中是变量16,即hashtable表长65536,1.1中HASHPOWER常量为20:
typedef  unsigned long  int  ub4;   /* unsigned 4-byte quantities */
typedef  unsigned       char ub1;   /* unsigned 1-byte quantities */
 
#define hashsize(n) ((ub4)1<<(n))
#define hashmask(n) (hashsize(n)-1)

hash表操作:

assoc_init(),assoc_find()、assoc_expand()、assoc_move_next_bucket()、assoc_insert()、assoc_delete()。

其中assoc_find()是根据key和key长寻找对应的item地址的函数,返回的是item结构指针(其地址在slab中的某个chunk上)。

数据项操作:

item_init()初始化了heads、tails、sizes三个数组为0,三个数组的大小都为常量LARGEST_ID(默认为255,这个值需要配合factor来修改)

item_assoc()会先尝试从slab中获取一块空闲的chunk,如果没有可用的chunk,会在链表中扫描50次,以得到一个被LRU踢掉的item,将它unlink,然后将需要插入的item插入链表中。

(4)内存回收

使用引用计数(item的refcount成员)。item被unlink之后只是从链表上摘掉,不是立刻就被free的,只是将它放到删除队列中(item_unlink_q()函数)。
item对应的操作,包括remove、update、replace,当然最重要的就是alloc操作。

item还有一个特性就是它有过期时间,这是memcached的一个很有用的特性,很多应用都是依赖于memcached的item过期,比如session存储、操作锁等。item_flush_expired()函数就是扫描表中的item,对过期的item执行unlink操作,当然这只是一个回收动作,实际上在get的时候还要进行时间判断:
/* expires items that are more recent than the oldest_live setting. */
void item_flush_expired() {
    int i;  
    item *iter, *next;
    if (! settings.oldest_live)
        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) { 
            if (iter->time >= settings.oldest_live) {
                next = iter->next;
                if ((iter->it_flags & ITEM_SLABBED) == 0) { 
                    item_unlink(iter);
                }       
            } else {
                /* We've hit the first old item. Continue to the next queue. */
                break;  
            }       
        }       
    }
}
/* wrapper around assoc_find which does the lazy expiration/deletion logic */
item *get_item_notedeleted(char *key, size_t nkey, int *delete_locked) {
    item *it = assoc_find(key, nkey);
    if (delete_locked) *delete_locked = 0;
    if (it && (it->it_flags & ITEM_DELETED)) {
        /* it's flagged as delete-locked.  let's see if that condition
           is past due, and the 5-second delete_timer just hasn't
           gotten to it yet... */
        if (! item_delete_lock_over(it)) {
            if (delete_locked) *delete_locked = 1;
            it = 0; 
        }       
    }
    if (it && settings.oldest_live && settings.oldest_live <= current_time &&
        it->time <= settings.oldest_live) {
        item_unlink(it);
        it = 0; 
    }
    if (it && it->exptime && it->exptime <= current_time) {
        item_unlink(it);
        it = 0; 
    }
    return it;
}


4、特点和限制

(1) 工作参数

1.最大30天的过期时间: 常量REALTIME_MAXDELTA 60*60*24*30
2.最大同时连接数: conn_init()中的freetotal(=200)
3.最大键长: 常量KEY_MAX_LENGTH 250

4.factor将影响chunk的步进大小: settings.factor(=1.25)

5.最大软连接: settings.maxconns(=1024)
6.块的大小: settings.chunk_size(=48)
一个保守估计的key+value长度,用来生成id1中的chunk长度(1.2)。id1的chunk长度等于这个数值加上item结构体的长度(32),即默认的80字节。

7.classid

常量POWER_SMALLEST 1

最小classid(1.2)
常量POWER_LARGEST 200
最大classid(1.2)
8)默认slab大小: 常量POWER_BLOCK 1048576,其中会常量CHUNK_ALIGN_BYTES (sizeof(void *))保证chunk大小是这个数值的整数倍,字节对齐,(void *的长度在不同系统上不一样,在标准32位系统上是4)
9)队列刷新间隔: 常量ITEM_UPDATE_INTERVAL 60,常量LARGEST_ID 255
10)最大item链表数: 这个值不能比最大的classid小
11)hashtable的大小: 变量hashpower决定(在1.1中是常量HASHPOWER)

(2)特点总结

1)在 Memcached中可以保存的item数据量是没有限制的,只要内存足够。
2)假设NewHash算法碰撞均匀,查找item的循环次数是item总数除以hashtable大小(由hashpower决定),是线性的。
3)Memcached限制了可以接受的最大item是1MB,大于1MB的数据不予理会。

4)Memcached的空间利用率和数据特性有很大的关系,又与DONT_PREALLOC_SLABS常量有关。 在最差情况下,有198个slab会被浪费(所有item都集中在一个slab中,199个id全部分配满)

5)Memcached单进程最大使用内存为2G,要使用更多内存,可以分多个端口开启多个Memcached进程,最大30天的数据过期时间,设置为永久的也会在这个时间过期 

6)常量REALTIME_MAXDELTA 60*60*24*30控制最大键长为250字节,大于该长度无法存储 
7)常量KEY_MAX_LENGTH 250控制单个item最大数据是1MB,超过1MB数据不予存储 
8)常量POWER_BLOCK 1048576进行控制,它是默认的slab大小 最大同时连接数是200,通过 conn_init()中的freetotal进行控制,最大软连接数是1024,通过settings.maxconns=1024 进行控制跟空间占用相关的参数:settings.factor=1.25, settings.chunk_size=48, 影响slab的数据占用和步进方式memcached是一种无阻塞的socket通信方式服务,基于libevent库,由于无阻塞通信,对内存读写速度非常之快。 
9)memcached是键值一一对应,key默认最大不能超过128个字 节,value默认大小是1M,也就是一个slabs,如果要存2M的值(连续的),不能用两个slabs,因为两个slabs不是连续的,无法在内存中 存储。 

5、定长优化

 Memcached本身是为变长数据设计的,根据数据特性,可以说它是“面向大众”的设计,但是很多时候,我们的数据并不是这样的“普遍”,典型的情况中,一种是非均匀分布,即数据长度集中在几个区域内(如保存用户 Session);或者是等长数据(如定长键值,定长数据,多见于访问、在线统计或执行锁)。
解决定长数据,首先需要解决的是slab的分配问题,第一个需要确认的是我们不需要那么多不同chunk长度的slab,为了最大限度地利用资源,最好chunk和item等长。
item的长度是字符串长度加上item头结构的长度32字节。
以下假设item长度是200字节。

在原始版本中,chunk长度和classid是有对应关系的,现在如果把所有的chunk都定为200个字节,那么这个关系就不存在了,我们需要重新确定这二者的关系。

方法一:整个存储结构只使用一个固定的id,即只使用199个槽中的1个,在这种条件下,就一定要定义DONT_PREALLOC_SLABS来避免另外的预分配浪费。

方法二:不使用长度来做键,可以使用key的NewHash结果或者直接根据key来做hash(定长数据的key也一定等长)。

第一种方法的不足之处在于只使用一个id,在数据量非常大的情况下,slab链会很长(因为所有数据都挤在一条链上了),遍历起来的代价比较高。

前面介绍了三种空间冗余,设置chunk长度等于item长度,解决了第一种空间浪费问题,不预申请空间解决了第二种空间浪费问题,那么对于第一种问题(slab内剩余)如何解决呢,这就需要修改POWER_BLOCK常量,使得每一个slab大小正好等于chunk长度的整数倍,这样一个slab就可以正好划分成n个chunk。这个数值应该比较接近1MB,过大的话同样会造成冗余,过小的话会造成次数过多的alloc,根据chunk长度为200,选择1000000作为POWER_BLOCK的值,这样一个slab就是100万字节,不是1048576。三个冗余问题都解决了,空间利用率会大大提升。


修改 slabs_clsid 函数,让它直接返回一个定值(比如 1 ):
unsigned int slabs_clsid(size_t size) {
        return 1;
}

修改slabs_init函数,去掉循环创建所有classid属性的部分,直接添加slabclass[1]:
slabclass[1].size = 200;                //每chunk 200字节

slabclass[1].perslab = 5000;        //1000000/200



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值