Memcached协议解析及Go语言客户端实现

前段时间在一个go语言项目中用到memcached,在githup找到github.com/bradfitz/gomemcach,beego的cache中也用的这个,但是发现它只能存储最终的bytes,需要自己做类型转换,用起来很不方便,而且通信协议用的是文本协议,不如二进制协议解析快,所以就自己写了一个,代码见:https://github.com/pangudashu/memcache

1、Memcached协议

memcached支持两种协议:文本协议、二进制协议

1.1 文本协议

1、数据以单字节ascii字符传输,解析时需要按分隔符切割,而且多字节数据类型需要进行文本转换,如string => int (“123” -> 123)
2、常见的用文本协议的有:http、ftp、redis等
3、文本协议的优点就是字段容易扩展,比如我们可以在http的header中定义自己的字段,缺点就是解析效率低,而且安全性低,容易被劫持

memached文本协议我们也经常用到,比如通过telnet连接使用就是最常见的,如Get命令:get \r\n

文本协议比较简单,这里不作过多说明,有兴趣的查下资料,这里我们具体说下二进制协议。

1.2 二进制协议

1、二进制协议是以二进制数据传输,比如8字节整形,传输的时候就是8byte的字节
2、这类协议应用最普遍了,协议栈基本都是,如:ip、tcp、fastcgi等等
3、与文本协议比二进制最大的特点就是解析快,不用对字符串进行处理,直接按长度读取、解析即可,所以效率非常高,而且相对比较安全,如果不知道具体的协议,很难从一堆二进制数据中分析出有用的信息,缺点就是不容易扩展,需要进行版本兼容

事实上memcached可以动态的支持这两种协议,也就是说客户端发送哪种协议都可以,这是怎么实现的呢?从memcached.c文件try_read_command函数中可以看到具体的处理:

//memcached-1.4.25
//memcached.c #3687 
if ((unsigned char)c->rbuf[0] == (unsigned char)PROTOCOL_BINARY_REQ) {
    c->protocol = binary_prot;
} else {
    c->protocol = ascii_prot;
}

从代码里看以看出是根据发送数据的第一个字节判断的,如果是PROTOCOL_BINARY_REQ(这个其实是二进制协议header的magic字段,后面会详细介绍)则作为二进制协议处理,否则按文本处理。

1.2.1 请求header

二进制协议通常会定义一个固定长度的header,里面包含一些信息,其中一般会有变长body的长度,发送的时候将body数据直接写在header后面,所以解析的时候服务端直接将header解析出来,然后取到body长度进一步处理。下面看下memached的header结构:

typedef union {
    struct {
        uint8_t magic;
        uint8_t opcode;
        uint16_t keylen;
        uint8_t extlen;
        uint8_t datatype;
        uint16_t reserved;
        uint32_t bodylen;
        uint32_t opaque;
        uint64_t cas;
    } request;
    uint8_t bytes[24];
} protocol_binary_request_header;

直接看request结构即可,各字段对应的内容如下图:

Memcached协议解析及Go语言客户端实现_第1张图片

magic —(1个字节)魔术值,用来识别发送的包,服务端也就是根据这个值区分客户端发送的协议,客户端发送的包这个值是0x80,收到的包是0x81,也就是加了1
opcode —(1个字节)命令,用来标示get、set、add这些命令的,具体值详见:protocol_binary.h protocol_binary_command
keylen —(2个字节)key的长度
extlen —(1个字节)extra域长度,放一些扩展字段,如set命令的过期时间就放在extra
datatype —(1个字节)固定值0x00,没什么用
reserved(status) —(2个字节)保留字段,没什么用
bodylen —(4个字节)body长度
opaque —(4个字节)用于存储客户端自定义的数据,服务端只存储不处理,(Will be copied back to you in the response)
cas —(8个字节)value版本号,每次对value写操作cas都会变,可以用于原子操作来保证数据的一致性

//opcode列表
typedef enum {
    PROTOCOL_BINARY_CMD_GET = 0x00,
    PROTOCOL_BINARY_CMD_SET = 0x01,
    PROTOCOL_BINARY_CMD_ADD = 0x02,
    PROTOCOL_BINARY_CMD_REPLACE = 0x03,
    PROTOCOL_BINARY_CMD_DELETE = 0x04,
    ......
} protocol_binary_command;

下面以set命令具体看下完整的数据包。

1.2.2 Set命令格式

memcache.Set("qp","hello")

