编码与演化

新旧版本的代码以及新旧数据格式,可能同时存在于系统中。系统要继续顺利运行,就需要保持双向兼容:

向后兼容通常不难实现,新代码作者知道旧代码写入的数据格式,可以显式处理。向前兼容则可能棘手得多,因为旧代码必须忽略新版本代码新增的部分。

向前兼容有一个难点,在模式中添加了一个字段,新代码创建一个包含这个字段的记录,旧版本代码读出数据修改然后又写入数据库,可能将新的字段搞丢了。

图 5-1 旧版应用程序更新先前由新版应用程序写入的数据时,若处理不慎,可能丢失数据

编码数据的格式

特定语言的格式

许多编程语言都内置了将内存对象编码成字节序列的功能,如 Java 有 java.io.Serializable,Python 有 pickle,Ruby 有 Marshal,等等。

这类编码通常与某种编程语言紧密绑定,其他语言很难读取。

JSON、XML 及其二进制变体

说到可由多种编程语言读写的标准编码,JSON 和 XML 是最显眼的候选者。CSV 是另一种流行的语言无关格式,但它只能表示不含嵌套的表格数据。

JSON、XML 和 CSV 都是文本格式,因而具有一定的人类可读性。

JSON 模式

JSON 模式规范提供了许多功能。它包含字符串、数值、整数、对象、数组、布尔值和空值等标准基本类型,还另有一套验证规范,开发者可以用它给字段附加约束。

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "patternProperties": {
        "^[0-9]+$": {
        "type": "string"
    }
    },
    "additionalProperties": false
}

比如 nodejs 后端就有一个 Joi 的库,可以验证客户端请求的 JSON 字段。

二进制编码

JSON 的二进制编码,如 MessagePack、CBOR、BSON、BJSON、UBJSON、BIJSON、Hessian、Smile 以及 XML 的二进制编码 WBXML、Fast Infoset。

{
    "userName": "Martin",
    "favoriteNumber": 1337,
    "interests": ["daydreaming", "hacking"]
}
图 5-2 示例中的记录使用 MessagePack 编码后的结果

0x83 高位 0x80 表示接下来是个对象,其中有三个字段 0x03

0xa8 高位 0xa0 表示接下来是个字符串,长度为8个字节 0x08

总之就只有一套自己的编码格式。

Protocol Buffers

Protocol Buffers(protobuf)由 Google 开发的二进制编码库。与 Facebook 的 Apache Thrift 很相似。

syntax = "proto3";

message Person {
    string user_name = 1;
    int64 favorite_number = 2;
    repeated string interests = 3;
}

Protocol Buffers 自带代码生成工具。它接收上述模式定义,生成用各种编程语言实现该模式的类,应用程序可以调用生成的代码来编码或解码符合模式的记录。

图 5-3 使用 Protocol Buffers 编码的示例记录

编码里是没有字段名的,只有 tag 号。字段标签好比字段的别名:无需写出字段名,就能以紧凑的方式指出所说的是哪个字段。

字段标签与模式演化

模式不可避免地会随时间改变,这称为 模式演化(schema evolution)。

只要标签号维护好,使用过某个标签号后就不要动了。

新代码读旧数据,只要前面的标签号与目前的字段标签号对应起来,缺少的标签号 直接赋为默认值。

旧代码读新数据,只要解析要用的标签号,没有的就跳过别管就好了。

已经用过的标签号绝不能再次使用

Avro

Apache Avro 是作为 Hadoop 的子项目启动。

它有两种模式语言:一种是供人编辑的 Avro IDL,另一种基于 JSON,更便于机器读取。与 Protocol Buffers 一样,Avro 的模式语言只规定字段及其类型,不支持 JSON 模式那样复杂的验证规则。

用 Avro IDL 编写的示例模式可能如下所示:

record Person {
    string                  userName;
    union { null, long }    favoriteNumber = null;
    array<string>           interests;
}

