Key-N-Value--基于Protocol Buffers的树型协议处理引擎

导言

KNV是Key-Value协议的无限嵌套和模式自由的扩展,允许使用者快速访问或修改ProtoBuffers协议中的一部分或者多个部分,KNV原是一个面向对象缓存系统的一部分,后面作为腾讯第一批开源组件对外开源。KNV的理念也申请并获得国家专利。​​​​​​​

项目地址:

GitHub - shaneyuee/KNVProtoEngine: KNV(Key-N-Value) is a very fast protocol engine for manipulating protocol data without knowing the detail of its contents. KNV serves for 3 main purposes: 1, As a fast protocol engine, supporting 1M+ processes per second; 2, As a schema-free protocol inspecter/modifier for general-purpose network server; 3, As a protocol and data storage engine for general data storage server.​​​​​​​

 这是腾讯开源项目地址,已经存档:

https://github.com/Tencent/KNVProtoEngine

 

一、需求

相信大家都很熟悉Key-Value存储系统,也相信很多人都深有体会,Key-Value系统对于每次只需要拉取和修改部分Value的应用来说,操作极为不方便,比如:

  • 你不得不自己去做乐观锁(读返回seq-->修改-->带seq写-->如果seq变化则重头开始);
  • 你不得不每次拉取一堆数据,然后再提取你想要的部分;
  • 为了修改一个bit,你不得不把一串数据拉取修改后再一起提交更新
  • 。。。

不过现在好了,有了Key-N-Value(KNV),一切都解决了。

我们看看Key-N-Value的演变历史:

Key-Value                 (Key-Type-Value、Key-Type-Length-Value/TLV)

Key-Key-Value                (Key-Key-TLV)

Key-Key-ValueList                 (Key-Key-TLVList)

Key-Key-Key-… …-ValueList                (Key*N Value, KNV)

可见,KNV就是Key-Value的衍生物,她允许将Value部分无限展开,更核心的是允许一次操作一个或多个叶子节点,不会涉及无关的节点

因为这是协议上的支持,如果某个存储系统希望拥有这样的功能,改进的办法是使用KNV协议代替原先的协议和存储格式

二、树型协议

我们知道,很多通用协议本质上都是树型协议,包括ProtoBuf、Json、XML、ASN.1、还有支持嵌套的TLV等等。

树型协议的最大好处是数据可以无限嵌套,用户高度自定义深度和广度。大概归纳了一下,树型协议有这么几个特点:

  • 嵌套

当然,这是基础

  • 无模式

就是说,你把数据给我,我就能分解出最基本的元素。后面我们会专门介绍。

  • 自描述或者半自描述

我们看到XML文件就知道是什么意思了,这就是自描述;

但看到PB数据包,只能知道是整型还是字符串,如果想继续知道什么意思,就只能看.proto描述,这就是半自描述。

树型协议的这些特点决定了其最主要的优点:可以描述任意数据类型,支持任意数据类型的编解码。

三、无模式编码

树型协议的最主要特性就是无模式(Schema Free)。为了理解无模式的重要性,我们先看一个简单的场景:

程序员S做一个简单的昵称存储系统,存储用户的昵称。存储方式为:

cLength + strNick[]

其中,cLength是一个字节,strNick是变长存储,长度是 cLength,最大不超过255。一切很简单,也很美好。

有一天,老大跑过来,说,有个需求,要把性别也存进来,S顿时傻了,协议和存储都没有预留字段,怎么办?没有办法,只能重构系统了,重构的时候S留了个心眼,把协议设计成:

wFieldCount(2) + cNickLength + strNick[] + cGender

这样,如果后面再加字段,只要把 wFieldCount 加1,再往末尾加上新字段即可。一切很简单,也很美好。

突然有一天,老大气呼呼地跑过来说,小S,我只要年龄,能不能不要把昵称、性别一大堆东西都塞给我

S一查代码,啊,原来协议不知不觉已经变成这样子了:

 wFieldCount(120) + cNickLength + strNick[] +...+cAge+cShoolLen+strSchool

如果用户想拉取前面的字段,把wFieldCount写小一点就可以获取了,但如果要拉取学校字段,就一定要整串buffer全部返回。。。

1. 无模式TLV编码

S苦恼了很久,后来了解到一种叫做TLV的编码,就是无论是1个比特、1个字节、定长还是变长数据,都用分配一个两个字节的T来表示,长度统一用两个字节的L来表示,值用变长的字节流V来表示,例如:

