TT Lab
开始
学习 学习路径 课程

数据流水线

模式总会变 — 让哪一边先改都不会坏

在 TT Lab 中继续学习

一句话总结

schema 变更真正的问题不是“改什么”,而是“写入方和读取方无论谁先改,都不会坏吗”,答案由默认值、别名、类型提升规则这三样决定。

为什么需要它

要给管道输出的事件多加一个字段。修改 schema 文件,部署生产方。当天夜里,三个下游停摆。其中一个,是我们团队甚至不知道它存在的团队。

下一次小心一点,先改读取方,后部署写入方。这回是读取方因为找不到新字段而停摆。因为还没有任何人在写这个字段。

两次事故的原因相同。部署不是在一瞬间完成的。写入方和读取方之间,必然有一段只有一方是新代码的时期,而在这段时期里数据仍在持续流动。所以设计变更时该问的,不是“这个变更对不对”,而是“无论按什么顺序部署这个变更,它都能存活吗”。

这里正是要和本课程前面的模块区分开的地方。检测别人提供的文件在没有通知的情况下发生了变化,是接收方的防御。这里改动的一方是我们,是我们给版本编号并做出改动,同时让旧数据和旧读取代码都继续存活。

工作原理

两个方向用名字来区分。直接使用 Confluent 兼容性文档所用的名字。

判定规则在 Avro 规范中的 schema 解析里已经写明,一共三条。

从这三条可以直接得出实务规则。增加带默认值的字段,在两个方向上都是安全的。读取方遇到旧数据时用默认值补上,而旧的读取代码只需要把新字段丢掉。反过来,增加没有默认值的必填字段会破坏向后兼容。因为旧数据里根本没有这个值,而读取方又没有办法补上。

改名更微妙。单看 schema,改名就是一次新增和一次删除。能把它们重新还原成一个事件的唯一机制就是别名(alias)。但是 Avro 只使用读取方 schema 的别名——因为它的做法是,把写入方 schema 按读取方的名字改写后再读取。所以改名只在一个方向上存活。新的读取代码带着别名,所以能读旧数据;但旧的读取代码里没有指向新名字的别名。Protocol Buffers 之所以用字段编号而不是名字,并且明确规定编号一旦使用就不能更改,道理也是一样的。

类型变更的方向也不同。把 int 加宽成 long,向后兼容,向前兼容被破坏。把 long 收窄成 int,则恰好相反。所以光说“改了类型”什么也定不了,要看是朝哪个方向改的。

在现场相遇的样子

第一,只写得出读一个版本的代码。过渡期内会有多个版本混在一起流动。如果读取方只认识最新版本,那整个过渡期里就会把旧数据整批丢掉,而丢掉这件事只会表现为记录数减少。度过过渡期的办法只有一个——给读取 schema 充分加上默认值,让旧版本也能被读出来。

第二,不把版本号写进数据。如果每一行都没有标明它是按哪个版本写的,读取方就只能猜,而猜是会猜错的。版本号必须跟着数据走。

第三,删除字段时不看默认值。删除字段后,旧的读取代码找不到该字段。如果这个字段在旧 schema 里有默认值,就会被补上,否则就会出错。所以能删除的字段只有一开始就带默认值的字段,这个事实在增加字段的时候就已经决定了。

第四,只用文档来管理兼容性。文档不会拦住部署。要把判定做成代码,让它在发布新版本时先运行。如果原因不是写给人看的句子,而是用固定的代码给出,自动化就容易了。

实际工作中真正重要的事

下一项实验要做什么

生成订单事件的五个版本并输出,然后一步步扩充契约工具 contract.py。按默认值区分必填和可选,把版本之间的变化分类为新增、删除、改名、类型变更,按 Avro 规则判定向后和向前兼容,并用固定的代码给出原因。最后把五个版本混在一起的行,分别用严格读取 schema 和宽松读取 schema 来读取,用数字看看一个默认值能救回多少条。评分器每次都会用不同的字段名和类型生成自己的 schema,真正运行你的工具并核对答案。