概述
在云原生与分布式架构深度演进的当下,微服务间基于RPC(Remote Procedure Call)的进程间通信已成为现代系统设计的核心范式。以Spring Cloud、Dubbo、gRPC为代表的各类开源框架,通过封装序列化、服务发现、负载均衡等底层逻辑,构建了高度抽象的API编程界面。这种”本地调用即远程”的设计哲学,使得开发者甚至无需感知TCP/IP握手、HTTP/2流式传输或TLS加密协商等网络细节,仅通过简单的注解声明(如@FeignClient)或接口定义(Protobuf)即可完成跨进程通信,极大提升了业务迭代效率。
但这种技术透明化的两面性正日益显现:当RPC框架通过”魔法式”的代码生成(如Java动态代理)和协议编解码(Thrift/Protobuf)将通信细节封装成黑盒时,关键的网络拓扑、链路时延、异常传播等系统级信息也随之被遮蔽。在复杂调用链场景下,这种信息缺失可能导致故障根因定位困难、全链路压测失真、容量规划失准等运维难题。因此,本文尝试简单讲讲这块内容。
服务发布/订阅
一次RPC调用,暴露到用户的使用界面一般就是一行普通的方法/函数调用,以Java为例,如下代码即发起了一次DubboRPC调用:
RPC框架提供的产品特性、提供的易用的操作界面与其整体架构密不可分。不同的语言生态、不同的使用场景、不同的框架实现在架构细节上有各自的取舍、权衡,但是宏观架构上总是贴近如下经典的RPC架构:
把嵌入到微服务进程的SDK剥离出来成为和微服务进程伴生的进程,进程间使用Unix IPC手段进行通讯,就演变成了时下热门的RPC Mesh架构。
微服务架构下,服务节点的上线、下线是不可预期的。可能是因为计划内的运维我们需要重启某个节点、对集群进行扩容/缩容,也可能是因为计划外的异常事件如宿主机宕机等。
我们都知道,网络通讯首先需要确定的就是对端网络地址,不能每次下游服务节点变动了以后,上游调用方服务重新改代码、改地址吧。
因此,RPC框架需要解决网络寻址问题。即,RPC调用方如何在上述条件下,尽可能实时、准确的得到下游被调用方节点的网络地址列表。
我们都知道,计算机领域的任何问题都可以通过增加一个间接的中间层来解决。
既然对端节点的物理网络地址一直在动态变化,那我们就在中间加层映射,用一个稳定的逻辑地址来映射到动态的物理地址。
DNS协议是这个思路的经典实现:
不管服务B的物理地址如何风云变幻,只需要它及时的在DNS系统上更新它的物理地址,上游服务A即可根据稳定的逻辑地址(a.com)从DNS系统获取其物理地址。
DNS是TCP/IP协议族中重要的一部分,支撑了互联网的发展和普及。同时在早些年很多业务系统就是用DNS+Nginx来实现高可用:
微服务架构中的注册中心,和DNS系统解决的是同一个大问题,但是微服务注册中心提供了RPC场景需要的其他特性:
- DNS系统中维护的往往是不经常改变的【逻辑地址 -> 物理地址】的映射,因此一般只提供了便于人肉变更的操作界面,而微服务注册中心一般会提供便于集成的OpenAPI,用于RPC框架集成后,动态查询、更新上述地址映射。
- DNS系统不关注上述映射表的可用性,需要人肉去维护有效的【物理地址】。微服务注册中心一般会实现节点健康检查。上述映射表中的【物理地址】不可达、不可用时,会及时的剔除。从而让上游调用方可以及时感知、避让下游不健康的节点。
- DNS系统一般通过在端侧【缓存】+【轮询】来实现变更感知,实时性较低。微服务注册中心一般会提供【变更推送】机制。
- DNS系统中维护的物理地址元数据是比较简单的,A记录只维护IP,SRV记录只维护IP、Port和Weight。而微服务注册中心出于服务治理、流量调度的需要,往往需要登记更详细的元信息如地域、可用区、应用名、染色环境、蓝绿集群等供RPC框架决策使用。
在某种意义上可以认为,微服务注册中心是DNS系统的超集(数据上、能力上),微服务注册中心完全可以覆盖DNS场景下的需求。因此,一些微服务注册中心也提供了对DNS协议的支持,如Consul、Nacos等。
微服务注册中心的可用选型有很多,有些选型是【注册中心】这个命题下的专门实现,如Eureka、Consul等,有些选型是复用了既有的、恰好可以提供注册中心需要的能力的通用实现,如Zookeeper、Etcd等。
点对点网络通讯
网络协议
借助注册中心,我们实现了物理地址解耦,调用方可以通过注册中心来发现所有可用的下游节点地址。为了实现网络通讯,我们还需要确定网络协议。
首先我们需要确定传输层协议,一般来说,如果没有特别需求,传输层协议选择保守的TCP协议都不会有什么大毛病。考虑到RPC往往用于在线事务处理,对数据的完整性要求比较高,那如果用UDP还得把TCP做的事情自己实现一遍,干脆就用TCP得了。因此,市面上常用的RPC框架都是基于TCP来干的。
接下来我们需要确定应用层协议。这一步一般就两个选择,要么复用既有的、通用的应用层协议,如HTTP协议。要么就是自行设计私有的自定义的协议。
- 复用既有的网络协议可以复用链路上的基础设施,保护既有的技术投资。如防火墙、负载均衡器、流量复制/观测系统等,同时也可以复用既有的开源网络库,降低了代码开发成本。gRPC走的这个路线,选择用成熟HTTP2协议来承载其RPC交互报文。
gRPC请求要素,被分别安置在了HTTP2协议的Path、Header、Body中。
- 自行设计私有协议可以实现精细化的报文设计、报文交互流程设计。Dubbo2、Thrift走的就是这个路线,Dubbo协议自行实现了TCP连接在应用层的多路复用、利用位图来压缩报文长度等。
复用一个成熟的、广泛使用的网络协议的收益还是蛮大的。比如Dubbo因为协议不通用,在RPC Mesh这个赛道下,走的就没有gRPC远,所以Dubbo3也开始拥抱HTTP2啦~
序列化/编解码
确定了交互报文使用的网络协议,只是确定了【请求的控制数据】的交互和编解码,我们还需要确定【请求的业务数据】的编解码。
交互协议只是确定了用什么规格的集装箱来往返运送货物,但是没有规定集装箱内部的货物布局,而卸货的时候需要按不同的布局来提前安排卸货设备。
不同的语言有不同的复杂的数据结构,尤其是OOP语言。但是TCP/IP网络只能传输octet-stream,也就是8bit的字节流。对端需要知道这个二进制流的格式才能解析、处理它。
有点绕吧。我们以HTTP/1为例解释下,HTTP协议规定了它的请求由3部分(Line、Heder、Body)构成,但是Body里的数据是什么格式,不是由HTTP协议规定的。或者换句话说,Body里可以放任何数据,只要使用HTTP协议的人/代码自己能解析、处理。因此,使用HTTP协议时,需要在请求头和响应头中,使用Content-Type
头来提示对方,Body里的数据是什么格式。如下图所示:
以上图为例,RPC框架应该用什么样的格式将TestRequest对象转换成二进制呢?需要考虑到对端在收到请求后,需要逆向的将二进制流恢复成TestRequest对象哦。
这个问题不同的场景有不同的选择。如果RPC框架不考虑跨语言支持,那随便挑一个甚至自己实现一个就行,无非就是按数据结构编排字节流。
但如果RPC框架需要支持跨语言调用,那设计就会复杂一些。首先考虑到不同的语言生态,数据类型是没法对齐的。比如Java里有long类型,golang里没有。
一般是独立设计一套接口描述语言,这套描述语言里有独立的基础数据类型,如整数、浮点数、数组,同时也支持自定义复杂的数据结构。我们称之为IDL。
IDL支持的数据类型、数据结构是各语言生态的【最大公约数】。或者说,是各语言生态类型系统的重叠的部分。有了这份中立的数据结构描述文件,配合相应的工具链生成各语言/平台的桩代码,就可以实现数据结构跨平台交互。
gRPC需要支持跨语言、跨平台,所以它需要有IDL文件,Dubbo诞生时没这个诉求,因此不需要IDL,只服务于Java。
负载均衡
我们已经通过微服务注册中心知道了对端节点物理网络地址,同时也确定网络通讯使用的网络协议,网络交互已经不成问题了。剩下还需要处理负载均衡的问题。
我们知道,微服务架构下,为保证系统可用性,一个RPC接口会有多个提供者。或者换句话说,每个微服务都是多副本的、冗余的。如上图,微服务B就有ip1、ip2两个副本。
那就带来两个需求:
- 最基本的需求是流量均衡。考虑到可维护性,服务副本的资源规格往往是对等的,比如都是4c8g的机器,因此理论上每个副本的承载力都是一样的,那如果承接到的上游RPC流量不均衡,有的多、有的少,必然导致这些副本旱的旱死涝的涝死,这不合理。
- 进一步的需求是流量调度。出于高可用或者是特定业务场景的要求,一个服务的多个副本可能分布在不同的机房、不同的城市,或者是服务的服务有不同的Qos、不同的代码版本等等吧。比如流量调度时同机房优先、同地域优先、染色环境、蓝绿发布等等。
负载均衡有两种套路,一种是LocalLoadBalance,一种是ProxyLoadBalance。
LoalLoadBalance
ProxyLoadBalance
Proxy模式好管理,但是会带来链路单点,同时在链路中新增了一跳转发,可能带来延迟。
Local模式架构简单,性能好。RPC框架一般使用该模式,也就是我们常说的软负载均衡。
因为软负载均衡没有对应的硬负载均衡的说法,我总感觉别扭。所以我爱把他们分别叫:LocalLoadBalance和ProxyLoadBalance。这两个名字多对称~
我理解负载均衡本质上是一种【供需撮合决策流程】。
RPC框架在进行负载均衡时,有如下供需上下文:
- 供应:从注册中心上获取到的下游所有节点的元信息,如节点权重、节点染色、节点蓝绿、节点机房/地域等等。
- 需求:当前请求的接口标识、请求上携带的染色标、请求上携带的蓝绿标、处理当前请求的节点所在的机房/地域等。
RPC框架基于上述供需关系,按优先级、按业务策略来决策、选择一个合适的下游节点发送RPC请求。这部分的逻辑是业务个性化的,因此一般的RPC框架都会提供易用的拓展点来允许开发者定制、拓展,gRPC和Dubbo都有哦。
总结
本文主要泛泛而谈的介绍了一下RPC框架的网络交互。