等价的 JSON 表示如下:

{
    "type": "record",
    "name": "Person",
    "fields": [
        {"name": "userName",        "type": "string"},
        {"name": "favoriteNumber",  "type": ["null", "long"], "default": null},
        {"name": "interests",       "type": {"type": "array", "items": "string"}}
    ]
}

字节序列,会发现其中没有任何内容标识字段或数据类型;编码仅仅是各个值的拼接。字符串就是长度前缀加上 UTF-8 字节,但编码数据本身并不说明它是字符串——它同样可能是整数或其他任何东西。整数则使用变长编码。

图 5-4 使用 Avro 编码的示例记录

这意味着,读取数据的代码只有使用与写入代码 完全相同的模式,才能正确解码二进制数据。读写双方的模式只要有任何不一致,解码结果就会出错。

写入者模式与读取者模式

写入模式 和 读取者模式,很好理解,顾名思义。

Protocol Buffers 的编码与解码可以使用不同版本的模式。Avro 解码时使用两个模式:写入者模式必须与编码时所用模式完全相同,读取者模式则可以是较旧或较新的版本。

写入者模式与读取者模式

如果读写双方的模式相同,解码很简单。如果不同,Avro 会并排比较写入者模式与读取者模式,把数据从前者转换成后者,从而协调其中的差异。

写入者模式和读取者模式中的字段顺序不同并不成问题,因为模式解析会按字段名配对。读取代码如果遇到只存在于写入者模式、却不在读取者模式中的字段,就将其忽略;如果读取代码需要某个字段,而写入者模式中没有同名字段,就填入读取者模式声明的默认值。

图 5-6 Avro 读取器协调写入者模式与读取者模式之间的差异

模式演化规则

对 Avro 而言,向前兼容意味着可以用新版模式写入、用旧版模式读取;反过来,向后兼容意味着可以用旧版模式写入、用新版模式读取。

为了保持兼容,只能添加或删除带默认值的字段。假设新增了一个带默认值的字段,于是该字段存在于新模式、却不存在于旧模式。当采用新模式的读取者读取旧模式写入的记录时,就会为缺少的字段填入默认值。

如果新增的字段没有默认值,新读取者就无法读取旧写入者产生的数据,因而破坏向后兼容。如果删除的字段没有默认值,旧读取者就无法读取新写入者产生的数据,因而破坏向前兼容。

只要 Avro 能完成相应的类型转换,就可以更改字段的数据类型。字段名也能更改,不过稍微麻烦一些:读取者模式可以为字段名声明别名,从而让旧写入者模式中的字段名与别名匹配。因此,更改字段名向后兼容,却不向前兼容。同样,给联合类型增加一个分支向后兼容,却不向前兼容。

Avro 就是屎,Hadoop 家族那套东西本来就挺鸡肋的。

什么是写入者模式

读取者如何知道某段数据是用哪个写入者模式编码的?不能把整个模式塞进每条记录。

包含大量记录的大文件:

Avro 的一种常见用途,是存储包含数百万条记录的大文件,所有记录都使用同一个模式编码。此时,文件的写入者只需在文件开头写入一次写入者模式。Avro 为此规定了一种文件格式,称为对象容器文件。

逐条写入记录的数据库:

在数据库中,不同记录可能在不同时间用不同的写入者模式写入,不能假定所有记录都采用同一个模式。最简单的解决方案,是在每条编码记录的开头放一个版本号,并在数据库中维护模式版本列表。读取者取出记录后先提取版本号,再从数据库取得该版本对应的写入者模式,用它解码记录的其余部分。

通过网络连接发送记录:

两个进程通过双向网络连接通信时,可以在建立连接时协商模式版本,并在连接的整个生命周期中使用这个模式。无论采用哪种方式,维护模式版本数据库都很有用:它既是文档,也让你有机会检查模式兼容性。版本号可以是简单递增的整数,也可以是模式的哈希值。