昵称:T = 1, L = 5, aV[] = “shane”,编码为: \01 \00 \05 \00 s h a n e

性别:T = 1, L = 1, av[] = {1}, 编码为: \01 \00 \01 \00 \01

鉴于TLV的应用已经非常广泛,这里不打算详细介绍,请见 http://en.wikipedia.org/wiki/Type-length-value

有了TLV编码,网络协议就变得很简单了:

请求包:

wTCount + wT(1) + wT(2) + ... + wT(wTCount)

回包:

 wTlvCount + TLV(1) + TLV(2) + ... + TLV(wTlvCount)

对比两种协议格式,我们看到主要的差别有:

1)字段长度

中,有的字段固定一个或数个字节,不需要有长度,有的没有固定长度;但协议的使用者需要清楚所有字段的长度类型(定长还是变长,定长多少个字节),否则无法解析;

中,所有字段都有长度部分,使用者不需要了解这个字段的长度类型,就能对这个字段进行基本的解析操作。

2)字段顺序

中,字段必须按照严格的顺序给出,使用者如果不知道字段的顺序,就无法解析;

中,字段可以是任意顺序。

3)兼容性

中,可以添加字段,但不能删除已有字段;旧代码不能处理新增的字段;

中,可以任意增加或删除字段;旧代码可以处理新增加的字段。

4)字段依赖性

中,后面的字段依赖前面的字段,不同字段无法单独处理;如果用户想处理最后面的一个字段,就不得不把所有字段都拉回来。

中,可以通过T选择处理的字段,不处理的字段可以跳过,不需要在网络中传输;字段间没有任何依赖关系。

由此可见,TLV协议是完全跟字段的类型/长度/含义解耦的,也就是一种无模式的编码格式。

实际上,本文使用的PB(谷歌的Protocol Buffers)也是一个TLV编码的实现,只是对于变长整型处理,长度L被编码到Value中去了而已。PB的详细介绍见这里:http://code.google.com/p/protobuf/

顺便提一下,谷歌的原生态PB API如果想支持无模式协议处理,就必须使用UnknownFieldSet或者Reflection机制,但处理性能并不乐观。否则,谷歌会把协议生成类给用户使用,用户还是必须知道具体类成员的类型和结构才能处理。

2. 无模式编码的不足

跟有模式(主要是基于C语言数据结构的紧凑格式,后面我们称为结构化)的设计相比,无模式编码的不足也很明显,主要体现在处理性能上:

结构化编码,可以通过偏移(或者基于偏移的简单相加)快速得到一个字段的位置;

无模式编码,必须遍历完全部数据结构,才能获得全部指定T的字段,因为,某个T出现的个数,我们事先是不知道的。

(中PB编码中,用户可以由.proto文件指定一个字段为repeated,但是,在无模式编码的情况下,我们不能假定已知用户的数据格式,也就是无法事先知道用户的.proto文件格式,因此,也无法知道一个字段是不是repeated)。

大家看一个结构化的协议打包例子,相信大家一定很熟悉:

    //IP header
    struct iphdr *iph = (struct iphdr *) datagram;
    //Fill in the IP Header
    iph->ihl = 5;
    iph->version = 4;
    iph->tos = 0;
    iph->tot_len = sizeof(struct iphdr) + sizeof(struct udphdr) + strlen(data);
    iph->id = htonl (54321);    //Id of this packet
    iph->frag_off = 0;
    iph->ttl = 255;
    iph->protocol = IPPROTO_UDP;
    iph->check = 0;     //Set to 0 before calculating checksum
    iph->saddr = inet_addr(source_ip); //Spoof the source ip address
    iph->daddr = sin.sin_addr.s_addr;
    //Ip checksum
    iph->check = csum ((unsigned short *) datagram, iph->tot_len);

