zhaoyu@home:~$

微服务设计模式-微服务之间的通信

目前有多种多样的程序间的通信技术,如基于HTTP的REST和gRPC;基于消息的通信,AMQP或者STOMP;还有人类可读的JSON或者XML; 以及效率更高的Avro或者Protocol Buffer。

微服务内部通信概述

服务器和客户端交互风格

这些多种多样的服务器交互方式,可以从两个维度考虑,一个是“一对一”和“一对多”。

  • 一对一:一个客户端请求被一个服务处理。
  • 一对多,一个客户端请求被多个服务器处理。

第二个维度是同步和异步。

  • 同步:客户端发出请求,并阻塞等待服务返回。
  • 异步:客户端不阻塞,响应不必立即返回。

两个维度示例图如下:

  • Request/response:阻塞等待导致服务之间紧密耦合。
  • Asynchronous request/response:异步让客户端不必阻塞等待。
  • One-way notifications:客户端发起一个请求,但是server不响应。 一对多的交互方式有两种:
  • Publish/subscribe:一个客户端发布一个通知消息,可以被零或多个服务消费。
  • Publish/async responses:一个客户端发布一个消息,等待一段时间从需要的多个service获得响应。

API设计

API是服务和客户端之间的合约。由操作和事件组成。API优先设计是根本,有太多后 端和前端各自完成任务,但是软件却不能运行的例子。

API进化

在分布式系统中,一个服务通常是另一个服务的客户端,被不同的团队开发,所以很难做到所有的客户端统一升级。通常一个服务的 老版本和新版本同时在运行。