动态生成的模式

假设你想把关系数据库的内容转储到文件,并希望使用二进制格式,避开前面提到的 JSON、CSV、XML 等文本格式的问题。使用 Avro 时,可以很容易地从关系模式生成 Avro 模式(采用前面展示过的 JSON 表示),再用它编码数据库内容,把所有数据转储到 Avro 对象容器文件中。可以为每张数据库表生成一个记录模式,让表中的每一列对应记录中的一个字段,数据库列名则映射为 Avro 字段名。

如果用 Protocol Buffers 完成这项工作,字段标签很可能必须手工分配:数据库模式每次变化,管理员都得手工更新数据库列名到字段标签的映射。

模式的优点

Protocol Buffers 和 Avro 都用模式描述二进制编码格式。它们的模式语言比 XML 模式或 JSON 模式简单得多;后两者支持更细致的验证规则,例如“这个字段的字符串值必须匹配某个正则表达式”,或者“这个字段的整数值必须介于 0 和 100 之间”。Protocol Buffers 和 Avro 实现起来更简单,使用起来也更简单,因此已经支持相当广泛的编程语言。

数据流的模式

每当你想把数据发送给不共享内存的另一个进程。例如通过网络发送数据,或者将数据写入文件 都需要先把它编码成字节序列。

流经数据库的数据流

在数据库中,写入数据库的进程负责编码数据,读取数据库的进程负责解码。也许始终只有一个进程访问数据库,此时读取者只不过是同一进程的后续版本——可以把向数据库存入数据看作 给未来的自己发送消息。

多个不同进程同时访问数据库很常见。数据库中的某个值可能由 较新 版本的代码写入,随后却被仍在运行的 较旧 版本读取。因此,数据库通常也需要向前兼容。

不同时间写入的不同值

数据库通常允许随时更新任何值。因此,同一个数据库里可能既有五毫秒前写入的值,也有五年前写入的值。

可以把数据重写(即 迁移)到新模式,但对大型数据集来说代价高昂,所以大多数数据库都会尽量避免。

大多数关系数据库允许某些简单的模式变更,例如增加一个默认值为 null 的新列,而不必重写已有数据。读取旧行时,如果磁盘上的编码数据缺少某一列,数据库便为它填入 null。

模式演化让整个数据库看上去仿佛都用同一个模式编码,尽管底层存储中可能混有按各个历史版本模式编码的记录。

归档存储

会不时为数据库制作快照,用于备份或者加载到数据仓库中。这时,即使源数据库的原始编码混合了不同时期的多个模式版本,数据转储通常也会统一使用最新模式编码。反正数据总要复制一遍,不妨让副本采用一致的编码。

数据转储一次写成,此后不再修改,因此 Avro 对象容器文件之类的格式很适合。

流经服务的数据流 REST 与 RPC

客户端(client)和 服务器(server)。服务器通过网络公开 API,客户端连接服务器并向 API 发出请求。服务器公开的这个 API 称为 服务(service)。

面向服务架构或微服务架构的一项关键设计目标,是让服务可以独立部署和演化,从而使应用程序更容易修改和维护。服务器和客户端的新旧版本同时运行是意料之中的,双方使用的数据编码必须跨服务 API 版本保持兼容。

Web 服务

如果以 HTTP 作为与服务通信的底层协议,就称为 Web 服务(Web service)。Web 服务常用于构建面向服务或微服务架构。

最流行的服务设计理念是 REST,它建立在 HTTP 的原则之上。REST 强调简单的数据格式,以 URL 标识资源,并利用 HTTP 的功能进行缓存控制、身份认证和内容类型协商。遵循 REST 原则设计的 API 称为 RESTful API。

