比赛结束之后也一直没空回顾,趁假期总结一下历程。

初赛赛题

赛题背景

Dubbo

Apache Dubbo (incubating) Architecture

Apache Dubbo (incubating) 是阿里巴巴中间件团队开源的一款高性能 Java RPC 通讯框架。在分布式应用场景下,服务间通讯是非常重要的能力,通常由服务提供者暴露服务,由服务消费者调用服务。在 Dubbo 服务整合能力的支持下,使用 RPC 可以像使用本地调用一样轻松、便捷。但是在异常复杂的系统环境下,服务间调用也会变得非常复杂,如果没有一套完善的、经过大规模生产环境验证的服务治理能力的话,系统将会处于非常危险的境地。因此,从另一个方面来讲,Dubbo 不只是单纯的服务通讯框架,更是一套完备的服务治理框架。

Service Mesh

Service Mesh Architecture

提到服务治理能力就不能不说一下微服务。微服务不光是创造性的将曾经的单体系统拆分为若干个独立的微服务系统,更重要的是其为这些服务的和谐运行提供了最佳实践和解决方案。服务注册、服务发现、服务治理、负载均衡、服务监控、流量管控、服务降级、服务熔断和服务安全等等,这些能力都是一个可用和可靠的微服务系统所不可或缺的。微服务的一大问题在于改造过程必须深入服务内部,拿使用 Dubbo 来说,所有接入的微服务系统都必须引入 Dubbo 组件,并暴露或消费相关的服务。

而 Service Mesh 则另辟蹊径,其实现服务治理的过程不需要改变服务本身。通过以 proxy 或 sidecar 形式部署的 Agent,所有进出服务的流量都会被 Agent 拦截并加以处理,这样一来微服务场景下的各种服务治理能力都可以通过 Agent 来完成,这大大降低了服务化改造的难度和成本。而且 Agent 作为两个服务之间的媒介,还可以起到协议转换的作用,这能够使得基于不同技术框架和通讯协议建设的服务也可以实现互联互通,这一点在传统微服务框架下是很难实现的。

有关 Service Mesh 的更多内容,请参考下列文章:

赛题由来

众所周知,Dubbo 的 RPC 通讯和服务治理能力一直局限在 Java 领域,因此增加多语言适配是建设 Dubbo 生态环境的一个重要方向。随着微服务及相关技术实践的落地,Service Mesh 已经成为分布式场景下服务化改造的热门解决方案,并与底层设施及周边环境实现了很好的融合,这些都与 Dubbo 的能力如出一辙,未来 Dubbo 将有可能发展成为 Service Mesh 的一种通用解决方案。

在初步了解了 Dubbo 和 Service Mesh 的情况下,我们来实现一个简化版本的 Agent,用 Service Mesh 的思想对 Dubbo 进行一下改进。

赛题说明

系统架构

系统架构

图中每个蓝色的方框代表一个 Docker 实例,全部运行在一台宿主机上。最上面的一个实例运行有 etcd 服务,左边的一个实例运行有 Consumer 服务及其 Agent,而右边的三个实例运行有 Provider 服务及其 Agent。从图中可以看出,Consumer 和 Provider 并不会直接通讯,所有进出服务的流量都需要经过 Agent 中转。

服务

etcd 是注册中心服务,用来存储服务注册信息,为了简化系统复杂度,etcd 是单节点运行的,并没有部署高可用能力。Provider 是服务提供者,Consumer 是服务消费者,Consumer 消费 Provider 提供的服务。Consumer 及 Provider 服务的实现是由赛会官方提供的。

在系统场景设定中,每个运行服务的实例所占用的系统资源都是不同的,如下表所示:

实例 百分比
操作系统 5%
运行 etcd 服务的实例 5%
运行 Consumer 服务及其 Agent 的实例 45%
运行 Provider (small) 服务及其 Agent 的实例 7.5%
运行 Provider (medium) 服务及其 Agent 的实例 15%
运行 Provider (large) 服务及其 Agent 的实例 22.5%
总计 100%

