这一章在做什么
程序在内存里用对象和指针,写文件、发网络之前要先变成一串字节。这一章先比几类编码:语言自带的序列化、JSON 这类文本格式、带模式的二进制格式(Protocol Buffers 靠标签号,Avro 靠写者和读者两份模式)。真正的考题是演化:滚动升级和迟迟不更新的客户端,让新旧代码、新旧数据长期并存,编码要两个方向都读得通。后半看数据经过数据库、服务调用、工作流和消息时,谁写谁读,各要哪个方向的兼容。
读之前:先预测
第 2 章提出了「可演化性」:系统要容易改。第 3 章说文档数据库在读的时候才解释结构(schema-on-read),关系数据库在写的时候就强制模式。这一章把「改」落到字节上:数据一旦写下或发出,读它的程序可能比写它的新,也可能比它旧。第 4 章的 LSM 合并在这里也露了一面:它顺手把旧数据改写成新格式。往后看,第 9 章接着讲超时和确定性重放,第 11 章会再用到 Avro 这类大文件,第 12 章细讲消息代理和幂等。
新旧代码、新旧数据会在系统里并存很久,没法一次全换掉。一种编码格式要有什么样的规矩,系统的各个部分才能各自升级,又不会互相读坏、写丢?
- 为什么说向前兼容比向后兼容难?除了「忽略不认识的字段」,旧代码还得做到什么?§0
- 在 Protocol Buffers 里,字段名和标签号哪个能改、哪个绝不能动?删掉的字段为什么要把标签号留着?§1.3
- Avro 的数据里没有任何标识,读的一方凭什么知道每一段是什么?§1.4
- 数据经过数据库、经过服务调用、经过消息代理,各自要哪个方向的兼容?§2.1§2.2§2.4
这一章的骨架
这一章分两半:§1 讲怎么把数据编码成字节,一种格式一种格式地比,落点是它们怎么对待模式的变化;§2 换个角度,讲字节在进程之间怎么流动,每一种流法里谁编码、谁解码、谁先升级。容易乱的是 §1.3 和 §1.4:两种格式都「带模式」,但兼容的规矩完全不同,一个靠数字,一个靠名字加默认值。读的时候把两套规矩分开记。
边读边填:每行一个问题,读到答案就写一两句。只存在你这台设备的浏览器里,别人看不到。
| 要找的 | 带着这个问题读,找到了就写下来 |
|---|---|
| 文本格式的坑 | 数字和二进制数据放进 JSON,各会出什么问题?人们怎么凑合?§1.2 |
| 标签号的规矩 | 加字段、删字段、改类型,各要守什么规矩?§1.3 |
| Avro 的规矩 | 什么样的字段才能加、才能删?改名为什么只有一个方向兼容?§1.4 |
| 写者模式来源 | 书里举了三种场合,读的一方各怎么拿到写者模式?§1.4 |
| 不重写旧数据 | 数据库怎么做到改了模式,却不用把所有旧数据重写一遍?§2.1 |
| RPC 的毛病 | 网络请求和本地函数调用有哪几处不一样?§2.2 |
| 持久执行代价 | 为什么改一个正在跑的工作流的代码很危险?怎么改才安全?§2.3 |
读完了?对照我找到的
- 文本格式的坑:超过 253 的整数在 JavaScript 里丢精度,只好再附一份字符串;二进制通常转成 Base64,体积大约多三分之一。
- 标签号的规矩:新字段用新号;删掉的号永远不再用,可以在模式里保留;改类型可能被截断。
- Avro 的规矩:只能加、删有默认值的字段;改名靠读者模式里的别名,所以新读旧行、旧读新不行。
- 写者模式来源:大文件在开头写一次;数据库里每条记录带版本号,去模式库里查;网络连接建立时双方协商。
- 不重写旧数据:读的时候把缺的列填成默认值(如 null),重写推迟到合并之类的时机,尽力而为。
- RPC 的毛病:结果不可预测、可能超时不知结果、重试会重复执行、延迟忽高忽低、参数要编码、跨语言类型要转换。
- 持久执行代价:重放要求按同样顺序发出同样的调用,改顺序就出问题;新代码单独部署,已有实例继续用旧版本。
分节地图
全章约 1.1 万词(不算代码和图注),建议分四次读。第二次(Protocol Buffers 和 Avro)最需要慢读,最好边读边玩下面的模拟器;第三次里 §2.2 很长,但负载均衡那一段和兼容的主线关系不大,可以读快些。
- 第一次坐下§0–§1.2 · 约 2500 词 · 为什么要两个方向的兼容,文本格式的毛病
- Applications inevitably change over time.…约 700 词开篇新旧代码为什么会同时在跑;向后、向前兼容的定义;旧代码把新字段写丢的风险。
- Formats for Encoding Data约 300 词编码数据的格式内存里的结构和字节序列两种表示,编码和解码这对词的几个别名。
- Language-Specific Formats约 300 词语言自带的格式Java、Python 自带的序列化方便在哪,又有哪几个深层问题。
- JSON, XML, and Binary Variants约 1300 词JSON、XML 和它们的二进制变体文本格式在数字、二进制上的含糊;JSON Schema 的开放与封闭;二进制 JSON 省了多少。
- 第二次坐下§1.3–§1.5 · 约 2900 词 · 两种带模式的二进制编码,全章的重心
- Protocol Buffers约 900 词Protocol Buffers(协议缓冲区)标签号代替字段名;加字段、删字段、改类型时各守什么规矩。
- Avro约 1600 词Avro 格式数据里只有值。写者模式和读者模式怎么对上,写者模式从哪来,为什么适合自动生成的模式。
- The Merits of Schemas约 400 词模式的好处老想法 ASN.1;带模式的二进制编码比 JSON 多给了什么。
- 第三次坐下§2–§2.2 · 约 3400 词 · 数据经过数据库和服务
- Modes of Dataflow约 200 词数据流动的几种方式兼容是编码方和解码方之间的关系。后面按数据怎么流来分。
- Dataflow Through Databases约 600 词经过数据库的数据流写数据库就是给将来的自己发消息;新旧格式的数据混在一起时数据库怎么办。
- Dataflow Through Services: REST and RPC约 2600 词经过服务的数据流:REST 与 RPCWeb 服务和接口定义;远程调用为什么不该装成本地调用;服务发现;接口怎么演化。
- 第四次坐下§2.3–§3 · 约 2100 词 · 工作流和事件,加小结
- Durable Execution and Workflows约 800 词持久执行与工作流一笔支付要调好几个服务。持久执行怎么做到「只执行一次」,代价是什么。
- Event-Driven Architectures约 900 词事件驱动的架构消息代理比直接调用多了什么;两种分发方式;分布式 actor 框架。
- Summary约 400 词小结全章回顾。先自己回忆一遍再读它。
先用中文过一遍
两个方向的兼容:新旧代码会一起跑很久
改一个功能,常常要给数据加个字段。可代码不能一瞬间全部换掉:服务端通常滚动升级(rolling upgrade),一次换几台,看没出事再换下一批,这期间新旧版本同时在线;手机上的 App 更新不更新,由用户说了算。所以系统里总有新代码在读旧数据,也总有旧代码在读新数据。
前一种叫向后兼容(backward compatibility),比较好办:写新代码的人知道旧格式长什么样,专门处理就行。后一种叫向前兼容(forward compatibility),难在旧代码写的时候还不知道将来会多出什么。它至少要能跳过不认识的字段;更进一步,旧代码读出一条记录、改了一处再写回去时,还得把那些不认识的字段原样带上,否则新代码写进去的东西就被它悄悄抹掉了。
同一条记录,四种写法:字段名、标签号,还是什么都不写
把一条记录编码成字节,最直接的是 JSON:每条记录都把字段名写一遍,人能看懂,体积也大,而且数字不分整数和浮点,没有二进制类型。二进制的 JSON 变体(如 MessagePack)把类型和长度换成字节,省得不多,因为字段名还在。
Protocol Buffers 要求先写模式,模式里给每个字段编一个号(标签号,field tag)。编码时只写「标签号 + 类型 + 值」,字段名根本不进数据,所以字段可以随便改名,标签号却一个也不能动。旧代码读到一个不认识的标签号,靠类型信息知道该跳过几个字节。
Avro 更进一步:数据里只有一个接一个的值,连标签号都没有。要解码,必须拿着写这份数据时用的那份模式(写者模式,writer's schema),按它的字段顺序一段段往下读。读的程序还有自己期望的模式(读者模式,reader's schema),两者可以不同,Avro 按字段名把它们对上:写者有、读者没有的字段跳过,读者有、写者没有的字段填读者模式里的默认值。于是兼容的规矩变成了「默认值」的规矩。
数据怎么流:判断方向只看读的一方是新还是旧
兼容总是两个进程之间的事:一个编码,一个解码。数据经过数据库时,写的进程编码、读的进程解码,读的人可能是将来的自己,也可能是还没升级的另一台实例。数据经过服务调用时,请求由客户端编码、服务端解码,响应反过来。书里给服务调用做了一个简化的假设:服务端先升级,客户端后升级。在这个假设下,请求只要向后兼容,响应只要向前兼容。经过消息代理时,发送方编码、接收方解码,中间的代理不管格式。
§2.2 后半有一段要单独留心:远程过程调用(RPC)想让一次网络请求看起来和本地函数调用一样。作者用一整串理由说明这条路为什么走不通,其中「超时之后不知道对方做没做」和「重试可能做两遍」会在后面几章反复出现。
这一章点名的系统
| 英文 | 中文 | 一句话 |
|---|---|---|
| encoding / decoding | 编码 / 解码 | 内存里的结构和字节序列之间的转换。也叫序列化、反序列化,书里避开这个词,因为第 8 章它另有所指(§1)。 |
| rolling upgrade | 滚动升级 | 一次只给几个节点换新版本,看没问题再往下换,不停服(§0)。 |
| backward compatibility | 向后兼容 | 新代码能读旧代码写的数据(§0)。 |
| forward compatibility | 向前兼容 | 旧代码能读新代码写的数据(§0)。 |
| schema evolution | 模式演化 | 模式随时间改变,同时保持两个方向的兼容(§1.3)。 |
| field tag | 标签号 | Protocol Buffers 模式里给每个字段编的号,编码时代替字段名(§1.3)。 |
| writer’s schema | 写者模式 | Avro 里编码这份数据时用的那一版模式,解码时必须拿到(§1.4)。 |
| reader’s schema | 读者模式 | 读数据的程序期望的模式,可以比写者模式新或旧(§1.4)。 |
| union type | 联合类型 | Avro 里一个字段可以是几种类型之一;想让字段可为空,就要把 null 列进去(§1.4)。 |
| IDL | 接口定义语言 | 描述数据结构或服务接口的语言,工具据此生成代码和文档(§1.3、§2.2)。 |
| RPC | 远程过程调用 | 让网络请求看起来像调用本地函数的做法(§2.2)。 |
| location transparency | 位置透明 | 调用方看不出对方在本地还是在远程(§2.2)。 |
| idempotence | 幂等 | 同一个操作做一次和做多次,结果一样;重试安全的前提(§2.2)。 |
| durable execution | 持久执行 | 记下工作流里每一步的调用和结果,失败后重放,已成功的步骤不再真的执行(§2.3)。 |
| message broker | 消息代理 | 发送方和接收方之间暂存消息的中间件(§2.4)。 |
动手:模拟器
先猜:按标签号编码,新模式删掉 seat、加一个字段 vip,而且 vip 用回 seat 留下的标签 2。新代码读一条旧数据,会报错吗?如果不报错,读出来的 vip 是什么?猜好了,在模拟器里依次点「删字段 seat」「加字段 vip」「vip 用 seat 的旧标签 2」验证。
模式演化:新旧代码能不能互读
旧模式描述一张票:买家 buyer、座位 seat、票价 price。用上面一排按钮把它改成新模式,看新代码读旧数据、旧代码读新数据各读出什么,再看旧代码改了票价写回之后,新字段还在不在。先从「加字段 vip」开始。
- 按标签号:点「加字段 vip」,看两个方向各读出什么;再取消「旧代码保留不认识的字段」,看最后一行,旧代码写回之后 vip 去哪了。
- 换成按字段名:加字段 vip,不勾「vip 带默认值」,哪个方向坏了?勾上再看。然后点「删字段 seat」,勾不勾「旧模式里 seat 带默认值」差在哪?同样的操作在按标签号的编码下为什么一直不报错?
- 「price 改名 amount」在两种编码下各会怎样?留意下面的字节数:改名之后,二进制的字节数变了吗?JSON 的呢?
词与短语
词条按在书里出现的顺序排,按节分组;读一节之前先把这一节的过一遍,读的时候就不用停。斜体小字是原句里连续的几个词,在电子书里搜它就能找到那一句。标着「每个字都认识」的,是字面意思会把你带偏的。行话(schema evolution、idempotence 这类)不在这里,在上面的术语表里。
§0 Applications inevitably change over time.…
§1 Formats for Encoding Data
§1.1 Language-Specific Formats
§1.2 JSON, XML, and Binary Variants
§1.3 Protocol Buffers
§1.4 Avro
§1.5 The Merits of Schemas
§2.1 Dataflow Through Databases
§2.2 Dataflow Through Services: REST and RPC
§2.3 Durable Execution and Workflows
§2.4 Event-Driven Architectures
§3 Summary
下面是读完再看的:你的预测对不对、几道换成你来选的判断题、值得细读的句子和理解检查。先看了,读书时就只是在找答案。
读完:回到你的预测
书里的答案是第三项(§0、§1.3)。Protocol Buffers 的旧代码读到不认识的标签号不会报错:每个字段带着类型,解析器知道跳过几个字节,也能把这些字节留着。但能不能活到写回,要看应用把记录解码成什么:如果解码成一个只认识旧字段的对象,写回时 coupon 就没了。作者希望的行为是旧代码原样保留新字段,即使它看不懂。§2.4 又提了一次:消费者把消息转发到别的主题时,同样要留住不认识的字段。想一想:如果换成 Avro,旧代码用自己的模式解码、再用自己的模式写回,结果会一样吗?(模拟器里可以试。)
书里的答案是第二项(§1.4)。Avro 按字段名对上:旧代码读新数据时,vip 只出现在写者模式里,跳过就行;新代码读旧数据时,读者模式要 vip,写者模式里没有,只能用读者模式里的默认值,可偏偏没给,于是读不了。所以书里的规矩是:只能加、删有默认值的字段。很多人直觉以为坏的是「旧读新」,因为「旧代码不认识新字段」听起来更危险。想一想:如果是删掉一个没有默认值的字段,坏的是哪个方向?
判断题:换成你来选
场景都是本站编的。先选,再看解释;解释里写了对应的节号,拿不准就回原书看那一节。
场景:一个票务系统每天凌晨把 MySQL 里的几十张表导出成文件,给数据团队分析。业务改得勤,表结构每周都在加列、删列;导出程序想从表结构自动生成编码用的模式,不想每次手工维护。
书里专门拿这个场景说明 Avro 的长处:模式里没有标签号,字段按名字对上,导出程序每次从表结构重新生成模式就行,读的一方照样能和旧模式对上。用 Protocol Buffers 就得有人维护「列名到标签号」的对应,还不能把用过的号再分出去。Java 自带的序列化把数据绑死在 Java 上,数据团队未必用 Java;CSV 没有模式,加删列都要手工处理,手写的文档也容易和实际脱节。
场景:会员服务用 Protocol Buffers 存会员档案。半年前删掉了字段 referrer(推荐人的用户 ID,标签 7)。这周新加字段 nickname,开发顺手用了空着的 7。上线后没有任何报错,但一批老会员的昵称显示成了一串用户 ID。
数据里只有标签号,没有字段名。老档案里标签 7 存的是推荐人 ID,新代码按新模式把 7 解释成昵称,类型又都是字符串,所以一声不吭地读错了。书里的规矩是删掉的标签号永远不能再用,可以在模式里把它保留起来防止有人忘记。Protocol Buffers 里改名本来就不影响数据;proto3 的字段缺了就填类型的默认值(空字符串、0),不会因为默认值报错。
场景:公司内部的库存服务被二十个下游服务调用。这次要给查询响应加一个字段 warehouse,给请求加一个可选参数 region。照惯例,库存服务自己先上线,下游各团队再陆续升级。
服务端先升级,所以有一段时间是新服务端处理旧客户端的请求(新代码读旧数据,向后兼容),旧客户端读新服务端的响应(旧代码读新数据,向前兼容)。书里正是做了「服务端先升级、客户端后升级」这个简化假设。加可选参数、给响应加字段,通常被认为是不破坏兼容的改动,前提是旧客户端会忽略响应里不认识的字段。
场景:订单号是 64 位整数,接口用 JSON 数字返回。后端日志里的订单号都对,网页前端显示的订单号却偶尔末几位不一样,而且只发生在号码特别大的订单上。
JSON 不区分整数和浮点数,也不规定精度。JavaScript 用双精度浮点数存数字,超过 253 的整数没法精确表示。书里举的例子是一家大网站的帖子 ID:接口里每个 ID 写两遍,一遍数字一遍字符串。后端日志是对的,说明数据库没溢出;JSON 是文本,没有字节对齐;gzip 出错会整个解析失败,不会只错末几位。
场景:退款流程用持久执行框架写成工作流,眼下有几千个实例在跑,有的停在人工审核上,要等好几天。你想在「扣回积分」和「退款到卡」之间插一步风控检查。
持久执行靠重放恢复:它记下每次调用的顺序和结果,重放时要求代码按同样的顺序发出同样的调用。往中间插一步,正在跑的实例一重放就对不上。书里的建议是新代码单独部署,已有实例继续用旧版本。随机 ID 是另一个坑:工作流代码要确定性,随机数要用框架提供的版本。全部取消会让几千笔退款中途停下,要人工收拾。
值得停下来的句子
the five-year-old data will still be there, in the original encoding, unless you have explicitly rewritten it since then.— §2.1。本站译:五年前的数据还会在那里,还是当初的编码,除非你后来特意把它重写过。
这句话把「兼容」从一个部署期间的临时问题,变成了一个长期问题。代码几分钟就能全部换掉,所以容易以为新旧并存只是升级那一阵子的事;数据不会自己升级,它让「新代码读旧数据」成了每一天都在发生的事。§2.1 后面讲数据库怎么靠读时填默认值、合并时顺手重写来应付,都是从这句话出发的。
there’s no point trying to make a remote service look too much like a local object in your programming language— §2.2。本站译:想让远程服务看起来太像你编程语言里的一个本地对象,是没有意义的。
这是作者对几十年 RPC 技术的判断。前面列的每一条差别(超时、重试、延迟、参数编码、类型转换)单看都是工程细节,合起来说明网络调用是另一种东西。留意作者的措辞是「太像」:不是不要 RPC 框架,而是别让它把网络的失败方式藏起来。第 9 章还会接着讲这些失败。
检查一下理解
1.(§0)服务端和客户端,各是什么原因让新旧代码同时存在?
服务端常用滚动升级,一次换几个节点,换完之前新旧版本同时在线。客户端要等用户自己安装更新,旧版本可能用很久。
2.(§1.1)语言自带的序列化,除了绑死一种语言,还有什么更危险的问题?
解码时要能实例化任意的类。攻击者如果能让程序解码一段他构造的字节,就可能借此在服务器上执行任意代码。
3.(§1.2)把二进制数据塞进 JSON,常见做法是什么?代价是什么?
转成 Base64 文本,再靠模式说明这个字段要按 Base64 解。能用,但不太体面,数据大约变大三分之一。
4.(§1.3)为什么字段名可以改,标签号却不能改?
编码后的数据里只有标签号,从不出现字段名,改名不影响任何已有数据。标签号一改,所有已经写下的数据就对不上号了。
5.(§1.4)在 Avro 里给字段改名,为什么是向后兼容而不是向前兼容?
新的读者模式可以给新名字加上旧名字作别名,读旧数据时按别名对上。旧代码的读者模式里没有新名字,读新数据时找不到这个字段。
6.(§2.1)关系数据库加一个默认为空的列,为什么不用重写整张表?
旧行照旧存着,读的时候发现缺这一列,就当它是 null 填上。整个库看起来像是用同一个模式写的,底下其实混着各个时期的编码。
7.(§2.2)一个网络请求超时了,为什么不能直接重试了事?
超时只说明没收到响应,不知道请求到没到、做没做。可能对方已经做完、只是响应丢了,重试就会做第二遍,除非协议里有去重(幂等)的机制。
8.(§2.4)消息代理比直接调用接收方多了哪些好处?说出三个。
接收方忙或不在时能当缓冲;接收方崩溃后能重新投递;发送方不用知道接收方的地址;同一条消息能发给好几个接收方;发送方和接收方在逻辑上解耦。
用一句话说这一章(写给一个没读过的朋友):
精彩回顾
一句话:新旧代码和新旧数据总会并存,编码格式的价值,在于让读的一方无论比写的一方新还是旧,都读得通、不写丢。
向后兼容是新代码读旧数据,向前兼容是旧代码读新数据。判断要哪一种,只看读的一方比写的一方新还是旧。
§0旧代码读、改、写回一条新记录时,要把看不懂的字段原样带上。解码成只认识旧字段的对象,新数据就悄悄没了。
§0Protocol Buffers 的数据里只有标签号。字段名随便改,标签号不能改;删掉的号永远不再用。
§1.3Avro 的数据里只有值。解码要拿着写者模式,再按字段名对到读者模式上,缺了的填默认值。所以只能加、删有默认值的字段。
§1.4代码几分钟换完,五年前的数据还按当年的格式躺着。数据库靠读时填默认值,免得一改模式就重写全部数据。
§2.1本地调用要么返回要么抛异常;网络请求还可能超时,你不知道对方做没做,重试可能做两遍。
§2.2持久执行靠按原顺序重放来跳过做过的步骤。改工作流代码要另起版本,已经在跑的实例继续走旧代码。
§2.3