(代码出处:How to Program raw UDP sockets in C on Linux - BinaryTides

从上面可以看出,结构化的打解包就跟访问跟访问数据结构的成员差不多,开销自然也特别小。

四、网络协议处理

我们知道,网络服务的操作可以分为几类,读、写和删除,这几类操作都有一个共同的特点,就是先匹配请求的部分数据,再对匹配结果进行操作。

对于读操作,这个过程就是,读出整块数据,匹配想要的部分数据,然后返回这部分数据;对于写操作,则是匹配后进行合并或覆盖,然后重新写入;对于删除操作,就是匹配后,删除被匹配的部分数据,然后写入。

我们先看看一个读请求的处理流程:

如果做一个特定的应用,上面是实现是非常简单的。

举个例子,如果这是一个用户数据存储系统,用户希望拉取一个号码A的昵称(Nick),那么:

  1. 请求的内容就是GetNick(A)(可以分解为使用Get命令操作号码A的Nick字段)
  2. 被请求的数据就是昵称(Nick字段的内容)
  3. 逻辑层(数据提取)要做到工作:
    1. 解析出被请求的号码A
    2. 到数据库中取出用户A的资料(假设是Key-Value系统)
    3. 判断用户是否请求Nick字段,如果是,就从用户资料中取出昵称并返回

事实上,逻辑层的实现会比上面的描述稍微复杂一点,原因是,存储系统中存了很多字段,用户也有可能拉取多个字段,那么逻辑层就会变成这样子:

    1. 解析出被请求的号码A
    2. 到数据库中取出用户A的资料
    3. 遍历所有请求字段并取出相应的值:

if (req_nick)

    取出昵称;

if (req_gender)

    取出性别;

if (req_birthday)

    取出生日;

... ...

    1. 合并取出的字段并返回给用户

这里需要稍微解释一下,为什么需要挨个字段进行操作,其实是因为我们假定了存储采用类似的存储格式,例如:

struct user_request

{

    int version; /* 值必须<=3,当前第3个版本 */

    int user_id;

    bool req_nick;

    bool req_gender;

    bool req_birthday;

};

struct user_data

{

    int version; /* 值是3,当前第3个版本 */

    int user_id;

    string nick;

    int gender;

    string birthday;

};

看到这里,细心的同学也许已经发现这种数据表达方式存在的不足了:

  1. 如果字段很多,比如有1000个,逻辑层的代码就会变得很复杂,也很冗余
  2. 增加字段很不方便,协议和存储都需要修改

- 基于TLV的处理过程

我们现在看看,采用TLV协议表示以后,上面例子中的请求处理过程是怎样的:

  1. 解析出被请求的号码A
  2. 取出用户A的资料
  3. 遍历所有请求字段 req_tlist,从数据 data_tlvlist 中取出对于tlv:
    for each t in req_tlist;
        for each tlv in data_tlvlist;
            if (t == tlv.tag)
                add_replay(tlv);
  4. 合并取出的全部tlv并返回给用户

可见,TLV极大的优化了逻辑层的处理过程,优点非常明显:

1)代码非常简单,跟字段多少没有关系

2)前后兼容,新增字段老代码无需修改就能处理

不知道各位同学是不是觉得TLV已经很完美了,没必要再弄什么KNV了?

如果这样想,你就错了。我们再看看另一个例子:

我要拉取我(A)对好友B和C的备注名称,采用TLV协议表示:

struct user_request
{
    int user_id;
    int friend_idlist[]; // 要请求的好友列表(这里是B和C的ID)
    int req_taglist[]; // 要请求的好友资料字段(这里只有一项:备注)
};


struct user_data
{
    TLV tlvlist[]; // 数据使用嵌套TLV进行存储
    // T=FRIEND_LIST表示好友表, T=REMARK表示备注
};

提取请求数据的过程:

for tlv in data.tlvlist;
{
    if (tlv.tag == FRIEND_LIST) // 找到好友表的tag,展开进入,tlv是一个好友的资料
    {
        for fid in friend_idlist // 遍历请求的好友号码
        {
            TLV friend_tlvlist[] = cast<TLV []> (tlv.value); // 把value展开为TLV列表

            for friend_tlv in friend_tlvlist;
            {
                if (friend_tlv.tag == FRIEND_ID && // 找到好友ID的tag
                   friend_tlv.value == fid)  // 值就是我要找的好友ID (假设为B)
                {
                    for req_t in req_taglist; // 遍历请求的字段列表(只有备注)
                    {
                        for friend_tlv in friend_tlvlist; // 再一次遍历好友B的tlvlist
                            if (friend_tlv.tag == req_t) //找到要请求的字段(备注)
                            {
                               add_request(fid, friend_tlv);
                               break;
                            }
                    }
                    break;
                }
            }
        }
    }
}

查找的时候先要找到二级key对应的tag,再看这个tag的值是不是要找的二级key,如果是,才继续解开里面的tlvlist。

由此可见,对于二级以上的嵌套关系,TLV查找的性能会急剧下降。

那么,有没有退而求其次的方法呢,答案是有的,就是牺牲部分特性,来换取CPU处理性能和代码的复杂度。