调用 Web 服务 API 的代码必须知道应该请求哪个 HTTP 端点、应发送什么格式的数据,以及预期得到什么响应。即使服务遵循 RESTful 设计原则,客户端也得通过某种途径获知这些细节。服务开发者通常使用接口定义语言(IDL)来定义并记录 API 端点和数据模型,随后再逐步演化它们。其他开发者可以根据服务定义判断如何发起请求。最流行的两种服务 IDL 是 OpenAPI(也称为 Swagger)和 gRPC。OpenAPI 用于收发 JSON 数据的 Web 服务,而 gRPC 服务收发 Protocol Buffers 数据。

使用 YAML 编写的 OpenAPI 服务定义示例:

openapi: 3.0.0
info:
  title: Ping, Pong
  version: 1.0.0
servers:
  - url: http://localhost:8080
paths:
  /ping:
    get:
      summary: Given a ping, returns a pong message
      responses:
        '200':
          description: A pong
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
                    example: Pong!

Spring Boot、FastAPI 和 gRPC 等框架让开发者只需编写每个 API 端点的业务逻辑,由框架负责路由、指标、缓存、身份认证等事务。

Python 使用 FastAPI 实现上面示例定义的服务

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="Ping, Pong", version="1.0.0")

class PongResponse(BaseModel):
    message: str = "Pong!"

@app.get("/ping", response_model=PongResponse,
         summary="Given a ping, returns a pong message")
async def ping():
    return PongResponse()

以流行的 Python 框架 FastAPI 为例,开发者先用代码编写服务器,框架再自动生成 IDL;gRPC 等框架则反过来,先编写服务定义,再生成服务器代码的脚手架。

远程过程调用的问题

远程过程调用(remote procedure call,RPC),RPC 模型试图让远程网络服务请求,看起来就像在同一进程内调用编程语言中的函数或方法一样。

RPC 乍看十分方便,这种思路却有根本性的缺陷 38 39。网络请求与本地函数调用大不相同:

负载均衡器、服务发现和服务网格

所有服务都通过网络通信,因此客户端必须知道目标服务的地址,这个问题称为 服务发现(service discovery)。

最简单的做法,是把运行服务的 IP 地址和端口配置到客户端中。这样确实能工作,但服务器一旦离线、迁移到另一台机器或负载过高,就必须手工重新配置客户端。

为了提高可用性和可伸缩性,一项服务通常会在不同机器上运行多个实例,任一实例都能处理传入的请求。把请求分摊到这些实例上的过程称为 负载均衡(load balancing)。

负载均衡和服务发现有许多实现方案:

硬件负载均衡器(hardware load balancer)

是安装在数据中心的专用设备。客户端只连接一个主机和端口,设备再把传入连接路由到运行该服务的某台服务器。此类负载均衡器会在连接下游服务器时检测网络故障,并将流量转移到其他服务器。

软件负载均衡器(software load balancer)

的行为与硬件负载均衡器大体相同,只是不需要专用设备。Nginx 和 HAProxy 等软件负载均衡器就是可以安装在普通机器上的应用程序。

域名系统(DNS)

用于在互联网上解析域名,例如打开网页时就会用到。它允许一个域名关联多个 IP 地址,从而实现负载均衡。

服务发现系统(service discovery system)

不使用 DNS,而是通过集中式注册表跟踪哪些服务端点可用。新服务实例启动时,会向发现系统注册自己,声明正在监听的主机和端口,以及分片归属信息、数据中心位置等相关元数据。随后,服务定期向发现系统发送心跳,表示自己仍然可用。比如使用 ZooKeeper。客户端要连接服务时,先向发现系统查询可用端点列表,再直接连接某个端点。

服务网格(service mesh)

