Go 数据库迁移入门:SQL 文件、版本表和上线顺序

用一个任务表变更示例讲 Go 项目里的数据库迁移基础,包括版本表、up/down SQL、幂等性和发布顺序。

数据库表结构不会一开始就设计完。你会新增字段、加索引、拆表、修约束。初学项目常见做法是手工连数据库执行 SQL,短期快,长期危险:谁执行过、执行到哪一步、线上和本地是否一致,都说不清。数据库迁移的目标就是让结构变更有版本、有记录、可重复。

本文不绑定具体迁移工具,而是讲基本概念:SQL 文件、版本表、up/down、幂等和上线顺序。

迁移文件长什么样

一种常见命名:

migrations/
  001_create_tasks.up.sql
  001_create_tasks.down.sql
  002_add_task_due_date.up.sql
  002_add_task_due_date.down.sql

up 表示应用变更,down 表示回滚变更。创建任务表:

CREATE TABLE tasks (
    id BIGINT PRIMARY KEY,
    title TEXT NOT NULL,
    status TEXT NOT NULL,
    created_at TIMESTAMP NOT NULL
);

回滚:

DROP TABLE tasks;

真实生产里,down 不一定总能安全执行。比如删除字段后数据已经丢失,回滚 SQL 只能恢复结构,不能恢复数据。迁移的“可回滚”要结合数据风险看。

版本表

迁移工具通常会维护版本表:

CREATE TABLE schema_migrations (
    version BIGINT PRIMARY KEY,
    applied_at TIMESTAMP NOT NULL
);

应用 001 后插入一行。下次运行迁移时,工具看到 001 已执行,就从 002 开始。版本表让迁移过程可追踪,不依赖人的记忆。

一个简化 Go 查询:

func appliedVersions(ctx context.Context, db *sql.DB) (map[int]bool, error) {
	rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	versions := map[int]bool{}
	for rows.Next() {
		var v int
		if err := rows.Scan(&v); err != nil {
			return nil, err
		}
		versions[v] = true
	}
	return versions, rows.Err()
}

实际项目建议使用成熟迁移工具,不要轻易自己造完整迁移系统。但理解版本表很重要。

迁移要小步

比如给任务加截止时间:

ALTER TABLE tasks ADD COLUMN due_at TIMESTAMP NULL;

这是相对安全的小步。危险的是一次迁移里做很多事:加字段、填充数据、加 NOT NULL、删除旧字段、重建索引。任何一步失败都难排查。

更稳的发布顺序通常是:

  1. 先加可空新字段。
  2. 发布应用,同时写新旧字段或开始写新字段。
  3. 后台回填历史数据。
  4. 确认数据完整后加约束。
  5. 最后删除旧字段。

数据库迁移和应用发布是配合关系,不只是 SQL 文件。

不要随便在大表上加重锁操作

在大表上 ALTER TABLE、创建索引、加 NOT NULL 可能锁表或耗时很长。不同数据库行为不同,不能把本地小表测试结果直接套到线上。上线前要知道表大小、数据库版本、锁行为和回滚方案。

比如加索引:

CREATE INDEX idx_tasks_status_created_at ON tasks (status, created_at);

在生产数据库上可能需要使用在线建索引语法。具体语法取决于数据库类型。Go 代码里不需要知道这些细节,但迁移文件必须考虑。

应用代码要兼容迁移过程

如果应用先发布,代码开始读 due_at,但迁移还没执行,就会报字段不存在。如果迁移先执行,但旧应用不认识新字段,通常没问题。更安全的做法是让数据库变更向前兼容:先加字段,不立刻要求应用必须写。

对于删除字段,顺序相反:先发布不再使用旧字段的应用,确认稳定后,再迁移删除字段。不要应用还在读字段时就把字段删掉。

测试迁移

至少在本地或 CI 里从空数据库跑一遍所有迁移,再运行测试。也可以测试从某个旧版本迁移到最新版本。迁移文件不是文档,它是代码的一部分,应该被验证。

Go 项目里可以写脚本:

go test ./...

测试前由 CI 创建临时数据库、执行迁移、再跑集成测试。即使一开始做不到完整自动化,也不要完全依赖手工执行 SQL。

回填数据要拆批

结构迁移和数据回填最好分开。比如新增 due_at 后,要根据历史任务规则填充默认值,不建议在一个大事务里一次更新全表:

UPDATE tasks
SET due_at = created_at + INTERVAL '7 days'
WHERE due_at IS NULL;

如果表很大,这条 SQL 可能锁住大量行。更稳的方式是后台任务按 ID 范围分批:

func BackfillDueAt(ctx context.Context, db *sql.DB, afterID int64, limit int) (int64, error) {
	rows, err := db.QueryContext(ctx, `
		SELECT id FROM tasks
		WHERE id > ? AND due_at IS NULL
		ORDER BY id
		LIMIT ?
	`, afterID, limit)
	if err != nil {
		return afterID, err
	}
	defer rows.Close()

	var ids []int64
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return afterID, err
		}
		ids = append(ids, id)
	}
	if err := rows.Err(); err != nil {
		return afterID, err
	}
	for _, id := range ids {
		if _, err := db.ExecContext(ctx, `UPDATE tasks SET due_at = created_at WHERE id = ?`, id); err != nil {
			return afterID, err
		}
		afterID = id
	}
	return afterID, nil
}

示例为了简单逐条更新,真实项目可以批量更新。关键是任务可暂停、可继续、可观察,而不是一次性赌数据库能扛住。

迁移也要写说明

每个高风险迁移最好在 PR 里写清楚:影响哪张表、是否锁表、是否需要回填、应用发布顺序是什么、如何验证、如何回滚。SQL 文件告诉机器怎么执行,说明告诉人为什么这么做。数据库变更一旦进入生产,沟通成本比代码变更更高。

本地开发也走迁移

本地开发时不要维护一份单独的 schema.sql,然后线上用迁移文件。两套来源很快会不一致。更好的方式是新建本地数据库后也从第一条迁移跑到最新版本。这样新人环境、CI 环境和生产环境至少在结构来源上是一致的。

如果需要快速初始化测试数据,可以把种子数据和结构迁移分开。结构迁移描述表和索引,种子脚本描述本地演示数据。不要把大量演示用户、演示订单塞进生产迁移里。

这能让结构演进和演示数据各自保持清楚边界。

迁移历史也会更容易审计。

后续排查线上结构差异时,也能快速定位是哪次变更引入的。

小结

数据库迁移的核心是版本化、可追踪、小步发布。SQL 文件记录结构变化,版本表记录已执行版本,应用发布顺序要和迁移兼容。up/down 有帮助,但数据回滚不总是简单。

Go 项目不一定要自己实现迁移工具,但每个后端开发都应该理解迁移的基本规则。表结构是生产系统的一部分,不能只靠临时 SQL 和口头约定维护。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页