通信是微服务架构中的关键要素,人们广泛讨论的焦点是如何选择最有效的方法进行服务间交互。在这篇文章中,将探讨和总结微服务的最佳通信策略,深入探讨如何有效利用每种通信方式。
交互方式
要有效理解微服务架构中的服务通信方式,首先必须熟悉可用的交互方式。每种风格都有其独特的优缺点,在为服务确定最合适的通信机制的明智决定之前,全面了解这些细微差别至关重要。这些基础知识可确保所选方法完全符合系统的具体要求和挑战。
交互方式可以分为两个维度,第一个维度是一对一还是一对多的交互:
- 一对一(One-to-one) - 每个客户请求由一个服务处理。
- 一对多(One-to-many) - 每个请求由多个服务处理。
第二个维度是同步还是异步交互。
- 同步(Synchronous) - 客户端希望服务及时做出响应,甚至可能在等待时阻塞。
- 异步(Asynchronous) - 客户端不会阻塞,即使有响应,也不一定立即发送。
下表显示了不同维度的组合:
通信维度
下面分别简要介绍一下。
一对一交互:
- 请求/响应 - 客户端向服务端提出请求并等待响应。客户端希望响应能及时到达,甚至可能在等待时阻塞。这种交互方式通常会导致服务紧耦合。
- 异步请求/响应 - 客户端向服务端发送请求,服务端以异步方式回复。客户端在等待时不会阻塞,而服务端可能很长时间都不会发送响应。
- 单向通知 - 客户端向服务端发送请求,但不期望立即获得回复。
一对多交互:
- 发布/订阅 - 客户端发布一条通知消息,由零个或多个感兴趣的服务消费。
- 发布/同步响应 - 客户端发布请求信息,然后等待相关服务的响应。
记住,一种服务可以有多种通信方式!
使用同步远程过程(Remote Procedure Invocation)调用模式进行通信
客户端向服务端发送请求,服务处理请求并发回响应。有些客户端可能会阻塞等待响应,有些客户端则可能采用反应式非阻塞架构。但与使用消息传递不同的是,客户端假定响应会及时到达。
下图显示了 RPI 的工作原理。客户端中的业务逻辑会调用 PRI 代理适配器类实现的代理接口,RPI 代理向服务发出请求。
请求由 RPI 服务端适配器类处理,该类通过接口调用服务的业务逻辑,然后将回复发送给 RPI 代理,后者将结果返回给客户端的业务逻辑。
代理接口通常封装了底层通信协议。我们将重点介绍最流行的 REST 和 gRPC 协议。
REST API
REST 的关键概念是资源,通常代表单个业务对象(如客户或产品)或业务对象集合。REST 通过 HTTP 动词来操作资源,资源使用 URL 引用。例如,GET 请求返回资源的表示形式,通常是 XML 文档或 JSON 对象,也可以使用二进制等其他格式。POST 请求创建新资源,PUT 请求更新资源。
1.REST API 的挑战:
(1) 在一次请求中获取多个资源:
REST 资源通常以客户和订单等业务对象为重点,这给在一次请求中获取多个相关对象带来了挑战。例如,获取订单及其关联的客户通常需要多次 API 调用。常见的解决方法是增强应用程序接口,使客户端可以在一次调用中获取相关资源,例如使用带有扩展查询参数的 GET 请求来指定相关资源。虽然这种方法在很多情况下都很有效,但实施起来可能会很复杂、很耗时,这也是 GraphQL 等用于更简化数据检索的替代技术兴起的原因之一。
(2) 将操作映射到 HTTP 动词
一个值得注意的 REST API 设计挑战是如何将业务对象上的特定操作分配给正确的 HTTP 动词。例如,更新订单可能涉及取消或修改订单等各种操作,而且并非所有更新都必须是幂等的,而这正是使用 HTTP PUT 方法所必需的。一种常见的方法是为不同的更新操作创建子资源,例如使用 POST 取消订单(POST /orders/{orderId}/cancel )或修改订单(POST /orders/{orderId}/revise )。另一种方法是将操作作为 URL 查询参数。不过,这些方法可能并不完全符合 REST 原则。将操作映射到 HTTP 动词上的这种困难,促成了 gRPC 等替代技术的流行。
使用 REST 有很多好处:
- 使用简单,大部分工程师都比较熟悉。
- 可以在浏览器中使用 Postman 插件等工具测试 HTTP API,也可以在命令行中使用 curl 进行测试(假设使用的是 JSON 或其他文本格式)。
- 直接支持请求/响应式通信。
- HTTP 对防火墙是友好的。
- 不需要中间代理,从而简化了系统架构。
使用 REST 有一些缺点:
- 只支持请求/响应式通信。
- 可用性低。由于客户端和服务端直接通信,没有中间组件缓冲信息,因此在交互过程中,客户端和服务端必须同时运行。
- 客户端必须知道服务端实例的位置(URL)。在现代应用中,这是一个非同小可的问题。客户端必须使用所谓的服务发现机制来定位服务实例。
- 在一次请求中获取多个资源具有挑战性。
- 有时很难将多个更新操作映射到 HTTP 动词。
3.使用 gRPC
gRPC 提供了另一种选择,它使用基于二进制消息的协议,强调 API 优先的方法。gRPC 采用协议缓冲区(Protobuf),一种由谷歌开发的语言中立的序列化系统,允许开发人员在基于Protobuf的接口定义语言(IDL)中定义 API。gRPC API 在 HTTP/2 上运行,支持简单的请求/响应和流式 RPC,因此服务端可以向客户端发送消息流,反之亦然。该技术支持创建定义明确的服务接口和强类型方法,为处理微服务架构中各种复杂的通信模式提供了强大的框架。
gRPC 的优点和缺点
gRPC 有几个好处:
- 设计一个拥有丰富更新操作的应用程序接口非常简单。
- 具有高效、紧凑的 IPC 机制,尤其是在交换大型信息时。
- 双向数据流可实现 RPI 和消息传递两种通信方式。
- 可实现客户端与用多种语言编写的服务之间的互操作性。
gRPC 也有若干缺点:
- 与基于 REST/JSON 的应用程序接口相比,JavaScript 客户端在使用基于 gRPC 的应用程序接口时需要花费更多的时间。
- 老式防火墙可能不支持 HTTP/2。
- gRPC 是 REST 的一个令人信服的替代方案,但与 REST 一样,它也是一种同步通信机制,因此也存在部分失效的问题。
使用异步消息传递模式进行通信
使用消息传递时,服务通过异步消息进行通信。基于消息传递的应用程序通常使用消息代理,作为服务之间的中介。客户端通过发送消息向服务端发出请求,如果服务端实例需要回复,就会向客户端发送一条单独的消息。由于通信是异步的,客户端不会阻塞等待回复。相反,客户端在编写时假定不会立即收到回复。
1.消息传递概述
摘自 Gregor Hohpe 和 Bobby Woolf 合著的《企业集成模式》一书:
消息通过信道进行交互。发送方(应用程序或服务)向信道写入消息,接收方(应用程序或服务)从信道读取消息。我们先介绍下信息,再了解下信道。
2.关于消息
消息由消息头(header) 和消息体(message body) 组成。
消息头是描述发送数据的名-值对和元数据的集合。除了表示消息发送方的名-值对,消息头还包含其他名-值对,如由发送方或消息基础设施生成的唯一消息 ID,以及可选的返回地址,该地址指定了应写入响应的信道。消息体是以文本或二进制格式发送的数据。
有几种不同的消息:
- 文档(Document) - 仅包含数据的通用消息,由接收方决定如何解释。对命令的回复就是文档消息的一个例子。
- 命令(Command) - 相当于 RPC 请求的消息,指定了要调用的操作及其参数。
- 事件(Event) - 表示发件人发生了值得注意的事情的信息。事件通常是领域事件,表示领域对象(如订单或客户)的状态变化。
本文将主要介绍命令和事件。
3.关于信道
发送方的业务逻辑调用发送端接口,该接口封装了底层通信机制。发送端由消息发送适配器类实现,该适配器类通过信道向接收者发送消息。信道是消息传送基础架构的一个抽象概念。接收器中的消息处理适配器类被调用来处理消息,并调用由消费者业务逻辑实现的接收端接口。任何数量的发送者都可以向同一个信道发送消息。同样,任何数量的接收者都可以从同一个信道接收消息。
信道基础设施
了解两种信道非常重要:点对点(Point-To-Point) 和发布-订阅(Publish-Subscribe)。
- 点对点信道将消息准确发送给正从信道读取消息的消费者之一。服务使用点对点信道来实现前面介绍的一对一交互方式。例如,命令消息通常通过点对点信道发送。
- 发布-订阅信道将每条消息发送给所有订阅的消费者。服务使用发布-订阅信道来实现前面介绍的一对多交互方式。例如,事件消息通常通过发布-订阅信道发送。
既然我们已经清楚了解了异步通信,包括消息和信道的概念,那么接下来就应该探索异步通信框架提供的各种通信机制的实现。
4.实现请求/响应和异步请求/响应
当客户端和服务端使用请求/响应或异步请求/响应进行交互时,客户端发送请求,而服务端则返回响应。这两种交互方式的区别在于,使用请求/响应时,客户端希望服务立即做出响应,而使用异步请求/响应时则没有这种期望。消息传递本质上是异步的,因此只提供异步请求/响应。但客户端可以阻塞,直到收到响应为止。
客户端和服务端通过交换消息来实现异步请求/响应式交互。如图所示,客户端向服务端对应的点对点消息传递信道发送一条命令消息,其中指定了要执行的操作及其参数。服务处理请求并向客户端拥有的点对点信道发送包含结果的回复消息。
5.异步请求/响应
从上图可以看出,客户端必须告诉服务端将响应发送到哪儿,并且必须将响应与请求匹配起来。幸运的是,解决这两个问题并不难。客户端发送的命令报文带有回复信道头reply channel header。服务端将响应消息写入回复信道,响应消息包含与命令消息标识符具有相同值的关联 ID correlation id。客户端通过correlation id将响应消息与请求匹配。
由于客户端和服务端使用消息传递进行通信,因此本质上是异步交互。理论上,客户端可以阻塞直到收到响应,但实际上,客户端会异步处理。此外,响应通常由客户端的任意实例进行处理。
6.实现单向通知
使用异步消息传递可以直接实现单向通知。客户端向服务端对应的点对点信道发送消息,通常是命令消息。服务端订阅该信道并处理消息,但不发送回复。可以复用"异步请求/响应"相同的图示,但没有回复信道。
7.实现发布/订阅
客户端向发布-订阅信道发布消息,多个消费者可以读取该消息。服务通过发布/订阅来发布域事件,这些事件代表了对域对象的更改。发布域事件的服务拥有一个发布-订阅信道,该通道的名称源自域类。对特定域对象事件感兴趣的服务只需订阅相应的信道即可。
发布/订阅
8.实现发布/同步响应
发布/同步响应交互方式是一种更高级别的交互方式,通过结合发布/订阅和请求/响应的元素来实现。客户端向发布-订阅信道发布一条指定了回复信道头reply channel header的消息。消费者向回复信道写入包含correlation id的回复信息。客户端通过correlation id将回复消息与请求匹配起来。
应用程序中具有异步 API 的每个服务都将使用其中一种或多种实现技术。使用异步 API 调用操作的服务将有一个用于请求的消息信道。同样,发布事件的服务也会将事件发布到事件消息信道。
使用消息代理
基于消息传递的应用程序通常会使用消息代理(一种基础架构服务,服务通过它进行通信)。但基于代理的架构并不是唯一的消息传递架构,也可以使用无代理消息传递架构,在这种架构中,服务之间可以直接通信(本文将不涉及这一主题)。
1.基于代理的消息传递概述
消息代理是所有消息流动的中介。发送者将消息写入消息代理,然后由消息代理将消息发送给接收者。使用消息代理的主要好处是,发送者不需要知道消费者的网络位置。另一个好处是,消息代理可以缓冲消息,直到消费者能够处理。
有许多消息代理可供选择。流行的开源消息代理包括以下几种:
- ActiveMQ
- RabbitMQ
- Apache Kafka
每个代理都会做出不同的权衡。例如,延迟极低的代理可能不保留排序,不保证传递消息,只将消息存储在内存中;而保证传递消息并可靠的将消息存储在磁盘上的代理可能会有更高的延迟。
哪种消息代理最合适取决于应用程序的需求,应用程序的不同部分甚至可能有不同的消息传递需求。
2.使用消息代理实现消息信道
每个消息代理都以不同的方式实现消息信道概念。如表所示,JMS 消息代理(如 ActiveMQ)有队列和主题。基于 AMQP 的消息代理(如 RabbitMQ)有交换和队列。Apache Kafka 有主题,AWS Kinesis 有流,AWS SQS 有队列。 此外,一些消息代理提供比本章所述消息和信道抽象更灵活的消息传递机制。
消息代理列表
这里介绍的几乎所有消息代理都支持点对点和发布-订阅信道。AWS SQS 是个例外,它只支持点对点信道。
消息代理的问题
1.接收竞争和消息排序
挑战之一保持消息有序的同时扩展消息接收器。为了并发处理消息,通常需要多个服务实例。此外,即使是单个服务实例也可能会使用线程来并发处理多个消息。使用多线程和多服务实例并发处理消息可以提高应用程序吞吐量,但并发处理消息的挑战在于确保每条消息都能按顺序处理。
例如,假设有三个服务实例从同一个点对点信道读取消息,发送方按顺序发布 "创建订单"、"更新订单"和"取消订单"事件消息。简单的消息传递实现可以同时将每条消息传递给不同的接收方。由于网络问题或垃圾回收导致的延迟,消息的处理顺序可能会被打乱,从而导致奇怪的行为。理论上,一个服务实例可能会在另一个服务处理"订单创建"消息之前处理"订单取消"消息!
Apache Kafka 和 AWS Kinesis 等现代消息代理常用的解决方案是使用分片(分区)信道,下图显示了其工作原理。该解决方案分为三个部分:
- 分片信道由两个或多个分片组成,每个分片的行为都和信道一样。
- 发送者在信息头中指定分片key,通常是任意字符串或字节序列。消息代理通过分片key将信息分配给特定分片/分区。例如,可以通过计算分片key的哈希值乘以分片数来选择分片。
- 消息代理将接收器的多个实例分组,并将它们视为同一个逻辑接收器。例如,Apache Kafka 使用消费者组(consumer group)一词。消息代理将每个分片分配给一个接收器。当接收器启动和关闭时,会重新分配分片。
分片信道架构
在本例中,每个订单事件消息都以orderId作为其分片key。特定订单的每个事件都发布到同一个分片,由单个消费者实例读取。因此,可以保证这些消息按顺序处理。
2.处理重复消息
使用消息传递时必须解决的另一个难题是处理重复消息。理想情况下,消息代理应该每条消息只传递一次(exactly-once),但保证准确传递一次消息通常成本太高。相反,大多数消息代理都承诺至少传递一次消息(at-least-once)。
当系统正常运行时,保证至少交付一次的消息代理只交付一次消息。然而,客户端、网络或消息代理的故障可能导致消息被传送多次。假设一个客户端处理了消息并更新了数据库,但在发送确认消息之前崩溃了。消息代理将再次传送未确认的消息,要么在该客户端重启时传送给它,要么传送给该客户端的另一个副本。
理想情况下,应该使用在重新传递消息时保留排序的消息代理。
假设客户端在处理"创建订单"事件后,又处理了同一订单的"取消订单"事件,而 "创建订单"事件没有得到确认。消息代理应同时重新传递"创建订单"和"取消订单"事件。如果只重新传递"创建订单"事件,客户机可能会取消订单。
有几种不同的方法可以处理重复信息:
- 编写幂等消息处理程序。
- 跟踪并丢弃重复消息。
我们来简单了解一下每种方案。
3.编写幂等消息处理程序
如果处理消息的应用逻辑是幂等的,那么重复报文是无害的。如果用相同的输入值多次调用应用逻辑不会产生额外的效果,那么应用逻辑就是幂等的。例如,取消一个已经取消的订单就是一个幂等操作。使用客户提供的 ID 创建订单也是如此。
只要消息代理在重新传递消息时保留排序,幂等消息处理程序就可以安全执行多次。
遗憾的是,应用逻辑往往不是等效的。或者消息代理在重新传递消息时不保留顺序,重复或失序的消息可能会导致错误。在这种情况下,就必须自己编写消息处理程序来跟踪消息并丢弃重复消息。
4.跟踪并丢弃重复消息
例如,考虑一个对消费者信用卡进行鉴权的消息处理程序,必须对每张订单的信用卡精确鉴权一次。这个应用程序逻辑示例每次调用都会产生不同的效果。如果重复的消息导致消息处理程序多次执行这一逻辑,应用程序的行为就会不正确。执行这种应用程序逻辑的消息处理程序必须通过检测和丢弃重复消息来独立实现。一个简单的解决方案是,消息消费者使用消息 ID 跟踪已处理的消息,并丢弃任何重复消息。举例来说,可以在数据库表中存储它处理过的每条消息的ID。下图显示了如何使用专用表来实现这一功能。
跟踪消息流
消费者处理消息时,会在数据库表中记录message id,作为创建和更新业务实体的事务的一部分。在本例中,消费者向 PROCESSED_MESSAGES 表中插入一条包含message id的记录。如果是重复消息,则 INSERT 将失败,用户可以丢弃该消息。
另一种方法是让消息处理程序在应用程序表而不是专用表中记录message id。这种方法在使用 NoSQL 数据库时特别有用,因为 NoSQL 数据库的事务模型有限,不支持将更新两个表作为数据库事务的一部分。
结论
总之,微服务架构中通信方式的选择对于应用程序的整体效率和可扩展性至关重要。在本文中,我们探讨了从同步调用到异步消息传递等各种通信机制,每种机制都有其独特的优势和合适的使用场景。正如我们所看到的,正确的通信策略不仅能提高性能,还能确保服务交互的弹性和灵活性。
在选择通信方式时,必须考虑服务交互的性质、对实时数据的需求以及所涉及服务的复杂性等因素。请记住,我们的目标是建立一个强大架构,使其能够随着组织需求和技术进步而发展。