微服务入门系列:微服务架构的进程间通信

这是下面七篇系列文章的第三篇:

  1. 微服务入门系列:微服务介绍
  2. 微服务入门系列:使用 API 网关
  3. 微服务入门系列:微服务架构的进程间通信(本文)
  4. 微服务入门系列:微服务架构的服务发现
  5. 微服务入门系列:基于事件驱动的数据管理
  6. 微服务入门系列:微服务的部署策略选择
  7. 微服务入门系列:单体应用的微服务重构

出自微服务部落:https://blog.idevfun.io/building-microservices-inter-process-communication/
原文链接:https://www.nginx.com/blog/building-microservices-inter-process-communication/
作者:Chris Richardson
翻译:董干

这 7 篇文章是很不错的微服务入门系列文章,介绍了微服务所需要的主要技术,是当年(本系列文章写于2015年,但现在仍然不过时)带领译者入门微服务的文章,强烈建议深入微服务架构之前先了解本系列。


本文是关于使用微服务架构构建应用的七篇系列文章的第三篇。第一篇介绍了微服务架构模式,同单体架构模式进行了对比,并讨论了微服务的优势和缺点。第二篇,描述了客户端应用如何使用 API 网关作为同微服务通信的中间层。在本文中,我们会详细看一下在一个系统中服务之间是如何通信的。第四篇将会探索有关服务发现的问题。

介绍

在单体应用中,组件通过语言级别的方法或函数调用其他组件。与此不同地,基于微服务的应用程序是运行在多个机器上的分布式系统。每个服务实例通常都是一个进程。因此,如下图所示,服务之间必须使用某种进程间通信(IPC)的机制。

Richardson-microservices-part3-monolith-vs-microservices-1024x518

后面我们会讨论一下具体的 IPC 技术,但是首先让我们看一下各种各样的设计问题。

交互方式

当进行服务间的 IPC 方式选型时,先思考一下服务间是如何交互的有很大帮助。客户端和服务端之间的交互方式有很多。它们可以从两个不同的维度进行分类。第一个维度是交互是否是一对一还是一对多的:

  • 一对一 —— 每个客户端请求有且仅有一个服务实例处理。
  • 一对多 —— 每个请求可以被多个服务实例处理。

第二个维度是交互是否是同步还是异步的:

  • 同步 —— 客户端期望在一段短的时间内服务端会返回响应,并在它等待的时候有可能会阻塞。
  • 异步 —— 客户端在等待响应时不阻塞,如果有响应的话,并不需要立即返回。

下表列出了各种各样的交互方式。

一对一 一对多
同步 请求/响应 ——
异步 通知 发布/订阅
请求/异步响应 发布/异步响应

一对一的交互方式有以下几种:

  • 请求/响应 —— 客户端向服务端发出请求,并等待响应。客户端期望服务端能够及时返回。在基于线程的应用中,处理请求的线程在等待时甚至可能会阻塞。
  • 通知(也叫单向请求) —— 客户端向服务端发出请求,但是不期待响应,或者没有响应发出。
  • 请求/异步响应 —— 客户端向服务端发出请求,服务端异步响应。客户端在等待时不阻塞,并在设计的时候就认为响应可能会有段时间还到不了。

一对多的交互方式有以下几种:

  • 发布/订阅 —— 客户端发布一个通知消息,被0个或多个感兴趣的服务消费。
  • 发布/异步响应 —— 客户端发布一个请求消息,然后等待一定的时间,期待感兴趣的服务发出响应

每个服务通常会使用这些交互方式的组合。对于某些服务来说,单一的某种 IPC 机制就足够了。其他的服务可能需要几种 IPC 机制的组合。下图示出了在打车应用中当有用户叫车时服务之间是如何交互的。

Richardson-microservices-part3-taxi-service-1024x609

这些服务使用通知、请求/响应和发布/订阅等这些方式的组合。举个例子,乘客从手机发送一个通知到 Trip Management 服务,发出一个打车请求。Trip Management 服务通过请求 Passenger 服务验证乘客的账户信息是有效的。然后 Trip Management 服务创建一个行程,并使用订阅/发布的方式通知其他服务,包括定位可用司机的 Dispatcher 服务。

现在我们了解了各种各样的交互方式,接下来我们看一下如何定义 API。

定义 API

