首发于行路

gRPC系列(四) 框架如何赋能分布式系统

本系列分为四大部分:

前面的系列,我们已经从技术要素透视了RPC的本质,包括其三大要素: 语义约定、网络传输、编解码。以及gRPC如何通过Protobuf和HTTP2实现这三大要素,并达到更低成本、更高效率、更高性能等终极目标。

本文我们将回归到RPC的使用场景: 分布式系统。从分布式系统的角度,来看待gRPC这个框架。框架本身的含义就意味着是一个集成者、整合者,提供出简单、全面的使用界面,以灵活适应不同的环境,并对外屏蔽足够多的细节。

简单讲,框架的本质是技术落地的最后一厘米,为大规模低成本使用提供直接支撑。

分布式系统

现代技术架构,已经重构了众多场景的生产关系,一方面服务海量的用户,另一方面承载了无数极重要的业务,并要及时作出调整,以适应环境的变化。

这都使得技术系统,在保证灵活和效率的同时,还必须要有高度的稳定性和健壮性,任何一次故障都会带来难以估计的直接损失,还有背后商业上的信用本钱。

受限于现代计算机的技术特性,以及通信的基础设施。一个技术系统想要在大流量下保持高健壮性、高稳定性,必然要将代码和数据分散在数量庞大的机器上,这些分散的机器一起组成一个个闭合的技术系统,完整业务需求由这些系统一起合作分工实现,这样便诞生了分布式的概念。

分布式场景下,系统中的子系统或模块间需要相互通信,或传递信号,或传输数据。这就自然导致了RPC的诞生。这种通信场景可以简单分为三类:

  1. 集群间的通信。如Redis集群基于gossip的数据交换,一般直接基于TCP,一般会收敛于子系统内部。
  2. 数据传输。应用读写MySQL、Redis等数据系统,一般直接基于TCP,场景定制性高。
  3. 消息传递。微服务之间相互接口调用。这种偏上层业务,需要适应复杂繁多的场景,并提供高度的可复用性、适应性、扩展性。所以一般会基于TCP再做一层封装,如HTTP2。

到此,我们可以从分布式系统的角度发现: RPC是分布式系统通信的一种工具。

而gRPC则是这种分布式系统通信场景中,偏上层应用的一种通信工具,也就是上面的第3类,我们且称为业务型分布式系统

本文将聚焦业务分布式系统通信场景来展开对gRPC的学习。

业务分布式系统

业务性分布式系统,可以对应于传统的单体应用。 当一个单体应用以微服务或SOA等方式拆分后,就变成了一个分布式系统,这也是大中型互联网公司的后台系统。

为了从虚到实地落地知识,我们暂且简单地把业务性分布式系统等价于微服务系统,从微服务的角度来看待gRPC,才算是真正将讨论点落实到了实际环境中。

实际上,gRPC就是微服务类系统间通信的核心工具。到此,我们基本上将知识结构及相关关系梳理完毕,完成宏观和微观间的互通。接下来着眼于微服务系统的通信需求,看gRPC是如何为其赋能的。

所谓赋能,用人话来讲,就是能低成本大规模使用,一般就几个需求:

  • 有良好的适应能力。对于部分核心能力可以通过插件实现定制化能力,适应不同的环境。
  • 提供关键性问题解决方案。例如高效率的并发模型、加密、压缩等等。
  • 有足够的扩展能力。能通过配置、注入等方式灵活实现扩展功能或开启部分功能。
  • 足够简单。屏蔽底层细节,能无脑上手使用,不需要懂http2、protobuf、IO模型等等是什么

接下来我们就从以上几个点,来展开对gRPC的学习。由于框架庞大复杂,受限于作者水平和使用经验,讨论将聚焦于几个核心点展开。

业务系统的通信需求

分布式系统中服务的相互调用看似复杂,其实本质就是两个点间的点对点通信,点与点相互连接串起一张网。

点与点通信进一步拆解后,在微观可以分为调用方服务方的关系,大部分情况下就是单向调用,在stream模式下可升级为相互调用,但每一次调用都不会逃离调用方服务方两个角色。这为我们提供了很好的突破口,搞清楚了两点之间的调用,也自然能延伸全局。

