数据流的类型

在本章的开始部分,我们曾经说过,无论何时您想要将某些数据发送到不共享内存的另一个进程,例如,只要您想通过网络发送数据或将其写入文件,就需要将它编码为一个字节序列。然后我们讨论了做这个的各种不同的编码。我们讨论了向前和向后的兼容性,这对于可演化性来说非常重要(通过允许您独立升级系统的不同部分,而不必一次改变所有内容,可以轻松地进行更改)。兼容性是编码数据的一个进程和解码它的另一个进程之间的一种关系。

这是一个相当抽象的概念 - 数据可以通过多种方式从一个流程流向另一个流程。谁编码数据,谁解码?在本章的其余部分中,我们将探讨数据如何在流程之间流动的一些最常见的方式:

数据库中的数据流

在数据库中,写入数据库的过程对数据进行编码,从数据库读取的过程对数据进行解码。可能只有一个进程访问数据库,在这种情况下,读者只是相同进程的后续版本 - 在这种情况下,您可以考虑将数据库中的内容存储为向未来的自我发送消息。

向后兼容性显然是必要的。否则你未来的自己将无法解码你以前写的东西。

一般来说,几个不同的进程同时访问数据库是很常见的。这些进程可能是几个不同的应用程序或服务,或者它们可能只是几个相同服务的实例(为了可扩展性或容错性而并行运行)。无论哪种方式,在应用程序发生变化的环境中,访问数据库的某些进程可能会运行较新的代码,有些进程可能会运行较旧的代码,例如,因为新版本当前正在部署在滚动升级,所以有些实例已经更新,而其他实例尚未更新。

这意味着数据库中的一个值可能会被更新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,数据库也经常需要向前兼容。

但是,还有一个额外的障碍。假设您将一个字段添加到记录模式,并且较新的代码将该新字段的值写入数据库。随后,旧版本的代码(尚不知道新字段)将读取记录,更新记录并将其写回。在这种情况下,理想的行为通常是旧代码保持新的领域完整,即使它不能被解释。

前面讨论的编码格式支持未知域的保存,但是有时候需要在应用程序层面保持谨慎,如图4-7所示。例如,如果将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。

解决这个问题不是一个难题,你只需要意识到它。

数据流的类型 - 图1

图4-7 当较旧版本的应用程序更新以前由较新版本的应用程序编写的数据时,如果不小心,数据可能会丢失。

在不同的时间写入不同的值

数据库通常允许任何时候更新任何值。这意味着在一个单一的数据库中,可能有一些值是五毫秒前写的,而一些值是五年前写的。

在部署应用程序的新版本(至少是服务器端应用程序)时,您可能会在几分钟内完全用新版本替换旧版本。数据库内容也是如此:五年前的数据仍然存在于原始编码中,除非您已经明确地重写了它。这种观察有时被总结为数据超出代码。

将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据^v读取旧行时,数据库将填充编码数据中缺少的任何列的空值在磁盘上。 LinkedIn的文档数据库Espresso使用Avro存储,允许它使用Avro的模式演变规则【23】。

因此,架构演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用模式的各种历史版本编码的记录。

归档存储

也许您不时为数据库创建一个快照,例如备份或加载到数据仓库(参阅“数据仓库”)。在这种情况下,即使源数据库中的原始编码包含来自不同时代的模式版本的混合,数据转储通常也将使用最新模式进行编码。既然你正在复制数据,那么你可能会一直对数据的副本进行编码。

由于数据转储是一次写入的,而且以后是不可变的,所以Avro对象容器文件等格式非常适合。这也是一个很好的机会,可以将数据编码为面向分析的列式格式,例如Parquet(请参阅第97页的“列压缩”)。

第10章中,我们将详细讨论在档案存储中使用数据。

服务中的数据流: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 【30】)。在这种情况下,服务器的响应通常不是用于显示给人的HTML,而是用于便于客户端应用程序代码(如JSON)进一步处理的编码数据。尽管HTTP可能被用作传输协议,但顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。