服务的 API 是服务和它的客户端之间的契约。不论选用什么样的 IPC 机制,使用某种接口定义语言(IDL)准确定义服务的 API 非常重要。甚至有很多观点倾向于 API 优先的方式定义服务。在开始一个服务的开发前,先编写接口定义,并同开户端的开发者一起评审。当 API 的定义迭代后再开始实现这个服务。使用这种设计增加了构建的服务能完全满足它所服务的客户端的可能性。

后面会提到,API 的设计取决于所使用的 IPC 机制。如果你使用消息传递的方式,API 由消息通道和消息类型构成。如果你使用 HTTP 的方式,API 由 URL 以及请求和响应的格式构成。稍后我们会更详细描述某些 IDL。

API 的演化

服务的 API 随着时间推移总会发生变化。在单体应用中,通常来说可以直接更改 API 并更新所有的调用者。在基于微服务的应用中会更困难些,即使 API 的所有消费者都是同一应用的其他服务。通常你不能强制所有的客户端和服务在同一步骤中升级。而且,你可能会增量地部署服务的新版本,使得服务的老版本和新版本能够同时运行。找到一个解决这些问题的策略很重要。

如何处理 API 的变化取决于改动的大小。有些改动比较小,而且是与之前的版本是向后兼容的。例如,你可能在请求或者响应中增加新的属性。将客户端和服务设计成符合健壮性原则是很有意义的。使用老 API 的客户端应该可以继续和新版本的服务交互。服务实现为缺失的请求参数提供默认值,客户端忽略响应中多余的属性。使用一种能简化 API 演化的 IPC 机制和消息格式很重要。

然而,有的时候你必须对 API 采用大的不兼容改动。由于你不能使得客户端都立刻更新,服务应该继续支持老的 API 一段时间。如果你使用基于 HTTP 的机制,例如 REST,一个方法是将版本号嵌入 URL 中。每个服务实例应该可以同时处理多个版本的请求。你也可以采取另一种方式,部署多个实例,每个实例处理一个具体的版本。

处理局部故障

正如上一篇关于 API 网关的文章中提到的,在一个分布式系统中,局部故障无处不在。由于客户端和服务都处在不同的进程中,服务可能没有及时对客户端的请求返回响应。服务有可能因为故障或者正在维护而不能服务。也有可能服务由于过载因此对请求响应极度缓慢。

例如,考虑那篇文章中商品详情的场景。假设推荐服务 Recommendation Service 没有响应了。不成熟的客户端的实现有可能会无限地等待响应。这样不仅会导致很差的用户体验,而且在很多应用中,它会消耗大量宝贵的资源,例如线程。最终,程序会线程耗尽,并变得无法响应,正如下图一样。

Richardson-microservices-part3-threads-blocked-1024x383

为了阻止这种情况的发生,将服务设计得能够处理局部故障很重要。

Netflix 描述了一种比较好的方式可以参考。处理局部故障的策略包括:

  • 网络超时 —— 当等待响应时,永远不要无限阻塞等待,永远使用超时。使用超时保证资源不会无限期地吃紧。
  • 为请求设置上限 —— 将客户端对某个服务的主要请求的数量设置上限。如果达到了上限,可能再继续请求就没有意义了,这个时候应该立即将请求返回失败。
  • 熔断模式 —— 记录成功和失败的请求数。如果错误率超过某个配置好的阈值,触发断路器使得接下来的请求尝试能够立即失败。如果大量的请求都失败了,这暗示服务不可用,继续发送请求也是没有意义的。在一段时间以后,客户端应该再重试,如果成功了,就关闭熔断断路器。
  • 提供降级逻辑 —— 当请求失败时,提供降级逻辑。例如,返回缓存的数据,或者默认的数据,例如空的推荐列表等。

Netflix 的 Hystrix 是一个实现了这些模式的开源库。如果你使用 JVM,一定要考虑使用 Hystrix。并且,如果你在非 JVM 的环境中,那么应该找一个等价的库。

IPC 技术

有很多不同的 IPC 技术可以选择。服务可以使用基于请求/响应的同步通信机制,例如基于 HTTP 的 REST 或者 Thrift。另外,也可以使用异步的,基于消息的通信机制,例如 AMQP 或者 STOMP。消息的格式也有很多种。服务可以使用可读性好的,基于文本的格式,例如 JSON 或者 XML。也可以使用更高效的二进制协议,例如 Avro 或者 Protocol Buffers。后面我们会看一下同步 IPC 的机制,但是在此之前,我们先讨论一下异步的 IPC 机制。

