深入微服务架构内部进程间通信

jopen 9年前


[编者的话]这是采用微服务架构创建自己应用系列第三篇文章。第一篇介绍了微服务架构模式,和单体式模式进行了比较,并且讨论了使用微服务架构的优缺点。第二篇描述了采用微服务架构应用客户端之间如何采用API-Gateway方式进行通信。在这篇文章中,我们将讨论系统服务之间如何通信。

简介

在单体式应用中,各个模块之间通过编程语言级别的方法或者函数互相唤醒。相对的,一个基于微服务的应用是分布在多台设备上的应用,每个服务实例一般都是一个进程,因此,如下图所示,服务必须通过进程间通信(IPC)机制来交互。

后面我们将会看看特定的IPC技术,但是首先要看看各种设计上中的问题。

互操作模式

当为一个服务选择了一种IPC机制,首先需要考虑服务之间如何交互。C/S模式有很多交互模式,可以从两个维度进行归类,第一个维度是交互式一对一还是一对多:

• 一对一 – 每个客户端请求有一个服务实例来响应。

• 一对多– 每个客户端请求有多个服务实例来响应

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

• 同步模式:客户端请求需要服务端即时响应,甚至可能由于等待而阻塞

• 异步模式:客户端请求不会阻塞进程,服务端的响应不必是即时的

下表显示了不同交互模式:

一对一 一对多

同步 请求/响应 —

异步 请求 发布/订阅

异步响应 发布/异步响应

有如下几种一对一的交互模式

• 请求/响应: 一个客户端向服务器端发起请求,等待响应。客户端期望此响应即时到达。在一个基于线程的应用中,等待过程可能造成线程阻塞。

• 通知(也就是常说的 单向请求):一个客户端请求发到服务端,但是并不期望服务端响应。

• 请求/异步响应:客户端请求发到服务端,响应异步。客户端不会阻塞,而且被设计成默认响应不会立刻到达。

有如下几种一对多的交互模式

• 发布/ 订阅模式:客户端发布通知消息,客户被感兴趣的服务接收到。

• 发布/异步模式:客户端发布请求消息,然后等待从感兴趣服务发回的响应。

每个服务都是以上这些模式的组合,对某些服务,一个IPC机制就足够了;而对另外一些服务则需要多种IPC机制组合。下图展示了在一个打车服务请求中服务之间是如何交互信息的。

这个服务模式是通知,请求/响应和发布/订阅模式的组合。例如,乘客移动应用端给西城广利服务发送一个通知,请求一次出租服务。行程管理服务发送请求/响应交互模式给乘客服务确认乘客账号是有效的,然后创建此次行程,用发布/订阅交互模式通知其他服务,包括定位可用司机的调度服务。

我们了解了交互模式,下面我们来看看如何定义APIs.

定义APIs

API是服务端和客户端之间的合约。不管选择了什么IPC机制,重要的是使用某种交互式语言(IDL)来精确定义一个服务的API。甚至有一些关于使用API第一位的方法(API-first approach)来定义服务的很好的争论。一般都是先定义接口,然后与客户端开发者讨论之后,才开始服务端开发的。这种方式开发出的服务端,更加容易满足客户端请求。

在本文后面将会看到,API定义本质上依赖于IPC机制的选择。如果使用消息机制,API则由消息频道和消息类型构成;如果选择使用HTTP机制,API则由URL和请求、响应格式构成。后面将会详细描述IDLs。

APIs的演化

服务端API会不断变化。在一个单体式应用中经常会直接修改API,然后更新给所有的调用者。而在基于微服务架构应用中,这很困难,即使只有一个服务使用这个API,不可能强迫用户跟服务端保持更新同步。而且,可能会增量式部署新版服务,从而新旧服务同时在运行,必须要考虑如何应对这些场景。

如何处置API改变依赖于改变大小。某些改变是微小的而且和以前版本兼容。设计客户端和服务端时候应该遵循健壮性原理,这点很关键。客户端使用旧版API应该也能和新版本一起工作。服务端仍然提供默认响应值,客户端忽略此版本不需要的响应。使用IPC机制和消息格式对于API演化很重要。

