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

本文详解数据库迁移的基本概念和实践方法,涵盖迁移文件组织、版本表、幂等性、应用兼容和分批回填策略,附带上线流程建议。

数据库表结构从来不会一次设计完成。随着业务发展,你会新增字段、添加索引、拆分表、修改约束。初学项目的常见做法是直接连生产数据库执行 SQL,短期来看很快,长期却留下隐患:谁执行过、执行到哪一步、线上和本地是否一致,都说不清楚。

数据库迁移的目标是让结构变更有版本、有记录、可重复。本文从迁移文件的基本结构出发,深入讲解版本表机制、上线策略、应用代码兼容、大表安全操作和数据回填的完整方案。

迁移文件的结构化设计

一个清晰可维护的迁移目录应该包含明确的命名规则和结构。最常见的约定是:

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
  003_add_task_priority_index.up.sql
  003_add_task_priority_index.down.sql

每个迁移由两个文件组成:up.sql 应用变更,down.sql 回滚变更。版本号建议用递增数字,也可以用时间戳。数字版本更直观,时间戳版本避免了多人协作时的冲突。

创建任务表的 up 迁移:

-- 001_create_tasks.up.sql
CREATE TABLE tasks (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    status VARCHAR(50) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

对应的 down 迁移:

-- 001_create_tasks.down.sql
DROP TABLE IF EXISTS tasks;

现实生产环境中,down 迁移并不总能安全执行。比如删除列后数据已经丢失,回滚 SQL 只能恢复表结构,不能恢复数据。迁移的可回滚性要结合数据风险综合评估。

版本表的追踪机制

迁移工具通常会维护一张版本表来记录已执行的迁移:

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

应用 001_create_tasks 后,版本表插入一行:

INSERT INTO schema_migrations (version) VALUES (1);

下次运行迁移时,工具查询已应用的版本:

package main

import (
	"context"
	"database/sql"
)

func appliedVersions(ctx context.Context, db *sql.DB) (map[int]bool, error) {
	rows, err := db.QueryContext(ctx, `SELECT version FROM schema_migrations ORDER BY version`)
	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()
}

版本表让迁移过程可追踪、可审计。无论是本地开发、CI 测试还是生产部署,都可以从版本表确认当前状态。

小步迁移的发布策略

数据库变更要遵循小步快跑的原则。一次迁移做太多事情(加字段、填充数据、加约束、删除旧字段、重建索引)会让回滚和排查变得极其困难。

以给任务表添加截止时间为例,推荐的分步迁移策略:

第一步:添加可空新字段

-- 004_add_task_due_date.up.sql
ALTER TABLE tasks ADD COLUMN due_at TIMESTAMP NULL;

第二步:发布应用代码

新代码同时写入新旧字段(双写),或开始写入新字段。应用发布顺序要与迁移配合。

第三步:后台回填历史数据

对于已有数据,需要填充默认值。这通常是一个分批的后台任务,不是迁移的一部分。

第四步:确认数据完整后添加约束

-- 005_make_due_date_not_null.up.sql
ALTER TABLE tasks MODIFY COLUMN due_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;

第五步:清理旧字段(确认稳定后)

-- 006_remove_old_deadline_field.up.sql
ALTER TABLE tasks DROP COLUMN old_deadline;

这个分步策略的核心思想是:每次迁移只做一个原子操作,保持前向兼容性。这样任何中间步骤出问题都可以单独回滚,而不影响整体系统。

大表迁移的安全操作

在大表(百万行以上)上执行 ALTER TABLE、创建索引、添加 NOT NULL 约束可能锁表或耗时极长。不同数据库的行为差异很大,不能把本地小表测试结果直接套到线上。

MySQL 在线 DDL

MySQL 8.0 引入了在线 DDL,大部分添加列和索引的操作不会阻塞 DML。但仍然要注意:

-- MySQL 8.0+ 支持在线添加索引
ALTER TABLE tasks ADD INDEX idx_status_created (status, created_at);

对于大表,应该使用 ALGORITHM=INPLACE, LOCK=NONE 来明确指定在线方式:

ALTER TABLE tasks ADD INDEX idx_status_created (status, created_at),
ALGORITHM=INPLACE, LOCK=NONE;

PostgreSQL 并发创建索引

PostgreSQL 支持 CONCURRENTLY 选项,避免阻塞写操作:

CREATE INDEX CONCURRENTLY idx_tasks_status_created ON tasks (status, created_at);

注意:CREATE INDEX CONCURRENTLY 不能在事务中执行,且执行时间比普通索引创建更长。

Go 中的迁移执行封装

package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"
)