基于消息的异步通信

当使用消息队列时,进程之间通过异步地交换消息进行通信。客户端通过发送消息来发送请求。如果希望服务回应,服务端将会给客户端发送回另一条消息。由于通信过程是异步的,客户端不会一直阻塞等待回应,而是以一种默认回应不会立即到达的方式编写。

消息由消息头(例如像发送者这样的元数据)和消息体组成。消息在 channel 之上进行交换。多个生产者都可以向同一个 channel 发送消息。类似地,多个消费者都可以从同一个 channel 接收消息。Channel 有点对点发布订阅两种类型。点对点 channel将消息投递到正在读取此 channel 的一个消费者中。点对点 channel 的服务交互方式是前面描述的一对一交互方式的一种。发布订阅 channel 将每个消息投递到所有连接的消费者中。发布订阅 channel 的服务交互方式是前面描述的一对多交互方式的一种。

下图描述了打车应用使用发布订阅 channel 的可能的方式。

Richardson-microservices-part3-pub-sub-channels-1024x639

行程管理服务(Trip Management)通知感兴趣的服务,例如 Dispatcher,向发布/订阅 channel 写入一个“行程创建”的消息。Dispatcher 服务寻找可用的司机,并且通过向发布/订阅 channel 写入一个“司机接单”的消息来通知其他的服务。

可供选择的消息系统有很多。你应该选用一种支持多种编程语言的。有些消息系统支持标准协议,例如 AMQP 和 STOMP。还有一些消息系统使用有文档的私有协议。有很多可供选择的开源消息系统,包括 RabbitMQApache KafkaApache ActiveMQNSQ。从较高的层面讲,它们都支持某种形式的消息和 channel。它们都力争做到可靠、高性能、高可扩容性。但是,每种消息代理的消息模型细节有很大的不同。

使用消息通信有很多好处:

  • 将客户端同服务之间解耦 —— 客户端仅通过简单地往某个合适的 channel 发条消息就可以做出一个请求。客户端可以完全不知道服务的存在。它不需要使用某种发现机制去确定服务实例的位置。
  • 消息缓存 —— 使用例如 HTTP 这样的同步的请求/响应协议,客户端和服务端必须保证在通信的一刻同时在线。与此相对地,消息队列将写入 channel 的消息缓存起来直到它们可以被消费者处理。这将意味着,例如一个在线电商当订单处理系统缓慢或者不可用的时候,也仍然可以接收新的订单。这些订单的消息只要入队列即可。
  • 灵活的客户端和服务端之间的交互 —— 消息通信支持之前描述的各种交互方式。
  • 显式的进程间通信 —— 基于 RPC 的机制尝试将调用远程服务看起来像是调用本地服务一样。然而,由于物理定律和局部故障的可能性,它们实际上是非常不同的。消息系统使得这种差异非常的明显,使得开发者不至于陷入一种安全的假象中。

然而,使用消息通信也有一些缺点:

  • 额外的运维复杂性 —— 消息系统本身也是另外一个需要安装、配置和运维的系统组件。消息队列本身能够高可用很重要,否则将会影响系统的稳定性。
  • 实现基于请求/响应交互方式的复杂性 —— 请求/响应风格的交互的实现需要一些工作量。每个请求消息必须包含回应的 channel 标识以及消息间用于关联的 ID。服务向这个回应 channel 写入一条包含此关联 ID 的响应消息。客户端使用这个关联 ID 将响应和它对应的请求匹配起来。通常来说使用某种直接支持请求/响应的 IPC 机制都会更简单一些。

现在我们研究过了基于消息通信的 IPC,我们再来看一下基于请求/响应的 IPC。

同步的请求/响应 IPC 机制

当使用同步的,基于请求/响应的 IPC 机制时,客户端直接请求服务。服务处理请求并将响应返回。在很多客户端中,发出请求的线程会阻塞等待响应。还有客户端使用异步的,事件驱动的客户端代码,也许封装了 Futrue 或者 Rx Observable。与使用消息通信不同的是,客户端认为请求会及时返回。可供选择的协议也非常多。其中 REST 和 Thrift 是两种流行的协议。我们先看一下 REST。

REST