但是有时候,API需要有些主版本升级,与以前版本不兼容。因为不可能强制所有客户端立刻都升级,因此支持老版本客户端的服务还需要在运行一段时间。如何使用基于HTTP机制的IPC,例如REST,可以采用的一个方法是把版本号嵌入到URL中。每个服务都可以同时处置多个版本,或者,可以部署不同的实例,每个处置不同的版本请求。

处置部分失效

再上一篇关于 API gateway的文章中,在一个分布式系统中部分失效是普片存在的问题。因为客户端和服务端是独立进程,一个服务端有可能因为故障或者维护而停止服务,或者此服务因为过载停止或者反应很慢。

考虑一个场景,如Product details scenario 文中描述的。假设推荐服务没有响应了,最幼稚的客户端实现会由于等待响应而阻塞,这不仅给客户带来很差的体验,而且在很多应用中还会占用很多资源,比如一个线程,以至于到最后由于等待响应被阻塞的客户端越来越多,线程资源被耗费完了。如下图所示:

为了预防这种问题,设计服务时候必须要考虑部分失效问题:

一个很好地方法可以参考 Netflix 案例,预防部分失效的策略包括:

• 网络超时:当等待响应时,不要无限期的阻塞,而是采用超时策略。使用超时确保资源不会无限期的占用。

• 限制请求的次数:对客户端对某特定服务设置一个访问请求上限。如果限制快到了,而且还要申请服务,就要立刻终止服务请求。

• Circuit breaker pattern:跟踪成功和失败请求的数量。如果失效率超过一个阈值,触发断路器使得后续的请求立刻失败。如果大量的请求失败,就可能是这个服务不可用,再发请求无意义。在一个失效期后,客户端可以再试,如果成功,关闭此断路器。

• 提供回滚:当一个请求失败后可以进行回滚逻辑。例如,返回缓存数据或者一个系统默认值。

Netflix Hystrix 是一个实现上述和其他模式的开源库。如果使用JVM,绝对应该考虑采用 Hystrix。而如果使用非JVM环境,则需要考虑同样的库实现。

IPC技术

有许多可用的IPC技术。服务端可以采用同步申请/响应通信机制,例如基于HTTP的REST或者Thrift;可选的,也可以采用异步的,基于消息的通信机制,例如AMQP或者STOMP;除此之外还有其它很多不同的消息格式。服务端可以使用易读的,基于字符的格式,例如JSON或者XML。除此之外,也可以采用二进制格式(更加有效),例如Avro或者Protocal Buffers。后面我们将讨论同步IPC机制,先来看看异步IPC机制。

异步的,基于消息通信

当使用基于异步交换消息的进程通信方式时,一个客户端通过向服务端发送消息提交请求。如果服务端需要回复,则发还另外一个独立的消息给客户端。因为通信是异步的,客户端不会因为等待而阻塞,相反,客户端可以理解响应不会立刻接收到。

一个message (消息)由头(元数据例如发送方)和一个消息体构成。消息通过 channels (渠道)发送,任何数量的生产者都可以发送消息到渠道,同样的,任何数量的消费者都可以从渠道中接受数据。有两类渠道,point to point (点对点)和 publish subscribe (发布-订阅)。一个点对点渠道会把消息准确的发送到某个从渠道读取消息的消费者,服务端使用点对点渠道来实现之前提到的一对一交互模式;而发布-订阅渠道则把消息投送到所有从渠道读取数据的消费者,服务端使用发布-订阅渠道来实现下面提到的一对多交互模式。

下图展示了打车软件如何使用发布-订阅渠道:

行程管理服务在发布-订阅渠道内创建一个新行程消息,来通知调度服务有一个新的行程请求,调度服务发现一个可用的司机然后想一个发布-订阅渠道写入司机建议消息(Driver Proposed message)来通知其他服务。

有很多消息系统可以选择,最好选择一种支持多编程语言的;一些消息系统支持标准协议,例如AMQP和STOMP。其他消息系统使用独有的协议,有大量开源消息系统可选,包括RabbitMQ, Apache Kafka,Apache ActiveMQ, 和 NSQ。他们都支持某种形式的消息和渠道,都是可靠的,高性能和可扩展的;然而,他们他们在消息模式(messaging model)方面确实完全不同的。

