第3章
微服务架构中的进程间通信
本章导读
- 通信模式的具体应用:远程过程调用、断路器、客户端发现、自注册、服务端发现、第三方注册、异步消息、事务性发件箱、事务日志拖尾、轮询发布者
- 进程间通信在微服务架构中的重要性
- 定义和演化API
- 如何在各种进程间通信技术之间进行权衡
- 使用异步消息对服务的好处
- 把消息作为数据库事务的一部分可靠发送
与大多数其他开发人员一样,玛丽和她的团队在进程间通信(IPC)机制方面有一些经验。FTGO应用程序有一个REST API,供移动应用程序和浏览器端JavaScript使用。它还使用各种云服务,例如Twilio消息服务和Stripe支付服务。但是在像FTGO这样的单体应用程序中,模块之间通过语言级方法或函数相互调用。FTGO开发人员通常不需要考虑进程间通信,除非他们正在开发REST API或与云服务集成有关的模块。
相反,正如你在第2章中看到的那样,微服务架构将应用程序构建为一组服务。这些服务必须经常协作才能处理各种外部请求。因为服务实例通常是在多台机器上运行的进程,所以它们必须使用进程间通信进行交互。因此,进程间通信技术在微服务架构中比在单体架构中扮演着更重要的角色。当应用程序迁移到微服务时,玛丽和其他FTGO开发人员将需要花费更多时间来思考进程间通信有关的问题。
当前有多种进程间通信机制供开发者选择。比较流行的是REST(使用JSON)。但是,需要牢记“没有银弹”这个大原则。你必须仔细考虑这些选择。本章将探讨各种进程间通信机制,包括REST和消息传递,并讨论如何进行权衡。
选择合适的进程间通信机制是一个重要的架构决策。它会影响应用程序可用性。更重要的是,正如我在本章和下一章中所解释的那样,进程间通信甚至与事务管理互相影响。一个理想的微服务架构应该是在内部由松散耦合的若干服务组成,这些服务使用异步消息相互通信。REST等同步协议主要用于服务与外部其他应用程序的通信。
我从介绍微服务架构中的进程间通信开始本章。接下来,我将以流行的REST为例描述基于远程过程调用的进程间通信。服务发现和如何处理“局部失效”是我会重点讨论的主题。在这之后,我会描述基于异步消息的进程间通信,还将讨论保留消息顺序、处理重复消息和实现事务性消息等问题。最后,我将介绍自包含服务的概念,这类服务在处理同步请求时无须与其他服务通信,可以提高可用性。
3.1 微服务架构中的进程间通信概述
有很多进程间通信技术可供开发者选择。服务可以使用基于同步请求/响应的通信机制,例如HTTP REST或gRPC。另外,也可以使用异步的基于消息的通信机制,比如AMQP或STOMP。消息的格式也不尽相同。服务可以使用具备可读性的格式,比如基于文本的JSON或XML。也可以使用更加高效的、基于二进制的Avro或Protocol Buffers格式。
在深入细节之前,我想提出一些值得考虑的设计问题。我们先来看看服务的交互方式,我将采用独立于技术实现的方式抽象地描述客户端与服务之间的交互。接下来,我将讨论在微服务架构中精确定义API的重要性,包括API优先的设计概念。之后,我将讨论如何进行API演化(变更)这个重要主题。最后,我会讨论消息格式的不同选项以及它们如何决定API演化的难易。让我们首先从交互方式开始吧。
3.1.1 交互方式
在为服务的API选择进程间通信机制之前,首先考虑服务与其客户端的交互方式是非常重要的。考虑交互方式将有助于你专注于需求,并避免陷入特定进程间通信技术的细节。此外,如3.4节所述,交互方式的选择会影响应用程序的可用性。正如你将在第9章和第10章中看到的,交互方式还可以帮助你选择更合适的集成测试策略。
有多种客户端与服务的交互方式。如表3-1所示,它们可以分为两个维度。第一个维度关注的是一对一和一对多。
- 一对一:每个客户端请求由一个服务实例来处理。
- 一对多:每个客户端请求由多个服务实例来处理。
交互方式的第二个维度关注的是同步和异步。
- 同步模式:客户端请求需要服务端实时响应,客户端等待响应时可能导致堵塞。
- 异步模式:客户端请求不会阻塞进程,服务端的响应可以是非实时的。
一对一的交互方式有以下几种类型。
- 请求/响应:一个客户端向服务端发起请求,等待响应;客户端期望服务端很快就会发送响应。在一个基于线程的应用中,等待过程可能造成线程阻塞。这样的方式会导致服务的紧耦合。
- 异步请求/响应:客户端发送请求到服务端,服务端异步响应请求。客户端在等待响应时不会阻塞线程,因为服务端的响应不会马上就返回。
- 单向通知:客户端的请求发送到服务端,但是并不期望服务端做出任何响应。
需要牢记,同步请求/响应的交互方式并不会因为具体的进程间通信技术而发生改变。例如,一个服务使用请求/响应的方式与其他服务交互,底层的进程间通信技术可以是REST,也可以是消息机制。也就是说,即使两个服务通过(异步)消息代理通信,客户端仍旧可能等待响应。这样的话,这两个服务在某种意义上仍旧是紧耦合的。我们稍后在讨论服务间通信和可用性这个话题时,会再深入讨论。
一对多的交互方式有以下几种类型。
- 发布/订阅方式:客户端发布通知消息,被零个或者多个感兴趣的服务订阅。
- 发布/异步响应方式:客户端发布请求消息,然后等待从感兴趣的服务发回的响应。
每个服务通常使用的都是以上这些交互方式的组合。FTGO应用中的某些服务同时使用同步和异步API,有些还可以发布事件。
现在,我们来看看如何定义服务的API。
3.1.2 在微服务架构中定义API
API或接口是软件开发的中心。应用是由模块构成的,每个模块都有接口,这些接口定义了模块的客户端可以调用若干操作。一个设计良好的接口会在暴露有用功能同时隐藏实现的细节。因此,这些实现的细节可以被修改,而接口保持不变,这样就不会对客户端产生影响。
在单体架构的应用程序中,接口通常采用编程语言结构(如Java接口)定义。Java 接口制定了一组客户端可以调用的方法。具体的实现类对于客户端来说是不可见的。而且,由于Java是静态类型编程语言,如果接口变得与客户端不兼容,那么应用程序就无法通过编译。
API和接口在微服务架构中同样重要。服务的API是服务与其客户端之间的契约(contract)。如第2章所述,服务的API由客户端结构可以调用的方法和服务发布的事件组成。方法具备名称、参数和返回类型。事件具有一个类型和一组字段,并且如3.3节所述,发布到消息通道。
相比单体架构,我们面临的挑战在于:并没有一个简单的编程语言结构可以用来构造和定义服务的API。根据定义,服务和它的客户端并不会一起编译。如果使用不兼容的API部署新版本的服务,虽然在编译阶段不会出现错误,但是会出现运行时故障。
无论选择哪种进程间通信机制,使用某种接口定义语言(IDL)精确定义服务的API都很重要。人们围绕着是否应该使用API优先这类方法定义服务展开了一系列有益的争论(www.programmableweb.com/news/how-to-design-great-apis-api-first-design-and-raml/how-to/2015/07/10 )。首先编写接口定义,然后与客户端开发人员一起查看这些接口定义。只有在反复迭代几轮API定义之后,才开始具体的服务实现编程。这种预先设计有助于你构建满足客户端需求的服务。
如何定义API取决于你使用的进程间通信机制。例如,如果你正在使用消息机制,则API由消息通道、消息类型和消息格式组成。如果你使用HTTP,则API由URL、HTTP动词以及请求和响应格式组成。在本章的后面,我将解释如何定义API。
服务的API很少一成不变,它可能会随着时间的推移而发展。让我们来看看如何做到这一点,并讨论你将面临的问题。
3.1.3 API的演化
API不可避免地会随着应用功能的增减而发生变化。在单体应用中,变更API并更新所有调用方的代码是相对简单的一件事情。如果你使用的是静态类型的编程语言,编译器就会对那些存在不兼容类型的调用给出编译错误。唯一的挑战在于变更的范围。当变更使用较广的API时,可能需要较长的时间。
在基于微服务架构的应用中改变服务的API就没这么容易了。服务的客户端可能是另外的服务,通常是其他团队所开发的。客户端也极有可能是由组织之外的人所开发和控制的。你不能够强行要求客户端跟服务端的API版本保持一致。另外,由于现代应用程序有着极高的可用性要求,你一般会采用滚动升级的方式来更新服务,因此一个服务的旧版本和新版本肯定会共存。
为这些挑战制定应对措施是非常重要的。具体的措施取决于API演化的实际情况。
语义化版本控制
语义化版本控制规范(http://semver.org )为API版本控制提供了有用的指导。它是一组规则,用于指定如何使用版本号,并且以正确的方式递增版本号。语义化版本控制最初的目的是用软件包的版本控制,但你可以将其用在分布式系统中对API进行版本控制。
语义化版本控制规范(Semvers)要求版本号由三部分组成:MAJOR.MINOR.PATCH。必须按如下方式递增版本号:
- MAJOR:当你对API进行不兼容的更改时。
- MINOR:当你对API进行向后兼容的增强时。
- PATCH:当你进行向后兼容的错误修复时。
有几个地方可以在API中使用版本号。如果你正在实现REST API,则可以使用主要版本作为URL路径的第一个元素,如下所述。或者,如果你要实现使用消息机制的服务,则可以在其发布的消息中包含版本号。目标是正确地为API设定版本,并以受控方式变更它们。让我们来看看如何处理次要和主要变化。
进行次要并且向后兼容的改变
理想情况下,你应该努力只进行向后兼容的更改。向后兼容的更改是对API的附加更改或功能增强:
- 添加可选属性。
- 向响应添加属性。
- 添加新操作。
如果你只进行这些类型的更改,那么老版本的客户端将能够直接使用更新的服务,但前提是客户端和服务都遵守健壮性原则(https://en.wikipedia.org/wiki/Robustness_principle ),这个原则类似于我们常说的“严以律己,宽以待人”。服务应该为缺少的请求属性提供默认值。同样,客户端应忽略任何额外的响应属性。为了避免问题,客户端和服务必须使用支持健壮性原则的请求和响应格式。在本节的后面部分,我将解释为什么基于文本格式(如JSON和XML)的API通常更容易进行变更。
进行主要并且不向后兼容的改变
有时你必须对API进行主要并且不向后兼容的更改。由于你无法强制客户端立即升级,因此服务必须在一段时间内同时支持新旧版本的API。如果你使用的是基于HTTP的进程间通信机制,例如REST,则一种方法是在URL中嵌入主要版本号。例如,版本1路径以
/v1/...为前缀,而版本2路径以/v2/...为前缀。
另一种选择是使用HTTP的内容协商机制,并在MIME类型中包含版本号。例如,客户端将使用如下格式针对1.x版的服务API发起Order相关的请求:
此请求告诉Order Service客户端需要以版本1.x做出响应。
为了支持多个版本的API,实现API的服务适配器将包含在旧版本和新版本之间进行转换的逻辑。此外,如第8章所述,API Gateway几乎肯定会使用版本化的API。它甚至可能必须支持许多旧版本的API。
现在我们来看一看消息格式的问题,看选择哪种格式会影响API变更的难易。
3.1.4 消息的格式
进程间通信的本质是交换消息。消息通常包括数据,因此一个重要的设计决策就是这些数据的格式。消息格式的选择会对进程间通信的效率、API的可用性和可演化性产生影响。如果你正在使用一个类似HTTP的消息系统或者协议,那么你需要选择消息的格式。有些进程间通信机制,如我们马上就会讲到的gRPC,已经指定了消息格式。在这两种情况下,使用跨语言的消息格式尤为重要。即使我们今天使用同一种编程语言来开发微服务应用,那也很有可能在今后会扩展到其他的编程语言。我们不应该使用类似Java序列化这样跟编程语言强相关的消息格式。
消息的格式可以分为两大类:文本和二进制。我们来逐一分析。
基于文本的消息格式
第一类是JSON和XML这样的基于文本的格式。这类消息格式的好处在于,它们的可读性很高,同时也是自描述的。JSON消息是命名属性的集合。相似地,XML消息也是命名元素和值的集合。这样的格式允许消息的接收方只挑选他们感兴趣的值,而忽略掉其他。因此,对消息结构的修改可以做到很好的后向兼容性。
XML文档结构的定义由XML Schema完成(www.w3.org/XML/Schema)。开发者社区逐渐意识到JSON也需要一个类似的机制,因此使用JSON Schema变得逐渐流行(http://json-schema.org)。JSON Schema定义了消息属性的名称和类型,以及它们是可选的还是必需的。除了能够起到文档的作用之外,应用程序还可以使用JSON Schema来验证传入的消息结构是否正确。
使用基于文本格式消息的弊端主要是消息往往过度冗长,特别是XML。消息的每一次传递都必须反复包含除了值以外的属性名称,这样会造成额外的开销。另外一个弊端是解析文本引入的额外开销,尤其是在消息较大的时候。因此,在对效率和性能敏感的场景下,你可能需要考虑基于二进制格式的消息。
二进制消息格式
有几种不同的二进制格式可供选择。常用的包括Protocol Buffers(https://developers.google.com/Protocol-buffers/docs/overview )和Avro(https://avro.apache.org )。这两种格式都提供了一个强类型定义的IDL(接口描述文件),用于定义消息的格式。编译器会自动根据这些格式生成序列化和反序列化的代码。因此你不得不采用API优先的方法来进行服务设计。此外,如果使用静态类型语言编写客户端,编译器会强制检查它是否使用了正确的API格式。
这两种二进制格式的区别在于,Protocol Buffers使用tagged fields(带标记的字段),而Avro的消费者在解析消息之前需要知道它的格式。因此,实行API的版本升级演进,Protocol Buffer要优于Avro。有篇博客文章对Thrift、Protocol Buffers和Avro做了非常全面的比较(http://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html )。
现在我们已经了解了消息格式,再来看看用于传输消息的特定进程间通信机制,从远程过程调用(RPI)模式开始。
3.2 基于同步远程过程调用模式的通信
当使用基于远程过程调用(RPI)的进程间通信机制时,客户端向服务发送请求,服务处理该请求并发回响应。有些客户端可能会处在堵塞状态并等待响应,而其他客户端可能会有一个响应式的非阻塞架构。但与使用消息机制时不同,客户端假定响应将及时到达。
图3-1显示了远程过程调用的工作原理。客户端中的业务逻辑调用代理接口,这个接口由远程过程调用代理适配器类实现。远程过程调用代理向服务发出请求。该请求由远程过程调用服务器适配器类处理,该类通过接口调用服务的业务逻辑。然后它将回复发送回远程过程调用代理,该代理将结果返回给客户端的业务逻辑。
代理接口通常封装底层通信协议。有许多协议可供选择。在本节中,我将介绍REST和gRPC。我将介绍如何通过正确处理局部故障来提高服务的可用性,并解释为什么使用远程过程调用的基于微服务的应用程序必须使用服务发现机制。
我们先来看看REST。
3.2.1 使用REST
如今开发者非常喜欢使用RESTful风格来开发API(https://en.wikipedia.org/wiki/Repres-entational_state_transfer )。REST是一种(总是)使用HTTP协议的进程间通信机制,REST之父Roy Fielding曾经说过:
REST中的一个关键概念是资源,它通常表示单个业务对象,例如客户或产品,或业务对象的集合。REST使用HTTP动词来操作资源,使用URL引用这些资源。例如,GET请求返回资源的表示形式,该资源通常采用XML文档或JSON对象的形式,但也可以使用其他格式(如二进制)。POST请求创建新资源,PUT请求更新资源。例如,Order Service具有用于创建Order的POST/order端点以及用于检索Order的GET/orders/{orderId}端点。
很多开发者都表示他们基于HTTP的API是RESTful风格的。但是,如同Roy Fielding在他的博客中所说,并非所有这些API都是RESTful风格的( http://rog.gbiv.com/untangled/ 2008/rest-apis-must-be-hypertext-driven)。为了更好地理解这个概念,我们来看一看REST成熟度模型。
REST成熟度模型
Leonard Richardson为REST定义了一个成熟度模型( https://martinfowler.com/articles/richardsonMaturityModel.html ),具体包含以下四个层次。
- Level 0:Level 0层级服务的客户端只是向服务端点发起HTTP POST请求,进行服务调用。每个请求都指明了需要执行的操作、这个操作针对的目标(例如,业务对象)和必要的参数。
- Level 1:Level 1层级的服务引入了资源的概念。要执行对资源的操作,客户端需要发出指定要执行的操作和包含任何参数的POST请求。
- Level 2:Level 2层级的服务使用HTTP动词来执行操作,譬如GET表示获取、POST表示创建、PUT表示更新。请求查询参数和主体(如果有的话)指定操作的参数。这让服务能够借助Web基础设施服务,例如通过CDN来缓存GET请求。
- Level 3:Level 3层级的服务基于HATEOAS(Hypertext As The Engine Of Application State)原则设计,基本思想是在由GET请求返回的资源信息中包含链接,这些链接能够执行该资源允许的操作。例如,客户端通过订单资源中包含的链接取消某一订单,或者发送GET请求去获取该订单,等等。HATEOAS的优点包括无须在客户端代码中写入硬链接的URL。此外,由于资源信息中包含可允许操作的链接,客户端无须猜测在资源的当前状态下执行何种操作(www.infoq.com/news/2009/04/hateoas-restful-api-advantages )。
建议检查你手中项目的REST API,看看它们达到了哪一个级别。
定义REST API
如前面3.1节所述,你必须使用接口定义语言(IDL)定义API。与旧的通信协议(如CORBA和SOAP)不同,REST最初没有IDL。幸运的是,开发者社区重新发现了RESTful API的IDL价值。最流行的REST IDL是Open API规范(www.openapis.org),它是从Swagger开源项目发展而来的。Swagger项目是一组用于开发和记录REST API的工具。它包括从接口定义到生成客户端桩(stub,存根)和服务器骨架的一整套工具。
在一个请求中获取多个资源的挑战
REST资源通常以业务对象为导向,例如Consumer和Order。因此,设计REST API时的一个常见问题是如何使客户端能够在单个请求中检索多个相关对象。例如,假设REST客户端想要检索Order和这个Order的Consumer。纯REST API要求客户端至少发出两个请求,一个用于Order,另一个用于Consumer。更复杂的情况需要更多往返并且遭受过多的延迟。
此问题的一个解决方案是API允许客户端在获取资源时检索相关资源。例如,客户可以使用GET/orders/order-id-1345?expand=consumer检索Order及其Consumer。请求中的查询参数用来指定要与Order一起返回的相关资源。这种方法在许多场景中都很有效,但对于更复杂的场景来说,它通常是不够的。实现它也可能很耗时。这导致了替代技术的日益普及,例如GraphQL(http://graphql.org)和Netflix Falcor(http://netflix.github.io/falcor),它们旨在支持高效的数据获取。
把操作映射为HTTP动词的挑战
另一个常见的REST API设计问题是如何将要在业务对象上执行的操作映射到HTTP动词。 REST API应该使用PUT进行更新,但可能有多种方法来更新订单,包括取消订单、修改订单等。此外,更新可能不是幂等的,但这却是使用PUT的要求。一种解决方案是定义用于更新资源的特定方面的子资源。例如,Order Service具有用于取消订单的POST/orders/{orderId}/cancel端点,以及用于修订订单的POST/orders/{orderId}/revise端点。另一种解决方案是将动词指定为URL的查询参数。可惜的是,这两种解决方案都不是特别符合RESTful的要求。
映射操作到HTTP动词的这个问题导致了REST替代方案的日益普及,例如gPRC,我将在3.2.2节中讨论这项技术。但首先让我们来看看REST的好处和弊端。
REST的好处和弊端
REST有如下好处:
- 它非常简单,并且大家都很熟悉。
- 可以使用浏览器扩展(比如Postman插件)或者curl之类的命令行(假设使用的是JSON或其他文本格式)来测试HTTP API。
- 直接支持请求/响应方式的通信。
- HTTP对防火墙友好。
- 不需要中间代理,简化了系统架构。
它也存在一些弊端:
- 它只支持请求/响应方式的通信。
- 可能导致可用性降低。由于客户端和服务直接通信而没有代理来缓冲消息,因此它们必须在REST API调用期间都保持在线。
- 客户端必须知道服务实例的位置(URL)。如3.2.4节所述,这是现代应用程序中的一个重要问题。客户端必须使用所谓的服务发现机制来定位服务实例。
- 在单个请求中获取多个资源具有挑战性。
- 有时很难将多个更新操作映射到HTTP动词。
虽然存在这些缺点,但REST似乎是API的事实标准,尽管有几个有趣的替代方案。例如,通过GraphQL实现灵活、高效的数据提取。第8章将讨论GraphQL,并介绍API Gateway模式。
gRPC是REST的另一种替代方案。我们来看看它是如何工作的。
3.2.2 使用gRPC
如上一节所述,使用REST的一个挑战是,由于HTTP仅提供有限数量的动词,因此设计支持多个更新操作的REST API并不总是很容易。避免此问题的进程间通信技术是gRPC(www.grpc.io ),这是一个用于编写跨语言客户端和服务端的框架(https://en.wikipedia.org/wiki/Remote_procedure_call )。gRPC是一种基于二进制消息的协议,这意味着如同前面讨论二进制消息格式时所说的,你不得不采用API优先的方法来进行服务设计。你可以使用基于Protocol Buffer的IDL定义gRPC API,这是谷歌公司用于序列化结构化数据的一套语言中立机制。你可以使用Protocol Buffer编译器生成客户端的桩(stub,也称为存根)和服务端骨架(skeleton)。编译器可以为各种语言生成代码,包括Java、C#、Node.js和GoLang。客户端和服务端使用HTTP/2以Protocol Buffer格式交换二进制消息。
gRPC API由一个或多个服务和请求/响应消息定义组成。服务定义类似于Java接口,是强类型方法的集合。除了支持简单的请求/响应RPC之外,gRPC还支持流式RPC。服务器可以使用消息流回复客户端。客户端也可以向服务器发送消息流。
gRPC使用Protocol Buffers作为消息格式。如前所述,Protocol Buffers是一种高效且紧凑的二进制格式。它是一种标记格式。Protocol Buffers消息的每个字段都有编号,并且有一个类型代码。消息接收方可以提取所需的字段,并跳过它无法识别的字段。因此,gRPC使API能够在保持向后兼容的同时进行变更。
代码清单3-1显示了Order Service的gRPC API。它定义了几种方法,包括createOrder()。此方法将CreateOrderRequest作为参数并返回CreateOrderReply。
CreateOrderRequest和CreateOrderReply是具有类型的消息。例如,Create-OrderRequest消息具有int64类型的restaurantId字段。字段的标记值为1。
gRPC有几个好处:
- 设计具有复杂更新操作的API非常简单。
- 它具有高效、紧凑的进程间通信机制,尤其是在交换大量消息时。
- 支持在远程过程调用和消息传递过程中使用双向流式消息方式。
- 它实现了客户端和用各种语言编写的服务端之间的互操作性。
gRPC也有几个弊端:
- 与基于REST/JSON的API机制相比,JavaScript客户端使用基于gRPC的API需要做更多的工作。
- 旧式防火墙可能不支持HTTP/2。
gRPC是REST的一个引人注目的替代品,但与REST一样,它是一种同步通信机制,因此它也存在局部故障的问题。让我们来看看它是什么以及如何处理它。
3.2.3 使用断路器模式处理局部故障
分布式系统中,当服务试图向另一个服务发送同步请求时,永远都面临着局部故障的风险。因为客户端和服务端是独立的进程,服务端很有可能无法在有限的时间内对客户端的请求做出响应。服务端可能因为故障或维护的原因而暂停。或者,服务端也可能因为过载而对请求的响应变得极其缓慢。
客户端等待响应被阻塞,这可能带来的麻烦就是在其他客户端甚至使用服务的第三方应用之间传导,并导致服务中断。
例如,考虑图3-2所示的场景,其中Order Service无响应。移动客户端向API Gateway发出REST请求,如第8章所述,它是API客户端应用程序的入口点。API Gateway将请求代理到无响应的Order Service。
OrderServiceProxy将无限期地阻塞,等待响应。这不仅会导致糟糕的用户体验,而且在许多应用程序中,它会消耗宝贵的资源,例如线程。最终,API Gateway将耗尽资源,无法处理请求。整个API都不可用。
要通过合理地设计服务来防止在整个应用程序中故障的传导和扩散,这是至关重要的。解决这个问题分为两部分:
- 必须让远程过程调用代理(例如OrderServiceProxy)有正确处理无响应服务的能力。
- 需要决定如何从失败的远程服务中恢复。
首先,我们将看看如何编写健壮的远程过程调用代理。
开发可靠的远程过程调用代理
每当一个服务同步调用另一个服务时,它应该使用Netflix描述的方法(http://techblog.netflix.com/2012/02/faulttolerance-in-high-volume.html)来保护自己。这种方法包括以下机制的组合。
- 网络超时:在等待针对请求的响应时,一定不要做成无限阻塞,而是要设定一个超时。使用超时可以保证不会一直在无响应的请求上浪费资源。
- 限制客户端向服务器发出请求的数量:把客户端能够向特定服务发起的请求设置一个上限,如果请求达到了这样的上限,很有可能发起更多的请求也无济于事,这时就应该让请求立刻失败。
- 断路器模式:监控客户端发出请求的成功和失败数量,如果失败的比例超过一定的阈值,就启动断路器,让后续的调用立刻失效。如果大量的请求都以失败而告终,这说明被调服务不可用,这样即使发起更多的调用也是无济于事。在经过一定的时间后,客户端应该继续尝试,如果调用成功,则解除断路器。
Netflix Hystrix(https://github.com/Netflix/Hystrix )是一个实现这些和其他模式的开源库。如果你正在使用JVM,那么在实现远程过程调用代理时一定要考虑使用Hystrix。如果你在非JVM环境中开发,则应该找到并使用类似的库。例如,Polly库(https://github.com/App-vNext/Polly )在.NET社区中很受欢迎。
从服务失效故障中恢复
使用诸如Hystrix之类的库只是解决方案的一部分。你还必须根据具体情况决定如何从无响应的远程服务中恢复你的服务。一种选择是服务只是向其客户端返回错误。例如,这种方法对于图3-2中所示的场景是有意义的,其中创建Order的请求失败。唯一的选择是API Gateway将错误返回给移动客户端。
在其他情况下,返回备用值(fallback value,例如默认值或缓存响应)可能会有意义。例如,第7章将描述API Gateway如何使用API组合模式实现findOrder()查询操作。如图3-3所示,其GET/orders/{orderId}端点的实现调用了多个服务,包括Order Service、Kitchen Service和Delivery Service,并将结果组合在一起。
每个服务的数据对客户来说重要性可能不同。Order Service的数据至关重要。如果此服务不可用,API Gateway应返回其数据的缓存版本或错误。来自其他服务的数据不太重要。例如,即使送餐状态不可用,客户也可以向用户显示有用的信息。如果Delivery Service不可用,API Gateway应返回其数据的缓存版本或从响应中省略它。
在设计服务时考虑局部故障至关重要,但这不是使用远程过程调用时需要解决的唯一问题。另一个问题是,为了让一个服务使用远程过程调用调用另一个服务,它需要知道服务实例的网络位置。表面上看起来很简单,但在实践中这是一个具有挑战性的问题。你必须使用服务发现机制。让我们来看看它是如何工作的。
3.2.4 使用服务发现
假设你正在编写一些调用具有REST API的服务的代码。为了发出请求,你的代码需要知道服务实例的网络位置(IP地址和端口)。在物理硬件上运行的传统应用程序中,服务实例的网络位置通常是静态的。例如,你的代码可以从偶尔更新的配置文件中读取网络位置。但在现代的基于云的微服务应用程序中,通常不那么简单。如图3-4所示,现代应用程序更具动态性。
服务实例具有动态分配的网络位置。此外,由于自动扩展、故障和升级,服务实例集会动态更改。因此,你的客户端代码必须使用服务发现。
什么是服务发现
正如刚才所见,你无法使用服务的IP地址静态配置客户端。相反,应用程序必须使用动态服务发现机制。服务发现在概念上非常简单:其关键组件是服务注册表,它是包含服务实例网络位置信息的一个数据库。
服务实例启动和停止时,服务发现机制会更新服务注册表。当客户端调用服务时,服务发现机制会查询服务注册表以获取可用服务实例的列表,并将请求路由到其中一个服务实例。
实现服务发现有以下两种主要方式:
- 服务及其客户直接与服务注册表交互。
- 通过部署基础设施来处理服务发现。(我将在第12章中详细讨论这一点。)
我们来逐一进行分析。
应用层服务发现模式
实现服务发现的一种方法是应用程序的服务及其客户端与服务注册表进行交互。图3-5显示了它的工作原理。服务实例使用服务注册表注册其网络位置。客户端首先通过查询服务注册表获取服务实例列表来调用服务,然后它向其中一个实例发送请求。
这种服务发现方法是两种模式的组合。第一种模式是自注册模式。服务实例调用服务注册表的注册API来注册其网络位置。它还可以提供运行状况检查URL,在第11章中有更详细的描述。运行状况检查URL是一个API端点,服务注册表会定期调用该端点来验证服务实例是否正常且可用于处理请求。服务注册表还可能要求服务实例定期调用“心跳” API以防止其注册过期。
第二种模式是客户端发现模式。当客户端想要调用服务时,它会查询服务注册表以获取服务实例的列表。为了提高性能,客户端可能会缓存服务实例。然后,服客户端使用负载平衡算法(例如循环或随机)来选择服务实例。然后它向选择的服务实例发出请求。
Netflix和Pivotal在应用层服务发现方面做了大量的普及工作。Netflix开发并开源了几个组件,包括:Eureka,这是一个高可用的服务注册表;Eureka Java客户端;Ribbon,这是一个支持Eureka客户端的复杂HTTP客户端。Pivotal开发了Spring Cloud,这是一个基于Spring的框架,使得Netflix组件的使用非常简单。基于Spring Cloud的服务自动向Eureka注册,基于Spring Cloud的客户端因此可以自动使用Eureka进行服务发现。
应用层服务发现的一个好处是它可以处理多平台部署的问题(服务发现机制与具体的部署平台无关)。例如,想象一下,你在Kubernetes上只部署了一些服务(将在第12章中讨论过),其余服务在遗留环境中运行。在这种情况下,使用Eureka的应用层服务发现同时适用于两种环境,而基于Kubernetes的服务发现仅能用于部署在Kubernetes平台之上的部分服务。
应用层服务发现的一个弊端是:你需要为你使用的每种编程语言(可能还有框架)提供服务发现库。Spring Cloud只能帮助Spring开发人员。如果你正在使用其他Java框架或非JVM语言(如Node.js或GoLang),则必须找到其他一些服务发现框架。应用层服务发现的另一个弊端是开发者负责设置和管理服务注册表,这会分散一定的精力。因此,最好使用部署基础设施提供的服务发现机制。
平台层服务发现模式
在第12章中,你将了解许多现代部署平台(如Docker和Kubernetes)都具有内置的服务注册表和服务发现机制。部署平台为每个服务提供DNS名称、虚拟IP(VIP)地址和解析为VIP地址的DNS名称。客户端向DNS名称和VIP发出请求,部署平台自动将请求路由到其中一个可用服务实例。因此,服务注册、服务发现和请求路由完全由部署平台处理。
图3-6显示了它的工作原理。
这种方法是以下两种模式的组合。
- 第三方注册模式:由第三方负责(称为注册服务器,通常是部署平台的一部分)处理注册,而不是服务本身向服务注册表注册自己。
- 服务端发现模式:客户端不再需要查询服务注册表,而是向DNS名称发出请求,对该DNS名称的请求被解析到路由器,路由器查询服务注册表并对请求进行负载均衡。
由平台提供服务发现机制的主要好处是服务发现的所有方面都完全由部署平台处理。服务和客户端都不包含任何服务发现代码。因此,无论使用哪种语言或框架,服务发现机制都可供所有服务和客户使用。
平台提供服务发现机制的一个弊端是它仅限于支持使用该平台部署的服务。例如,如前所述,在描述应用程序级别发现时,基于Kubernetes的发现仅适用于在Kubernetes上运行的服务。尽管存在此限制,我建议尽可能使用平台提供的服务发现。
现在我们已经学习了使用REST或gRPC的同步进程间通信,让我们来看看替代方案:基于异步消息模式的通信。
3.3 基于异步消息模式的通信
使用消息机制时,服务之间的通信采用异步交换消息的方式完成。基于消息机制的应用程序通常使用消息代理,它充当服务之间的中介。另一种选择是使用无代理架构,通过直接向服务发送消息来执行服务请求。服务客户端通过向服务发送消息来发出请求。如果希望服务实例回复,服务将通过向客户端发送单独的消息的方式来实现。由于通信是异步的,因此客户端不会堵塞和等待回复。相反,客户端都假定回复不会马上就收到。
我将从概述消息开始本节。我将展示如何独立于消息技术描述基于消息的架构。接下来,我将对比无代理和有代理的架构,并描述选择消息代理的标准。然后,我将讨论几个重要的主题,包括在扩展接收方的同时保持消息的顺序、检测和丢弃重复的消息,以及作为数据库事务的一部分发送和接收消息。让我们从查看消息机制的工作原理开始。
3.3.1 什么是消息传递
Gregor Hohpe和Bobby Woolf在《Enterprise Integration Patterns》一书(Addison-Wesley,2003年)中定义了一种有用的消息传递模型。在此模型中,消息通过消息通道进行交换。发送方(应用程序或服务)将消息写入通道,接收方(应用程序或服务)从通道读取消息。让我们先学习消息,然后学习通道。
关于消息
消息由消息头部和消息主体组成(www.enterpriseintegrationpatterns.com/Message.html )。标题是名称与值对的集合,描述正在发送的数据的元数据。除了消息发送者提供的名称与值对之外,消息头部还包含其他信息,例如发件人或消息传递基础设施生成的唯一消息ID,以及可选的返回地址,该地址指定发送回复的消息通道。消息正文是以文本或二进制格式发送的数据。
有以下几种不同类型的消息。
- 文档:仅包含数据的通用消息。接收者决定如何解释它。对命令式消息的回复是文档消息的一种使用场景。
- 命令:一条等同于RPC请求的消息。它指定要调用的操作及其参数。
- 事件:表示发送方这一端发生了重要的事件。事件通常是领域事件,表示领域对象(如Order或Customer)的状态更改。
在本书描述的微服务架构实践中大量使用了命令式消息和事件式消息。
现在让我们看一看通道,即服务沟通的机制。
关于消息通道
如图3-7所示,消息通过消息通道进行交换(www.enterpriseintegrationpatterns.com/MessageChannel.html )。发送方中的业务逻辑调用发送端接口,该接口封装底层通信机制。发送端由消息发送适配器类实现,该消息发送适配器类通过消息通道向接收器发送消息。消息通道是消息传递基础设施的抽象。调用接收器中的消息处理程序适配器类来处理消息。它调用接收方业务逻辑实现的接收端接口。任意数量的发送方都可以向通道发送消息。类似地,任何数量的接收方都可以从通道接收消息。
有以下两种类型的消息通道:点对点(www.enterpriseintegrationpatterns.com/PointToPointChannel.html )和发布-订阅(www.enterpriseintegrationpatterns.com/PublishSubscribeChannel.html )。
- 点对点通道向正在从通道读取的一个消费者传递消息。服务使用点对点通道来实现前面描述的一对一交互方式。例如,命令式消息通常通过点对点通道发送。
- 发布-订阅通道将一条消息发给所有订阅的接收方。服务使用发布-订阅通道来实现前面描述的一对多交互方式。例如,事件式消息通常通过发布-订阅通道发送。
3.3.2 使用消息机制实现交互方式
消息机制的一个有价值的特性是它足够灵活,可以支持3.1.1节中描述的所有交互方式。一些交互方式通过消息机制直接实现。其他必须在消息机制之上实现。
我们来看看如何实现每种交互方式,从请求/响应和异步请求/响应开始。
实现请求/响应和异步请求/响应
当客户端和服务使用请求/响应或异步请求/响应进行交互时,客户端会发送请求,服务会发回回复。两种交互方式之间的区别在于,对于请求/响应,客户端期望服务立即响应,而对于异步请求/响应,则没有这样的期望。消息机制本质上是异步的,因此只提供异步请求/响应。但客户端可能会堵塞,直到收到回复。
客户端和服务端通过交换一对消息来实现异步请求/响应方式的交互。如图3-8所示,客户端发送命令式消息,该消息指定要对服务执行的操作和参数,这些内容通过服务拥有的点对点消息通道传递。该服务处理请求,并将包含结果的回复消息发送到客户端拥有的点对点通道。
客户端必须告知服务发送回复消息的位置,并且必须将回复消息与请求匹配。幸运的是,解决这两个问题并不困难。客户端发送具有回复通道头部的命令式消息。服务器将回复消息写入回复通道,该回复消息包含与消息标识符具有相同值的相关性ID。客户端使用相关性ID将回复消息与请求进行匹配。
由于客户端和服务使用消息机制进行通信,因此交互本质上是异步的。理论上,使用消息机制的客户端可能会阻塞,直到收到回复,但实际上客户端将异步处理回复。而且,回复通常可以由任何一个客户端实例处理。
实现单向通知
使用异步消息实现单向通知非常简单。客户端将消息(通常是命令式消息)发送到服务所拥有的点对点通道。服务订阅该通道并处理该消息,但是服务不会发回回复。
实现发布/订阅
消息机制内置了对发布/订阅交互方式的支持。客户端将消息发布到由多个接收方读取的发布/订阅通道。如第4章和第5章所述,服务使用发布/订阅来发布领域事件,领域事件代表领域对象的更改。发布领域事件的服务拥有自己的发布/订阅通道,通道的名称往往派生自领域类。例如,Order Service将Order事件发布到Order通道,Delivery Service将Delivery事件发布到Delivery通道。对特定领域对象的事件感兴趣的服务只需订阅相应的通道。
实现发布/异步响应
发布/异步响应交互方式是一种更高级别的交互方式,它通过把发布/订阅和请求/响应这两种方式的元素组合在一起实现。客户端发布一条消息,在消息的头部中指定回复通道,这个通道同时也是一个发布-订阅通道。消费者将包含相关性ID的回复消息写入回复通道。客户端通过使用相关性ID来收集响应,以此将回复消息与请求进行匹配。
应用程序中包含异步API的每个服务都会使用这些实现技术中的一种或多种。带有异步API调用操作的服务会拥有一个用于发出请求的通道,同样地,需要发布事件的服务也会拥有一个事件式消息发布通道。
如3.1.2节所述,为服务编写API规范很重要。我们来看看如何设计和定义异步API。
3.3.3 为基于消息机制的服务API创建API规范
如图3-9所示,服务的异步API规范必须指定消息通道的名称、通过每个通道交换的消息类型及其格式。你还必须使用诸如JSON、XML或Protobuf之类的标准来描述消息的格式。但与REST和Open API不同,并没有广泛采用的标准来记录通道和消息类型,你需要自己编写这样的文档。
服务的异步API包含供客户端调用的操作和由服务对外发布的事件。这些API的记录方式不尽相同。让我们从操作开始逐一分析。
记录异步操作
可以使用以下两种不同交互方式之一调用服务的操作:
- 请求/异步响应式API:包括服务的命令消息通道、服务接受的命令式消息的具体类型和格式,以及服务发送的回复消息的类型和格式。
- 单向通知式API:包括服务的命令消息通道,以及服务接受的命令式消息的具体类型和格式。
服务可以对异步请求/响应和单向通知使用相同的请求通道。
记录事件发布
服务还可以使用发布/订阅的方式对外发布事件。此API风格的规范包括事件通道以及服务发布到通道的事件式消息的类型和格式。
消息和消息通道模型是一种很好的抽象,也是设计服务异步API的好方法。但是,为了实现服务,你需要选择具体的消息传递技术并确定如何使用它们的能力来实现设计。让我们看一看所涉及的内容。
3.3.4 使用消息代理
基于消息传递的应用程序通常使用消息代理,即服务通信的基础设施服务。但基于消息代理的架构并不是唯一的消息架构。你还可以使用基于无代理的消息传递架构,其中服务直接相互通信。这两种方法(如图3-10所示)具有不同的利弊,但通常基于代理的架构是一种更好的方法。
本书侧重于基于消息代理的软件架构,但还是值得快速浏览一下无代理的架构,因为有些情况下你可能会发现它很有用。
无代理消息
在无代理的架构中,服务可以直接交换消息。ZeroMQ( http://zeromq.org )是一种流行的无代理消息技术。它既是规范,也是一组适用于不同编程语言的库。它支持各种传输协议,包括TCP、UNIX风格的套接字和多播。
无代理的架构有以下一些好处:
- 允许更轻的网络流量和更低的延迟,因为消息直接从发送方发送到接收方,而不必从发送方到消息代理,再从代理转发到接收方。
- 消除了消息代理可能成为性能瓶颈或单点故障的可能性。
- 具有较低的操作复杂性,因为不需要设置和维护消息代理。
尽管这些好处看起来很吸引人,但无代理的消息具有以下明显的弊端:
- 服务需要了解彼此的位置,因此必须使用3.2.4节中描述的服务发现机制。
- 会导致可用性降低,因为在交换消息时,消息的发送方和接收方都必须同时在线。
- 在实现例如确保消息能够成功投递这些复杂功能时的挑战性更大。
实际上,这些弊端中的一些(例如可用性降低和需要使用服务发现),与使用同步请求/响应交互方式所导致的弊端相同。
由于这些限制,大多数企业应用程序使用基于消息代理的架构。让我们来看看它是如何工作的。
基于代理的消息
消息代理是所有消息的中介节点。发送方将消息写入消息代理,消息代理将消息发送给接收方。使用消息代理的一个重要好处是发送方不需要知道接收方的网络位置。另一个好处是消息代理缓冲消息,直到接收方能够处理它们。
有许多消息代理可供选择。流行的开源消息代理包括:
- Apache ActiveMQ(http://activemq.apache.org )。
- RabbitMQ(https://www.rabbitmq.com )。
- Apache Kafka(http://kafka.apache.org )。
还有基于云的消息服务,例如AWS Kinesis(https://aws.amazon.com/kinesis )和AWS SQS(https://aws.amazon.com/sqs/ )。
选择消息代理时,你需要考虑以下各种因素:
- 支持的编程语言:你选择的消息代理应该支持尽可能多的编程语言。
- 支持的消息标准:消息代理是否支持多种消息标准,比如AMQP和STOMP,还是它仅支持专用的消息标准?
- 消息排序:消息代理是否能够保留消息的排序?
- 投递保证:消息代理提供什么样的消息投递保证?
- 持久性:消息是否持久化保存到磁盘并且能够在代理崩溃时恢复?
- 耐久性:如果接收方重新连接到消息代理,它是否会收到断开连接时发送的消息?
- 可扩展性:消息代理的可扩展性如何?
- 延迟:端到端是否有较大延迟?
- 竞争性(并发)接收方:消息代理是否支持竞争性接收方?
每个消息代理都有不同的侧重点。例如,一个非常低延迟的代理可能不会保留消息的顺序,不保证消息投递成功,只在内存中存储消息。保证投递成功并在磁盘上可靠地存储消息的代理可能具有更高的延迟。哪种消息代理最适合取决于你的应用程序的需求。你的应用程序的不同部分甚至可能具有不同的消息传递需求。
但是,消息顺序和可扩展性很可能是必不可少的。现在让我们看看如何使用消息代理实现消息通道。
使用消息代理实现消息通道
每个消息代理都用自己与众不同的概念来实现消息通道。如表3-2所示,ActiveMQ等JMS消息代理具有队列和主题。基于AMQP的消息代理(如RabbitMQ)具有交换和队列。 Apache Kafka有主题,AWS Kinesis有流,AWS SQS有队列。更重要的是,一些消息代理提供了比本章中描述的消息和通道抽象更灵活的消息机制。
这里描述的几乎所有消息代理都支持点对点和发布-订阅通道。唯一的例外是AWS SQS,它仅支持点对点通道。
现在让我们来看看基于代理的消息的好处和弊端。
基于代理的消息的好处和弊端
使用消息有以下很多好处。
- 松耦合:客户端发起请求时只要发送给特定的通道即可,客户端完全不需要感知服务实例的情况,客户端不需要使用服务发现机制去获得服务实例的网络位置。
- 消息缓存:消息代理可以在消息被处理之前一直缓存消息。像HTTP这样的同步请求/响应协议,在交换数据时,发送方和接收方必须同时在线。然而,在使用消息机制的情况下,消息会在队列中缓存,直到它们被接收方处理。这就意味着,例如,即使订单处理系统暂时离线或不可用,在线商店仍旧能够接受客户的订单。订单消息将会在队列中缓存(并不会丢失)。
- 灵活的通信:消息机制支持前面提到的所有交互方式。
- 明确的进程间通信:基于RPC的机制总是企图让远程服务调用跟本地调用看上去没什么区别(在客户端和服务端同时使用远程调用代理)。然而,因为物理定律(如服务器不可预计的硬件失效)和可能的局部故障,远程和本地调用还是大相径庭的。消息机制让这些差异变得很明确,这样程序员不会陷入一种“太平盛世”的错觉。
然而,消息机制也有如下一些弊端。
- 潜在的性能瓶颈:消息代理可能存在性能瓶颈。幸运的是,许多现代消息代理都支持高度的横向扩展。
- 潜在的单点故障:消息代理的高可用性至关重要,否则系统整体的可靠性将受到影响。幸运的是,大多数现代消息代理都是高可用的。
- 额外的操作复杂性:消息系统是一个必须独立安装、配置和运维的系统组件。
现在我们来深入看看基于消息的架构可能会遇到的一些设计难题。
3.3.5 处理并发和消息顺序
挑战之一是如何在保留消息顺序的同时,横向扩展多个接收方的实例。为了同时处理消息,拥有多个实例是一个常见的要求。而且,即使单个服务实例也可能使用线程来同时处理多个消息。使用多个线程和服务实例来并发处理消息可以提高应用程序的吞吐量。但同时处理消息的挑战是确保每个消息只被处理一次,并且是按照它们发送的顺序来处理的。
例如,假设有3个相同的接收方实例从同一个点对点通道读取消息,发送方按顺序发布了Order Created、Order Updated和Order Cancelled这3个事件消息。简单的消息实现可能就会同时将每个消息给不同的接收方。若由于网络问题或JVM垃圾收集等原因导致延迟,消息可能没有按照它们发出时的顺序被处理,这将导致奇怪的行为。理论上,服务实例可能会在另一个服务处理Order Created消息之前处理Order Cancelled消息。
现代消息代理(如Apache Kafka和AWS Kinesis)使用的常见解决方案是使用分片(分区)通道。图3-11展示了这是如何工作的。该解决方案分为三个部分。
1.分片通道由两个或多个分片组成,每个分片的行为类似于一个通道。
2.发送方在消息头部指定分片键,通常是任意字符串或字节序列。消息代理使用分片键将消息分配给特定的分片。例如,它可以通过计算分片键的散列来选择分片。
3.消息代理将接收方的多个实例组合在一起,并将它们视为相同的逻辑接收方。例如,Apache Kafka使用术语消费者组。消息代理将每个分片分配给单个接收器。它在接收方启动和关闭时重新分配分片。
在此示例中,每个Order事件消息都将orderId作为其分片键。特定订单的每个事件都发布到同一个分片,而且该分片中的消息始终由同一个接收方实例读取。因此,这样做就能够保证按顺序处理这些消息。
3.3.6 处理重复消息
使用消息机制时必须解决的另一个挑战是处理重复消息。理想情况下,消息代理应该只传递一次消息,但保证有且仅有一次的消息传递通常成本很高。相反,大多数消息代理承诺至少成功传递一次消息。
当系统正常工作时,保证传递的消息代理只会传递一次消息。但是客户端、网络或消息代理的故障可能导致消息被多次传递。假设客户端在处理消息后、发送确认消息之前,它的数据库崩溃了,这时消息代理将再次发送未确认的消息,在数据库重新启动时向该客户端或客户端的另一个副本发送。
理想情况下,你应该使用消息代理,在重新传递消息时保留排序。想象一下,客户端处理Order Created事件,然后紧接着收到了同一Order的Order Cancelled事件,但这时候Order Created事件还没有得到确认。消息代理应重新投递Order Created和Order Cancelled事件。如果它仅重新发送Order Created,客户可以撤回Order的取消。
处理重复消息有以下两种不同的方法。
- 编写幂等消息处理程序。
- 跟踪消息并丢弃重复项。
我们来逐一进行分析。
编写幂等消息处理器
如果应用程序处理消息的逻辑是满足幂等的,那么重复的消息就是无害的。所谓应用程序的幂等性,是指即使这个应用被相同输入参数多次重复调用时,也不会产生额外的效果。例如,取消一个已经被取消的订单,就是一个幂等性操作。同样,创建一个已经存在的订单操作也必是这样。满足幂等的消息处理程序可以被放心地执行多次(而不会引起错误的结果)只要消息代理在重新传递消息时保持相同的消息顺序。
不幸的是,应用程序逻辑通常不是幂等的。或者你可能正在使用消息代理,该消息代理在重新传递消息时不会保留排序。重复或无序消息可能会导致错误。在这种情况下,你必须编写跟踪消息并丢弃重复消息的消息处理程序。
跟踪消息并丢弃重复消息
例如,考虑一个授权消费者信用卡的消息处理程序。它必须为每个订单仅执行一次信用卡授权操作。这段应用程序逻辑在每次调用时都会产生不同的效果。如果重复消息导致消息处理程序多次执行该逻辑,则应用程序的行为将不正确。执行此类应用程序逻辑的消息处理程序必须通过检测和丢弃重复消息而成为幂等的。
一个简单的解决方案是消息接收方使用message id跟踪它已处理的消息并丢弃任何重复项。例如,它可以存储它在数据库表中使用的每条消息的message id。图3-12显示了如何使用专用表执行此操作。
当接收方处理消息时,它将消息的message id作为创建和更新业务实体的事务的一部分记录在数据库表中。在此示例中,接收方将包含message id的行插入PROCESSED_MESSAGES表。如果消息是重复的,则INSERT将失败,接收方可以选择丢弃该消息。
另一个选项是消息处理程序在应用程序表,而不是专用表中记录message id。当使用具有受限事务模型的NoSQL数据库时,此方法特别有用,因为NoSQL数据库通常不支持将针对两个表的更新作为数据库的事务。第7章将介绍这种方法的一个例子。
3.3.7 事务性消息
服务通常需要在更新数据库的事务中发布消息。例如,在本书中,你将看到在创建或更新业务实体时发布领域事件的例子。数据库更新和消息发送都必须在事务中进行。否则,服务可能会更新数据库,然后在发送消息之前崩溃。如果服务不以原子方式执行这两个操作,则类似的故障可能使系统处于不一致状态。
传统的解决办法是在数据库和消息代理之间使用分布式事务。然而,在第4章中你会了解到,分布式事务对现今的应用程序而言并不是一个很好的选择。而且,很多新的消息代理,例如Apache Kafka并不支持分布式事务。
因此,应用必须采用不同的机制确保消息的可靠发送,我们在本章会介绍一些方案。
使用数据库表作为消息队列
我们假设你的应用程序正在使用关系型数据库。可靠地发布消息的直接方法是应用事务性发件箱模式。此模式使用数据库表作为临时消息队列。如图3-13所示,发送消息的服务有一个OUTBOX数据库表。作为创建、更新和删除业务对象的数据库事务的一部分,服务通过将消息插入到OUTBOX表中来发送消息。这样可以保证原子性,因为这是本地的ACID事务。
OUTBOX表充当临时消息队列。MessageRelay是一个读取OUTBOX表并将消息发布到消息代理的组件。
你可以对某些NoSQL数据库使用类似的方法。作为record存储在数据库中的每个业务实体都有一个属性,该属性是需要发布的消息列表。当服务更新数据库中的实体时,它会向该列表附加一条消息。这是原子的,因为它是通过单个数据库操作完成的。但是,挑战在于有效地找到那些拥有事件并发布事件的业务实体。
将消息从数据库移动到消息代理并对外发送有两种不同的方法。我们来逐一分析。
通过轮询模式发布事件
如果应用程序使用关系型数据库,则对外发布插入OUTBOX表的消息的一种非常简单的方法是让MessageRelay在表中轮询未发布的消息。它定期查询表:
接下来,MessageRelay把这些消息发送给消息代理,它把每个消息发送给它们的目的消息通道。最后,MessageRelay把完成发送的消息从OUTBOX表中删除。
轮询数据库是一种在小规模下运行良好的简单方法。其弊端是经常轮询数据库可能造成昂贵的开销(导致数据库性能下降)。此外,你是否可以将此方法与NoSQL数据库一起使用取决于NoSQL数据库支持的查询功能。这是因为应用程序必须查询业务实体,而不是查询OUTBOX表,这可能会无法有效地执行。由于这些弊端和限制,通常在某些情况下,更好的办法是使用更复杂和高性能的方法,来拖尾(tailing)数据库事务日志。
使用事务日志拖尾模式发布事件
更加复杂的实现方式,是让MessageRelay拖尾数据库的事务日志文件(也称为提交日志)。每次应用程序提交到数据库的更新都对应着数据库事务日志中的一个条目。事务日志挖掘器可以读取事务日志,把每条跟消息有关的记录发送给消息代理。图3-14展示了这个方案的具体实现方式。
Transaction-Log-Miner读取事务日志条目。它将对应于插入消息的每个相关日志条目转换为消息,并将该消息发布到消息代理。此方法可用于发布写入关系型数据库中的OUTBOX表的消息或附加到NoSQL数据库中的记录的消息。
这个方案有一些实际的应用案例和实现可供参考:
- Debezium(http://debezium.io ):一个开源项目,它可以向Apache Kafka消息代理发布数据库更改。
- LinkedIn Databus(https://github.com/linkedin/databus ):一个开源项目,用于挖掘Oracle事务日志文件并将更改发布为事件。 LinkedIn使用Databus将各种派生数据存储与记录系统同步。
- DynamoDB streams(http://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Streams.html ):包含过去24小时内的DynamoDB表更改(创建、更新和删除)的序列,并且这个序列是按时间排序的。应用程序可以从流中读取这些更改,例如,将它们作为事件发布。
- Eventuate Tram(https://github.com/eventuate-tram/eventuate-tram-core ):这是我自己的开源事务消息库,它使用MySQL binlog协议、Postgres WAL或轮询来读取对OUTBOX表所做的更改并将它们发布到Apache Kafka。
虽然这种方法看似晦涩,但效果非常好。挑战在于实现它需要做一些开发努力。例如,你可以需要编写调用特定数据库API的底层代码。或者,你可以使用开源框架(如Debezium)将应用程序对MySQL、Postgres或MongoDB所做的更改发布到Apache Kafka。使用Debezium的缺点是它的重点是捕获数据库级别的更改,但是用于发送和接收消息的API超出了其范围。这就是我创建Eventuate Tram框架的原因,该框架可提供消息传递API以及事务拖尾和轮询。
3.3.8 消息相关的类库和框架
服务需要使用库来发送和接收消息。一种方法是使用消息代理的客户端库,但是直接使用这样的库有几个问题:
- 客户端库将发布消息的业务逻辑耦合到消息代理API。
- 消息代理的客户端库通常是非常底层的,需要多行代码才能发送或接收消息。作为开发人员,你不希望重复编写类似的代码。另外,作为本书的作者,我不希望我的演示代码与重复性的底层消息实现代码混杂在一起。
- 客户端库通常只提供发送和接收消息的基本机制,不支持更高级别的交互方式。
更好的方法是使用更高级别的库或框架来隐藏底层的细节,并直接支持更高级别的交互方式。为简单起见,本书中的示例使用了我的Eventuate Tram框架。它有一个简单易用的API,可以隐藏使用消息代理的复杂性。除了用于发送和接收消息的API之外,Eventuate Tram还支持更高级别的交互方式,例如异步请求/响应和领域事件发布。
什么?!为什么要使用Eventuate框架?
本书中的代码示例使用我开发的开源Eventuate框架,这个框架是为事务性消息、事件溯源和Saga量身定做的。之所以选择使用我的框架,是因为它与依赖注入和Spring框架不同,对于微服务架构所需的许多功能,目前开发者社区还没有广泛采用的框架。如果没有Eventuate Tram框架,许多演示代码必须直接使用底层消息传递API,这会使它们变得更加复杂并且会模糊重要的概念。或者使用一个没有被广泛采用的框架,这也会引起批评。
相反,这些演示代码使用Eventuate Tram框架,该框架具有隐藏实现细节的简单易懂的API。你可以在应用程序中使用这些框架。或者,你可以研究Eventuate Tram框架并自己重新实现这些概念。
Eventuate Tram还实现了两个重要机制:
- 事务性消息机制:它将消息作为数据库事务的一部分发布。
- 重复消息检测机制:Eventuate Tram支持消息的接收方检测并丢弃重复消息,这对于确保接收方只准确处理消息一次至关重要,如3.3.6节所述。
我们来看一看Eventuate Tram的API。
基础消息API
基础消息API由两个Java接口组成:MessageProducer和MessageConsumer。发送方服务使用MessageProducer接口将消息发布到消息通道。以下是使用此接口的例子:
接收方服务使用MessageConsumer接口订阅消息:
MessageProducer和MessageConsumer是用于异步请求/响应和领域事件发布的更高级API的基础。
现在我们来谈谈如何发布和订阅事件。
领域事件发布API
Eventuate Tram具有用于发布和使用领域事件的API。第5章将解释领域事件是聚合(业务对象)在创建、更新或删除时触发的事件。服务使用DomainEventPublisher接口发布领域事件。如下是一个具体的例子:
服务使用DomainEventDispatcher消费领域事件。如下是一个具体的例子:
事件式消息不是Eventuate Tram支持的唯一高级消息传递模式。它还支持基于命令/回复的消息机制。
基于命令/回复的消息
客户端可以使用CommandProducer接口向服务发送命令消息。例如:
服务使用CommandDispatcher类接收命令消息。CommandDispatcher使用Message-Consumer接口来订阅指定的事件。它将每个命令消息分派给适当的处理程序。如下是一个具体的例子:
在本书中,你将看到使用这些API发送和接收消息的代码示例。
如你所见,Eventuate Tram框架为Java应用程序实现事务性消息。它提供了一个相对底层的API,用于以事务方式发送和接收消息。它还提供了更高级别的API,用于发布和使用领域事件以及发送和处理命令式消息。
现在让我们看一下使用异步消息来提高可用性的服务设计方法。
3.4 使用异步消息提高可用性
正如你所见,我们需要在不同的进程间通信机制之间权衡利弊。其中的一个重要权衡因素,就是进程间通信机制与系统的可用性之间的关系。在本节中,你会看到,与其他服务采用同步通信机制作为请求处理的一部分,会对系统的可用性带来影响。因此,应该尽可能选择异步通信机制来处理服务之间的调用。
我们先看看同步消息带来的具体问题,以及这些问题是如何影响可用性的。
3.4.1 同步消息会降低可用性
REST是一种非常流行的进程间通信机制。你可能很想将它用于服务间通信。但是,REST的问题在于它是一个同步协议:HTTP客户端必须等待服务端返回响应。只要服务使用同步协议进行通信,就可能降低应用程序的可用性。
要了解原因,请考虑图3-15中显示的情况。Order Service有一个用于创建Order的REST API。它调用Consumer Service和Restaurant Service来验证Order。这两个服务都有REST API。
创建订单的流程如下:
1.客户端发起HTTP POST/orders请求到Order Service。
- Order Service通过向Consumer Service发起一个HTTP GET/consumers/id请求获取客户信息。
- Order Service通过向Restaurant Service发起一个HTTP GET/restaurant/id请求获取餐馆信息。
- Order Service使用客户和餐馆信息来验证请求。
- Order Service创建一个订单。
- Order Service向客户端发出HTTP响应作为客户端调用的返回。
因为这些服务都使用HTTP,所以它们必须同时在线才能够完成FTGO应用程序中的CreateOrder这个请求。如果上述任意一个服务出了问题,FTGO应用程序将无法创建新订单。从统计意义上讲,一个系统操作的可用性,由其所涉及的所有服务共同决定。如果Order Service服务和它所调用的两个服务的可用性都是99.53,那么这个系统操作的整体可用性就是99.5%3,大约是98.5%,这其实是个非常低的数值了。每一个额外增加的服务参与到其中,都会更进一步降低整体系统操作的可用性。
这个问题不仅仅跟基于REST的通信有关。当服务必须从另外一个服务获取信息后,才能够返回它客户端的调用,这种情况都会导致可用性问题。即使服务使用异步消息的请求/响应方式的交互进行通信,也存在此问题。例如,如果通过消息代理向Consumer Service发送消息然后等待响应,则Order Service的可用性将会降低。
如果你想最大化一个系统的可用性,就应该设法最小化系统的同步操作量。我们来看看如何实现。
3.4.2 消除同步交互
在必须处理同步请求的情况下,仍旧有一些方式可以最大限度地降低同步通信的数量。当然,最彻底的方式还是把所有的服务都改成异步API,但是在现实情况下这并不太可能,例如一些公用API总是采用RESTful方式,另外有些情况下服务也必须被设计为采用同步API。
幸运的是,总有一些办法在不发出同步调用请求的情况下来处理同步的调用请求。我们看看有哪些方法。
使用异步交互模式
理想的情况是,所有的交互都应该使用本章之前所描述的异步交互。例如,让我们假设FTGO采用请求/异步响应的交互方式来创建订单。客户端可以通过向Order Service发送一个请求消息交换消息的方式创建订单。这个服务随即采用异步交换消息的方式跟其他服务通信完成订单的创建,并向客户端发回一个返回消息。图3-16展示了具体的设计。
客户端和服务端使用消息通道发送消息来实现异步通信。这个交互过程中不存在堵塞等待响应的情况。
这样的架构非常有弹性,因为消息代理会一直缓存消息,直到有服务端接收并处理消息。然而,问题是服务很多情况下都采用类似REST这样的同步通信协议的外部API,并且要求对请求立即做出响应。
在这种情况下,我们可以采用复制数据的方式来提高可用性。我们看看如何实现。
复制数据
在请求处理环节中减少同步请求的另外一种办法,就是进行数据复制。服务维护一个数据副本,这些数据是服务在处理请求时需要使用的。这些数据的源头会在数据变化时发出消息,服务订阅这些消息来确保数据副本的实时更新。例如,Order Service可以维护来自Consumer Service和Restaurant Service的数据副本。在这种情况下,Order Service可以在不与其他服务进行交互的情况下完成订单创建的请求。图3-17展示了具体的设计。
Consumer Service和Restaurant Service在它们的数据发生变化时对外发布事件。Order Service服务订阅这些事件,并据此更新自己的数据副本。
在有些情况下,复制数据是一种有用的方式,第5章中还会有更具体的讨论,描述Order Service如何从Restaurant Service复制数据以验证菜单并定价。然而,复制数据的一个弊端在于,有时候被复制的数据量巨大,会导致效率低下。例如,让Order Service服务去维护一个Consumer Service的数据副本并不可行,因为数据量实在太大了。复制的另外一个弊端在于,复制数据并没有从根本上解决服务如何更新其他服务所拥有的数据这个问题。
解决该问题的一种方法是让服务暂缓与其他服务交互,直到它给客户端发送了响应。接下来我们将看看它是如何工作的。
先返回响应,再完成处理
另外一种在请求处理环节消除同步通信的办法如下:
1.仅使用本地的数据来完成请求的验证。
2.更新数据库,包括向OUTBOX表插入消息。
3.向客户端返回响应。
当处理请求时,服务并不需要与其他服务直接进行同步交互。取而代之的是,服务异步向其他的服务发送消息。这种方式确保了服务之间的松耦合。正如我们将在下一章看到的,这是通过Saga实现的。
例如,Order Service可以用这种方式创建一个未经验证(Pending)状态的订单,然后通过异步交互的方式直接跟其他服务通信来完成验证。图3-18展示了createOrder()被调用时发生的具体过程。
事件的顺序是:
- Order Service在PENDING状态下创建订单。
- Order Service返回包含订单ID的响应给客户。
- Order Service向Consumer Service发送ValidateConsumerInfo消息。
- Order Service向Restaurant Service发送ValidateOrderDetails消息。
- Consumer Service接收ValidateConsumerInfo消息,验证消费者是否可以下订单,并向Order Service发送ConsumerValidated消息。
- Restaurant Service收到ValidateOrderDetails消息,验证菜单项是否有效以及餐馆是否可以交付订单的交付地址,并向Order Service发送OrderDetailsValidated消息。
- Order Service接收ConsumerValidated和OrderDetailsValidated,并将订单状态更改为VALIDATED。
- ……
Order Service可以按任意顺序接收ConsumerValidated和OrderDetails-Validated消息。它通过更改订单状态来跟踪它首先收到的消息。如果它首先收到ConsumerValidated,它会将订单状态更改为CONSUMER_VALIDATED;如果它首先收到OrderDetailsValidated消息,则会将其状态更改为ORDER_DETAILS_VALIDATED。Order Service在收到其他消息时将订单状态更改为VALIDATED。
订单验证后,Order Service完成订单创建过程的其余部分,这些细节将在下一章中讨论。这种方法的优点在于,即使Consumer Service中断,Order Service仍然会创建订单并响应其客户。最终,Consumer Service将重新启动并处理任何排队的消息,并且验证订单。
在完全处理请求之前响应服务的弊端是它使客户端更复杂。例如,Order Service在返回响应时对新创建的订单的状态提供最低限度的保证。它会在验证订单并授权消费者的信用卡之前立即创建订单并返回。因此,为了使客户端知道订单是否已成功创建,要么必须定期轮询,要么Order Service必须向客户端发送通知消息。听起来很复杂,但是在许多情况下这是首选方法:特别是因为它还解决了我将在下一章中讨论的分布式事务管理问题。在第4章和第5章中,我将介绍Order Service如何使用这种方法。
本章小结
- 微服务架构是一种分布式架构,因此进程间通信起着关键作用。
- 仔细管理服务API的演化至关重要。向后兼容的更改是最容易进行的,因为它们不会影响客户端。如果对服务的API进行重大更改,通常需要同时支持旧版本和新版本,直到客户端升级为止。
- 有许多进程间通信技术,每种技术都有不同的利弊。一个关键的设计决策是选择同步远程过程调用模式或异步消息模式。基于同步远程过程调用的协议(如REST)是最容易使用的。但是,理想情况下,服务应使用异步消息进行通信,以提高可用性。
- 为了防止故障通过系统层层蔓延,使用同步协议服务的客户端必须设计成能够处理局部故障,这些故障是在被调用的服务停机或表现出高延迟时发生的。特别是,它必须在发出请求时使用超时,限制未完成请求的数量,并使用断路器模式来避免调用失败的服务。
- 使用同步协议的架构必须包含服务发现机制,以便客户端确定服务实例的网络位置。最简单的方法是使用部署平台实现的服务发现机制:服务器端发现和第三方注册模式。但另一种方法是在应用程序级别实现服务发现:客户端发现和自注册模式。它需要的工作量更大,但它确实可以处理服务在多个部署平台上运行的场景。
- 设计基于消息的架构的一种好方法是使用消息和通道模型,它抽象底层消息系统的细节。然后,你可以将该设计映射到特定的消息基础结构,该基础结构通常基于消息代理。
- 使用消息机制的一个关键挑战是以原子化的方式同时完成数据库更新和发布消息。一个好的解决方案是使用事务性发件箱模式,并首先将消息作为数据库事务的一部分写入数据库。然后,一个单独的进程使用轮询发布者模式或事务日志拖尾模式从数据库中检索消息,并将其发布给消息代理。