gRPC理论基础

1. 什么是RPC

在讲gRPC之前还是要先搞明白什么是RPC。所谓RPC(remote procedure call 远程过程调用)框架实际是提供了一套机制,使得应用程序之间可以进行通信,而且也遵从server/client模型。使用的时候客户端调用server端提供的接口就像是调用本地的函数一样。广义上来讲,所有本应用程序外的调用都可以归类为RPC,不管是分布式服务,第三方服务的HTTP接口,还是读写Redis的一次请求。从抽象的角度来讲,它们都一样是RPC,由于不在本地执行,都有三个特点:

  • 需要事先约定调用的语义(接口语法)
  • 需要网络传输
  • 需要约定网络传输中的内容格式

以一次Redis调用为例,执行redis.set("rpc", 1)这个调用,其中:

  • set及其参数("rpc", 1),就是对调用语义的约定,由redis的API给出
  • RedisServer会监听一个服务端口,通过TCP传输内容,用异步事件驱动实现高并发
  • 底层库会约定数据如何进行编解码,如何标识命令和参数,如何表示结果,如何表示数据的结尾等等

这三个特点都是因为调用不在本地而不得不衍生出来的问题,也因此决定了RPC的形态。所有的RPC解决方案都是在解决这三个问题,不断地在提出更加优良的解决方案,试图达到更好的性能,更低的使用成本。 本文也将围绕这三个特点来展开内容。

常规的RPC一般都是基于一个大的内部服务,进行分布式拆分,由于其语义上以本地方法的作为入口,那么天然的就更倾向于具备高性能、支持复杂参数和返回值、跨语言等特性。下图是RPC调用的过程示意图:

1. 内容组织约定

Stub会负责封装命令和参数,并以特定的数据格式进行打包。其中命令、参数和返回值的需要客户端和服务端的Stub事先进行协商,双方都需要维护一份完全一样的方法及参数列表。更进一步需要知道对方如何进行压缩打包,如何压缩结构体,如何压缩Class等等,并严格按照标准进行解压缩,中途有任何一丝的差错都会的导致调用失败。所以一般情况下可能会对数据进行一定的校验,同时要协商方法、参数等错误时如何返回。 这是一个比较繁杂的过程,混合了调用语法内容解压缩两部分内容,可被理解为如何组织内容的问题。

2. 网络传输

搞定了协议约定问题后,接下来就是要通过Runtime进行内容传输了,这又是一大难题,一般是需要通过Socket编程来实现,使用TCP或UDP来进行传输,如果是UDP可以用数据报来区分每一次请求和回复。但如果是字节流的TCP,就需要用特殊的方式来标示请求或回复的末尾,用来区分不同的请求。同时当对调用性能有要求时,可能会使用Socket的异步编程模型,消除等待中的消耗,这会引入事件机制,通过状态机来解析处理或回复请求。当出现超时、丢包等情况时还进行做重试、重传、报错等等。

2. gRPC

对rpc有了一定概念的了解之后下来我们就来往下看,什么是gRPC ,这个问题可以用官网的一句话来概括

"A high-performance, open-source universal RPC framework"
一个高性能,开源的通用的RPC框架

gRPC

1. 多语言

语言中立,支持多种语言。

如图,比如你的Service是用C++编写的,那么我的gRPC客户端可以是用go语言编写,也可以是用Ruby编写,也可以是用Java编写的,这就提现了语言中立,支持多种语言。

2. 轻量级、高性能

序列化支持 PB(Protocol Buffer)和 JSON,PB 是一种语言无关的高性能序列化框架。

PB(Protocol Buffer)的结构是比较紧凑的,不管是从序列化的角度还是从传输的角度来看,PB都优与JSON。

使用PB优点比较的突出,总结起来一句话就是代码文档,相比于常规的Restful API而言,API的修改都需要去修改文档,要是文档忘记更新,没有告知上下游的调用者就会出现API调用异常的问题,而PB的定义就比较的清蒸,那如果可以很好的理解PB的IDL定义的话,你就能马上理解我的意思了。如下图

rpc的调用方法是SayHello,request的参数是name,reply的返回参数是message,方法和请求参数,返回参数都定义的清清楚楚,这就是所谓的清蒸

3. IDL

基于文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub。

详情参考

4. 可拔插

可拔插的意思就是支持插件,为啥是可插拔,原因很简单,就是要支持魔改啊,哈哈哈哈。

5. 设计理念

并非所有的rpc框架都支持元数据传递,也就是不能自定义MetaData,http1.1协议大家再熟悉不过了,我要说的是Http Head,比如说Request Head,Response Head。并非所有的东西都要定义到Body或者是Payload里面。grpc支持元数据传递,这就很爽了。

6. 移动端节约流量

基于标准的 HTTP2** 设计,支持双向流、消息头压缩、单 TCP 的多路复用、服务端推送等特性,这些特性使得 gRPC 在移动端设备上更加省电和节省网络流量。

HTTP1.0,HTTP1.1与HTTP2.0的对比