当A要调用B时,问题就来了。

适应能力

服务发现

  • A要能得知B的可调用地址
  • B的部署方式可能多种多样

B可能是以微服务的形式注册,通过注册中心可以拉到其节点列表,不同的公司注册中心实现也千差万别。但也可能是只暴露出了一个代理的地址(如Nginx/lvs),甚至实现了传统的DNS模式。

业务发展过程中什么情况都可能有,大概率是一个大杂烩。gRPC作为一个落地的框架就必须要有足够的适应能力,覆盖繁杂的情形,通过提供自定义接入能力(插件),以适应复杂的环境。

为此gRPC仿造RFC的标准设计了一套名称发现协议[1],一个服务的标示可以表示如下:

scheme://authority/endpoint_name

  • scheme 表示要使用的名称系统,例如DNS,或一套自己的服务发现系统,例如ectd、Eureka、consul,或任意自研服务发现系统的名字。
  • authority 表示一些特定于方案的引导信息,例如对于DNS,authority可以提供一个解析endpoint_name的地址,相当于DNS服务器。(一般在DNS模式下才有用)
  • endpoint_name 表示一个服务的具体名字。例如 login-service

例如使用DNS时,B服务的地址可以设计为:dns://somedns.com/addrOfServerB,其内涵为B服务通过dns这种名称系统来发现,B具体的地址可以定时轮询somedns.com得知(带上参数addrOfServerB),实际请求发往解析得到的具体IP:port,这套机制即可满足上面我们说的暴露代理地址的模式。

而常规的微服务模式则是有一个服务注册中心,B服务的实例启动后将自己注册到服务中心,调用方通过服务中心拉取到可调用地址。目前的技术现状是,每个公司都恨不得自己搞一套服务注册系统,而实际上这就是中大厂的现状。通过插件接入对接自己的服务发现系统是不可绕过的刚需。

假设我们这套服务注册中名字为discovery,那B服务的地址也可以表示为:

discovery://someauthority/appID_B

B服务的可用地址通过discovery来获取。 具体怎么获取呢?gRPC其实将这种能力完全外放了。提供了名称系统注册能力,实际上就是一个interface:

type Builder interface {
    Build(target Target, cc ClientConn, opts BuildOptions) (Resolver, error)
    Scheme() string
}
// 注册一个名称系统
resolver.Register(&Builder{scheme: "discovery"})

向B服务发送请求前,会解析discovery://someauthority/appID_B出scheme的值,并用使用对应注册的名称系统来获取可调用的节点。相当于gRPC将这部分能力外放出去了。具体如何通过appID_B去获取可调用的地址列表,gRPC不管,由使用方自己实现。当部署节点发生变化时,调用gRPC的接口(NewAddress/UpdateState)通知其即可。[2]

简单理解为以下过程:

  1. 注册一个名称系统的实例到gRPC (一般启动时注册, 可以注册任意数量)
  2. A 通过gRPC调用B时,gRPC会解析出B的scheme,从注册的名称系统获得可用的服务地址列表,一般是一批 IP:port (IP:port如何得到gRPC不关心,使用方根据自身情况实现即可)
  3. gRPC针对IP:port建立网络连接
  4. gRPC将请求发出去,接收回复

通过上面的方式,使用方按照scheme://authority/endpoint_name定好服务名字,并实现相应的接口(可调用地址的获取、变更等),注册到gRPC,便可以适应复杂的分布式调用场景。

假设某个公司由于山头过多,实现了多个服务发现系统,短时间内还难以统一,服务分散注册到这几个系统。在用gRPC时,也能轻松应对。

服务注册现状:

discovery://someauthority/appID_B    appID_B 通过自研discovery系统注册
etcd123://someauthority/appID_C   appID_C 通过自研etcd123系统注册
consul456://someauthority/appID_D    appID_D 通过自研consul456系统注册

启动时向gRPC注册名称系统:

import "google.golang.org/grpc/resolver"
resolver.Register(&Builder{scheme: "discovery"})
resolver.Register(&BuilderEtcd{scheme: "etcd123"})
resolver.Register(&BuilderConsul{scheme: "consul456"})