type Migration struct {
	Version int
	Name    string
	Up      string
	Down    string
}

type Migrator struct {
	db *sql.DB
}

func (m *Migrator) RunMigration(ctx context.Context, migration Migration) error {
	// 检查是否已执行
	var exists bool
	err := m.db.QueryRowContext(ctx,
		"SELECT 1 FROM schema_migrations WHERE version = ?", migration.Version).Scan(&exists)
	if err == nil {
		// 已执行,跳过
		return nil
	}

	// 在事务中执行迁移
	tx, err := m.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin tx: %w", err)
	}
	defer tx.Rollback()

	if _, err := tx.ExecContext(ctx, migration.Up); err != nil {
		return fmt.Errorf("exec up: %w", err)
	}

	if _, err := tx.ExecContext(ctx,
		"INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
		migration.Version, time.Now()); err != nil {
		return fmt.Errorf("record version: %w", err)
	}

	return tx.Commit()
}

这个简化版实现展示了迁移的核心逻辑。生产环境建议使用 golang-migrate、Atlas 等成熟工具。

应用代码与迁移的兼容性

迁移和应用发布不是独立的,它们需要密切配合才能保证系统平滑升级。

向前兼容

当数据库变更先执行(如新增字段),旧应用代码继续运行,此时旧代码不认识新字段。只要新字段是 nullable 或有默认值,通常不会报错。

-- 安全的向前兼容变更
ALTER TABLE tasks ADD COLUMN priority INT NULL DEFAULT 0;

向后兼容

当应用代码先发布(开始读取 due_at),但迁移还没执行,代码会报字段不存在错误。解决方案:

  1. 迁移先执行,字段设为 nullable
  2. 应用发布后开始读取新字段(有默认值兜底)
  3. 后续回填数据后再加约束

删除字段的顺序

删除字段时顺序相反:

  1. 发布不再读取旧字段的应用版本
  2. 确认所有环境都已更新
  3. 执行迁移删除旧字段

千万不要在应用还在读取某字段时就把它删掉。

批量数据回填的工程实践

结构迁移和数据回填应该分开。在新增 due_at 字段后,要根据历史任务的创建时间来填充默认值。不要在一个大事务中一次性更新全表:

-- 危险:可能锁住大量行
UPDATE tasks
SET due_at = DATE_ADD(created_at, INTERVAL 7 DAY)
WHERE due_at IS NULL;

更安全的分批处理方案:

package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"
)

func BackfillDueAt(ctx context.Context, db *sql.DB, batchSize int) error {
	var lastID int64
	for {
		// 查询一批需要更新的记录
		rows, err := db.QueryContext(ctx, `
			SELECT id, created_at FROM tasks
			WHERE id > ? AND due_at IS NULL
			ORDER BY id
			LIMIT ?
		`, lastID, batchSize)
		if err != nil {
			return fmt.Errorf("query: %w", err)
		}

		type record struct {
			id        int64
			createdAt time.Time
		}
		var records []record
		for rows.Next() {
			var r record
			if err := rows.Scan(&r.id, &r.createdAt); err != nil {
				rows.Close()
				return fmt.Errorf("scan: %w", err)
			}
			records = append(records, r)
		}
		rows.Close()

		if len(records) == 0 {
			break // 全部处理完毕
		}

		// 逐条更新(可以优化为批量更新)
		for _, r := range records {
			dueAt := r.createdAt.Add(7 * 24 * time.Hour)
			if _, err := db.ExecContext(ctx,
				"UPDATE tasks SET due_at = ? WHERE id = ?", dueAt, r.id); err != nil {
				return fmt.Errorf("update id %d: %w", r.id, err)
			}
			lastID = r.id
		}

		// 中间状态可以随时中断,下次从 lastID 继续
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
	}
	return nil
}

这个实现的关键点:

  • id 顺序分批查询,确保可以暂停后继续
  • 每次只处理一个批次,避免长事务和大锁
  • 支持 context 取消,可以在需要时优雅停止
  • 更新后记录 lastID,下次从断点继续

迁移的测试策略

迁移文件是代码的一部分,应该被验证。至少应该在 CI 中执行以下测试:

package main

import (
	"database/sql"
	"os"
	"testing"

	_ "github.com/go-sql-driver/mysql"
)

