微服务架构设计模式(三)进程间通信

进程间通信

一. 概述

1.1 微服务架构中要频繁使用进程间通信

FIGO有一个REST API供移动端和Web端使用,还使用了一些云服务如Twilio消息服务和Stripe支付服务,但在单体应用内是语言级方法或函数相互调用,除了开发API和集成云服务不会考虑进程间通信(IPC)。

微服务架构中各个服务实例通常是在多台机器上运行的进程,所以必须使用进程间通信进行交互。选择合适的进程间通信机制是一个重要的架构决策,除了影响应用的可用性,甚至还与事务管理互相影响。理想的微服务架构应该在内部由松散耦合的若干服务组成,服务间使用异步消息相互通信

1.2 常见进程间通信技术

进程间通信技术有很多:

  • 基于同步请求 / 响应的通信机制:如 HTTP REST 或 gRPC,REST(JSON)主要用于服务与外部其他应用程序的通信。
  • 异步的基于消息的通信机制:如 AMQP 或 STOMP。

消息格式也有:

  • 基于文本的 JSON 或 XML,有可读性。
  • 基于二进制的 Avro 或 Protocol Buffers 格式,更加高效。

1.3 交互方式

选择进程间通信机制要先考虑服务与其客户端的交互方式,从而可以专注于需求而不是单纯技术层面的考量,交互方式的选择会影响可用性和后续的集成测试策略。

交互方式关注两个维度:

  • 映射关系:
    • 一对一:每个客户端由一个服务实例来处理。
    • 一对多:每个客户端由多个服务实例来处理。
  • 同步异步:
    • 同步模式:客户端请求需要服务端实时响应,客户端等待响应时可能导致阻塞。
    • 异步模式:客户端请求不会阻塞进程,服务端响应可以非实时。
一对一 一对多
同步模式 请求/响应
异步模式 异步请求/响应
单向通知
发布/订阅
发布/异步响应
  • 一对一的交互方式:
    • 请求/响应:一个客户端向服务端发起请求,等待响应;客户端期望服务端很快发送响应。在一个基于线程的应用中,等待过程可能造成线程阻塞,这种方式会导致服务紧耦合。
    • 异步请求/响应:客户端发送请求到服务端,服务端异步响应请求。客户端等待时不会阻塞线程,而服务端响应也不一定会马上返回。
    • 单向通知:客户端请求发送到服务端,但不期望服务端做出任何响应。
  • 一对多的交互方式:
    • 发布/订阅:客户端发布通知消息,被零个或多个感兴趣的服务订阅。
    • 发布/异步响应:客户端发布请求消息,然后等待从感兴趣的服务发回的响应。

注意:底层的进程间通信技术并不会影响所选择的交互方式,可以是REST或消息机制,但选择同步请求/响应方式都会使客户端等待响应。

1.4 定义API

设计良好的接口在暴漏有用功能的同时隐藏实现细节,实现可以被修改,但接口尽量保持不变以不对客户端产生影响。

Java是静态类型编程语言,如果接口与客户端不兼容,应用程序会无法通过编译。微服务架构的挑战是:没有一个简单的编程语言结构可以用来构造和定义服务的API。若使用不兼容的API部署新版本的服务,编译阶段不会出错,但会导致运行时故障。

使用接口定义语言(IDL)精确定义服务的API很重要,API优先设计,迭代几轮API定义后再开始具体的服务实现编程。

  • 使用消息机制,API由消息通道、消息类型和消息格式组成。
  • 使用HTTP,API由URL、HTTP动词以及请求和响应格式组成。

1.5 API演化

微服务中API的使用者很有可能是另外的开发团队或组织外的人,不能够要求客户端跟服务端API版本保持一致,现代应用程序有着极高的可用性要求,一般会采用滚动升级的方式来更新服务,所以一个服务的新版本和旧版本肯定会共存。

  • 语义化版本控制:是一组规则,用于指定如何使用版本号,并且以正确的方式递增版本号。

    版本号由三部分组成:

    • MAJOR:对API进行不兼容的更改时。
    • MINOR:对API进行向后兼容的增强时。
    • PATCH:进行向后兼容的错误修复时。

    如使用REST API时,可以使用主要版本作为URL路径的第一个元素;使用消息机制的服务,可以在发布的消息中包含版本号。

  • 进行次要并且向后兼容的改变:向后兼容是对API的附加更改或功能增强。

    • 客户端和服务端应该遵守健壮性原则,服务为缺少的请求属性提供默认值,客户端忽略额外的响应属性。
  • 进行主要并且不向后兼容的改变:因为无法强制客户端升级,所以需要在一段时间内同时支持新旧版本的API。

    • 使用基于HTTP的进程间通信机制,如REST,可以在URL中嵌入主要版本号,版本1对应 /v1/... 和 版本2对应 /v2/... 为前缀。