所以需要使用版本号来定义服务,The Semantic Versioning specification (http://semver.org)是对于版本化API非常适用。 由Major.Minor.Patch三部分组成:

  • MAJOR:当你对API做出不兼容的变更时。
  • MINOR:当你对API做出向后兼容的增强时。
  • PATCH:当你对API做出向后兼容修复一个bug时。

Minor向后兼容的变更如:

  1. 对请求增加可选属性。
  2. 对返回增加属性。
  3. 增加新的操作。

Major变更,有时,我们必须做出一些不兼容的变更,一个service可能同时支持一个API老的版本和新的版本,如在REST中,我们可以 使用‘/v1/…’和‘/v2/…’表示不同的Major版本。

消息格式

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

文本格式

如Json或者XML,文本格式不仅人类可读,而且能够自我描述(如XML schema)。可以只挑选需要的信息,忽略其他信息,所以,对消息 的改变可以轻松做到向后兼容。 但是文本格式也特别冗长,尤其是XML,并且增大了解析的消耗,特别是消息非常大的时候。

二进制格式

有名的二进制格式有Protocol Buffer和Avro。两种格式都提供了一种IDL(接口定义语言)描述消息的结构。编译器可以生成序列化 和反序列化的消息。你可以很方便地应用API优先的设计方式。如果使用静态语言,编译器还可以检查是否正确使用API。

Protocol Buffer和Avro的不同是,Protocol Buffer使用标记字段。而Avro需要知道schema才能解析消息。

同步远程调用通信

上图描述了RPI(Remote Procedure Invocation)远程过程调用的步骤,客户端业务调用一个代理接口,该代理接口由RPI代理适配器 实现,该RPI代理再请求server,服务端使用一个RPI服务适配器处理请求,RPI服务再通过接口调用服务器的业务逻辑。请求完成后 给RPI代理返回响应。

代理接口一般会封装底层的通信协议,如REST和gRPC。

使用REST

REST的一个核心概念是resource,通常代表一个业务对象,如客户或者产品。使用HTTP提供的方法操作resource,如POST /orders创建 一个订单,GET /orders/{orderId}获取一个订单。

REST通常有4种等级:

  • 服务之间的调用通过POST,每个请求指定执行的动作,和动作的目标如:POST /addUser。
  • 支持资源的概念,对一个资源执行一个动作。客户端用Post请求指定执行的动作和参数。如:POST /user/add。
  • 使用HTTP方法执行动作。get获取资源,POST创建,PUT更新。请求会带上参数。如: GET /orders/{orderId}
  • HATEOAS(Hypertext As The Engine Of Application State)使用一个GET请求返回资源操作连接的列表。
挑战:一个单一请求获取多个资源的

REST是面向资源的,设计面临的一个挑战就是如何在一个请求中获取多个资源。如,一个请求想要获取一个订单,和下单用户。纯粹 的REST可能会分两个请求来获取。一个获取订单,另一个获取用户,如果这种场景复杂,那么会导致更多的网络请求,从而产生网络 延迟。

一种解决方法就是让客户端可以获取关联的资源,如同时获取订单和下单用户可以使用:GET /orders/order-id-1345?expand=consumer。 通常情况下,这是很有效的方法,但是很难处理更加复杂的情况,需要花费大量的时间来实现,这就催生了GraphQL和Netflix Falcor 等技术的产生。

挑战:将操作映射到HTTP方法

REST使用PUT执行更新操作,但是对一个订单来说,有很多更新操作,取消,修改等等。可以定义一个子资源说明不同的操作, 如POST /orders/{orderId}/cancel用来取消订单,POST /orders/{orderId}/revise用来修改订单。另一种方案是将操作指定为一个 请求参数。但是这两种方案都是特殊的RESTful。

操作映射的困难,催生出gRPC等技术。

好处和坏处

REST的一些好处如下:

  • 简单
  • 可以使用浏览器测试。
  • 防火墙友好。
  • 不需要中间代理,简化了系统架构。

REST的一些缺点如下:

  • 只支持request/response通信风格。
  • 没有中间代理缓存消息,可用性降低。
  • 客户端必须知道服务的URL,客户端必须使用服务发现机制定位服务实例。
  • 单个请求获取多个资源是个挑战。
  • 有时候很难将多个更新操作映射到HTTP方法。

使用gPRC

gPRC解决了REST很难将update操作映射到HTTP方法的难题。

gRPC是一中基于消息的二进制协议,这也就意味着,你必须使用API优先原则设计服务。我们需要使用Protocol Buffers-based IDL 定义gRPC API。

protocol Buffer是google定义的跨语言的数据序列化格式,使用Protocol Buffer 生产客户端stub和服务端skeleton。编译器可以 为各种语言生产代码,client和server在Protocol Buffer中使用HTTP/2传输二进制消息。

gRPC支持消息的流传输,client和server可以传输一组消息。在protocl Buffer中,每个消息的字段被编号,并有一个code。所以, 可以抽出需要的字段,而忽略不认识的字段,这样gRPC就可以让API做到向后兼容。

好处和会坏处

gPRC有如下优点:

  • 让API拥有多种多样的更新操作。
  • 高效,尤其是在交换较大消息的时候。
  • 客户端和服务器可以使用多种多样的语言,跨语言。

gPRC也有一些缺点:

  • javaScript client消费gRPC消息相比REST需要做更多的工作。
  • 老的防火墙可能不支持HTTP2。

使用熔断器模式处理局部失败

在分布式系统中,局部失败可能会级联到整个应用。处理不好不仅对用户造成很坏的体验,还会消耗过多的系统资源。所以处理局部失败 非常重要,可以从两个方面考虑解决:

  1. 必须设计一个RPI(remote procedure invocation)代理,如OrderServiceProxy,处理无响应的远程服务。
  2. 如何从失败的远程调用中恢复。
设计一个健壮的PRI代理

设计一个健壮的PRI代理,有以下几个要素:

  1. 设置一个合理的网络超时。
  2. 设置一个合理的重试次数。
  3. 使用熔断器:追踪失败和成功的请求数,如果失败率超过阈值,启用熔断,快速失败。大规模的请求失败,再发送请求是无意义的, 认为请求不可用。经过一定的超时时间后,客户端应该重新发送请求,如果成功,关闭熔断器。
从失败的服务恢复

使用熔断器只是解决快速失败解决方案的一部分,还可以让请求快速失败,如在创建订单失败后,让API网关直接返回一个错误。

在其他场景下,返回一个fallback。如一个默认值或者一个缓存。一些不太关键的数据, API网关可以使用缓存或者直接不返回。

服务发现

服务发现有应用级别和平台级别,我们常用的如Eureka,好处是可以处理多类型的部署平台,如一些实例在Kubernate上,而另一些在 tomcat,Eureka可以在跨这两种环境的情况下工作,但是应用级别的服务发现不能跨语言,如Eureka只能工作在java语言的应用中。

平台级别的服务发现就可以跨语言。如Docker和k8s有内建的服务注册和服务发现机制。

异步消息调用通信

关于消息

一个消息通常由一个header和一个body组成。可分为如下几种:

  • Document——只包含数据的消息,接收者决定如何翻译它,一个Command格式的消息的响应,一般是一个Document。
  • Command——相当于一个RPC请求,指定了操作方法和它使用的参数。
  • Event——表示发送端发生了改变,通常是一个领域事件,表示一个领域对象的状态发生了改变,如Order,Customer。

异步通信过程

异步通信的大概过程如下:

message handler是中适配器,用于将Message channel消息转换为程序能够处理的对象。

Message channel是对消息组件的抽象,一般分为两种:

  • 点对点channel,一个消息只能被一个指定的消费者消费,Command消息通常使用点对点channel发送。
  • 发布订阅channel,每个消息可以被所有的消费者消费,需要一对多交互的服务通常使用发布订阅channel。

接收者竞争和消息排序

消息中间件的一个挑战是,在有多个接收者的情况下,如何保持消息的顺序,如,有三个接收者处理消息,一个发布者按顺序发布了 订单创建,订单修改,和订单取消,三个事件消息。那么这个三个消息可能会被发送给不同的消费者处理,理论上,订单取消应该在 订单创建之后,由于网络等原因,消息处理的顺序可能发生改变,导致不可预料的结果产生,

kafka使用的解决方法是使用(sharded/partitioned)channel。为每一个Receiver分配一个分片,sender在发送消息时,会为每个 消息生产一个shard key(随机顺序产生),kafka中间件按照shard key将消息划分到某个shard中,这个shard里的数据会发送给 同一个receiver。当receiver重启或者关闭,kafka 中间件又会重新分配shard。其示例图如下:

处理重复消息

kafka等消息中间件,在使用过程中,由于网络等原因,可能存在消息重复发送的情况,有两种途径解决消息重复发送。

  • 消息处理幂等。
  • 追踪消息,丢弃重复。

追踪消息可以将id记录为一张单独的表,启用事务,也可以和业务表记录在一起(适用于Nosql)。

处理事务型消息

服务通常在更新数据库的时候将发消息作为事务的一部分,传统的解决方法是使用跨消息中间件和数据的分布式事务。但是,分布式事务 十分消耗性能,并不是一个好的选择,kafka等流行的消息中间件不支持分布式事务。可以使用如下的方案代替。

OUTBOX模式

如上图所示,服务会将事务写入一个OUTBOX表中,消息中继器(Message relay)会读取OUTBOX表,并将消息发送被消息中间件。

也可以在Nosql中使用,消息发送会作为数据库记录的一部分。

将消息从数据库移动到消息中间件有多种方式:

  • 轮询发布模式

    轮询OUTBOX表,定期查询并发送给消息中间件,发送成功后,删除。该模式简单,但是轮询数据库十分消耗资源。Nosql虽然查询 效率高,但是相比OUTBOX,每次都要拉取整条记录。效率也不一定高。

  • 事务日志模式

    一个更优的解决方案是消息中继器读取数据库的事务日志(也称为提交日志)。

事务日志读取器(Transaction Log Miner)会读取事务日志,将消息插入的实体转化为消息,并发送给消息中间件。