Dawn's Blogs

分享技术 记录成长

0%

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

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

交互方式

交互方式有两个维度来表示:一对一和一对多,同步和异步。

一对一 一对多
同步 请求/响应
异步 异步请求/响应;单向通知 发布/订阅;发布/异步响应
  • 一对一的交互方式:
    • 请求/响应:客户端发起请求,阻塞等待服务器的响应。这样的方式会导致服务的紧耦合。
    • 异步请求/响应:客户端发起,服务端异步响应,客户端在等待时不会阻塞。
    • 单向通知:客户端请求发送到服务端,不期望服务端做出任何响应。
  • 一对多的交互方式:
    • 发布/订阅:客户端发布通知消息,被零个或者多个服务端订阅。
    • 发布/异步响应:客户端发布请求消息,等待从感兴趣的服务发回的响应。

每个服务通常是上述交互方式的组合

服务 API

API 定义

在微服务架构中,使用接口定义语言(IDL)精确定义服务的 API 接口。

IDL 是客户端和服务端之间的契约,一个良好的 API 定义暴露有用功能的同时隐藏了内部实现的细节。

API 的演化

服务的 API 很少一成不变,它可能会随着时间的推移而发展。

在微服务架构中变更 API 是困难的,因为服务 API 的调用方无法控制 API 的实现方式,而 服务 API 的提供方无法控制客户端与服务端的版本保持一致。所以需要对服务 API 进行版本控制

语义化版本控制要求版本号由三部分组成:Major.Minor.Patch

  • Major:可能对之前的版本不兼容。
  • Minor:进行后向兼容的增强。
  • Patch:进行后向兼容的错误修复。

消息的格式

基于文本的消息格式

基于文本的消息格式,如 Json 和 XML。

这种消息格式的好处是:可读性高、后向兼容。而弊端在于消息冗长、传输效率低。

基于二进制的消息格式

基于二进制的消息格式,如 Protobuf,Thrift 和 Avro。

  • Protobuf:
    • 优点:时间和空间效率都不错,支持前向兼容(新加字段采用默认值)和后向兼容(忽略新加的字段),支持多语言。基于 HTTP 2.0。
    • 缺点:可读性差,不支持动态生成消息类型。
  • Thrift:
    • 优点:不仅支持二进制也支持文本类型的消息格式(支持多种序列化方式),支持多种传输协议,支持多语言,支持同步和异步通信。可以直接基于 TCP,省去了额外应用层开销。
    • 缺点:不支持动态生成消息类型。
  • Arvo:
    • 优点:在握手阶段交换模式定义(支持动态生成消息类型)。
    • 缺点:语言支持不丰富,在解析消息之前需要知道它的格式不支持后向兼容。

基于同步 RRC 的通信

同步 RPC 的调用原理如下,在客户端和服务的边界有远程过程调用代理接口(这个接口由远程过程调用代理适配器类实现)。客户端和服务之间的请求和响应是同步进行的。

image-20230518094946903

RESTful

RESTful 要求以 HTTP 动词的方式,对一个资源进行操作。在定义 RESTful API 时,可以采用 Open API 作为 IDL

RESTful API 设计的挑战在于如何将在业务对象上执行的操作映射到 HTTP 动词。比如更新订单,可以是取消订单、修改订单等多种方式去更新,而且更新也可能不是幂等的。

这个问题导致了 RESTful 的代替方案(如 gRPC)日益普及。

好处:

  • 简单,对 HTTP 协议非常熟悉
  • 可以使用 HTTP 的方式测试 API
  • 不需要中间代理,简化了系统结构

弊端:

  • 只支持请求/响应的方式进行通信
  • 可用性降低,客户端和服务端直接通信没有代理缓冲消息
  • 请求中获取多个资源困难(RESTful 只针对一种资源进行操作)
  • 难以将不同的操作映射到 HTTP 动词

gRPC

gRPC 是以 Protobuf 定义 IDL 的跨语言远程调用框架。除了支持简单的请求/响应 RPC 外,gRPC 还支持流式 RPC

优点:

  • 使用 protobuf 定义 API,设计 API 简单
  • 传输时间和空间效率较高
  • 支持流式 RPC
  • 支持 HTTP 2

弊端:

  • 编写客户端和服务端带来了额外的工作(复杂性)

相关技术

熔断器

熔断器(存在于在远程过程调用代理部分)用于处理局部故障,在对某个服务连续失败次数超过阈值的一段时间内,对这个服务的远程过程调用会直接返回错误,而不进行实际的远程过程调用

如果没有熔断器,因为服务无响应,那么在请求侧发起远程过程调用后会阻塞。当这样的调用多起来后,因为在调用侧的阻塞等待服务响应,会占用大量的资源。在远程过程调用的提供方,如果响应缓慢,那么因为请求的堆积所以也不会从响应缓慢中恢复过来。

