Memcached 源码分析--命令流程分析

Posted andyhuabing

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Memcached 源码分析--命令流程分析相关的知识,希望对你有一定的参考价值。

一、执行命令

首先是启动memcached 自带参数如下:

<span style="font-size:18px;">-p <num>      设置TCP端口号(默认设置为: 11211)
-U <num>      UDP监听端口(默认: 11211, 0 时关闭) 
-l <ip_addr>  绑定地址(默认:所有都允许,无论内外网或者本机更换IP,有安全隐患,若设置为127.0.0.1就只能本机访问)
-c <num>      max simultaneous connections (default: 1024)
-d            以daemon方式运行
-u <username> 绑定使用指定用于运行进程<username>
-m <num>      允许最大内存用量,单位M (默认: 64 MB)
-P <file>     将PID写入文件<file>,这样可以使得后边进行快速进程终止, 需要与-d 一起使用</span>

#$: ./usr/local/bin/memcached -d -u root -l 192.168.10.156 -m 2048 -p 12121


客户端通过网络方式连接:

telnet 192.168.10.156 12121

然后就可以操作命令、常见命令如下:

<span style="font-size:18px;">set
add
replace
get
delete</span>

格式如下:

<span style="font-size:18px;">command <key> <flags> <expiration time> <bytes>
<value>

参数说明如下:
command set/add/replace
key     key 用于查找缓存值
flags     可以包括键值对的整型参数,客户机使用它存储关于键值对的额外信息
expiration time     在缓存中保存键值对的时间长度(以秒为单位,0 表示永远)
bytes     在缓存中存储的字节点
value     存储的值(始终位于第二行)</span>


二、命令执行流程代码分析

首先看一下工作线程中的命令数据结构:

/**
 * The structure representing a connection into memcached.
 */
typedef struct conn conn;


非常重要的几个参数:
char * rbuf:用于存储客户端数据报文中的命令。
int rsize:rbuf的大小。
char * rcurr:未解析的命令的字符指针。
int rbytes:为解析的命令的长度。


结构如下:

<span style="font-size:18px;">struct conn {
    int    sfd;
   	char   *rbuf;   /** buffer to read commands into */  
    char   *rcurr;  /** but if we parsed some already, this is where we stopped */  
    int    rsize;   /** total allocated size of rbuf */  
    int    rbytes;  /** how much data, starting from rcur, do we have unparsed */  
	
	/* data for the mwrite state */  
    struct iovec *iov;  
    int    iovsize;   /* number of elements allocated in iov[] */  
    int    iovused;   /* number of elements used in iov[] */  
  
    struct msghdr *msglist;  
    int    msgsize;   /* number of elements allocated in msglist[] */  
    int    msgused;   /* number of elements used in msglist[] */  
    int    msgcurr;   /* element in msglist[] being transmitted now */  
    int    msgbytes;  /* number of bytes in current msg */      
    LIBEVENT_THREAD *thread; /* Pointer to the thread object serving this connection */  
	...
};</span>




状态机迁移: drive_machine(conn *c)



以上图相当有水平,引用作者 http://calixwu.com/ 上的、自已就不再画了。


以文字说明一下整体状态机流程:
1. 当客户端和Memcached建立TCP连接后,Memcached会基于Libevent的event事件来监听客户端新的连接及是否有可读的数据。
2. 当客户端有命令数据报文上报的时候,就会触发drive_machine方法中的conn_read这个case状态。
3. memcached通过try_read_network方法读取客户端的报文。如果读取失败,则返回conn_closing,去关闭客户端的连接;如果没有读取到任何数据,则会返回conn_waiting,继续等待客户端的事件到来,并且退出drive_machine的循环;如果数据读取成功,则会将状态转交给conn_parse_cmd处理,读取到的数据会存储在c->rbuf容器中。
4. conn_parse_cmd主要的工作就是用来解析命令。主要通过try_read_command这个方法来读取c->rbuf中的命令数据,通过\\n来分隔数据报文的命令。如果c->buf内存块中的数据匹配不到\\n,则返回继续等待客户端的命令数据报文到来conn_waiting;否则就会转交给process_command方法,来处理具体的命令(命令解析会通过\\0符号来分隔)。
5. process_command主要用来处理具体的命令。其中tokenize_command这个方法非常重要,将命令拆解成多个元素(KEY的最大长度250)。例如我们以get命令为例,最终会跳转到process_get_command这个命令process_*_command这一系列就是处理具体的命令逻辑的。
6. 我们进入process_get_command,当获取数据处理完毕之后,会转交到conn_mwrite这个状态。如果获取数据失败,则关闭连接。
7. 进入conn_mwrite后,主要是通过transmit方法来向客户端提交数据。如果写数据失败,则关闭连接或退出drive_machine循环;如果写入成功,则又转交到conn_new_cmd这个状态。
8. conn_new_cmd这个状态主要是处理c->rbuf中剩余的命令。主要看一下reset_cmd_handler这个方法,这个方法回去判断c->rbytes中是否还有剩余的报文没处理,如果未处理,则转交到conn_parse_cmd(第四步)继续解析剩余命令;如果已经处理了,则转交到conn_waiting,等待新的事件到来。在转交之前,每次都会执行一次conn_shrink方法。
9. conn_shrink方法主要用来处理命令报文容器c->rbuf和输出内容的容器是否数据满了?是否需要扩大buffer的大小,是否需要移动内存块。接受命令报文的初始化内存块大小2048,最大8192。


