本文出自Nginx官网,是微服务介绍系列文章的第三篇。原文地址:https://www.nginx.com/blog/building-microservices-inter-process-communication/
1.介绍
在第一篇文章中我们比较了微服务架构的应用和单体应用的差异,讨论了微服务架构的优点与缺点;第二篇文章中讨论了客户端如何使用API网关跟微服务通信;在本篇文章中我们讨论微服务之间的通信机制;在第四篇文章中我们将讨论服务发现机制。
在单体应用中,应用的各个模块使用语言级的方法调用通信;微服务架构的应用是分布式系统,服务运行在多台物理服务器上,每个服务都是一个进程,需要使用进程间通信机制,详细见下图:
接下来,我们先讨论进程间通信需要考虑的设计问题,再详细讨论进程间通信的主流技术。
2.进程间通信的方式
选择进程间通信机制,首先要确定微服务的交互方式,交互方式有很多种,可以从两个维度来分类,一个维度是按照服务端是一个还是多个来分类:
一对一:客户端的请求只被一个特定的服务端实例处理。
一对多:请求被多个服务端实例处理。
另外一个维度是按照交互是异步还是同步进行分类:
同步调用:客户端希望立即获得响应,甚至会在等待响应期间阻塞。
异步调用:响应不一定立即发出,客户端在等待响应的过程中不会阻塞。
下面的表格展示了不同的进程间通信方式:
一对一交互分为以下几类:
请求/响应模式:客户端向服务器发送请求,等待响应;客户端期望响应能及时到达,在等待过程中客户端可能阻塞。
通知模式(也叫单向请求):客户端向服务器发送请求,不期望有响应。
请求/异步响应模式:客户端向服务器发送请求,等待响应;客户端认定响应不会立即达到,客户端不会阻塞。
一对多交互分为以下几类:
发布/订阅模式:客户端发布通知消息,零个或者多个服务消费该消息。
发布/异步响应模式:客户端发布请求消息,在一定时间内等待服务端响应。
服务一般都会组合应用以上多种交互方式。对于一些服务而言,单一的进程间通信机制就能满足需求;对于另外一些服务,可能需要组合多种通信机制才能满足需求。以下图示展示了当用户发布行程时,叫车应用的服务如何交互:
图中应用了通知模式、请求/响应模式、发布/订阅模式等多种方式:用户使用智能手机向行程管理服务发布新行程请求通知;行程管理服务使用请求/响应模式调用乘客管理服务来验证用户账号;验证通过后,行程管理服务创建行程并使用发布/订阅模式将新的行程信息通知到分发服务(基于地理位置寻找潜在司机)。接下来,我们讨论服务接口的定义。
3. 接口定义
服务接口是服务和客户端之间的契约,无论你使用哪种进程间通信机制,使用某一种接口定义语言精确定义接口都非常重要。使用接口优先的方法开发服务是个聪明的选择,通过跟客户端开发人员讨论接口定义,对接口进行迭代之后再开始服务的开发工作会让你的服务更有可能满足需求。
服务接口的定义取决于你使用哪一种进程间通信机制:如果使用消息机制,接口定义可能包含消息通道和消息类型;如果使用HTTP机制,接口定义可能包含URL以及请求和响应的消息格式。
4.接口的变化
服务接口会随着时间不断变化,当接口变化时,在单体应用中,直接更新接口和所有调用者就可以;在微服务架构下,即使所有调用者都是应用内的其他服务,更新接口和所有调用者也是件困难的事情。你无法强制所有客户端和服务端同步升级,因此,服务的新旧版本往往同时存在,你需要采取策略应对这种情况。
如何应对接口变化取决于变化的大小。有些变化比较小,能兼容之前的版本。比如说,请求和响应中可能会增加一些属性。这种情况下,客户端和服务端遵从健壮性设计就很有用,能保证实现旧接口的客户端在新接口下依然能工作:服务端为不存在的属性设置默认值,客户端忽略新增加的属性。考虑到系统健壮性,选用合适的进程间通信机制、使用支持变化的消息格式很重要。
有时候接口必须进行较大的变化,不能向前兼容。既然无法强制客户端和服务端同时更新,在一定时期内就需要保证新旧接口都能使用。如果你使用基于HTTP的通信机制(REST),一种处理方式是在请求URL中加上服务版本号,这样,每个服务实例就能同时处理多个版本的请求;另外一种方式是部署多个版本的服务实例,每一种服务实例处理特定版本的请求。
5.处理部分失败
就像在第二篇文章中描述的,分布式系统中存在部分失败的风险。既然客户端和服务端都是独立的进程,服务端就可能无法及时响应。服务端有可能因为维护升级而关闭,也有可能由于过载而导致响应缓慢。
还是以商品详情的页面为例,假设智能推荐的服务无响应,简单的客户端实现可能会一直阻塞等待响应,这不但会带来糟糕的用户体验,还会消耗宝贵的线程资源,最终可能会消耗掉运行环境的所有线程资源使得整个应用宕掉。以下是线程资源消耗光的图示:
为了阻止此类问题发生,必须要针对部分失败进行相应设计。Netflix找到了好办法来应对部分失败,它的策略主要包括:
网络超时:等待响应时用超时机制替代阻塞机制,避免资源的无期限占用。
限定未完成的请求数:限定客户端对同一个服务的最大未完成请求数;如果达到最大值,后续的请求会立即返回失败。
断路器模式:跟踪成功和失败的请求数,如果请求失败的比率超过阈值,断路器会打开,后续的请求会立即返回失败。如果大量的请求都失败,预示着服务不可用,因此后续的服务请求无意义;经过一个超时周期后,客户端会再次尝试访问服务,请求如果成功,则关闭断路器。
提供回调:当请求失败时,执行回调逻辑,返回缓存数据或默认数据。
NetflixHystrix是一个开源代码库,它实现了以上策略,如果你的应用使用JVM,应该考虑使用Hystrix;如果没有JVM,也需要考虑使用类似的代码库。
6.进程间通信技术
有许多进程间通信的技术可供选择。可以使用像REST或者Thrift之类的请求/响应同步模式,也可以使用类似AMQP或者STOMP之类基于消息的异步模式。消息格式也有很多种,有容易理解的基于文本的JSON和XML;也有更高效的二进制的Avro和Protocol Buffers。我们先讨论异步机制再讨论同步机制。
异步的基于消息的通信机制
当使用基于消息的异步通信机制时,客户端通过发送消息的形式发送请求,如果服务端需要响应,也发送消息给客户端。既然是异步通信,客户端就不用阻塞等待响应。实际上,在异步机制下,客户端认定响应不会立即到达。
消息包括消息头和消息体,消息在通道中传递,可以由任意多个生产者向通道发送消息;类似的也可以有任意多个消费者从通道上接收消息。有两种类型的通道,一种是点对点,一种是发布/订阅。点对点模式下,通道将消息分发给某个特定的消费者,之前描述的一对一交互可以使用该模式;发布/订阅模式下,通道将消息分发给所有监听该通道的消费者,一对多交互可以使用该模式。
下面图示展示了叫车应用中如何使用发布/订阅通道:
行程管理服务通过向通道写新的行程消息通知其他相关服务;分发服务定位到可用的驾驶员,通过向发布/订阅通道写一条可用司机的消息通知其他相关服务。
有许多种消息系统可以选择,为了以后扩展方便,要优先选择支持多语言的消息系统。一些消息系统支持标准的协议,像AMQP和STOMP;另外一些使用专用协议。有很多开源的消息系统,像RabbitMQ、Apache Kafka、Apache ActiveMQ、NSQ等。从更高层次上来说,这些消息系统都支持一些消息格式和通道,都致力于实现可靠的、高性能的、可伸缩的消息传递,然而在消息模型的实现细节上还是有很大差异。
使用消息机制通信有很多好处:
实现客户端和服务端解耦:客户端通过向消息中间件的通道发送消息实现服务请求,完全不用考虑服务端,不需要使用服务发现机制确定服务端地址。
消息缓存:同步的请求/响应协议(如REST),在信息交换期间,服务端和客户端必须都可用;在基于消息的异步通信机制下,消息中间件会在队列中缓存消息,一直到有消费者消费为止。这就像一家在线商店,虽然订单处理系统很慢甚至有时候不可用,但不耽误顾客下单子,订单会被先缓存下来。
灵活的客户端/服务端交互:使用消息机制通信支持上面提到所有交互方式。
明确的进程间通信:基于RPC的通信机制允许客户端像调用本地服务一样调用远程服务;由于分布式系统自身的特性和部分失败的存在,远程调用和本地调用差别很大。使用消息机制通信使得本地调用和远程调用的差异明显化,促使开发人员充分考虑远程调用的各种问题。
当然,基于消息通信的机制也有缺点:
增加额外复杂度:必须安装、配置和部署消息系统,并且消息中间件必须是高可靠的,否则应用的可靠性就会受到影响。
实现请求/响应模式比较复杂:每个请求消息中必须包含消息标识和响应消息的通道标识符,携带消息标识的响应消息被写到响应通道中,客户端接收响应消息后根据请求标识实现请求消息和响应消息的匹配。如果使用其他直接支持请求/响应模式的进程间通信机制,这个过程就简单多了。
接下来讨论基于请求/响应模式的进程间通信机制。
同步的基于请求/响应的通信机制
当使用基于IPC(进程间通信)的同步请求/响应方式时,客户端直接向服务端发送请求,服务端处理请求并返回响应。大多数客户端在等待响应时会阻塞;也可以使用异步的基于事件驱动的方式,客户端的代码被封装在Future或Rx Observables中;与消息机制不同的是,即使使用异步方式,客户端还是会认定响应将立即到达。有许多同步请求/响应协议可以选择,REST和Thrift是用的较多的两种。
REST
现在编写REST风格的接口很流行,REST是使用HTTP协议的IPC。REST的主要概念是资源,资源代表一个业务对象(像顾客或者产品)或者一组业务对象的集合。REST使用HTTP原语处理URL引用的资源:GET请求返回资源,可能用XML文本表示也可能用JSON表示;POST请求创建资源;PUT请求更新资源。
“REST提供了一组架构约束,强调组件交互的可扩展性、接口的通用性、组件的独立部署,以及用于降低延迟、实施安全性、封装遗留系统的中间组件。”在《基于网络的架构设计》一书中这样定义。
下图展示了叫车应用使用REST实现的效果:
乘客在智能手机上发布新行程请求,客户端向行程管理服务的“/trips”资源POST请求;行程管理服务接收请求,通过发送查询乘客信息的GET请求来处理;行程管理服务验证乘客账号有效后,创建新行程,并向智能手机返回201消息。
许多开发人员声称他们的接口是RESTful的,实际上并非如此;Leonard Richardson定义了一个非常有用的REST成熟度模型,它包括以下级别:
L0:客户端使用HTTP POST向唯一服务端请求服务,在请求中指定要执行的操作、操作的目标(比如业务对象)以及参数。
L1:支持资源的概念。客户端向某个资源发请求,指定要执行的动作和参数。
L2:使用HTTP原语执行动作:GET获取数据、POST新建数据、PUT更新数据,参数包含在请求中或者消息体中。使用HTTP原语的好处是可以使用Web基础设施,比如GET请求的缓存等。
L3:基于HATEOAS(超文本作为应用状态引擎)设计接口。基本思想是在GET请求返回的资源表示中包含在该资源上可执行操作的链接,比如:客户端可以调用一个取消订单的链接来执行取消订单的操作,而这个链接包含在获取订单的GET操作所返回的订单资源中。使用HATEOAS的好处是不用在客户端代码中硬编码URL;还有一个好处是由于返回的资源表达包含了所有能执行的操作,客户端就不用去猜测当前状态下服务端能做什么。
使用基于HTTP的协议有很多好处:HTTP简单熟悉;接口容易测试(使用JSON或者其他文本格式),可以在安装了Postman插件的浏览器中调用,也可以在命令行使用curl调用;直接支持请求/响应模式的通信;防火墙友好;不需要中间代理,简化系统结构。
使用HTTP也有一些缺点:直接支持的模式只有请求/响应模式,HTTP也可以用于通知模式,但是服务端总是会发送响应消息;由于客户端和服务端直接通信(没有中间代理缓存消息),它们在信息交换过程中必须同时可用;客户端必须知道所有服务端实例的地址,这非常困难,必须依赖于服务发现机制。
开发者社区最近重新认识到接口定义语言对于RESTful接口开发的意义,可用的接口定义语言有RAML和Swagger。一些接口定义语言(像Swagger)支持定义请求消息和响应消息的格式;一些接口定义语言(像RAML)要求使用单独的规范(像JSON Schema)。除了定义接口之外,接口定义语言一般还支持根据接口描述生成客户端的stubs和服务器端的skeletons。
Thrift
ApacheThrift是REST一个有趣的替代品,它是用于编写跨语言RPC客户端和服务器的框架。Thrift使用C语言风格的接口定义,需要使用Thrift编译器生成客户端stub和服务端skeleton。编译器支持大多数开发语言,包括:C++、Java、Python、PHP、Ruby、Erlang和Node.js。
Thrift接口包含一个或者多个服务,服务定义类似Java接口,是一些强类型方法的集合。Thrift方法可以是双向的(有返回),也可以是单向的(无返回);有返回的方法对应于请求/响应模式的实现,客户端等待响应的过程中可能会抛出异常;无返回的方法对应于通知模式,该模式下服务端不发送响应消息。
Thrift支持多种类型的消息:JSON、二进制和压缩二进制。二进制消息比JSON高效,编解码更快;压缩二级制消息比二进制节约空间;JSON的优势是易读和浏览器友好。Thrift还支持传输协议的选择,包括Raw TCP和HTTP;Raw TCP更高效,HTTP易读、对防火墙和浏览器友好。
消息格式
如果使用消息机制或者REST,你可以选择消息格式;其他的IPC机制(比如Thrift),可能只支持一种消息格式。在任一情况下,选择支持跨语言的消息格式都有必要,即使现在你编写微服务只用一种编程语言,也不能排除将来会使用其他语言。
有两种主要的消息格式:文本和二进制。基于文本的消息格式包括JSON和XML,优势是易读、自描述。在JSON中,对象的属性由名称-数值对的集合表示;在XML中也类似,属性由名称命名元素和值表示;这样的结构允许消费者获取感兴趣的属性值,忽略其他属性值,这样的接口对属性的增加和减少都能兼容。
XML文档的结构由XML
Schema指定,长期以来,开发者社区认为JSON也应该有类似的机制,一个选项是JSON Schema,它可以独立存在也可以作为接口定义语言的一部分(像Swagger)。
文本消息的一个缺点是消息太臃肿,特别是XML,由于消息是自描述的,每一条消息除了包含属性值还包含属性名;另外一个缺点是解析文本的开销。你可能会考虑选用二级制消息。
有几种二级制消息格式可以选择。如果你使用Thrift,你能使用binary Thrift;如果你可以选择消息格式,流行的二进制消息格式有Protocol Buffers和Apache Avro。它们都提供了消息定义语言来定义消息结构,一个区别是Protocol Buffers使用标记语言,而Apache Avro需要知道schema才能解析;因此Protocol Buffers比Apache Avro更能适应接口的变化。要详细了解Thrift、Protocol Buffers和Avro的对比,可以参考这个链接:http://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html。
7.总结
微服务必须使用进程间通信的机制进行交互,当设计通信方式时,有几点需要考虑:服务如何交互、怎么定义服务接口、服务接口如何变化、怎样处理部分失败。进程间机制可分为两大类:异步消息模式和同步请求/响应模式。在下篇文章,我们讨论微服务架构下服务发现机制面临的问题。
网友评论