1.6 消息格式

  • 基于文本:消息过度冗长,解析文本引入了额外开销。
    • XML:相比JSON更长。
    • JSON
  • 基于二进制:提供了一个强类型定义的IDL(接口描述文件),用于定义消息的格式。编译器自动根据格式生成序列化和反序列化代码。
    • Protocol Buffers:使用tagged fields,带标记的字段。
    • Avro:其消费者在解析消息前要知道格式。

二. 基于同步远程过程调用模式的通信

基于同步远程过程调用(RPI)的通信机制中,客户端假定消息将及时到达。

代理接口通常封装底层通信协议,可以选如REST何gRPC等。

2.1 REST

2.1.1 RESTful风格

REST是一种总是使用HTTP协议的进程间通信机制,提供了一系列架构约束,强调组件交互的可扩展性、接口的通用性、组件的独立部署,以及那些能减少交互延迟的中间件,其强化了安全性也能封装遗留系统。

REST使用资源表示单个业务对象,使用HTTP动词来操作资源,使用URL引用资源。

  • GET获取资源
  • POST创建资源
  • PUT更新资源
  • DELETE删除资源

2.1.2 成熟度模型

  • Level 0:客户端只是向服务端点发起HTTP POST请求,进行服务调用。每个请求指明了需要执行的操作、针对的目标和必要的参数。
  • Level 1:该层级引入资源的概念,客户端需要发出指定要执行的操作和包含任何参数的POST请求。
  • Level 2:使用HTTP动词来执行操作,请求查询参数和主体指定操作的参数,这能够让服务借助Web基础设施服务,如通过CDN缓存GET请求。
  • Level 3:基于HATEOAS(Hypertext As The Engine Of Application State)原则设计,基本思想是由GET请求返回的资源信息中包含链接,链接能够执行该资源允许的操作。如客户端通过订单资源包含的链接取消某一订单或获取该订单详情。优点包括无需在客户端代码中写入硬链接的URL,因为资源包括允许操作的链接,客户端无需猜测在当前状态应执行何操作。

2.1.3 需要解决的问题

  • 在一个请求中获取多个资源:
    • API允许获取资源时检索相关资源,如 GET/orders/order-id-1345?expand=consumer 检索order和consumer。但对于复杂的场景不太适用。
    • GraphQL 和 Netflix Falcor 实现了高效的数据获取。
  • 把操作映射为HTTP动词:有可能有多种更新操作,如取消或修改订单。
    • 使用子资源,如 POST/orders/{orderId}/cancelPOST/orders/{orderId}/revise 端点。
    • 将动词指定为URL的查询参数。
    • 此两种解决方案都有些违背了RESTful的要求。

2.1.4 优缺点

优点:

  • 简单直观
  • 可以使用浏览器扩展(如Postman插件)或 curl 之类的命令行来测试HTTP API
  • 直接支持请求 / 响应方式的通信
  • HTTP对防火墙友好
  • 不需要中间代理,简化了系统架构

缺点:

  • 只支持请求 / 响应方式的通信
  • 可能导致可用性降低。由于客户端和服务直接通信,而未通过代理缓冲消息,二者在调用期间都要保持在线。
  • 客户端必须知道服务实例的位置-URL,但现代应用要求客户端必须使用服务发现机制来定位服务实例。
  • 在单个请求中获取多个资源具有挑战性。
  • 很难将多个更新操作映射为HTTP动词。

2.2 gRPC

gRPC是一个用于编写跨语言客户端和服务端的框架,是一种基于二进制消息的协议,所以必须采用API优先的服务设计方法。

