数据流的类型
微服务之间的数据交互方式通常有两种:应用服务调用和领域事件驱动机制。
领域事件驱动机制更多的用于不同微服务之间的集成,实现微服务之间的解耦。事件库(表)可以用于微服务之间的数据对账,在应用、网络等出现问题后,可以实现源和目的端的数据比对,在数据暂时不一致的情况下仍可根据这些数据完成后续业务处理流程,保证微服务之间数据的最终一致性。
应用服务调用方式通常应用于实时性要求高的业务场景,但一旦涉及到跨微服务的数据修改,将会增加分布式事务控制成本,影响系统性能,微服务之间的耦合度也会变高。
无论何时您想要将某些数据发送到不共享内存的另一个进程,例如,只要您想通过网络发送数据或将其写入文件,就需要将它编码为一个字节序列。然后我们讨论了做这个的各种不同的编码。我们讨论了向前和向后的兼容性,这对于可演化性来说非常重要(通过允许您独立升级系统的不同部分,而不必一次改变所有内容,可以轻松地进行更改) 。兼容性是编码数据的一个进程和解码它的另一个进程之间的一种关系。
数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码。
RPC 和REST API ,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
异步消息传递(使用消息代理或参与者) ,其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码。
数据库中的数据流
在数据库中,写入数据库的过程对数据进行编码,从数据库读取的过程对数据进行解码。可能只有一个进程访问数据库,在这种情况下,读者只是相同进程的后续版本,在这种情况下,您可以考虑将数据库中的内容存储为向未来的自我发送消息。向后兼容性显然是必要的。否则你未来的自己将无法解码你以前写的东西。
一般来说,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,或者它们可能只是几个相同服务的实例(为了可扩展性或容错性而并行运行) 。无论哪种方式,在应用程序发生变化的环境中,访问数据库的某些进程可能会运行较新的代码,有些进程可能会运行较旧的代码,例如,因为新版本当前正在部署在滚动升级,所以有些实例已经更新,而其他实例尚未更新。
这意味着数据库中的一个值可能会被更新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,数据库也经常需要向前兼容。但是,还有一个额外的障碍。假设您将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的领域完整,即使它不能被解释。
前面讨论的编码格式支持未知域的保存,但是有时候需要在应用程序层面保持谨慎,如下图所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。解决这个问题不是一个难题,你只需要意识到它。
在不同的时间写入不同的值
数据库通常允许任何时候更新任何值。这意味着在一个单一的数据库中,可能有一些值是五毫秒前写的,而一些值是五年前写的。在部署应用程序的新版本(至少是服务器端应用程序)时,您可能会在几分钟内完全用新版本替换旧版本。数据库内容也是如此:五年前的数据仍然存在于原始编码中,除非您已经明确地重写了它。这种观察有时被总结为数据超出代码。
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据^v 读取旧行时,数据库将填充编码数据中缺少的任何列的空值在磁盘上。LinkedIn 的文档数据库Espresso 使用Avro 存储,允许它使用Avro 的模式演变规则。
因此,架构演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用模式的各种历史版本编码的记录。
归档存储
也许您不时为数据库创建一个快照,例如备份或加载到数据仓库。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你正在复制数据,那么你可能会一直对数据的副本进行编码。
由于数据转储是一次写入的,而且以后是不可变的,所以Avro 对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如Parquet 。
服务中的数据流:REST 与RPC
当您需要通过网络进行通信的进程时,安排该通信的方式有几种。最常见的安排是有两个角色:客户端和服务器。服务器通过网络公开API ,并且客户端可以连接到服务器以向该API 发出请求。服务器公开的API 被称为服务。Web 以这种方式工作:客户(Web 浏览器)向Web 服务器发出请求,使GET 请求下载HTML ,CSS,JavaScript,图像等,并向POST 请求提交数据到服务器。API 包含一组标准的协议和数据格式(HTTP,URL,SSL/TLS,HTML 等) 。由于网络浏览器,网络服务器和网站作者大多同意这些标准,您可以使用任何网络浏览器访问任何网站。
Web 浏览器不是唯一的客户端类型。例如,在移动设备或桌面计算机上运行的本地应用程序也可以向服务器发出网络请求,并且在Web 浏览器内运行的客户端JavaScript 应用程序可以使用XMLHttpRequest 成为HTTP 客户端(该技术被称为Ajax ) 。在这种情况下,服务器的响应通常不是用于显示给人的HTML ,而是用于便于客户端应用程序代码(如JSON )进一步处理的编码数据。尽管HTTP 可能被用作传输协议,但顶层实现的API 是特定于应用程序的,客户端和服务器需要就该API 的细节达成一致。
此外,服务器本身可以是另一个服务的客户端(例如,典型的Web 应用服务器充当数据库的客户端) 。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 面向服务的体系结构(service-oriented architecture,SOA) ,最近被改进和更名为 微服务架构 。
在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在第2 章 中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的API ,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出。这种限制提供了一定程度的封装:服务可以对客户可以做什么和不可以做什么施加细粒度的限制。
面向服务/ 微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API 之间兼容——正是我们所做的本章一直在谈论。
Web 服务
当服务使用HTTP 作为底层通信协议时,可称之为Web 服务。这可能是一个小错误,因为Web 服务不仅在Web 上使用,而且在几个不同的环境中使用。例如:
运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用Ajax 的JavaScript web 应用程序)通过HTTP 向服务发出请求。这些请求通常通过公共互联网进行。
一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/ 微型架构的一部分。 (支持这种用例的软件有时被称为 中间件(middleware) )
一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API ,或用于共享访问用户数据的OAuth 。
有两种流行的Web 服务方法:REST 和SOAP 。他们在哲学方面几乎是截然相反的,往往是各自支持者之间的激烈辩论(即使在每个阵营内也有很多争论。例如,HATEOAS(超媒体作为应用程序状态的引擎)经常引发讨论。 )REST 不是一个协议,而是一个基于HTTP 原则的设计哲学。它强调简单的数据格式,使用URL 来标识资源,并使用HTTP 功能进行缓存控制,身份验证和内容类型协商。与SOAP 相比,REST 已经越来越受欢迎,至少在跨组织服务集成的背景下,并经常与微服务相关。根据REST 原则设计的API 称为RESTful 。
相比之下,SOAP 是用于制作网络API 请求的基于XML 的协议(尽管首字母缩写词相似,SOAP 并不是SOA 的要求。SOAP 是一种特殊的技术,而SOA 是构建系统的一般方法。 ) 。虽然它最常用于HTTP ,但其目的是独立于HTTP ,并避免使用大多数HTTP 功能。相反,它带有庞大而复杂的多种相关标准(Web 服务框架,称为 WS-*
) ,它们增加了各种功能。SOAP Web 服务的API 使用称为Web 服务描述语言(WSDL)的基于XML 的语言来描述。WSDL 支持代码生成,客户端可以使用本地类和方法调用(编码为XML 消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少。
由于WSDL 的设计不是人类可读的,而且由于SOAP 消息通常是手动构建的过于复杂,所以SOAP 的用户在很大程度上依赖于工具支持,代码生成和IDE 。对于SOAP 供应商不支持的编程语言的用户来说,与SOAP 服务的集成是困难的。尽管SOAP 及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题。由于所有这些原因,尽管许多大型企业仍然使用SOAP ,但在大多数小公司中已经不再受到青睐。REST 风格的API 倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如OpenAPI ,也称为Swagger )可用于描述RESTful API 并生成文档。
远程过程调用(RPC)的问题
Web 服务仅仅是通过网络进行API 请求的一系列技术的最新版本,其中许多技术受到了大量的炒作,但是存在严重的问题。Enterprise JavaBeans(EJB)和Java 的远程方法调用(RMI)仅限于Java 。分布式组件对象模型(DCOM)仅限于Microsoft 平台。公共对象请求代理体系结构(CORBA)过于复杂,不提供前向或后向兼容性。
所有这些都是基于 远程过程调用(RPC)的思想,该过程调用自20 世纪70 年代以来一直存在。RPC 模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明) 。尽管RPC 起初看起来很方便,但这种方法根本上是有缺陷的。网络请求与本地函数调用非常不同:
本地函数调用是可预测的,并且成功或失败,这仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃) 。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过。
如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重(幂等(idempotence) )机制。本地函数调用没有这个问题。
每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:在不到一毫秒的时间内它可能会完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完全一样的东西。
调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。没关系,如果参数是像数字或字符串这样的基本类型,但是对于较大的对象很快就会变成问题。
客户端和服务可以用不同的编程语言实现,所以RPC 框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型。所有这些因素意味着尝试使远程服务看起来像编程语言中的本地对象一样毫无意义,因为这是一个根本不同的事情。REST 的部分吸引力在于,它并不试图隐藏它是一个网络协议的事实(尽管这似乎并没有阻止人们在REST 之上构建RPC 库) 。
RPC 的当前方向
尽管有这样那样的问题,RPC 不会消失。在本章提到的所有编码的基础上构建了各种RPC 框架:例如,Thrift 和Avro 带有RPC 支持,gRPC 是使用Protocol Buffers 的RPC 实现,Finagle 也使用Thrift ,Rest.li 使用JSON over HTTP 。这种新一代的RPC 框架更加明确的是,远程请求与本地函数调用不同。例如,Finagle 和Rest.li 使用futures (promises)来封装可能失败的异步操作。Futures 还可以简化需要并行发出多项服务的情况,并将其结果合并。gRPC 支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应。
其中一些框架还提供服务发现,即允许客户端找出在哪个IP 地址和端口号上可以找到特定的服务。使用二进制编码格式的自定义RPC 协议可以实现比通用的JSON over REST 更好的性能。但是,RESTful API 还有其他一些显著的优点:对于实验和调试(只需使用Web 浏览器或命令行工具curl ,无需任何代码生成或软件安装即可向其请求) ,它是受支持的所有的主流编程语言和平台,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。由于这些原因,REST 似乎是公共API 的主要风格。RPC 框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。
数据编码与RPC 的演化
对于可演化性,重要的是可以独立更改和部署RPC 客户端和服务器。与通过数据库流动的数据相比,我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,您只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。RPC 方案的前后向兼容性属性从它使用的编码方式中继承:
Thrift,gRPC(Protobuf)和Avro RPC 可以根据相应编码格式的兼容性规则进行演变。
在SOAP 中,请求和响应是使用XML 模式指定的。这些可以演变,但有一些微妙的陷阱。
RESTful API 通常使用JSON (没有正式指定的模式)用于响应,以及用于请求的JSON 或URI 编码/ 表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。
由于RPC 经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务API 。关于API 版本化应该如何工作(即,客户端如何指示它想要使用哪个版本的API )没有一致意见) 。对于RESTful API ,常用的方法是在URL 或HTTP Accept 头中使用版本号。对于使用API 密钥来标识特定客户端的服务,另一种选择是将客户端请求的API 版本存储在服务器上,并允许通过单独的管理界面更新该版本选项。
消息传递中的数据流
我们一直在研究从一个过程到另一个过程的编码数据流的不同方式。到目前为止,我们已经讨论了REST 和RPC (其中一个进程通过网络向另一个进程发送请求并期望尽可能快的响应)以及数据库(一个进程写入编码数据,另一个进程在将来再次读取) 。我们将简要介绍一下RPC 和数据库之间的异步消息传递系统。它们与RPC 类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。
与直接RPC 相比,使用消息代理有几个优点:
如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
避免发件人需要知道收件人的IP 地址和端口号(这在虚拟机经常出入的云部署中特别有用) 。
它允许将一条消息发送给多个收件人。
将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者) 。
然而,与RPC 相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。
消息掮客
过去,信息掮客主要是TIBCO ,IBM WebSphere 和webMethods 等公司的商业软件的秀场。最近像RabbitMQ ,ActiveMQ,HornetQ,NATS 和Apache Kafka 这样的开源实现已经流行起来。详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给一个或多个消费者或订阅者到那个队列或主题。在同一主题上可以有许多生产者和许多消费者。
一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起) ,或者发送给原始消息的发送者使用的回复队列(允许请求/ 响应数据流,类似于RPC ) 。消息代理通常不会执行任何特定的数据模型,消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题。
分布式的Actor 框架
Actor 模型是单个进程中并发的编程模型。逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题) 。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享) ,它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。
在分布式的行为者框架中,这个编程模型被用来跨越多个节点来扩展应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
位置透明在Actor 模型中比在RPC 中效果更好,因为Actor 模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用参与者模型时,本地和远程通信之间的基本不匹配是较少的。分布式的Actor 框架实质上是将消息代理和角色编程模型集成到一个框架中。但是,如果要执行基于角色的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
三个流行的分布式Actor 框架处理消息编码如下:
默认情况下,Akka 使用Java 的内置序列化,不提供前向或后向兼容性。但是,你可以用类似缓冲区的东西替代它,从而获得滚动升级的能力。
Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集。像Akka 一样,可以使用自定义序列化插件。
在Erlang OTP 中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能) 。滚动升级是可能的,但需要仔细计划。一个新的实验性的maps 数据类型(2014 年在Erlang R17 中引入的类似于JSON 的结构)可能使得这个数据类型在未来更容易。