从表中可以看出,运行 Consumer 服务及其 Agent 的实例(为了便于描述,下文将简称为 Consumer 实例,Provider 实例类似)占用的系统资源是最多的,而三个 Provider 实例占用的系统资源总和与 Consumer 实例是相同的,而且按照 small:medium:large = 1:2:3 的比例进行分配。

在每个 Consumer 和 Provider 实例中,都存在一个以 sidecar 形式运行的 Agent,其在整个系统中起到了非常关键的作用。

第一、Consumer 服务是基于 Spring Cloud 实现的,其远程通讯协议使用 HTTP。但是 Provider 服务是基于 Dubbo 实现的,其远程通讯协议使用 DUBBO。因此在没有任何外界支援的情况下,Consumer 和 Provider 服务是无法直接通讯的。这就要求 Agent 实现 HTTP 协议到 DUBBO 协议的转换。有关 DUBBO 协议的格式,请参见附录3。

第二、因为任何一个 Provider 实例的性能都是小于 Consumer 实例的,这就要求在 Agent 实现的过程中考虑负载均衡的因素。

第三、Consumer Agent 在负载均衡过程中到底需要访问哪一个 Provider Agent 不是在配置文件中写死的,而是需要通过服务注册与发现机制来完成。在 Agent 启动的时候,其要把相关服务的信息写到注册中心里,当服务调用发生的时候,再从注册中心中读取信息,并路由到指定的服务节点。

服务运行及调用流程
  1. 启动 etcd 实例
  2. 启动三个 Provider 实例,Provider Agent 将 Provider 服务信息写入 etcd 注册中心
  3. 启动 Consumer 实例,Consumer Agent 从注册中心中读取 Provider 信息
  4. 客户端访问 Consumer 服务
  5. Consumer 服务通过 HTTP 协议调用 Consumer Agent
  6. Consumer Agent 根据当前的负载情况决定调用哪个 Provider Agent,并使用自定义协议将请求发送给选中的 Provider Agent
  7. Provider Agent 收到请求后,将通讯协议转换为 DUBBO,然后调用 Provider 服务
  8. Provider 服务将处理后的请求返回给 Agent
  9. Provider Agent 收到请求后解析 DUBBO 协议,并将数据取出,以自定义协议返回给 Consumer Agent
  10. Consumer Agent 收到请求后解析出结果,再通过 HTTP 协议返回给 Consumer 服务
  11. Consumer 服务最后将结果返回给客户端
  12. 结束

每个通讯环节所使用的协议如下:

通讯环节 序列化协议 远程通讯协议 备注
Client => Consumer (无参数传递) HTTP
Consumer => Consumer Aagent FORM HTTP
Consumer Agent => Provider Agent FORM HTTP 可根据需要自定义
Provider Agent => Provider JSON DUBBO
Provider => Provider Agent JSON DUBBO
Provider Agent => Consumer Agent TEXT HTTP 可以根据需要自定义
Consumer Agent => Consumer TEXT HTTP
Consumer => Client TEXT HTTP
功能与接口

Provider 服务接口:

1
2
3
4
5
6
7
8
9
10
public interface IHelloService {

/**
* 计算传入参数的哈希值.
*
* @param str 随机字符串
* @return 该字符串的哈希值
*/
int hash(String str);
}

Provider 接口的实现会人为增加 50ms 的延迟,以模拟现实情况下查询数据库等耗时的操作。

Consumer 在接收到客户端请求以后,会生成一个随机字符串,该字符串经过 Consumer Agent 和 Provider Agent 后到达 Provider,由 Provider 计算哈希值后返回,客户端会校验该哈希值与其生成的数据是否相同,如果相同则返回正常(200),否则返回异常(500)。

Consumer 发送给 Consumer Agent 的 HTTP POST 请求格式如下:

