Microservices Communications

在微服务架构中,会将一个完整的应用程序拆分成一组服务。这些服务之间需要经过协作,通过接口调用,才能组成一个完整的应用。

不同的服务部署在不同的机器上,或者同一个机器的多个容器中,进程间进行通信就不可避免了,也变得非常重要。

微服务的服务间通信与服务治理是微服务架构的实现层面的两大核心问题。

服务间通信

从通信类型的角度看,大概有三种类型:同步调用,异步调用,广播。

在微服务的设计之初要想清楚调用关系以及调用方式,哪些需要同步,哪些可以异步,哪些需要广播,好团队内部要有统一的认识。

然后就是要确定好调用协议了,例如常见的选择有:

  • 同步调用:HTTP REST, gRPC, thrift, etc.
  • 异步调用:Sidekiq, Celery, etc. 对应的backend有 Redis, 各类 AMQP 实现, Kafka 等等
  • 广播:各类 AMQP 实现, Kafka,etc.

对于如何选择,需要从很多角度去思考。其实无非就是从几个角度:性能,成熟度,易用性,生态,技术团队。建议优先选择社区活跃,以及和自己团队技术栈匹配的协议。

除了选择协议,更加重要的应该就是接口文档的管理了。好接口文档和代码是强相关的,就像 gRPC 的 proto 和生成出来的代码那样。文档比较难以维护,很有可能代码改了文档没改。

总之,关于服务间通信,需要做好:

  • 确定接口规范,什么用同步调用,什么用异步,什么用广播;同步调用用什么协议,异步用什么
  • 确定接口协议,以及思考接口文档的管理,接口文档与代码之间如何建立强联系

REST

如今开发者非常喜欢使用RESTful风格来开发API。REST是一种总是使用HTTP协议的进程间通信机制,REST之父Roy Fielding曾经说过:

REST中的一个关键概念是资源,它通常表示单个业务对象,例如客户或产品,或业务对象的集合。REST使用HTTP动词来操作资源,使用URL引用这些资源。例如,GET请求返回资源的表示形式,该资源通常采用XML文档或JSON对象的形式,但也可以使用其他格式(如二进制)。POST请求创建新资源,PUT请求更新资源。例如,Order Service具有用于创建Order的POST/order端点以及用于检索Order的GET/orders/{orderId}端点。

REST使用HTTP verb来操作资源,如:

  • POST /movies : Create a movie
  • PUT /movies : Update a movie
  • GET /movies : Get all movies
  • GET /movies/{movieId} : Get a movie

REST的好处和弊端

REST有如下好处:

  • 它非常简单,并且大家都很熟悉。
  • 可以使用浏览器扩展(比如Postman插件)或者curl之类的命令行(假设使用的是JSON或其他文本格式)来测试HTTP API。
  • 直接支持请求/响应方式的通信。
  • HTTP对防火墙友好。
  • 不需要中间代理,简化了系统架构。

它也存在一些弊端:

  • 它只支持请求/响应方式的通信。
  • 可能导致可用性降低。由于客户端和服务直接通信而没有代理来缓冲消息,因此它们必须在REST API调用期间都保持在线。
  • 客户端必须知道服务实例的位置(URL)。如3.2.4节所述,这是现代应用程序中的一个重要问题。客户端必须使用所谓的服务发现机制来定位服务实例。
  • 在单个请求中获取多个资源具有挑战性。
  • 有时很难将多个更新操作映射到HTTP动词。

虽然存在这些缺点,但REST似乎是API的事实标准,尽管有几个有趣的替代方案。例如,通过GraphQL实现灵活、高效的数据提取。

gRPC

在 gRPC 里客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法,能够更容易地创建分布式应用和服务。与许多 RPC 系统类似,gRPC 也是基于以下理念:定义一个服务,指定其能够被远程调用的方法(包含参数和返回类型)。在服务端实现这个接口,并运行一个 gRPC 服务器来处理客户端调用。在客户端拥有一个存根能够像服务端一样的方法。

