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

不可撤销的变更

新旧模式共存与安全收缩

在 TT Lab 中继续学习

一句话总结

修改 schema 时,不要只看新代码是否成功,还要把仍然存活的旧代码读写哪些列,也放进兼容契约里。

为什么需要它

要修改外星庆典的名牌系统。客户说 name 这个名字含糊,要求改成 display_name。一次性改列名好像就完事了,但活动现场的平板上还留着旧程序。在新程序部署期间,批量打印程序和正在重试的作业也会读取旧列。即使一条数据都没丢,也可能因找不到列名的错误而使名牌打印停止。

这个问题在 Kubernetes 滚动更新(rolling update)中也会出现。新 Pod 变成 Ready,并不意味着所有旧的消费方都已经终止。即使界面能连上,等待中的作业、其他服务、分析查询、运维人员的工具也是另外的东西。仅凭一个新 Pod 的正常响应就判断数据库变更成功,验证范围太窄了。

这节课是把字面上相同的名字的值搬过去的变更。不涉及拆分或翻译名字这种有损的转换。如果两边的值变得不同,也不会自动决定丢弃哪一边。此外只在一次性的实验数据库里执行,不修改 LabHub 生产数据库的 schema。

工作原理

1. 扩展就是保留旧契约

保持原有的 name NOT NULL 不变,再添加可为 null 的 display_name。已有行的新列是 NULL。如果把它填成空字符串或“未知”,就很难区分真实的名字和尚未迁移的状态。PostgreSQL 的 ALTER TABLE 对不同类型的操作需要不同的锁,添加列也可能要等待正在运行的读取事务。请在 ALTER TABLE 官方文档中确认各种形式的说明。

不要无限期地等锁,而是应用较短的锁上限。失败的话,保留原有 schema,并清理打开的事务。“只是简单的 DDL,很快就结束”这种推测,在有长时间 SELECT 打开的连接面前就会落空。实验会让真实的旧读取连接保持存活,来重现这种等待。上限值 500ms 是小型教学数据库的契约,并不是所有生产环境的推荐值。

2. 客户端不是两种,而是三种

客户端 读取的表达式 删除旧列之后
旧版本 name 列不存在错误
过渡用 COALESCE(display_name, name) 同样是列不存在错误
最终版本 display_name 只靠新列就能工作

过渡用代码是为了读取尚未迁移的行才需要的。但是,即使 COALESCE 在运行时没有选择旧值,SQL 也引用了 name 这个列。不能因为新列都填满了,就保留过渡用代码不动而把旧列删掉。在回填和约束验证之后,还需要再有一次发布,迁移到最终版本。

最终版本在删除旧列之前就可以测试。这时旧版本、过渡用、最终版本会暂时同时存在。要用真实的 SQL 确认这个共存区间,才能判断之后的收缩是否同时满足数据保留和客户端兼容。

3. 把双向写入连起来,但不隐藏矛盾

只添加新列之后,如果旧应用修改了 name,display_name 就会落后。本实验把 BEFORE INSERT OR UPDATE 触发器用作兼容桥。旧版本只修改 name,就复制到新列;最终版本只修改 display_name,就复制到旧列。允许把两者都改成同一个新值,但改成不同的值就拒绝。

哪个字段变了,必须把 NEW 和 OLD 连同 NULL 一起比较。只用普通的相等比较,遇到 NULL 判定就可能漏掉。请阅读 PL/pgSQL 触发器官方文档中的 NEW、OLD、TG_OP 以及行返回规则,并区分没有输入的字段被自动填充的情况,和明确要清成 NULL 的情况。

INSERT 时,如果只有一边的字段,就填充另一边。UPDATE 时,值没有改变但新列仍为 NULL 的已有行,可以把旧值填入新列。相反,把已有的名字清成 NULL 的写入要拒绝。这条规则不是通用的数据转换规则,而是为了保持同一个名字的两种表示相等的本次客户契约。

触发器是临时的复杂性。留得太久,就很难知道哪段代码才是真正的来源,只看应用日志,也可能看不到额外的写入。安装的时候,就要同时设计移除的条件和验证。不要以为触发器能代替用户认证或按客户的权限。

4. 回填以当前行为准,而不是以过去读取的值为准

回填对象是明确指定的 ID 中 display_name IS NULL 的行。UPDATE 右边的 name,也是在数据库内部读取当前行。如果应用先用 SELECT 取出旧名字,再之后 UPDATE,那么这期间其他负责人改过的名字,可能被过去的值覆盖。

实验里会先打开旧应用修改名字的事务并持有锁,再在另一个进程里开始回填。观测到回填确实在等待之后,再提交旧应用。正确的回填会在锁释放之后重新评估当前的 NULL 条件,不去碰已经迁移的行。请结合这一行的时间顺序阅读 Read Committed 官方说明。在同样的情形下,也会检查先取出旧值、之后无条件写入的错误答案。

5. 把对新写入的保护和对过去的整体验证分开

CHECK(display_name IS NOT NULL) NOT VALID 不会立刻检查已有的未迁移行,却会对新写入应用这个条件。必须先准备好兼容触发器,旧应用的新插入才能满足这条规则。回填结束后,用 VALIDATE CONSTRAINT 确认到已有行,最后把列本身强化为 NOT NULL。

不能把 NOT VALID 读成“还什么都没检查”。反过来,也不能仅因为有约束的名字,就相信已有数据的验证已经完成。实验中会确认回填之前整体验证会失败,回填之后 convalidated 和 attnotnull 是否真的变了。不能只看语句里有没有 VALIDATE 这个词。

在现场相遇的样子

如果先删掉了兼容功能,后面的 DDL 却失败了

如果删除触发器、删除函数、删除旧列是不同的提交,中途失败之后,可能出现两个名字都还在、而连接两边写入的功能却已消失的情况。这次的收缩是一个事务。如果中途异常或客户端终止发生在提交之前,连兼容桥也必须恢复。如果只是提交之后丢失了响应,就要用新连接观测真实的 schema。

请确认 psycopg 的 transaction 上下文提交了哪一段。如果已经有外层事务,内部上下文的结束可能并不是真正的提交。本课约定每个迁移函数都从 autocommit=True 的 IDLE 连接开始。请把事务管理文档和钩子的位置一起看。

“退役批准 True”并不能证明消费方真的不存在

实验中的两个批准值,是在外部确认了旧版本和过渡用消费方已经退役的输入。在生产中要证明这一点,必须调查运行中的版本、计划作业、重试队列,甚至直接读取数据库的工具。仅凭一个简单的标志,或者几天没有错误的观测,是无法证明所有消费方都不存在的。

FDE 需要做的,是和客户一起确认这张消费方地图,并就什么时候停下或回退达成一致。这是与所查看的 Palantir FDE 招聘启事中理解客户问题、实现解决方案的能力相关联的作者设计案例,并不意味着那家公司要求这种触发器模式或保证录用。

下一项实验要做什么

保留名牌的名字和 ID,依次经过添加可为 null 的列、过渡读取、双向写入、限定 ID 的回填、约束验证、获批的收缩,最后迁移到最终客户端。如果错误迁移的两列值不同,即使条数相同,也拒绝收缩。要确认真实的 DDL 锁等待、并发回填、三个点的进程终止,并亲眼看收缩之后旧 SQL 和过渡 SQL 为什么会失败。

本实验不证明服务器断电的耐久性或大规模在线迁移的性能。目标是在准备好的小型数据库中,验证客户端、schema、事务的兼容关系。