是一种更复杂的负载均衡方案,把软件负载均衡器与服务发现结合起来。传统软件负载均衡器运行在独立机器上,服务网格的负载均衡器则通常部署为进程内客户端库,或者部署为伴随客户端和服务器的进程或“边车”容器。客户端应用程序连接本机的服务负载均衡器,后者再连接服务器一侧的负载均衡器,最终把连接路由到本机的服务器进程。这种拓扑虽然复杂,却有不少优点。客户端和服务器应用程序都只需建立本地连接,因此连接加密可以完全由负载均衡器处理,让应用程序不必面对 SSL 证书和 TLS 的复杂性。服务网格还提供了强大的可观测性,能够实时跟踪服务间的调用关系、检测故障、监测流量负载等。

在使用 Kubernetes 等编排器的高度动态环境中,组织往往会选择 Istio 或 Linkerd 等服务网格。

RPC 的数据编码与演化

为了实现可演化性,RPC 客户端与服务器必须能够独立修改和部署。

服务数据流可以作一个简化假设:先更新所有服务器,再更新所有客户端通常是合理的。因此,请求只需向后兼容,响应只需向前兼容。

RPC 方案的向后与向前兼容性质,取决于它所采用的编码:

RPC 经常用于跨组织边界通信,这让服务兼容性变得更加困难:服务提供者通常无法控制客户端,也不能强迫它们升级。因此,兼容性必须维持很长时间,甚至可能永远维持下去。如果不得不作出破坏兼容性的变更,服务提供者往往只好同时维护多个版本的服务 API。

RESTful API 的常见做法,是在 URL 或 HTTP Accept 标头中加入版本号。如果服务用 API 密钥识别具体客户端,还可以在服务器端记录该客户端请求的 API 版本,并通过单独的管理界面更新版本选择。

常见到某个网站 API,https://example.com/api/v1/...https://example.com/api/v2/... 这样的操作。

持久化执行与工作流

基于服务的架构由多项服务组成,每项服务负责应用程序的一部分。以支付处理应用为例,它要从信用卡扣款,再把资金存入银行账户。系统很可能分别用不同服务负责欺诈检测、信用卡集成、银行系统集成等工作。

处理一笔付款需要多次服务调用。支付处理服务可能先调用欺诈检测服务检查风险,再调用信用卡服务扣款,最后调用银行服务把扣下的款项存入账户。

一系列步骤称为 工作流(workflow),其中每一步称为 任务(task)。工作流通常定义成一张任务图,其定义可以使用通用编程语言、领域特定语言(DSL),也可以使用业务流程执行语言(BPEL)之类的标记语言。

图 5-7 使用图形化的业务流程模型与标记法(BPMN)表示工作流的示例

工作流由 工作流引擎(workflow engine)运行或执行。引擎决定每项任务何时运行、在哪台机器上运行、任务失败时该怎么办(例如执行任务的机器崩溃),以及允许多少任务并行执行等。

工作流引擎通常由编排器和执行器组成:编排器负责调度,执行器负责真正运行任务。

工作流引擎种类繁多,面向的使用场景也各不相同。Airflow、Dagster 和 Prefect 等引擎与数据系统集成,用于编排 ETL 任务。Camunda 和 Orkes 等引擎提供图形化工作流表示,例如 图 5-7 中的 BPMN,让非工程师也能更方便地定义和执行工作流。Temporal 和 Restate 等引擎则提供 持久化执行(durable execution)。

持久化执行

对于需要事务语义的服务架构,持久化执行框架已经成为一种流行的构建方式。

在支付示例中,我们希望每笔付款都恰好处理一次。但工作流执行期间一旦发生故障,就可能出现信用卡已经扣款,银行账户却没有收到相应款项的情况。在基于服务的架构中,无法简单地把这两项任务包进一个数据库事务;况且,系统可能还要与我们无法充分控制的第三方支付网关交互。

持久化执行框架可以为工作流提供 恰好一次语义(exactly-once semantics)。任务失败后,框架会重新执行它,但会跳过失败前已经成功完成的 RPC 调用或状态变更:框架表面上再次发起调用,实际上却直接返回上一次调用的结果。这之所以可行,是因为框架把所有 RPC 和状态变更都记录在预写日志(WAL)之类的持久存储中。