func TestMigrations(t *testing.T) {
	dsn := os.Getenv("TEST_DB_DSN")
	if dsn == "" {
		t.Skip("TEST_DB_DSN not set")
	}

	db, err := sql.Open("mysql", dsn)
	if err != nil {
		t.Fatal(err)
	}
	defer db.Close()

	// 1. 从空数据库开始,执行所有迁移
	migrator := &Migrator{db: db}
	migrations := loadMigrations() // 从目录加载所有迁移
	for _, m := range migrations {
		if err := migrator.RunMigration(m); err != nil {
			t.Fatalf("migration %d failed: %v", m.Version, err)
		}
	}

	// 2. 验证表结构是否符合预期
	var tableName string
	err = db.QueryRow("SHOW TABLES LIKE 'tasks'").Scan(&tableName)
	if err != nil {
		t.Fatal("tasks table not found after migrations")
	}

	// 3. 反向迁移(可选,测试回滚逻辑)
	for i := len(migrations) - 1; i >= 0; i-- {
		if err := migrator.RollbackMigration(migrations[i]); err != nil {
			t.Fatalf("rollback %d failed: %v", migrations[i].Version, err)
		}
	}
}

迁移测试的价值:

  • 确保迁移文件本身没有语法错误
  • 验证迁移版本不会冲突或遗漏
  • 确认迁移后表结构符合预期
  • (可选)验证 down 迁移能正常回滚

数据回填与上线的完整时间线

以一个真实的场景说明迁移和应用发布的完整协作流程。

阶段操作数据库状态应用代码状态
T0执行迁移 004:添加 due_at nullable 字段tasks 表有 due_at NULL旧代码运行,忽略新字段
T1发布应用 v2.0:开始读取 due_at(有默认值)同上新代码运行,新字段为空时走默认逻辑
T2执行后台回填任务due_at 逐步被填充同上
T3回填完成,执行迁移 005:改为 NOT NULLdue_at NOT NULL同上
T4观察一段时间后,发布应用 v2.1:移除默认逻辑同上新代码假设 due_at 总是有值

这个流程确保了每一步都是安全、可回滚的。

本地开发与生产的一致性问题

最常见的反模式是:本地维护一份 schema.sql 用于初始化,而线上用迁移文件。两套来源很快会不一致。

正确的做法:

  • 本地新建数据库后从第一条迁移跑到最新版本
  • 测试环境的初始化也用同样的迁移路径
  • 种子数据(演示数据)和结构迁移分开管理
migrations/          # 结构迁移(所有环境共用)
  001_xxx.up.sql
  002_xxx.up.sql
seeds/               # 种子数据(仅本地/测试)
  users.sql
  tasks.sql

不要把大量演示用户、演示订单塞进生产迁移里。结构演进和演示数据各自保持清楚边界。

迁移审查清单

每次提交数据库迁移 PR 时,应该包含以下信息:

  1. 影响表和字段:明确说明改了哪些表
  2. 是否锁表:在线 DDL 还是离线操作
  3. 是否需要数据回填:回填策略是什么
  4. 应用发布顺序:迁移先还是代码先
  5. 回滚方案:出问题怎么恢复
  6. 执行时间估算:大表变更需要预估时长
  7. 灰度/监控计划:如何确认变更安全

主流 Go 迁移工具对比

Go 生态中有多种迁移方案,了解它们的特点有助于做出合适的选择。

工具成熟度特点推荐场景
golang-migrateCLI 驱动,支持多种数据库,社区活跃大多数 Go 项目
Atlas中高声明式 + 命令式,支持 diff 和 drift 检测需要可视化管理和团队协同
bun/migrateORM 内置,与 Bun ORM 深度集成使用 Bun 的项目
GORM AutoMigrate自动同步结构,零迁移文件快速原型,但不推荐生产
goose支持 Go 函数迁移,不只是 SQL 文件需要在迁移中执行 Go 逻辑

golang-migrate 的命令行使用示例:

# 创建新迁移
migrate create -ext sql -dir migrations add_user_phone_column

# 执行所有待处理的迁移
migrate -path migrations -database "postgres://localhost/dbname?sslmode=disable" up

# 回滚最近 1 个迁移
migrate -path migrations -database "postgres://localhost/dbname?sslmode=disable" down 1

# 查看当前版本
migrate -path migrations -database "postgres://localhost/dbname?sslmode=disable" version

Atlas 的优势在于可以检测实际数据库结构与期望状态之间的差异(schema drift),这在长期维护的项目中非常有用。

GORM 的 AutoMigrate 虽然方便,但不记录迁移历史,无法回滚,不适合生产环境的数据库变更管理。建议只在开发阶段快速同步表结构。

FAQ:迁移常见问题

Q1: 自己写迁移工具还是用现成的?

大部分项目应该使用成熟工具(golang-migrate、Atlas、Flyway)。自己实现迁移工具容易遗漏边界情况,如并发执行、版本表初始化、部分失败处理等。

