一个RPC服务客户端代理中间件的设计过程的回顾
0 前言
近两年来,负责重新设计和开发一组RPC服务调用的中间件的通讯模块,包括RPC服务端模块(RPC服务容器I/O通讯模块),以及一个RPC客户端代理模块。
在整个设计开发过程中,经历了多次模块重构,终于实现预设的功能以及性能目标。
写这篇文章的目的,一是觉得有必要对这两年的思考、设计和开发过程做一次阶段总结,将这段时间的经验和尝试,以文字的形式沉淀下来,二是为了给将来的进步设定一个认知的基线。
1 设计需求
RPC服务通讯中间件的需求,主要设定了功能和性能两方面的内容。总的来说,实现一个高性能、低延迟的RPC服务中间件, 将客户端的业务处理请求,相对均衡地分配到后端服务执行。
1.1 功能需求
RPC服务中间件的通讯模块,主要包括客户端代理和服务端I/O通讯模块两部分组成,实现分布式服务的透明调用。
客户端代理中间件模块的主要功能包括:
- 接收业务请求并组成RPC请求消息,并发送到远端服务。
- 监听Socket连接获取服务端回复消息,从回复的RPC消息提取业务对象,返回给业务模块。
- 服务的发现。客户端可以动态的获取到指定服务实例的地址。
- 服务Socket连接的动态维护。如运行过程中关闭异常连接、新增连接等。
1.2 性能需求
性能需求主要在于整个通讯模块整体的吞吐量,以及单个消息的处理延时指标上。这些指标主要包括:
- 单个业务的单次请求的空载响应时间指标
- 空载:业务对象内没有实质数据,服务端不做实质处理。
- 平均每个CPU CORE的吞吐量指标。(因现在很多系统以CPU核数收费,提高单核效率能有效降低成本)
- 服务端进程数扩展后总吞吐量的增长率
【这些指标在不同的测试系统上,具体的数值有差异,因此不在此处列出具体数据。】
2 第一个设计版本 - 同步多线程通讯模式
最初,由于上一版本的系统,对于消息通讯的完整性没有完整的设计,当请求消息因故超时且无法到达后端服务时,不能被前端业务模块所感知,导致在异常情况发生后,数据处理的完整上存在较严重的缺陷。 因此,在当前系统设计和评审时,各方意见都强调数据的完整性,请求发送成功与否需要立即被业务层感知,并因此主张客户端实现全同步调用,每个客户端线程发送消息后,等待直至服务端回应。因此,第一个版本的客户端代理,设计了全同步模式。
2.1 总体结构
- 一个客户端同时连接多个服务端
- 使用Zookeeper实现服务的注册和发现
2.2 同步多线程通讯模式客户端设计
这一版本的优势比较明显:
- 模型简单,对业务开发较为便利;
- RPC调用的成功或者失败,业务能够立即感知;
- 单次RPC调用几乎没有额外开销, 因此单次请求的性能指标很容易达到。
但是,RPC消息发送后的阻塞等待,使得其劣势也很突出:
- 单线程的吞吐量很低。如果要提高总吞吐量,必然需要多线程并发执行。
- 总有效线程数受制于CPU核数,过多线程并没有太大意义。
- 过多的线程频唤醒和阻塞,使线程的CS切换消耗了大量的CPU时间,降低了单位CPU的利用率。
因此,通过增加线程数提升总吞吐量的方案,并不能提升每CPU核的平均吞吐量,而且总吞吐量的提升最终也不能达到目标。CPU单核利用率的低下,意味着这个方案必须被重构。
3 第二个版本 - 异步多线程通讯模式
第一个版本中,客户端大量的时间被耗费在等待服务回应上,单一线程的效率极低。而很多低效线程堆积出来的吞吐量,占用了大量的CPU时间用于线程CS切换。
因此,第二版本的改进关键在于,提升单线程的工作效率。既然大量时间耗费在同步等待上,那么放弃同步通讯模式而采用异步模式,成为主要的改进目标。
3.1 客户端异步多线程模式
此版本的RPC客户端代理模块,只要包括3个部分组成:
- RPC连接池。用于存储所有与RPC服务端的连接。
- RPC消息发送线程池。其中线程专用于发送请求,发送完成后立即返回,通过增加池内线程扩展总吞吐量。
- RPC消息接收线程池。其中线程专用于接收服务端的消息后,通过回调的方式将回复消息传递给业务模块。
3.2 异步多线程模式的测试结果
此方案利用了Socket的全双工特性,将消息的发送和接收分离到不同的线程中处理,消息发送后不必等待回复,即可继续发送下一条消息。通过测试,效率和吞吐量大幅提升,在总线程数配置到一定值后,吞吐量达到峰值,基本达到了单一任务处理时间和总吞量的预定目标。
但是,测试结果还显示,吞吐量峰值时,CPU占用过高(几乎占满了当时测试系统的所有CPU核心),测算下来每核心的性能仍然偏低,且后续增加线程数性能提升不明显(线程数增加,平均每线程的性能提升量大幅衰减)。
后续经过分析,得出几个问题点:
-
单一的共享RPC连接池,面对线程池里众多线程的存取连接操作,形成了严重的冲突(连接池的同步访问锁冲突)。
单一的连接池,原本是为了对将来可能存在的大量服务连接,在池内做统一的管理维护,但却带来的严重的瓶颈。 -
网络的偶尔不稳定,会使消息接收线程出现阻塞卡顿。
一直使用阻塞模式的Socket,而当网络不稳定时,阻塞模式会进入等待,引起线程CS切换。
尽管第二个版本性能提升很大,但测试情况仍不十分理想,单CPU核心的效率仍然提升有限。于是,结合此版本的测试问题,开始了第三版的重构。
4 第三个版本 - 异步非阻塞Socket线程组模式
多线程共享访问一个连接池,以及阻塞的Socket I/O操作,成为瓶颈所在,多线程无法线性地提升性能和总吞吐量。因此在第三个版本中,着重处理这两个问题,提升单线程的效率,以及实现多线程对性能的线性扩展能力。
4.1 异步非阻塞Socket线程组模式结构
此版本模式的设计要点,主要包括:
- 以线程组为单位实现横向扩展,线程组间没有共享资源,以实现性能的线性扩展。
- 每一个线程组中,包含一个Socket发消息线程,一个收消息线程,和一个连接池,所有Socket读写操作都是非阻塞的,读写全双工。
- 发消息线程从连接池获取连接发送消息,发送完成后放回连接池,连接池不再被多线程共享操作。采用非阻塞IO,对于一次没有发完的消息,则由一个EPOLL服务负责监视和继续发送。
- 收消息线程使用一个EPOLL服务持续监听和接收消息,收完一个消息则执行业务回调函数。
- 为保证消息发送和接收线程不被业务过程所阻断,业务逻辑执行在其各自的线程中,中间使用无锁队列传递任务。
- 当目标服务和客户但代理线程组较多时,目标服务可实现分组,不同的客户端代理线程组,可以绑定到不同分组的服务,避免过多的客户端线程组连接同一个服务实例引起的服务端任务积压。
4.2 测试结果概述
按此方案重构并测试,结果基本满足最初的性能指标:
- 客户端性能可线性扩展(CPU核心数及系统I/O允许的情况下)
- 总吞吐量指标(在指定配置的系统上达到目标任务处理峰值要求)
- 单CPU核心平均吞吐量指标(经济性指标)
- 单个任务的请求/响应的耗时指标
5 后记
兜兜转转重构了3个版本之后,除了对相关的技术细节有了较深的认识外,关键在于对多线程并发处理和性能之间的关系,有了比较直观的认识和衡量。
- 在多核时代,多线程并发处理成为提升性能的关键。但多线程对性能的提升并非是无条件的。
多线程并发对性能的提升,应该是通过提高单位线程的处理效率,再加上线程数与性能的可线性化扩展设计,实现系统整体性能的最大化,而不仅仅是通过资源堆叠来提升性能。
- 共享资源的多线程并发访问,是最大的性能杀手,应在设计上极力避免。
- 一些利用原子变量实现的无锁技术用于线程间数据交换,尽管比使用锁的技术在性能上有大幅改进,但仍然有可能是瓶颈所在,需要严格限制使用范围。
来源:oschina
链接:https://my.oschina.net/u/1014977/blog/687253