当A通过gRPC调用B、C、D时,会解析出schema,从注册的名称系统来获取实际调用地址。调用示例[3]。Resolver实现示例[4]。

负载均衡

上面解决了获取可调用地址的问题,紧接着问题又来了,如何做负载均衡?一批可调用的地址中,到底选哪个,怎么选?

常规的负载均衡算法非常多,如轮询、随机、耗时最短、加权随机等等,由于技术系统的异构性,很多时候难以简单随机轮询。gRPC为了提供出足够强的适应性,把负载均衡的策略也外放了。使用者可以在启动时设置负载均衡的对象,通过插件可只定义策略。

type PickerBuilder interface {
    // gRPC将建立好的所有连接传给负载均衡器,创建一个picker
    Build(readySCs map[resolver.Address]balancer.SubConn) balancer.Picker
}
type Picker interface { 
       // 从gRPC给的连接中选一个可调用的节点返回给gRPC
    Pick(ctx context.Context, info PickInfo) (conn SubConn, done func(DoneInfo), err error)
}

具体的实现相对简单:

  1. gRPC会将封装好的网络连接丢给负载均衡对象,当连接变化时,由 PickerBuilder 新Build一个picker。
  2. 每次调用前调用picker Pick一个节点出来供使用
  3. Pick接口会返回一个 done 函数,rpc调用完毕后会回调,支持回传一些 balancer.DoneInfo
  4. balancer.DoneInfo 里面支持一些metadata,也就是服务方可以通过HTTP2的header回传的一些key:value
  5. 服务方可以在返回请求时,将自己的CPU、负载等反映压力的数据写到metadata中,这些数据可以通过done函数回写到picker,供决策使用。

根据上面开放的能力,例如你可以实现一种叫p2c的策略[5],先随机选择两个节点,然后根据记录的对端CPU负载等多种参数,最后选择一个最佳节点。这种相比轮询或随机具有更强的适应能力,可以避开部分出问题的节点。

总结下,gRPC对于负载均衡提供了以下能力:

  • 负载均衡策略外放
  • 支持done回调,透传服务方的一些数据(需要在服务方支持,通过grpc.SetTrailer写入流即可)
  • 支持透传自定义命名系统回传的metadata,这个里面可以携带众多信息,如权重等等。(resolver 包 struct Address)

基于这些特性,便可以自由实现花样百出的负载均衡策略。

整体结果简单示意图如下:

节点刷新与调用流程

关键问题

框架在整合编解码、网络传输等feature同时,也需要提供部分核心功能,这些功能往往是系统刚需,存在重复劳动的地方。最常见的就是并发模型。

并发模型一般是针对服务方而言的,服务方需要有高效率的IO,在资源有限的情况下一方面快速处理请求,另一方面提供足够高的并发能力,实现高吞吐低延迟。

这些都可以认为是C10k问题[6]的延伸。传统的服务方基于阻塞IO实现请求读写,这样一个线程/进程只能同时处理一个请求。当用户量暴增后,不能来一个请求就fork一个子进程或创建一个线程来处理,这样资源扛不住。

所以得有更有效率的策略,得让一个线程/进程能同时处理多个请求。这便诞生了多路复用[7]的需求。让一个线程同时监听多个socket的状态,谁就绪才处理谁,而不是依赖操作系统的接口直接hang住,白白浪费CPU时间。(select/epoll)

为了能实现高吞吐的目标,一方面要尽量减少在IO上的无效等待,另一方面要利用好多核。

  • 要减少无效等待,最好的策略之一就是基于事件驱动。
  • 要利用多核,就需要和CPU数量相匹配的线程数量来并发处理请求。同时尽量减少上下文切换

于是便诞生了大名鼎鼎的Reactor模型[8],linux环境下大量号称高并发server都是实现了Reactor模型,例如java系的netty。

每一个好的RPC框架势必要提供高吞吐的并发模型,也就相当于实现Reactor,屏蔽网络IO处理这堆复杂的细节,解决服务方高吞吐的刚需,让小白上手就能高并发。

