DDIA 导读 CH5 — 编码与演化(下)
2026年6月28日
在 DDIA 导读 CH5 — 编码与演化(上) 中,我们讨论了第五章前半段关于编码格式的比较,从 JSON、XML,一直到 Protocol Buffers 和 Avro。如前半部所说,我们之所以需要编码与解码,是因为当数据离开当前程序的内存,就需要被转换成其他程序能够读取的形式。当数据进入另一台服务器、被写入数据库,或者被放到消息队列中,在这些不同地方都必须能解读该数据,所以格式转换很重要。
不仅是不同系统之间的格式要统一,当系统改版时,新系统和旧系统如果没有统一,就会产生兼容性问题。因此,在做数据格式演化时,需要同时考虑向前兼容(旧程序要能处理新版数据)与向后兼容(新程序要能读懂旧数据),这样才能确保不同版本的程序在面对数据时都能有效应对。
前半部谈了不同格式,后半部作者则进一步讨论数据实际会流经的地方:数据库、不同服务、工作流引擎,以及异步消息传递。在章节最后,作者也谈到了事件驱动架构(event-driven architecture)中的数据流动方式。
数据库需要兼容性
很多人看数据库时,会把数据库当成单纯存储的地方。但换个角度看,数据库其实类似一个数据中转站。对不同服务的程序来说,会在某个时间点把数据留在数据库,然后在未来另一个时间点读取。读取时,可能是新版本程序来读取,因此前面谈到的兼容性,对数据库来说很重要。
比如,如果数据库没有做到向后兼容,未来新版程序代码就读不懂旧版本程序写下的数据;这会导致新版本部署后,旧数据读不出来。从软件角度来看,这是相当严重的事故。
此外,在现代数据系统中,同时有多个不同服务对数据库发出读写请求,并不是罕见情况。而这些服务实例,可能会因为扩容需要而进行实例升级;升级时通常会是渐进式的,先升一台,完成后再升下一台,逐步把旧版本替换掉。这时就可能出现一个微妙状况:已经升级的实例写入新版本的数据格式,而还没升级的旧实例仍在运行,所以会遇到新数据。在这种情况下,就必须通过向前兼容来确保系统顺利运行。
例如原本订单系统有 pending、paid 和 cancelled 三种状态,后来支持退款后加入 refunded。这时新版服务开始写入 refunded,还没升级的旧服务不认识这个状态,处理起来就会出错。如果有做好向前兼容,就能避免系统因为无法处理而直接崩溃。
除了这种情况,数据库中也常有不同时间点写入的数据。因此可能有某条刚写入的数据,同时也有另一条多年前写入的数据。这些不同时期写入的数据,可能因为版本不同而产生问题,例如上面的订单系统例子。
要处理这类问题,可以通过数据迁移,一次性把旧数据转换成新格式。但在数据量大的情况下,迁移成本会很高。如果要避免这种全量重写,通过前面提到的默认 null 来处理,会是更简便的方法。当然,在比较复杂的变更中,可能就需要从程序层级处理数据迁移,确保数据能被顺利处理。
客户端与服务器端如何通信
除了数据库可能需要和多个服务器通信外,在现代系统架构中,往往会有服务器与客户端通信,以及服务器之间彼此通信的需求。在这些不同端点通信的过程中,也需要确保数据传递格式是彼此能理解的。
比如在网页开发中,浏览器是客户端,网站服务器则是服务器端。当用户进入网页时,浏览器会向服务器发送 GET 请求,拿到 HTML、CSS、JavaScript、图片等静态资源,以渲染网页内容并让网页能够交互。
而在一个微服务架构中,某台服务器可能会是另一台服务器的客户端。例如订单服务会调用支付微服务,这时订单服务对支付服务来说,就是客户端角色。除了内部架构,也经常会有调用第三方服务的情况,例如后端系统调用第三方支付 API,这时自家的后端系统就是客户端。
在网页开发中,客户端会通过 API 向服务器端发送请求,而在众多形式中,最热门的莫过于 REST 的设计理念。REST 经常搭配 HTTP 使用,把 URL 当作资源表示,搭配 HTTP 方法,例如 GET 与 POST,同时用标准 HTTP 功能处理缓存、内容类型协商等议题。
不过即使用了 REST,客户端在发送请求时仍需要知道很多细节。例如每个端点用什么 HTTP 方法、请求时要带什么、服务器端响应长什么样。对此,开发者之间会通过接口定义语言(IDL)来描述 API。我们可以把接口定义语言理解成 API 规格书。在 REST/HTTP API 中常见的是 OpenAPI;在 RPC 类型的 API 中,常见的是 gRPC 搭配 Protocol Buffers 的 .proto 文件描述服务接口。
举例来说,书中提供了以下 OpenAPI 示例。在下面的例子中,定义了 API 的名称叫 Ping, Pong,本地服务器 http://localhost:8080 中有一个 /ping 端点,可以用 GET 调用,成功时会返回某个 JSON 格式的对象,内容会带有一个 message。
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!
通过这种定义格式,开发者之间沟通会容易许多。现在也有许多框架可以根据接口定义语言,自动生成相应的类型验证,让开发者只需要通过一个指令,就可以直接拉取并自动更新,不必担心调用 API 时出错。
远程过程调用(RPC)的问题
谈到客户端与服务器端通信,就不能不提 1970 年代就已经存在的远程过程调用(RPC)。这种做法是让对远程程序的调用,变得像本地函数一样。把分布式系统包装得像本地普通程序,能让程序代码看起来很简单,但作者也提醒,由于网络调用与本地函数仍有区别,所以需要特别小心。
RPC 很容易让人忘记自己正在跨网络发请求,而请求中间一旦隔着网络,就可能出现各种失败情况,也需要面对延迟、数据与版本兼容问题。而这里面出现的许多问题,可能都不在自己的控制范围内,因此写程序时需要特别注意可能出现的异常情况,并在程序代码中做相关防御。
举例来说,因为有网络断线的可能,所以程序代码中要有重试逻辑;而当程序代码中有重试逻辑,就必须考虑重复请求可能造成的重复执行问题(备注:我们在 API 设计 — 如何设计稳定可预测的 API 一文有详谈)。
从作者的观点来看,把远程服务当成本地对象意义不大,因为本质上它们是不同东西。不过这不代表不要用 RPC。以我们的经验来看,像 gRPC 这样的工具仍然非常好用,只是如作者所述,使用时不要忘记背后是通过网络发请求,所以程序代码中要明确处理超时、重试、版本兼容等情况。
加入 E+ 会员方案
对更深入了解这个主题,以及其他前后端开发、软件工程、AI 工程主题感兴趣的读者,欢迎加入 E+ 一起成长(链接)。