Chris Richardson 微服务系列翻译全7篇链接:
- 微服务介绍
- 构建微服务之使用API网关
- 构建微服务之微服务架构的进程通讯(本文)
- 微服务架构中的服务发现
- 微服务之事件驱动的数据管理
- 微服务部署
- 重构单体应用为微服务
原文链接:Building Microservices: Inter-Process Communication in a Microservices Architecture
简介
在单体应用中,模块间使用编程语言级别的方法或函数彼此调用。而基于微服务架构的本质是是运行在多台机器上的分布式应用,每个服务都是一个进程。如下图所示,微服务之间必须使用进程间通信(IPC)的机制实现交互:
稍后我们将讨论 IPC 技术,先看下设计相关的问题。
交互模式
当为某个服务选择 IPC 机制时,首先要考虑服务间如何交互。client 和 server 端有很多交互的方式,可以按两个维度分类:
第一个维度是一对一还是一对多:
- 一对一:每个 client 请求只会被一个 server 处理
- 一对多:每个 client 请求会被多个 server 处理
第二个维度是交互是同步还是异步:
- 同步模式:client 期望来自 server 的及时响应,甚至可能由于等待而阻塞
- 异步模式:client 等待响应时不会阻塞,不需要及时响应
下面表格展示了两种方式的不同:
一对一 | 一对多 | |
同步 | 请求/响应 | |
异步异步 | 通知 | 发布/订阅 |
请求/异步响应 | 发布/异步响应 |
下面有几种一对一的交互模式:
- 请求/响应:client 向 server 发送请求并等待响应,client 期望响应能及时到达。在一个基于线程的应用中,请求的线程可能在等待时阻塞线程的执行。
- 通知(单向请求):client 往 server 发送请求,但不期望响应。
- 请求/异步响应:client 往 server 发送请求,server 异步响应。client 不会阻塞,因为设计时就默认请求不会立即返回。
下面有几种一对多的交互模式:
- 发布/订阅模式:client 发布一个通知消息,消息会被 0 或多个感兴趣的服务消费。
- 发布/异步响应模式:client 发布一个请求消息,在一定时间内等待感兴趣服务的响应。
每个服务都是以上几种模式的组合,对某些服务来说,一个 IPC 机制就能满足了,另外一些服务可能需要多个 IPC 机制的组合。下图展示了用户叫车应用中,用户请求行程时,服务是如何交互的:
上图服务使用了通知、请求/响应、发布/订阅的方式。例如:乘客在移动端向『行程管理服务』发送接送需求的通知;『行程管理服务』使用 请求/响应 模式 调用『乘客服务』来验证乘客账号是否有效;然后『行程管理服务』创建行程并使用 发布/订阅 模式来通知其他服务(定位可用司机的『调度服务』等)。
我们讨论了交互风格,下面看下如何定义 API。
定义API
API 是服务端和客户端的契约。无论选择选择哪种 IPC 机制,都需要使用接口定义语言(IDL)来定义 服务的API。开发服务前,先定义服务接口,并与 client端开发者一起 review,后续再对 API 进行迭代。这样设计能帮助你构建更符合客户需求的服务。
文章后半段你会发现,API 的定义依赖选择的 IPC 机制。如果使用消息机制,API 则由消息频道和消息类型组成。如果使用 HTTP, API 则是由 URL 和 request/response 格式组成。后面我们将讨论 IDL 的细节。
API进化
服务的 API 不可避免的随着时间进化。单体应用中,可以直接修改 API 并更新所有的调用者。但在微服务应用中,即时 API 的所有调用者都在一个应用中,去更新其他服务也是很困难的,通常不能强制让所有 client 升级来保持和 server 端一致。此外,你可能还会增加部署新的服务版本,与老版本同时运行。了解处理这些问题的策略是非常重要的。
如何根据更改的大小来处理 API 呢?有的变化很小,通常可以与旧版本做到向后兼容,例如:为请求或响应添加了一个属性。对此,设计服务时考虑鲁棒性是很有必要的:使用旧版本 API 的 client 在新版本的 API 下能正常工作;server 为缺失的属性提供默认值;client 忽略响应中额外添加的属性。
有时候 API 不得不做一些大的、不兼容的变动,此时又不能强制让所有 client 立即升级,因此,旧版本 API 还需要运行一段时间。如果使用的是基于 HTTP 的 IPC,可以在 URL 里嵌入服务版本,每个服务实例可以同时处理多个版本。另一种方式也可以选择为每个版本单独部署。
处理局部故障
分布式系统普遍存在局部失败的问题,由于 client 和 server 是运行在独立的进程中,server 可能因为挂了或维护而暂时不可用,不能及时响应 client 的请求,或者因为过载而导致响应很慢。
以上篇文章提到的商品详情页场景为例,假设推荐服务没有响应,client 可能无限期的等待服务响应而导致阻塞,这不仅导致用户体验很糟糕,而且会占用线程等宝贵资源,就像下图所示,运行时线程耗尽,而无法响应任何请求:
为解决此类问题,设计时需要考虑局部故障的问题:
Netfilix 提供了较好的解决方案:
- 网络超时:等待响应时不设置无期限阻塞,而采用超时策略,保证资源不会无限被占用。
- 限制请求数量:为 client 对某个服务的请求设置访问上限,如果请求达到上限,则不再处理任何请求,做到快速失败。
- 熔断器模式:记录成功和失败的请求数量,如果失败率超过一个阀值,触发熔断器使得后面的请求立刻失败。如果大量请求失败,那这个服务可认为不可用,继续请求也没有意义。一段时间后,client 可以再次重试,如果成功,则关闭熔断器。
- 提供 fallback 机制:请求失败时提供 fallback,例如:返回缓存或一个默认值
Netflix Hystrix 是一个实现相关模式的开源库。如果使用 JVM,那么推荐使用 Hystrix。如果使用的非 JVM 环境,也可以使用类似的库。
IPC 技术
现在有不同的 IPC 技术可选择:基于 请求/响应 的同步通信模式,例如基于 HTTP 的 Rest 或 Thrift;也可以选择异步的、基于消息的通信模式,例如AMQP、STOMP。这些通信有着不同的消息格式,服务可以选择基于文本、方便阅读的 JSON 或 XML格式,或者效率更高的二进制格式(例如 Avro、Protocol Buffers)。
异步,基于消息的通信
使用消息模式时,进程间通过异步消息的方式来通信,client 发送消息来请求 server,如果期望 server 响应,则 server 会发送另外一条消息给 client。由于通信是异步的,client 不会因为等待响应而阻塞,同时 client 编程时也以服务不会立即响应来处理。
消息由消息头(元数据和发送者)和消息体组成,消息通过频道进行交换,任意数量的生产者都可以往频道里发送消息,同样,任意数量的消费者都可以从频道里消费消息。频道分为点对点、订阅/发布两种:
- 点对点模式:频道中的消息只会被交付给某个消费者,这种适用于前面提到的一对一的交互方式
- 订阅/发布模式:频道中的消息会被交付到所有感兴趣的消费者,这种适用于一对多的交互方式
下图展示了打车软件中如何使用 发布/订阅 模式:
行程管理服务向『订阅-发布』频道写入『创建行程』的消息,通知调度服务有新的行程请求。调度服务查找空闲的司机,并通过『发布-订阅』频道写入『推荐司机』的消息,通知其他服务。
有多种消息系统供我们选择,当然我们尽可能选择支持多种编程语言的。一些消息系统支持 AMQP和 STOMP 这样的标准协议,有的则支持专有的协议。开源的消息系统例如:RabbitMQ、Apacha Kafka、Apache ActiveMQ 和 NSQ。统一来看,他们都支持一些消息和频道,都致力于高可用、高性能和高可扩展性。
使用消息系统有很多优点:
- client 和 server 解耦,client 只需要将消息发送到合适的频道,完全不需要感知 server 的存在,因此不需要再去使用服务发现机制来确定服务实例的位置。
- 消息缓冲:在 HTTP 这样的请求/响应协议下,client 和 server 交互期间需要保证双方的可用性。然而在消息模式中,消息组件会将消息按照队列方式进行管理,直到消息被消费者消费。例如:即使订单系统很慢或不可用,在线商店仍旧可以接受客户的下单请求,只需要将下单消息放入队列即可。
- 灵活的 client-server 交互方式:消息支持前面提到的所有交互风格。
- 清晰的进程间通信:基于 RPC 的通信机制视图使调用远程服务像调用本地服务一样,然而,由于局部故障的可能,他们大不相同。消息机制使这些差异直观明显,开发者不会产生安全错觉。
当然,消息系统也有缺点:
- 额外的运维复杂度:消息系统组件的安装、部署、运维等工作,消息系统的高可用保障,否则会影响到系统的可用性。
- 实现 请求/响应 交互模式的复杂度:每条请求消息需要包含一个 回复渠道ID 和 关联ID,server 发送包含关联ID的响应消息到渠道中,client 使用关联ID 去匹配对应的响应。这种情况下,使用支持请求/响应的 IPC 机制会更容易些。
同步,请求/响应 IPC
使用同步、请求/响应的 IPC 时,client 请求 server 时有可能由于等待 server 响应而被阻塞。另外一些client 会使用异步、事件驱动的代码,例如封装好的 Future 或者 Rx Observable。这个模式最常见的协议是 Rest 和Thrift。
Rest
当前流行开发 RESTful 风格的 API。 Rest 是基于 HTTP 的 IPC 机制,其核心概念是使用 URL 来表示资源(用户或产品的一组业务对象)。例如:GET 请求会返回一个资源的信息,可能是 XML 文档 或 JSON 对象格式;POST 请求会创建新的资源;PUT 请求会更新资源。REST 之父 Roy Fielding 曾经说过:
REST provides a set of architectural constraints that, when applied as a whole, emphasizes scalability of component interactions, generality of interfaces, independent deployment of components, and intermediary components to reduce interaction latency, enforce security, and encapsulate legacy systems.
Rest 提供了一些列架构系统参数作为整体使用,强调组件交互的扩展性、接口的通用性、组件的独立部署、减少交互延迟的中间件,他强化安全,也能封装遗留系统。
下面展示打车软件使用 Rest 的场景:
Leonard Richardson 为 REST 定义了一个成熟度模型,分为如下四个层次:
- Level 0:web 服务使用 HTTP 作为传输方式,调用固定的 URL,每次请求指定方法和参数
- Level 1:引入了资源的概念,要执行对资源的操作,请求通过 POST,指定要执行的操作和参数
- Level 2:使用 HTTP 的语法来执行操作,例如:GET 表示获取,POST 表示创建,PUT 表示更新
- Level 3:API 定义按照 HATEOAS(Hypertext As The Engine Of Application State)设计原则,基本思想 GET 请求返回资源的一些对资源允许操作的链接。例如:client 使用 GET 订单资源中包含的链接取消某一订单。HATEOAS 的一个优点就是无需在 client 代码中写入硬链接的 URL。此外,返回的资源信息中包含了对资源允许操作的链接,client 无需再猜测当前资源下所能做哪些操作了
基于 HTTP 协议的优点:
- 简单,为大家所熟悉
- 可使用浏览器、postman,curl 之类的命令行测试 API
- 支持 请求/响应 模式的通信
- 不需要中间代理,减价系统架构
HTTP 不足之处:
- 只支持 请求/响应的交互
- client 和 server 之间没有消息缓冲机制,要求交互时双方必须同时运行
- client 需要知道每个 server实例 的url
Thrift
Apache Thrift 是 REST 的一个有趣的替代品,实现了跨语言的客户端和服务端RPC通信的框架,Thrift 提供了 C 语言风格的接口定义语言来定义 API,可以通过编译生成客户端Stub 和 服务端的骨架,可以生成多种语言的代码(包括 C++、Java、Python、PHP、Ruby、Erlang、Node.js)。
Thrift 接口通常包含一个或多个服务,服务定义与 Java 接口类似,是一组强类型方法的集合。Thrift 能返回值,也可以定义为单向通信。如果需要返回值就需要实现 请求/响应风格的交互,客户端等待响应时可以抛出异常;单向通信就是通知模式,服务端不需要返回响应。
Thrift 支持 JSON、二进制、压缩二进制等不同的消息格式。二进制解码比 JSON 更快,更为高效;压缩二进制比 JSON 空间利用率更高; JSON 则更易读。Thrift 也支持不同的通信协议:TCP 或 HTTP,TCP 比 HTTP 更加高效,而 HTTP 对防火墙、人及浏览器更加友好。
消息格式
选择一种支持多语言的消息格式非常重要,哪怕你只用一种语言实现微服务,谁又能保证以后不会使用新的语言呢?
目前有文本和二进制两种格式。文本格式包括 JSON 和 XML。这种格式优点不仅可读,而且是自描述的。JSON中,对象的属性是键值对的集合;XML中,属性表示为命名的元素和值。消费者能选择感兴趣的值而忽略其他部分,对格式的修改也能容易的向后兼容。
XML文档的结构是 XML Schema 定义的,随着时间的发展,开发者意识到 JSON 也需要一个类似的机制,方法一是使用 JSON Schema,要么独立使用,要么作为 Swagger 这类 IDL的一部分使用。
文本格式的一大缺点是消息会变的冗长,尤其是 XML:因为消息是自描述的,每条消息除了值之外还包括属性的名称。另一大缺点是解析文本的开销略大,此时可以考虑二进制格式。
二进制格式也很多,如果使用 Thrift,那么可以用二进制Thrift;如果使用其他消息格式,常用的还包括 Protocol Buffers 和 Apache Avro,两者都提供了 IDL 来定义消息结构。差异之处在于 Protocol Buffers 使用标记字段,而 Avro 消费者需要了解 Schema 来解析消息,使用 Protocol Buffers 时,API进化比 Avro 更容易。Martin Kleppmann 的 博客文章 对Thrift、Protocol Buffers 和 Avor 进行了详细的比较。
总结
微服务需要使用进程间消息通信机制来交互,设计服务的通信模式时,需要考虑一下几个问题:服务如何交互、如何定义 API、如何升级 API,如何处理局部故障。微服务架构有两种 IPC 机制可用:异步消息机制和同步请求/响应机制。下篇文章中,我们会讨论微服务架构中的服务发现问题。