有两种方法可以解决随着级数增长处理性能急速下降的问题。

1)半TLV:

牺牲前面N-1级TLV,前面N-1级都采用固定数据结构表示,只让最后一级key的数据采用TLV方式。这是目前比较常见的key-key-value的设计实现,包括Grocery和QQ关系链存储层的实现。另外,BigTable和Cassandra等模拟关系数据库的数据模型也可以看做半TLV的一种实现,行key和列key都是固定格式的,只有Value部分用户可以高度自定义。

这种方法的优点是前面N-1级只需要很少的性能开销就能找到想要的部分数据,不过不足也很明显,包括:

  1. 不支持不同类型TLV扩展(比如好友属性的系统不能扩展支持群成员资料)。
  2. 不支持不同级数扩展,每增加一级key系统几乎都要重写。
  3. 不支持TLV嵌套使用(如果TLV的Value里面还是TlvList,就只能由调用者自己去解析了)。

可见,这种方法是以牺牲嵌套无模式这两个树型协议的基本特性来达到目的的。

2)预留部分T

预留一部分T用作内部使用,比如Tag=1表示key,这样每次展开一个TLV节点,只要看tag=1的值是不是想要的key就可以了,没必要遍历查找所有tag,这也是目前KNV的实现方法。

为了提高key的读取性能,可以进一步限制key一定是tlvlist里面的第一个孩子节点,这样,匹配key的时候,只要看第一个孩子节点就行了。

这种方法保留了嵌套和无模式的主要特性,同时也有效解决了处理性能下降、代码复杂度提高的问题,当然,不足是用户不能任意使用预留的T。

五、Key-N-Value

前面说过,树型协议有3个特点:嵌套无模式自描述

KNV是一种树型协议,因此也拥有树型协议的3个特点,恰切地说,是KNV利用了树型协议的这3个特点。

当然,对于第3点“自描述或半自描述”,我们只需要半自描述就足够了,因为我们不需要知道用户数据的具体含义,而只需要知道数据的基本类型。

KNV在树型协议的基础上进行一些限制,使其更适合于数据的存储和请求处理,包括:

1,tag=1 一定是key,key一定是message中的第一个子节点;

2,一个节点由 tag+key 唯一标识(tag是这个节点中父节点中的tag),如果节点没有key,就由tag唯一标识;

3,tag 2 ~ 10 是系统预留的,也就是用户自定义的数据必须从tag=11 开始;

这是一棵KNV树:

需要特别说明的是,KNV的所有节点都有一个tag值,根节点也不例外 -- 也就是说,KNV的根节点就是只包含一个字段的PB Message中的这个字段

在上面的例子中,根节点的tag是11,其值是一个包含有一个Key(tag=1,值为123的节点)和3个孩子节点的Message。

这棵树对应的PB Message是:

Message
{
    11(Root):  // 11是tag, Root是.proto中的Message名称
    {
        1(Key): 123 // 1是key的tag,必须是Root中的第一个孩子节点

        11(Profile): // tag=11的子Message,孩子节点里面没有tag=1的节点,所以没有key
        {
            100(Nick): shane
            101(Gender): boy
        }

        12(Friends):
        {
            11(Friend):
            {
                1(Key): endev
                200(Birthday): 20000710
                201(Remark): 测试
            }

            11(Friend):
            {
                1(Key): yao
            }
        }

        13(Group):
        {
        }
    }
}

1. KNV请求树

KNV协议的读请求包必须是一颗请求树,读回包和写请求包是一颗数据树

请求树是一棵普通的KNV树,只是

1)叶子节点必须是Int类型(只取0和1两个值),表示是否请求数据树中相应的节点,默认是0

2)如果某个非叶子节点没有给出孩子节点,表示请求对应节点的所有孩子节点。(这是为了处理请求一个子key下的所有属性,因为子key中一定有一个tag=1的孩子节点,不能直接把整个节点变成一个int)

因此,如果请求某个非叶子节点的全部内容,有两种方法:

1)将请求树对应节点写成Int类型,其值设置为1 (所有tag相同的节点都会返回)

2)将请求树对应节点写成非叶子节点(Node)类型,但不指定孩子节点

下面是一颗典型的请求树:

2. 请求树匹配过程

我们现在看看上面两个例子分别给出的数据树和请求树中,请求树匹配数据树的过程,也就是用户拿着请求树,系统根据数据树,提取出对应数据子树的过程。

1)根节点匹配,两个根节点的tag(11)和key(123)都相等

