BodySense 数据库第 5 课:Schema Migration 与 Expand-and-Contract
从 BodySense golang-migrate 与 PostgreSQL baseline 出发学习版本化 schema、data backfill、锁风险、应用兼容窗口与 expand-and-contract。
[!info] related notes
- 课程 MOC:BodySense 数据库工程深化课程 MOC
- 前置:第 4 课
- 知识:数据库 Schema Migration、Migration 与发布兼容性
BodySense 数据库第 5 课:Schema Migration 与 Expand-and-Contract
[!abstract] 本课目标 Migration 不是“把当前 Model 同步到 DB”,而是从旧持久状态到新持久状态的可审计转换历史。
从零建立概念:为什么数据库 Schema 一定会变化
真实产品不会永远使用第一版表结构。
需求变化会带来:
新增列
删除旧列
修改类型
新增约束
新增索引
拆表 / 合表
数据 backfill
状态模型变化
这叫 Schema Evolution。
难点是:
数据库里已经有真实数据,而且旧版本应用可能还在运行。
所以不能把 migration 理解成“开发环境删库重建”。
Migration 是什么
Database Migration 是:
把数据库从一个已知 schema/data state,按照版本化步骤转换到另一个 state。
典型:
v41
↓ 000042_add_source_version.up.sql
v42
Migration 文件本身是历史。
它回答:
以前长什么样?
怎么变成现在?
部署顺序是什么?
出错停在哪个版本?
而 ORM Model 只回答:
当前 Go 代码希望怎样映射
DDL 与 DML
DDL
Data Definition Language:改变结构。
CREATE TABLE
ALTER TABLE
CREATE INDEX
DROP COLUMN
DML
改变数据。
INSERT
UPDATE
DELETE
生产 migration 经常同时包含:
DDL
+
Data backfill
这也是风险所在:结构变化和大量数据写入可能有不同的锁/时间特征。
Up / Down Migration
常见工具允许:
up
→ 向前升级
down
→ 尝试回退结构
但一定记住:
Down SQL 存在,不等于数据层面一定可逆。
例如:
v2 DROP old_column
↓
old data gone
后来再:
ADD old_column
只能恢复列,不会自动恢复原来的值。
所以 release rollback 与 database rollback 是不同问题。
为什么 AutoMigrate 在生产里风险更高
ORM AutoMigrate 的诱惑:
改 struct
→ 启动应用
→ schema 自动变
问题是它弱化了:
- 版本化历史;
- 人工审查 SQL;
- rollout 顺序;
- lock 风险;
- backfill 设计;
- 新旧版本兼容窗口。
BodySense 当前用 golang-migrate + SQL migration,更适合显式治理生产 schema。
Expand-and-Contract 为什么是线上演进主线
假设旧字段:
name
新设计改成:
display_name
危险做法:
一个 migration 直接 DROP name
ADD display_name NOT NULL
同时部署新代码
因为 deployment 不是原子事件。
真实过程中可能同时存在:
old API pods
new API pods
migration running
background jobs
Expand-and-Contract 把变化拆开:
Expand
先新增 display_name
保持 old code 还能工作
Backfill
历史 rows
name → display_name
大表要考虑:
batch size
lock
WAL volume
replica lag
query load
Switch
新代码开始读/写新字段,必要时双写/兼容读。
Contract
确认旧版本完全退出、数据迁移验证后再移除旧字段。
DDL 也有 Lock 语义
例如:
ALTER TABLE
CREATE INDEX
VALIDATE CONSTRAINT
不同操作会拿不同 lock。
所以:
Migration SQL 能在测试库 100ms 跑完,不等于在线大表上就安全。
PostgreSQL 提供 CREATE INDEX CONCURRENTLY 等在线化能力,但它本身有额外限制和失败状态,需要理解后使用。
Constraint 收紧为什么常要分阶段
想把 nullable 列改成 NOT NULL:
如果历史已经有 NULL:
ALTER ... SET NOT NULL
→ validation fails
更可控:
1. add column / compatibility
2. new writes start populating
3. backfill old rows
4. verify no NULL
5. add/validate constraint
6. contract old compatibility
这是“数据事实先满足规则,再让数据库强制规则”。
BodySense Baseline 与未来 Migration 的区别
当前 vNext baseline 注释明确说明:
pre-user phase
→ allowed reset legacy chain
→ capture canonical PG18 baseline
这是项目早期的特殊自由度。
一旦已有真实用户数据:
reset database
就不再是普通可选方案。
因此课程后续按“已有生产数据”的标准训练 migration 思维。
1. Model 与 Migration 是不同事实
Go model
= 当前应用希望看到的 shape
Migration
= 数据库从 version N → N+1 的历史动作
这就是为什么 production 不应仅依赖 ORM AutoMigrate。
2. Schema change 的三类风险
Data risk → 旧数据不满足新约束
Lock risk → DDL 获得强锁阻塞线上流量
Compat risk → 新旧应用版本同时运行时 schema 不兼容
3. Expand-and-Contract
一个典型安全流程:
Expand: 先添加兼容的新列/表/索引
Backfill: 填历史数据
Dual/Read: 新代码逐步使用新结构
Contract: 确认旧版本退出后再删旧结构
例如直接给大表新增最终 NOT NULL 且没有 default/backfill 可能失败或造成风险,应根据 PostgreSQL 实际版本与 DDL 行为设计。
4. BodySense 当前锚点
apps/api/internal/database/migrate.go
apps/api/migrations/000001_vnext_baseline.up.sql
apps/api/migrations/checksums.sha256
当前 vNext baseline 是一次 pre-user reset 后的 canonical PostgreSQL 18 schema;未来真实用户数据存在后,migration 的兼容性要求会明显提升。
Prediction
[!question]
- 只修改 Go struct,不写 migration,会发生什么?
- 应用二进制回滚是否自动等于数据库 schema 回滚?
- 为什么“新增 nullable 列 → backfill → 收紧 NOT NULL”通常比一步到位更容易控制?
- 新索引一定可以随便在线 CREATE 吗?
- migration 成功执行是否足以证明生产发布安全?
[!success]- Answer / 解析 1. 数据库真实 schema 不会自动拥有同样变化,可能在运行期出现列缺失、类型/约束不匹配。
2. 不等于。 Schema/data migration 可能不可逆,新旧应用也可能要求不同 shape;回滚要单独设计兼容窗口。
3. 它把结构扩展、历史数据修复和约束收紧拆开,降低一次 migration 同时承担多种风险。
4. 不一定。 普通
CREATE INDEX会涉及锁和大量 I/O;生产常需要考虑CREATE INDEX CONCURRENTLY及其限制。5. 不足。 还需验证新旧版本兼容、数据 backfill、锁/延迟、回滚边界和真实读写。
Lab
设计一个 BodySense 迁移:给一个已有大表增加最终必填的 source_version。写出 Expand → Backfill → Contract 三阶段,以及每阶段可观察验证。
Failure Injection
假设 deploy 新 API 后 migration 尚未完成,或 migration 完成但旧 API 仍有实例存活。分别推导失败模式。
L4 Gate
能审查一条真实 migration,并回答:它会拿什么锁、已有数据如何变化、失败能否重试、新旧代码是否兼容。