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

这章包含:

  • 应用通信模式: 远程过程调用、断路器、客户端发现、自注册、服务器端发现、第三方注册、异步消息、事务发件箱(Transactional outbox)、事务日志跟踪、轮询发布者
  • 微服务架构中进程间通信的重要性
  • 定义和逐步演进 API
  • 各种进程间通信选项及其权衡
  • 使用异步消息进行通信的服务的好处
  • 作为数据库事务的一部分可靠地发送消息

Mary 和她的团队与大多数其他开发人员一样, 对进程间通信(IPC)机制有一定的经验.FTGO 应用程序有一个由移动应用程序和浏览器端 JavaScript 使用的 REST API.它还使用各种云服务, 如 Twilio 消息服务和 Stripe 支付服务.但是在像 FTGO 这样的单体应用程序中, 模块通过语言级别的方法或函数调用彼此调用.FTGO 开发人员通常不需要考虑 IPC, 除非他们正在开发REST API 或与云服务集成的模块.

相反, 正如您在第 2 章中看到的, 微服务架构将应用程序构建为一组服务.为了处理请求, 这些服务必须经常协作.因为服务实例通常是在多台计算机上运行的进程, 所以它们必须使用 IPC 进行交互.它在微服务架构中所起的作用要比在单体应用程序中所起的作用大得多.因此, 当他们将应用程序迁移到微服务时, Mary 和其他 FTGO 开发人员将需要花费更多的时间考虑 IPC.

可供选择的 IPC 机制并不缺乏.如今, 流行的选择是 REST(JSON 格式).然而, 重要的是要记住, 没有灵丹妙药.你必须仔细考虑这些选择.本章探讨了各种 IPC 选项, 包括 REST 和消息, 并讨论了权衡.

IPC 机制的选择是一个重要的架构决策.它会影响应用程序的可用性.而且, 正如我在本章和下一章中解释的那样, IPC 甚至与事务管理交叉.我喜欢由使用异步消息彼此通信的松散耦合服务组成的体系结构.同步协议(如 REST)主要用于与其他应用程序通信.

本章首先概述了微服务架构中的进程间通信.接下来, 我将描述基于远程过程调用的 IPC, 其中 REST 是最流行的例子.我将讨论重要的主题, 包括服务发现和如何处理分区故障.在那之后, 我描述了基于异步消息的 IPC.我还讨论了在保持消息顺序、正确处理重复消息和事务性消息传递的同时扩展消费者.最后, 我介绍了自包含服务的概念, 它处理同步请求, 而不与其他服务通信, 以提高可用性.

微服务架构进程间通信概览

有许多不同的 IPC 技术可供选择.服务可以使用基于请求/响应的同步通信机制, 例如基于 HTTP 的 REST 或 gRPC.或者, 它们可以使用异步的、基于消息的通信机制, 如 AMQP 或 STOMP.还有各种不同的消息格式.服务可以使用人类可读的、基于文本的格式, 如 JSON 或 XML.或者, 它们可以使用更有效的二进制格式, 如 Avro 或 Protocol Buffers.

在详细讨论具体技术之前, 我想提出几个您应该考虑的设计问题.本节首先讨论交互方式, 交互方式是描述客户端和服务如何交互的一种独立于技术的方式.接下来, 我将讨论在微服务架构中精确定义 API 的重要性, 包括 API 优先设计的概念.在此之后, 我将讨论 API 演进的重要主题.最后, 我将讨论消息格式的不同选项, 以及它们如何确定 API 演进的易用性.让我们从交互方式开始.

交互方式

在为服务的 API 选择 IPC 机制之前, 首先考虑服务与其客户端之间的交互方式是很有用的.首先考虑交互方式将帮助您专注于需求, 避免陷入特定 IPC 技术的细节中.而且, 正如 3.4 节所述, 交互方式的选择会影响应用程序的可用性.此外, 正如您将在第 9 章和第 10 章中看到的, 它将帮助您选择适当的集成测试策略.

客户端-服务交互方式有很多种.如表 3.1 所示, 它们可以分为两个维度.第一个维度是交互是一对一还是一对多:

  • 一对一-每个客户端请求都由一个服务处理.
  • 一对多-每个请求由多个服务处理.

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

  • 同步-客户端期望来自服务的及时响应, 甚至可能在等待时阻塞.
  • 异步-客户端不会阻塞, 响应(如果有的话)也不一定会立即发送.

表 3.1 各种交互方式可以从两个维度来描述: 一对一 vs 一对多和同步 vs 异步

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

以下是一对一交互的不同类型:

  • 请求/响应-服务客户端向服务发出请求并等待响应.客户端希望响应及时到达.它可能会在等待时阻塞.这是一种通常会导致服务被紧密耦合的交互方式.
  • 异步请求/响应-服务客户端向服务发送请求, 服务异步响应.客户机在等待时不会阻塞, 因为服务可能很长一段时间没有发送响应.
  • 单向通知-服务客户端向服务发送请求, 但不期望或发送任何响应.

重要的是要记住同步请求/响应交互方式与 IPC 技术大多是正交的.例如, 服务可以使用与 REST 或消息的请求/响应方式与另一个服务交互.即使两个服务使用消息代理(message broker)通信, 客户端服务也可能在等待响应时被阻塞.这并不一定意味着它们是松散耦合的.在本章后面讨论服务间通信对可用性的影响时, 我将再次讨论这一点.

以下是一对多交互的不同类型:

  • 发布/订阅-客户端发布一条通知消息, 该消息由零个或多个感兴趣的服务使用.
  • 发布/异步响应-客户端发布请求消息, 然后等待来自感兴趣的服务的响应, 等待一定的时间.

每个服务通常会使用这些交互方式的组合.FTGO 应用程序中的许多服务都有用于操作的同步和异步的 API, 而且许多服务还发布事件.

让我们看看如何定义服务 API.

定义微服务架构 API

API 或接口是软件开发的核心.应用程序由模块组成.每个模块都有一个定义模块的客户端可以调用的接口的操作集.设计良好的接口在隐藏实现的同时暴露了有用的功能.它使实现能够在不影响客户端的情况下进行更改.

在单体应用程序中, 接口通常使用 Java 接口等编程语言结构指定.Java 接口指定客户端可以调用的一组方法.实现类对客户端是隐藏的.此外, 由于 Java 是静态类型语言, 如果接口更改与客户端不兼容, 应用程序将无法编译.

API 和接口在微服务架构中同样重要.服务的 API 是服务与其客户端之间的契约.如第 2 章所述, 服务的 API 由客户端可以调用的操作和服务发布的事件组成.操作具有名称、参数和返回类型.事件具有类型和一组字段, 如 3.3 节所述, 事件被发布到消息通道.

挑战在于服务 API 不是使用简单的编程语言结构定义的.根据定义, 服务及其客户端不会一起编译.如果使用不兼容的 API 部署服务的新版本, 则不会出现编译错误.相反, 会出现运行时故障.

无论选择哪种 IPC 机制, 使用某种 接口定义语言(IDL) 精确定义服务的 API 都非常重要.此外, 使用 API 优先的方式定义服务也有很好的理由(更多信息请参见 www.programmableweb.com/news/how-to-design-great-apis-api-first-design-and-raml/how-to/2015/07/10), 首先编写接口定义, 然后与客户端开发人员一起审查接口定义.只有在对 API 定义进行审查完之后, 才能实现服务.进行这种预先设计可以增加构建满足客户需求的服务的机会.

API 优先设计是必不可少的

即使在小型项目中, 我也看到过因为组件在 API 上达不成一致而出现的问题.例如, 在一个项目中, 后端 Java 开发人员和 AngularJS 前端开发人员都说他们已经完成了开发.然而, 应用程序没有工作.前端应用程序用于与后端通信的 REST 和 WebSocket API 定义得很差.结果, 两个应用程序无法通信!

API定义的性质取决于您使用的 IPC 机制.例如, 如果使用消息, API 由消息通道、消息类型和消息格式组成.如果使用 HTTP, API 由 URL、HTTP 谓词和请求/响应格式组成.在本章的后面, 我将解释如何定义 API.

服务的 API 很少是一成不变的.它很可能会随着时间的推移而演变.让我们看看如何做到这一点, 并考虑一下您将面临的问题.

演进的 API

随着新特性的添加、现有特性的更改以及(也许)旧特性的删除, API 总是会随着时间的推移而变化.在一个单体应用程序中, 更改 API 并更新所有调用者相对简单.如果您使用的是静态类型语言, 那么编译器可以通过提供编译错误列表来提供帮助.唯一的挑战可能是改变的范围.更改一个广泛使用的 API 可能需要很长时间.

在基于微服务的应用程序中, 更改服务的 API 要困难得多.服务的客户端是其他服务, 这些服务通常由其他团队开发.客户端甚至可能是组织之外的其他应用程序.通常不能强制所有客户端与服务同步升级.而且, 由于现代应用程序通常不会停机进行维护, 所以通常会对服务执行滚动升级, 因此服务的新旧版本将同时运行.

制定应对这些挑战的策略很重要.如何处理对 API 的更改取决于更改的性质.