key value 说明
interface com.alibaba.performance.dubbomesh.provider.IHelloService 拟调用的服务名。因 Provider 只暴露了一个服务,因此这个参数是固定的。但考虑到实现的通用性,该值不允许缓存。
method hash 拟调用的方法。同理 Provider 只提供了一个方法,因此该值也是固定的。不允许缓存。
parameterTypesString Ljava/lang/String;(注意这后面有个分号) 同一个方法名可能会有重载的版本,所以需要指定参数类型来确定方法的签名。由于只存在一个方法重载,这个参数是固定的,永远是Ljava/lang/String;。 Dubbo 内部用它来表示方法的参数是 String 类型。不允许缓存。
parameter <生成的随机字符串> 传递给 hash 方法的参数值,是 Consumer 生成的一个随机的字符串。
要求与限制

由于本次比赛是不限语言的,因此仅对 Agent 的能力做出要求。Agent 必须实现如下一些功能:

  1. 服务注册与发现
  2. 负载均衡
  3. 协议转换
  4. 要具有一定的通用性

同样由于不限定语言,本次比赛将构建 Consumer 和 Provider 镜像的主动权交给了参赛选手,选手们可以根据自己使用的技术和实现手段对镜像进行定制——可以安装额外的运行时环境、添加依赖库、调整 Agent 启动参数等,但如下一些行为是不被允许的:

  1. 必须使用官方提供的 Consumer 和 Provider 实现、以及启动脚本,不允许对其进行修改(如果发现缺陷,请提交 Merge Request 或发起 Issue)
  2. 启动 Consumer 和 Provider 所使用的 JDK 版本必须与官方镜像中提供的版本保持一致(当前版本是 Oracle JDK 1.8.0_172-b11)
  3. 不允许通过脚本等手段停止官方启动的服务后替换为自己的服务
  4. 不允许在 Consumer 和 Provider 运行过程中使用一些字节码增强技术替换现有实现
  5. 不限制使用第三方应用服务器,如 Tomcat、Nginx 或 Envoy 等,但不可以使用现成的 Service Mesh 解决方案
  6. 可以参考第三方实现,借用其思想和少量代码,但不可以全盘复制

初赛总结

思路

可以刨去很多背景的东西,这个赛题大概是说有一个 Consumer,几个 Provider,Consumer 想去调用 Provider 提供的计算,然后我们得实现两个 Agent,Consumer 只需要调用 ConsumerAgent(简称CA),Provider 不做任何改动直接暴露服务,CA 则通过 ProviderAgent 对 Provider 的服务进行调用。

具体来说,Consumer Provider 可以通过引入 Agent 无痛地提供服务、调用服务,至于注册到 etcd、负载均衡之类的事情,交给 Agent 来做。这就意味着 CA 和 PA 之间需要设计通信协议,CA 需要提供 Consumer 可调用的接口,PA 需要把来自 CA 的请求转换成 Provider 提供的协议,并把 Provider 的结果反馈给 CA。

负载均衡的思路比较明确。一般来说,负载均衡需要解决的问题:如何选择负载指标、何时触发负载均衡、选择节点上哪些任务进行迁移、怎样选择合适的目标节点、系统中哪部分节点进行合适目标节点的搜寻。当然,在这个问题下不需要考虑迁移这种复杂的情况,负载指标其实也不用考虑太多,因为3个 Provider 的资源是按比例分配的,静态地按比例进行负载均衡即可。

比赛的分值是 QPS,这个可以通过每个请求的最小时间进行计算。好像之前计算过,理论上可以达到7200的QPS。 剩下的就是怎么样设计 PA 和 CA 的通信协议,怎样实现 CA 和 PA。

最后选择的协议设计的原则是,定长部分协商好,变长部分发送之前先发送数据长度。最理想的情况是顺序 write,顺序 read,然而没有想到特别好的办法能顺序 write。(另一种选择是在边长数据末尾添加一些分隔符,这样可以顺序读写,但是总觉得有点 tricky ,不是很想用)压缩啥的就算了,好像是随机数据为主,感觉没必要压缩。

实现

具体实现真是个漫长而艰难踩坑的过程。主要原因还是菜,没有经验。