gRPC 客户端和服务端可以在多种环境中运行和交互 - 从 google 内部的服务器到你自己的PC
,并且可以用任何 gRPC 支持的语言来编写。所以,可以很容易地用 Java 创建一个 gRPC 服务端,用 Go、Python、Ruby 来创建客户端。此外,Google 最新 API 将有 gRPC 版本的接口,使你很容易地将 Google 的功能集成到你的应用里。

gRPC 默认使用 protocol buffers,这是 Google 开源的一套成熟的结构数据序列化机制(当然也可以使用其他数据格式如 JSON)。正如你将在下方例子里所看到的,用 proto files 创建 gRPC 服务,用 protocol buffers 消息类型来定义方法参数和返回类型。你可以在 Protocol Buffers 文档找到更多关于 Protocol Buffers 的资料。

gRPC无论是客户端还是服务端都可以在多种语言环境中运行。客户端和服务端之间通过Proto的请求和响应完成跨网络和跨语言的访问。

基于HTTP/2

HTTP/2 提供了连接多路复用、双向流、服务器推送、请求优先级、首部压缩等机制。可以节省带宽、降低TCP链接次数、节省CPU,帮助移动设备延长电池寿命等。gRPC 的协议设计上使用了HTTP2 现有的语义,请求和响应的数据使用HTTP Body 发送,其他的控制信息则用Header 表示。

数据编码

数据编码顾名思义就是在将请求的内存对像转化成可以传输的字节流发给服务端,并将收到的字节流再转化成内存对像。方法有很多,常见的有 XML、JSON、Protobuf。XML 已经日薄西山,JSON 风头正盛,Protobuf 则方兴未艾。gRPC 默认选用 Protobuf,早期貌似只支持 Protobuf,现在号称也支持 JSON 了,但不知道有多少人在用。

为什么选 Protobuf 呢?Protobuf 同样也是谷歌的产品,我想这是其中的一个原因。另外一个原因应该是 Protobuf 在某些场景下的效率要比 JSON 高一些。请大家牢记,天下没有免费的午餐,所有的优化都是有代价的。我们在考虑问题的时候一定要思考选择什么和放弃什么。

gRPC使用ProtoBuf来定义服务,ProtoBuf是由Google开发的一种数据序列化协议(类似于XML、JSON、hessian)。

JSON

要理解 Protobuf 的优化,我们就需要回过头来看 JSON 有什么缺点。这是一段典型的 JSON

1
2
{ "int":12345, "str": "hello", "bool": true }
{ "int":67890, "str": "hello", "bool": false }

头一个缺点是非字符串编码低效。比如 int 字段的值是 12345,内存表示只占两个字节,转成 JSON 却要五个字节。 bool 字段则占了四或五个字节。

再一个缺点就是信息冗余。同一个接口同一个对像,只是 int 字段的值不同,每次都还要传输”int”这个字段名。JSON 在可读性和编码效率之间选择了可读性,所以效率方面做了一定的牺牲。

Protobuf

如果人们觉得效率是主要矛盾,那就必然会牺牲可读性。为此,Protobuf 一方面选用了 VarInts 对数字进行编码,解决了效率问题;另一方面给每个字段指定一个整数编号,传输的时候只传字段编号,解决了冗余问题。更多细节可参考的另一篇文章Protobuf

在传输的时候只传了字段编号固然可以提高传输效率,但接收方如何知道各个编号对应哪个字段呢?只能事先约定了。就像当年地下工作者一样,一人拿一个密码本。Protobuf 使用 .proto 文件当密码本,记录字段和编号的对应关系

1
2
3
4
5
message Demo {
int32 i = 1;
string s = 2;
bool b = 3;
}

Protobuf 提供了一系列工具,为 proto 描述的 message 生成各种语言的代码。传输效率上去了,工具链也更加复杂了。如果你给 gRPC 通信抓过包,你一定会怀念 JSON 的。

好了,数据编码问题到此告一段落,我们继续讨论请求映射问题。

因为有 .proto 作为 IDL,Protobuf 确实可以做很多 JSON 不方便做的事情。其中最重的就是 RPC 描述!

1
2
3
4
5
6
7
8
9
10
11
12
13
package demo.hello;

service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
string name = 1;
}

message HelloReply {
string message = 1;
}

