复杂系统的数据流模式设计
这是一个纷杂而无规则的世界,越想忘掉的事情,越难忘记。
正文
向前和向后的兼容性对可演化性很重要,通过允许独立升级系统的不同部分,而不必一次改变所有,使得变更方便。兼容性是编码数据的一个进程和执行解码的另一个进程之间的关系。无论何时想将某些数据发送到不共享内存的另一个进程,如只要您想通过网络发送数据或将其写入文件,就需将其编码为一个字节序列。
数据能通过多种方式从一个进程流向另一个进程。
1 数据库中的数据流
在数据库中:
- 写入数据库的过程对数据进行编码
- 从数据库读取的过程对数据进行解码
可能只有一个进程访问DB,这时,reader只是同一进程的较新版本。此时可考虑向DB存储内容,就是给未来的自己发消息。
这时,向后兼容性就很必要。否则未来的自己将无法解码之前写的东西。
一般多个不同进程同时访问DB很常见。这些进程可能是不同的应用程序或服务或同一服务的几个实例(为了可伸缩性或容错性而并行运行)。无论哪种,在应用程序正变化的环境中,访问DB的某些进程可能会运行较新代码,有些进程可能会运行较旧代码。如因为新版本当前正在滚动升级部署,所以有些实例已更新,而其他实例未更新。
这说明DB中的一个值可能会被新版本的代码写入,然后被仍旧运行的旧版本的代码读取。因此,DB一般得向前兼容。
但还有障碍。假设将一个字段添加到记录模式,且较新的代码将该新字段的值写入DB。随后,旧版本代码(还不知道新字段)将读取记录,更新记录并将其写回。这时,理想行为通常是旧代码保持新字段不变,即便它无法解释。
如图-7。若将数据库值解码为应用程序中的模型对象,稍后重新编码这些模型对象,那么未知字段可能会在该翻译过程中丢失。这不是个难题,只需意识到。
图-7 当较旧版本应用更新以前由较新版本的应用编写的数据时,若不小心,数据可能会丢失
不同的时间写入不同值
DB允许任何时候更新任何值。这意味着在一个单DB中,可能有一些值是5ms前写的,而一些值是5年前写的。
在部署服务端应用的新版本时,也许用不了几min就能将所有旧版本替换为新版本。但DB内容并非如此:五年前的数据,除非对其进行显式重写,否则它仍以原始编码形式存在。这种现象有时被概括为:数据的生命周期超出代码的生命周期。
将数据重写(迁移)到一个新的模式当然是可能的,但是在一个大数据集上执行是一个昂贵的事情,所以大多数数据库如果可能的话就避免它。大多数关系数据库都允许简单的模式更改,例如添加一个默认值为空的新列,而不重写现有数据^v。读取旧行时,对于磁盘上的编码数据缺少的任何列,数据库将填充空值。LinkedIn的文档数据库Espresso使用Avro存储,允许它使用Avro的模式演变规则【23】。
因此,模式演变允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
归档存储
也许会不时为DB创建快照,如:
- 备份
- 或加载到数仓
这时即使源DB中的原始编码包含不同时期的各种模式版本,数据转储通常也将使用最新模式进行编码。既然你不管怎样都要复制数据,那么你可以对这个数据副本做一致的编码。
由于数据DUMP是一次性写入的,且以后不可变,所以Avro对象容器文件等格式非常适合。可将数据编码为分析型友好的列式存储。
服务中的数据流:REST与RPC
需要通过网络进行通信的进程,有多种通信方式。最常见的两个角色:客户端和服务器。服务器通过网络公开API,客户端可以连接到服务器以请求该API。服务器公开的API称为服务。
Web的工作方式:客户端(Web浏览器)向Web服务器请求,通过GET请求下载HTML、CSS、JavaScript、图像等,并通过POST请求提交数据到服务器。API包含一组标准的协议和数据格式(HTTP,URL,SSL/TLS,HTML等)。
移动设备或PC运行的本地应用程序也可向服务器发出网络请求,并且在Web浏览器内运行的客户端JavaScript应用程序可使用XMLHttpRequest成为HTTP客户端(该技术被称为Ajax )。这时,服务器响应通常不是用于显示给人的HTML,而是便于客户端应用程序代码进一步处理的编码数据(如JSON)。尽管HTTP可被用作传输协议,但顶层实现的API是特定于应用程序的,客户端和服务器需要就该API的细节达成一致。
服务器本身可以是另一服务的客户端(如Web应用服务器作为DB客户端)。这种方法通常用于将大型应用按功能区域分解为较小服务,这样当一个服务需要来自另一个服务的某些功能或数据时,就会请求那个服务。这种构建应用的方式传统上称为面向服务的体系结构(service-oriented architecture,SOA),近年被改进和更名为微服务架构。
有时,服务类似DB:允许客户端提交和查询数据。虽然DB允许使用查询语言任意查询,但服务公开了特定于应用程序的API,它只允许由服务的业务逻辑(应用程序代码)预定的输入和输出。这限制提供了一定程度的封装:服务能够对客户能做、不能做什么并细粒度限制。
SOA/微服务架构的一个关键设计目标:通过使服务独立部署和演化,使应用程序更易于更改和维护。如每个服务应该由一个团队拥有,该团队应能经常发布新版本服务,而不必与其他团队协调。即应期望新旧版本的服务器和客户端同时运行,因此服务器和客户端使用的数据编码必须在不同版本服务API之间兼容!
Web服务
当HTTP被作为服务通信的底层协议时,可称为Web服务。这可能有点小错误,因为Web服务不仅在Web上使用,且在几个不同的环境中使用。如:
- 运行在用户设备上的客户端应用程序(如移动设备上的本地应用或使用Ajax的JS web应用)通过HTTP请求服务。这些请求通常通过公网进行
- 一种服务向同一组织拥有的另一项服务请求,这些服务通常位于同一IDC,作为面向服务/微型架构的一部分。支持这种用例的软件有时被称为中间件(middleware)
- 一种服务通过互联网向不同组织所拥有的服务提出请求。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共API或用于共享访问用户数据的OAuth
常用Web服务方法:REST和SOAP。设计理念几乎完全相反,所以争辩激烈。
REST不是一种协议,而是一个基于HTTP原则的设计理念。强调简单数据格式,使用URL标识资源,并使用HTTP功能进行缓存控制、身份验证和内容类型协商。与SOAP相比,REST越来越受欢迎,至少在跨组织服务集成的背景下,并经常与微服务相关。根据REST原则设计的API称为RESTful。
SOAP
SOAP,用于发出网络API请求的基于XML协议。虽常用于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)问题
- Enterprise JavaBeans(EJB)和Java的远程方法调用(RMI)仅限于Java
- 分布式组件对象模型(DCOM)仅限于Microsoft平台
- 公共对象请求代理体系结构(CORBA)过于复杂,不提供前向或后向兼容性
所有这些都是基于诞生自1970s的远程过程调用(RPC)思想。RPC模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言的方法相同(这种抽象称为位置透明)。尽管RPC起初看起来很方便,但这种方法根本上是有缺陷的。网络请求与本地函数调用很不同:
- 本地方法调用可预测,且成功或失败仅取决于控制的参数。网络请求不可预知:由于网络问题,请求或响应可能会丢失,或远程计算机可能很慢或不可用。网络问题很常见,所以必须做好应对,如重试失败的请求
- 本地方法调用要么返回一个结果,要么抛出一个异常或永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能结果:由于超时,返回时可能没有结果。此时不知发生什么:若未收到远程服务响应,就无法知道请求是否成功
- 若重试失败请求,可能会发生请求实际已完成,只有响应丢失。此时,重试将导致该操作被执行多次,除非在协议中引入除重( 幂等性)机制。本地方法调用就没这问题
- 每次调用本地方法时,一般需执行大致相同的时间。网络请求比函数方法慢得多,且其延迟也有很大变化:好时可能不到1ms内完成,网络拥塞或远程服务超载时,可能需几s才完成
- 调用本地方法,可高效地将引用传递给本机内存中的对象。当发出网络请求时,所有这些参数都需编码成可通过网络发送的字节序列。若参数是数字或字符串这样基本类型倒没啥,但当是较大对象就会变成问题
- 客户端和服务可以用不同编程语言实现,所以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协议可实现如REST上的JSON之类的通用协议更好的性能。但RESTful API还有其他优点:方便实验和调试(只需使用Web浏览器或命令行工具curl,无需代码生成或软件安装),能被所有主流语言和平台支持,还有丰富工具(服务器,缓存,负载平衡器,代理,防火墙,监控,调试工具,测试工具等)生态系统。
由于这些原因,REST似乎是公共API的主流风格。RPC框架的主要侧重于同一组织内多个服务之间的请求,通常在同一IDC。
RPC 的数据编码与演化
可演化性,重要的是独立更改和部署RPC客户端和服务器。与基于DB的数据流相比,可做个简化假设:假定所有服务器都先被更新,其次是所有客户端。因此,只需在请求上具有向后兼容性,且对响应具有前向兼容性。
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,消息传递通信一般单向:发送者一般不期望收到对其消息的回复。进程可能发送一个响应,但这通常是在一个单独的通道上完成。通信模式异步:发送者不会等待消息被传递,而只是发送它,然后就忘了它。
消息代理
过去,消息代理(Message Broker)主要由TIBCO,IBM WebSphere和WebMethods等公司的商业软件的秀场。最近像RabbitMQ,ActiveMQ,HornetQ,NATS和Apache Kafka这样的开源实现已流行。
详细的传递语义因实现和配置而异,一般消息代理的使用方式:
- 一个进程将消息发送到指定队列或主题,代理确保将消息传递给那个队列或主题的一或多个消费者或订阅者
- 同一主题上,可以有多个生产者、消费者
主题只提供单向数据流。但:
- Con本身可能会将消息发布到另一个主题(可将它们链接在一起)
- 也可发送到一个回复队列,该队列由原始消息发送者来消费(这样支持类似RPC的请求/响应数据流)
消息代理通常不会强制任何特定数据模型,消息只是包含一些元数据的字节序列,因此可使用任何编码格式。若编码是向后和向前兼容,可最大程度灵活地对发布者和消费者的编码进行独立的修改,并以任意顺序部署
若消费者重新发布消息到另一个主题,则可能需要小心保留未知字段,以防止前面在数据库环境中描述的问题图-7
分布式的Actor框架
Actor模型,用于单个进程中并发的编程模型。逻辑被封装在Actor,而非直接处理线程(及竞争条件、锁和死锁相关问题)。每个Actor代表一个客户端或实体,它可能具有某些本地状态(不与其他任何角色共享),且它通过发送和接收异步消息与其他Actor通信。消息传送不保证:某些错误情况下,消息将丢失。由于每个角色一次只能处理一条消息,无需担心线程,每个Actor可由框架独立调度。
在分布式Actor框架中,该编程模型用于跨多个节点来扩展应用程序。无论发送方和接收方是在同一还是不同节点,都使用相同的消息传递机制。若它们在不同的节点,则该消息被透明地编码成字节序列,通过网络发送,并在另一侧解码。
位置透明在Actor模型中比RPC更有效,因为Actor模型已假定消息可能会丢失,即使在单进程中也是。尽管网络延迟可能比同一进程中的延迟更高,但使用Actor模型时,本地和远程通信之间根本上的不匹配发生概率较更小。
分布式的Actor框架实质上是将消息代理和Actor编程模型集成到单个框架。但若对基于Actor的应用程序执行滚动升级,仍需担心向前和向后兼容性问题,因为消息可能会从运行新版本的节点发送到运行旧版本的节点,反之亦然。
分布式Actor框架处理消息编码方案
- 默认情况下,Akka使用Java的内置序列化,不提供前向或后向兼容性。但可以用类似Prototol Buffers的东西替代,从而获得滚动升级能力
- Orleans 默认使用不支持滚动升级部署的自定义数据编码格式,要部署新版本的应用程序,需建立一个新集群,将流量从旧集群导入新集群,然后关闭旧集群。像Akka一样,可以使用自定义序列化插件。
- 在Erlang OTP中,对记录模式进行更改很困难(尽管系统具有许多为高可用性设计的功能)。滚动升级是可能的,但需仔细规划。一些实验性的新型映射数据类型(2014年在Erlang R17中引入的类似于JSON的结构)可能使得这个数据类型在未来更容易。
总结
将内存数据结构转换为网络或磁盘上的字节流有多种方案,这些编码细节不仅影响效率,也影响应用程序的体系结构和部署可支持选项。
许多服务需支持滚动升级,滚动升级允许在不停机情况下发布新版本的服务(因此鼓励频繁地发布小版本的迭代,而非长周期的大版本),并降低部署风险(允许在影响大量用户之前检测并回滚有故障的版本)。这些特性非常有利于应用程序的模化和更改。
滚动升级期间,或其他原因,必须假设不同节点正在运行应用代码的不同版本。因此,在系统周围流动的所有数据都以提供向后兼容性(新代码可读取旧数据)和向前兼容性(旧代码可读取新数据)的方式进行编码很重要~
多种数据编码格式及其兼容性:
- 编程语言特定的编码仅限于单一编程语言,往往无法提供前向和后向兼容性
- JSON,XML和CSV等文本格式应用普遍,兼容性取决于如何使用。他们有可选模式语言。这些格式对于数据类型的支持有些模糊,必须小心处理数字和二进制字符串等问题
- 像Thrift,Protocol Buffers和Avro这样的二进制模式驱动格式,支持使用清晰定义的前向和后向兼容性语义进行紧凑,高效编码。这些模式对静态类型语言的文档和代码生成很有用。但都有个缺点,只有在数据解码后才是人类可读
数据流的几种模式,数据编码的重要性:
- 数据库,写入DB的进程对数据进行编码,而读取数据库的进程对数据进行解码
- RPC和REST API,客户端对请求进行编码,服务器对请求解码,对响应编码,客户端最终对响应解码
- 异步消息传递(使用消息代理或Actor),节点之间通过发送消息进行通信,消息由发送者编码,由接收者解码
文章转载自公众号: JavaEdge