此外,服务器本身可以是另一个服务的客户端(例如,典型的Web应用服务器充当数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会向另一个服务发出请求。这种构建应用程序的方式传统上被称为 面向服务的体系结构(service-oriented architecture,SOA) ,最近被改进和更名为 微服务架构 【31,32】。

在某些方面,服务类似于数据库:它们通常允许客户端提交和查询数据。但是,虽然数据库允许使用我们在第2章 中讨论的查询语言进行任意查询,但是服务公开了一个特定于应用程序的API,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出【33】。这种限制提供了一定程度的封装:服务可以对客户可以做什么和不可以做什么施加细粒度的限制。

面向服务/微服务架构的一个关键设计目标是通过使服务独立部署和演化来使应用程序更易于更改和维护。例如,每个服务应该由一个团队拥有,并且该团队应该能够经常发布新版本的服务,而不必与其他团队协调。换句话说,我们应该期望服务器和客户端的旧版本和新版本同时运行,因此服务器和客户端使用的数据编码必须在不同版本的服务API之间兼容——正是我们所做的本章一直在谈论。

Web服务

当服务使用HTTP作为底层通信协议时,可称之为Web服务。这可能是一个小错误,因为Web服务不仅在Web上使用,而且在几个不同的环境中使用。例如:

  1. 运行在用户设备上的客户端应用程序(例如,移动设备上的本地应用程序,或使用Ajax的JavaScript web应用程序)通过HTTP向服务发出请求。这些请求通常通过公共互联网进行。
  2. 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内,作为面向服务/微型架构的一部分。 (支持这种用例的软件有时被称为 中间件(middleware)
  3. 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API,或用于共享访问用户数据的OAuth。

有两种流行的Web服务方法:REST和SOAP。他们在哲学方面几乎是截然相反的,往往是各自支持者之间的激烈辩论(即使在每个阵营内也有很多争论。 例如,HATEOAS(超媒体作为应用程序状态的引擎)经常引发讨论【35】。)

REST不是一个协议,而是一个基于HTTP原则的设计哲学【34,35】。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。与SOAP相比,REST已经越来越受欢迎,至少在跨组织服务集成的背景下【36】,并经常与微服务相关[31]。根据REST原则设计的API称为RESTful。

相比之下,SOAP是用于制作网络API请求的基于XML的协议( 尽管首字母缩写词相似,SOAP并不是SOA的要求。 SOAP是一种特殊的技术,而SOA是构建系统的一般方法。)。虽然它最常用于HTTP,但其目的是独立于HTTP,并避免使用大多数HTTP功能。相反,它带有庞大而复杂的多种相关标准(Web服务框架,称为WS-*),它们增加了各种功能【37】。

SOAP Web服务的API使用称为Web服务描述语言(WSDL)的基于XML的语言来描述。 WSDL支持代码生成,客户端可以使用本地类和方法调用(编码为XML消息并由框架再次解码)访问远程服务。这在静态类型编程语言中非常有用,但在动态类型编程语言中很少(参阅“代码生成和动态类型化语言”)。

由于WSDL的设计不是人类可读的,而且由于SOAP消息通常是手动构建的过于复杂,所以SOAP的用户在很大程度上依赖于工具支持,代码生成和IDE【38】。对于SOAP供应商不支持的编程语言的用户来说,与SOAP服务的集成是困难的。

尽管SOAP及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题【39】。由于所有这些原因,尽管许多大型企业仍然使用SOAP,但在大多数小公司中已经不再受到青睐。

REST风格的API倾向于更简单的方法,通常涉及较少的代码生成和自动化工具。定义格式(如OpenAPI,也称为Swagger 【40】)可用于描述RESTful API并生成文档。

远程过程调用(RPC)的问题

Web服务仅仅是通过网络进行API请求的一系列技术的最新版本,其中许多技术受到了大量的炒作,但是存在严重的问题。 Enterprise JavaBeans(EJB)和Java的远程方法调用(RMI)仅限于Java。分布式组件对象模型(DCOM)仅限于Microsoft平台。公共对象请求代理体系结构(CORBA)过于复杂,不提供前向或后向兼容性【41】。

所有这些都是基于 远程过程调用(RPC) 的思想,该过程调用自20世纪70年代以来一直存在【42】。 RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。尽管RPC起初看起来很方便,但这种方法根本上是有缺陷的【43,44】。网络请求与本地函数调用非常不同:

  • 本地函数调用是可预测的,并且成功或失败,这仅取决于受您控制的参数。网络请求是不可预知的:由于网络问题,请求或响应可能会丢失,或者远程计算机可能很慢或不可用,这些问题完全不在您的控制范围之内。网络问题是常见的,所以你必须预测他们,例如通过重试失败的请求。
  • 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会返回没有结果。在这种情况下,你根本不知道发生了什么:如果你没有得到来自远程服务的响应,你无法知道请求是否通过。 (我们将在第8章更详细地讨论这个问题。)
  • 如果您重试失败的网络请求,可能会发生请求实际上正在通过,只有响应丢失。在这种情况下,重试将导致该操作被执行多次,除非您在协议中引入除重( 幂等(idempotence))机制。本地函数调用没有这个问题。 (在第十一章更详细地讨论幂等性)
  • 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求比函数调用要慢得多,而且其延迟也是非常可变的:在不到一毫秒的时间内它可能会完成,但是当网络拥塞或者远程服务超载时,可能需要几秒钟的时间完全一样的东西。
  • 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当你发出一个网络请求时,所有这些参数都需要被编码成可以通过网络发送的一系列字节。没关系,如果参数是像数字或字符串这样的基本类型,但是对于较大的对象很快就会变成问题。

客户端和服务可以用不同的编程语言实现,所以RPC框架必须将数据类型从一种语言翻译成另一种语言。这可能会捅出大篓子,因为不是所有的语言都具有相同的类型 —— 例如回想一下JavaScript的数字大于$2^{53}$的问题(参阅“JSON,XML和二进制变体”)。用单一语言编写的单个进程中不存在此问题。

所有这些因素意味着尝试使远程服务看起来像编程语言中的本地对象一样毫无意义,因为这是一个根本不同的事情。 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还可以简化需要并行发出多项服务的情况,并将其结果合并【45】。 gRPC支持流,其中一个调用不仅包括一个请求和一个响应,还包括一系列的请求和响应【46】。

其中一些框架还提供服务发现,即允许客户端找出在哪个IP地址和端口号上可以找到特定的服务。我们将在“请求路由”中回到这个主题。

使用二进制编码格式的自定义RPC协议可以实现比通用的JSON over REST更好的性能。但是,RESTful API还有其他一些显著的优点:对于实验和调试(只需使用Web浏览器或命令行工具curl,无需任何代码生成或软件安装即可向其请求),它是受支持的所有的主流编程语言和平台,还有大量可用的工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)的生态系统。由于这些原因,REST似乎是公共API的主要风格。 RPC框架的主要重点在于同一组织拥有的服务之间的请求,通常在同一数据中心内。

数据编码与RPC的演化

对于可演化性,重要的是可以独立更改和部署RPC客户端和服务器。与通过数据库流动的数据相比(如上一节所述),我们可以在通过服务进行数据流的情况下做一个简化的假设:假定所有的服务器都会先更新,其次是所有的客户端。因此,您只需要在请求上具有向后兼容性,并且对响应具有前向兼容性。

RPC方案的前后向兼容性属性从它使用的编码方式中继承

  • Thrift,gRPC(Protobuf)和Avro RPC可以根据相应编码格式的兼容性规则进行演变。
  • 在SOAP中,请求和响应是使用XML模式指定的。这些可以演变,但有一些微妙的陷阱【47】。
  • RESTful API通常使用JSON(没有正式指定的模式)用于响应,以及用于请求的JSON或URI编码/表单编码的请求参数。添加可选的请求参数并向响应对象添加新的字段通常被认为是保持兼容性的改变。

由于RPC经常被用于跨越组织边界的通信,所以服务的兼容性变得更加困难,因此服务的提供者经常无法控制其客户,也不能强迫他们升级。因此,需要长期保持兼容性,也许是无限期的。如果需要进行兼容性更改,则服务提供商通常会并排维护多个版本的服务API。

关于API版本化应该如何工作(即,客户端如何指示它想要使用哪个版本的API)没有一致意见【48】)。对于RESTful API,常用的方法是在URL或HTTP Accept头中使用版本号。对于使用API密钥来标识特定客户端的服务,另一种选择是将客户端请求的API版本存储在服务器上,并允许通过单独的管理界面更新该版本选项【49】。