在golang出现之前,大量的语言例如Java、python、ruby等的rpc框架都要自己实现Reactor模型实现高吞吐,这其实是应用层的重复劳动。golang从语言层面下沉了类似的实现,通过实现netpoller[9],让golang程序的网络IO读写规避掉无意义的等待,和上下文切换。简单讲就是几点:

  1. golang runtime封装了非阻塞IO,给应用程序暴露成阻塞IO
  2. 当goutouting操作一个未就绪的socket时,操作系统会返回error,runtime会拦截这个error,将该socket加入状态监听队列(可以简单认为是一个epoll),并将该gourouting挂起
  3. 当监听到对应socket可读/可写时,会将对应的gourouting找到并让其立即等待执行
  4. 第2,3步周而复始,从gourouting角度自己在操作阻塞IO,然而并没有CPU时间浪费在等待上,也没有线程上下文切换

这样的结果就是golang的相关应用程序不再需要实现Reactor模型,来一个请求则创建一个gourouting去处理就行,这极大简化了并发模型。

扩展能力

了解一般框架的人都知道,有不少标配能力,以提供高度自由的扩展功能,一般通过几种方式提供:

1. 自定义插件

例如上面的服务发现、负载均衡。因为gRPC天然和protobuf绑定,谈到扩展插件,就不得不提及gRPC在编码层的解耦。

因为使用protobuf的前提是你得有对应的.proto文件,这样才能进行编解码。但有些场景下,类似代理的角色没法持有所有的proto,这便限制了下面的场景:

gRPC代理

假设有一个http的场景需要调用gRPC的接口实现功能,这需要有一个近似透传的代理来实现,但都通过protobuf包装数据,代理则要持有所有下游的proto文件,这不现实。

但如果将编解码的方式解耦出来,例如通过JSON进行编解码,便能轻松解决问题,这带来了极大的灵活性。在http和gRPC的混用融合上价值不菲,而且gRPC请求的调用调试也可以像http那样简单。

gRPC提供了codec的插件注入能力,以实现自定义编解码:

type Codec interface {
    Marshal(v interface{}) ([]byte, error)
    Unmarshal(data []byte, v interface{}) error
    Name() string
}
// 注册一个编解码插件
import "google.golang.org/grpc/encoding"
encoding.RegisterCodec(JSON{}) // JSON实现了上面的interface
grpc.CallContentSubtype(JSON{}.Name()) // 通过option指定使用JSON

只要服务方也注册了,且下游参数能通过 json 反序列化成struct对象,则调用便顺利进行。

type Req struct {
    Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform" form:"platform" validate:"required"`
    Build int64 `protobuf:"varint,2,opt,name=build,proto3" json:"build" form:"build"`
    XXX_NoUnkeyedLiteral struct{} `json:"-"`
    XXX_unrecognized     []byte   `json:"-"`
    XXX_sizecache        int32    `json:"-"`
}

2. 配置 - 调用时通过 option注入

gRPC可以通过option配置提供多种能力,例如:
- 自动重试。(RetryableStatusCodes)
- 加密
- 压缩
- 超时定制

3. 拦截器(Interceptor),有些也称middleware

拦截器可以在调用方和服务方同时存在。 一般用来实现熔断、限流、日志收集、open-tracing、异常捕获、数据统计、鉴权、数据注入等等多种功能。可以一层包一层,支持任意数量。

插句题外话,上面大部分扩展能力一般是以SDK的方式独立于业务代码,但都是运行在同一个进程中。如果把上面分布式治理部分功能剥离出来集中治理优化,并和业务进程隔离部署,就是一个ServiceMesh落地雏型。

屏蔽细节

作为落地的最后一厘米,框架要尽最大能力屏蔽底层细节,特别是HTTP2相关细节:如何建立网络连接,如何发送数据,如何保持连接状态等等。gRPC在这方面做得很好,使用方只需要注册实现几个接口,便可能无脑run起来。

以下是一个简单的gRPC核心对象关系图:

gRPC调用对象简单示意

对于使用方而言,只需要实现Resolver插件提供可调用的列表,以及Picker如何选择节点的逻辑。其余复杂的状态管理、网络处理等等都被完全屏蔽。

开源参考资料

编辑于 2021-01-17 17:45