三、下面以代码简要分析一下
1、读写事件回调函数:event_handler,这个方法中最终调用的是drive_machine

void event_handler(const int fd, const short which, void *arg) {
	conn* c = (conn *) arg;
	drive_machine(c); 
}

drive_machine:
drive_machine这个方法中,都是通过c->state来判断需要处理的逻辑。
conn_listening:监听状态
conn_waiting:等待状态
conn_read:读取状态
conn_parse_cmd:命令行解析
conn_mwrite:向客户端写数据
conn_new_cmd:解析新的命令
//

static void drive_machine(conn *c) { 
	bool stop = false;
	while(!stop) {
		switch (c->state) {
			case conn_waiting:
			// 通过update_event函数确认是否为读状态,如果是则切到conn_read
			if (!update_event(c, EV_READ | EV_PERSIST)) {
				conn_set_state(c, conn_closing);
			}
			conn_set_state(c, conn_read);
            stop = true;
            break;
            
           case conn_read:
           	// 读取数据并根据read的情况切到不同状态、正常情况切到conn_parse_cmd
           	res = try_read_network(c);
           	switch (res) {
            case READ_NO_DATA_RECEIVED:
                conn_set_state(c, conn_waiting);
                break;
            case READ_DATA_RECEIVED:
                conn_set_state(c, conn_parse_cmd);
                break;
            case READ_ERROR:
                conn_set_state(c, conn_closing);
                break;
            case READ_MEMORY_ERROR: /* Failed to allocate more memory */
                /* State already set by try_read_network */
                break;
            }
            break; 
            
            case conn_parse_cmd:
            // 读取命令并解析命令,如果数据不够则切到conn_waiting
            if (try_read_command(c) == 0) {  
            	/* we need more data! */  
            	conn_set_state(c, conn_waiting);  
        	}
            break;
            
            case conn_mwrite:
            res = transmit(c);
            switch(res){
            	case TRANSMIT_COMPLETE:
            	if (c->state == conn_mwrite) {
            		/* XXX:  I don't know why this wasn't the general case */
                    if(c->protocol == binary_prot) {
                        conn_set_state(c, c->write_and_go);
                    } else {
                    	// 命令回复完成后、又切换到conn_new_cmd处理剩余的命令参数
                        conn_set_state(c, conn_new_cmd);
                    }
            	}
            }
            break;
            
            ...
		}
	}
}

上面的逻辑主要反映了状态机的转换流程,下面重点看下数据处理这一块:
命令格式:set username zhuli\\r\\n get username \\n
通过\\n这个换行符来分隔数据报文中的命令。因为数据报文会有粘包和拆包的特性,所以只有等到命令行完整
才能进行解析。所有只有匹配到了\\n符号,才能匹配一个完整的命令。

static int try_read_command(conn *c) { 
	if (c->protocol == binary_prot) { // 二进制模式
		dispatch_bin_command(c);
	}else{
		//查找命令中是否有\\n,memcache的命令通过\\n来分割
		el = memchr(c->rcurr, '\\n', c->rbytes);
		
		//如果找到了\\n,说明c->rcurr中有完整的命令了  
        cont = el + 1; //下一个命令开始的指针节点  
        //这边判断是否是\\r\\n,如果是\\r\\n,则el往前移一位  
        if ((el - c->rcurr) > 1 && *(el - 1) == '\\r') {  
            el--;  
        }  
        //然后将命令的最后一个字符用 \\0(字符串结束符号)来分隔  
        *el = '\\0';  
        
        //处理命令,c->rcurr就是命令  
        process_command(c, c->rcurr);
        
        //移动到下一个命令的指针节点	
		c->rbytes -= (cont - c->rcurr);
		c->rcurr = cont;
	}
}