消息传递中的数据流

我们一直在研究从一个过程到另一个过程的编码数据流的不同方式。到目前为止,我们已经讨论了REST和RPC(其中一个进程通过网络向另一个进程发送请求并期望尽可能快的响应)以及数据库(一个进程写入编码数据,另一个进程在将来再次读取)。

在最后一节中,我们将简要介绍一下RPC和数据库之间的异步消息传递系统。它们与RPC类似,因为客户端的请求(通常称为消息)以低延迟传送到另一个进程。它们与数据库类似,不是通过直接的网络连接发送消息,而是通过称为消息代理(也称为消息队列或面向消息的中间件)的中介来临时存储消息。

与直接RPC相比,使用消息代理有几个优点:

  • 如果收件人不可用或过载,可以充当缓冲区,从而提高系统的可靠性。
  • 它可以自动将消息重新发送到已经崩溃的进程,从而防止消息丢失。
  • 避免发件人需要知道收件人的IP地址和端口号(这在虚拟机经常出入的云部署中特别有用)。
  • 它允许将一条消息发送给多个收件人。
  • 将发件人与收件人逻辑分离(发件人只是发布邮件,不关心使用者)。

然而,与RPC相比,差异在于消息传递通信通常是单向的:发送者通常不期望收到其消息的回复。一个进程可能发送一个响应,但这通常是在一个单独的通道上完成的。这种通信模式是异步的:发送者不会等待消息被传递,而只是发送它,然后忘记它。

