专业书籍精读 · DDIA · 第 4 章
Designing Data-Intensive Applications · Ch 4 · Martin Kleppmann · 2017
你手机上的 App 差不多每周都在更新,可你去年发的朋友圈、三年前下的订单,今天刷开还得原样显示。这里藏着一个悄悄的难题:代码天天变,但旧数据得一直能被读懂。这一章讲的就是——数据在内存里是活生生的对象,一旦要存进磁盘、发上网络,就得先「压扁」成一串字节;而当代码升级了、数据的样子也想改一改时,怎么让新旧两代人还能互相看懂对方写的东西。
把「存数据、发数据」想成寄快递:你桌上摊开的东西(内存里的对象)没法直接塞进邮筒,得先打包装箱——这一步叫编码;对方收到再拆箱还原——叫解码。麻烦的是,寄件方和收件方用的不是同一版说明书:你这边 App 已经更新、箱子里多塞了样新东西,对方 App 还是老版本、说明书上根本没这一项。怎么让老版本收到也不至于当场懵掉?这就是这一章的全部戏眼。
因为大系统没法说停就停、一次全换新。升级几百上千台服务器,只能一台一台滚着来(滚动升级)——于是必然有一段时间,新版代码和老版代码在同时干活。老代码写下的数据,新代码要能读(这叫向后兼容,照顾过去);反过来更别扭:新代码写下的数据,老代码也得能读,哪怕里面有它没见过的新字段(这叫向前兼容,照顾未来)。数据往往比代码活得更久——五年前入库的一条记录还躺在数据库里,早换了好几拨代码来读它。兼容做不好,升级就等于埋雷。
聪明的格式(Google 的 Protocol Buffers、Facebook 的 Thrift)用了一个朴素到位的招:不靠字段的名字,而是给每个字段钉一个号码牌——就像机场托运,认的是行李条上的号,不是你行李箱长啥样。存下来的字节里只有「几号:什么值」,没有一长串字段名。这样一来:想加个新字段,就发一个没用过的新号;老代码扫到不认识的号,耸耸肩跳过去就是了,绝不崩溃。加字段、彼此不打架,靠的全是这块号码牌。唯一的铁规矩:号码牌一旦发出去,就永远不能改、不能换给别人用。
数据从一段代码流到另一段,无非三条路,条条都要过兼容这关:①存进数据库——写它的代码和以后读它的代码,可能隔了好几个版本;②调用服务——手机 App 发请求给后台,双方各自独立升级;③走消息队列——一个系统把消息扔进管道、另一个系统慢慢取,发和收根本不在同一时刻。只要收发两头版本可能不一致,编码格式就得替你把「互相看得懂」这件事兜住。
数据出内存前要打包成字节(编码),进内存再拆包(解码);难点在于代码在滚动升级、新旧版本同时在跑,所以格式必须让新代码读得懂老数据、老代码也扛得住新数据。靠的是给字段编号而非记名字——加字段用新号、老号永不复用,升级才不埋雷。
想看具体格式、字节怎么排、真实系统怎么用? → 切到精读版
第 2、3 章讲数据怎么建模、怎么落盘;第 4 章问一个更贴近工程日常的问题:数据要在内存表示(对象、结构体、列表)和字节序列(存文件、走网络)之间来回翻译,这层翻译(编码 / 序列化)怎么做,才能让代码和数据各自独立地演化?全章的灵魂是两个词——向后兼容(backward compatibility):新代码能读老数据;向前兼容(forward compatibility):老代码能读新代码写的数据。做到这两条,你才敢对一个跑着的大系统做滚动升级,而不必让所有节点同时停机换代码。
.proto、Thrift 的 .thrift),再由工具生成各语言的读写代码。本章是 Part I「数据系统的基石」的收官。它上承第 2 章(数据模型)、第 3 章(存储引擎),把「数据长什么样、怎么存」的话题收到一个绕不开的落点——数据一旦要跨越进程、跨越时间,就必须编码成字节;同时它又是通往 Part II「分布式数据」的桥:复制、分区、多节点通信,本质都是数据在网络上流动,而流动就得先编码。落到现实,这一章对应的是你每天都在碰的决策:接口用 JSON 还是 Protobuf?给消息队列里的事件加个字段会不会搞挂下游?数据库某张表的结构能不能安全地改?
应用总在变——需求变了,数据的形态也跟着变:加个字段、拆个字段、改个类型。DDIA 把「数据形态可变」称为 schema 演化(evolution)。问题是,改动不会瞬间同步到系统的每一处:
于是「新旧代码、新旧数据四种组合同时存在」成了常态。要让系统在这种混乱里照常运转,就得让所有数据的编码格式同时满足向后和向前兼容。DDIA 有句点题的话:「数据的生命周期常常比写它的代码更长」——一条五年前写入的记录还静静躺在库里,读它的代码早已改过无数遍。不把兼容当第一公民,每次升级都是在给未来埋雷。
内存里的数据充满指针 / 引用——对象里套着对另一个对象的地址,这些地址一出本进程就毫无意义。所以要存盘、要上网,必须把它翻译成一段自包含的字节序列,这一步就是编码(encoding),也叫序列化。几乎每种语言都自带一套(Java 的 Serializable、Python 的 pickle、Ruby 的 Marshal),一行就能用——但 DDIA 劝你别拿它做长久存储或跨系统通信:① 绑死语言,Java 序列化的东西别的语言基本读不了;② 安全隐患,反序列化时能被诱导实例化任意类,是经典的远程执行漏洞;③ 版本兼容做得差,本章最看重的向前 / 向后兼容它恰恰照顾不好;④ 性能与体积也常不理想。结论:内置序列化图一时省事,长期是坑。
跨语言就转向标准格式。JSON、XML、CSV 胜在人能读、生态无处不在,是 Web 与配置的事实标准。但坑也实在:数字含糊——JSON 分不清整数和浮点、更没精度概念,超过 2^53 的大整数(如推特的雪花 ID)在按 IEEE 双精度浮点解析的语言里会悄悄丢精度(Twitter 的 API 为此对同一个 ID 既返回数字 id、又返回字符串 id_str);不支持二进制串,塞二进制得先 Base64、白胖 33%;schema 可选,字段类型全靠双方口头约定,容易对不上。它们够用、够通用,但当你在乎体积、类型精确、和强制的兼容规则时,就该看二进制 + schema 的格式了。
Thrift(Facebook)和 Protocol Buffers(Google)是同一路思想:先用 IDL 写死 schema(每个字段配一个数字标签 field tag),再由工具生成各语言的读写代码。关键在于——编码后的字节里只存「字段号 + 类型 + 值」,完全不存字段名。一个 userName="Martin" 不再拖着 8 个字符的键名,只留一个字节的标签号。所以它们比 JSON 小得多:DDIA 那条示例记录,JSON 约 81 字节,Thrift 紧凑协议约 34 字节、Protobuf 约 33 字节,小了一半还多。
省空间只是副产品,真正的价值是 schema 演化:字段靠号不靠名,于是——加字段只要给个新的、没用过的标签号:老代码读到不认识的号,按类型信息跳过即可(向前兼容);新代码读老数据时,缺的新字段给个默认值即可(向后兼容)。铁律有二:新加的字段不能设成必填(required),否则老数据一律读不过;标签号一旦用出去,永不修改、永不复用——改名字随便(名字不入编码),改号就等于换了个字段。删字段只能删可选字段,且那个号从此作废、不许再分给别人。
Avro(2009 年为 Hadoop 而生)走了另一条路:编码后的字节里既没字段名、也没字段号、连类型标记都没有——纯粹是一串值挨着排。这么极致的紧凑靠什么解码?靠两份 schema:写入时用的 writer's schema 和读取时用的 reader's schema。二者不必相同,只需兼容;Avro 按字段名把两份 schema 对齐(schema resolution)——writer 有、reader 没有的字段被忽略(向前兼容),reader 有、writer 没有的字段用 reader 里写好的默认值填(向后兼容)。
代价是:解码时必须拿得到 writer's schema。实践中,大文件在开头存一份 writer schema(Hadoop 里一存百万条记录,摊薄到几乎没成本);数据库 / 消息流则给每条记录带一个小小的版本号,去一个 schema 注册表(registry)查对应 schema。Avro 最大的甜头是对「动态生成的 schema」友好:数据库表加一列,直接照新表结构生成一份新 Avro schema 即可,不必像 Protobuf 那样手工给每列维护一个不重复的标签号——这正是它在数据仓库 / 大数据管道里受宠的原因。
格式讲完,DDIA 追问:数据究竟怎么从一段代码流到另一段?归成三种数据流(dataflow)模式,每一种都得过兼容关。经数据库:写它的进程和读它的进程可能是不同版本,甚至「给未来的自己写」;一个坑是——旧代码读了一条含新字段的记录、改了别的字段又写回去,若解析时把不认识的新字段丢了,就会悄悄抹掉数据,格式和代码都要保住未知字段。经服务(REST / RPC):客户端与服务端各自升级,通常服务端先升,所以要请求向后兼容、响应向前兼容;RPC 想把网络调用伪装成本地调用,但网络会延迟、丢包、超时,得靠幂等与重试兜。经异步消息(消息队列 / actor):发送方把消息投进 broker、接收方将来某刻再取,两头彻底解耦、时序错开,兼容要求最直接。
本章的选型有两层。第一层是选编码格式——从「一行就能用」的语言内置,到人可读的文本,再到紧凑且强制兼容规则的二进制 schema,代价与收益逐级不同:
表 1 · 五类编码格式对比
| 格式 | 体积 / 性能 | schema 与兼容 | 跨语言 | 适合 |
|---|---|---|---|---|
| 语言内置 (pickle/Serializable) | 一般 | 版本兼容差、有安全漏洞 | 否(绑死语言) | 同语言、临时缓存;别用于长存 / 通信 |
| JSON / XML | 大、含字段名 | schema 可选;数字含糊、无二进制 | 是 | Web API、配置、人要读的场合 |
| CSV | 较小 | 无类型、易歧义、无嵌套 | 是 | 简单表格导入导出 |
| Thrift / Protobuf | 小(约 JSON 的 1/2) | schema 必填;字段号驱动演化 | 是(生成多语言代码) | 微服务 RPC、内部高频通信 |
| Avro | 最小(无标签无类型) | writer/reader 两份 schema 对齐 | 是 | 大数据 / 数仓、动态生成 schema |
第二层是选数据流方式——同一份数据,走数据库、走服务、还是走消息,兼容的方向和难点各不同:
表 2 · 三种数据流模式的兼容重点
| 经数据库 | 经服务(REST / RPC) | 经异步消息 | |
|---|---|---|---|
| 谁跟谁通信 | 写进程 ↔ 读进程(含未来的自己) | 客户端 ↔ 服务端 | 发送方 ↔ 接收方(隔 broker) |
| 时序 | 跨时间,数据比代码活得久 | 同步请求-响应 | 异步、解耦、可缓冲重投 |
| 兼容重点 | 两向都要;读-改-写勿丢未知字段 | 请求向后、响应向前兼容 | 两向都要,收发独立升级 |
| 典型技术 | 各类数据库 + 上述编码 | REST、gRPC、SOAP、Finagle | Kafka、RabbitMQ、actor 框架 |
| 特有的坑 | 老代码回写抹掉新字段 | 网络会延迟 / 失败,需幂等重试 | 消息重复 / 乱序,需消费端兜 |
一条实用心法:对外、要人读、变动少的接口,JSON 足矣;对内、高频、在乎体积和强制兼容的服务间通信,上 Protobuf / gRPC;大数据管道、schema 随库结构自动变的场景,Avro 最省心。
这一章是微服务和大数据时代的地基。凡是拆成多服务、要独立部署的系统,团队 A 升级自己的服务时不能等团队 B 一起改——能做到这点,全靠接口的编码格式向前 / 向后兼容。gRPC 用 Protobuf、Kafka + Avro + Schema Registry 管事件流、REST + JSON 撑开放 API——你每天用的基础设施,正是本章思想的直接产物。这些主张也有公开可查的工程实践替它们背书:
① 一句话:数据出内存要编码成字节、进内存要解码;核心是让格式支持 schema 演化,达成向后兼容(新读旧)+ 向前兼容(旧读新)。
② 为什么非做不可:大系统靠滚动升级,新旧代码 / 新旧数据必然共存,且数据比代码活得久,兼容做不好升级就埋雷。
③ 别用语言内置序列化(pickle / Serializable):绑死语言、有安全洞、版本兼容差,只配做临时缓存。
④ 文本格式(JSON / XML / CSV)通用可读,但数字含糊(大整数丢精度)、不支持二进制、schema 可选。
⑤ Thrift / Protobuf:用字段号认字段、不用名;加字段给新号、老号永不复用、新字段不设必填——体积约 JSON 的一半,兼容靠号成立。
⑥ Avro:数据不带标签不带类型,靠 writer / reader 两份 schema 按名对齐;最紧凑,且对动态生成 schema 友好,大数据管道最爱。
⑦ 三种数据流:经数据库(跨时间、防读改写丢字段)、经服务(REST / RPC,请求向后·响应向前)、经异步消息(Kafka / actor,收发解耦)。
⑧ 落地:gRPC=Protobuf、Kafka+Avro+Schema Registry、REST+JSON;能独立部署微服务、能安全演化事件流,全建立在本章的兼容规则上。