数据编码与演化 #
数据编码格式 #
程序经常使用至少以下两个数据表示格式:
- 内存中,数据保存在对象、结构体、列表、数组、哈希表等数据结构中。这些数据结构通常针对 CPU的高效访问和操作进行了优化。
- 当数据被写入文件或使用网络进行发送时,必须将其编码称某种字符序列。
将内存中的数据转换为字符序列,称之为序列化(亦称编码);而将字符序列重新转化为内存数据,则称为反序列化(解码)。
对于数据编码格式的细节:
- 决定了效率(性能)的高低。
- 影响应用程序的体系结构和部署时的支持选项。
语言特定的格式 #
不同语言都会有内置的序列化库(如 Java 的 java.io.Serializable
),可惜它们会有以下问题:
- 难以跨语言:编码通常都与特定的编程语言绑定在一起,使用其他语言来访问数据比较困难。
- 不够安全:反序列化时,若攻击者能让程序反序列化任意的字符序列,那么它能够实例化任何的类。
- 兼容性不好:经常忽略向前向后兼容性的问题。
- 效率不高:如 Java 的 Serializable 糟糕的性能。
JSON, XML 与其二进制变体 #
可有不同编程语言编写和读取的标准化编码,JSON、XML 以及 CSV 固然是强有力的竞争对手。它们都是文本格式,都有不错的可读性。
除了语法问题以外,它们也存在一些特别的问题:
-
对数字编码的规则较为模糊:XML 和 CSV 中,无法区分“数字”和“由数字组成的字符串”;而在 JSON 中,数字并不区分为整数和浮点数,而且不会指定精度。
这在处理大数字的时候是一个大问题,需要程序员定义合适的大数表示方式。在编码(序列化)和解码(反序列化)时,需要不同程序遵循相同的规则运行,否则将会出错。
-
不支持二进制字符串:虽然 JSON 和 XML 对 Unicode 都有很好的支持,因此有着较好的人类可读性,但是其都不支持二进制字符串1的存储(如图片、可执行文件等都是二进制字符串)。人们通常使用 Base64 将二进制字符串编码为字符文本以解决该限制。但其方法让数据大小增加了 33%。
-
XML 和 JSON 都有模式支持,数据(数字和二进制字符串等)的正确解释取决于模式中的信息。因此不使用 XML/JSON 架构的应用程序可能需要将模式硬编码为适当的编码/解码逻辑。
-
CSV 没有任何模式,完全需要应用程序去定义每行/每列的含义。因此,若数据有更改(增删改行/列),必须要手动处理格式的修改。
作为数据交换格式,JSON、XML 以及 CSV 都特别受欢迎。在这种情况(数据从一个组织发送到另一个组织)下,只要人们就格式本身达成一致,格式多么美观或者高效往往都不太重要。让不同组织达成格式一致的难度通常超过了所有其他问题(达成共识才是最难的😂)。
二进制编码 #
动机:
-
当数据集达到了 TB 或以上的级别,数据格式的选择(更紧凑、更快)就会对系统效率有较大的影响。
-
虽然 JSON 不像 XML 那样冗长,但是相比于二进制编码,JSON 仍占用更多的空间(明文字符序列)。因此,出现了很多基于 JSON/XML 的二进制编码格式,如 MessgePack 等。虽然在细分领域有不少使用,但都没有如 JSON/XML 那样被广泛使用。
MessagePack #
TODO
Thrift & Protocol Buffers #
Apache Thift 和 Protocol Buffers(protobuf) 是基于相同原理的两种二进制编码库(都有自己的编码格式)。
模式定义 #
TODO
编码方式 #
TODO
字段标签和模式演化 #
模式演化:模式需要随着时间而不断变化。
一条编码记录只是一组编码字段的拼接,每个字段由标签号标识,并使用数据类型(字符串、整数等)进行注释。若没有设置字段值,则在编码记录中可以将其忽略。因此,字段标签(field tag)对于编码数据的含义来说至关重要。
在编码中,可以轻松更改模式中字段的名称(因为编码中永远不引用字段名称),而不能随便更改字段的标签,因为这会导致现有编码的数据无效。
模式演化应该在 增加、修改、读取、删除 过程中提供向前和向后兼容性。
向前兼容性2:旧代码若无法识别一个新的标记号码(字符标签),将这个字段简单忽略即可。在实现时,通过数据类型的注释即可通知解释器来跳过特定的字节数。
向后兼容性3:若添加了一个新字段,则该字段无法使其成为“必需”字段(required),否则当新代码在读取旧数据时,数据检查将失败。因此,在模式的初始部署之后,所有的新增字段都必须为“可选”(optional)或具有默认值(default value)。
增加字段:
- 只能添加“可选”字段和具有默认值的字段。
- 不能再次使用之前相同的标签号码(仍有可能有旧数据在使用,需要保证向前兼容)。
删除字段:
- 只能删除“可选”字段(optional)。
- “必需”字段永远不能被删除。
数据类型和数据演化 #
Avro #
一个 Hadoop 子项目,也使用模式来制定编码的数据结构。
提供两种模式语言:
- Avro IDL:用于人工编辑。
- 基于 JSON 的另一种模式语言:易于机器读取。
TODO:这玩意太复杂了,还有读/写模式。不想看了。
模式动态生成、代码生成和动态类型语言 #
Avro 其中一个优点是:不包括任何标签号。因此 Avro 对动态生成的模式更加友好。
若模式发生了变化,只需要根据更新的模式生成新的 Avro 模式,即可使用新的 Avro 模式读/写数据。这个过程不需要关注模式的转变,在每次运行时,都可以简单的进行模式转换。这样让 Avro 可以轻松获得向前/向后兼容能力。
但是对于 Thrift 和 Protocol Buffers,在每次模式更改时,管理员必须手动分配字段标签。因此,它们与 Avro 最大的区别是:Thrift 和 Protocol Buffers 依赖于代码生成4,Avro 的设计目标就是模式的动态生成。
Avro 也为静态语言提供了可选的代码生成,但可以在不生成代码的情况下直接使用:若有一个对象容器文件(已嵌入 writer 模式),可以简单的直接使用 Avro 库来打开它5,和查看 JSON 一样方便的方式来查看其中的数据。
模式的优点 #
ASN.1 是在 1984 年首次被标准化的模式定义语言,常被用来定义各种网络协议,其二进制编码(DER)仍被用于编码 SSL 证书(X.509)。由于其过于复杂(文档也不尽人意),不建议用来定义新型应用的模式。
基于模式的二进制编码,有这些不错的优点:
- 节约空间:可以省略编码数据中的字段名称,比各种“二进制 JSON”变体更紧凑。
- 有价值的文档形式:模式是解码所必需的,因此它一定是最新的(代码即文档)。
- 向前向后兼容性:模式数据库允许在部署任何内容之前检查模式更改的向前向后兼容性。
- 类型检查:对于静态类型的编程语言用工具从模式生成代码,能够在编译时进行类型检查。
模式通过“模式演化”来支持与“无模式/读时模式”的 JSON 数据库 MongoDB 相同的灵活性,还提供了相关工具,使得数据具有更好的保障。
数据流格式 #
常见的进程间数据流动的方式:
- 通过数据库(写入时编码,读取时解码)。
- 通过服务调用:REST 和 RPC (客户端发送请求编码,服务端收到请求后解码,服务端发送响应编码,客户端收到响应解码)。
- 通过异步消息传递:消息队列(发送者编码,接收者解码)。
基于数据库的数据流 #
数据比代码更长久:旧数据仍然采用原始编码,新代码在更新旧记录后,可能导致原有记录部分内容丢失。
为了新模式而进行数据重写/迁移,在大规模数据集上代价非常高。因此大多数关系型数据库都允许简单的模式更改6,如添加具有默认值为空的新列,而不是重写现有所有数据。
LinkedIn 的文档数据库 Espresso 使用 Avro 进行存储,并支持 Avro 的模式演化规则。
意义:模式演化支持整个数据库看起来像是采用单个模式编码,即使底层存储可能包含各个版本模式所编码的记录(抽象新层次,屏蔽底层差异)。
数据转储(创建快照)时:
- 通常使用最新的模式进行编码。
- 用相同的模式对数据副本进行统一编码。
基于服务的数据流:REST 和 RPC #
面向服务的体系结构(Service-Oriented Architecture,SOA)/ 微服务体系结构(Microservices Architecture):
- 构建应用程序的方式:服务本身可以是另一项服务的客户端。
- 提供一定程度的封装:服务端可以对客户端的行为施加细粒度的限制。
- 关键设计目标:通过是服务可独立部署和演化,让应用程序更易于更改和维护。
- 期望:新旧版本的服务端和客户端能够同时运行,数据编码必须在不同服务 API 之间兼容。
网络服务 #
即 Web 服务,一般指代的是由 HTTP 用作底层通信协议的服务。
Web 服务类型:
- 客户端应用程序:通过 HTTP 向服务发出请求。
- 中间件:一种服务向同一组织拥有的另一项服务提出请求,这种服务通常作为 SOA/MSA 的一部分。
- 在线服务:一种服务向不同组织所拥有的服务提出请求,如开放 API、OAuth 认证等。
两种流行的 Web 服务方法:REST 和 SOAP。
REST(Representational State Transfer,表现层状态转换) 是一个基于 HTTP 原则的设计理念。它强调:
- 简单的数据格式。
- 使用 URL 来标记资源。
- 使用 HTTP 功能来进行缓存控制(Cache-Control)、身份验证(Authenticate)和内容类型协商(Content-Type)。
根据 REST 原则设计的 API 成为 RESTful API。
SOAP 是一个基于 XML 的协议,但其目标是独立于 HTTP,并避免使用大多数 HTTP 的功能。SOAP Web 服务的 API 使用被称为 WSDL(Web Serivces Description Language)。WSDL 的设计目标不是人类可读的,因此 SOAP 用户严重依赖工具支持、代码生成和 IDE。
相反的是,RESTful 的 API 倾向于使用简单的方法,通常涉及较少的代码生成和自动化工具。
RESTful 的定义格式有:OpenAPI(前身为 Swagger),用户描述 RESTful API 并生成 API 文档。
RESTful 的优点:
- 利于调试:只需要浏览器或 curl 即可发起请求,无需代码生成或安装软件)。
- 支持所有主流编程语言和平台,有庞大的生态系统。
The simpler, the better!
RPC #
RPC 的设想:使向远程服务发出请求这过程看起来与同一进程中调用编程语言的函数/方法相同。此抽象称之为“位置透明”。
虽然 RPC 设想很美好,但是现实很骨感。这种方法根本上是有缺陷的,网络调用和本地函数有这些不同:
- 本地函数调用是可预测的,成功/失败仅取决于控制的参数;而网络请求是不可预测的,请求/响应很可能由于网络问题而丢失,或远程计算机出现故障导致不可用。网络问题非常常见,所以必须有所准备,如重试失败的请求。
- 本地函数调用的行为是确定的:正确结果、抛出异常或永远不返回(死循环或进程崩溃)。网络请求还可能会请求超时,这导致它可能没有返回结果。在这种情况下,程序根本就不知道发生了什么,更不知道这次请求是否成功。
- 重试失败的网络请求,可能会发生请求实际上已经执行完成,只是响应丢失了的情况。这种情况下重复重试可能会导致操作反复执行多次。解决方法只能为在协议中建立幂等性7机制。本地函数调用不会有这样的问题。
- 每次网络调用耗费的时间难以估量,而本地调用的时间大致相同。
- 本地调用函数时,可以直接使用内存中存储的高效数据结构,而且直接使用引用(指针)即可高效传递对象。然而在网络调用中,必须要将数据进行序列化和反序列化的操作。这在大规模数据场景下,性能损失极为严重。
- 客户端和服务可能用不同的编程语言来实现。其中的数据类型的转换也需要额外注意,并不是所有语言都有相同的数据类型。单一语言编写的本地函数则没有这个问题。
因此,若想将 RPC 看起来像是编程语言中的本地对象一样,是毫无意义的。网络调用和本地调用完全是不同的事情。
之前的 RPC 总是试图隐藏自己是网络调用的事实,而 REST 能够吸引人的一个原因也在于,它明确了自己建立在不稳定的网络调用之上。
新一代 RPC 框架明确了:远程请求和本地函数调用不同。
新一代 RPC 的特性包含:
- 使用 Futures(Promises)来封装可能失败的异步操作,Futures 还简化了需要并行请求多个服务的情况(Finagle 和 Rest.li)。
- 支持流:其中调用不仅仅只包括一个请求和响应,还会包括一段时间内一系列的请求和响应(gRPC)。
- 提供服务发现:允许客户端查询在哪个 IP 地址和端口能获得特定的服务。
REST 和 RPC 的应用程度:
- REST 是公共 API 的风格(易于调试,生态庞大)。
- RPC 框架主要侧重于同组织内的服务之间请求,通常发生在一个数据中心内(通过二进制编码的自定义 RPC 协议,能够获得比 REST 更好的性能表现)。
数据编码和演化 #
对于网络服务的演化性,重要的是可以独立的更改和部署客户端和服务器。相比于基于数据库的数据流,我们在网络服务上可以做一个简单的假设:假定所有的服务器都先于客户端被更新。那么我们只需要在请求上有向后兼容性,而在响应上有向前兼容性即可。
如果 RPC 经常用于跨组织边界的通信,则服务的兼容性会变得更加困难,服务的提供者经常无法控制客户,也不能强制它们升级。这时候,服务的提供者为了实现一些破坏兼容性的更改,往往会同时维护多个版本的 API。
API 的版本管理:
- 客户端管理:在 URL 或 HTTP Accept 头中标识版本号(常用)。
- 服务端管理:将客户端请求的 API 版本存储于服务器上,服务端可以根据客户端的 API 密钥获取客户端可接受的 API 版本(更麻烦)。
基于消息传递的数据流 #
异步消息传递系统与 RPC 和数据库的异同:
- 客户端请求(称为消息)以低延迟传递到另一个进程(与 RPC 类似)。
- 不直接通过网络连接发送消息,而是通过称为消息代理(消息队列)的中介来发送,该中介会暂存消息(与数据库类似,数据库被用于记录数据)。
而相比于直接使用 RPC,消息代理有这几个优点:
- 提高系统稳定性:接收方不可用或过载,可以充当缓冲区(削峰填谷)。
- 防止消息丢失:自动将消息重新发送到崩溃的进程。
- 利于部署于云服务中:避免了发送方需要知道接收方的 IP 和端口号。
- 支持将一条消息发给多个接收方(单一生产者,多方消费者)。
- 利于架构解耦:逻辑上将消息生产者和消费者隔离。
消息代理与 RPC 的差异:
- 消息传递通信是单向的:发送方并不期望收到对其消息的回复。
- 异步通信模式:发送者不会等待消息被传递,只是发送就忘记它。
消息代理 #
消息代理的使用方式:
- 生产:一个进程向指定的队列/主题发送消息。
- 消费:消息代理确保消息被传递到队列/主题的一个或多个消费者/订阅者。
主题只提供单向的数据流。
消息代理不会强制任何特定的数据模型,消息只是包含一些元数据的字节序列,可以使用任何编码格式。
分布式 Actor 框架 #
Actor 模型是用于单个进程中并发的模型。每个 Actor 通常代表一个实体,它具有某些本地独立状态,只通过发送接收异步消息来与其他 Actor 通信。
在分布式 Actor 框架中,这个模型可以被用来跨越多个节点来扩展应用程序。因此,相比于 RPC,位置透明性在 Actor 模型中更有效。
分布式 Actor 框架实质是将消息代理和 Actor 编程模型集成到了单个框架中。
不过,基于 Actor 的应用程序在执行滚动升级时,仍然要考虑向前向后兼容性问题。