现今使用 RESTful 的方式开发 API 是件很流行的事。REST 是种(几乎总是)使用 HTTP 协议做进程间通信的机制。REST 的一个关键概念是资源(resource),它通常代表一个业务实体,例如 Customer 或 Product,或者一些业务实体的集合。REST 使用 HTTP 动词操作资源,并通过 URL 关联起来。例如, GET 请求返回某种形式的资源,可以是 XML 文档或者 JSON 对象。POST 请求创建一个新的资源,PUT 请求更新一个资源。引用 REST 的创作者 Roy Fielding 的话:

REST 提供了一些架构上的约束,当遵守这些约束时,强调组件之间交互的可扩容性、接口的通用性、组件的可独立部署能力,以及减少交互延时、实施安全保障和封装遗留系统的中间组件。

——Fielding, 《架构风格和基于网络的软件架构设计》

下图示出了打车应用使用 REST 的可能的方式之一。

Richardson-microservices-part3-rest

乘客的手机应用通过发出一个 POST 请求给 Trip Management 服务的 /trips 资源接口。该服务处理这个请求,并发出 GET 请求给 Passenger Management 服务。在验证该乘客拥有可创建一个行程的授权之后,Trip Management 服务创建一个行程订单,并返回 201 响应给手机应用。

许多开发者声称他们的基于 HTTP 的 API 是 RESTful 的。然而,根据 Fielding 在这篇博客文章中的的描述,实际上并不是所有的都是。Leonard Richardson (非作者亲属) 定义了一个很有用的 REST 成熟度模型,包含以下几个级别。

  • 级别0 —— 处于级别0 API的客户端通过 HTTP POST 请求某个单独的 URL 端点来调用服务。每个请求指定需要实施的操作动作、操作的目标(例如业务实体),以及任意请求参数。
  • 级别1 —— 处于级别1的 API 支持资源的概念。对某资源实施一个动作,客户端发出一个 POST 请求,并指定要执行的操作和任意的请求参数。
  • 级别2 —— 处于级别2的 API 使用 HTTP 动词执行动作:GET 用来获取,POST 用来创建,PUT 用来更新。请求参数和请求体,如果有的话,用来指定动作的参数。这使得服务能利用 web 基础设施,例如对 GET 请求的缓存。
  • 级别3 —— 处于级别3的 API 设计基于一个名字听起来很恐怖的原则 —— HATEOAS (Hypertext As the Engine of Application State)。基本思想是 GET 请求获得的资源的表示包含这个资源所允许做的操作。例如,客户端可以使用 GET 请求获取订单的表示,并通过其中关于取消订单操作的链接取消该订单。使用 HATEOAS 的好处包括不需要将 URL 硬编码到客户端代码中。还有一个好处是由于资源的表示中包含允许的操作,客户端不再需要猜测当前资源所在的状态所允许做的操作。

使用基于 HTTP 的协议有以下各种好处:

  • HTTP 简单且熟悉。
  • 可以通过浏览器扩展例如 Postman 或者通过命令行使用 curl (假设使用的是 JSON 或者其他文本格式)来测试 HTTP API。
  • 直接支持请求/响应风格的通信。
  • HTTP 天然防火墙友好。
  • 不需要额外的中间层代理,简化了系统的架构

使用 HTTP 也有以下缺点:

  • 只直接支持请求/响应交互方式。你可以使用 HTTP 发送通知,但是服务端必须返回 HTTP 响应。
  • 由于客户端和服务端直接通信(没有用于缓存消息的中间层),它们在交互时必须同时在线。
  • 客户端必须知道每个服务实例的位置(即 URL)。就像在上篇关于 API 网关的文章中介绍的一样,在现代应用中,这并不是个简单的事。客户端必须使用某种服务发现机制来定位服务的实例。

开发者社区最近重新发现了 RESTful API 的接口定义语言(IDL)的价值。目前有一些选择,包括 RAMLSwagger 等。一些 IDL 例如 Swagger 允许你定义请求和响应的消息格式。另一些像 RAML 需要使用其他的规范,例如 JSON Schema。IDL 不仅定义 API,通常还有能够通过接口定义生成客户端和服务端代码的能力。

Thrift

Apache Thrift 是与 REST 不同的一个有趣的替代者。它是一种用来编写跨语言 RPC 客户端和服务器的框架。Thrift 提供了一种类 C 语言的用来定义 API 的 IDL。你可以使用 Thrift 编译器生成客户端存根和服务端骨架代码。它可以生成至包括 C++、Java、Python、PHP、Ruby、Erlang 和 Node.js 的各种语言。