消息掮客

过去,信息掮客主要是TIBCO,IBM WebSphere和webMethods等公司的商业软件的秀场。最近像RabbitMQ,ActiveMQ,HornetQ,NATS和Apache Kafka这样的开源实现已经流行起来。我们将在第11章中对它们进行更详细的比较。

详细的交付语义因实现和配置而异,但通常情况下,消息代理的使用方式如下:一个进程将消息发送到指定的队列或主题,代理确保将消息传递给一个或多个消费者或订阅者到那个队列或主题。在同一主题上可以有许多生产者和许多消费者。

一个主题只提供单向数据流。但是,消费者本身可能会将消息发布到另一个主题上(因此,可以将它们链接在一起,就像我们将在第11章中看到的那样),或者发送给原始消息的发送者使用的回复队列(允许请求/响应数据流,类似于RPC)。

消息代理通常不会执行任何特定的数据模型 - 消息只是包含一些元数据的字节序列,因此您可以使用任何编码格式。如果编码是向后兼容的,则您可以灵活地更改发行商和消费者的独立编码,并以任意顺序进行部署。

如果消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题(图4-7)。

分布式的Actor框架

Actor模型是单个进程中并发的编程模型。逻辑被封装在角色中,而不是直接处理线程(以及竞争条件,锁定和死锁的相关问题)。每个角色通常代表一个客户或实体,它可能有一些本地状态(不与其他任何角色共享),它通过发送和接收异步消息与其他角色通信。消息传送不保证:在某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,因此不需要担心线程,每个角色可以由框架独立调度。

在分布式的行为者框架中,这个编程模型被用来跨越多个节点来扩展应用程序。不管发送方和接收方是在同一个节点上还是在不同的节点上,都使用相同的消息传递机制。如果它们在不同的节点上,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。

位置透明在actor模型中比在RPC中效果更好,因为actor模型已经假定消息可能会丢失,即使在单个进程中也是如此。尽管网络上的延迟可能比同一个进程中的延迟更高,但是在使用参与者模型时,本地和远程通信之间的基本不匹配是较少的。

分布式的Actor框架实质上是将消息代理和角色编程模型集成到一个框架中。但是,如果要执行基于角色的应用程序的滚动升级,则仍然需要担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。

三个流行的分布式actor框架处理消息编码如下:

  • 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。 但是,你可以用类似缓冲区的东西替代它,从而获得滚动升级的能力【50】。
  • Orleans 默认使用不支持滚动升级部署的自定义数据编码格式; 要部署新版本的应用程序,您需要设置一个新的群集,将流量从旧群集迁移到新群集,然后关闭旧群集【51,52】。 像Akka一样,可以使用自定义序列化插件。
  • 在Erlang OTP中,对记录模式进行更改是非常困难的(尽管系统具有许多为高可用性设计的功能)。 滚动升级是可能的,但需要仔细计划【53】。 一个新的实验性的maps数据类型(2014年在Erlang R17中引入的类似于JSON的结构)可能使得这个数据类型在未来更容易【54】。