数据库表结构不会一开始就设计完。你会新增字段、加索引、拆表、修约束。初学项目常见做法是手工连数据库执行 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、删除旧字段、重建索引。任何一步失败都难排查。
更稳的发布顺序通常是:
- 先加可空新字段。
- 发布应用,同时写新旧字段或开始写新字段。
- 后台回填历史数据。
- 确认数据完整后加约束。
- 最后删除旧字段。
数据库迁移和应用发布是配合关系,不只是 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 和口头约定维护。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。