上面的 .proto 文件定义了一个 Greeter 服务,其中有一个 SayHello 的方法,接受 HelloRequest 消息并返回 HelloReply 消息。如何实现这个 Greeter 则是语言无关的,所以叫 IDL。gRPC 就是用了 Protobuf 的 service 来描述 RPC 接口的。

请求映射

接口路径

那问题来了,gRPC 如何映射请求呢?要回答这个问题,首先要回答 gRPC 在底层使用什么传输协议。答案是 HTTP 协议,准确的说,gRPC 使用的是 HTTP/2 协议。不过就我们现在讨论的内容而言,我们暂时可以忽略 HTTP/2 和 HTTP/1 区别。

现在你可以简单认为一个 gRPC 请求就是一个 HTTP 请求(不严格)。这个 HTTP 请求用的是 POST 方法,对应的资源路径则是根据 .proto 定义确定的。我们前文提到的 Greeter 服务对应的路径是/demo.hello.Greeter/SayHello 。

一个 gRPC 定义包含三个部分,包名、服务名和接口名,连接规则如下

1
/${包名}.${服务名}/${接口名}

SayHello的包名是demo.hello,服务名是Greeter,接口名是SayHello,所以对应的路径就是 /demo.hello.Greeter/SayHello。如此的朴实无华!

gRPC 协议规定Content-Typeheader 的取值为application/grpc,当然也可以写成application/grpc+proto。如果你想使用 JSON 编码,也可以设成application/grpc+json,只要服务支持都行。

消息格式

最后就要确定请求 body 的定义了。如果用的 Protobuf 编码,那 body 肯定是编码后的字节流。那 gRPC 的 HTTP 请求是不是这样呢?

1
2
3
4
5
6
POST /demo.hello.Greeter/SayHello HTTP/1
Host: grpc.demo.com
Content-Type: application/grpc
Content-Length: 1234

<protobuf bytes>

答案是否定的!简单来说,gRPC 要求在 Protobuf 字节流前面加一个五字节的前缀,第一个字节表示字节流是否被压缩,后四个字节存储数据长度,并取名叫作 Length-Prefixed Message。

熟悉 HTTP 协议的同学都清楚,HTTP 协议本身可以通过 Content-Encoding 表示压缩算法,使用 Content-Length 指定数据长度。gRPC 为什么要重新定义一套机制呢?

流式接口

答案在于 gRPC 支持的另一特性 stream rpc!为方便行文,我们称之为流式接口。所谓流式,就是可以源源不断收发消息。这个跟 HTTP 的一收一发有着显著的差别。

1
2
3
4
5
6
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
rpc SayHello (stream HelloRequest) returns (HelloReply) {}
rpc SayHello (HelloRequest) returns (stream HelloReply) {}
rpc SayHello (stream HelloRequest) returns (stream HelloReply) {}
}

gRPC 持三种流式接口,定义的办法就是在参数前加上 stream 关键字,分别是:请求流、响应流和双向流。

第一种叫请求流,可以在 RPC 发起之后不断发送新的请求消息。此类接口最典型的使用场景是发推送或者短信。

第二种叫响应流,可以在 RPC 发起之后不断接收新的响应消息。此类接口最典型的使用场景是订阅消息通知。

最后一种是双向流。可以在 RPC 发起之后同时收发消息。此类接口最典型的使用场景是实时语音转字幕。

为了实现流式传输,gRPC 不得不引入所谓的 Length-Prefixed Message。同一个 gRPC 请求的不同消息共用 HTTP 头信息,所以只能给每个消息单独加一个五字节的前缀来表示压缩和长度信息了。

就是因为这五个字节,不管你是 Protobuf 还是 JSON,都注定了 gRPC 只能是二进制协议,UNIX 下常用的文本工具都无法很好地处理 gRPC 的通信内容。

返回状态

gRPC 还定义了自己的返回状态和消息,分别用 grpc-status 和 grpc-message 头传输。所以最简单的 gRPC 通信(非流式调用,unary)内容长成这个样子

请求内容

1
2
3
4
5
6
POST /demo.hello.Greeter/SayHello HTTP/1.1
Host: grpc.demo.com
Content-Type: application/grpc
Content-Length: 1234

