// PC530 技术论坛 v1.0
● online 登录 / 注册
返回列表
后端开发

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 那边的方案,来说说。不同框架处理这事的路子差挺多的,我挺想看看的。