用于所示支付工作流的 Temporal 工作流定义片段

@workflow.defn
class PaymentWorkflow:
    @workflow.run
    async def run(self, payment: PaymentRequest) -> PaymentResult:
        is_fraud = await workflow.execute_activity(
            check_fraud,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        if is_fraud:
            return PaymentResultFraudulent
        credit_card_response = await workflow.execute_activity(
            debit_credit_card,
            payment,
            start_to_close_timeout=timedelta(seconds=15),
        )
        # ...

Temporal 框架,外部服务仍然必须提供幂等API,开发者也必须记得为调用使用唯一ID,以防重复执行。

持久化执行框架会按顺序记录每次 RPC 调用,因此要求后续执行以同样的顺序发起同样的调用。这让代码变更十分脆弱:仅仅调整函数调用顺序,就可能引入未定义行为 。

与其修改现有工作流的代码,更安全的做法是单独部署一个新版本,让已有工作流的重执行继续使用旧版代码,只有新启动的工作流才使用新版。

事件驱动的架构

事件驱动架构(event-driven architecture),这是编码数据在进程间流动的另一种方式。请求在这里称为 事件(event)或 消息(message);与 RPC 不同,发送者通常不会等待接收者处理事件。事件一般也不会通过直接网络连接发给接收者,而是先经过一个临时存储消息的中介,称为 消息代理(message broker),也叫 事件代理(event broker)、消息队列(message queue)或 面向消息的中间件(message-oriented middleware)。

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

通过消息代理进行的通信是 异步的(asynchronous):发送者不等待消息送达,只管发出消息,然后就将其忘掉。不过,也可以让发送者在另一条通道上等待响应,从而实现类似同步 RPC 的模型。

消息代理

RabbitMQ、ActiveMQ、HornetQ、NATS 和 Apache Kafka 等开源实现逐渐流行。近年来,Amazon Kinesis、Azure Service Bus 和 Google Cloud Pub/Sub 等云服务也得到广泛采用。

最常见的是以下两种消息分发模式:

许多代理会把消息写入磁盘,以免代理崩溃或重启时丢失消息。不过,与数据库不同,许多消息代理会在消息被消费后自动删除它。也有些代理可以配置为无限期保存消息;典型的就是 RabbitMQ 和 Kafka 两者。

分布式 actor 框架

actor 框架对于 游戏服务器 开发领域很出名,也很适合。

Actor 模型(actor model)是一种用于单进程并发的编程模型。它不直接处理线程以及随之而来的竞态条件、锁和死锁,而是把逻辑封装在 actor 中。每个 actor 通常代表一个客户端或实体,可以拥有不与其他 actor 共享的本地状态,并通过收发异步消息与其他 actor 通信。消息传递并无保证:在某些错误场景下,消息会丢失。由于每个 actor 一次只处理一条消息,所以无需操心线程问题,而框架可以独立调度每个 actor。

Akka、Orleans 和 Erlang/OTP 等 分布式 actor 框架(distributed actor framework),用这种编程模型把应用程序扩展到多个节点。无论发送者和接收者位于同一节点还是不同节点,都使用同一种消息传递机制。如果双方位于不同节点,消息会被透明地编码成字节序列,通过网络发送,再由另一端解码。

位置透明性在 actor 模型中比在 RPC 中效果更好,因为 actor 模型本来就假设消息可能丢失,即使消息只在单个进程内传递也一样。网络延迟固然可能高于进程内延迟,但在 actor 模型中,本地通信与远程通信之间的根本差异要小得多。

分布式 actor 框架本质上把消息代理与 actor 编程模型集成在同一个框架中。不过,要对基于 actor 的应用程序进行滚动升级,仍然必须考虑向前和向后兼容:消息可能从运行新版代码的节点发往运行旧版代码的节点,也可能反过来。