// 处理具体的命令。将命令分解后,分发到不同的具体操作中去
static void process_command(conn *c, char *command) {
	token_t tokens[MAX_TOKENS];
	// 拆分命令:将拆分出来的命令元素放进tokens的数组中
	ntokens = tokenize_command(command, tokens, MAX_TOKENS);
	
	// 分解出来的命令的第一个参数为操作方法
	1、process_get_command(c, tokens, ntokens, false); // "get"/"bget"
	
	2、process_update_command(c, tokens, ntokens, comm, false); // "add"/"set"/...
	
	3、process_get_command(c, tokens, ntokens, true); // "gets"
	
	...>> 4-n 
}

这里以 get 命令走读下:

static inline void process_get_command(conn *c, token_t *tokens...){
	it = item_get(key, nkey, c); // 内存存储快块取数据 
	if (it) { // 获取到了数据
        /*
         * Construct the response. Each hit adds three elements to the
         * outgoing data list:
         *   "VALUE "
         *   key
         *   " " + flags + " " + data length + "\\r\\n" + data (with \\r\\n)
         */
		// 构建初始化返回出去的数据结构
		add_iov(c, "VALUE ", 6);
		add_iov(c, ITEM_key(it), it->nkey);
		add_iov(c, ITEM_suffix(it), it->nsuffix - 2);
		add_iov(c, suffix, suffix_len);
		add_iov(c, "END\\r\\n", 5);
		
		// 最后切到 conn_mwrite 即调用 transmit 函数
		conn_set_state(c, conn_mwrite);
	}
}
/*
 * Returns an item if it hasn't been marked as expired,
 * lazy-expiring as needed.
 */
item *item_get(const char *key, const size_t nkey, conn *c) {
    item *it;
    uint32_t hv;
    hv = hash(key, nkey);
    item_lock(hv);
    it = do_item_get(key, nkey, hv, c);
    item_unlock(hv);
    return it;
}

// 向客户端写数据。写完数据后,如果写失败,则关闭连接;如果写成功,则会将状态修改成conn_new_cmd,
// 继续解析c->rbuf中剩余的命令
static enum transmit_result transmit(conn *c) {
	//msghdr 发送数据的结构  
    struct msghdr *m = &c->msglist[c->msgcurr];  
    
    //sendmsg 发送数据方法  
    res = sendmsg(c->sfd, m, 0); 

	...
}

对于剩余命令的处理:

//重新设置命令handler  
static void reset_cmd_handler(conn *c) {  
    c->cmd = -1;  
    c->substate = bin_no_state;  
    if (c->item != NULL) {  
        item_remove(c->item);  
        c->item = NULL;  
    }  
    conn_shrink(c); //这个方法是检查c->rbuf容器的大小  
    //如果剩余未解析的命令 > 0的话,继续跳转到conn_parse_cmd解析命令  
    if (c->rbytes > 0) {  
        conn_set_state(c, conn_parse_cmd);  
    } else {  
        //如果命令都解析完成了,则继续等待新的数据到来  
        conn_set_state(c, conn_waiting);  
    }  
}

/*
 * Shrinks a connection's buffers if they're too big.  This prevents
 * periodic large "get" requests from permanently chewing lots of server
 * memory.
 *
 * This should only be called in between requests since it can wipe output
 * buffers!
 */  
static void conn_shrink(conn *c) { // 检查rbuf的大小
    if (c->rsize > READ_BUFFER_HIGHWAT && c->rbytes < DATA_BUFFER_SIZE) {
        char *newbuf;

        if (c->rcurr != c->rbuf)
            memmove(c->rbuf, c->rcurr, (size_t)c->rbytes);

        newbuf = (char *)realloc((void *)c->rbuf, DATA_BUFFER_SIZE);
        if (newbuf) {
            c->rbuf = newbuf;
            c->rsize = DATA_BUFFER_SIZE;
        }
        c->rcurr = c->rbuf;
    }	
	...
}

对于异步套接字编译就是 回调+状态机、一定要记下所有的状态。有几点要特别注意:
1、注册的事件处理函数不能堵塞或主动sleep、否则整个工作线程处于挂起状态。
2、单线程、但其内在的复杂性——将线性思维分解成一堆回调的负担(breaking up linear thought into a bucketload of callbacks)——仍然存在
3、对于每个事件的处理都需要维护一个状态、上下文是紧密相关的、代码编写时需要时刻注意小心。
4、注意epoll的工作模式:LT还是ET模式、一般是回调时尽量处理更多的数据包。


以上是关于Memcached 源码分析--命令流程分析的主要内容,如果未能解决你的问题,请参考以下文章

memcached源代码分析-----set命令处理流程

Memcached源码分析之从SET命令开始说起

Android 逆向整体加固脱壳 ( DexClassLoader 加载 dex 流程分析 | DexFile loadDexFile 函数 | 构造函数 | openDexFile 函数 )(代码片

Memcached源码分析之memcached.c

Memcached源码分析之内存管理

memcached源码分析-----slab内存分配器