微服务架构设计模式-第三章

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

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

3.1.1 交互方式

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

3.1.2 在微服务架构中定义API

首先编写接口定义,然后与客户端开发人员一起查看这些接口定义。只有在反复迭代几轮API 定义之后,才开始具体的服务实现编程。这种预先设计有助于你构建满足客户端需求的服务。

3.1.3 API的演化

语义化版本控制:为 API 版本控制提供了有用的指导。它是一组规则,用于指定如何使用版本号,并且以正确的方式递增版本号。

语义化版本控制规范(semvers)要求版本号由三部分组成:major、minor、patch。

  • major:当你对 API 进行不兼容的更改时
  • minor:当你对 API 进行向后兼容的增强时
  • patch:当你进行向后兼容的错误修复时

进行次要而且向后兼容的改变

  • 添加可选属性
  • 向响应添加属性
  • 添加新操作

如果你只进行这些类型的更改,那么老版本的客户端将能够直接使用更新的服务,但前提是客户端和服务都遵守健壮性原则。服务应该为缺少的请求属性提供默认值。同样,客户端应忽略任何额外的响应属性。

进行主要并且不向后兼容的改变

有时你必须对 API 进行主要并且不向后兼容的更改。由于你无法强制客户端立即升级,因此服务必须在一段时间内同时支持新旧版本的 api。一种办法是在 URL 中嵌入主要版本号。例如,版本 1 路径以 /v1/... 为前缀,而版本 2路径以 /v2/... 为前缀。

另一种选择是使用 HTTP 的内容协商机制,并在 MIME 类型中包含版本号。

GET /orders/xyz HTTP/1.1
Accept: application/vnd.example.resource+json;version=1

此请求告诉 order service 客户端需要以版本 1.x 做出响应。

3.1.4 消息的格式

格式可以分为两大类:文本和二进制

基于文本的消息格式

JSON 和 XML,可读性很高,同时也是自描述的

二进制消息格式

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

这两个二进制格式的区别在于,Protocol Buffers 使用 tagged fields(带标记的字段),而 Avro 的消费者在解析消息之前需要知道它的格式。因此,实行 API 的版本升级演进,Protocol Buffers 要优于Avro。

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

3.2.1 使用REST

REST 中的一个关键概念是资源,它通常表示单个业务对象,例如客户或产品,或业务对象的集合。REST 使用 HTTP 动词来操作资源,使用 URL 引用这些资源。例如,GET 请求返回资源的表示形式。POST 请求创建新资源,PUT 请求更新资源。

REST 成熟度模型

Leonard Richardson 为 REST 定义了一个成熟度模型,具体包含以下四个层次:

  • level 0:客户端只是向服务端发起 HTTP POST 请求没进行服务调用。每个请求都指明了需要执行的操作、这个操作针对的目标(例如,业务对象)和必要的参数
  • level 1:1 层级的服务引入了资源的概念。要执行对资源的操作,客户端需要发出指定要执行的操作和包含任何参数的 POST 请求
  • level2:2 层级的服务使用 HTTP 动词来执行操作,譬如 GET 表示获取、POST 表示创建、PUT 表示更新。请求查询参数和主体(如果有的话)指定操作的参数。这让服务能够借助 web 基础设施服务,例如通过 CDN 来缓存 GET 请求。
  • level3:基于 HATEOAS(hypertex as the engine of application state)原则设计,基本思想是在由 GET 请求返回的资源信息中包含链接,这些连接能够执行该资源允许的操作。例如,客户端通过订单资源中包含的链接取消某一订单,或者发送 GET 请求去获取该订单等等。HATEOAS 的优点包括无需在客户端代码中写入硬链接的 URL。此外,由于资源信息中包含可允许操作的链接,客户端无需猜测在资源的当前状态下执行何种操作

把超文本(HTML/链接)当作驱动应用状态变更的核心引擎。

hypertex as the engine of application state

定义 REST API

最流行的 REST IDL(接口定义语言)是 open api 规范(www.openapis.org),它是从 swagger 开源项目发展而来的。

https://www.runoob.com/swagger/openapi-basic.html

在一个请求中获取多个资源的挑战

比如客户端想要检索 order 和这个 order 的 consumer。纯 rest api 要求客户端至少发出两个请求,一个用于 order,另一个用户 consumer。更复杂的情况需要更多往返并且遭受过多的延迟。