可以使用Protocol Buffer的IDL定义gRPC的API,是谷歌公司用于序列化结构化数据的一套语言中立机制,使用Protocol Buffer编译器生成客户端的(sub,也叫存根)和服务器骨架(skeleton),支持如Java、C#、Node.js 和 Golang等。客户端和服务端使用 HTTP/2 以Protocol Buffer格式交换二进制消息

gRPC API 由一个或多个服务和请求/响应消息定义组成。服务定义类似于Java接口,是强类型方法的集合,支持请求/响应RPC和流式RPC。服务器可以使用消息流回复客户端,客户端也可以向服务器发送消息流。

Protocol Buffers是一种高效的二进制消息格式,消息的每个字段都有编号和一个类型代码;消息接收方可以提取所需字段,跳过无法识别的字段。因此特性gRPC API可以保持向后兼容的同时进行变更。

如OrderService的gRPC API如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// CreateOrderRequest和CreateOrderReply是具有类型的消息
service OrderService {
rpc createOrder(CreateOrderRequest) returns (CreateOrderReply) {}
rpc cancelOrder(CancelOrderRequest) returns (CancelOrderReply) {}
rpc reviseOrder(ReviseOrderRequest) returns (ReviseOrderReply) {}
...
}

message CreateOrderRequest {
int64 restaurantId = 1;
int64 consumerId = 2;
repeated LineItem lineItems = 3;
}

message LineItem {
string menuItemId = 1;
int32 quantity = 2;
}

message CreateOrderReply {
int64 orderId = 1;
}
...

优点:

  • 设计具有复杂更新操作的API很简单
  • 具有高效、紧凑的进程间通信机制,特别是交换大量消息时
  • 支持在远程过程调用和消息传递过程中使用双向流式消息方式
  • 实现了客户端和各种语言编写的服务端之间的互操作性

缺点:

  • 与基于REST/JSON的API机制相比,JavaScript客户端使用基于gRPC的API需要做更多工作
  • 旧式防火墙可能不支持HTTP/2

2.3 使用断路器模式处理局部故障

服务间通信总是无法避免会遇到局部故障,可以通过断路器模式来应对故障。

断路器模式:远程过程调用的代理,在连续失败次数超过指定阈值后的一段时间内,这个代理会立即拒绝其他调用

OrderServiceProxy会无限期阻塞,等待响应,在带来糟糕体验的同时,消耗如线程等宝贵资源,最终API Gateway 将耗尽资源无法处理请求,整个API都不可用。

解决此问题:

  • 开发可靠的远程过程调用代理:如 Netflix Hystrix 等开源库。

    当服务同步调用一个服务时,需要保证:

    • 网络超时:等待请求的响应时,不要无限阻塞,设定一个超时,保证不会一直在无响应的请求上浪费资源。
    • 限制客户端向服务器发出请求的数量:对特定请求服务设置上限,达到上限后新的请求会立即失败。
    • 断路器模式:监控客户端发出请求的成功和失败数目,失败比例超过阈值,启动断路器,让后续调用立即失效。大量的请求失败表示被调服务不可用,即使再发送请求也无济于事,而是经过一段时间后再尝试,若请求成功则解除断路器。
  • 从服务失效故障中恢复

    根据具体情况选择如何从无响应的远程服务中恢复服务:

    • 返回错误:如图 3-3 创建Order失败时,只能将失败返回给移动端。
    • 返回备用值:如图 3-3 使用API组合模式实现,调用了多个服务并将结果组合在一起。结果的重要性不同,如Order服务不可用时要返回其数据的缓存版本或错误信息;其他则不重要,可以返回缓存版本或忽略。

2.4 服务发现

2.4.1 为什么需要服务发现?

发送请求需要知道服务实例的网络位置,现代基于云的微服务应用程序中,服务实例具有动态分配的IP地址,还会因为自动扩展、故障和升级等导致服务实例集动态修改。

2.4.2 服务发现机制会做什么?

服务发现的关键组件是服务注册表,是包含服务实例网络位置信息的数据库。服务实例启动和停止时,服务发现机制都会更新服务注册表,客户端调用服务时,服务发现机制会查询服务注册表获取服务实例的列表,并将请求路由到其中一个服务实例。