<Length-Prefixed Message>

响应内容

1
2
3
4
5
HTTP/1.1 200 OK
Content-Length: 5678
Content-Type: application/grpc

<Length-Prefixed Message>

如果你真的理解前文所讲的内容,那么你现在可以写一个非流式 gRPC 的客户端了。我们自用的 sniper 框架就自带了一个,源码在这里

gRPC vs HTTP

最后讲一下 gRPC 跟 HTTP 协议的关系。

如果单看非流式调用,也就是 unary call,gRPC 并不复杂,跟普通的 HTTP 请求也没有太大区别。我们甚至可以使用 HTTP/1.1 来承载 gRPC 流量。但是 gRPC 支持流式接口,这就有点难办了。

我们知道,HTTP/1.1 也是支持复用 TCP 连接的。但这种复用有一个明显的缺陷,所有请求必须排队。也就是说一定要按照请求、等待、响应、请求、等待、响应这样的顺序进行。先到先服务。而在实际的业务场景中肯定会有一些请求响应时间很长,客户端在收到响应之前会一直霸占着TCP连接。在这段时间里别的请求要么等待,要么发起新的 TCP 连接。在效率上确实有优化的余地。一言以蔽之,HTTP/1.1 不能充分地复用 TCP 连接。

后来,HTTP/2 横空出世!通过引入 stream 的概念,解决了 TCP 连接复用的问题。你可以把 HTTP/2 的 stream 简单理解为逻辑上的 TCP 连接,可以在一条 TCP 连接上并行收发 HTTP 消息,而无需像 HTTP/1.1 那样等待。

所以 gRPC 为了实现流式特性,选择使用 HTTP/2 进行通信。所以,前文的 Greeter 调用的实际通信内容长这个样子。

请求内容

1
2
3
4
5
6
7
8
9
HEADERS (flags = END_HEADERS) # header frame
:method = POST
:scheme = http
:path = /demo.hello.Greeter/SayHello
:authority = grpc.demo.com
content-type = application/grpc+proto

DATA (flags = END_STREAM) # data frame
<Length-Prefixed Message>

响应内容

1
2
3
4
5
6
7
8
9
HEADERS (flags = END_HEADERS) # header frame
:status = 200
content-type = application/grpc+proto

DATA # data frame
<Length-Prefixed Message>

HEADERS (flags = END_STREAM, END_HEADERS) # header frame
grpc-status = 0

HTTP/2 的 header 和 data 使用独立的 frame(中文译作帧,简单来说也是一种 Length-Prefixed 消息,是 HTTP/2 通信的基本单位) 发送,可以多次发送。HTTP/1.1 只能先发 header 再发 data(不完全准确。提示 http trunk),HTTP/2 可以交替发送。比如上文中的 gRPC 响应,先发一个 header frame,告知 http 状态;再发一个 data frame,传输 gRPC 消息;最后又发了一个 header frame,告知 grpc-status 状态,这是 gRPC 自定义的状态码。

一般不是先发 header 再发 data 的吗?为什么 gRPC 需要在发完 data 之后才发 grpc-status 头呢?

还是流式接口导致的问题。在所有的流式消息没有传输完成之前,服务端也不知道要传什么 grpc-status 。

多语言支持

