语义不可逆 Schema Downgrade 的 Fail-Closed 策略
当新 schema 的业务语义无法被旧 schema 无损表示时,down migration 应拒绝降级而不是用伪默认值回填;区分“SQL 可逆”与“业务语义可逆”。
[!info] related notes
- 所属 MOC:
- 前置概念: database-migration-golang-migrate、schema-versioning
- 并列概念:
- 易混淆概念:
- 关系笔记:
语义不可逆 Schema Downgrade 的 Fail-Closed 策略
一句话定义
当新版本数据已经表达了旧 Schema 无法表示的业务语义时,Down Migration 不应该通过伪造默认值来“强行成功”,而应该 fail closed:拒绝降级并要求先处理不兼容数据或采用 forward-fix。
核心判断:
SQL can run backward
≠ business meaning can be restored backward
为什么普通的 up/down 心智不够
数据库迁移常被画成:
v59
↓ up
v60
↓ down
v59
这很容易让人误以为:
只要 down.sql 能执行
→ 迁移就是可逆的
但真正需要问的是:
v60 已经写入的数据,在 v59 的字段和约束里还有没有忠实表示?
如果没有,就不是单纯的 DDL 问题,而是 semantic downgrade。
BodySense 的典型例子
旧 Assessment v1 要求:
health_grade NOT NULL
value ∈ {A,B,C,D}
新 Assessment v2 明确表达:
没有临床评分 rubric
→ 不生成 health_grade
→ health_grade = NULL
这时如果降级到旧 schema,最简单的技术做法可能是:
UPDATE assessment_reports
SET health_grade = 'D'
WHERE health_grade IS NULL;
DDL 随后确实可以恢复:
ALTER COLUMN health_grade SET NOT NULL;
但业务语义被破坏了:
NULL = 系统明确没有做健康等级判断
D = 系统做出了 D 级健康判断
它们不是同一件事。
因此“让 migration 成功”反而制造了新的伪事实。
Fail-Closed Downgrade
正确做法可以是:
IF incompatible-v2-data exists:
refuse downgrade
ELSE:
allow schema downgrade
BodySense 中最终采用的语义就是:
没有 v2 report
→ 60 → 59 可以回滚
已经存在 v2 report
→ down migration 明确失败
→ 不伪造 D / fake score
这同时保留:
- 空库 migration history 的可验证性;
- 真实数据的语义完整性。
“不可逆”到底不可逆在哪里
常见有四类。
1. 新状态比旧状态更丰富
例如:
old: status = active | inactive
new: active | inactive | rejected | superseded
旧 schema 无法忠实容纳新增状态。
2. 新版本区分了旧版本混在一起的概念
例如:
old: injury_safety
new: health_report + injury_symptoms
回滚时若重新合并,信息会丢失。
3. 新版本允许“未知/未判断”,旧版本强制一个值
例如:
new: grade = NULL # not assessed
old: grade NOT NULL
此时默认值很容易伪造业务事实。
4. 数据已经经过不可逆转换
例如:
raw plaintext deleted after irreversible hash/tokenization
旧结构即使能建回来,也恢复不了原数据。
Down Migration 的三个选择
A. 真正无损 Down
如果新旧语义可以一一映射:
safe down
正常提供 down.sql。
B. 条件式 Fail-Closed Down
Schema 本身可以回滚,但只有在不存在新语义数据时才安全:
if incompatible rows == 0:
down
else:
abort
这是很多真实系统更合适的方案。
C. 明确不可逆 / Forward Fix Only
如果业务上根本不应该后退:
no down migration
or
explicit irreversible guard
通过新 migration 修复,而不是假装能恢复旧世界。
为什么“备份了就可以强行 down”仍不成立
备份可以降低数据丢失风险,但它不能让两个语义自动等价。
backup exists
≠ downgrade meaning is valid
备份解决的是:
能否恢复数据字节
Fail-closed 解决的是:
旧版本是否能正确解释这些数据
这是不同层的问题。
Migration Test 应该测什么
只测:
empty DB: up → down → up
不够。
对于 semantic downgrade,至少要再测:
A. empty/no-new-data
60 → 59 → 60
PASS
B. insert new-semantic row
60 → 59
MUST FAIL CLOSED
C. failed down
schema/data remains valid
D. no fabricated legacy values
这类测试证明的不是 SQL 语法,而是回滚语义。
与 Expand/Contract 的关系
Expand/Contract 主要解决多版本应用在发布过程中的 schema 兼容。
Fail-Closed Downgrade 解决另一件事:
当新业务语义已经真实产生后,还能不能把整个系统退回旧世界?
两者可以同时需要:
expand/contract
→ rollout compatibility
fail-closed downgrade
→ rollback semantic safety
一个设计检查表
写 down.sql 前问:
1. 新版本有没有新状态/新含义?
2. 旧 schema 能无损表示吗?
3. 需要默认值才能通过 NOT NULL 吗?
4. 这个默认值是真的业务事实,还是只是让 SQL 过?
5. 已存在新数据时回滚会不会制造错误含义?
6. 是否应该在检测到不兼容数据时直接 abort?
尤其第 4 个问题非常重要:
不要把 schema compatibility default 当成 domain truth。
常见误区
- down.sql 能跑 = 可逆:只能证明语法/结构可执行。
- 填一个默认值就行:默认值可能制造伪业务事实。
- NULL 改成 D/0/false 没关系:unknown/not-assessed 与真实 D/0/false 不等价。
- 生产必须支持任意 down:很多业务变更更安全的是 forward-fix 或条件式拒绝。
- 备份解决所有 rollback 问题:备份不能解决语义解释不兼容。
最短记忆
Schema 回得去
≠ 业务世界回得去
如果旧版本无法忠实表达已经产生的新事实,宁可拒绝降级,也不要为了让 migration 变绿而伪造历史。