HTTP1.0 无状态、无连接
HTTP1.1 1.持久连接 2. 请求管道化 3. 增加缓存处理(新的字段如: cache-control) 4.增加Host字段,支持断点传输等(把文件分成几部分)
HTTP2.0 1. 二进制分帧 2. 多路复用(或连接共享) 3. 头部压缩 4. 服务器推送

1. HTTP 1.0

浏览器的每次请求都需要与服务器建立一个TCP连接,服务器处理完成后立即断开TCP连接(无连接),服务器不跟踪每个客户端也不记录过去的请求(无状态)。

2. HTTP 1.1

HTTP/1.0中默认使用Connection: close。在HTTP/1.1中已经默认使用Connection: keep-alive,避免了连接建立和释放的开销,但服务器必须按照客户端请求的先后顺序依次回送相应的结果,以保证客户端能够区分出每次请求的响应内容。通过Content-Length字段来判断当前请求的数据是否已经全部接收。不允许同时存在两个并行的响应

3. HTTP 2.0

HTTP/2引入二进制数据帧的概念,其中帧对数据进行顺序标识,如下图所示,这样浏览器收到数据之后,就可以按照序列对数据进行合并,而不会出现合并后数据错乱的情况。同样是因为有了序列,服务器就可以并行的传输数据,这就是所做的事情。