gRPC支持多种语言(C, C++, Python, PHP, Nodejs, C#, Objective-C、Golang、Java),并能够基于语言自动生成客户端和服务端功能库。目前已提供了C版本grpc、Java版本grpc-java 和 Go版本grpc-go,其它语言的版本正在积极开发中,其中,grpc支持C、C++、Node.js、Python、Ruby、Objective-C、PHP和C#等语言,grpc-java已经支持Android开发。

优缺点

优点

  • protobuf二进制消息,性能好/效率高(空间和时间效率都很不错)
  • proto文件生成目标代码,简单易用
  • 序列化反序列化直接对应程序中的数据类,不需要解析后在进行映射(XML,JSON都是这种方式)
  • 支持向前兼容(新加字段采用默认值)和向后兼容(忽略新加字段),简化升级
  • 支持多种语言(可以把proto文件看做IDL文件)
  • Netty等一些框架集成

缺点:

  • GRPC尚未提供连接池,需要自行实现
  • 尚未提供“服务发现”、“负载均衡”机制
  • 因为基于HTTP2,绝大部多数HTTP Server、Nginx都尚不支持,即Nginx不能将GRPC请求作为HTTP请求来负载均衡,而是作为普通的TCP请求。(nginx1.9版本已支持)
  • Protobuf二进制可读性差(貌似提供了Text_Fromat功能)
    默认不具备动态特性(可以通过动态定义生成消息类型或者动态编译支持)

Protocol Buffers

你可以理解 ProtoBuf 是一种更加灵活、高效的数据格式,与 XML、JSON 类似,在一些高性能且对响应速度有要求的数据传输场景非常适用。

ProtoBuf 在 gRPC 的框架中主要有三个作用:定义数据结构、定义服务接口,通过序列化和反序列化方式提升传输效率。

使用 XML、JSON 进行数据编译时,数据文本格式更容易阅读,但进行数据交换时,设备就需要耗费大量的 CPU 在 I/O 动作上,自然会影响整个传输速率。

Protocol Buffers 不像前者,它会将字符串进行序列化后再进行传输,即二进制数据。

消息格式

进程间通信的本质是交换消息。消息通常包括数据,因此一个重要的设计决策就是这些数据的格式。消息格式的选择会对进程间通信的效率、API的可用性和可演化性产生影响。如果你正在使用一个类似HTTP的消息系统或者协议,那么你需要选择消息的格式。有些进程间通信机制,如我们马上就会讲到的gRPC,已经指定了消息格式。在这两种情况下,使用跨语言的消息格式尤为重要。即使我们今天使用同一种编程语言来开发微服务应用,那也很有可能在今后会扩展到其他的编程语言。我们不应该使用类似Java序列化这样跟编程语言强相关的消息格式。
消息的格式可以分为两大类:文本和二进制。我们来逐一分析。

基于文本的消息格式

第一类是JSON和XML这样的基于文本的格式。这类消息格式的好处在于,它们的可读性很高,同时也是自描述的。JSON消息是命名属性的集合。相似地,XML消息也是命名元素和值的集合。这样的格式允许消息的接收方只挑选他们感兴趣的值,而忽略掉其他。因此,对消息结构的修改可以做到很好的后向兼容性。

XML文档结构的定义由XML Schema完成W3C XML Schema。开发者社区逐渐意识到JSON也需要一个类似的机制,因此使用JSON Schema变得逐渐流行。JSON Schema定义了消息属性的名称和类型,以及它们是可选的还是必需的。除了能够起到文档的作用之外,应用程序还可以使用JSON Schema来验证传入的消息结构是否正确。

使用基于文本格式消息的弊端主要是消息往往过度冗长,特别是XML。消息的每一次传递都必须反复包含除了值以外的属性名称,这样会造成额外的开销。另外一个弊端是解析文本引入的额外开销,尤其是在消息较大的时候。因此,在对效率和性能敏感的场景下,可能需要考虑基于二进制格式的消息。

二进制消息格式

有几种不同的二进制格式可供选择。常用的包括Protocol BuffersAvro。这两种格式都提供了一个强类型定义的IDL(接口描述文件),用于定义消息的格式。编译器会自动根据这些格式生成序列化和反序列化的代码。因此你不得不采用API优先的方法来进行服务设计。此外,如果使用静态类型语言编写客户端,编译器会强制检查它是否使用了正确的API格式。

这两种二进制格式的区别在于,Protocol Buffers使用tagged fields(带标记的字段),而Avro的消费者在解析消息之前需要知道它的格式。因此,实行API的版本升级演进,Protocol Buffer要优于Avro。有篇博客文章对Thrift、Protocol Buffers和Avro做了非常全面的比较。

现在我们已经了解了消息格式,再来看看用于传输消息的特定进程间通信机制,从远程过程调用(RPI)模式开始。

异步消息模式的通信

当使用消息传递时,进程通过异步交换消息进行通信。客户端通过发送消息向服务发出请求。如果服务需要回复,则通过向客户端发送一条单独的消息来实现。由于通信是异步的,因此客户端不会阻塞等待回复。相反,客户端被假定不会立即收到回复。

一条消息由头部(如发件人之类的元数据)和消息体组成。消息通过通道进行交换。任何数量的生产者都可以向通道发送消息。类似地,任何数量的消费者都可以从通道接收消息。有两种通道类型,分别是点对点(point‑to‑point)与发布订阅(publish‑subscribe):

  • 点对点通道发送一条消息给一个切确的、正在从通道读取消息的消费者。服务使用点对点通道,就是上述的一对一交互方式。

  • 发布订阅通道将每条消息传递给所有已订阅的消费者。服务使用发布订阅通道,就是上述的一对多交互方式。

打车应用如何使用发布订阅通道:

Trip Management 服务通过向发布订阅通道写入 Trip Created 消息来通知已订阅的服务,如 Dispatcher。Dispatcher 找到可用的司机并通过向发布订阅通道写入 Driver Proposed 消息来通知其他服务。

有许多消息系统可供选择,你应该选择一个支持多种编程语言的。

一些消息系统支持标准协议,如 AMQP 和 STOMP。其他消息系统有专有的文档化协议。

有大量的开源消息系统可供选择,包括 RabbitMQApache KafkaApache ActiveMQNSQ。从高层而言,他们都支持某种形式的消息和通道。他们都力求做到可靠、高性能和可扩展。然而,每个代理的消息传递模型细节上都存在着很大差异。

使用消息传递有很多优点:

  • 将客户端与服务分离

    客户端通过向相应的通道发送一条消息来简单地发出一个请求。服务实例对客户端而言是透明的。客户端不需要使用发现机制来确定服务实例的位置。

  • 消息缓冲

    使用如 HTTP 的同步请求/响应协议,客户端和服务在交换期间必须可用。相比之下,消息代理会将消息排队写入通道,直到消费者处理它们。这意味着,例如,即使订单执行系统出现缓慢或不可用的情况,在线商店还是可以接受客户的订单。订单消息只需要简单地排队。

  • 灵活的客户端 — 服务交互

    消息传递支持前面提到的所有交互方式。

  • 毫无隐瞒的进程间通信

    基于 RPC 的机制试图使调用远程服务看起来与调用本地服务相同。然而,由于物理因素和局部故障的可能性,他们实际上是完全不同的。消息传递使这些差异变得非常明显,所以开发人员不会被这些虚假的安全感所欺骗。

然而,消息传递也存在一些缺点:

  • 额外的复杂操作

    消息传递系统是一个需要安装、配置和操作的系统组件。消息代理程序必须高度可用,否则系统的可靠性将受到影响。

  • 实现基于请求/响应式交互的复杂性

    请求/响应式交互需要做些工作来实现。每个请求消息必须包含应答通道标识符和相关标识符。该服务将包含相关 ID 的响应消息写入应答信道。客户端使用相关 ID 将响应与请求相匹配。通常使用直接支持请求/响应的 IPC 机制更加容易。

服务治理

微服务化带来了很多好处,例如:通过将复杂系统切分为若干个微服务来分解和降低复杂度,使得这些微服务易于被小型的开发团队所理解和维护。然而也带来了很多挑战,例如:微服务的连接、服务注册、服务发现、负载均衡、监控、AB测试,金丝雀发布、限流、访问控制,等等。
这些挑战即是服务治理的内容。

reference

微服务的服务间通信与服务治理

微服务之间最佳调用方式是什么?

远程过程调用(RPC)详解

微服务架构中的进程间通信 | ykgarfield’s blog

微服务架构中的进程间通信

聊聊微服务的通信模式_云计算_Bibek Shah_InfoQ精选文章

进程间通信 - 微服务

API 设计 - Azure Architecture Center

微服务:服务间如何通信?

https://levelup.gitconnected.com/4-ways-to-establish-communication-between-microservices-984207f29497

Communication in a microservice architecture | Microsoft Learn

https://medium.com/design-microservices-architecture-with-patterns/microservices-communications-f319f8d76b71

Schema evolution in Avro, Protocol Buffers and Thrift &mdash; Martin Kleppmann&rsquo;s blog

高效的数据压缩编码方式 Protobuf