Q2: 迁移文件冲突了怎么办?

如果两个人同时创建了 003_xxx.up.sql003_yyy.up.sql,需要手动合并版本号。建议在团队中使用时间戳版本号,减少冲突概率。

Q3: down 迁移一定要写吗?

建议写,但要诚实标注哪些 down 迁移有数据风险(比如删除列、修改数据类型)。数据丢失风险的 down 迁移不能在生产上随意执行。

Q4: 如何回滚已经执行的迁移?

标准流程是执行对应的 down 文件,然后从版本表中删除记录。但如果迁移已经删除了列或修改了数据,物理回滚是不可能的,只能从备份恢复。

Q5: 怎么测试迁移在大表上的性能?

在预发布环境复制生产数据量进行测试,使用 EXPLAIN 和性能监控工具。MySQL 可以测试 pt-online-schema-change,PostgreSQL 可以测试 pg_repack

Q6: 如何处理多数据库环境?

如果同时使用 MySQL 和 PostgreSQL,迁移文件可能需要两套。建议用工具抽象(如 Atlas)或在迁移中检测数据库类型。

最佳实践总结

  1. 每次迁移只做一件事:加字段、建索引、改约束分开执行。
  2. 维护版本表:让迁移状态可追踪、可审计。
  3. 迁移与应用前向兼容:先加 nullable 字段,后续再加约束。
  4. 大表操作前做性能评估:不能把小表测试结果直接推广到线上。
  5. 数据回填分批执行:避免一次性更新全表造成锁竞争。
  6. 迁移就是代码:迁移文件应该在 CI 中被测试和验证。
  7. 本地和线上用同一套迁移:不要维护多套 schema 定义。
  8. 写清楚回滚风险:down 迁移不是万能的,数据丢失风险要标注。
  9. 种子数据和结构分离:演示数据不进生产迁移。
  10. 团队审查迁移 PR:数据库变更的沟通成本比代码更高。

数据库迁移是生产系统的生命线。Go 项目不一定要自己造轮子,但每个后端开发者都应该理解迁移的基本规则和安全边界。把表结构的演进当作代码的一部分来管理,才能在业务增长中保持数据层的可维护性和可靠性。

性能对比与基准测试

理解性能问题的最佳方式是通过基准测试观察实际行为。运行 go test -bench=. -benchmem 可以得到每个操作的耗时和内存分配数据。对比不同实现时,建议固定输入规模,跑多次取平均值。

常见错误与最佳实践

错误一:性能优化过早
很多初学者刚写好代码就开始担心性能,结果引入了不必要的复杂度。正确的做法是先用清晰的写法实现功能,在性能问题真实出现时再通过 profile 定位热点。

错误二:忽略边界条件
空输入、超大输入、并发场景、系统资源耗尽等边界条件往往是 bug 的来源。写代码时养成习惯:每个函数都问自己,空值怎么办?错误怎么处理?

错误三:错误处理不完整
Go 的错误处理要求显式检查。常见问题是只在最外层处理错误,中间层把 error 吞掉。使用 fmt.Errorf 配合 %w 保留原始错误链。

错误四:并发代码缺少同步
Go 的并发模型很简洁,但共享内存访问必须同步。用 go test -race 验证并发安全性。

生产环境注意事项

  1. 日志要克制:不要记录敏感信息,不要在热路径上打印大量日志。
  2. 超时和取消:所有外部调用都要有超时。
  3. 资源限制:限制请求体大小、并发连接数、内存使用。
  4. 优雅关闭:http.Server 要设置 Shutdown 超时,goroutine 要有退出机制。
  5. 可观测性:至少记录关键指标。

测试策略

好的测试应该覆盖正常路径、错误路径和边界条件。表驱动测试是推荐的方式。每次修改代码后都要跑一遍测试,CI 中集成 go test ./... 是最基本的自动化保障。

实战 FAQ

Q: 这个功能在旧版 Go 中能用吗?
A: 需要看具体功能引入的版本。建议使用最新的稳定版 Go。

Q: 第三方库更好还是标准库更好?
A: 能标准库解决先用标准库,第三方库引入依赖成本和许可证风险。

Q: 怎么判断代码算不算过度设计?
A: 问自己:这个抽象让调用方更简单了吗?减少了多少重复?维护成本是增加还是减少了?

小结

掌握这项技能的关键不是记住所有 API,而是理解背后的设计原则和适用边界。先让代码工作,再让它正确,最后才考虑让它更快。清晰的代码比聪明的代码更有价值。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南