模式总会变 — 让哪一边先改都不会坏
一句话总结
schema 变更真正的问题不是“改什么”,而是“写入方和读取方无论谁先改,都不会坏吗”,答案由默认值、别名、类型提升规则这三样决定。
为什么需要它
要给管道输出的事件多加一个字段。修改 schema 文件,部署生产方。当天夜里,三个下游停摆。其中一个,是我们团队甚至不知道它存在的团队。
下一次小心一点,先改读取方,后部署写入方。这回是读取方因为找不到新字段而停摆。因为还没有任何人在写这个字段。
两次事故的原因相同。部署不是在一瞬间完成的。写入方和读取方之间,必然有一段只有一方是新代码的时期,而在这段时期里数据仍在持续流动。所以设计变更时该问的,不是“这个变更对不对”,而是“无论按什么顺序部署这个变更,它都能存活吗”。
这里正是要和本课程前面的模块区分开的地方。检测别人提供的文件在没有通知的情况下发生了变化,是接收方的防御。这里改动的一方是我们,是我们给版本编号并做出改动,同时让旧数据和旧读取代码都继续存活。
工作原理
两个方向用名字来区分。直接使用 Confluent 兼容性文档所用的名字。
- 向后兼容(backward):使用新 schema 的读取代码读取按旧 schema 写出的数据。能做到这一点,就可以先升级读取方。
- 向前兼容(forward):使用旧 schema 的读取代码读取按新 schema 写出的数据。能做到这一点,就可以先升级写入方。
- 两者都能做到就是完全兼容,只有这时才能让两边独立部署。
判定规则在 Avro 规范中的 schema 解析里已经写明,一共三条。
- 只存在于写入方的字段,读取方直接丢弃。所以增加字段不会让旧的读取代码出错。
- 只存在于读取方的字段,必须有默认值。没有就是错误。所以一旦增加没有默认值的必填字段,新的读取代码就读不了旧数据。
- 同一个字段的类型,必须能从写入方提升到读取方。规范允许的提升是:int 到 long、float、double,long 到 float、double,float 到 double。反方向不是提升。
从这三条可以直接得出实务规则。增加带默认值的字段,在两个方向上都是安全的。读取方遇到旧数据时用默认值补上,而旧的读取代码只需要把新字段丢掉。反过来,增加没有默认值的必填字段会破坏向后兼容。因为旧数据里根本没有这个值,而读取方又没有办法补上。
改名更微妙。单看 schema,改名就是一次新增和一次删除。能把它们重新还原成一个事件的唯一机制就是别名(alias)。但是 Avro 只使用读取方 schema 的别名——因为它的做法是,把写入方 schema 按读取方的名字改写后再读取。所以改名只在一个方向上存活。新的读取代码带着别名,所以能读旧数据;但旧的读取代码里没有指向新名字的别名。Protocol Buffers 之所以用字段编号而不是名字,并且明确规定编号一旦使用就不能更改,道理也是一样的。
类型变更的方向也不同。把 int 加宽成 long,向后兼容,向前兼容被破坏。把 long 收窄成 int,则恰好相反。所以光说“改了类型”什么也定不了,要看是朝哪个方向改的。
在现场相遇的样子
第一,只写得出读一个版本的代码。过渡期内会有多个版本混在一起流动。如果读取方只认识最新版本,那整个过渡期里就会把旧数据整批丢掉,而丢掉这件事只会表现为记录数减少。度过过渡期的办法只有一个——给读取 schema 充分加上默认值,让旧版本也能被读出来。
第二,不把版本号写进数据。如果每一行都没有标明它是按哪个版本写的,读取方就只能猜,而猜是会猜错的。版本号必须跟着数据走。
第三,删除字段时不看默认值。删除字段后,旧的读取代码找不到该字段。如果这个字段在旧 schema 里有默认值,就会被补上,否则就会出错。所以能删除的字段只有一开始就带默认值的字段,这个事实在增加字段的时候就已经决定了。
第四,只用文档来管理兼容性。文档不会拦住部署。要把判定做成代码,让它在发布新版本时先运行。如果原因不是写给人看的句子,而是用固定的代码给出,自动化就容易了。
实际工作中真正重要的事
- 优先选择增加带默认值的字段。在两个方向上都安全的变更只有这一种。
- 不要改名;一定要改的话,把别名和部署顺序一起定下来。读取方先。
- 类型只朝加宽的方向改。收窄只对旧读取代码成立,而往往需要的正是反方向。
- 在每一行里写版本号,并用一个读取 schema 覆盖多个版本。过渡期不是例外,而是正常状态。
下一项实验要做什么
生成订单事件的五个版本并输出,然后一步步扩充契约工具 contract.py。按默认值区分必填和可选,把版本之间的变化分类为新增、删除、改名、类型变更,按 Avro 规则判定向后和向前兼容,并用固定的代码给出原因。最后把五个版本混在一起的行,分别用严格读取 schema 和宽松读取 schema 来读取,用数字看看一个默认值能救回多少条。评分器每次都会用不同的字段名和类型生成自己的 schema,真正运行你的工具并核对答案。