每个生产迁移现在都执行两次,第一次我们主动回滚
本文译自 2026-09-14 的英文原文。
(原文写于 2026-09-14;之后,干跑已改为由脚本生成的多个部分:从 2026-10-06 的迁移 0059 起,每部分不超过 12,288 字节,各自是一次调用里的一个完整事务,以
raise exception回滚;第一部分应用整个迁移,其余部分各自从迁移前的状态起步、做各自的检查,每部分之后都查一次有无残留。下文「为什么是一次调用」的道理不变,只是现在落在每一部分上,任何部分都不手工拆分。)
MCP Checkup 是一人公司,只有一个生产数据库,每一次表结构改动都直接落在它上面。没有存着生产副本的预发布集群;在一个支撑公开目录的数据库上写错一条 create view,不是能悄悄撤回的事。所以现在每个迁移都执行两次。
干跑长什么样
每个迁移都带着它必须满足的确切断言,写在注释块里,最近则写在一个配套文件里。应用之前,我们先跑一次形如下面这样的 SQL 调用:
begin;
-- the migration's DDL, pasted verbatim
set local role anon; -- become the public, unauthenticated reader
do $$
begin
if current_user <> 'anon' then
raise exception '§0 FAIL: not running as anon';
end if;
-- §1: the view exists and exposes exactly the columns we promised
-- §2: as anon, the view shows exactly the rows the live policies admit
-- §3: the anon query plan and wall time are within budget
-- ... one section per property, each raising '§n FAIL: …' on violation
raise exception 'DRYRUN OK: anon_rows=%, exec_ms=%', ...;
end $$;
最后那个 raise exception 不是 bug。它是让事务回滚的手段:这次调用唯一的成功方式就是失败,而它的错误信息带回了我们想要的测量值。如果哪一节先失败了,我们看到的是 §2 FAIL 而不是 DRYRUN OK,什么都没有改变。之后再用只读查询确认没有留下任何东西,然后才正式应用,把同样的 DDL 真正执行一遍。
为什么是一次调用,而不是一个分成多次调用的脚本?因为我们的数据库工具不会跨调用保持一个事务。把干跑拆成几条语句分别跑,就成了几个小干跑,中间夹着真实的提交,这就失去了意义。
干跑是做什么用的
它不是语法检查,数据库本来就会免费做这件事。它的用处是:在三类说法于生产上成立之前,先把它们变成可以测量的东西。
权限方面的说法。「匿名角色只能看到公开的行」,写在注释里很容易,写进策略里却很容易写错。在事务里切换到 anon,把它看到的和现行策略允许的作比较,再以所有者身份数一数不该出现的行,这句话就变成了两个必须相等的数和一个必须为零的数。
**性能方面的说法。**一个视图可以完全正确,却依然没法用。干跑会记录匿名查询的 explain analyze,也可以设成超出预算就失败。
**形状方面的说法。**列名和列的顺序,是这个视图与读取它的应用代码之间契约的一部分。干跑会把系统目录里的实际情况和迁移承诺的列表逐一比对。
它抓到的两件事
第一件是权限函数的开销。一个目录视图用了 security_invoker,于是每一行都要经过底层表本来就有的行级安全辅助函数。结果是正确的,但以匿名角色读 15 个 target 要 467 ms,而有特权的读者读同样的行只要 17 ms。干跑的计时一节把「本地感觉还行」变成了一个硬数字。这个视图改成了带 security_barrier 的定义者视图,把同样的谓词显式写出来,再以匿名角色测一次:19.3 ms。
第二件小一些,也更难为情。干跑里「看不到任何非公开行」那一节,原本预计只是空转,因为据说生产库里没有非公开的 target。结果它实实在在地碰到了两个。它们是几周前一次本地开发冒烟测试留下的,那次测试指向了生产数据库。视图本身没有错,错的是迁移里一句「这种情况不会发生」的注释,它在文件冻结之前被改写了。
冻结就是冻结
迁移一旦应用,它的文件就永远逐字节不变。我们比较的是已提交 blob 的哈希,而不是工作区里的文件,因为 Windows 检出会把换行符变成虚假的差异。这个月,一个已经干跑、但尚未应用的迁移里有一句注释需要改写,这次改动用两种相互独立的方法核对过:去掉注释后的 SQL 哈希完全相同;两个 blob 的普通 diff 显示,每一处改动的行都以 -- 开头,行数也与 git diff --numstat 一致。之所以要两种核对,是因为任何一种单独拿出来,都可能被一个悄悄吞掉行的过滤器骗过。我们知道这一点,因为第二种核对的第一版恰好就是这样。
这些都谈不上巧妙。这是让一个人改动在线数据库之后还能安心睡觉的最低限度的仪式。