使用消息机制有很多优点:

• 将客户端和服务端解耦:客户端发送请求只需要将消息发送到正确的渠道。客户端完全不需要知道服务实例,不需要一个发现机制来决定服务实例的位置。

• Message Buffering:在一个同步请求/响应协议中,例如HTTP,所有的客户单和服务端必须在交互期间保持可用。相比较的,消息代理将所有写入渠道的消息按照队列方式管理,知道被消费者处理。也就意味着,例如,在线商店可以接受客户订单,即使下单系统很慢或者不可用,只要保持下单消息如队列就好了。

• 弹性客户端-服务端交互:消息机制支持以上说的所有交互模式。

• 直接进程间通信:基于RPC机制,试图唤醒远程服务看起来跟唤醒本地服务一样。然而,因为物理定律和部分失效可能性,他们实际上非常不同。消息使得这些不同非常明确,开发者不会出现问题。

然而,消息机制还是有缺点:

• 额外的操作复杂性:消息系统式额外被安装,配置和部署的模块。消息代理必须高可用,否则系统可靠性将会受到影响。

• 实现基于请求/响应交互模式的复杂性:请求/响应交互模式需要完成额外的工作。每个请求消息必须包含一个回复渠道ID和相关ID。服务端发送一个包含相关 ID的响应消息到渠道中,使用相关ID来将响应对应到发出请求的客户端。使用一个直接支持请求/响应的IPC机制更容易些。

下面我们来看看如何使用基于消息的IPC,我们检查一下基于请求/响应的IPC。

同步,请求/响应IPC

当使用一个基于同步,请求/响应的IPC机制,客户端向服务端发送一个请求,服务端处理请求,发还一个响应。很多客户端,由于等待服务端反馈而被阻塞,另外一些客户端可能使用异步,事件驱动的客户单代码(用Futures或者Rx Observables封装)。然而,不像使用消息机制,客户端认为响应会很及时。也有很多可选的协议,两个最常见的协议是REST和Thrift。

我们先看REST:

REST

今天用RESTful 风格开发应用是很时尚的事情。REST是一个(基本上全部)使用HTTP的IPC机制,一个关键概念,REST是一个资源,一般代表这一个商业对象,比如一个客户或者一个产品,或者一组商业对象。REST使用HTTP语言来修改资源,一般通过URL来实现。举个例子,GET请求返回一个资源的代表,一般采用XML文档或者JSON对象格式。POST请求创建一个新资源,PUT请求更新一个资源。

Roy Fielding, REST之父说:

“当需要提供一个整体的,强调模块交互可扩展性,接口概括性,组件部署独立性和减小延迟,提供安全性和封装性时,REST提供了一组满足需求的架构”

—Fielding, Architectural Styles and the Design of Network-based Software Architectures

下图展示了打车软件如何使用REST的一种方式。

乘客移动端请求一个行程,提交一个POST请求到行程管理服务的/trips资源,此服务处置这条请求,发送一个GET请求到乘客管理服务或者此乘客信息,确认乘客创建行程的权限后,行程管理服务创建此行程,返回一个201响应给乘客移动端。

许多开发者声称他们的基于HTTP的应用是RESTful的,如同Fielding在博客中描述的:blog post, 其实并不都是。Leonard Richardson 定义了,一个成熟RESTmaturity model for REST 模块包括如下构成元素:

• Level0:0级API客户端通过创建来激活服务

• HTTP POST请求到唯一URL服务点。每个请求都标注了动作,对象和参数。

• Level1:1级API支持资源的概念。对一个资源做某个动作,客户端发出包括动作和参数的POST请求

• Leve2:2级API使用HTTP动作:Get接收,POST创建,PUT更新。请求参数和数据体,标识动作参数。使得服务端能够整合web架构下资源,例如为GET使用缓存资源。

• Level3:3级API是基于HATEOAS(Hypertext As The Engine Of Application State)定律的。关键点是GET请求返回的资源代表中包含允许动作的链接。例如,一个客户端,使用GET返回中的链接,可以取消订单。 Benefits of HATEOAS 包括不用在客户端使用写死的URL。另外一个好处是客户不需要猜测针对某资源可以做什么操作,因为这些操作都会被以链接的方式返回。