2)深度遍历请求树,对每一个请求树节点在数据树中找到匹配tag+key的节点:

在上图中:

A) 11:key==null 的请求树节点跟11(Profile)数据节点匹配,同样都是key==NULL, tag==11。

B) 请求树叶子节点 100:int=1 和数据树 100的节点匹配,因为int的请求树节点表示是否请求对应tag的全部节点。

在上图中:

最后的叶子节点 11:key=endev 因为没有指定孩子节点,匹配了对应数据树节点的本身和所有孩子节点。

3. 数据树的删除

删除子树的时候,要求传入的树一定是一棵请求树,因此,删除的过程也是请求树的匹配过程,只是在匹配的过程中,同时将被匹配到的节点删除。

4. 数据树的更新

数据树的更新过程,可以分解为这样几个过程:

  1. 根据要写入的数据树,生成请求树;
  2. 用这棵请求树从存储的数据树中删除对应子树;
  3. 合并写入的子树成为新的数据树;

其中,B 就是上面讲的数据树的删除过程,C 是合并子树的操作,相对比较简单,这里就不多说了。

现在,我们重点看看从数据树生成请求树的过程。

我们假定请求深度为T,表示用户希望把深度为0~T的叶子节点变成对应的请求节点,把深度大于T的子树变成一个请求节点。

假如我们的数据树是这样的,现在看看其对应的请求树:

1) 如果请求深度T==0,请求树是:

此时,用户希望把整棵树都匹配到,所以只有根节点被转成请求节点

2) 假如T==1,那么,请求树变成:

这时,用户希望请求key=123下面的3个domain(Profile/Firends/Group)的全部数据。

3) T==2时:

此时,表示请求Profile下的字段100/101,Friends下的好友表(tag=11),Group没有孩子节点,所以变成一棵空树。

细心的同学也许已经发现:数据树中Friends下是有两个好友的(endev和yao),变成请求树的时候却变成一个节点了。

这里其实也是我们的一个约束条件:由数据树变成请求树时,相同深度的节点变成请求节点后,必须能够匹配所有tag相同的节点。因此,最后一层的请求节点一定是没有key的int类型。

大家想想这个场景,就知道为什么会有这个约束了:

        - 我本来有3个好友A、B、C,现在我要对好友表进行更新,修改为2个好友A和D。这时,我拿着由A和D组成的数据树来生成一棵请求树。

        - 如果请求树只匹配了A和D,那么删除A,再合并A、D后,新的树变成ABCD了。但这并不是我想要的,我的本意是想把B和C删除。

        - 所以解决的办法就是,把A、D变成一个只有tag的请求节点,同时匹配ABC,这样删除ABC后再插入AD,就达到我们想要的效果了。

当然,用户很可能就是真的希望最后变成ABCD,也很容易,只要把请求深度加1就可以了。

后面T>=3的情况,各位同学应该可以想象是怎样的了。

六、实现技术

在实现上,KNV采用了很多独立的高性能辅助模块来提高处理性能,最主要的模块有:

1. 对象池

使用链表管理被释放的对象,申请对象时,先从链表里面取,只有链表为空时才真正创建新的对象。分配对象的性能是O(1),一次访问链表头部的开销。

该模块是下面提到的内存池的一部分,也开源单独使用:

mempool/obj_pool.h at main · shaneyuee/mempool · GitHub

2. 内存池

基于对象池实现的一个用于快速分配内存的算法实现,比系统库函数提高5倍的性能,分配+回收可以达到3000w/s以上性能。

该模块已经作为开源组件分享,请见:

GitHub - shaneyuee/mempool: A very fast memory pool in C++ for speeding up memory allocation.

基本原理

系统内建10个池子

static struct
{
    uint64_t sz;
    UcMemPool pool;
} magics[] = {
    { 64 },{ 256 },{ 1024 },{ 4096 },{ 16384 },{ 65536 },{ 262144 },{ 1048576 },{ 4194304 },{ 16777216 }
};

分配的时候根据大小找出最适合的池子,从这个池子中分配内存。

如果某个池子已分配的内存超出最大限制,就从最大的池子开始,回收空间,直至空间足够,并且把回收的空间加入到这个池子中。

每个池子就是一个对象池,管理已分配的缓冲区对象和被释放的缓冲区对象,真正的内存分配使用malloc()进行,释放使用free()。

3. PB裸编解码引擎