此问题的一个解决方案是 API 允许客户端在获取资源时检索相关资源。例如,使用GET/orders/order-id-1234?expand=consumer 检索 order 及其 consumer。请求中的查询参数用来指定要与 order 一起返回的相关资源。这种方法在许多场景中都很有效,但对于更复杂的场景来说,它通常是不够的。实现也很耗时。这导致了替代技术的日益普及,例如 graphql 和 netflix falcor

Netflix Falcor 是一个**用于高效数据获取的 JavaScript 库**。你可以把它理解为一个为前端应用设计的“数据获取中间层”,它的核心思想是**把后端所有复杂的数据源,在前端“伪装”成一个单一的、巨大的 JSON 对象**。

开发者可以用很简单的 JavaScript 路径(比如 `user.name`)去“取”这个虚拟 JSON 里的任何数据,就像操作本地变量一样简单,但实际请求是异步的。

Falcor 主要解决的是传统 API 带来的几个痛点:

*   **数据获取的“请求爆炸”问题**:传统 RESTful API 常常需要前端发起多次请求来拼凑一个页面所需的数据(例如,先请求用户信息,再根据用户ID请求其订单)。Falcor 允许客户端在**一次网络请求**中,获取一个页面所需的所有数据,极大地减少了网络延迟。
*   **API 的频繁变动问题**:Falcor 遵循 **“数据即 API”** 的原则。只要你知道最终数据的结构,前端就能用同样的方式去获取,无论后端数据源如何变化(比如从数据库换成另一个微服务)。这很好地解耦了前后端。
*   **网络请求冗余问题**:Falcor 的客户端 **Model** 会自动管理一个**单一、统一的缓存**。它会智能地**合并(Batching)和去重(De-duping)** 请求,避免对同一份数据重复发起网络请求。

### ⚙️ 它是如何工作的?

Falcor 在前后端分别扮演不同角色,两端配合实现“虚拟 JSON 对象”的效果:

*   **客户端(Falcor Model)**:在前端,你创建一个 `Model` 对象,它维护着一个本地缓存。当你通过路径获取数据时,`Model`会**先查缓存**;如果缓存没有,它就会把需要的路径通过网络发给服务器。
*   **服务器端(Falcor Router)**:在 Node.js 服务器上,Falcor Router 负责“翻译”客户端发来的路径请求。它收到路径后,会去调用真实的数据库或微服务,把拿到的数据按路径“组装”好,再返回给客户端。

### 🎬 一个极简示例

根据官方文档,一个最基础的 Falcor 应用可以从搭建一个只返回 `"Hello World"` 的 `greeting` 路径开始,这与搭建一个 Spring Boot 的 “Hello World” 接口有异曲同工之妙。

1.  **服务器端**:使用 `falcor-express` 和 `falcor-router` 创建一个路由,当客户端请求 `greeting` 路径时,返回 `"Hello World"`。
2.  **客户端**:创建一个 `falcor.Model`,并指定数据源为服务器地址(如 `/model.json`)。然后像读取本地对象一样,通过 `model.get("greeting")` 发起请求,并获得结果。

这个完整的 Hello World 例子在 Netflix 的 Falcor 官方 GitHub 仓库里有详细教程。

把操作映射成HTTP动词的挑战

另一个常见的REST API 设计问题是如何将要在业务对象上执行的操作映射到http动词。REST api应该使用PUT进行更新,但可能由多种方法来更新订单,包括取消订单、修改订单等。此外,更新可能不是幂等的,但这却是使用 PUT 的要求。一种解决方案是定义用于更新资源的特定方面的子资源。例如,order service 具有用于取消订单的 post/orders/{orderId}/cancel 端点,以及用于修订订单的 POST/orders/{orderId}/revise 端点。另一种解决方案是将动词指定为 URL 的查询参数。可惜的是,这两种解决方案都不是特别符合 RESTful 的要求。

映射操作到 http 动词的这个问题导致了 REST 替代方案的日益普及,例如 grpc


REST 的好处和弊端

好处:

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

弊端:

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

3.2.2 使用gRPC

:这是一个用于编写跨语言客户端和服务端的框架。

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

