数据库迁移
框架的表结构变更走版本化迁移 + 变更管控:每个库有一张账本表 schema_migrations(当前状态)和一张流水表 schema_migration_runs(执行证据)。启动时只执行账本里没有、且已获授权的版本。
企业标准到底要求什么
市面上讲「迁移」的文章大多只讲工具。但真正卡住上线的是审计与变更管控,规范原文里对数据库变更的要求是这样:
| 规范 | 关键要求 | 落到本框架 |
|---|---|---|
| SOC 2 CC8.1 | 变更须经授权、测试、批准、可追溯;数据库 schema 变更与数据修正在覆盖范围内;紧急变更要有事后复核 | 分级 + 变更单号 + 流水表 |
| SOC 2 职责分离 | 申请人不能同时是唯一批准人与执行人;管理员绕过也要留痕 | 启动流程不能批准 destructive;批准只能由人带单号在 CLI/接口发起 |
| SOX ITGC(CM-01~06) | 每条 DDL/DML 关联变更单;申请人与批准人分离;完整执行记录(SQL、谁申请、谁批准、时间);多环境验证 | 账本记 operator/ticket/reason;流水记 source/时间/耗时/结果 |
| ITIL 4 变更使能 | 变更分标准/正常/紧急三类,控制强度匹配风险;标准变更可预授权 | additive 预授权自动执行;rewrite 自动但先备份;destructive 必须授权 |
| 零停机 schema 演进 | 只做兼容的中间态;先扩展后收缩;大表改结构要有在线方案 | expand-contract 流程(见文末),禁止在同一版里"先删后加" |
| 回退预案 | 高风险变更必须有可执行的 rollback,而不只是"到时候再说" | rewrite/destructive 执行前自动备份并写进流水 |
一句话概括差距:多数项目有「版本账本」,但没有「谁批的、谁执行的、凭什么批、出事怎么退」。后四件事才是审计取样时要看的东西。
结构
framework/database/
migrations.py 引擎:账本 + 流水 + 分级授权 + 备份 + 校验和 + 加锁
schema.py 注册表 MIGRATIONS + 建表语句 + 各版迁移体
migrate.py 运维命令行(status / plan / up / history / runs / check / verify / baseline)
sql/init.sql 全新安装的完整建表(不走迁移)分工原则:建表不属于迁移。全新安装直接按 init.sql 建出最新结构;迁移只服务于「已经在跑的库怎么追上代码」。
变更分级
每条迁移必须声明 level,它不是注释,直接决定能不能自动执行:
| 级别 | 含义 | 典型操作 | 执行策略 |
|---|---|---|---|
additive | 旧代码仍能跑 | 加表、加可空列、加索引、放宽约束 | 启动时自动执行 |
rewrite | 保数据但要动整表 | 改列类型、重建表、批量回填 | 启动时自动执行,执行前备份 |
destructive | 不可逆 | 删表、删列、删行、收紧语义 | 必须人工授权(带变更单号) |
判断标准只有一条:回滚这条语句需不需要恢复数据。需要,就是 destructive。
Migration(10, 'groups.drop_legacy_tag', _migrate_drop_legacy_tag,
'groups_info 删除 legacy_tag 列', level=LEVEL_DESTRUCTIVE)授权闸门与职责分离
destructive 迁移不会在启动时执行。启动日志会给出明确指令,CLI 带变更单号才放行:
python -m framework.database.migrate up --approve CHG-2026-0117 \
--reason "legacy_tag 已无代码引用,按 2026-11 清理计划删除"这道闸门的设计意图不是"多一步操作",而是:
- 申请与执行分离:写迁移的人在代码里表达意图,执行由运维在变更单下完成;
- 启动进程不是批准人:自动化流程(重启、容器拉起)不能替人做不可逆决定;
- 凭据可追溯:变更单号写进账本与流水,审计问"谁批准的"时能直接查。
rewrite 不需要单号(不丢数据),但会在执行前自动备份,备份路径写进流水。
首次接管的例外
账本一条记录都没有时(新库、或从无账本版本升级上来的老库),历史迁移会一次性收敛,包括 destructive 版。理由:此时不存在"上一个已批准的状态"可保护,若拦下来,安装会卡在半途(连 additive 都跑不完)。这次执行会在日志里以 warning 明示,流水的 reason 记「首次接管,历史迁移一次性收敛」。此后的 destructive 一律需要授权。
这等价于 Flyway 的 baselineOnMigrate:首次接触按现状接管,之后才开始严格管控。
证据链:两张表的分工
| 表 | 语义 | 内容 | 变更方式 |
|---|---|---|---|
schema_migrations | 当前状态 | 每版一行:版本、名称、校验和、成功与否、级别、操作人、单号、原因 | 失败重试时覆盖写 |
schema_migration_runs | 执行证据 | 每次尝试一行:时间、耗时、来源(boot/cli/api)、操作人、单号、原因、错误、备份路径、数据库类型、框架版本 | 只增不改(append-only) |
审计取样要的是流水(能证明"当时发生了什么"),排障要的是账本(能回答"现在到哪一版")。把两者混成一张表,就必然在"重试覆盖历史"和"历史污染状态"之间二选一。
python -m framework.database.migrate runs --limit 20执行前备份(回退预案)
rewrite / destructive 执行前会自动备份,同一轮只备一次:
- SQLite:
VACUUM INTO在线快照,落在库文件同级backups/,按keep_backups轮转。不锁写、不受 WAL 影响;旧版 SQLite 不支持时退化为文件复制并明确告警(WAL 下副本可能缺未 checkpoint 的事务)。 - MySQL:不在框架里 dump(会长时间锁表、体积不可控),改为记录
SHOW MASTER STATUS的 binlog 位点作为恢复点;拿不到位点(未开 binlog 或无权)时直接报 error 让运维确认实例备份,不假装有备份。
备份是"回退预案"的物化,不是万能。数据回滚仍然要靠 DBA 判断。
新增一个字段:三处同步
按顺序做,缺一不可:
- 新装的库:改
sql/init.sql与sql/init_mysql55.sql(两个方言文件必须同改); - 已在跑的库:在
MIGRATIONS末尾追加一版,声明level:
def _migrate_groups_tag(database):
"""v10:groups_info 增加 tag 列(分组标签)"""
if not database.table_exists('groups_info'):
return
if database.table_has_column('groups_info', 'tag'):
return
if database.db_type == 'sqlite':
database.execute("ALTER TABLE groups_info ADD COLUMN tag TEXT DEFAULT ''")
else:
database.execute(
"ALTER TABLE groups_info ADD COLUMN tag VARCHAR(64) DEFAULT '' "
"COMMENT '分组标签'")
MIGRATIONS = (
# ... 已有版本,不要动 ...
Migration(10, 'groups.tag', _migrate_groups_tag,
'groups_info 增加 tag 列', level=LEVEL_ADDITIVE),
)- 幂等:先判断"要做的事是否已存在"再动手。中断重入、重复执行都靠它兜底。
版本号用 next_version(MIGRATIONS) 取,不要在中间插号。
铁律:已发布的迁移不可变
一版迁移一旦发布,不能再改内容。账本存的是它的校验和:
- 老库记录旧校验和,永远匹配不上新代码;
- 账本又说"这版已应用",所以不会重跑 —— 改了等于没改;
- 结果是这个库的结构与新装的库悄悄分叉。
引擎发现不一致会报 error 并在状态里标 drift,但不重跑。正确修法是追加一版新的迁移。
python -m framework.database.migrate check # 有漂移返回 1,适合放 CI命令行
在项目根目录执行(默认读 config.yaml,可用 --config 指定):
python -m framework.database.migrate status # 当前版本 / 待应用(含级别与是否需授权)/ 漂移
python -m framework.database.migrate plan # 干跑:将执行什么、要不要授权、会不会备份
python -m framework.database.migrate up # 执行已获授权的待应用迁移
python -m framework.database.migrate up --approve CHG-001 --reason "..." # 批准 destructive
python -m framework.database.migrate history # 账本:版本/级别/结果/操作人/变更单
python -m framework.database.migrate runs # 流水:每次尝试的时间/来源/结果/备份
python -m framework.database.migrate check # 只检查,有未应用或漂移返回 1
python -m framework.database.migrate verify # 结构自检:版本、缺失表、未授权迁移
python -m framework.database.migrate baseline --version 9 # 接管老库(只记账不执行)退出码:0 正常,1 有未应用迁移 / 漂移 / 失败 / 待授权,2 运行环境错误。
plan 不出现在 CI 之外也很有用:上线前把输出贴进变更单,就是"影响评估"那一栏。
接管一个没有账本的老库
正常升级不需要这一步:老库首次启动时账本为空,历史版本会各跑一遍(都是幂等判断,效果是空转),然后记账。
baseline 用于另一种情况:库结构早已是目标状态,但不想让引擎逐条确认。它只写账本、不执行迁移体:
python -m framework.database.migrate baseline --version 9前提是运维确认该库结构确实等于该版本。基线等于断言,断错了会把缺陷永久记账掩盖掉。基线行不参与校验和漂移判定。
接口
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| GET | /api/db/migrations | 登录管理员 | 当前版本、待应用(含 level、requires_approval、blocked_reason)、漂移、历史、最近流水 |
| POST | /api/db/migrations/up | 超级管理员 | 应用待执行迁移;body 带 ticket 才能放行 destructive,全部写审计日志 |
接口不是绕过闸门的后门:它和 CLI 走同一个 approval_gap() 判定。status.ok 在出现漂移或待授权迁移时为 false,可直接接监控告警。
配置
database:
migrations:
auto_apply_destructive: false # 生产保持 false:开机流程不能兼任批准人
backup_before_rewrite: true # rewrite/destructive 前自动备份
keep_backups: 5 # SQLite 备份保留份数
# operator: "" # 流水里的操作人;默认取系统登录名auto_apply_destructive: true 只建议用于纯本地开发库。
失败与并发
- 单条失败不阻塞框架启动,但会:写
success=0、流水记错误、error 日志列出首个失败原因、停止后续迁移(后面的版本可能依赖它)、下次启动自动重试。 - MySQL 多节点用命名锁
zcbot_schema_migrations串行化,拿不到锁的节点跳过本次迁移并报 error。 - SQLite 靠进程内锁串行化;同一 sqlite 文件被多进程同时写本身不在支持范围。
- 账本表建不出来(只读库、无 DDL 权限)时退化为「不记账、照旧执行」:结构正确优先于记账,但每次启动都会重复跑迁移并留下告警。
附:零停机改造(expand-contract)
ALTER TABLE 在数据量大时会锁表,DROP COLUMN 后旧代码立刻报错。所以破坏性改造要拆成多版,每版都兼容当时的线上代码:
v10 expand 加新列(可空),旧代码不受影响
部署 应用开始双写;后台分批回填历史数据
v11 switch 应用改为只读新列(观察一个发布周期)
v12 contract 删旧列(destructive,走授权闸门)配套约定:
- 先加后删,禁止同一版里"删旧列 + 加新列";
- 回填按主键分批(每批 1000~10000 行),避免长事务;
- 删列前确认没有代码、报表、导出、定时任务还在引用它;
contract版必须destructive—— 它是不可逆的那一步。
框架不强制这个流程,但 level 让它在代码里显式可见:一次提交里出现 destructive,评审就该问"前两步做了吗"。