把proto buf格式的协议当作TLV来打解包,可以实现高速打解包性能。解包的时候,只解开头部和整型值部分,如果是bytes/string类型,其内容用指针表示,没有解包的开销。

打包的时候,允许先打头部部分,再打里面的字段,让KNV一遍memcpy就完成打包成为可能。

该模块也已经作为公共组件分享,请见:http://pub.code.oa.com/project/home?projectName=FastPB

4. KNV内部实现技术

1)非递归化

KNV的内部实现大部分依靠递归操作完成,但有一些对性能损耗比较严重的地方,我们尽量做非递归化处理。

比如,回收节点的性能开销占整体开销约10%左右,当节点数很多的时候,这个开销对系统的整体性能影响很大。为此,我们采用临时链表来缓存需要回收的节点,对这些节点单独执行释放资源操作后,一次性把链表提交给对象池。

避免了KNV调用对象池释放,对象池又调用KNV回收函数释放下一级节点的循环依赖关系。

2)基于bitmap的动态哈希表

KNV对每一级孩子节点都会动态建立一个哈希表,目的是为了为查找孩子节点加速。

KNV不仅仅是普通的打解包工具,而是提供了树型协议基本操作的一个综合处理引擎,为此,KNV的请求树匹配、更新、删除操作才是KNV的核心理念。

因此,KNV对每一级孩子节点都动态建立一个哈希表,来加速孩子节点的查找过程。

我们知道,请求树的匹配会频繁用到孩子节点的查找匹配操作,如果没有使用哈希表索引孩子节点,当节点数比较多时,这些操作将消耗大量的CPU,KNV就变得没什么实际用处了。

使用哈希表而不是其它索引方式还有更重要的原因:建立哈希表的性能开销是O(1),这为高性能解包提供了基础。

KNV使用的哈希表跟普通的哈希表相比,有两个最主要的特点:

① 基于bitmap映射

哈希表的回收需要对整个哈希数组清0,当节点数很多时,这个性能开销变得无法容忍,所以我们引入了bitmap映射。

哈希插入时先看hash_key对应的bit有没有被使用,如果没有,就把bit置1,然后占用哈希数组的这个位置,如果已经被占用,就直接查到队列的后面。

回收时只需要把bitmap清0就可以了,这是一个相对轻量级的操作。

② 动态再哈希功能

KNV会随着孩子节点的增加扩张哈希数组的大小,并进行再哈希操作。默认的哈希数组元素是64,当孩子节点超过64时,扩展为256,当孩子节点超过256时,扩展为1024。

3)“0”拷贝技术

KNV为了尽可能减少打解包的开销,实现了“0”拷贝的技术,即:
1)事先计算好打包需要的空间,在数据变更时进行计算;
2)打包时一次遍历、一次内存拷贝完成全部内存复制;

七、API介绍

1. 打解包API

KNV提供了基本的打解包功能,允许用户把KNV当做普通的无模式打解包函数库。

1)从PB协议流构造KNV树(解包):

static KnvNode *New(const char *data, int data_len, bool own_buf=true);

own_buf指定是否需要申请新的内存,并拷贝内容,如果为false,KNV节点的指针指向用户的buffer。

使用例子:

KnvNode *tree = KnvNode::New(buffer, buffer_length);

if(tree==NULL)
{
    cout << "Construct KnvNode failed: " << KnvNode::GetGlobalErrorMsg() << endl;
    return -1;
}

2)从无到有构造KNV树:

static KnvNode *NewTree(knv_tag_t _tag, const knv_key_t *_key=NULL);

KnvNode *InsertSubNode(knv_tag_t _tag, const knv_key_t *_key = NULL);

KnvNode *InsertIntLeaf(knv_tag_t _tag, uint64_t _val);

KnvNode *InsertStrLeaf(knv_tag_t _tag, const char *_val, int _len);

使用例子:

knv_key_t k(12345678);

KnvNode * tree = KnvNode::NewTree(3501, &k);
if(tree==NULL)
{
    cout << "KnvNode::New() returns: " << KnvNode::GetGlobalErrorMsg() << endl;
    return -1;
}

KnvNode *dm = tree->InsertSubNode(11); //Profile
if(dm==NULL)
{
    cout << "Add domain Profile failed: " << tree->GetErrorMsg() << endl;
    KnvNode::Delete(tree);
    return -2;
}

if(dm->InsertStrLeaf(101, (char*)"shaneyu", 8)==NULL)
{
    cout << "Add field 101 failed: " << dm->GetErrorMsg() << endl;
    KnvNode::Delete(tree);
    return -3;
}