//order service 的 grpc api
service OrderService{
  rpc createOrder(CreateOrderRequest) returns (CreateOrderReply){}
  rpc cancelOrder(CreateOrderRequest) returns (CancelOrderReply){}
  rpc reviseOrder(CreateOrderRequest) 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;
    ...
  }
}

gRPC 有几个好处:

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

弊端:

  • 基于 rest/json 的 API 机制相比,javascript 客户端使用基于 grpc 的 api 需要做更多的工作
  • 旧式防火墙可能不支持 HTTP/2

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

要通过合理地设计服务来防止在整个应用程序中故障的传导和扩散,这是至关重要的。解决这个问题分为两部分:

  • 必须让远程过程调用代理(例如 orderServiceProxy)有正确处理无响应服务的能力
  • 需要决定如何从失败的远程服务中恢复

开发可靠的远程过程调用代理

每当一个服务同步调用另一个服务时,它应该使用 netflix 描述的方法来保护自己。这种方法包括以下机制的组合。( http://techblog.netflix.com/2012/02/faulttolerance-in-high-volume.html

  • 网络超时:在等待针对请求的响应时,一定不要做成无限阻塞,而是要设定一个超时。
  • 限制客户端向服务器发出请求的数量:把客户端能够向特定服务发起的请求设置一个上限,如果请求达到了这样的上限,很有可能发起更多的请求也无济于事,这时就应该让请求立即失败
  • 断路器模式:监控客户端发出请求的成功和失败数量,如果失败的比例超过一定的阈值,就启动断路器,让后续的调用立即失效。如果大量的请求都以失败而告终,这说明被调服务不可用,这样即使发起更多的调用也是无济于事。在经过一定的时间后,客户端应该继续尝试,如果调用成功,则解除断路器。
舱壁模式(Bulkhead):如同船体隔舱,为不同业务或租户分配独立的线程池或连接池。即使一个业务调用耗尽自己的资源,也不会影响其他业务的资源池。

降级与回退(Fallback):当依赖服务不可用时,自动提供备选方案。例如,推荐服务故障时,返回用户最近观看的历史记录或热门内容,保证核心播放功能不受影响。

健康检查与主动重启:系统持续监控各服务的健康状态(如通过/health端点),自动重启不健康的实例,或由调度器(如Conductor)重新编排任务。

hystrix

3.2.4 使用服务发现

11-服务实例IP地址动态分配
什么是服务发现

服务发现在概念上非常简单:其关键组件是服务注册表,它是包含服务实例网络位置信息的一个数据库

服务实例启动和停止时,服务发现机制会更新服务注册表。当客户端调用时,服务发现机制会查询服务注册表以获取可以服务实例的列表,并将请求路由到其中一个服务实例。

实现服务发现有以下两种主要方式:

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

应用层服务发现模式
12-服务注册表
这种服务发现方法是两种模式的组合。第一种是自注册模式。服务实例调用服务注册表的注册 API 来注册其网络位置。它还可以提供允许状况检查 URL。允许状况检查 URL 是一个 API 端点,服务注册表会定期调用该端点来验证服务实例是否正常且可用于处理请求。服务注册表还可能要求服务实例定期调用"心跳"API 以防止其注册过期。

模式:自注册

服务实例向服务注册表注册自己

第二种模式是客户端发现模式。当客户端想要调用服务时,它会查询服务注册表以获取服务实例的列表。为了提高性能,客户端可能会缓存服务实例。然后服客户端使用负载均衡算法(例如循环或随机)来选择服务实例。然后它向选择的服务实例发出请求。

模式:客户端发现

客户端从服务注册表检索可用服务实例的列表,并在它们之间进行负载均衡

netflix eureka(服务注册表),ribbon(支持 eureka 客户端的复杂 http 客户端)

应用层服务发现的一个弊端是:你需要为你使用的每种编程语言(可能还有框架)提供服务发现库。另一个弊端是开发者负责设置和管理服务注册表,这会分散一定的精力。因此,最好使用部署基础设施提供的服务发现机制。

平台层服务发现模式

docker k8s 都具有内置的服务注册和服务发现机制。客户端向 DNS 名称和 VIP 发出请求,部署平台会自动将请求路由到其中一个可用服务实例。因此,服务注册、服务发现和请求路由完全由部署平台处理。

这种方法是以下两种模式的组合。

  • 第三方注册模式:由第三方负责(称为注册服务器,通常是部署平台的一部分)处理注册,而不是服务本身向服务注册表注册自己
  • 服务端发现模式:客户端不再需要查询服务注册表,而是向 DNS 名称发出请求,对该 DNS 名称的请求被解析到路由器,路由器查询服务注册表并对请求进行负载均衡

模式:第三方注册

服务实例由第三方自动注册到服务注册表

好处:服务发现的所有方面都完全由部署平台处理,服务和客户端都不包含任何服务发现代码。因此,无论使用哪种语言或框架,服务发现机制都可供所有服务和客户使用

弊端:仅限于支持使用该平台部署的服务。比如 k8s

3.3 基于异步消息模式的通信

3.3.1 什么是消息传递

gregor hohpe 和 bobby woolf 在《enterprise integration patterns》一书中定义了一种有用的消息传递模型。在此模型中,消息通过消息通道进行交换。发送方(应用程序或服务)将消息写入通道,接收方(应用程序或服务)从通道读取消息。

关于消息

消息由消息头部和消息主题组成。标题 是名称和值对的集合,描述正在发送的数据的元数据。除了消息发送者提供的名称与值对之外,消息头部还包含其他信息,例如发件人或消息传递基础设施生成的唯一消息 ID,以及可选的返回地址,该地址指定发送回复的消息通道。消息正文是以文本或二进制格式发送的数据。

有以下几种不同类型的消息

  • 文档:仅包含数据的通用消息。接收者决定如何解释它。对命令式消息的回复是文档消息的一种使用场景
  • 命令:一条等同于 RPC 请求的消息。它指定要调用的操作及其参数
  • 事件:表示发送方法这一端发生了重要的事情。事件通常是领域事件,表示领域对象(如 order 或 customer)的状态更改

关于消息通道
14-消息通道
有两种类型的消息通道:点对点,发布-订阅

  • 点对点:向正在从通道读取的一个消费者传递消息。
  • 发布-订阅:将一条消息发给所有订阅的接收方

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

实现请求/响应和异步请求/响应

实现单向通知

实现发布订阅

实现发布/异步响应

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

记录异步操作

  • 请求/异步响应式 API
  • 单向通知式 api

记录事件发布

3.3.4 使用消息代理

无代理消息:zeroMQ

好处:

  • 允许更轻的网络流量和更低的延迟,因为消息直接从发送方发送到接收方,而不必从发送方到消息代理
  • 消除了消息代理可能成为性能瓶颈或单点故障的可能性
  • 具有较低的操作复杂性,因为不需要设置和维护消息代理

弊端:

  • 服务需要了解彼此的位置,因此必须使用服务发现机制
  • 会导致可用性降低,因为在交换消息时,消息的发送方和接收方都必须同时在线
  • 在实现例如确保消息能够成功投递这些复杂功能时的挑战性更大

基于代理的消息

消息代理是所有消息的中介节点。使用消息代理的一个重要好处是发送方不需要知道接收方的网络位置。另一个好处是消息代理缓冲消息,直到接收方能够处理它们。

选择消息代理时,需要考虑以下几种因素:

  • 支持的编程语言:你选择的消息代理应该支持尽可能多的编程语言
  • 支持的消息标准:消息代理是否支持多种消息标准,比如 AMQP 和 STOMP,还是它仅支持专用的消息标准
  • 消息排序:消息代理是否能够保持消息的排序
  • 投递保证:消息代理提供什么样的消息投递保证?
  • 持久性:消息是否持久化保存到磁盘并且能够在代理崩溃时恢复?
  • 耐久性:如果接收方重新连接到消息代理,它是否会收到断开连接时发送的消息?
  • 可扩展性:消息代理的可扩展性如何
  • 延迟:端到端是否有较大延迟
  • 竞争性(并发)接收方:是否支持竞争性接收方

每个消息代理都有不同的侧重点,但是,消息顺序和可扩展性很可能是必不可少的。

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

15-消息通道实现
基于代理的消息的好处与弊端

好处

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

弊端

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

3.3.5 处理并发和消息顺序

kafka 分片:消费代理将接收方的多个实例组合在一起,并将它们视为相同的逻辑接收方。例如 kafka 使用术语 消费者。消息代理将每个分片分配给单个接收器。它在接收方启动和关闭时重新分配分片。

3.3.6 处理重复消息

理想情况下,消息代理应该只传递一次消息,但保证有且仅有一次的消息传递通常成本很高。相反,大多数消息代理承诺至少成功传递一次消息。

处理重复消息有以下两种不同的方法:

  • 编写幂等消息处理程序
  • 跟踪消息并丢弃重复项

如果应用程序处理消息的逻辑是满足幂等的,那么重复的消息就是无害的。将 mseeage id 记录在表中,不允许重复插入。

3.3.7 事务性消息

使用数据库表作为消息队列

它解决的核心问题,是当你的应用需要在同一个操作里既更新数据库又发送消息(比如通知其他服务)时,如何保证这两个操作要么都成功,要么都失败,从而避免数据不一致。这在微服务架构中也称为“双写问题”

你可以把它想象成我们平时寄信的过程:

  1. 写入本地“发件箱” (业务与事件同事务):你的应用在更新业务数据(比如创建订单)时,在同一数据库事务中,把需要发送的消息也一并存入一张专门的“发件箱”表。这确保了只要订单数据保存成功,消息就肯定不会丢;如果消息保存失败,整个订单操作也会回滚。这样就保证了“本地事务”的原子性。
  2. “邮递员”负责取件和投递 (消息中继):一个独立的后台进程(可以是一个定时任务或服务)会轮询这张“发件箱”表,找到未发送的消息,并将其转发给真正的消息队列(如Kafka、RabbitMQ)。
  3. 标记已发送 (确认与清理):消息成功发送到消息代理后,这个后台进程就会更新“发件箱”中对应记录的状态,或者将其删除,避免重复处理

也可以对某些 nosql 数据库使用类似的方法。

两种常见的实现方式

实现“消息中继”这个“邮递员”角色,主要有两种方式:

  1. 轮询发布 (Polling Publisher):这是最常见的实现方式。通过一个定时任务,不断轮询“发件箱”表来获取和发布新消息。这种方式的优点是比较直接,易于实现。
  2. 事务日志追踪 (Transaction Log Tailing):也被称为变更数据捕获(CDC)。这种方式利用数据库的事务日志(如MySQL的binlog),通过监听日志的变化来捕捉需要发送的事件。它的好处是对应用代码侵入性小,且实时性更高。可以使用像Debezium这样的工具来实现
    16-事务日志跟踪
    这个方案有一些实际的应用案例和实现可供参考:
  • debezium:开源,可以向 kafka 消息代理发布数据库更改
  • linkedin databus,开源,用于挖机 oracle 事务日志文件并将更改发布为事件。linkedin 使用 databus 将各种派生数据存储与记录系统同步
  • dynamoDB streams:包含过去 24 小时内的 dynamodb 表更改(创建、更新、删除)的序列,并且这个序列是按时间排序的。应用程序可以从流中读取这些更改,例如,将它们作为事件发布。
  • eventuate tram:开源事务消息库,使用 mysql binlog 协议、pg wal 或轮询来读取对 outbox 表所做的更改并将它们发布到 kafka

虽然这种方法看似晦涩,但效果非常好。挑战在于实现它需要做一些开发努力。

3.3.8 消息相关的类库和框架

3.4 使用异步消息提高可用性

3.4.1 同步消息会降低可用性

3.4.2 消除同步交互

使用异步交互模式

复制数据:服务维护一个数据副本,这些数据是服务在处理请求时需要使用的。这些数据的源头会在数据变化时发出消息,服务订阅这些消息来确保数据副本的实时更新。
17-数据副本
弊端:

  • 有时候复制的数据量巨大,会导致效率低下
  • 并没有从根本上解决服务如何更新其他服务所拥有的数据这个问题

解决该问题的一种方法是让服务暂缓与其他服务交互,直到它给客户端发送了响应。

先返回响应,再完成处理

  1. 仅使用本地的数据来完成请求的验证
  2. 更新数据库,包括向 outbox 表插入消息
  3. 向客户端返回响应

18-先响应再处理

posted @ 2026-08-15 13:24  LHX2018  阅读(6)  评论(0)    收藏  举报