上一篇留了话头:contracts 里除了 proto,还有一份 7000 多行的单文件数据定义语言(DDL)。上线之前它怎么改都随意,重建一遍数据库就回到最新;上线之后,prod 库里躺着真实用户的报名数据,这条路断了。这篇讲我现在的做法:单文件 DDL 依旧是唯一的结构真相,迁移链只在上线时点动笔,中间靠一个台账文件衔接。开发期的快迭代和 prod 的可追溯,两头都要。
全量重放会静默失效
01-schema.sql 全文是 CREATE ... IF NOT EXISTS 形态,几十张表连同约束、索引、行级安全(RLS)策略、函数、触发器都在一个文件里。最初的想法很自然:schema 改了,把这份文件对存量库整个再跑一遍不就行了。
实测打脸,而且是最阴的一种失败:不报错。IF NOT EXISTS 对已存在的表整段跳过,表都还在,但这几批新加的列、新收紧的约束、新挂的策略一概没有。后端一查新列就是 column does not exist,顺着报错往回翻,才发现 prod 已经落后两批结构。这次实测发生在对外上线之前,prod 里已经攒了真实数据,算是花小钱买了个大教训。
结论一句话:全量重放只适合新库,存量库的结构演进必须走增量。
新库直装,存量库走链
于是库的装载分成两条路径:新库(任何环境)拿 00-roles(集群级角色定义,不进链,角色是集群对象不是库对象)加 01-schema 直装;存量库(prod)走 migrations/ 迁移链,只追加节点。
迁移机制用的是 sqlx(Rust 的 PostgreSQL 工具链,下文简称 PG)自带的 migrate,朴素够用:migrations/ 下文件名带数字前缀按序执行,跑过的记进库里的 _sqlx_migrations 表,一行一个版本号加文件字节的 checksum。再跑只追新节点;已入账的节点字节变了,checksum 对不上直接拒跑。这里区分两个词:库里的 _sqlx_migrations 我叫账本(已入账),后面那个 markdown 文件叫台账(待入账),一字之差,两个东西。
这条链的历史折腾过一轮。上线之前建过一版,二十几个节点;后来一次数据库全量重置,没有任何环境再持有数据,我把链整个清零,基线回到「直装即全部」,机制全套保留待命;上线当天第一笔不可丢弃的用户数据出现,链从 0001 重新起算。重启条件就这一条:有没有不可弃数据。
两条路径的关系要想清楚:01-schema.sql 仍是新库唯一的全量真相,链不是它的生成物,是它在每个上线时点的增量影子。后来整套制度都长在这句话上。
开发期只做两件事
上线之后的开发期,改 schema 的动作清单就两条:
- 改
01-schema.sql,HEAD 即开发态; - 在
PENDING.md台账追加一行。
然后 make -C contracts check 过门,reset-db 重建本地库,完事。同批写链节点是明令禁止的。
reset 的时序有讲究:直装、零数据链重放入账、灌种子,三步顺序不能乱。种子先灌进去,链里那些收紧约束、清洗数据的节点重放时撞上真实数据,结构性跑不动,这个坑踩过一次就写成了纪律。账本入账必须走真实 Migrator,手工 INSERT 假 checksum 也是禁手,假行往后必 mismatch。
配套还有个漂移探测:initdb 时把 00 加 01 的原始字节 md5 写进库级 COMMENT,改了 DDL 忘了 reset,dev.sh status 一比指纹就报 DRIFT。
台账三列:
| 列 | 内容 |
|---|---|
| 批 / commit | 变更随哪个批次走 |
| 结构变更 | 一句话,表 / 列 / 约束 / 策略层面的语义 |
| 数据回填 / 清洗意图 | 必填,没有就写「无」 |
台账活着的时候长这样,摘三条已销账的真实登记(批号略):
| 结构变更 | 数据回填 / 清洗意图 |
|---|---|
| tenants 加 short_name 列并挂 UNIQUE | 无 |
| banners 全局表改租户维度 | 按 prod 行数为零的假设回填,入账时点复核假设仍成立 |
| 五张会议族表补 scope_delete 策略 | 无 |
第三列是台账的灵魂。结构变更可以由 diff 复原,终态写错,拿 HEAD 直装对一遍就知道;存量数据怎么处理的口径,事后从任何 diff 里都看不出来。有条登记的回填带着前置假设:按某表在 prod 行数为零的假设写回填,入账时点还得复核假设仍然成立。这种信息不落在纸上,发布窗动笔的人(两周后的我)无从知晓。台账是写给未来的自己看的。
逐批写节点是怎么死掉的
「上线时点节点制」不是一开始就有的,是被撞出来的。最初按教科书做法,每个开发批次落地时同步写一个链节点,双写。上线后头十天,这个做法死了两次。
第一次,节点被反复整编回收。攒了八个没上线的节点,压成一个终态快照;过几天又攒出六个,再次收编成单节点。按批写的节点一个都没上过 prod,全部成了被废弃的中间产物,写它们的工夫白花。
第二次更疼,staging 预演当场炸:一个节点里内联外键引用了同节点稍后才建的表;同轮还暴露 23 条 RLS 策略没镜像进节点。开了 FORCE RLS 的表一条策略都不挂,fail-closed 变全盲表,查询静默零行。这类错误的阴险在掩蔽性,下面单独说。
复盘出两条认知。其一,逐批节点是被反复废弃的中间产物;其二,真正的安全网从来不是写得勤,是「演进重放终态 ≡ HEAD 直装」的对拍验证,跟撰写频次无关。我把两条路径各自装出的库做结构归一后逐字比对,管这叫对拍。既然安全网在验证侧,节点这个高维护成本的东西就该降到最低频。拍板:开发期不写节点,只改 DDL 记台账;节点按上线时点统一撰写。唯一的例外是 hotfix,两次上线之间 prod 的紧急修复允许即时写节点,纪律和验证门不减,只豁免时点。
发布窗动笔:照单收编
发布窗里写节点的流程固定五步:
- 照台账逐行核对全部待入账变更,diff 的目标态 = HEAD 直装,起点 = prod 实际库。对拍顺带暴露 prod 的链外手工漂移,新节点把漂移一并收编,文件头注记;
- 默认单节点收编全部 diff。
CREATE INDEX CONCURRENTLY不能进事务,拆独立节点是唯一例外; - 验证门不减配:SQL lint、迁移矩阵、演进重放物证留档、staging 全流程预演;
- build 前强制 touch 后端入口文件(原因见下节的宏快照坑);
- prod migrate 入账,回台账销账删行。
节点的写法有一套幂等守卫标配:加列 ADD COLUMN IF NOT EXISTS,约束和 policy 先 DROP IF EXISTS 再建,函数视图走 OR REPLACE。目标是三种起点重放都收敛:存量库应用增量、HEAD 态库守卫放行变 no-op、空库全链自举。
现链 0002 是最小的样本,34 行,节选看形态:
1 | SET search_path = public; |
开头那行 SET 也是判例:sqlx 跑链时连接串把 search_path 钉在 public,手工 psql 重放没有这层兜底,裸的 "$user" 前缀会把表建进同名的影子 schema,一行 SET 把两类路径都兜住。
未上 prod 的节点可以改写合并
这是整套判例里最值钱的一条:只要还没上 prod,链节点就没有历史包袱,可以任意改写、合并、删号。机理前面埋过:账本锁的是已入账节点的字节,而入过账的环境都能无痕重建,dev 走 reset-db,staging 走 restore 重演。prod 账本停在 0003,0004 改一百次也无妨。
现链 0001 到 0006,正好是这套判例的展品。0001、0002 各是一个小批次的增量,先后上了 prod,从此字节锁死。0003 是「上线前统一收编节点」,前后四轮改写:八节点压一、六节点收编、再把台账八行和五枚 policy 分两次增补进本体,3000 多行,吞了十几个节点的量。0004 更夸张,五轮:起手两个节点合并成一个,随后三批不同面孔的变更先后折进来,最近一轮是微信绑定/解绑语境的四个策略与函数,814 行,§A 排到 §L。0005、0006 是最近一个开发窗收编的,占着临时号,发布窗定稿前还可能再并再改号。
激活链下线那一单最戏剧。早期账号激活链路整体下线,涉及这条链上第一个 DROP TABLE:一个深夜窗把它独立写成 0005,全套验证跑绿,五形态矩阵、prod 克隆演进重放、staging 实跑一样不缺,连「旧二进制回退必被拒」的红方验证都做了。同夜另一条并行线把同一行台账折进了 0004 本体。天亮后两个载体打架,裁决:采 0004 载体,0005 删号回池。零历史包袱的改写合并,这次是两个载体撞在同一个台账行上。
合流不是零成本。staging 账本还留着旧 0005 形态的五行,仓内链回到四节点后,新二进制一跑 migrate 就拒:账本里多出链上没有的节点。解法是重演同步,拿 prod dump 重建 staging,install 先行、migrate 入账,账本四行与仓内链 checksum 逐字节一致,这笔债才算清。台账行是收编的协调点,同一行进两个载体就是事故,这条现在写在流程里。
每条纪律背后一次事故
行首不许出现非 ASCII
prod 判例:一个节点某行以全角破折号开头,PG 直接 syntax error。坑在报错里的 at line N 是服务端扫描行号,跟迁移文件行号无关,排查极容易被带偏。现在的 lint 规则一挂在提交门上:任何 .sql 的行在可选空白后以非 ASCII 字节开头即红。中文注释合法,但只能待在 -- 后面。
版本号全局唯一
两个并行批次各自取了 0016。文件名不同,git 合并零冲突;可 sqlx 的版本号就是文件名数字前缀,已应用的库撞号即 checksum mismatch 拒跑,报错文案还说「文件被改过」,纯误导。撞号的代价在库上,git 层面毫无知觉,这种错最阴。lint 规则二拦截,后到批次必须改号。
宏快照:新增节点不触发重编
sqlx::migrate! 在编译期把目录快照进二进制。改既有文件能动 mtime 触发重编,新增文件没有任何 cargo 跟踪点,于是新增节点后二进制没重编,migrate 静默 no-op,账本停在旧版,零告警。解法笨但可靠:build 前强制 touch 入口 main.rs,逼宏重展开,prod 和 dev 脚本里都内建了。
演进库是新增 DDL 唯一真实执行过的路径
这是全篇最重要的一条认知。staging 那单的掩蔽链是这样的:新库直装路径里,01-schema 已含全部表,链重放全程 no-op,新增节点一行都没执行;存量克隆路径里,账本已入,DDL 同样不执行。几种验证形态里,只有「演进库」,也就是停在上一节点的库接着跑新链,真正执行新增节点的每个字节。依赖序错误、策略漏镜像,都只有这条路径暴露得出来。所以迁移矩阵里专门有一格演进重放:基线取新节点入链前时点的 01-schema 直装,跑完新链,终态必须与 HEAD 直装零差异。静态侧再加一道 lint:链自身建的表,对它的引用必须落在链内存活窗内。
节点里不许写部署标识符
staging 档的库名、角色名都经换名部署。01-schema 有替换管道,链是编译期嵌进二进制的,没人给它做替换,节点里写死应用角色名,staging 独立实例上 migrate 当场炸。合规形态是零字面量派生:
1 | DO $$ |
角色名从库名派生,两档都命中;角色真缺失就 RAISE。这个守卫还兼任矩阵的红方锚点:故意在没跑 00-roles 的库上 migrate,它必须炸,证明验证本身有效。
其余几条一笔带过
ALTER COLUMN TYPE ... USING 的表达式里不许子查询,是 PG 的语法域硬限制,需要行级重排的改型走两段式过渡;独立 migrate 必须用与链同版本的已装二进制,旧二进制会把旧链入账,换新二进制后同号撞 checksum、旧节点失踪,双杀死在半装态,现在 migrate 前置了版本检查;破坏性变更必须附回填与回滚段。
五形态对拍
bash scripts/migration-matrix.sh 一条命令拉起隔离容器,跑五种形态:
- V1 initdb 自举,schemas 目录挂 docker entrypoint,真实开发形态;
- V2 HEAD 直装加真二进制 migrate 重放,验证幂等收敛;
- V3 开发存量库克隆,含数据含账本,migrate 应为 no-op;
- V4 prod 的 setup 路径复刻;
- V5 演进重放,唯一真实执行新节点的路径。
最近一轮的真输出,节选(时间戳、库名后缀与基线注记略):
1 | V1 initdb 自举(00+01+02 戳): OK(指纹 baseline_md5…) |
判定就是末行那四条:归一 dump 零差异、基线指纹相等、计数一致、账本对齐。输出物证落 tests/poc/ 入 git。
再往上一档是真 dump 重放:拿 prod 实际 dump 在本机复刻 restore 语义,真二进制 migrate,账本 checksum 逐枚与链文件核对,结构对拍 HEAD 直装,行数逐表核对无变化。这套对拍真抓过东西:有一轮收编漏抄了函数体内两行中文注释,V1 与 V2 的归一 dump 差两行被当场按住。函数体随 pg_dump 持久化,policy 的注释不持久化,对拍面是不对称的,漏的偏偏就是这种暗面。
最后 staging 全流程预演,deploy 的每一步原样走一遍,绿了才轮到 prod。
小结
这套做法的本质,是把「写迁移」从高频低价值挪到低频高价值:开发期一个字节的迁移 SQL 都不写,换来每个发布窗一次、带着全套验证的郑重动笔。台账是中间的传送带,只记 diff 复原不出来的东西。适用条件也交代清楚:单人或小团队、单实例 PostgreSQL、没有 CI 强制、全靠纪律约束。要是有成熟的 migration 工具链加 CI 兜底,按批写节点配自动验证可能更顺,我这套是给「没有兜底、只有自己」的环境设计的。
这套门禁和上一篇按下的「生成物不许手改」是同一族思路:真相源只有一份,其余全部派生,派生物过对拍门。下一篇讲那半边:E-R 文档由 DDL 纯投影生成、小程序生成码禁手改、descriptor 新鲜度校验,再生成加双零 diff 的完整形态。