3)打包

int Serialize(string &out);

2. 协议处理API

1)请求树匹配

// 根据请求树构造并返回对应的数据树,同时返回由无效请求构成的树
// 请求树的定义:
//    是一棵普通的树,但:
//      不一定有key,如果没有指定key,匹配所有tag相同的节点
//      如果节点为int类型,表示是否请求tag对应节点的数据
//      如果节点为Node,但没有子节点,表示请求这个节点下所有的子节点
// 返回:
//    out_tree -- 存储返回数据的tree,数据部分指向 this,调用者必须保证out_tree先于this被释放
//    empty_req_tree -- 存储无效请求的请求树,数据指向req_tree,必须先于req_tree被释放掉
//    这两棵树用完后必须用KnvNode::Delete(tree)删除
// 返回码:
//    0  成功
//    <0 失败
int GetSubTree(KnvNode *req_tree, KnvNode *(&out_tree), KnvNode *(&empty_req_tree), bool no_empty = false);

2)由数据树生成请求树

KnvNode *MakeRequestTree(int max_level);

3)删除子树

// 用于根据变化的节点淘汰数据
// 返回:
//    match_req_tree -- 存储节点匹配的请求tree,是req_tree的一部分,用于回填使用
// 返回码:
//    1  成功:删除整棵树(不做任何操作,由上层删除)
//    0  成功:删除部分子节点
//    <0 失败
int DeleteSubTree(KnvNode *req_tree, KnvNode *(&match_req_tree), int depth=0);

4,更新子树

// 根据已有的数据树来更新当前的树,更新步骤:
//    如果有相同节点,则覆盖原来的节点
//    否则,插入新的节点
// 参数:
//    update_tree -- 用于更新的数据树
//    max_level   -- 展开的最大深度,大于或等于这个层次时,更新整个节点,不再展开
int UpdateSubTree(KnvNode *update_tree, int max_level);

八、同类产品及性能

1. 打解包功能

如果只就打解包功能来说,同类产品确实很多,数不胜数,最直接的就是Protocol Buffers,当然也包括前面提到过的几个树型协议:XML、Json、ASN.1等等。

需要特别指出的是,KNV并不是一种协议编解码格式,而是一个打解包和协议处理引擎,主要的特点在于无模式打解包和协议处理。

在拥有同样无模式打解包功能的同类产品中,XML是一个典型的代表,但文本处理天生就不具备性能优势,即便是基于二进制的Binary XML或者Bson(Binary Json),由于使用文本来作为对象的标识符,在处理性能占用空间上也没有优势。

还有另外一类打解包引擎,把处理性能做到极致,但却牺牲了无模式的主要特性,比如结构化打解包方式、谷歌刚推出的FlatBuffers需要依赖结构体定义或者描述文件才能把协议解开,不适合作为通用的协议处理引擎。

有另外一个与FlatBuffers类似,但却支持无模式打解包的协议CapN Protohttp://kentonv.github.io/capnproto/cxx.html#dynamic_reflection),但因为PB已经被广泛使用,我们目前也只支持PB协议,有兴趣的同学可以尝试把Cap’N Proto作为KNV的底层打解包引擎(KNV预留了底层协议的扩展)。

PB本身也有Reflection、UnknownFieldSet和自定义解析机制,允许用户直接操作数据节点,而不需要知道具体的含义。

我们重点看看PB内置的几种打解包方式和KNV在性能上的差别,下面这个表格上我在另外一位同事(yaoli)的测试基础上完善测试得到的:

测试方法:构造数据,遍历100w次,解包,取出部分数据,重新打包。

实现方法

数据大小

耗时(s/百万次)

打解包次数(w/s)

说明

PB Client

1SubKey*10Field (105字节)

3.2

31.25

次优

5SubKey*10Field (468字节)

9.1

10.99

Reflection

1*10

13.0

7.69

5*10

21.8

4.59

UnknownFieldSet

1*10

25.4

3.94

最差

5*10

56.4

1.77

Google自定义解析

1*10

6.9

14.49

5*10

13.6

7.35

Key-N-Value

1*10

1.3

76.9

最优

5*10

4.0

25.0

由此可见,KNV的打解包性能跟PB原生态的API比,占有很大的优势,小数据量的时候,打包+解包可以达到100万次/秒左右。

2. 协议处理功能

在协议处理这个视角来看,KNV跟大多数的Key Value处理系统实现了类似的功能:读取更新删除指定路径(Key)的一个对象(Value),但跟Key Value系统相比,主要的区别在嵌套深度并发程度上。

