[重磅] 关于服务调用和消息推送的解题思路

640?wx_fmt=jpeg

640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1

大赛背景


第四届阿里中间件性能挑战赛是由阿里巴巴集团发起,阿里巴巴中间(Aliware)、阿里云天池联合举办,是集团唯一的工程性品牌赛事。大赛的初衷是为热爱技术的年轻人提供一个挑战世界级技术问题的舞台,希望选手在追求性能极致的同时,能深刻体会技术人的匠心精神,用技术为全社会创造更大的价值。


本文是亚军选手“做作业”的解题思路,来自南京理工大学的95后开发者

初赛部分


一、赛题重述及理解


初赛主要的考察点是实现一个高性能的http服务器,一套优秀的负载均衡算法。而协议转换仅需要对协议进行详细了解即可实现,属于基本技能考察。而服务的注册和发现参考demo中的实现即可,属于比较常用的处理逻辑。


针对高性能的http服务器,想要在竞赛环境下取得较好的成绩,使用常用通用http框架是较难取得高分的,因为consumer agent和consumer共享一个docker运行环境,在512连接测试环境下,系统资源就已经很紧张了,需要在舍弃一部分http特性的情况下才能将consumer性能发挥到极致。所以自己造轮子是一个比较合适的方案。Consumeragent的总体结构(所有逻辑基本集中在这)如图1所示。


640?wx_fmt=png

图1 Consumer Agent结构


二、网络编程模型的选择


高并发网络模型首选epoll,在连接数增多的情况下,其性能不会像select和poll一样明显下降,是由于内核对其进行了特殊优化,不是线性遍历所有连接。实际测试中发现,因为请求是在收到响应后才会继续发出,所以不仅要求高并发,同时要求低延迟,因为延迟叠加的原因也会导致QPS下降。


而我们使用的常见的异步网络库libuv、libevent等都是one loop per thread的,即一个线程一个事件循环,这种设计具有较高的吞吐量,但请求只能绑定到一个事件循环上,一旦请求不均匀,则会导致一核有难众核围观的情况,对于动态均衡线程负载很不适合。


基于此原因我决定自己造轮子,让多个线程处理一个epoll循环,让操作系统的线程调度均衡请求,同时所有连接均绑定到一个事件循环上,也便于处理。最终我选择使用多线程的et模式的epoll模型,整体框架代码如图2所示。


640?wx_fmt=png

图2 多线程epoll框架


整体框架非常简洁,所有网络处理类仅需继承CepollCallback完成网络数据的读取和写入即可。


640?wx_fmt=png

图3 epoll回调抽象类


三、程序整体流程


Consumer端agent负责解析http请求转换为dubbo请求,以udp形式发给provider agent处理,provider agent收到udp后直接传给provider dubbo服务处理,将结果以udp形式回传,consumer agent处理后返回http形式结果。


640?wx_fmt=png


640?wx_fmt=png

图4、5 CA/PA处理流程


程序初始化的时候provider agent调用etcd接口将自己的服务注册,consumer agent调用etcd接口获取到注册的provider agent地址,由于采用udp协议通信,故不用提前建立连接。Provider agent端也仅在第一个请求到达时建立到provider的tcp连接,保证provider已经启动完毕可以接受请求。


四、中间协议设计


考虑到高吞吐量,低延迟,采用了udp协议,请求为单包单请求,数据格式为dubbo请求格式,相应包为单包多相应,数据格式为dubbo响应。即consumer agent端实现协议转换,provider agent端实现udp转tcp。


由于请求仅存在于一个包中,所以无需考虑乱序情况,直接将收到的数据包进行重组发到dubbo的tcp连接上,提供最大的吞吐量和最低的延迟。具体处理操作如图3。


640?wx_fmt=png

图3 udp重组转发tcp


同时为了减少系统调用,可以一直取udp包,当缓冲满后一次性写入tcp的缓冲中。同理在tcp转udp时,可以将数据凑成接近MTU,减少网络压力。如图4所示。


640?wx_fmt=png

图4 udp单包多相应


还有很多工程上的技巧,比如调整缓冲区大小等,保证每次异步调用都能成功,减少系统调用次数。


五、负载均衡算法


负载均衡采用计数方法实现,每次请求发送到和配置负载比例差距最大的Provider上,如果3台都超出200负载,则进行请求排队,当有请求返回时,优先调度排队请求。整体如图5所示。


640?wx_fmt=png

图5 负载均衡算法


六、创新点及工程价值


  • 1、consumer agent实现负载均衡和请求控制,能够有效在consumer使用排队机制端遏制过载,不把过载传递到网络传输上。

  • 2、两个agent间使用udp传输,利用udp的低损耗,因为是单包单请求,即使乱序也能随意合并重组,丢包不会造成协议出错,仅造成超时出错,非常适合这种实时性和吞吐量要求高的情况。

  • 3、在请求id中存储了调用者和时间戳,能正确相应合适的http调用者的同时,能够统计性能数据,作为调参依据。

  • 4、多线程等待epoll的et模式,能够充分挖掘网卡通信潜力,不存在单线程拷贝速度比网络传输慢的情况,同时省去了任务队列,每个处理线程具备一定的CPU计算处理能力,也能最小化响应时间,使用自旋锁后保证一个连接的消息不会被多个线程处理,健壮性有保障。

  • 5、设置合适的tcp缓冲区大小,在不阻塞情况下保证每次write调用都会成功。