使用基于HTTP的协议额还有很多其他好处:

• HTTP简单常见。

• 可是使用浏览器或者简单命令行就可以测试HTTP API

• 内置支持请求/响应模式通信

• HTTP是对防火墙友好的

• 不需要中间代理,简化了系统架构

不足之处包括:

• 只支持请求/响应模式交互。可以使用HTTP通知,但是服务端必须一致发送HTTP响应才行。

• 因为客户端和服务端直接通信(没有代理或者buffer机制),在交互期间必须都在线

• 客户端必须知道每个服务实例的URL。如previous article about the API gateway所述,还有一个不致命的问题,客户端必须使用服务实例发现机制。

开发者社区最近重新发现了RESTful APIs接口定义语言的价值。有一些选择,包括RAML 和Swagger. 一些IDL,例如Swagger允许定义请求和响应消息的格式。其他的,例如RAML,需要使用另外的标识,例如JSON Schema. 对于描述APIs,IDLs一般都有工具来定义客户端票根和服务端骨架接口。

Thrift

Apache Thrift 是一个很有趣的REST替代。它是一个跨语言的RPC客户端和服务端框架。Thrift提供了一个C风格的IDL定义APIs。使用Thrift编译器创建客户端票根和服务端骨架。编译器可以产生多种语言代码,包括C++, Java, Python, PHP, Ruby, Erlang, and Node.js.

Thrift接口包括一个或者多个服务。一个服务定义类似于一个JAVA接口,是一组方法。Thrift方法可以返回响应,也可以被定义为单向的。返回值的方法完成请求/响应类型的交互;客户端等待响应,并可能抛出一个意外。单向方法对应于通知类型交互;服务端并不返回响应。

Thrift支持多种消息格式:JSON,二进制和压缩二进制。二进制比JSON更加有效,因为二进制解码更快。同样原因,压缩二进制提供更多压缩效率。JSON,是易读的。Thrift也可以在裸TCP和HTTP中间选择,裸TCP看起来比HTTP更加有效。然而,HTTP对防火墙,浏览器和人来说更加友好。

消息格式

现在我们来看看HTTP和Thrift,先检查一下消息格式上的问题。如果使用消息系统或者REST,就可以选择消息格式。其它IPC机制,例如Thrift可能只支持以下部分消息格式,也许只有一种。无论哪种方式,使用跨语言消息格式很重要。即使今天使用单一语言写微服务,明天也需要其他语言。

有两类消息格式:文本和二进制。文根格式的例子包括JSON和XML。这种格式的优点在于不仅刻度,而且是自描述的。在JSON中,一个对象是一组键值对。类似的,在XML中,属性是定义属性名和值构成。这可以使得消费者从中提取感兴趣的值而忽略其它部分。接续的,对消息格式轻微改变可以很容易向后兼容。

XML文档结构被XML schema描述。随着时间发展,开发者社区意识到JSON也需要一个类似的机制。一个选择是使用JSON Schema,要么是独立的,要么是例如Swagger的IDL。

使用基于文本消息格式的不足在于消息可能是冗长的,特别是XML。因为消息是自描述的,每个消息都包含属性和值。另外一个不足在于分解文本的系统消耗。接续的,可能需要考虑使用二进制格式。

有一些二进制格式可选。如果使用Thrift RPC,可以使用二进制Thrift。如果选择消息格式,常用的还包括Protocol Buffers 和 Apache Avro. 它们都提供典型的IDL来定义消息架构。一个不同点在于Protocol Buffers使用tagged域,而Avro消费者需要知道刚要来解读消息。因此,用前者,API更容易演进。这篇博客blog post比较了 Thrift, Protocol Buffers, 和Avro.

总结

微服务必须使用进程间通信机制。当设计服务端如何通信是,必须考虑多种可能的问题:服务如何交互,每个服务如何标识API,如何演进API,以及如何处置部分失效。微服务架构有两类IPC机制可选,异步消息机制和同步请求/响应机制。下一篇文章中,我们将会讨论微服务架构中服务发现问题。