编码数据的格式
程序通常以(至少)两种不同的表示形式处理数据:
- 在内存中,数据保存在对象、结构体、列表、数组、哈希表、树等中。这些数据结构针对 CPU 的高效访问和操作进行了优化(通常使用指针)。
- 当你想要将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列(例如,JSON 文档)。由于指针对任何其他进程都没有意义,因此这种字节序列表示通常与内存中常用的数据结构看起来截然不同。
因此,我们需要在两种表示之间进行某种转换。从内存表示到字节序列的转换称为 编码(也称为 序列化 或 编组),反向过程称为 解码(解析、反序列化、反编组)。
JSON、XML 及其二进制变体
JSON、XML 和 CSV 是文本格式,因此在某种程度上是人类可读的(尽管语法是一个热门的争论话题)。除了表面的语法问题之外,它们还有一些微妙的问题。
- 数字的编码有很多歧义。
- JSON 和 XML 对 Unicode 字符串(即人类可读文本)有很好的支持,但它们不支持二进制字符串(没有字符编码的字节序列)。二进制字符串是一个有用的功能,因此人们通过使用 Base64 将二进制数据编码为文本来绕过这个限制。然后模式用于指示该值应被解释为 Base64 编码。这虽然有效,但有点取巧,并且会将数据大小增加 33%。
- XML 模式和 JSON 模式功能强大,因此学习和实现起来相当复杂。由于数据的正确解释(如数字和二进制字符串)取决于模式中的信息,不使用 XML/JSON 模式的应用程序需要潜在地硬编码适当的编码/解码逻辑。
- CSV 没有任何模式,因此应用程序需要定义每行和每列的含义。如果应用程序更改添加了新行或列,你必须手动处理该更改。CSV 也是一种相当模糊的格式
字段标签与模式演化
我们之前说过,模式不可避免地需要随时间而变化。我们称之为 模式演化。从示例中可以看出,编码记录只是其编码字段的串联。每个字段由其标签号(示例模式中的数字 1、2、3)标识,并带有数据类型注释(例如字符串或整数)。如果未设置字段值,则它会从编码记录中省略。由此可以看出,字段标签对编码数据的含义至关重要。你可以更改模式中字段的名称,因为编码数据从不引用字段名,但你不能更改字段的标签,因为这会使所有现有的编码数据无效。
Avro
要解析二进制数据,你需要按照模式中出现的字段顺序进行遍历,并使用模式告诉你每个字段的数据类型。这意味着只有当读取数据的代码使用与写入数据的代码 完全相同的模式 时,二进制数据才能被正确解码。读取器和写入器之间的任何模式不匹配都意味着数据被错误解码。
写入者模式与读取者模式
如果读取者模式和写入者模式相同,解码很容易。如果它们不同,Avro 通过并排查看写入者模式和读取者模式并将数据从写入者模式转换为读取者模式来解决差异。
例如,如果写入者模式和读取者模式的字段顺序不同,这没有问题,因为模式解析通过字段名匹配字段。如果读取数据的代码遇到出现在写入者模式中但不在读取者模式中的字段,它将被忽略。如果读取数据的代码期望某个字段,但写入者模式不包含该名称的字段,则使用读取者模式中声明的默认值填充它。
模式演化规则
为了保持兼容性,你只能添加或删除具有默认值的字段。
动态生成的模式
Avro 对 动态生成 的模式更友好。例如,假设你有一个关系数据库,其内容你想要转储到文件中,并且你想要使用二进制格式来避免前面提到的文本格式(JSON、CSV、XML)的问题。如果你使用 Avro,你可以相当容易地从关系模式生成 Avro 模式(我们之前看到的 JSON 表示),并使用该模式对数据库内容进行编码,将其全部转储到 Avro 对象容器文件中。你可以为每个数据库表生成记录模式,每列成为该记录中的一个字段。数据库中的列名映射到 Avro 中的字段名。
现在,如果数据库模式发生变化(例如,表添加了一列并删除了一列),你可以从更新的数据库模式生成新的 Avro 模式,并以新的 Avro 模式导出数据。数据导出过程不需要关注模式更改——它可以在每次运行时简单地进行模式转换。读取新数据文件的任何人都会看到记录的字段已更改,但由于字段是按名称标识的,因此更新的写入者模式仍然可以与旧的读取者模式匹配。
相比之下,如果你为此目的使用 Protocol Buffers,字段标签可能必须手动分配:每次数据库模式更改时,管理员都必须手动更新从数据库列名到字段标签的映射。(这可能是可以自动化的,但模式生成器必须非常小心,不要分配以前使用过的字段标签。)这种动态生成的模式根本不是 Protocol Buffers 的设计目标,而 Avro 则是。
模式的优点
尽管文本数据格式(如 JSON、XML 和 CSV)广泛存在,但基于模式的二进制编码也是一个可行的选择。它们具有许多良好的属性:
- 它们可以比各种"二进制 JSON"变体紧凑得多,因为它们可以从编码数据中省略字段名。
- 模式是一种有价值的文档形式,并且由于解码需要模式,因此你可以确保它是最新的(而手动维护的文档很容易与现实脱节)。
- 保留模式数据库允许你在部署任何内容之前检查模式更改的向前和向后兼容性。
- 对于静态类型编程语言的用户,从模式生成代码的能力很有用,因为它可以在编译时进行类型检查。
数据流的模式
流经数据库的数据流
在数据库中,写入数据库的进程对数据进行编码,从数据库读取的进程对其进行解码。可能只有一个进程访问数据库,在这种情况下,读取者只是同一进程的后续版本——在这种情况下,你可以将在数据库中存储某些内容视为 向未来的自己发送消息。
向后兼容性在这里显然是必要的;否则你未来的自己将无法解码你之前写的内容。
不同时间写入的不同值
将数据重写(迁移)为新模式当然是可能的,但在大型数据集上这是一件昂贵的事情,因此大多数数据库尽可能避免它。大多数关系数据库允许简单的模式更改,例如添加具有 null 默认值的新列,而无需重写现有数据。从磁盘上的编码数据中缺少的任何列读取旧行时,数据库会为其填充 null。因此,模式演化允许整个数据库看起来好像是用单个模式编码的,即使底层存储可能包含用各种历史版本的模式编码的记录。
归档存储
由于数据转储是一次性写入的,此后是不可变的,因此像 Avro 对象容器文件这样的格式非常适合。这也是将数据编码为分析友好的列式格式(如 Parquet)的好机会。
流经服务的数据流:REST 与 RPC
面向服务/微服务架构的一个关键设计目标是通过使服务可独立部署和演化来使应用程序更容易更改和维护。一个常见的原则是每个服务应该由一个团队拥有,该团队应该能够频繁发布服务的新版本,而无需与其他团队协调。因此,我们应该期望服务器和客户端的新旧版本同时运行,因此服务器和客户端使用的数据编码必须在服务 API 的各个版本之间兼容。
Web 服务
最流行的服务设计理念是 REST,它建立在 HTTP 的原则之上。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制、身份验证和内容类型协商。根据 REST 原则设计的 API 称为 RESTful。
两种最流行的服务 IDL 是 OpenAPI(也称为 Swagger)和 gRPC。
负载均衡器、服务发现和服务网格
所有服务都通过网络进行通信。因此,客户端必须知道它正在连接的服务的地址——这个问题称为 服务发现。为了提供更高的可用性和可伸缩性,通常在不同的机器上运行服务的多个实例,其中任何一个都可以处理传入的请求。将请求分散到这些实例上称为 负载均衡。
有许多负载均衡和服务发现解决方案可用:
- 硬件负载均衡器 是安装在数据中心的专用设备。
- 软件负载均衡器 的行为方式与硬件负载均衡器大致相同。但是,软件负载均衡器(如 Nginx 和 HAProxy)不需要特殊设备,而是可以安装在标准机器上的应用程序。
- 域名服务 (DNS) 是当你打开网页时在互联网上解析域名的方式。它通过允许多个 IP 地址与单个域名关联来支持负载均衡。
- 服务发现系统 使用集中式注册表而不是 DNS 来跟踪哪些服务端点可用。当新服务实例启动时,它通过声明它正在侦听的主机和端口以及相关元数据
- 服务网格 是一种复杂的负载均衡形式,它结合了软件负载均衡器和服务发现。与在单独机器上运行的传统软件负载均衡器不同,服务网格负载均衡器通常作为进程内客户端库或作为客户端和服务器上的进程或"边车"容器部署。
持久化执行与工作流
在我们的示例中,处理单个付款需要许多服务调用。支付处理器服务可能会调用欺诈检测服务以检查欺诈,调用信用卡服务以扣除信用卡费用,并调用银行服务以存入扣除的资金。我们将这一系列步骤称为 工作流,每个步骤称为 任务。工作流通常定义为任务图。工作流定义可以用通用编程语言、领域特定语言 (DSL) 或标记语言(如业务流程执行语言 (BPEL)编写。
工作流引擎通常由编排器和执行器组成。编排器负责调度要执行的任务,执行器负责执行任务。当工作流被触发时,执行开始。如果用户定义了基于时间的调度(例如每小时执行),则编排器会自行触发工作流。外部源(如 Web 服务)甚至人类也可以触发工作流执行。一旦触发,就会调用执行器来运行任务。
持久化执行
持久化执行框架是为工作流提供 恰好一次语义 的一种方式。如果任务失败,框架将重新执行该任务,但会跳过任务在失败之前成功完成的任何 RPC 调用或状态更改。相反,框架将假装进行调用,但实际上将返回先前调用的结果。
同样,由于持久化执行框架期望以确定性方式重放所有代码(相同的输入产生相同的输出),因此随机数生成器或系统时钟等非确定性代码会产生问题。
事件驱动的架构
事件驱动架构,这是编码数据从一个进程流向另一个进程的另一种方式。请求称为 事件 或 消息;与 RPC 不同,发送者通常不会等待接收者处理事件。此外,事件通常不是通过直接网络连接发送给接收者,而是通过称为 消息代理(也称为 事件代理、消息队列 或 面向消息的中间件)的中介,它临时存储消息。
消息代理
消息代理通常不强制执行任何特定的数据模型——消息只是带有一些元数据的字节序列,因此你可以使用任何编码格式。常见的方法是使用 Protocol Buffers、Avro 或 JSON,并在消息代理旁边部署模式注册表来存储所有有效的模式版本并检查其兼容性。AsyncAPI(OpenAPI 的基于消息传递的等效物)也可用于指定消息的模式。
分布式 actor 框架
Actor 模型 是单个进程中并发的编程模型。与其直接处理线程(以及相关的竞态条件、锁定和死锁问题),逻辑被封装在 actor 中。每个 actor 通常代表一个客户端或实体,它可能有一些本地状态(不与任何其他 actor 共享),并通过发送和接收异步消息与其他 actor 通信。消息传递不能保证:在某些错误场景中,消息将丢失。由于每个 actor 一次只处理一条消息,因此它不需要担心线程,并且每个 actor 可以由框架独立调度。