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

early · 2021年01月17日 · 最后由 yfractal 回复于 2021年01月19日 · 1930 次阅读
本帖已被管理员设置为精华贴

本系列分为四大部分:

前面的系列,我们已经从技术要素透视了 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,这便限制了下面的场景:

假设有一个 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 核心对象关系图:

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

开源参考资料

xiaoronglv 将本帖设为了精华贴。 01月17日 22:14

微服务的 health check,大家通常是怎么做的?

如果 gPRC 服务挂掉了,caller 通常该怎么办呢?如何在框架层面统一处理微服务挂掉的情况?

对 xiaoronglv 回复

服务发现中心会定时心跳,发现挂了会立即摘掉。caller 可以针对挂掉的情况利用 grpc 做一次无缝重试

对 xiaoronglv 回复

边车模式,现在 service mesh 比较火,istio 之类的,把错误处理啥的放边车做 或者说业务层面重试

对 xiaoronglv 回复

服务不可用,有多种情况。

一种是临时性的,比如重启,这种只要加一下超时重试,就可以解决。

另一种是服务直接被打趴下了。这个时候,需要调用方放慢一点速度,比如限流、熔断。

还有用类似 tcp 的拥塞控制来做的,比如 网飞有个 java 库是 https://github.com/Netflix/concurrency-limits。

Facebook 的 memorycache 集群也有类似的机制,说是可以降低延迟 https://pdos.csail.mit.edu/6.824/papers/memcache-fb.pdf 3.1

限流需要预先知道服务能承受多大压力,如果服务比较少,依赖简单,还行。但复杂了,就没办法知道这个数值了,就需要靠拥塞控制的思路搞。

early 在 【上海】B 站招聘后端工程师 (golang) -- 直推 提及了此话题。 06月20日 11:59
需要 登录 后方可回复, 如果你还没有账号请 注册新账号