因为我以前只 Python 用的比较多,JAVA,C++ 都不太会。看完题考虑了一下,知道 Python 性能比较差劲,就决定现学一下 Netty,用 java 来写,因为不敢踩 C++ 的坑,也不知道 C++ 应该怎么选择底层网络库。拿 Netty 写了一版,实际测试效果很差,具体数字忘记了,好像最高就 2900 的 QPS,而且原因不明,排查了一两天也没有发现问题,整个代码库已经非常简单,都是 Netty 官方文档里的用法,让做 java ,用过 netty 的队友帮着看了看,也没发现坑点所在。

于是决定先拿 Python 写一发看看,没准用了 pypy 分数还可以接受。就拿比较熟悉的 tornado 新写了一版,其实就是实现一个 TCPServer,不得不说 Python 快速开发是真的快。。。因为使用了 tornado,从 tornado 读写数据这一步基本没有办法去优化,只能在外围做各种优化,试着改了各种 TCP 参数多进程之类的,pypy cpython 来回换,最高大概能达到 3500 的 分数,而且大概能确定拿 Python 很难得到更大的提高,也不想去尝试其他的网络库了(tornado 在 linux 依赖的就是 epoll,我认为用其他库也不会有很大的提升,毕竟Python速度慢摆在那里,tornado 对数据的读写的源码以前看过,不认为其他库或者自己手写会快很多)。

Python 版的意义是方便了后续的测试,而且分析日志的时候拿 Python 写脚本也很方便。然后 java 系队友现学了一发 vert.x ,我也跟着学了学。和队友一起用 vert.x 捣鼓了一版 ConsumerAgent,ProviderAgent 仍然使用 Python 版,因为当时数据分析来看,Py 的瓶颈主要在 ConsumerAgent ( Py 的计算效率确实低 )。然后分数渐渐的是3700, 3800, 4000, 4800。最高的时候达到了 5200。从日志分析,感觉主要问题还是在 CA 瓶颈。

vert.x 达到 5200 的时候,离比赛结束还剩三五天了,这时候感觉 vert.x 难以再高,可以选择把 PA 也拿 vert.x 重写,但是从日志分析来看,可能提升也不会很大。当时的想法是,Netty 难驾驭,那用 C 咋样,不像 Netty ,容易踩各种坑。幸亏 C 学的还可以,在 libuv, libevent 之间选择了 libuv,然后 http 的解析用的是 nodejs 的 http-parser,虽然 libuv 的文档匮乏,但是翻翻官方文档和代码,基本能对着开始写代码。写的代码就基本是 C with STL 了,基本上纯 C,中间有个地方想用平衡树自己管理一个内存池(考虑到请求大小的特性,类似于 slab)于是用了 C++ STL。比较痛苦的就是糙快猛,自己进行内存分配回收总归还是有点问题(当时可以说是不懂 C++,不知道什么 RAII, 写了一脸的 malloc 和 free)。然后各种方法去让代码 work (比如 malloc 的时候多分配1字节之类的trick)。等到比赛还有一两天结束的时候,总算调通了代码,用 libuv 实现了 CA 和 PA。其中Python版 CA 和 PA 起了很大的参考作用。全部跑通之后一提交就是 5800,然后看了看代码,修复一些内存问题,顺利跑到了 6200,然而此时比赛已经快结束了,提交队列非常拥堵,评测机一直是炸了的状态,分数可能都偏低。至今我也不知道如果评测机正常,并且修好所有内存问题,再做一些其他优化到底能达到几分。

前几名的分数大约是 6800,6900。看了大佬的分享,感觉处理办法基本差不多,只不过大家都是 C++/JAVA ,还看到一些优化是通过非常 hard code 的协议设计,个人感觉有点取巧。不过这次比赛也算是很大的实践课了,踩了各种坑,认识了 Python 的慢和作用。

复赛由于时间不够用没有参与。总体感觉可惜,但也学到了些东西。

流水账记了一堆,代码库在这 pyagent。 libuv 还是不错的,值得自己封装的更顺手一些。