数据库的 schema 不是一次性的设计;它随使用它的软件一起演进。与其临时编辑 schema,不如用迁移应用一组命名、有序的变更,让任何环境都能重放。
把 schema 当代码
把 schema 当源代码一样对待:每一条迁移都是一个带顺序、带编号、被命名的小步骤,把库从一个状态推进到下一个状态,整个序列都收进仓库。全新的库从空库开始重放这些步骤直到当前版本,这正是测试建库、临时环境克隆、安装新机器的统一口径;没有人再对着线上实例随手敲一条 ALTER TABLE。schema 也因此从「某个实例才知道的事实」变成有历史、可 review、有 diff 的正式产物。
顺序就是契约
迁移按既定顺序执行,于是任何从零开始的环境都会落在同一个已知状态。这种可预测性就是契约:它让测试能创建全新数据库、让暂存能克隆,让运维升级时不用靠猜。随之而来的铁律是——凡是已经被任何环境执行过的迁移,都不得重写或重排,因为它的校验和或文件名本身就是「已经执行过」的证据。篡改历史,环境就开始分叉,再也无法证明一致性。
危险在不可逆
删除列或表的迁移不可逆,必须谨慎对待。给它们配一张清单:先备份,把 drop 拆成单独的后续迁移,这样能先回滚前面的 schema 改动,最后再面对不可逆的破坏。真正的纪律是「只能向前、但留有退路」——绝不要写一条把维护者锁在门外的迁移。上线前一定要在 staging 上跑一遍这类破坏性迁移,让失败模式在真正投入生产前就变成一件平淡无奇的事。
坏掉的样子
最贵的一种坏法,是把不可逆迁移直接送上生产。在 PostgreSQL 里,删除列在新版本中只是廉价的元数据操作,许多类型转换或新增带易变默认值的列会在 ACCESS EXCLUSIVE 锁下重写,但二进制兼容的类型转换可能避免重写,读写请求全部排在部署后面。大型重写可能长期等待或运行。PostgreSQL 的事务性 DDL 失败后可以回滚;是否留下部分结果,取决于先前提交、非事务步骤与执行器事务边界,不能把超时一概理解为半张表已经改好。而 down 迁移并不能撤销数据丢失:删掉邮箱列再加回来,得到的是空列,而不是被删掉的地址。破坏性步骤应当拆成独立的后续迁移,上线前先备份,优先使用扩张再收缩的顺序,先加新结构,再回填数据,最后删除旧结构,让每一次发布都平淡无奇。换句话说,只要迁移涉及删数据,就默认它回不来。
另外两类经典故障是并发双跑和篡改历史。如果两个发布副本在没有任何锁的情况下同时执行迁移,它们可能重复应用同一版本,也可能把版本顺序搅乱。生产级的 runner 因此只允许单个执行者,或者借助 PostgreSQL 咨询锁之类的机制加锁,而 golang-migrate 在失败后会把库标记为 dirty,而不是假装一切正常。重写已经被应用过的迁移,或者合并了两个各自新增版本 7 的分支,都会让历史分叉:环境可能分叉。还要检查执行器到底保存什么:golang-migrate 的 PostgreSQL 账本保存版本与 dirty 状态,并不为每份文件保存内容校验和。先核对实际状态,再设计前向修复,不能假定工具自动发现所有历史编辑。strong_migrations 这类检查工具能在进入评审之前拦下危险操作。
快速验证
把下面代码保存为 migration_chain_check.py,运行 python3 migration_chain_check.py(需要 Python 3.10 或更新版本)。它只用标准库,以内存中的字典模拟 schema,用 applied 账本模拟 schema_migrations 表,按顺序执行三个迁移的 up 与 down,不需要真实数据库。身份只由版本与名称组成,不是代码内容摘要;它能发现重命名,不能发现任意函数体修改。账本为空时高版本无法先行,也不代表完整生产迁移器的全部行为。
"""Simulate a versioned migration chain with an applied ledger (stdlib only)."""
# Each migration is (version, name, up, down). down=None means irreversible.
# The `applied` dict emulates a schema_migrations table: version -> a name-based identity (not a content digest).
def identity(version, name):
return "%d:%s" % (version, name)
def make_migrations():
db = {"users": []}
def up1():
db["users"] = []
def down1():
del db["users"]
def up2():
for user in db["users"]:
user["email"] = None
db["has_email_column"] = True
def down2():
# Irreversible in effect: dropping the column destroys its data.
for user in db["users"]:
del user["email"]
del db["has_email_column"]
def up3():
db["index_on_email"] = True
def down3():
del db["index_on_email"]
migrations = [
(1, "create_users", up1, down1),
(2, "add_email_column", up2, down2),
(3, "add_email_index", up3, down3),
]
return db, migrations
def apply_single(db, applied, migrations, version):
by_version = {m[0]: m for m in migrations}
assert version in by_version, "unknown migration version: %r" % (version,)
wanted = by_version[version]
if version in applied:
if applied[version] != identity(version, wanted[1]):
raise RuntimeError("version drift: v%d was edited after apply" % version)
return "skipped" # idempotent re-run
missing = [m[0] for m in migrations if m[0] < version and m[0] not in applied]
if missing:
raise RuntimeError("out of order: v%d needs missing %s first" % (version, missing))
wanted[2]()
applied[version] = identity(version, wanted[1])
return "applied"
def migrate_up(db, applied, migrations):
results = []
for m in migrations:
results.append((m[0], apply_single(db, applied, migrations, m[0])))
return results
def migrate_down(db, applied, migrations, target):
by_version = {m[0]: m for m in migrations}
while applied and max(applied) > target:
version = max(applied)
migration = by_version[version]
assert migration[3] is not None, "v%d is irreversible" % version
migration[3]()
del applied[version]
return sorted(applied)
# 1. Fresh database replays the whole chain in order.
db, migrations = make_migrations()
applied = {}
assert migrate_up(db, applied, migrations) == [(1, "applied"), (2, "applied"), (3, "applied")]
assert sorted(applied) == [1, 2, 3] and db["has_email_column"] is True
print("fresh replay applies v1..v3 in order:", sorted(applied))
# 2. Re-running is idempotent: the applied ledger makes it a no-op.
assert migrate_up(db, applied, migrations) == [(1, "skipped"), (2, "skipped"), (3, "skipped")]
assert sorted(applied) == [1, 2, 3]
print("second run is a no-op; applied ledger still:", sorted(applied))
# 3. Out-of-order apply is rejected instead of silently forking history.
db2, migrations2 = make_migrations()
applied2 = {}
try:
apply_single(db2, applied2, migrations2, 3)
except RuntimeError as error:
assert "out of order" in str(error), error
print("out-of-order v3 rejected:", error)
else:
raise AssertionError("applying v3 before v1..v2 must fail")
assert applied2 == {} and "index_on_email" not in db2
# 4. Rollback runs downs in reverse and only to the requested target.
assert migrate_down(db, applied, migrations, 2) == [1, 2]
assert "index_on_email" not in db and sorted(applied) == [1, 2]
assert migrate_up(db, applied, migrations) == [(1, "skipped"), (2, "skipped"), (3, "applied")]
print("rollback to v2 drops the index; re-up restores it:", sorted(applied))
# 5. A down migration destroys data: emails do not come back on re-up.
db["users"].append({"id": 1, "email": "a@example.com"})
assert migrate_down(db, applied, migrations, 1) == [1]
assert db["users"] == [{"id": 1}] # email column (and its values) is gone
assert migrate_up(db, applied, migrations)[1] == (2, "applied")
assert db["users"] == [{"id": 1, "email": None}] # re-up gives NULL, not the old address
print("down v2 drops stored emails; re-up yields None instead of the original")
# 6. Editing an already-applied migration is version drift, not a fix.
drifted = [(v, n + "_renamed" if v == 2 else n, u, d) for v, n, u, d in migrations]
try:
migrate_up(db, applied, drifted)
except RuntimeError as error:
assert "drift" in str(error), error
print("edited history detected:", error)
else:
raise AssertionError("renaming applied v2 must fail")
print("PASS: ordered up, idempotent ledger, rejected jump, ordered rollback, lossy down, drift check")
六个场景依次验证:全新库按顺序重放版本一到版本三;第二次运行因为账本已经记录名称身份而成为空操作;跳过前置版本直接应用版本三会被拒绝;回滚到版本二只撤销索引,重跑后索引恢复;回滚版本二证明 down 是有损的,存入的邮箱地址在重新应用后变成空值而不是原文;重命名已应用的迁移会被判定为版本漂移。只有全部断言通过,最后一行才会打印 PASS。
这个实验只验证顺序、幂等、回滚与漂移逻辑,不覆盖真实数据库锁、并发执行者与备份恢复。修订后的完整示例已在 Linux、Python 3.12.14 下运行通过。动生产之前,请先在暂存环境的副本上演练真实迁移,并确认 down 路径能恢复到预期状态。
延伸阅读
来源
- golang-migrate/migrate,带 dirty 状态跟踪的版本化 up/down 迁移,已核验 2026-10-15。
- PostgreSQL:ALTER TABLE,表变更的锁与重写行为,已核验 2026-10-15。
- strong_migrations,拦截危险迁移如阻塞性操作,已核验 2026-10-15。