为了防止局部故障在整个应用中的扩散,需要分为两部分来解决:

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

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

当一个服务同步调用另一个服务时,需要用以下方法的组合来保护自己。

  • 网络超时:在等待服务响应时,设置超时时间。使用超时可以保证不会一直等待无响应的请求,减少无意义的资源浪费。
  • 限制客户端向服务器发出请求的数量(限制服务端接收请求的数量):客户端应该设置发起请求的上限,当超过这个上限时可能会淹没服务端,应该让请求立刻失败。
  • 熔断器:监控客户端请求成功和失败的数量,当失败的比例超过阈值就启动熔断器,让一段时间内后续的调用立即失败。之后会熔断器会进入办关闭状态,观察客户端的请求是否调用成功,决定是否关闭或者开启熔断器。

从服务失效故障中恢复

还需要决定如何从无响应的远程服务中恢复自己的服务,有两种方法:

  • 向客户端返回错误:对于重要的数据,那么必须向客户端返回错误。
  • 返回备用值(fallback value):对于不重要的信息,返回备用值可能是有用的。备用值可以是默认值,也可以是缓存的响应

服务发现

远程过程调用的调用方需要知道服务提供方的地址,此时就需要服务发现。

在传统的应用程序中,服务实例的网络地址是静态的,所以可以从配置文件中读取网络位置。但是在微服务架构应用程序中,服务实例可能是动态分配的。

服务发现的关键组件就是服务注册表,它是包含服务实例网络位置的数据库。当服务实例启动和停止时都会更新服务注册表,当客户端调用服务时,会查询服务注册表获取可用的服务实例列表,从中选择一个并发起调用。

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

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

应用层服务发现模式

应用层服务发现就是,应用程序的服务及其客户端直接与服务注册表进行交互,比如基于 Etcd、Consul、Zookeeper 等的服务发现都是属于这个模式。包含以下两种模式:

  • 自注册:服务实例向服务注册表注册自己。服务实例还可以提供运行状况检查地址,服务注册表定期调用该地址来验证服务实例是否正常。服务注册表与服务实例之间还需要维护心跳,来保证注册不会过期。
  • 客户端发现:当客户端调用服务时会查询服务注册表,获取服务实例的列表。可能会缓存实例地址,使用负载均衡算法(如随机选择或者轮询)选择实例发起远程过程调用。

image-20230518103827173

这样的应用层服务发现的好处是:处理多平台部署问题(服务发现机制与具体部署平台无关)。可以在 Kubernetes 上部署一些服务,在遗留环境中运行剩余的服务。如果是基于 Kubernetes 的服务发现,智能用于部署在 k8s 上的服务。

弊端是:需要为每一种语言提供服务发现库,并且开发者设置和管理服务注册表。

平台层服务发现模式

部署平台如 Docker 和 Kubernetes 都有内置的服务发现机制。部署平台为每个服务提供 DNS 名称和虚拟 IP。客户端以服务 DNS 名称发起请求,部署平台会自动路由到一个可用的服务实例。包含以下两种模式:

  • 第三方注册模式:由第三方负责(注册服务器,部署平台的一部分)在服务注册表中注册,而不是服务直接向服务注册表中注册自己。
  • 服务端发现模式:客户端向路由器发起请求,路由器负责服务发现和负载均衡。

image-20230518103913606

这样的好处是:服务发现机制都交给部署平台去处理,服务和客户端不需要包含服务发现的代码;并且不论是哪种语言和框架都可以使用服务发现机制。

弊端在于:仅支持在平台上部署的服务。

基于异步消息模式的通信

使用消息机制时,服务之间的通信采用异步交换消息的方式完成。基于消息机制的异步通信方式有两种方法实现,消息代理(服务之间的中介)和无代理架构(直接向服务发送消息)。

image-20230518115252997

消息由消息头部和消息主体组成。头部是键值对,表示消息的元数据(如唯一的消息 ID,可选的返回地址或者返回的消息通道)。消息主体是以文本或者二进制的格式发送的数据。有以下不同类型的消息:

  • 文档式消息:仅包含数据的通用消息。
  • 命令式消息:一条等同于 RPC 请求的消息。指定要调用的操作及其参数。
  • 事件式消息:表示发送方发生了重要的事件。事件表示领域对象的状态变更。

无代理的消息机制

无代理架构的消息机制中,客户端与服务直接通信。有以下好处

  • 更低的时间延迟,消息直接从发送方到达接收方不用经过消息代理。
  • 消除了消息代理称为性能瓶颈或者单点故障的可能性。
  • 相对单,不需要设置和维护消息代理。