通过tcpdump可以抓到这个命令都发送些什么:
Memcached协议解析及Go语言客户端实现_第2张图片
这是一个完整的ip包,包含ip首部(20字节)、tcp首部(20字节),白色部分才是memcache发送的数据。
根据上面的header我们可以对应起来:
Memcached协议解析及Go语言客户端实现_第3张图片
opcode是0x01
key(”qp”)长度为2
extra长度为8,包含4字节的flag(此字段与opaque类似,主要提供给客户端存一些数据处理的记录信息,后面实现的go客户端就是用这个字段来区分存的数据类型的),另外4字节是value的过期时间
bodylen是0x0000000F,包含extra长度、key长度、value长度,共15字节
extra直接拼在header后,然后是key,最后是value,整个包长度是39字节。

关于更多的memcached命令数据包格式可以看这篇文章:http://blog.csdn.net/pangudashu/article/details/50667123
我们来简单看下memcached是怎么解析命令的。

1.2.3 命令解析

memcached有一个非常关键的函数drive_machine(),很多资料称之为状态机,这个函数有一个while循环,memcached命令处理的各个阶段就是在这里调度的,这里我们不对它展开详细的说明,有兴趣的可以去仔细看下,我们直接看命令解析那部分:

//memcached.c #4154
case conn_parse_cmd :
    if (try_read_command(c) == 0) {
        /* wee need more data! */
        conn_set_state(c, conn_waiting);
    }

break;

再看try_read_command()函数:

//memcached.c #3715
protocol_binary_request_header* req;
req = (protocol_binary_request_header*)c->rcurr; 
...
//对多字节类型进行字节序转换:网络序->主机序
c->binary_header = *req;
c->binary_header.request.keylen = ntohs(req->request.keylen);
c->binary_header.request.bodylen = ntohl(req->request.bodylen);
c->binary_header.request.cas = ntohll(req->request.cas);
...
dispatch_bin_command(c);

c->rcurr就是从socket中读出的客户端发送的数据buffer,这里直接将其强转为header的结构体(对,就是这么暴力^_^),这个地方也可以看到c语言的强大之处,本质上结构体是连续的一块内存,与char*完全相同,只是根据类型解读出来的值不同,所以可以直接将char*转为为一个结构体。
从这个过程可以看到header解析起来有多容易,相对的我们看下文本协议的解析:

//memcached.c #3771
el = memchr(c->rcurr, '\n', c->rbytes);
...

可以看到用到了memchr进行了字符串查找操作,由此可以看出二进制的优势。

接下来介绍下写的go版本的memcached客户端。

2、Go语言客户端实现

有兴趣的可以去看下代码,欢迎反馈bug。代码位置:https://github.com/pangudashu/memcache

2.1 特性

主要针对一些已有的客户端存在的一些问题或者不便之处进行了一些改进:
1、支持多server集群:一致性哈希,与php的memcached扩展一样
2、与memcached使用二进制协议通信
3、连接池:所有连接维持长连接状态
4、存储value支持golang基本数据类型:string、[]byte、int、int8、int16、int32、int64、bool、uint8、uint16、uint32、uint64、float32、float64、map、结构体,不需要单独转为string或者bytes存储,省去了繁杂的序列化过程,这个是我不能忍受其他客户端的地方了…
5、Replace、Increment/Decrement、Delete、Append/Prepend命令支持cas原子操作:基于cas保证数据的一致性

2.2 性能

拿github.com/bradfitz/gomemcache来比较,测试方式:启动一个http服务,每次请求调用一次memcached的Get操作,用ab看下qps。(具体测试脚本在项目githup上有)

ab -c 200 -n 10000 http://127.0.0.1:9955/bradfitz_foo
ab -c 200 -n 10000 http://127.0.0.1:9955/pangudashu_foo

结果如下图,右边是github.com/bradfitz/gomemcache的:
Memcached协议解析及Go语言客户端实现_第4张图片
虽然这个测试比较简单不能代表全部,但是可以肯定的是使用二进制协议的效率要比文本协议高。

2.3 架构图

Memcached协议解析及Go语言客户端实现_第5张图片

比较核心的一个结构是Nodes,其中有两个信息:哈希节点值与memcached server的对应(serverNodeMap)及所有的哈希节点值数组nodeList。
根据key计算散落的server时就是首先根据nodeList作二分查找找到具体的node值,然后从serverNodeMap取出对应的server。

2.4 server哈希节点分布

Memcached协议解析及Go语言客户端实现_第6张图片

一致性hash,这类介绍的文章比较多,这里简单描述下:首先根据server的连接信息(eg:127.0.0.1:11211)的md5值得到一个16字节的数组,然后根据ketama算法每4字节一组作移位运算得到一个hash值,这样每个md5的连接信息都可以得到4个hash节点。
当然具体的实现时加入了权重,也就是对127.0.0.1:11211加了额外的后缀(eg:127.0.0.1:11211-1),以此来计算出多个虚拟节点,从而更好的实现均匀性。

你可能感兴趣的:(go语言,c/c++)