复赛部分


一、  赛题重述及理解


复赛的主要考察点是单机实现100W规模的队列信息存储与读取,具体考察如何在单进程空间中的实现1M个队列的信息存储,如何设计合理的数据存储及索引结构,在特定硬盘的情况下完成高性能的顺序写、随机读和顺序读功能。同时由于测评环境是4C8G的,如何合理分配利用系统资源也是很重要的(任何对队列存储的东西都会放大100W倍,同时维护这么多数据需要占用更多的系统资源)。


针对上述考察点,本队提出了一套完整的队列消息数据存储方案,稀疏索引存储方案。程序开发迭代中的总共提出了3套写入方案,2套读方案,分别在Java和C++平台上进行了实现。


二、  整体系统架构


系统的整体结构较为简单和清晰,主要分为以下几个部分:每个队列对应一个对象,用于维护buffer等必要结构;一个容器负责将队列名映射到队列对象;文件管理对象负责维护读写文件;负责整体读写状态切换和管理的控制模块。如图6所示。


640?wx_fmt=png

图6 复赛系统整体架构


三、  消息存储方案


考虑到顺序写、随机读、顺序读的使用场景,参考文件系统以簇为单位的文件存储方式,设计了以块为单位的队列消息存储方式。


640?wx_fmt=png

图7 队列消息存储方式


考虑到不定长消息及超长消息的情况,设计了变长整形+消息体的结构记录单条消息,同时支持跨块的消息。


640?wx_fmt=png

图8 消息数据存储方式


每个块开头都是变长整形,每个VarInt存储的都是目前还剩的消息体长度(便于快速跳过或判断消息是否继续跨块),这种结构支持任意长度的消息,如果块剩余空间不足写VarInt,则padding。


考虑到节省磁盘空间,在CPU充足的情况下,可以采用消息数据压缩,但是线上CPU紧张,考虑到通用性并未使用特化压缩算法。实际测试中由于消息生成性能及CPU性能的问题,未开启压缩功能,但在程序中预留了压缩接口,以适应各种场合。图9所示为预留的压缩接口。


640?wx_fmt=png

图9 预留的压缩接口


四、  队列写入方案


设计好了消息存储方案,剩下的就是合适的消息读写方案了,由于读取方案比较固定,而写入由于系统资源的匮乏(4C8G),需要合理设计。


1、mmap方案(Java实现)(淘汰)


本方案使用引用计数机制控制mmap生命周期,同时mmap的块存储在hash cache(定长slot基于hash冲突淘汰的cache算法)中,cache也会增加引用计数,这样在连续写文件时保证了较好cache命中率。实现结构如图10所示。


640?wx_fmt=png

图10 mmap方案


  • 优点:完全不用flush操作,因为读写都是引用mmap实现的。

  • 缺点:由于page cache脏超过20%会block写入线程,为了不阻塞线程,队列块的大小必须非常小(512bytes,1M队列512MB),性能较差。


2、整体内存引用方案(Java实现)(淘汰)


考虑到mmap直接作用在page cache上,受脏页比例影响,难以提升缓冲大小,故考虑使用整体内存方案,机制和mmap类似。同样使用引用计数机制控制写入缓冲区内存块的生命周期,额外使用独立线程负责将缓冲区刷盘。该方法能够等待新缓冲区内存块有效保障不写入过载,同时使用环形缓冲实现内存块及等待对象复用。使用2K大小block的java程序线上成绩为167w。方案整体流程如图11所示。


640?wx_fmt=png

图11整体内存引用方案


  • 优点:没有额外拷贝,异步写入不会阻塞。

  • 缺点:由于内存限制,存在写入线程等待刷盘数据释放,block大小受限制(2K,如果使用4K则会存在写入等待内存交换出来,写入有波动,性能不是最优)。


3、分片内存池方案(C++实现)(最优成绩)


综合前2种方案后,提出采用分片内存池+iovec内存重组写入的方案。具体实现为将block分为更小的slice,每次缓冲区申请一个slice,写满一个block后用iovec提交。


写入线程将提交的slice用iovec拼接成更大的part,整体写入磁盘。写入后将slice释放回内存池便于后续队列缓冲使用。虽然均匀写入是一种糟糕的写入方式,存在缓冲申请激增的情况,但由于分片机制的存在,每次激增为1M*slice的大小,可以通过slice大小控制。具体流程如图12,13所示。


640?wx_fmt=png

图12分片内存池方案


  • 优点:更细粒度的内存分配和释放,4K大小block情况下缓冲区能均匀移动,不会阻塞,写入和落盘同步进行;能在有限内存中实现更大的block。


640?wx_fmt=png

图13 分片内存方案细节


内部使用的ring buffer方案及代码如图14所示,所有对象均实现了复用。


640?wx_fmt=png

图14 ring buffer实现细节


五、  索引方案


