Go 项目数据库 Migration 实战:从 Dirty Version 炸库到零停机发版的完整方案
Go 项目数据库 Migration 实战:从 Dirty Version 炸库到零停机发版的完整方案
蹦熊项目上线两个月的时候,有一天运维在群里发了一张截图——API 服务挂了,日志里躺着一行红字:
Dirty database version 66. Fix and force version.
这就是经典的 `golang-migrate` 脏版本问题。如果你也用 Go 做后端、用 migration 管理数据库变更,几乎一定会碰到这个坑。这篇文章把我们从踩坑到建立一套生产级 migration 规范的完整过程写下来,包括代码、流程和几个容易忽略的细节。
## 问题是怎么冒出来的
先说背景:蹦熊用 `golang-migrate/migrate` 做数据库 schema 管理,migration 文件按时间戳命名,比如 `000060_add_order_cancel_table.up.sql`。每次发布通过 `release-api.sh` 跑 `migrate up` 自动执行。
上线两周后,业务提了一个新需求——订单表要加一个 `cancel_reason` 字段。我写了一个 `000061_add_cancel_reason.up.sql`,本地跑测试全绿,顺手把上一个版本还在测试环境的 `000060` 也合并了。推代码、打 tag、触发 CI 发布。
十分钟后,报警响了。
排查发现:测试环境的 migration 状态表 `schema_migrations` 里 `000060` 标记为 dirty。原因是测试环境之前手动跑过一次 `000060`,但又没有正式打 version 标记。现在 CI 流程自动执行 `migrate up`,读到 `000060` 发现 dirty flag,直接拒绝继续执行。
## golang-migrate 的 dirty 机制到底怎么判断的
理解这个问题之前,得先搞清楚 migrate 的锁和状态机制。migrate 在执行每个 migration 文件时会做三件事:
1. 向 `schema_migrations` 表写入一条记录,`version` 为当前脚本编号,`dirty` 字段设为 `true`
2. 执行 `.up.sql` 脚本
3. 执行成功后把 `dirty` 改回 `false`
如果在步骤 2 执行脚本的过程中数据库连接中断、脚本报错、或者被手动 kill 了进程,`dirty` 就会卡在 `true`。这时 migrate 认为"上一条 migration 可能没跑完整",拒绝继续执行后续脚本。
看一下 migrate 源码里这个判断逻辑,在 `database.go` 的 `run` 函数中:
// migrate/database.go (简化版)
func (m *Migrate) run() error {
for m.version < len(m.migrations) {
migration := m.migrations[m.version]
// 检查 dirty flag
if m.dirty {
return ErrDirty{Version: m.version}
}
if err := m.databaseDriver.Run(migration); err != nil {
return err
}
m.version++
}
return nil
}
核心就是 m.dirty 这个 bool 值。一旦为 true,整个 migration 链直接中断。
三种解决思路,为什么我们选了合并而非 force
碰到 dirty version,社区通常有三种处理方式:
方案一:force version(不推荐)
migrate -path ./migrations -database "mysql://..." force 66
直接在数据库里把 `dirty` 改成 `false`,锁定版本号。问题是——你不知道 `000060` 到底有没有完整执行。可能是表建了一半、字段加了一半、索引没建完。`force` 等于盲飞。
### 方案二:手动检查和修复
登录数据库,检查上一条 migration 的 DDL 有没有生效,如果生效了就手动改 `dirty=false`,没生效就先手动补执行再改状态。这个方案理论上最安全,但操作依赖人工判断,凌晨三点出问题的时候脑子容易短路。
### 方案三:合并迁移文件
把出问题的 migration 和它后面的 migration 合并成一个文件。这是我们在蹦熊上最终采用的方式。具体操作:
```sql
-- 原来的结构
000060_add_order_cancel_table.up.sql
000061_add_cancel_reason.up.sql
-- 合并后
000060_merge.up.sql (内容 = 000060 + 000061)
然后删除 `schema_migrations` 表中 dirty 的那条记录,把 version 回退到上一个安全版本(比如 `000059`),再跑 `migrate up`。用新命名的合并文件覆盖掉旧的两个文件,migrate 会把它当作一条新的 migration 执行。
这个方法为什么比 force 好?因为你不依赖"猜测上一个 migration 有没有执行完整"这个前提。合并后的文件包含了完整的新 DDL,跑一遍就全是新状态。
## 合并 migration 文件的实际操作步骤
1. 备份数据库(永远第一步)
mysqldump -u root -p bengxiong > backup_$(date +%Y%m%d_%H%M%S).sql
2. 查看当前 migration 状态
mysql -u root -p -e " SELECT * FROM schema_migrations ORDER BY version DESC LIMIT 5; "
3. 删除 dirty 记录
mysql -u root -p -e " DELETE FROM schema_migrations WHERE version = 66 AND dirty = 1; "
4. 合并 SQL 文件
cat 000060_add_order_cancel_table.up.sql \ 000061_add_cancel_reason.up.sql \ > 000060_merge.up.sql
5. 删除旧的 migration 文件
rm 000060_add_order_cancel_table. rm 000061_add_cancel_reason.
6. 改名字(让 migrate 能识别为新文件)
mv 000060_merge.up.sql 000061_add_order_cancel_with_reason.up.sql mv 000060_merge.down.sql 000061_add_order_cancel_with_reason.down.sql
7. 重新跑 migration
migrate -path ./migrations -database "mysql://..." up
这里有个细节容易漏:down.sql 也要合并。migrate 在执行的时候会校验 up 和 down 的配对,缺一个就会报 no down migration。
合并后要注意的连锁问题
ODM 模型文件如果用了 GORM 的 AutoMigrate,也得同步检查。比如我们在 migration 里加了字段,但 GORM model struct 里还没加 tag,部署之后 API 返回的 JSON 就会缺字段。这种情况线上排查起来很费时间。
我们的做法是在 CI 流程里加了一卡:每次 migration 执行完之后,跑一个专门的小脚本对比 information_schema.COLUMNS 和 GORM model struct 的字段列表,有不一致就报警。
-- 检查字段对齐的查询
SELECT
COLUMN_NAME,
DATA_TYPE,
IS_NULLABLE,
COLUMN_DEFAULT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'bengxiong'
AND TABLE_NAME = 'orders'
ORDER BY ORDINAL_POSITION;
对蹦熊来说,这一步帮我们抓到过两次 GORM tag 和实际表结构不一致的问题。
## 防止再次出现的措施
出了这次事故之后我们在 CI 里加了三个硬约束:
**1. migration 文件不能手动修改**
所有 migration 一旦合并到主分支,不允许再用 force 或手动改数据库。如果发现 dirty,走上面说的合并流程,留审计记录。
**2. CI 部署脚本加 dirty 检查**
在 `release-api.sh` 里,`migrate up` 之前先跑一步检测:
```bash
# 加了 dirty 检查的部署脚本片段
VERSION=$(migrate -path ./migrations -database "$DB_URL" version 2>&1)
if echo "$VERSION" | grep -q "Dirty"; then
echo "[ERROR] Migration state is dirty: $VERSION"
echo "[ERROR] 拒绝自动部署,请走手动合并流程"
exit 1
fi
migrate -path ./migrations -database "$DB_URL" up
```
就是这几行 Bash 帮我们拦住了之后三次可能的线上事故。
3. 测试环境做 migration 干跑
每次 CI 部署先在 staging 数据库上跑一遍 migration,通过后才推到生产。如果 staging 也不干净,先修 staging 再推生产。
我们现在的 migration 规范
事故处理后,定了规范:
- migration 文件一旦合进主分支,视为不可变,只能新增不能修改
- 每个 migration 必须提供 up 和 down,缺一个不让合
- 生产数据库的
schema_migrations表每日自动备份到 S3 - 部署流程中,
dirty状态直接阻断发布,禁止自动 force - schema 对比脚本跑在第二步,GORM model 和实际表结构不一致必须告警
这个规范放在团队 Wiki 里,新同事第一天就能看到。
什么团队适合用
这套流程不只适用于 golang-migrate。Django、Rails、Laravel、TypeORM 的 migration 都有类似的 dirty/version 机制,核心问题是一样的:你怎么在迁移失败后安全恢复,而不是靠一句 force 蒙混过关。
说白了,以下几种情况的团队收益最大: - 多人协作、migration 文件经常有交叉的开发团队 - 测试环境和生产环境 migration 执行顺序不一致的 - CI/CD 流程里自动执行 migration 的
这个问题本身不复杂,但第一次碰上的人大概率会愣住——我当时就在服务器前愣了五分钟。上面这套流程我们跑了几个月了,没再出过事。
论坛里如果谁遇到过类似的 migration 翻车,特别是 Django 和 Rails 那边的方案,来说说。不同框架处理这事的路子差挺多的,我挺想看看的。