流(stream 已建立连接上的双向字节流
消息 与逻辑消息对应的完整的一系列数据帧
HTTP2.0通信的最小单位,每个帧包含帧头部,至少也会标识出当前帧所属的流(stream id)。
1. 多路复用
  • 所有的HTTP2.0通信都在一个TCP连接上完成,这个连接可以承载任意数量的双向数据流。

  • 每个数据流以消息的形式发送,而消息由一或多个帧组成。这些帧可以乱序发送,然后再根据每个帧头部的流标识符(stream id)重新组装。

    举个例子,每个请求是一个数据流,数据流以消息的方式发送,而消息又分为多个帧,帧头部记录着stream id用来标识所属的数据流,不同属的帧可以在连接中随机混杂在一起。接收方可以根据stream id将帧再归属到各自不同的请求当中去。

  • 另外,多路复用(连接共享)可能会导致关键请求被阻塞。HTTP2.0里每个数据流都可以设置优先级和依赖,优先级高的数据流会被服务器优先处理和返回给客户端,数据流还可以依赖其他的子数据流。

  • 可见,HTTP2.0实现了真正的并行传输,它能够在一个TCP上进行任意数量HTTP请求。而这个强大的功能则是基于“二进制分帧”的特性。

2, 头部压缩

HTTP1.x中,头部元数据都是以纯文本的形式发送的,通常会给每个请求增加500~800字节的负荷。

HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。高效的压缩算法可以很大的压缩header,减少发送包的数量从而降低延迟。

3. 服务器推送

服务器除了对最初请求的响应外,服务器还可以额外的向客户端推送资源,而无需客户端明确的请求。

7. 服务而非对象、消息而非引用

促进微服务的系统间粗粒度消息交互设计理念,同时避免分布式对象的陷阱和分布式计算的谬误。

还是这张图,服务指的是service,消息指的就是message,rpc提供的服务不是对象,就是一个api,一个方法,消息不是靠引用的,二十靠传递的,粗粒度的指的就是batch,一次调用批量返回。

8. 负载无关

不同的服务需要使用不同的消息类型和编码,例如 protocol buffers、JSON、XML 和 Thrift

9. 流

Streaming API,解决一个接口发送大量数据的场景。

gRPC 中的 Service API 有如下4种类型:

  • UnaryAPI:普通一元方法

    Unary API 就是普通的 RPC 调用,例如我在PB定义的SayHello就是一次简单的一元调用

  • ServerStreaming:服务端推送流

    服务端流:服务端可以发送多个数据给客户端

    使用场景: 例如图片处理的时候,客户端提供一张原图,服务端依次返回多个处理后的图片。
    
  • ClientStreaming:客户端推送流

    和 ServerStream 相反,客户端可以发送多个数据。

  • BidirectionalStreaming:双向推送流

    双向推送流则可以看做是结合了 ServerStream 和 ClientStream 二者。客户端和服务端都可以向对方推送多个数据

    注意:服务端和客户端的这两个Stream 是独立的。
    

10. 阻塞式和非阻塞式

支持异步和同步处理在客户端和服务端间交互的消息序列。

11. 元数据交换

常见的横切关注点,如认证或跟踪,依赖数据交换。

12. 标准化状态码

客户端通常以有限的方式响应 API 调用返回的错误,支持我们熟悉的HTTP的状态码。

13. 性能问题

我还是听到比较多的人在用gRPC的时候一直喊着,注重性能问题,如果你的团队已经经历了一个标准化的阶段之后,那么再来讨论性能问题是有价值的,如果你的团队连标准化的工作都还没有做就去谈论什么性能化的事情,简直就是天方夜谭.

Don't focus on performance too early, standardize first
不要过早关注性能问题,先标准化
-- 笔者本人ttlv

14. HealthCheck

1. 节点与节点之间

gRPC 有一个标准的健康检测协议,这是一个非常重要的功能。在 gRPC 的所有语言实现中基本都提供了生成代码和用于设置运行状态的功能,这是一个主动的健康检测。

health_check

现在我用一个经典的生产者消费者的服务做一个分析。现在有一个Provider服务和一个Consumer服务,还有一个服务发现服务(Discovery)。Provider服务会先主动去DIscovery主动注册,注册啥东西?其实就是告诉注册中心,我是啥协议的服务,ip是啥,端口号又是啥,如上图就是rpc|IP:PORT,Consumer服务只要查询Discovery服务就可以知道Provider服务的IP和Port信息,然后Consumer向Provider发起请求。这是一个正常的流程。现在我们讨论一个异常的场景,假设Consumer和Provider发生了网络抖动,也就是上图中call的链路,如果这条链路断开了,这其实就说明了Provider服务实际上应该是异常了,此时不能在继续对外提供服务,而且在注册中心(Discovery)也应该要将Provider服务给剔除,不过并没有第三者去告知Discovery服务说Provider服务异常,这就意味着,Consumer实现并不知道与Provider的网络不通,这就会导致会有请求不断的打过来,这就可能会导致RPC调用的阻塞或者是请求超时。这个时候HealthCheck就派上有场了,如果Provider提供了health check的接口,就可以直接用一种peer to peer 节点对节点的方式,HealthCheck检测异常以后就可以去注册发现服务(Discovery)中去剔除服务。

主动健康检查 health check,可以在服务提供者服务不稳定时,被消费者所感知,临时从负载均衡中摘除,减少错误请求。当服务提供者重新稳定后,health check成功,重新加入到消费者的负载均衡,恢复请求。

2. 平滑发布/平滑下线

相信大家在平常的生活中一定遇到过XXX系统在维护中,带来不便敬请谅解的情况,一般都是政府的服务,这说白了就是服务在更新维护,准备发版呢,没有做到平滑上下线而已,我们期望的是在新版服务发布的过程中,服务是不能停止的。升级版本的逻辑是先下线旧版本然后上新新的版本,一个滚动更新的过程,这个过程我们期望是圆滑的,对业务的负面影响是最小的。

1. 服务上线的依据

比如上面图中生产者消费者模式的架构图中,Provider服务什么时候才算是可以向Discovery(注册服务)说我已经完全准备好了,可以接收外部的流量请求了。

我们要考虑到一个服务run起来了,服务需要做一个初始化的准备,比如service,dao,load remote data 等等,需要有一个外挂的服务去做检测,比如下图中,我们在k8s中可以使用liveness和readiness探针作为一个依据,证明服务已经准备好了,可以接受流量了,去注册中心注册服务。

health check,同样也被用于外挂方式的容器健康检测,或者流量检测(k8s liveness & readiness)。

平滑发布

2. 服务什么时候下线

我的应用还是全部跑在k8s中,我现在有50个节点需要更新服务,从A版本升级到A‘版本,这个时候A服务还是在运行的,说明还是在处理不断打过来的请求,怎么做平滑的下线A版本。第一步是Provider服务收到一个kill的信号,第二步是需要告诉Discovery(服务发现注册中心),A服务要退出,这个过程就是一个自己注销自己的行为,Discovery服务注销了之后,剩下的Consumer服务会陆陆续续的接收到Provider注销的消息,Consumer就会在自己本地的负载均衡连接池中将已经注销的Provider给剔除,就不会把流量请求往已经注销的Provider中打。不过这个过程需要一个时间,这个时间具体是多久,不好说。第三步Provider服务已经是准备下线了,所以自己的health check接口需要标记成失败,因为consumer的连接池去定时的去请求Provider的健康检查的接口,发现不通了,就会把本地的连接池中provider给剔除,这也是变相的缩短的通知的时间。一般来说所有的consumer都能收到provider下线的消息,或者是在consumer发一个ping请求之后,在两个health check心跳周期内可以收到健康检查失败的结果。[PS:很多的rpc框架都不支持平滑退出,不过GRPC支持]为啥Nginx可以实现热reload,平滑加载配置文件,大致的逻辑是等上一波的请求运行万,直到活跃的请求数为0,安全的请求数为0,所以平滑退出也要做成这个样子,这样才是一个完整的平滑退出的过程。最后要提一下的是,凡事都有意外,总有那么几个服务会出现这样那样的问题导致无法退出,这个时候我们不能一直等待,最后要做兜底,需要强制的去kill掉这类的服务。

3 平滑发布流程总结

结合grpc的health check的完整的平滑下线的过程。

  • 收到kill信号
  • 向注册服务注销自己
  • 把自己的health check标记为失败
  • 使用GRPC或者HTTP的shutdown接口,比较多的prc的shutdown方法都支持传递一个context作为入参,我们可以设置一个超时时间
  • 最后还是无法退出只能是强制销毁,将容器或者pod强制的delete或者是直接kill -9 pid
posted @ 2021-02-20 14:08  ttlv  阅读(767)  评论(0编辑  收藏  举报