由于内存空间限制,索引记录所有消息的偏移并不是个好方案。本系统方案采用了稀疏索引,因为设计的消息存储结构保证块内消息能够自行区分,仅记录块信息即可。只要能知道消息所在的块的位置和消息是该块的第几个即可定位到该消息。


每个消息块仅需要记录3个信息:块所在文件偏移(块号);块内消息数;上个块消息是否跨块。通过索引定位到具体消息数据的方法也十分简单。


  • 1、通过从前往后累加每个块的消息数量,快速定位到消息所在块;

  • 2、通过索引查找到块所在文件偏移,并完整读出块(4K读);

  • 3、通过是否跨块信息和目标消息ID,确定需要跳过的消息数,快速跳过无用消息。

  • 4、读取目标消息。具体流程如图15所示。


640?wx_fmt=png

图15 消息数据定位流程


定位完成后可以连续读,一旦发现长度超出当前块,则说明读到跨块消息,继续切到下一块读取即可。


六、  随机读与连续读


随机读取的消息落到一个块内有较大的概率,故即使是读一个小范围的数据,大部分情况能一次IO读取完成。针对连续读,记录每次读取到的位置,如果下次访问是从0或上次读取的位置,则认为是连续消费,对队列的下一个块发起readahead操作,将其读入到page cache中,实测可以提升40%性能。由于块的大小是4K,连续读时不会读入任何无效数据,操作系统的page cache能提供非常高的读取性能。


七、  Map容器优化


由于消息写入接口是以队列名对队列进行区分的,队列名到队列对象的映射也是系统的瓶颈所在。针对数字后缀队列名进行特殊hash计算处理,可以降低冲突概率。对于后缀计算hash冲突的情况,使用正常哈希计算,离散到32个加锁的unordered map中进行存储,保证鲁棒性和性能。由于后缀的区分度高,spin lock基本没有冲突,100W插入比传统map快几百ms,100W查找快几十ms。Suffix map结构如图16所示。


640?wx_fmt=png

图16 suffix map结构


八、  读写状态切换


程序一开始就设计有内存和磁盘协同工作的代码,但由于缓冲占用内存过大,使得后续随机读和连续读(依赖page cache)存在性能下降的问题,采用了读之前将缓冲全部落盘的策略。


在第一次读的时候,block所有读线程,落盘,释放所有缓冲内存(~5.5G)然后开始读取操作。由于空余缓冲是全部填充padding的,即使再写入队列,数据也不会出错。同时整体状态可以切换回写状态(需重新申请缓冲内存)。


九、  创新性与工程价值


  • 1、针对内存不足使用内存分片+iovec重组写技术,达到在小内存的情况下写内存和写硬盘同时进行的目的,极大提升了写磁盘性能。线上测试写时存在CPU idle>0现象,说明生产已大于写盘速度。

  • 2、针对队列名称改进了map,提出suffix map,针对后缀进行hash(现实情况下也大量存在编号结尾的队列名),极大降低了hash碰撞几率(测评数据中没有碰撞),同时用自旋锁和多个细粒度的unordered_map处理了碰撞情况,提升性能的时候保证了map的正确性鲁棒性。

  • 3、先写缓冲,缓冲满了排队,最后填满一个大block后刷盘,极大利用了SSD顺序写入性能,同时在队列不均匀时也能提供同样的高性能。

  • 4、变长整形+消息存储,及跨块消息,超长消息的处理机制保证任意长度消息都能正确处理,并有同样的高性能。

  • 5、稀疏索引,只对块的偏移和消息数量做记录,整个索引大小仅29*(4(blockId)+2(number))*1000000≈166MB,完全可以存储在内存中。

  • 6、使用ring buffer,分散写入和等待对象,采用引用机制(最后释放引用的线程负责提交该块到写入队列中),工作期间没有任何对象创建和销毁,建立在数组上,充分利用cpu cache,提升了性能。

  • 7、整体内存分配,然后分片,使用完成后整体释放,降低了内存管理的cpu消耗。

  • 8、设计时考虑了缓冲和磁盘协同工作读,不用将缓冲刷盘仅仅只用等待当前io完成,正确性可以保证,但由于占用太多内存,性能不好,故后期改为全刷盘操作。

  • 9、设计时候考虑了队列索引自增涨算法,但由于tcmalloc分配内存不释放,后面改为定长的了(有宏控制开关)。

  • 10、所有长度,池大小等参数都可以拿宏修改,可以修改适应大内存等各种情况。

    

本号对所涉及的技术做了总结、汇总和打包(20+本),请识别小程序或点击原文链接获取电子书详细信息。


640?wx_fmt=jpeg&tp=webp&wxfrom=5&wx_lazy=1


推荐阅读



温馨提示:

请搜索“ICT_Architect”“扫一扫”二维码关注公众号,点击原文链接获阅读原文了解更多

640?wx_fmt=png&tp=webp&wxfrom=5&wx_lazy=1

求知若渴, 虚心若愚

640?wx_fmt=gif&tp=webp&wxfrom=5&wx_lazy=1

猜你喜欢

转载自blog.csdn.net/BtB5e6Nsu1g511Eg5XEg/article/details/81351343