但是有以下弊端

  • 需要使用服务发现机制。
  • 可用性降低,在交换消息时必要确保发送方和接收方同时在线
  • 实现如确保消息投递成功这些复杂功能时的挑战更大。

消息代理的消息机制

消息代理是所有消息的中介节点,发送方将消息写入消息代理,消息代理将消息发送给接收方。消息代理可以看作是消息队列(MQ)

image-20230518121327244

使用消息代理的好处是:

  • 发送方不需要知道接收方的网络位置,不需要服务发现机制
  • 消息代理可以缓冲消息,直到接收方可以处理消息。
  • 消息机制可以实习所有的交互方式(请求/响应,异步请求/响应,单向通知,订阅/发布,发布/异步响应)。
  • 明确的进程间通信,基于 RPC 的机制看起来使得远程调用跟本地调用没什么区别,但是可能会出现物理定律(服务器不可预计的硬件失效)和局部故障,消息机制让远程调用和本地调用的差距更加明显。

弊端为:

  • 潜在的性能瓶颈单点故障。消息代理可能会出现性能瓶颈和单点故障。
  • 额外的复杂性。必须配置安装消息代理。

设计难题

基于消息的架构可能会遇到一些设计难题。

处理并发和消息顺序

在横向扩展多个实例的同时,如何保证消息的顺序。

保证消息的顺序在一些场景下是必须的,如消息发出的顺序是创建订单、修改订单、取消订单,如果消息的处理顺序是取消订单之后创建订单,会导致奇怪的行为发生。

一个解决方法是使用分片通道

  • 分片通道由多个分片组成,每一个分片都可以看成是一个通道。
  • 消息的发送方指定分片的键,消息代理通过分片键路由给特定的通道
  • 消息代理将每一个分片只分配给单独的接收器,这样在同一个分片上的消息就是可以保证消息顺序。

image-20230518140157982

处理重复的消息

理想情况下,消息应该只被传第一次,但是保证有且仅有一次的消息传递通常成本很高。所以,大多数消息代理承诺至少成功传递一次消息。处理重复消息由两种方法:

  • 编写幂等性消息处理方法。
  • 跟踪消息并丢弃重复项

幂等的消息处理

如果消息处理的逻辑是幂等的,那么重复的消息就是无害的。但是消息处理逻辑通常不是幂等的,或者说无法编写成幂等的逻辑。

跟踪消息并丢弃重复的消息

一个简单的方法是使用 message id 来跟跟踪已经处理过的消息,可以将 message id 记录在数据库表中。

也可以在应用程序表中跟踪消息,而不是专用的表记录 message id(这个方法后面会有)。

事务性消息

数据库的更新消息的发送必须在事务中进行。

服务可能会更新数据库,然后在发送消息之前崩溃。如果服务不以原子的方式执行这两个操作,则可能会导致系统处于不一致的状态。

在数据库和消息代理之间使用分布式事务可以解决,但是这不是一个很好的选择,很多消息代理并不支持分布式事务。

可以使用数据库表作为临时的消息队列,服务通过将消息插入到 OUTBOX 表中来发送消息。需要引入一个消息中继,用于读取 OUTBOX 表并且将消息发布到消息代理中。如何将消息从 OUTBOX 表移动到消息代理中,有两种不同的方法:

  • 通过轮询:消息中继不断地轮询未发布的消息(定期查表),将未发送的消息发送给消息通道。最后消息中继把完成的消息从 OUTBOX 表中删除。轮询的方法非常简单,但是定期的查表会导致数据库性能下降
  • 使用事务日志拖尾模式:让消息中继拖尾数据库日志文件,将对应于插入消息的日志转换为消息,并将消息发送到消息代理中。这种方法的效果很好。

image-20230518144252756

异步提高可用性

同步消息要求客户端和服务同时在线,这降低了可用性。异步消息可以提高可用性,理想的情况下,所有的交互都使用的异步交互:

image-20230518153202098

这样的架构非常具有弹性,但是很多情况下是不现实的,需要同步的对客户端的请求做出响应。

复制数据

在请求处理时减少同步请求的一种方法就是数据复制。服务维护一个数据副本,拥有了数据副本服务就可以避免去同步远程调用数据,而是直接在本地进行读取。这些数据副本的源头会在数据变化时发送消息,服务订阅这些消息来确保数据副本的实时更新

image-20230518154739008

但是复制数据的弊端在于,维护一个数据副本数据量巨大,导致效率低下。下面的方法可以解决这个问题。

先返回响应,再完成处理

在处理请求时,不需要与其他服务进行同步交互,而是仅仅使用本地的数据简单的完成验证后,直接向服务器返回响应。之后,服务异步的向其他服务发送消息

image-20230518163403219

这种方法的弊端在于使得客户端更复杂,为了知道后续的处理是否完成,必须定期的轮询或者服务向客户端通知消息