1)HTTP

比较直观的是HTTP,通过GET/POST/PUT/DELETE 来操作指定URL资源。KNV实现了同样类似的操作:提取/更新/删除指定(请求树中相应)位置的数据。

不同的是,KNV允许一次处理同一棵树中任意多项数据,提供超强的并发操作能力

2)SQL

SQL同样实现了一次操作多个对象的能力,但必须通过规则进行匹配,对于类似设置shane.friends.yao.age=23, shane.friends.endev.gender=male的操作,则只能分开两个语句执行,而KNV只需要一次数据合并就完成了。

SQL可以通过外键关联实现类似的嵌套功能,但是,当嵌套深度大于一定深度时,数据表的关联关系将变得异常复杂,如果不借助设计文档,开发人员将很难弄清楚其中的关系。

KNV就没有这个问题,任意深度的数据,一路展开就行了,就跟操作磁盘文件一样,实现了无限嵌套能力。

3)类SQL

有很多系统,都希望实现跟SQL一样强大的功能,目的是为了使用统一的接口来操作任意的数据或数据集合。

这些系统中,比较典型的是谷歌的BigTable。在BigTable中,每一个数据项可以看作一个三元组map中的元素:

Map<triple_key(row_key, col_key, timestamp), value>

BigTable本质上可以看作一个 Key-Key-Tag-Value,其中第一个key是行key,第二个key是列key,Tag是timestamp,因此,在数据结构表示上,BigTable是KNV的一个子集。

3. 协议处理性能

我们把协议处理分为两类操作,一类是读操作,一类是写操作(包括更新和删除)。对于大多少读多写少的业务而言,读性能要远比写性能重要得多,因此,KNV专门对匹配请求树来获取子树的操作进行优化。

我们针对做了跟前面打解包类似的性能测试,测试步骤做了很小的变化:

构造请求树 -> 构造数据树 -> 遍历100w次执行:解开请求树,根据数据树提取子树,删除解开的请求树和提取的子树。

实现方法

数据大小

耗时(s/百万次)

解包提取次数(w/s)

Key-N-Value

1 SubKey * 10 Field

请求树27字节

数据树100字节

1.08

92.6

5 SubKey * 10 Field

请求树91字节

数据树458字节

3.89

25.7

可见,提取的性能比打包的性能还好,这主要归功于解开子树时同时建立的基于bitmap的哈希表

根据UnifiedCache实践的经验,通常情况下,对于1级key的业务,处理性能在100w左右,对于10个子key以内的二级key业务,处理性能在20w左右。这个两级对于处理请求量20w/s以内的服务器来说,已经是绰绰有余的了(服务器基本上都同时有多个CPU在工作)。

九、结束语

最后,我想说的是,KNV是Unified Cache(UC,腾讯内部的一个Key Value存储系统)的产物,无模式的需求源于通用存储,嵌套的需求源于对多级key的支持,自描述的需求则源于请求树。

请求树是KNV一个最主要的特色,可以说,没有请求树,KNV就无法做协议的通用化处理。

应用上,UC已经接入了即通几个主要的业务,包括查找、备注、关系链和登录,总共部署超过200台机器。

在UC之外,KNV的使用也越来越广,目前使用KNV的业务包括来电、QQ资料后台、即通统一接入层等,总共部署的机器超过1千台。

KNV已经作为腾讯公司内部公共组件内部开源了,请在code平台搜索KnvProtoEngine。

如果你觉得KNV是一个好东西,就为我们加油吧^^

  • 参考文档

1. UnifiedCache -- 一个基于Key-N-Value存储结构的通用Cache  (敬请期待后面分享)

2. 对象池 mempool/obj_pool.h at main · shaneyuee/mempool · GitHub

3. 内存池 GitHub - shaneyuee/mempool: A very fast memory pool in C++ for speeding up memory allocation.

4. PB裸编解码引擎 (敬请期待后面分享)

5. TLV编码的介绍

http://en.wikipedia.org/wiki/Type-length-value

6. 谷歌的Protocol Buffers

http://code.google.com/p/protobuf/

7. 谷歌的FlatBuffers

FlatBuffers: FlatBuffers

8. Cap'N Proto

http://kentonv.github.io/capnproto/

9. 谷歌的BigTable

http://static.googleusercontent.com/media/research.google.com/zh-CN//archive/bigtable-osdi06.pdf

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值