一个 Thrift 接口包含了一个或多个服务。每个服务的定义类似于 Java 中的 Interface,是一组强类型的方法。Thrift 方法可以返回(可能为空的)值,也可以被定义成单向方法。有返回的方法实现了请求/响应风格的进程间交互。客户端等待请求的响应并有可能抛出异常。单向请求对应于通知类型的进程间交互,服务端不发送响应给客户端。

Thrift 支持多种消息格式:JSON、二进制以及压缩的二进制。二进制格式比 JSON 效率更高,因为它解码更快。正如名字暗示,压缩的二进制是种空间效率更高的格式。JSON 当然对人和浏览器来说都更友好。Thrift 还允许你选择不同的传输协议,包括原始的 TCP 和 HTTP。原始的 TCP 比 HTTP 一般来说更为高效一些。但 HTTP 对于人、浏览器以及防火墙来说都更友好一些。

消息格式

我们已经介绍过了 HTTP 和 Thrift,现在我们来看一下消息格式的相关问题。如果你正在使用某种消息系统或者 REST,你需要选择一种消息格式。其他像 Thrift 这样的 IPC 机制可能只支持少数几种消息格式,也许只支持一种。不论是哪种,选择一种跨语言的消息格式很重要。即使你只使用单一的语言开发微服务,很有可能将来你需要使用某种其他语言。

消息格式有两种主要的类型:文本和二进制。基于文本格式的例子包括 JSON 和 XML。这些格式的一个优点是,它们不仅是人类可读的,还是自描述的。在 JSON 中,对象的属性由一些名值对集合表示。类似地,在 XML 中,属性由带命名的元素和值表示。这使得消息的消费者可以从中仅仅挑出自己感兴趣的值并忽略掉其余的。因此,对消息格式小的改动可以很容易地保持向后兼容。

XML 文档的结构由 XML schema 描述指定。随着时间发展,开发者社区意识到 JSON 也需要一个类似的机制。一个选择是使用 JSON Schema,可以独立使用,也可以作为像 Swagger 这样的 IDL 的一部分。

使用基于文本的消息格式的一个缺陷是消息太过于啰嗦,尤其是 XML。因为消息是自描述的,每条消息不仅包含了属性的值,还包含了属性的名称。另一个缺点是解析文本的性能消耗。因此,你可能会考虑使用二进制格式。

有很多二进制格式可供选择。如果你使用 Thrift RPC,你可以使用二进制 Thrift。如果你需要选择消息格式,流行的选项包括 Protocal BuffersApache Avro。这两种消息格式都提供了用来定义消息结构的强类型的 IDL。一个不同点是,Protocal Buffers 使用标签字段(tagged fields,译者注:protobuf 依靠消息定义时的序号 tag 来区分不同的 field),而 Avro 消费者需要知道具体的 schema 才能解释消息。因此,API 的演化对于 Protocal Buffers 来说比 Avro 要更容易一些。这篇不错的文章对比了 Thrift、Protocal Buffers 和 Avro 的不同。

总结

微服务之间通信必须使用某种进程间通信机制。当设计服务间如何通信时,你需要考虑各种问题:服务之间如何交互、如何指定每个服务的 API、如何升级 API,以及如何处理局部失败等。微服务可以选择使用两种类别的 IPC 机制:异步消息和同步请求/响应。在本系列的下一篇文章中,我们会探索一下微服务架构中服务发现的问题。


这是下面七篇系列文章的第三篇:

  1. 微服务入门系列:微服务介绍
  2. 微服务入门系列:使用 API 网关
  3. 微服务入门系列:微服务架构的进程间通信(本文)
  4. 微服务入门系列:微服务架构的服务发现
  5. 微服务入门系列:基于事件驱动的数据管理
  6. 微服务入门系列:微服务的部署策略选择
  7. 微服务入门系列:单体应用的微服务重构

出自微服务部落:https://blog.idevfun.io/building-microservices-inter-process-communication/
原文链接:https://www.nginx.com/blog/building-microservices-inter-process-communication/
作者:Chris Richardson
翻译:董干

这 7 篇文章是很不错的微服务入门系列文章,介绍了微服务所需要的主要技术,是当年(本系列文章写于2015年,但现在仍然不过时)带领译者入门微服务的文章,强烈建议深入微服务架构之前先了解本系列。

Show Comments

Get the latest posts delivered right to your inbox.