2.4.3 实现方式

  • 服务及其客户直接与服务注册表交互;
  • 通过部署基础设施来处理服务发现。

2.4.4 应用层服务发现模式

服务实例使用服务注册表来注册网络位置,客户端先查询表获取信息再向其中一个实例发送请求。

  • 自注册模式:服务实例向服务注册表注册自己,服务注册表定期调用运行状况检查端点来验证服务实例是否正常且能接收请求,服务实例要定期调用心跳API以防止注册过期。
  • 客户端发现模式:客户端从服务注册表检索可用服务实例的列表,客户端可以缓存服务实例,并且使用负载均衡算法来选择服务实例。

应用层服务发现工具:Spring Cloud默认使用Eureka进行服务发现。

  • Eureka:高可用的注册中心
  • Ribbon:Eureka的 HTTP 客户端

优点:

  • 服务发现与部署平台无关,可以解决多平台部署问题。如同时使用Kubernetes和遗留环境,基于Eureka能同时适用二者,而基于平台的服务发现只能用于Kubernetes平台。

弊端:

  • 需要为每种编程语言提供服务发现库,Spring Cloud 只能服务于 Spring 开发,如 Node.js 或 Golang 需要相应的服务发现框架。
  • 开发者负责设置和管理服务注册表,最好使用部署基础设施提供的服务发现机制。

2.4.5 平台层服务发现模式

现代部署平台,如Docker和Kubernetes都有内置的服务注册表和服务发现机制。平台为每个服务提供DNS名称、虚拟IP地址(VIP地址)和解析为VIP地址的DNS名称。客户端向DNS名称和VIP发出请求,部署平台自动将请求路由到其中一个可用实例,整个流程完全由平台处理。

  • 第三方注册模式:由注册服务器(第三方)负责处理注册,服务不用再自己注册。
  • 服务端发现模式:客户端不需要查询注册表,而是向DNS名称发出请求,对此名称的请求会解析到路由器,路由器查询服务注册表并对请求进行负载均衡。

优点:

  • 服务和客户端不需包含服务发现代码,不论任何语言和框架都可以使用。

弊端:

  • 仅限于使用此平台部署的服务,但尽管如此还是推荐平台提供的服务发现。

三. 基于异步消息模式的通信

使用消息机制时,服务之间的通信采用异步交换消息的方式完成。

实现方案:

  • 通常会使用消息代理,它充当服务之间的中介。
  • 无代理架构,直接向服务发送消息,因为通信是异步的,所有客户端不会堵塞和等待回复。

3.1 消息传递

消息通过消息通道进行交换,发送方将消息写入通道,接受方从通道读取消息:

  • 消息

    • 组成结构:消息头部 + 消息主体。
    • 内容:
      • 标题:名称与值对的集合,描述正在发送的数据的元数据。
      • 消息ID:唯一标识。
      • 返回地址:指定发送回复的消息通道。
      • 正文:文本或二进制格式的数据。
    • 消息类型:
      • 文档:仅包含数据的通用消息。
      • 命令:一条等同于RPC请求的消息,指定要调用的操作及参数。
      • 事件:标识发送方发生了重要事件,通常是领域事件,表示领域对象状态更改。
  • 消息通道:消息传递基础设施的抽象。

    • 消息通道类型:
      • 点对点通道:向正在从通道读取的一个消费者传递消息,实现一对一交互方式,常用于命令式消息。
      • 发布-订阅通道:将一条消息发给所有订阅的接收方,实现一对多交互方式,常用于事件式消息。

3.2 使用消息机制实现交互方式

消息机制本质是异步的,只提供异步请求/响应,客户端和服务端通过交换一对消息来实现异步请求/响应方式的交互。

如下图,客户端发送命令式消息,内容通过服务拥有的点对点消息通道传递,服务处理请求后将包含结果的回复消息发送到客户端拥有的点对点通道。

客户端必须告诉服务发送回复消息的位置,并且要对回复和请求进行匹配。msgId叫相关性ID,用来匹配二者。