使用语义版本控制
语义版本控制规范(http://semver.org)是版本控制 API 的有用指南.它是一组规则, 用来指定版本号是如何使用和增加的.语义版本控制最初打算用于软件包的版本控制, 但您可以将其用于分布式系统中的版本控制 API.

语义版本规范(Semvers)要求版本号包含三个部分: MAJOR.MINOR.PATCH.你必须增加版本号的每一部分, 如下:

  • MAJOR-当您对 API 进行不兼容的更改时
  • MINOR-当您对 API 进行向后兼容的增强时
  • PATCH-当您进行向后兼容的 bug 修复时

在 API 中有几个地方可以使用版本号.如果您正在实现一个 REST API, 您可以像下面提到的那样, 使用 major 版本作为 URL 路径的第一个元素.或者, 如果您正在实现一个使用消息的服务, 您可以在它发布的消息中包含版本号.目标是正确地对 API 进行版本控制, 并以可控的方式对其进行改进.让我们看看如何处理 minor 的和 major 的更改。

进行较小的(minor), 向后兼容的更改
理想情况下, 您应该努力只进行向后兼容的更改.向后兼容的更改是 API 的附加更改:

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

如果您只进行这些类型的更改, 老客户端将使用新服务, 前提是它们遵守健壮性原则(https://en.wikipedia.org/wiki/Robustness_principle), 该原则声明: “在您所做的事情上要保守, 在您接受别人的事情上要自由.” 服务应该为没有的请求属性提供默认值.类似地, 客户端应该忽略任何额外的响应属性.为了做到这一点, 客户端和服务必须使用支持健壮性原则的请求和响应格式.在本节稍后部分中, 我将介绍 JSON 和 XML 等基于文本的格式通常如何使 API 更容易演进.

做出重大的(major), 突破性的改变
有时您必须对 API 进行重大的、不兼容的更改.因为您不能强制客户端立即升级, 所以服务必须在一段时间内同时支持 API 的新旧版本.如果使用基于 HTTP 的 IPC 机制(如 REST), 一种方法是将主(major)版本号嵌入 URL.例如, 版本 1 的路径以 '/v1/...' 为前缀, 版本 2 的路径以 '/v2/...' 为前缀.

另一种选择是使用 HTTP 的内容协商机制, 并在 MIME 类型中包含版本号.例如, 客户端将使用如下请求请求Order1.x 版本:

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

这个请求告诉 Order 服务客户端期望版本 1.x 的响应.

为了支持一个 API 的多个版本, 实现 API 的服务适配器将包含在新旧版本之间转换的逻辑.而且, 正如第 8 章所述, API 网关几乎肯定会使用版本化的 API.它甚至可能必须支持许多旧版本的 API.

现在我们来看看消息格式的问题, 消息格式的选择会影响 API 的演进.

消息格式

IPC 的本质是消息的交换.消息通常包含数据, 因此数据的格式是重要的设计决策.消息格式的选择会影响 IPC 的效率, API 的可用性和可扩展性.如果使用的是消息传递系统或 HTTP 等协议, 则可以选择消息格式.一些 IPC 机制-例如 gRPC, 您将简要了解它-可能规定了消息格式.无论哪种情况, 都必须使用跨语言的消息格式.即使您现在使用一种语言编写微服务, 将来也可能使用其他语言.例如, 您不应该使用 Java 序列化.

消息格式有两大类:文本和二进制.让我们看看每一个.

基于文本的消息格式
第一类是基于文本的格式, 如 JSON 和 XML.这些格式的优点是, 它们不仅是人类可读的, 而且是自我描述的.JSON 消息是命名属性的集合.类似地, XML 消息实际上是命名元素和值的集合.这种格式使消息的使用者能够选择感兴趣的值, 而忽略其他值.因此, 对消息模式的许多更改可以很容易地向后兼容.

XML 文档的结构由 XML schema 指定(www.w3.org/XML/Schema).随着时间的推移, 开发人员社区逐渐认识到 JSON 也需要类似的机制.一个流行的选择是使用 JSON schema标准(http://json-schema.org).JSON schema 定义消息属性的名称和类型, 以及它们是可选的还是必需的.JSON模式不仅是有用的文档, 应用程序还可以使用它来验证传入的消息.

使用基于文本的消息格式的缺点是消息往往很冗长, 尤其是 XML.每个消息除了包含属性的值之外, 还包含属性的名称.另一个缺点是解析文本的开销, 尤其是在消息很大的情况下.因此, 如果效率和性能很重要, 您可能需要考虑使用二进制格式.

二进制消息格式
有几种不同的二进制格式可供选择.流行的格式包括 Protocol Buffers(https://developers.google.com/protocol-buffers/docs/overview) 和 Avro (https://avro.apache.org).这两种格式都提供了用于定义消息结构的类型化 IDL.然后, 使用编译器生成序列化和反序列化消息的代码.您被迫采用 API 优先的方法进行服务设计!此外, 如果您使用静态类型语言编写客户端, 编译器将检查它是否正确使用 API.

这两种二进制格式之间的一个区别是, Protocol Buffers 使用标记(tagged)字段, 而 Avro 使用者需要知道模式才能解释消息.因此, 使用 Protocol Buffers 比使用Avro 更容易处理 API 演进.这篇博文(http://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html)是对 Thrift、Protocol buffer 和 Avro 的绝佳比较。

现在我们已经了解了消息格式, 让我们从远程过程调用(Remote procedure invocation, RPI)模式开始, 研究传输消息的特定 IPC 机制.

使用同步远程过程调用模式进行通信

当使用基于远程过程调用的 IPC 机制时, 客户端向服务发送请求, 服务处理请求并发回响应.一些客户端可能阻塞等待响应, 而其他客户端可能具有响应式的、非阻塞的架构.但与使用消息不同, 客户端假定响应将以一种及时的方式到达.

图 3.1 显示了 RPI 是如何工作的.客户端中的业务逻辑调用由 RPI 代理适配器类实现的代理接口.RPI 代理向服务发出请求.请求由 RPI 服务器适配器类处理, 该类通过接口调用服务的业务逻辑.然后, 它向 RPI 代理发送回复.RPI 代理将结果返回给客户端的业务逻辑.

模式: 远程过程调用

客户端使用同步的, 基于远程过程调用的协议, 比如 REST 调用服务(http://microservices.io/patterns/communication-style/messaging.html).

Figure 3.1 How RPI works

代理接口通常封装底层通信协议.有多种协议可供选择.在本节中, 我将描述 REST 和 gRPC.我将介绍如何通过正确处理分区故障来提高服务的可用性, 并解释为什么使用 RPI 的基于微服务的应用程序必须使用服务发现机制.

让我们首先看看 REST.

使用 REST

现在, 流行以 RESTful 风格开发 API(https://en.wikipedia.org/wiki/Representational_state_transferr).REST 是一种(几乎总是)使用 HTTP 的 IPC 机制.REST 的创建者 Roy Fielding 对REST的定义如下:

REST 提供了一组架构约束, 当作为一个整体应用时, 这些约束强调组件交互的可伸缩性、接口的通用性、组件的独立部署和中介组件, 以减少交互延迟、加强安全性和封装遗留系统.

                                 www.ics.uci.edu/~fielding/pubs/dissertation/top.htm

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

许多开发人员声称他们基于 HTTP 的 API 是 RESTful 的.但正如 Roy Fielding 在一篇博客文章中所描述的, 并非所有这些都是由超文本驱动的(http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven).为了理解其中的原因, 让我们看看 REST 成熟度模型.

REST 成熟度模型

Leonard Richardson(与作者无关)为 REST 定义了一个非常有用的成熟度模型(http://martinfowler.com/articles/richardsontymodel.html), 它包含以下级别:

  • 级别 0-0 级服务的客户端通过向其唯一的 URL 端点发出 HTTP POST 请求.每个请求指定了执行的动作, 动作的目标(比如, 业务对象), 和任何参数.
  • 级别 1-级别 1 的服务支持资源的理念.为了执行一个资源上的动作, 客户端发出一个 POST 请求指定要执行的动作和任何参数.
  • 级别 2-级别 2 的服务使用 HTTP 谓词指定动作: GET 用来获取, POST 用来创建, PUT 用来更新.请求查询参数和主体, 如果有的话, 指定了动作的参数.这使得服务可以使用 web 基础设施比如对 GET 请求进行缓存.
  • 级别 3-级别 3 的服务的设计基于非常有名的 HATEOAS(作为应用程序状态引擎的超文本)原则.基本思想是 GET 请求返回的资源的表示包含对该资源执行操作的链接.例如, 客户端可以使用检索订单的 GET 请求返回的表示形式中的链接来取消订单.HATEOAS 的好处包括不再需要将 url 硬织入到客户机端码(www.infoq.com/news/2009/04/hateoas-restful-api-advantages).

我鼓励您查看组织中的 REST api, 看看它们对应于哪个级别.

指定 REST APIs

如之前在 3.1 节中提到的, 你必须使用接口定义语言(IDL)定义你的 APIs.不像老的通信协议, 比如 CORBA 和 SOAP, REST 最初没有 IDL.幸运地是, 开发者社区重新发现了 IDL 对于 RESTful API 的价值.最流行的 REST IDL 是 Open API 规范(www.openapis.org), 它从 Swagger 开源项目中演进而来.Swagger 项目是一个工具集用于开发和文档化 REST APIs.它包含了从接口定义生成客户端桩和服务器骨架的工具.

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

REST 资源通常面向业务对象, 比如 消费者订单.因此, 在设计 REST API 时, 一个常见的问题是如何让客户端在一个请求中获取多个相关联的对象.例如, 假设REST 客户端想要获取订单和订单消费者.纯粹的 REST API 要求客户端至少发出两个请求, 一个请求用于订单, 另一个请求用于其消费者.更复杂的场景将需要更多的往返, 并遭受过度延迟.

这种 API 的问题的一种解决方案就是允许客户端获取相关的资源当它获取某个资源的时候.比如, 一个客户端可能使用 GET /orders/order-id-1345?expand=consumer 获取一个订单和它的消费者.查询参数指定了要随着 订单 一起返回的相关资源.这种方式在许多场景中都工作良好, 但是对于更复杂的场景它通常并不能满足.实现它也可能很耗时.这导致了其它 API 技术的日益流行, 如 GraphQL(http://graphql.org) 和 Netflix Falcor(http://netflix.github.io/falcor/), 这些技术旨在支持高效的数据获取.

将操作映射到 HTTP 谓词的挑战

另一个常见的 REST API 设计问题是如何将在一个业务对象上执行的操作映射到 HTTP 谓词.一个 REST API 应该使用 PUT 用于更新, 但是有多种方式更新一个订单, 包括取消订单, 修改订单等等.另外, 更新可能不是幂等的, 这是使用 PUT 的一个要求.一种方式就是定义子资源用来更新资源的特定方面.比如, 订单服务, 有一个 POST /orders/{orderId}/cancel 端点用来取消订单, 和一个 POST /orders/{orderId}/revise 端点用来修改订单.另一种方式是指定一个谓词作为 URL 查询参数.遗憾的是, 这两种解决方案都不是特别 RESTful.

将操作映射到 HTTP 谓词的问题导致 REST 的替代方案, 如在 3.2.2 节中稍后讨论的 gPRC 越来越流行.但是首先让我们看看 REST 的优点和缺点.

REST 的缺点和优点

使用 REST 有不少好处:

  • 简单又熟悉.
  • 你可以使用浏览器测试 HTTP API, 比如, Postman 插件, 或者使用 curl(假设使用是的 JSON 或某些其他文本格式)的命令行工具.
  • 它直接支持请求/响应风格的通信.
  • 当然, HTTP 对防火墙是友好的.
  • 它不需要中间代理, 这简化了系统的架构.

使用 REST 也有一些缺点:

  • 它只支持请求/响应风格的通信.
  • 降低了可用性.因为客户端和服务直接通信而不用中间代理来缓冲消息, 在通信期间, 它们必须同时运行.
  • 客户端必须知道服务实例的位置(URLs).如在 3.2.4 节所描述的, 这在现代应用程序中是一个非常重要的问题.客户端必须使用服务发现机制来定位服务实例.
  • 在单个请求中获取多个资源是一个挑战.
  • 映射多个更新操作到 HTTP 谓词有时候是有些困难的.

尽管有这些缺点, REST 似乎是 API 的实际标准, 尽管有一些令人关注的替代方法.例如, GraphQL 实现了灵活、高效的数据获取.第 8 章讨论了 GraphQL 并介绍了 API 网关模式.

gRPC 是 REST 的另一种选择.让我们看下它是如何工作的.

使用 gRPC

正如上一节提到的, 使用 REST 的一个挑战就是由于 HTTP 只提供了有限数量的谓词, 因此设计支持多个更新操作的 REST API 并不总是那么简单.避免这个问题的一个 IPC 技术就是 gRPC(www.grpc.io), 一个用来编写跨语言客户端和服务端的框架(见 https://en.wikipedia.org/wiki/Remote_procedure_call 查看更多信息), gRPC 是一种基于二进制消息的协议, 这意味着如之前在讨论二进制消息格式时所提到的, 你必须采用 API 优先的方式来进行服务设计.你可以使用 Protocol Buffer(基于 IDL)来定义你的 gRPC APIs, 这是 Google 的用来序列化结构化数据的独立于语言的机制.你可以使用 Protocol Buffer 编译器来生成客户端桩和服务端骨架.编译器可以生成多种语言的代码, 包括 Java, C#, NodeJS 和 GoLang.客户端和服务端使用 HTTP/2 以 Protocol Buffers 的格式来交换二进制消息.

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

gRPC 使用 Protocol Buffers 作为消息格式.如之前提到的, Protocol Buffers 是一种高效的, 紧凑的, 二进制格式.它是一种带标签的格式.Protocol Buffers 消息的每个字段都有编号并且具有类型编码.一个消息的接收者可以提起它需要的字段并跳过它不能识别的字段.因此, gRPC 允许 API 在保持向后兼容的同时不断演进.

清单 3.1 显示了订单服务的 gRPC API 摘录.它定义了几个方法, 包括 createOrder.此方法有一个 CreateOrderRequest 参数并返回一个 CreateOrderReply.

清单 3.1 订单服务的 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
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;
}
...

CreateOrderRequestCreateOrderReply 是定义的消息.比如, CreateOrderRequest 消息由一个 int64 类型的 restaurantId 字段.字段的标记值是 1.

gRPC 有几个优点:

  • 设计一个具有丰富更新操作集的 API 非常简单.
  • 它具有高效、紧凑的 IPC 机制, 特别是在交换大型消息时.
  • 双向流支持 RPI 和消息风格的通信.
  • 它支持使用多种语言编写的客户端和服务之间的互操作性.

gRPC 同样有几个缺点:

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

gRPC 是 REST 的一个很有吸引力的替代方案, 但与 REST 一样, 它也是一个同步通信机制, 因此也存在局部故障的问题.让我们看看这是什么以及如何处理它.

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

在分布式系统中, 无论何时一个服务发出一个同步请求到另一个服务, 局部故障的风险一直存在.因为客户端和服务是独立的进程, 一个服务可能无法及时响应客户端的请求.服务可能宕机因为故障或者维护.或者服务可能过载, 对请求的响应速度非常慢.因为客户端是阻塞等待响应, 危险在于故障可能会级联到客户端的客户端, 从而导致停机.

模式: 断路器
RPI 代理在连续失败的次数超过指定的阈值后, 会在超时期间立即拒绝调用.详见 http://microservices.io/patterns/reliability/circuit-breaker.html.

考虑一个例子, 如图 3.2 所示的场景, 订单服务无法响应.移动客户端发出一个 REST 请求到 API 网关(这是 API 客户端到应用程序的入口), 这将在第 8 章讨论.API 网关代理了到无法响应的 订单服务 的请求.

Figure 3.2-An API gateway must protect itself from unresponsive services

OrderServiceProxy 是一个简单实现是无限阻塞, 等待响应.这不仅会导致糟糕的用户体验, 而且会在许多应用程序中消耗宝贵的资源, 比如线程.最终, API 网关将耗尽资源无法处理请求.整个 API 将不可用.

设计服务以防止局部故障在整个应用程序中级联是非常重要的.解决方案分为两部分:

  • 你必须使用设计好的 RPI 代理, 比如 OrderServiceProxy, 来处理无法响应的远程服务.
  • 你需要决定如何从一个失败的远程服务中恢复.

首先让我们看看如何编写鲁棒的 RPC 代理.

开发鲁棒的 API 代理

无论何时一个服务同步调用另一个服务, 它应该使用由 Netflix 描述的方案(http://techblog.netflix.com/2012/02/fault-tolerance-in-high-volume.html)保护自身.这种方案由以下机制组成:

  • 网络超时-永远不会无限期阻塞, 并总是使用超时当等待响应的时候.使用超时保证了资源不会被无限期占用.
  • 限制从客户端到服务的未完成请求的数量-对客户端可以向特定服务发出的未完成请求的数量施加上限.如果限制达到, 发起额外的请求可能毫无意义, 并且这些尝试应该立即失败.
  • 断路器模式-追踪成功和失败的请求数量, 如果错误率超过了某些阈值, 触发断路器, 这样后面的尝试将会立即失败.大量失败的请求表明服务是不可用的, 因此发送更多的请求是无意义的.一段时间之后, 客户端应该再次尝试, 如果成功, 关闭断路器.

Netflix Hystrix (https://github.com/Netflix/Hystrix) 是一个开源的库, 实现了这些以及一些其他的模式.如果你正在使用 JVM, 你一定要使用 Hystrix 当实现 RPI 代理的时候.如果你的程序运行在非 JVM 环境中, 你应该使用类似的库.比如, 在 .NET 社区中 Polly 是一个流行的库(https://github.com/App-vNext/Polly).

从无法响应的服务中恢复

使用 Hystrix 类似的库只是解决方案的一部分.你还必须根据具体情况决定服务应该如何从无响应的远程服务中恢复.一种方法就是简单返回一个错误给客户端.比如, 这种方法对于图 3.2 所示的场景是有意义的, 也就是创建订单的请求失败了.对于 API 网关的唯一方法就是返回一个错误给移动客户端.

在其它场景中, 返回一个 fallback 值, 比如一个默认值或者一个缓存的响应, 这样可能有意义一点.比如, 第 7 章描述了 API 网关如何使用 API 组合模式(API composition pattern)来实现 findOrder() 查询操作.如图 3.3 所示, GET /orders/{orderId} 端点的实现调用多个服务, 包括 订单服务, 餐厅服务配送服务, 然后组合结果.

Figure 3.3-The API gateway using API composition

很可能每个服务的数据对于客户端来说不是同等重要的.订单服务 的数据是重要的.如果服务不可用, API 网关应该返回一个缓存版本的数据或者一个错误.其他服务的数据不那么重要.比如, 客户端可以展示有用的信息给用户即使配送状态不可用.如果 配送服务 不可用, API 网关应该返回缓存版本的值或者在响应中省略它.

设计你的服务可以用来处理局部故障是至关重要的, 但这不是使用 RPI 的时候要解决的唯一问题.另外一个问题是为了让一个服务使用 RPI 调用另外一个服务, 它需需要知道服务实例的网络地址.表面上看这听起来比较简单, 但是在实践中这是一个挑战问题.你必须使用服务发现机制, 让我们看下这是如何工作的.

使用服务发现

假设你正在编写调用一个有 REST API 的服务的代码.为了发起一个请求, 你的代码需要知道服务实例的网络位置(IP 地址和端口).在一个运行在服务硬件上的传统应用程序中, 服务实例的网络位置通常是静态的.比如, 你的代码从一个偶尔更新的配置文件中读取网络位置.但是现在, 基于云的微服务应用程序, 通常不那么简单.如图 3.4 所示, 一个现代的应用程序更加的动态.

服务实例具有动态分配的网络地址.此外, 服务实例的集合动态变化因为自动扩容、故障和升级.因此, 你的客户端代码必须使用服务发现.

Figure 3.4-Service instances have dynamically assigned IP addresses

服务发现概览

如你刚刚看到的, 你无法静态配置一个客户端使用的服务的 IP 地址.相反, 应用程序必须使用动态服务发现机制.服务发现从概念上来说是相当简单的: 它的主要组件是服务注册表, 也就是一个应用程序服务实例网络地址的数据库.服务发现机制更新服务注册表当服务实例启动或停止的时候.当一个客户端调用一个服务, 服务发现机制查询服务注册表来获取服务实例的可用列表, 然后将请求路由到其中一个服务实例.

有两种方式来实现服务发现:

  • 服务和它们的客户端直接使用服务注册表进行交互.
  • 部署基础设施处理服务发现(我将在第 12 章详细讨论这一点).

让我们看下每种方式.

应用应用程序级别的服务发现模式

对于应用程序服务和它们的客户端来说实现服务发现的一种方式就是使用注册表进行交互.图 3.5 展示了这是如何工作的.服务实例使用服务注册表注册它的网络地址.服务客户端调用一个服务的时候首先查询服务注册表来获取服务实例的列表.然后它将请求发给这些实例中的其中一个.

Figure 3.5-The service registry keeps track of the service instances

这种方式的服务发现是两种模式的组合.第一种模式是自注册模式.服务实例调用服务注册的注册 API 来注册它的网络地址.它可能也提供了 健康检查 URL, 将在第 11 章详细描述.健康检查 URL 是一个 API 端点用于服务注册周期性调用来验证服务实例的健康状态以及是否可以处理请求.服务注册可能也需要服务实例周期性地调用 “心跳(heartbeat)” API, 为了防止注册过期.

模式: 自注册
服务实例注册它自己到服务注册表中.详见 http://microser-vices.io/patterns/self-registration.html.

第二个模式是客户端发现模式.当一个服务客户端想调用一个服务, 它查询服务注册表来获取服务实例的列表.为了改善性能, 客户端可以缓存服务实例.然后服务客户端使用负载均衡算法, 比如循环(round-robin)或随机, 来选择一个服务实例.接着发起一个请到选择的服务实例.

模式: 客户端发现
服务客户端从服务注册表中获取可用服务实例的列表, 并进行负载均衡.详见 http://microservices.io/patterns/client-side-discovery.html.

应用程序级别的服务发现已经被 Netflix 和 Pivotal 推广.Netflix 开发并开源了几个组件: Eureka, 一个高可用的服务发现组件, Eureka Java 客户端, 和 Ribbon, 一个支持 Eureka 客户端的复杂 HTTP 客户端.Pivotal 开发了 Spring Cloud, 一个基于 Spring 的框架, 使得使用 Netflix 组件非常容易.基于 Spring Cloud 的服务自动使用 Eureka 注册, 基于 Spring Cloud 的客户端自动使用 Eureka 用于服务发现.

应用程序级别的服务发现的一个好处是它处理了这样的场景: 当服务被部署到多个发布平台.假设, 比如你在 Kubernetes 只部署了某些服务, 将在第 12 章讨论, 剩余的服务运行在遗留的环境中.应用程序级别的服务发现使用 Eureka, 比如, 在两种环境中都可以工作, 而基于 Kubernetes 的服务发现只能在 Kubernetes 中工作.

应用程序级别的服务发现的一个缺点是你需要一个用于各种语言的服务发现库, 也可能是框架.Spring Cloud 只是帮助 Spring 开发者.如果你正在使用某些其他的 Java 框架或者非 JVM 语言, 比如 NodeJS 或 GoLang, 你必须找到某些其它的服务发现框架.应用程序级别的服务发现的另一个缺点是你负责设置和管理服务发现, 这容易让人分心.所以, 通常最好使用由部署基础设施提供的服务发现机制.

应用平台提供的服务发现模式

后面在第 12 章你将学习到许多现代部署平台比如 Docker 和 Kubernetes 已经内置了服务注册和服务发现机制.部署平台给每个服务提供了一个 DNS 名称, 一个虚拟 IP(VIP) 地址, 和一个解析到 VIP 地址的 DNS 名称.服务客户端发起一个请求到 DNS 名称/VIP, 部署平台自动路由请求到可用服务实例中的一个.所以, 服务注册, 服务发现, 和请求路由都由部署平台处理.图 3.6 展示了这是如何工作的.

Figure 3.6-The platform is responsible for service registration, discovery, and request routing

DNS 名称 order-service, 解析到虚拟 IP 地址 10.1.3.4.部署平台自动负载均衡请求到 订单服务 的三个实例中.

这种方式由两种模式组成:

  • 第三方注册模式-与向服务注册中心注册自己的服务不同, 处理注册的是一个名为 注册器(registrar) 的第三方, 它通常是部署平台的一部分.
  • 服务端发现模式-与客户端查询服务注册表不同, 它发起请求到一个 DNS 名称, 解析到请求路由器, 请求路由器查询服务注册中心并负载平衡请求.

模式: 第三方注册
服务实例自动由第三方注册为服务.详见 http://microservices.io/patterns/3rd-party-registration.html.

模式: 服务端发现
客户端发起一个请求到用于服务发现的路由器.详见 http://microservices.io/patterns/server-side-discovery.html.

平台提供的服务发现的主要优点是服务发现的所有方面都由部署平台处理.服务和客户端都不包含任何服务发现代码.因此, 服务发现机制对所有服务和客户端都是可用的, 不管它们是用哪种语言或框架编写的.

平台提供的服务发现的一个缺点是它只支持使用平台部署的服务发现.比如, 在之前提到的当描述应用程序级别发现的时候, 基于 Kubernetes 的发现只能用于运行在 Kubernetes 上服务.尽管有这个限制, 我还是推荐尽可能使用由平台提供的服务发现.

目前我们已经看过了使用 REST 或 gRPC 的同步 IPC, 现在让我们看下另一种选择: 异步, 基于消息的通信.

使用异步消息模式的通信

当使用消息的时候, 服务通过异步交换消息进行通信.基于消息的应用程序通常使用 消息代理, 作为服务之间的中介, 尽管另一种选择是使用无代理体系结构, 其中服务彼此直接通信.一个服务发起请求到一个服务通过发送消息.如果希望服务实例进行应答, 它将通过向客户端发送单独的消息来进行应答.因为通信是异步的, 客户端不会阻塞等待应答.相反, 客户端是在假定不会立即收到应答的情况下编写的.

模式: 消息
客户端使用异步消息调用服务.详见 http://microservices.io/patterns/communication-style/messaging.html.

我从消息概览开始这一节.我将展示如何描述一个独立于消息技术的消息架构.接下来我比较了无代理和基于代理的架构, 以及描述了选择一个消息代理的标准.然后讨论了几个重要的主题, 包括在保持消息顺序的同时扩容消费者, 检测和废弃重复的消息, 发送和接收消息作为数据库事务的一部分.让我们从看看消息如何工作的开始.

消息概览

一个有用的消息模型在 Gregor Hohpe 和 Bobby Woolf 编写的《企业集成模式》(Addison-Wesley Professional, 2003) 一书中定义了.在这个模型中, 消息通过消息通道交换(message channels).发送者(应用程序或服务)将消息写入通道, 然后接收者(应用程序或服务)从通道中读取消息.我们先看下消息, 然后看下通道.

关于消息

一条消息由 header 和消息体组成(www.enterpriseintegrationpatterns.com/Message.html).header 是一个 name-value 对的集合, 描述了要发送数据的元数据信息.除了消息发送者提供的 name-value 对, 消息头也包含了 name-value 对, 比如由发送者或消息的基础设施生成的唯一的消息 id, 和可选的返回地址, 指定了响应应该被写入的消息通道.消息体是要发送的数据, 文本或二进制格式.

有几种不同类型的消息:

  • 文档-常见的只包含数据的消息.接收者决定如何解释它.命令的响应是文档消息的示例.
  • 命令-等同于 RPC 请求的消息.它指定了要调用的操作和参数.
  • 事件-表示在发送方中发生了值得注意的事情的消息.一个事件通常是一个域事件, 表示域对象, 如 订单客户 的状态变更.

本书中描述的微服务架构广泛地使用命令和事件.

现在让我们看下通道, 也就是服务通信的机制.

关于消息通道

如图 3.7 所示, 消息通过通道进行交换(www.enterpriseintegrationpatterns.com/MessageChannel.html).发送者的业务逻辑是调用*发送接口*, 此接口封装了底层的通信机制.发送接口消息发送者适配类实现, 它通过消息通道发送消息给接收者.消息通道是消息基础设施的抽象.接收者中的消息处理器适配类被调用用来处理消息.它调用由消费者的业务逻辑实现的接收接口.任意数量的发送者都可以向通道发送消息.类似地, 任意数量的接收者都可以从通道接收消息.

Figure 3.7-Messages are exchanged over channels

有两种类型的通道: 点对点(www.enterpriseintegrationpatterns.com/PointToPointChannel.html) 和发布订阅(www.enterpriseintegrationpatterns.com/PublishSubscribeChannel.html):

  • 点对点通道向从通道读取的某个消费者发送消息.对于前面描述的一对一交互方式, 服务使用点对点通道.例如, 命令消息通常通过点对点通道发送.
  • 发布订阅通道发送每条消息给所有的消费者.服务使用发布订阅通道用于之前描述的一对多的交互方式.比如, 一条事件消息通过发布订阅通道发送.

使用消息进行交互的实现

消息的一个有价值的特性是足够灵活用来支持如 3.1.1 节描述的所有的交互方式.某些交互方式直接由消息实现.其它的必须在消息的基础之上实现.

让我们看下如何实现每种交互方式, 从请求/响应和异步请求/响应开始.

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

当客户端和服务使用请求/响应或异步请求/响应方式进行交互的时候, 客户端发送请求, 服务发回应答.这两种交互方式的差异是使用请求/响应方式, 客户端期望服务立即进行响应, 而异步请求/响应则没有这样的期望.消息本质上就是异步的, 所以只提供了异步请求/响应.但是一个客户端可以阻塞直到收到应答.

客户端和服务通过交换一个消息对来实现异步请求/响应的交互方式.如图 3.8 所示, 客户端发送一条命令消息给一个服务拥有的点对点消息通道, 指定了要执行的操作和参数.服务处理请求然后发送一个应答消息给客户端拥有的点对点通道, 其中包含了结果.

Figure 3.8-Implementing asynchronous request-response

客户端必须告诉服务将应答消息发送到哪里, 服务必须给请求发送应答消息.幸运地是, 解决这两个问题不是很难.客户端发送一条包含了应答通道 header 的命令消息.服务器将包含了和消息标识符一样的关联 id(correlation id)写入到应答通道中.客户端使用关联 id 将应答消息与请求匹配.

因为客户端和服务使用消息通信, 所以交互方式本质上是异步的.理论上来说, 消息客户端可以阻塞直到它收到应答, 但是在实践中客户端将异步处理应答.此外, 应答通常由客户端实例的任意一个来处理.

实现单向通知

使用异步消息实现单向通知非常简单.客户端将消息, 通常是命令消息, 发送到服务拥有的点对点通道.服务订阅通道并处理消息.它不会发送应答.

实现发布/订阅

消息已经内建了支持发布/订阅方式的交互.客户端发布消息到由多个消费者读取的发布-订阅通道.如在第 4 章和第 5 章描述的, 服务使用发布/订阅来发布域事件, 它代表了域对象的变更.发布域事件的服务拥有一个发布-订阅通道, 它的名字从域对象类(domain class)衍生而来.比如, 订单服务(Order Service) 发布 订单(Order) 事件到 订单通道(Order Channel), 配送服务(Delivery Service) 发布 配送(Delivery) 事件到 配送(Delivery)通道(Delivery Channel).对特定域对象事件感兴趣的服务只会订阅相应的通道.

实现发布/异步响应

发布/异步响应交互方式是一种高级的交互方式, 它通过组合发布/订阅和请求/响应的元素实现.客户端发布指定了应答通道 header 的消息到发布/订阅通道.消费者将包含了相关联 id 的应答消息写入应答通道.客户端通过使用关联 id 来收集响应, 以将应答消息与请求匹配.

在你的应用程序中的有异步 API 的每个服务都将使用这些实现技术中的一种或多种.拥有异步 API 用来调用操作的服务将有一个消息通道用于请求.类似地, 发布事件的服务将发布它们到事件消息通道.

如在 3.1.2 节描述的, 对于一个服务来说, 编写 API 规范是很重要的.接着让我们看看编写异步 API 该怎么做.

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

对于服务异步 API 的规范, 必须如图 3.9 所示, 指定消息通道的名称, 通过每个通道交换的消息字节, 和它们的格式.你也必须使用一个标准来描述消息的格式, 比如 JSON, XML 或 Protobuf.但是不像 REST 和 Open API, 没有一个被广泛采用的标准来文档化通道和消息类型.相反, 你需要编写一份非正式的文档.

Figure 3.9-A service asynchronous API consists

服务异步 API 由操作, 被调用的客户端和由服务发布的事件组成.它们以不同的方式被文档化.让我们看下每一个, 从操作开始.

文档化异步操作

服务的操作可以使用两种不同的交互方式被调用:

  • 请求/异步响应方式 API-这由服务命令消息通道, 类型和能被服务接受的命令消息字节的格式, 以及由服务发送的应答消息的类型和格式组成.
  • 单向通知方式 API-这由服务命令消息通道, 能被服务接受的命令消息字节的格式组成.

服务可以使用相同的请求通道同时用于异步请求/应答和单向通知.

文档化发布事件

服务也可以使用发布/订阅的交互方式发布事件.这种方式的 API 的规范由事件通道和被服务发布到通道的事件消息的类型和格式组成.

消息和消息的通道模型是一个很好的抽象, 也是设计服务异步 API 的一种好方法.但是为了实现服务, 你需要选择消息技术, 并确定如何使用其功能实现你的设计.让我们来看看其中包含了什么.

使用消息代理

基于消息的应用通常使用消息代理, 一种基础设施服务, 服务通过它进行通信.但是基于代理的架构不是消息架构的唯一方式.你也可以使用基于无代理的消息架构, 服务直接与另一个服务通信.这两种方式如图 3.10, 有不同的权衡, 但是通常基于代理的架构是更好的方式.

Figure 3.10-Brokerless architecture and broker-based architecture

本书关注于基于代理的架构, 但是快速查看一下无代理架构也是值得的, 因为也许你可能发现在某些常见下这种方式是有用的.

无代理架构

在无代理架构中, 服务可以直接交换消息.ZeroMQ(http://zeromq.org)是一个流行的无代理消息技术.它既一个规范, 也是一组不同语言的库.它支持多种传输, 包括 TCP, 类 UNIX 域套接字(UNIX-style domain sockets) 和组播.

无代理架构有几个优点:

  • 轻量级的网络传输和更小的延迟, 因为消息直接从发送者传输到接收者, 而不用从发送者到消息代理, 再从消息代理到接收者.
  • 消除消息代理成为性能瓶颈或单点故障的可能性.
  • 减少了操作复杂性, 因为不需要设置和维护消息代理.

尽管这些好处看起来很吸引人, 但无代理消息也有明显的缺点:

  • 服务需要知道其他服务的地址, 所以必须使用在 3.2.4 节中描述的发现机制.
  • 降低了可用性, 因为消息的发送者和接收者都必须是可用的, 当消息进行交换的时候.
  • 实现机制, 如有保证的交付, 更具挑战性.

    事实上, 其中的一些缺点, 比如降低可用性和服务发现的需求, 在使用同步, 请求/响应的时候同样会有.

    因为这些限制, 大部分企业应用使用基于代理的架构.让我们看下它是如何工作的.

基于代理架构的概览

消息代理是所有消息流经的中介.发送者将消息写入消息代理, 消息代理将它发送给接收者.使用消息代理的一个好处是发送者不需要知道消费者的网络地址.另一个好处是消息代理缓冲消息直到消费者能够处理它们.

有多种消息代理可供选择.流行的开源消息代理的例子包括如下:

也有一些基于云的消息分为, 比如 AWS Kinesis(https://aws.amazon.com/kinesis/) 和 AWS SQS(https://aws.amazon.com/sqs/).

当选择一个消息代理的时候, 有几个因素需要考虑, 比如以下:

  • 支持编程语言-你可能应该选择一种支持多种编程语言的消息服务.
  • 支持消息标准-消息代理是否支持一些标准, 比如 AMQP 和 STOMP, 或者是它专有的?
  • 消息顺序-消息代理是否保持消息的顺序?
  • 投递保证-消息代理提供什么样的投递保证?
  • 持久化-消息是否持久化到磁盘上, 并能够在代理崩溃时存活?
  • 持久性-如果一个消费者重新连接到消息代理, 它是否可以接收到当它断开连接的时候发送到代理的消息.
  • 扩展性-消息代理如何扩容?
  • 延迟-端到端的延迟是多少?
  • 消费者竞争-消息代理是否支持消费者竞争?

每种代理有不同的权衡.比如, 一个低延迟的代理可能不会保持顺序, 不保证消息送达, 并可能只在内存中存储消息.保证送到和可靠存储消息到磁盘上的消息代理可能有较高的延迟.哪一种代理最好依赖于你的应用程序需求.甚至可能应用程序的不同部分有不同的消息需求.

不过, 消息顺序和可扩展性可能很重要.现在让我们看下如何使用消息代理实现消息通道.

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

每个消息代理以不同的方式实现消息通道概念.如表 3.2 所示, JMS 消息代理, 比如 ActiveMQ 有队列和主题(topics).基于 AMQP 的消息代理比如 RabbitMQ 有交换机和队列.Apache Kafka 有主题, AWS Kinesis 有流(streams), AWS SQS 有队列.此外, 由些消息代理提供了比本章描述的消息和通道抽象更灵活的消息.

表 3.2 每个消息代理以不同的方式实现消息通道概念

消息代理点对点通道发布-订阅通道
JMS队列主题
Apache Kafka主题主题
基于 AMQP 的代理, 比如 RabbitMQ交换机 + 队列广播交换机(Fanout exchange) 和配个消费者一个队列
AWS Kinesis
AWS SQS队列-

在这里描述的几乎所有的消息代理都支持点对点和发布-订阅两种通道.其中一个例外是 AWS SQS, 它只支持点对点通道.

现在让我们看看基于代理消息的缺点和优点.

基于代理消息的缺点和优点

使用代理的消息有很多好处:

  • 松耦合-客户端通过简单地发送消息到对应的通道发出请求.客户端完全不关心服务实例.它不需要使用发现机制来寻找服务实例的地址.
  • 消息缓冲-消息代理缓冲消息直到它们可以被处理.使用同步请求/响应协议, 比如 HTTP, 客户端和服务两者在交换期间都必须是可用的.而使用消息, 消息将排队等待, 直到它们可以被消费者处理.这意味着, 比如, 在线商品可以接受消费者的订单, 即使订单履行(order-fulfillment)系统缓慢或不可用.消息将简单地排队等待直到它们可以被处理.
  • 灵活的通信方式-消息支持之前描述的所有交互方式.
  • 显示的进程间通信-基于 RPC 的机制尝试将调用远程的服务当做是调用本地服务.但是由于物理定律和局部故障的可能性, 实际上它们是完全不同的.消息使得这些差异非常明显, 所以开发者不会被引入一种错误的安全感.

使用消息也有一些缺点:

  • 潜在的性能瓶颈-消息代理的一个风险可能是性能瓶颈.幸运地是, 许多现代化的消息代理被设计成高度可扩展的.
  • 潜在的单点故障-消息代理必须具有高可用性, 否则将影响系统的可靠性.幸运的是, 大多数现代代理被设计成高度可用的.
  • 额外的操作复杂性-消息系统是另一个系统组件, 它必须被安装、配置和操作.

让我们看看你可能面对的一些设计问题.

竞争的接收者和消息排序

有一个挑战就是如何扩展消息接收者而同时保持消息顺序.这是一个常见的需求, 有多个服务实例为了并发处理消息.进一步来说, 即使单个服务也可能使用现成来并发地处理多条消息.使用多线程, 服务实例并发处理消息将增加应用程序的吞吐量.但是并发处理消息的调整是保证每条消息都按顺序处理一次.

比如, 假设有三个服务实例从相同的点对点通道读取, 并且一个发送者顺序发布 消息创建(Order Created)消息更新(Order Updated)订单取消(Order Cancelled) 事件消息.简单的消息实现可以将每个消息并发地传递到不同的接收方.因为网络问题或垃圾收集造成的延迟, 消息可能被无序处理, 这可能导致奇怪的行为.理论上说, 服务实例可以在另一个服务处理 消息创建(Order Created) 消息之前处理 订单取消(Order Cancelled) 消息.

一个被现代化消息代理, 比如 Apache Kafka 和 AWS Kinesis, 使用的通用解决方案, 就是使用分片(分区)的通道.图 3.11 展示了这是如何工作的, 这个方案由三部分:

  • 分配通道由两个或更多的分片组成, 每一个都像一个通道.
  • 发送者在消息头中指定一个分片 key, 通常是一个任意的字符串或字节序列.消息代理使用分片 key 来分配消息到特定的分片/分区.比如, 它可以通过计算分片 key 的哈希值, 对分片数量取模, 来选择分片.
  • 消息代理组将接收者的多个实例分组在一起, 然后将它们作为逻辑上的接收者.比如, Apache Kafka, 使用消费者组(consumer group)的术语.消息代理将每个分片分配给单个接收者.当接收者启动和停止的时候重新分片.

Figure 3.11-Using a sharded message channel

在这个例子中, 每个订单事件消息有一个 orderId 座位它的分片 key.一个特定订单的每个事件被发布到相同的分片, 被单个消息费读取.因此, 可以保证消息被顺序处理.

处理重复消息

使用消息时必须解决的另一个挑战是处理重复的消息.在理想情况下, 消息代理应该只交付每条消息一次, 但是确保消息只传递一次通常成本太高.相反, 大多数消息代理承诺交付消息 至少一次(at least).

当系统正常工作时, 保证只交付一次的消息代理将只交付每条消息一次.但是客户端、网络或消息代理的故障可能导致消息被交付多次.假设客户端在处理消息并更新其数据库之后崩溃-但在确认消息之前崩溃.当它重启的时候消息代理将再次交付未确认的消息到那个客户端, 或者到另一个客户端的副本.

理想情况下, 你应该使用保持顺序的消息代理当重新交付消息的时候.假设客户端处理一个 订单创建(Order Created) 的事件, 然后为相同的订单处理一个 订单取消(Order Cancelled) 的事件, 但是不知道为什么 订单创建(Order Created) 事件没有被确认.消息代理应该重新交付 订单创建(Order Created)订单取消(Order Cancelled) 的事件.如果它只重新交付 订单创建(Order Created), 客户端可能会取消订单.

有两种不同的方式来处理重复消息:

  • 编写幂等消息处理程序.
  • 追踪消息和丢弃重复消息.

让我们看下每种方式.

编写幂等消息处理程序

如果处理消息的应用程序逻辑是幂等的, 那么重复的消息将没有什么影响.如果使用相同的输入值调用应用程序逻辑多次而没有额外的影响那么它就是幂等的.比如, 取消一个已经取消的订单是一个幂等操作.使用客户端提供的 ID创建订单也是这样.如果消息代理再重新交付消息的时候保留了顺序, 那么幂等的消息处理程序可以被安全地执行多次.

不幸地是, 应用程序逻辑通常不是幂等的.或者你可能使用了重新交付消息的时候不保留顺序的消代理.重复或者无序的消息可能引起 bugs.为了解决这个问题, 你必须编写追踪消息并丢弃重复消息的消息处理程序.

追踪消息和丢弃重复消息

比如, 考虑一个授权客户信用卡的消息处理程序.每笔订单必须授权一次.这个应用程序的逻辑的例子每次调用的时候有不同的影响.如果重复消息引起消息处理程序执行了这个逻辑多次, 应用程序的行为将不正确.这种应用程序的消息处理程序逻辑必须是幂等的通过检测和丢弃重复的消息.

一个简单的方案是消息消费者使用 消息 id 追踪它已经处理过的消息并丢弃重复的.比如, 它可以存储它消费过的每条消息的 消息 id 到数据库表中.图 3.12 展示了如何使用一张专用表.

Figure 3.12-Process duplicate messages

当消费者处理一条消息, 它记录 消息 id 到数据库表中作为创建和更新业务实体的一部分.在这个例子中, 消费者插入一条包含 消息 id 的记录到 PROCESSED_MESSAGES 表中.如果一条消息重复了, INSERT 将失败并且消费者可以丢弃消息.

另一种消息处理程序方式是记录 消息 ids 到应用程序表中而不是一张专用表.这种方式特别有用当使用有限制事务模型的 NoSQL 数据库, 因此它不支持作为数据库事务的一部分更新两张表.第 7 章展示了这种方式的一个例子.

事务消息

服务通常需要将消息发布为更新数据库的事务的一部分.比如, 在本书中, 你可以看到每当创建或更新业务实体时发布域事件的服务示例.数据的更新和消息的发送必须在一个事务中进行.另外, 比如在发送消息之前, 一个服务可能更新数据库然后崩溃了.如果服务不支持原子性地执行者两个操作, 故障可能会让系统处于不一致的状态.

传统的方式是使用跨数据库和消息代理的分布式事务.但是如你将在第 4 章看到的, 分布式事务不是现代应用程序的一个好的选择.此外, 许多现代消息代理比如 Apache Kafka 不支持分布式事务.

因此, 应用程序必须使用不同的机制来可靠地发布消息.让我们看看这是如何工作的.

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

让我们假设你的应用程序使用关系型数据库.一种简单方法是应用事务性发件箱(Transactional outbox)模式可靠发布消息.这种模式使用数据库表作为临时的消息队列.如图 3.13 所示, 发送消息的服务有一个 OUTBOX 数据库表.作为创建、更新和删除业务对象的数据库事务的一部分, 服务通过插入它们到 OUTBOX 表来发送消息.原子性将得到保证因为这是本地的 ACID 事务.

Figure 3.13-A service reliably publishes a message

OUTBOX 表作为一个临时的消息队列.MessageRelay 是一个读取 OUTBOX 表的组件, 并发布消息到消息代理.

模式: 事务收件箱

通过保存一个事件或消息到 OUTBOX 数据库表中, 将其作为数据库事务的一部分发布.想见 http://microservices.io/patterns/data/transactional-out-box.html.

你可以使用某些 NoSQL 数据库来实施类似的方案.作为记录存储在数据库中的每个业务实体都有一个属性, 该属性是需要发布的消息列表.当一个事务更新数据库中的一个实体, 它将消息追加到那个列表中.这是原子的因为它使用单个数据操作完成.然而, 挑战在于有效地找到具有事件的业务实体并发布它们.

有两种不同的方式来把消息从数据库移动到消息代理中.我们将看下每一种方式.

通过使用轮询发布器模式(POLLING PUBLISHER PATTERN)来发布事件

如果应用程序使用关系型数据库, 发布消息的一种非常简单的方式就是将消息插入到 OUTBOX 表中, 然后 MessageRelay 会轮询表中未发布的消息.它周期性地查询表:

1
SELECT * FROM OUTBOX ORDERED BY ... ASC

接着, MessageRelay 发布这些消息到消息代理, 并将一条消息发送到其目标消息通道.最后, 它将这些消息从 OUTBOX 表中删除.

1
2
3
BEGIN
DELETE FROM OUTBOX WHERE ID in (....)
COMMIT

模式: 长轮询发布器

通过轮询数据中的 outbox 表来发布消息.详见 http://microservices.io/patterns/data/polling-publisher.html.

轮询数据库是一种简单的方法, 在低规模下工作得相当好.缺点是频繁轮询数据库可能会很昂贵.此外, 是否可以将此方法用于 NoSQL 数据库取决于其查询能力.这是因为应用程序必须查询业务实体, 而不是查询 OUTBOX 表, 这可能有效, 也可能无效.由于这些缺点和限制, 使用跟踪数据库事务日志的更复杂和性能更好的方法通常更好, 在某些情况下, 这是必要的.

通过应用事务日志追踪(TRANSACTION LOG TAILING)模式来发布事件

一个复杂的解决方案是 MessageRelay 跟踪数据库事务日志(也称为 commit log).应用程序的每次提交更新都作为数据事务日志的一条记录.事务日志挖掘器可以读取事务日志, 并将每个更改作为消息发布到消息代理.图 3.14 展示了这种方式是如何工作的.

Figure 3.14-Service publishing events by applying the transaction log tailing pattern

事务日志挖掘器(Transaction Log Miner) 读取事务日志条目.它将与插入的消息对应的每个相关日志条目转换为消息, 并将该消息发布到消息代理.这种方式可以用来发布写入到 RDBMS 中的 OUTBOX 表的消息或添加到 NoSQL 数据库记录的消息.

模式: 事务日志追踪(Transaction log tailing)

通过跟踪事务日志发布对数据库所做的更改.详见 http://microservices.io/patterns/data/transaction-log-tailing.html.

以下是一些使用这种方式的例子:

尽管这种方法很模糊, 但它的效果非常好.挑战在于实现它需要一些开发工作.例如, 你可以编写调用特定于数据库 API 的底层代码.或者, 你可以使用开源框架, 如Debezium, 它发布应用程序对 MySQL、Postgres、MongoDB 的变更到 Apache Kafka.使用 Debezium 的缺点是, 它的重点是捕获数据库级别的更改, 用于发送和接收消息的 API 超出了它的范围.这就是为什么我创建了 Eventuate Tram 框架, 它提供消息 API 以及事务跟踪和轮询.

用于消息的库和框架

服务需要使用库来发送和接收消息.一种方式是使用消息代理客户端库, 虽然直接使用这样的库有几个问题:

  • 客户端库将发布消息到消息代理 API 的业务逻辑耦合在一起.
  • 消息代理客户端库通常是低层次的, 需要编写很多行代码来发送或接收消息.作为一个开发者, 你不希望重复编写样板代码.同样, 作为本书的作者我不希望示例代码与低层次的重复代码混杂在一起.
  • 客户端库通常只提供发送和接收消息的基本机制, 不支持高级别的交互方式.

一种更好的方式是使用高级别的库或框架, 它们隐藏了低级别的细节, 并且支持高级别的交互方式.为简单起见, 本书的示例使用了我的 Eventuate Tram 框架.它有简单, 易于理解的 API, 隐藏了使用消息代理的复杂性.除了发送和接收消息的 API, Eventuate Tram 也支持高级别的交互方式, 比如异步请求/响应和域事件发布.

什么!? 为什么用 Eventuate 框架?

本书的代码示例使用了我开发的开源 Eventuate 框架, 它可以用于事务消息, 事件溯源(event sourcing) 和 sagas.我选择使用我的框架, 是因为与依赖注入和 Spring 框架不同,对于微服务架构所需的许多特性, 目前还没有得到广泛采用的框架.不使用 Eventuate Tram 框架, 许多示例将需要直接使用低级别的消息 APIs, 使它们更加复杂, 模糊了重要的概念.或者他们会使用一个没有被广泛采用的框架, 这也会招致批评.相反, 示例使用了 Eventuate Tram 框架, 它有简单、易于理解的 API, 隐藏了实现细节.你可以在你的应用程序中使用这些框架.或者, 你也可以学习 Eventuate Tram 框架, 并自己重新实现这些概念.

Eventuate Tram 也实现了两个重要的机制:

  • 事务消息-它将消息作为数据库事务的一部分进行发布.
  • 重复消息检测-Eventuate Tram 消息消费者检测和丢弃重复消息, 这对于确保消费者只处理一次消息是至关重要的, 如 3.3.6 节描述的.

让我们看下 Eventuate Tram APIs.

基础的消息

基础的消息 API 有两个 Java 接口组成: MessageProducerMessageConsumer.生产者服务使用 MessageProducer 接口来发布消息到消息通道.下面是使用这个接口的示例:

1
2
3
4
MessageProducer messageProducer = ...;
String channel = ...;
String payload = ...;
messageProducer.send(destination, MessageBuilder.withPayload(payload).build())

消费者方服务使用 MessageConsumer 接口来订阅消息.

1
2
3
MessageConsumer messageConsumer;
messageConsumer.subscribe(subscriberId, Collections.singleton(destination),
message -> { ... })

MessageProducerMessageConsumer 是用于异步请求/响应和域事件发布的高级 APIs 的基础.

域事件发布

Eventuate Tram 有 APIs 用于发布和消费域事件.第 5 章解释了域事件是聚合(业务对象)事件, 在创建、更新或删除时发出.服务使用 DomainEventPublisher 接口来发布域事件.下面是一例子:

1
2
3
4
5
6
7
DomainEventPublisher domainEventPublisher;

String accountId = ...;

DomainEvent domainEvent = new AccountDebited(...);

domainEventPublisher.publish("Account", accountId, Collections.singletonList(domainEvent));

服务使用 DomainEventDispatcher 消费域事件.下面是一个示例:

1
2
3
4
5
6
DomainEventHandlers domainEventHandlers = DomainEventHandlersBuilder
.forAggregateType("Order")
.onEvent(AccountDebited.class, domainEvent -> { ... })
.build();

new DomainEventDispatcher("eventDispatcherId", domainEventHandlers, messageConsumer);

事件不是 Eventuate Tram 支持的唯一的高级别消息模式.它也支持基于命令/应答的消息.

基于命令/应答的消息

客户端可以使用 CommandProducer 接口来发送一条命令消息到服务.比如:

1
2
3
4
5
6
7
8
CommandProducer commandProducer = ...;

Map<String, String> extraMessageHeaders = Collections.emptyMap();

String commandId = commandProducer.send("CustomerCommandChannel",
new DoSomethingCommand(),
"ReplyToChannel",
extraMessageHeaders);

服务使用 CommandDispatcher 类来消费命令消息.CommandDispatcher 使用 MessageConsumer 接口来订阅指定的事件.它派发每条命令消息到对应的 handler 方法.下面是一个示例:

1
2
3
4
5
6
7
CommandHandlers commandHandlers = CommandHandlersBuilder
.fromChannel(commandChannel)
.onMessage(DoSomethingCommand.class, (command) -> { ... ; return withSuccess(); })
.build();

CommandDispatcher dispatcher = new CommandDispatcher("subscribeId",
commandHandlers, messageConsumer, messageProducer);

在本书中, 您将看到使用这些 API 发送和接收消息的代码示例.

如你所见, Eventuate Tram 框架为 Java 应用程序实现了事务消息.它提供了低级别的 API 用来事务性的发送和接收消息.它也提供了高级别的 APIs 用来发布、消费域事件和处理命令.

然我们看一个使用异步消息来提高可用性的服务设计方式.

使用异步消息提高可用性

如你所见, 不同的 IPC 机制有不同的权衡.其中一个特殊的权衡是你选择的 IPC 机制是如何影响可用性的.在这节, 你将学习到, 作为请求处理的一部分, 与其他服务的同步通信会降低应用程序的可用性.因此, 无论何时你应该使用异步消息来设计你的服务.

让我们首先看下同步通信的问题, 以及它是如何影响可用性的.

同步通信降低可用性

REST 是一种非常流行的 IPC 机制.你可能想将其用于服务间通信.不过 REST 的问题是它是一个同步协议: HTTP 客户端必须等待服务发送响应.无论何时服务使用同步通信, 应用程序的可用性都将降低.

为了了解为什么, 考虑图 3.15 的场景.订单服务 有一个 REST API 用于创建一个 订单.它调用 消费者服务餐厅服务 来验证 订单.这两个服务都有 REST APIs.

Figure 3.15-Synchronous communication reduces availability

创建订单的步骤如下:

  • 客户端发出 HTTP POST /orders 请求到 订单服务.
  • 订单服务 通过发出 HTTP GET /consumers/id 请求到 消费者服务 来查询消费者信息.
  • 订单服务 通过发出 HTTP GET /restaurant/id 请求到 餐厅服务 来查餐厅信息.
  • 订单接收服务(Order Taking) 使用消费者和餐厅信息来验证请求.
  • 订单接收服务 创建订单.
  • 订单接收服务 发送 HTTP 响应到客户端.

因为服务使用 HTTP, 所以它们对于 FTFO 应用程序来说必须同时可用来处理 创建订单(CreateOrder) 请求.如果三个服务中的其中一个宕机, FTGO 应用程序将不能创建订单.从数学上讲, 系统操作的可用性是该操作调用的服务的可用性的产物.如果 订单服务 和它调用的两个服务是 99.5 可用, 那么整体的可用性是 99.5% 3 = 98.5%, 这要少得多.参与处理请求的每个额外服务进一步降低了可用性.

这个问题不是特定于基于 REST 的通信.当服务只能在接收到来自另一个服务的响应后才对其客户端进行响应时, 可用性就会降低.即使服务通过异步消息使用请求/响应交互方式进行通信, 也存在此问题.比如, 订单服务 的可用性将降低如果它通过消息代理发送消息到 消费者服务 然后等待响应.

如果你想最大化可用性, 那你必须最小化同步通信的数量.我们来看看怎么做.

消除同步交互

在处理同步请求时, 有几种不同的方法可以减少与其他服务的同步通信.一种解决方案是通过定义只有异步 API 的服务来完全避免这个问题.但这并不总是可能的.例如, 公共 API 通常是 RESTful 的.因此, 服务有时需要具有同步 API.

幸运地是, 有几种方式处理同步请求而不用发出同步请求.让我们谈谈选择.

使用异步交互方式

理想情况下, 所有交互都应该使用本章前面描述的异步交互方式来完成.比如, 假设 FTGO 应用程序的客户端使用异步请求/异步响应交互方式来创建订单.客户端通过发送请求消息给 订单服务 来创建订单.这个服务然后与其他服务异步交换消息, 并最终发送应答消息给客户端.图 3.16 展示了这个设计.

Figure 3.16-The FTGO application has higher availability if its services communicate using asynchronous messaging

客户端和服务通过消息通道发送消息进行异步通信.在这种交互中, 没有任何参与者在等待响应时被阻塞.

这样的架构将具有极强的弹性, 因为消息代理缓冲消息直到它们可以被消费.然而, 问题是服务通常具有使用 REST 等同步协议的外部 API, 因此它必须立即响应请求.

如果服务有同步 API, 一种提高可用性的方式是复制数据.让我们看看这是如何工作的.

复制数据

在请求处理期间最小化同步请求的一种方法是复制数据.服务在处理请求时维护所需数据的副本.它通过订阅拥有数据的服务发布的事件来更新数据副本.例如,订单 可以维护 消费者服务餐厅服务 拥有的数据的副本.这将使 订单服务 能够处理创建订单的请求, 而不必与这些服务交互.图 3.17 显示了设计.

Figure 3.17-Order Service is self-contained because it has replicas of the consumer and restaurant data

消费者服务餐厅服务 在数据发生变化时发布事件.订单服务 订阅这些事件并更新其副本.

在某些情况下, 复制数据是一种有用的方法.例如, 第 5 章描述了 订单服务 如何复制 餐厅服务 中的数据, 以便验证和给菜单项定价.复制的一个缺点是有时需要复制大量数据, 这是低效的.例如, 订单服务 维护 消费者服务 拥有的数据的副本可能不实际, 因为有大量的使用者.复制的另一个缺点是它不能解决服务如何更新其他服务拥有的数据的问题.

解决这个问题的一种方法是服务延迟与其他服务的交互, 直到它响应其客户端.接下来我们来看看它是如何工作的.

响应返回后完成处理

对于服务处理请求而言在请求处理期间消除异步通信的另一种方式如下所述:

  • 只使用本地可用的数据验证请求.
  • 更新它的数据库, 包括插入消息到 OUTBOX 表中.
  • 返回响应到它的客户端.

当处理请求时, 服务不用与其它服务进行同步交互.相反, 它异步发送消息给其他服务.这种方式保证了服务是松耦合的.如你将在下一章所见, 这个通常使用 saga 实现.

比如, 如果 订单服务 使用这种方式, 它创建一个订单, 状态为 PENDING, 然后通过与其他服务异步交互消息来验证订单.图 3.18 展示了当 createOrder() 操作被调用的时候发生了什么.事件的顺序如下:

Figure 3.18-Order Service creates an order without invoking any other service

  • 订单服务 创建订单, 状态为 PENDING
  • 订单服务 返回响应给它的客户端, 包含了订单 ID.
  • 订单服务 发送 ValidateConsumerInfo 消息到 消费者服务.
  • 订单服务 发送 ValidateOrderDetails 消息到 餐厅服务.
  • 消费者服务 接收到 ValidateConsumerInfo 消息, 验证消费者是否可以下单, 并发送 ConsumerValidated 消息给 订单服务.
  • 餐厅服务 接收到 ValidateOrderDetails 消息, 验证菜单项, 餐厅是否可以配送到订单的地址, 并发送 OrderDetailsValidated 消息到 订单服务.
  • 订单服务 接收到 ConsumerValidatedOrderDetailsValidated 消息, 并更改订单的状态为 VALIDATED.

订单服务 可以按任意顺序接收 ConsumerValidatedOrderDetailsValidated 消息.它通过更改订单状态来跟踪它首先接收的消息.如果它先接收到了 ConsumerValidated, 它更改订单的状态为 CONSUMER_VALIDATED, 然而如果它首先接收到了 OrderDetailsValidated 消息, 它改变订单的状态为 ORDER_DETAILS_VALIDATED.订单服务 更改订单的状态为 VALIDATED 当它接收到其他消息.

在订单被验证后, 订单服务 完成订单创建过程的其余部分, 将在下一章讨论.这种方式的好处是, 比如, 即使 消费者服务 宕机了,订单服务仍然可以创建订单并向它的客户端响应.最终,消费者` 服务将会恢复, 并处理所有队列中的消息, 订单随后会被验证.

服务在完全处理请求之前进行响应的一个缺点是, 这会使客户端更加复杂.比如, 订单服务 在返回响应时, 对新创建的订单的状态做出最小程度的保证.它创建订单并立即返回在验证订单和授权消费者的信用卡之前.因此, 为了让客户端知道订单是否创建成功, 它必须定期轮询或订单服务必须向它发送通知消息.尽管听起来很复杂, 但在许多情况下这是首选的方法-特别是因为它还解决了我在下一章讨论的分布式事务管理问题.在第 4 章和第 5 章, 我将描述 订单服务 使用这种方式.

总结

  • 微服务架构是分布式架构, 所以进程间通信扮演了关键角色.
  • 仔细管理服务 API 的演变是非常重要的.向后兼容的更改是最容易实现的, 因为它们不会影响客户端.如果您对服务的 API 进行了重大更改, 通常需要同时支持新旧版本直到它的客户端已经升级.
  • 有多种 IPC 技术, 每种有不同的权衡.一个关键的设计决策是选择同步远程过程调用模式还是异步消息模式.基于同步远程过程调用的协议, 比如 REST 是最容易使用的.但是, 为了提高可用性, 服务最好使用异步消息进行通信.
  • 为了防止系统间的级联故障, 使用同步协议的服务客户端必须被设计成可以处理局部故障, 当被调用的服务出现故障或表现出高延迟的时候.特别是, 它必须使用超时当发出请求的时候, 限制未完成请求的数量, 并使用断路器模式来避免调用故障服务.
  • 使用同步协议的架构必须包含服务发现机制为了让客户端知道服务实例的网络地址.最简单的方式是使用部署平台实现的服务发现机制: 客户端发现和第三方的注册模式.另外一种方式是在应用层实现服务发现: 客户端发现和自注册模式.这需要更多的工作, 但是它确实处理了服务在多个部署平台上运行的场景.
  • 设计基于消息的架构的一种好的方式是使用消息和通道模型, 它抽象了底层消息系统的细节.然后, 你可以将该设计映射到特定的消息基础设施, 该基础设施通常基于消息代理.
  • 使用消息时的一个关键挑战是原子更新数据库和发布消息.一个好的解决方案是使用事务性发件箱(OUTBOX)模式, 首先将消息作为数据库事务的一部分写入数据库.然后, 使用轮询发布者模式或事务日志跟踪模式从数据库检索消息, 并将其发布到消息代理.
vscode github 图床插件 markdown-pic2github 改造