消息机制可以实现的交互方式:

  • 实现请求/响应和异步请求/响应,前者期望立即响应。
  • 实现单向通知:客户端将消息发送到服务所拥有的点对点通道,服务订阅并处理消息,但并不回复。
  • 实现发布/订阅:客户端将消息发布到由多个接收方读取的发布/订阅通道,微服务中可以用发布/订阅来发布领域事件,如OrderService将Order事件发布到Order通道,对特定领域事件感兴趣的服务只要订阅即可。
  • 实现发布/异步响应:是发布/订阅和请求/响应两种方式的元素组合在一起,客户端发布一条消息,在消息的头部中指定回复通道(同时也是一个发布-订阅通道)。消费者将包含相关性ID的回复消息写入回复通道,客户端通过相关性ID来收集响应,以此将回复消息与请求匹配。

3.3 为基于消息机制的服务API创建API规范

服务的异步API包含供客户端调用的操作和由服务对外发布的事件:

  • 记录异步操作:
    • 请求/异步响应式API:包括服务的命令消息通道、服务接受的命令式消息的具体类型和格式,以及服务发送的回复消息的类型和格式。
    • 单向通知式API:包括服务的命令消息通道、服务接受的命令式消息的具体类型和格式。
  • 记录事件发布:服务还可以使用发布/定义的方式对外发布事件,API风格的规范包括事件通道以及服务发布到通道的事件式消息的类型和格式。

3.4 使用消息代理

消息代理,即服务通信的基础设施服务。除了消息代理架构,还有基于无代理的消息传递架构,其中服务相互通信。二者各有利弊,通常会选择消息代理架构。

  • 无代理消息:服务直接交换消息

    • ZeroMQ是一种流行的无代理消息技术,支持各种传输协议(如TCP、UNIX)风格的套接字和多播。
    • 优点:
      • 允许更轻的网络流量和更低的延迟,因为消息直接从发送方到接收方,少了一层代理;
      • 消除了消息代理可能成为性能瓶颈或单点故障的可能性;
      • 具有较低的操作复杂性,因为不需要设置和维护消息代理。
    • 缺点:
      • 服务需要知道彼此的位置,必须使用服务发现机制。
      • 会导致可用性降低,因为在交换消息时,消息的发送方和接收方必须同时在线。
      • 在实现例如确保消息能够成功投递这些复杂功能时的挑战性更大。
    • 这些弊端和同步请求响应的交互方式相同,所以大多数企业应用选择基于消息代理的架构。
  • 基于代理的消息:消息代理作为所有消息的中介节点。

    • 开源消息代理技术:

      • Apache ActiveMQ
      • RabbitMQ
      • Apache Kafka
      • AWS Kinesis 和 AWS SQS :基于云的消息服务。
    • 优点:

      • 发送方不需知道接收方的网络位置。
      • 消息代理可以缓冲消息,直到接收方能够处理它们。
    • 需要考虑:

      • 支持的编程语言
      • 支持的消息标准:如AMQP何STOMP
      • 消息排序
      • 投递保证
      • 持久性:消息能否持久化到磁盘并在代理崩溃时恢复
      • 耐久性:接收方重连到消息代理,是否会收到断开连接时发送的消息
      • 可扩展性
      • 延迟
      • 竞争性(并发)接收方
    • 每种消息代理只能尽量侧重以上的几点,需要根据场景需求选择合适的消息代理。

使用消息代理实现消息通道:

  • ActiveMQ等JMS消息代理具有队列和主题;
  • RabbitMQ等基于AMQP的消息代理具有交换和队列;
  • Kafka有主题;
  • AWS Kinesis有流
  • AWS SQS有队列。
消息代理 点对点通道 发布-订阅通道
JMS 队列 主题
Kafka 主题 主题
AMQP 队列 组播式交换和每客户端队列
AWS Kinesis
AWS SQS 队列 /

只有AWS SQS只支持点对点通道,其余都支持发布-订阅通道。

消息代理的优点:

  • 松耦合:
  • 消息缓存
  • 灵活的通信
  • 明确的进程间通信

消息代理的缺点:

  • 潜在的性能瓶颈
  • 潜在的单点故障
  • 额外的操作复杂性

3.5 处理并发和消息顺序

3.6 处理重复消息

3.7 事务性消息

3.8 消息相关类库和框架

四. 使用异步消息提供可用性

4.1 同步消息会降低可用性

4.2 消除同步交互


参考:

🔗 《微服务架构设计模式》