Go 数据库迁移:goose、golang-migrate 与最佳实践

全面讲解 Go 生态中的数据库迁移方案,涵盖 goose 和 golang-migrate 两大主流工具的实战用法,以及迁移文件规范、版本控制、数据迁移、测试策略、CI/CD 集成和生产环境最佳实践

Go 数据库迁移:goose、golang-migrate 与最佳实践

你有没有经历过这样的噩梦:凌晨两点上线新版本,代码部署成功了,结果打开页面一片空白——因为数据库少了一个字段。或者更惨,回滚代码后发现数据库结构已经被改得面目全非,数据也丢了。又或者团队协作时,同事 A 加了一个索引,同事 B 删了一张表,合并代码的时候数据库结构已经乱成一锅粥。

如果你经历过其中任何一个场景,那你一定会 appreciate 数据库迁移(Database Migration)这个概念。它不是什么新鲜事物,但在很多 Go 项目中却常常被忽视。今天我们就来深入聊聊,如何在 Go 项目中优雅地管理数据库变更。

什么是数据库迁移?

在软件开发中,数据库迁移是指对数据库结构(schema)和数据(data)进行版本化管理的机制。简单来说,就像我们用 Git 管理代码变更一样,数据库迁移工具帮我们管理数据库的每一次变更。

为什么需要数据库迁移?

让我先讲一个真实的故事。某初创团队的数据库变更流程是这样的:

  1. 开发者 A 在本地手动执行了一条 ALTER TABLE 语句
  2. 他把这条语句记在了 Slack 的一个频道里
  3. 部署的时候,运维人员需要去 Slack 里翻找这些 SQL 语句
  4. 有一次 Slack 消息太多,漏了一条,线上环境崩了

这个流程有几个致命问题:

  • 不可追溯:谁在什么时候改了什么,无法精确追踪
  • 不可重复:同样的操作在新环境(比如测试环境)需要手动再执行一遍
  • 不可回滚:出了问题很难安全地撤回变更
  • 不可协作:多人同时修改数据库时容易冲突

数据库迁移工具解决的正是这些问题。它把每一次数据库变更都变成一个版本化的、可追溯的、可重复的、可回滚的文件。

迁移的核心概念

在深入了解具体工具之前,我们先建立几个核心概念:

向上迁移(Up Migration):应用变更,比如创建表、添加字段、创建索引。

向下迁移(Down Migration):撤销变更,比如删除表、移除字段、删除索引。

版本号(Version):每个迁移文件都有一个唯一的版本标识,工具通过版本号来确定哪些迁移已经执行、哪些还需要执行。

迁移状态表(Schema Version Table):迁移工具在数据库中维护一张表,记录已经执行过的迁移版本号。goose 使用 goose_db_version 表,golang-migrate 使用 schema_migrations 表。

goose:轻量优雅的迁移工具

goose 是由 Pressly 团队维护的数据库迁移工具。它的设计理念是简单、直接、可嵌入。goose 既是一个独立的命令行工具,也是一个可以嵌入到你 Go 应用中的库。

安装 goose

# 使用 Go 安装 CLI 工具
go install github.com/pressly/goose/v3/cmd/goose@latest

# 验证安装
goose --version
# goose version: v3.22.1

初始化项目

首先,创建一个目录来存放迁移文件:

mkdir -p migrations
cd migrations

创建迁移文件

goose 支持两种格式的迁移文件:SQL 格式和 Go 格式。

SQL 格式的迁移

goose -dir migrations create create_users_table sql

这会生成一个类似 20250725083000_create_users_table.sql 的文件:

-- +goose Up
-- +goose StatementBegin
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    username    VARCHAR(50) NOT NULL UNIQUE,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);
-- +goose StatementEnd

-- +goose Down
-- +goose StatementBegin
DROP INDEX IF EXISTS idx_users_created_at;
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;
-- +goose StatementEnd

注意 -- +goose Up-- +goose Down 这两个标记,它们告诉 goose 哪些语句是向上迁移、哪些是向下迁移。-- +goose StatementBegin-- +goose StatementEnd 则用于标记一个完整的 SQL 语句块,这对于包含分号的复合语句(比如存储过程、触发器)尤其重要。

Go 格式的迁移

有时候你需要在迁移中执行一些复杂逻辑,比如数据转换。这时可以用 Go 格式:

goose -dir migrations create populate_user_display_names go

生成的文件 20250725090000_populate_user_display_names.go 看起来像这样:

package migrations

import (
	"context"
	"database/sql"

	"github.com/pressly/goose/v3"
)

func init() {
	goose.AddMigrationNoTxContext(upPopulateUserDisplayNames, downPopulateUserDisplayNames)
}

func upPopulateUserDisplayNames(ctx context.Context, db *sql.DB) error {
	// 为已有用户设置 display_name,格式为 "user_{id}"
	_, err := db.ExecContext(ctx, `
		UPDATE users
		SET display_name = CONCAT('user_', id)
		WHERE display_name IS NULL
	`)
	return err
}

func downPopulateUserDisplayNames(ctx context.Context, db *sql.DB) error {
	// 向下迁移时将 display_name 重置为 NULL
	_, err := db.ExecContext(ctx, `
		UPDATE users
		SET display_name = NULL
	`)
	return err
}

这里用了 AddMigrationNoTxContext 而不是 AddMigrationContext,因为 UPDATE 大量数据时可能不适合在一个事务中执行。如果你的迁移操作是幂等的且数据量不大,使用带事务的版本更安全。

执行迁移

# 设置数据库连接(以 PostgreSQL 为例)
export GOOSE_DBSTRING="host=localhost user=postgres password=secret dbname=myapp sslmode=disable"
export GOOSE_DRIVER="postgres"

# 执行所有未应用的迁移
goose -dir migrations up

# 只执行一个迁移
goose -dir migrations up-by-one

# 回滚最后一个迁移
goose -dir migrations down

# 回滚到指定版本
goose -dir migrations down-to 20250725083000

# 查看当前迁移状态
goose -dir migrations status

# 重置到最初状态(慎用!)
goose -dir migrations reset

在 Go 应用中嵌入 goose

goose 的一大优势是可以直接嵌入到你的应用中,这样就不需要额外安装 CLI 工具了:

package main

import (
	"database/sql"
	"embed"
	"log"

	_ "github.com/lib/pq"
	"github.com/pressly/goose/v3"
)

//go:embed migrations/*.sql
var embedMigrations embed.FS

func main() {
	db, err := sql.Open("postgres", "host=localhost user=postgres password=secret dbname=myapp sslmode=disable")
	if err != nil {
		log.Fatalf("failed to connect database: %v", err)
	}
	defer db.Close()

	// 使用嵌入的文件系统
	goose.SetBaseFS(embedMigrations)

	// 设置数据库驱动
	if err := goose.SetDialect("postgres"); err != nil {
		log.Fatalf("failed to set dialect: %v", err)
	}

	// 执行迁移
	if err := goose.Up(db, "migrations"); err != nil {
		log.Fatalf("failed to run migrations: %v", err)
	}

	log.Println("migrations completed successfully")
}

使用 embed.FS 是 Go 1.16 引入的特性,它可以将迁移文件编译进二进制文件中,这意味着你部署的时候只需要一个可执行文件,再也不用担心迁移文件丢失的问题。

golang-migrate:功能丰富的迁移工具

golang-migrate/migrate 是另一个广受欢迎的 Go 数据库迁移工具,它的特点是驱动丰富、功能完善、可扩展性强

安装 golang-migrate

# 安装 CLI 工具
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# 验证安装
migrate --version
# v4.18.1

注意 -tags 'postgres' 这个编译标签,你需要根据实际使用的数据库来指定。支持的标签包括 postgresmysqlsqlite3sqlserver 等。

创建迁移文件

golang-migrate 使用数字递增的版本号(而不是时间戳),并且要求 Up 和 Down 分别在两个文件中:

migrate create -ext sql -dir migrations -seq create_users_table

这会生成两个文件:

  • migrations/000001_create_users_table.up.sql
  • migrations/000001_create_users_table.down.sql

Up 文件:

-- migrations/000001_create_users_table.up.sql

CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    username    VARCHAR(50) NOT NULL UNIQUE,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,
    is_active   BOOLEAN NOT NULL DEFAULT true,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at ON users(created_at);

Down 文件:

-- migrations/000001_create_users_table.down.sql

DROP INDEX IF EXISTS idx_users_created_at;
DROP INDEX IF EXISTS idx_users_email;
DROP TABLE IF EXISTS users;

执行迁移

# 使用连接字符串
export DATABASE_URL="postgres://postgres:secret@localhost:5432/myapp?sslmode=disable"

# 执行所有迁移
migrate -path migrations -database $DATABASE_URL up

# 执行指定数量的迁移
migrate -path migrations -database $DATABASE_URL up 2

# 回滚所有迁移
migrate -path migrations -database $DATABASE_URL down

# 回滚指定数量的迁移
migrate -path migrations -database $DATABASE_URL down 1

# 跳转到指定版本
migrate -path migrations -database $DATABASE_URL goto 3

# 查看当前版本
migrate -path migrations -database $DATABASE_URL version

# 强制设置版本号(用于修复 dirty 状态)
migrate -path migrations -database $DATABASE_URL force 2

# 丢弃所有 dirty 标记(慎用)
migrate -path migrations -database $DATABASE_URL drop

在 Go 应用中嵌入 golang-migrate

package main

import (
	"embed"
	"log"

	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/postgres"
	"github.com/golang-migrate/migrate/v4/source/iofs"
	_ "github.com/lib/pq"
)

//go:embed migrations/*.sql
var migrationsFS embed.FS

func main() {
	db, err := setupDB("postgres://postgres:secret@localhost:5432/myapp?sslmode=disable")
	if err != nil {
		log.Fatalf("failed to connect database: %v", err)
	}
	defer db.Close()

	// 从嵌入的文件系统读取迁移文件
	sourceDriver, err := iofs.New(migrationsFS, "migrations")
	if err != nil {
		log.Fatalf("failed to create source driver: %v", err)
	}

	// 创建数据库驱动
	dbDriver, err := postgres.WithInstance(db, &postgres.Config{})
	if err != nil {
		log.Fatalf("failed to create db driver: %v", err)
	}

	// 创建 migrate 实例
	m, err := migrate.NewWithInstance(
		"iofs", sourceDriver,
		"postgres", dbDriver,
	)
	if err != nil {
		log.Fatalf("failed to create migrate instance: %v", err)
	}

	// 执行迁移
	if err := m.Up(); err != nil && err != migrate.ErrNoChange {
		log.Fatalf("failed to run migrations: %v", err)
	}

	log.Println("migrations completed successfully")
}

迁移文件的编写规范

好的迁移文件不仅仅是正确的 SQL,它们还需要遵循一些规范,确保迁移过程的可靠性和可维护性。

命名规范

# goose 使用时间戳格式
20250725083000_create_users_table.sql
20250725090000_add_avatar_to_users.sql
20250725100000_create_orders_table.sql

# golang-migrate 使用递增序号格式
000001_create_users_table.up.sql
000002_add_avatar_to_users.up.sql
000003_create_orders_table.up.sql

好的命名应该描述变更的目的,而不是变更的手段:

# ✅ 好的命名
add_email_index_to_users.sql          -- 明确说明做了什么
create_orders_table.sql                -- 明确说明创建了什么
add_soft_delete_to_posts.sql           -- 明确说明加了软删除功能

# ❌ 不好的命名
update_table.sql                       -- 哪张表?更新了什么?
migration_3.sql                        -- 完全没有信息量
fix.sql                                -- 修了什么?怎么修的?

幂等性原则

迁移文件应该尽量做到幂等——即多次执行和一次执行的效果一样。这在团队协作中尤其重要,因为你可能不确定同事是否已经执行了某个迁移。

-- ✅ 幂等的写法
CREATE TABLE IF NOT EXISTS users (...);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;

-- ❌ 不幂等的写法
CREATE TABLE users (...);                -- 表已存在会报错
CREATE INDEX idx_users_email ON users(email);  -- 索引已存在会报错

向前兼容性

每次迁移都应该保证与当前正在运行的代码兼容。这意味着你不能在一次迁移中直接重命名字段,因为旧代码可能还在使用旧字段名。正确的做法是分步执行:

-- 第一步:添加新字段(迁移 1)
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);

-- 第二步:迁移数据并更新代码使用新字段(迁移 2)
UPDATE users SET display_name = username WHERE display_name IS NULL;

-- 第三步:确认所有代码都已更新后,删除旧字段(迁移 3,可能几周后)
ALTER TABLE users DROP COLUMN username;

单一职责原则

每个迁移文件只做一个逻辑变更:

-- ✅ 一个迁移做一件事
-- 迁移 1: 20250725083000_create_users_table.sql
CREATE TABLE users (...);

-- 迁移 2: 20250725090000_create_posts_table.sql
CREATE TABLE posts (...);

-- 迁移 3: 20250725100000_add_user_id_index_to_posts.sql
CREATE INDEX idx_posts_user_id ON posts(user_id);

-- ❌ 一个迁移做多件事
-- 20250725083000_init_everything.sql
CREATE TABLE users (...);
CREATE TABLE posts (...);
CREATE TABLE comments (...);
CREATE INDEX ...;
ALTER TABLE ...;

数据迁移:不仅仅是结构变更

很多时候我们不仅需要变更数据库结构,还需要迁移已有的数据。数据迁移比结构迁移更复杂,因为它涉及数据量、执行时间、锁表等问题。

小批量数据迁移

对于数据量不大的情况,可以直接在迁移文件中执行:

-- +goose Up
-- 添加新字段
ALTER TABLE users ADD COLUMN role VARCHAR(20) NOT NULL DEFAULT 'viewer';

-- 根据已有数据设置角色
UPDATE users SET role = 'admin' WHERE email LIKE '%@company.com';
UPDATE users SET role = 'editor' WHERE created_at < '2024-01-01' AND is_active = true;
UPDATE users SET role = 'viewer' WHERE role = 'viewer';  -- 保持默认值

-- +goose Down
ALTER TABLE users DROP COLUMN IF EXISTS role;

大批量数据迁移

对于百万级以上的数据迁移,需要分批处理以避免长时间锁表:

// migrations/20250725100000_migrate_large_dataset.go
package migrations

import (
	"context"
	"database/sql"
	"fmt"
	"log"

	"github.com/pressly/goose/v3"
)

func init() {
	goose.AddMigrationNoTxContext(upMigrateLargeDataset, downMigrateLargeDataset)
}

const batchSize = 5000

func upMigrateLargeDataset(ctx context.Context, db *sql.DB) error {
	var lastID int64 = 0

	for {
		result, err := db.ExecContext(ctx, `
			UPDATE users
			SET search_vector = to_tsvector('english', COALESCE(username, '') || ' ' || COALESCE(email, ''))
			WHERE id IN (
				SELECT id FROM users
				WHERE id > $1 AND search_vector IS NULL
				ORDER BY id
				LIMIT $2
				FOR UPDATE SKIP LOCKED
			)
		`, lastID, batchSize)
		if err != nil {
			return fmt.Errorf("batch update failed: %w", err)
		}

		rowsAffected, _ := result.RowsAffected()
		if rowsAffected == 0 {
			break
		}

		// 更新游标
		err = db.QueryRowContext(ctx, `
			SELECT id FROM users
			WHERE id > $1 AND search_vector IS NOT NULL
			ORDER BY id DESC
			LIMIT 1
		`, lastID).Scan(&lastID)
		if err != nil {
			return fmt.Errorf("cursor update failed: %w", err)
		}

		log.Printf("processed up to id=%d (%d rows in batch)", lastID, rowsAffected)
	}

	return nil
}

func downMigrateLargeDataset(ctx context.Context, db *sql.DB) error {
	_, err := db.ExecContext(ctx, `UPDATE users SET search_vector = NULL`)
	return err
}

这里使用了 FOR UPDATE SKIP LOCKED 来避免与其他事务冲突,分批处理确保不会长时间阻塞其他操作。

版本控制和回滚策略

版本控制的最佳实践

1. 迁移文件必须提交到版本控制系统

迁移文件是代码的一部分,必须和源代码一起提交到 Git。这样每个团队成员都能获取到最新的数据库结构定义。

2. 不要修改已经发布的迁移文件

一旦迁移文件被推送到远程仓库并且已经被其他人或环境执行过,就不应该再修改它。如果需要修改,应该创建一个新的迁移文件。

3. 使用迁移状态检查

# goose 检查状态
goose -dir migrations status

# golang-migrate 检查版本
migrate -path migrations -database $DATABASE_URL version

回滚策略

回滚是数据库迁移中最棘手的部分。有些操作可以安全回滚,有些则不行。

可以安全回滚的操作:

  • 添加列(如果新列没有被使用)
  • 添加索引
  • 创建新表
  • 添加外键约束

难以或不能安全回滚的操作:

  • 删除列(数据会丢失)
  • 重命名列(需要两步操作)
  • 修改列类型(可能丢失精度)
  • 删除表(数据会丢失)

对于不可逆的操作,建议在 Down 迁移中做好数据备份:

-- +goose Up
-- 删除旧的状态字段
ALTER TABLE orders DROP COLUMN legacy_status;

-- +goose Down
-- 回滚时重新创建字段
ALTER TABLE orders ADD COLUMN legacy_status VARCHAR(20) DEFAULT 'unknown';

-- 注意:数据已经丢失,只能设置默认值
-- 如果数据很重要,应该在 Up 迁移中先将数据导出到备份表

更安全的做法——先备份再删除:

-- +goose Up
-- 先创建备份表
CREATE TABLE orders_legacy_status_backup AS
SELECT id, legacy_status FROM orders;

-- 再删除原字段
ALTER TABLE orders DROP COLUMN legacy_status;

-- +goose Down
-- 恢复字段
ALTER TABLE orders ADD COLUMN legacy_status VARCHAR(20);

-- 从备份表恢复数据
UPDATE orders o
SET legacy_status = b.legacy_status
FROM orders_legacy_status_backup b
WHERE o.id = b.id;

-- 清理备份表
DROP TABLE orders_legacy_status_backup;

测试数据库迁移

迁移文件的测试经常被忽略,但它和代码测试一样重要。

单元测试迁移

package migrations_test

import (
	"database/sql"
	"testing"

	"github.com/pressly/goose/v3"
	_ "github.com/lib/pq"
)

func setupTestDB(t *testing.T) *sql.DB {
	t.Helper()
	db, err := sql.Open("postgres", "host=localhost user=postgres password=secret dbname=myapp_test sslmode=disable")
	if err != nil {
		t.Fatalf("failed to connect test database: %v", err)
	}
	return db
}

func TestCreateUsersTable(t *testing.T) {
	db := setupTestDB(t)
	defer db.Close()

	// 重置测试数据库
	if err := goose.Reset(db, "."); err != nil {
		t.Fatalf("failed to reset: %v", err)
	}

	// 执行迁移
	if err := goose.Up(db, "."); err != nil {
		t.Fatalf("failed to run up migrations: %v", err)
	}

	// 验证表结构
	var tableName string
	err := db.QueryRow(`
		SELECT table_name
		FROM information_schema.tables
		WHERE table_schema = 'public' AND table_name = 'users'
	`).Scan(&tableName)
	if err != nil {
		t.Fatalf("users table not found: %v", err)
	}

	// 验证字段存在
	var columnCount int
	err = db.QueryRow(`
		SELECT COUNT(*)
		FROM information_schema.columns
		WHERE table_name = 'users' AND table_schema = 'public'
	`).Scan(&columnCount)
	if err != nil {
		t.Fatalf("failed to query columns: %v", err)
	}
	if columnCount < 5 {
		t.Errorf("expected at least 5 columns, got %d", columnCount)
	}

	// 验证可以插入数据
	_, err = db.Exec(`
		INSERT INTO users (username, email, password)
		VALUES ($1, $2, $3)
	`, "testuser", "test@example.com", "hashed_password")
	if err != nil {
		t.Fatalf("failed to insert test data: %v", err)
	}

	// 测试向下迁移(回滚)
	if err := goose.Down(db, "."); err != nil {
		t.Fatalf("failed to run down migration: %v", err)
	}

	// 验证表已被删除
	err = db.QueryRow(`
		SELECT table_name
		FROM information_schema.tables
		WHERE table_schema = 'public' AND table_name = 'users'
	`).Scan(&tableName)
	if err != sql.ErrNoRows {
		t.Errorf("expected users table to be dropped, but it still exists")
	}
}

使用 testcontainers-go 进行集成测试

为了不依赖外部数据库实例,可以使用 testcontainers-go 在测试中启动一个真实的数据库容器:

package migrations_test

import (
	"context"
	"database/sql"
	"fmt"
	"testing"

	"github.com/pressly/goose/v3"
	"github.com/testcontainers/testcontainers-go"
	"github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
	_ "github.com/lib/pq"
)

func setupContainerDB(t *testing.T) (*sql.DB, context.CancelFunc) {
	t.Helper()
	ctx := context.Background()

	pgContainer, err := postgres.Run(ctx,
		"postgres:16-alpine",
		postgres.WithDatabase("testdb"),
		postgres.WithUsername("test"),
		postgres.WithPassword("test"),
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2),
		),
	)
	if err != nil {
		t.Fatalf("failed to start container: %v", err)
	}

	connStr, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
	if err != nil {
		t.Fatalf("failed to get connection string: %v", err)
	}

	db, err := sql.Open("postgres", connStr)
	if err != nil {
		t.Fatalf("failed to connect: %v", err)
	}

	cancel := func() {
		db.Close()
		pgContainer.Terminate(ctx)
	}

	return db, cancel
}

func TestMigrationsWithContainer(t *testing.T) {
	db, cancel := setupContainerDB(t)
	defer cancel()

	if err := goose.SetDialect("postgres"); err != nil {
		t.Fatalf("failed to set dialect: %v", err)
	}

	// 执行所有迁移
	if err := goose.Up(db, "."); err != nil {
		t.Fatalf("up migrations failed: %v", err)
	}

	// 执行完整往返测试
	if err := goose.DownTo(db, ".", 0); err != nil {
		t.Fatalf("down to version 0 failed: %v", err)
	}

	if err := goose.Up(db, "."); err != nil {
		t.Fatalf("up migrations after full rollback failed: %v", err)
	}
}

这种测试方式的好处是完全隔离的——每次测试都会启动一个全新的数据库,测试结束后自动销毁。

CI/CD 中的数据库迁移

将数据库迁移集成到 CI/CD 流水线中是现代开发流程的重要组成部分。

GitHub Actions 示例

# .github/workflows/migrate.yml
name: Database Migration

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test-migrations:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: myapp_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Install goose
        run: go install github.com/pressly/goose/v3/cmd/goose@latest

      - name: Run migrations (up)
        env:
          GOOSE_DRIVER: postgres
          GOOSE_DBSTRING: "host=localhost user=postgres password=postgres dbname=myapp_test sslmode=disable"
        run: goose -dir migrations up

      - name: Run migrations (down and up again)
        env:
          GOOSE_DRIVER: postgres
          GOOSE_DBSTRING: "host=localhost user=postgres password=postgres dbname=myapp_test sslmode=disable"
        run: |
          goose -dir migrations reset
          goose -dir migrations up

      - name: Run Go tests
        env:
          DATABASE_URL: "postgres://postgres:postgres@localhost:5432/myapp_test?sslmode=disable"
        run: go test ./...

  deploy-migrations:
    needs: test-migrations
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install goose
        run: go install github.com/pressly/goose/v3/cmd/goose@latest

      - name: Run production migrations
        env:
          GOOSE_DRIVER: postgres
          GOOSE_DBSTRING: ${{ secrets.PROD_DATABASE_URL }}
        run: goose -dir migrations up

GitLab CI 示例

# .gitlab-ci.yml
stages:
  - test
  - deploy

test-migrations:
  stage: test
  image: golang:1.22-alpine
  services:
    - postgres:16-alpine
  variables:
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: postgres
    POSTGRES_DB: myapp_test
    GOOSE_DRIVER: postgres
    GOOSE_DBSTRING: "host=postgres user=postgres password=postgres dbname=myapp_test sslmode=disable"
  before_script:
    - go install github.com/pressly/goose/v3/cmd/goose@latest
  script:
    - goose -dir migrations up
    - goose -dir migrations reset
    - goose -dir migrations up
    - go test ./...

deploy-migrations:
  stage: deploy
  image: golang:1.22-alpine
  only:
    - main
  before_script:
    - go install github.com/pressly/goose/v3/cmd/goose@latest
  script:
    - goose -dir migrations -allow-missing up
  variables:
    GOOSE_DRIVER: postgres
    GOOSE_DBSTRING: $PROD_DATABASE_URL

迁移顺序保证

在 CI/CD 中,一个常见的问题是先迁移数据库还是先部署代码。黄金法则是:

  1. 先迁移数据库,后部署代码(推荐):确保新代码运行时数据库已经准备好
  2. 迁移必须是向后兼容的:旧代码也能在新结构上正常运行
时间线:
[T1] 执行数据库迁移(向后兼容)
[T2] 部署新代码
[T3] 执行清理迁移(可选,删除废弃的结构)

生产环境迁移的最佳实践

生产环境的数据库迁移需要格外谨慎。以下是多年实战总结的最佳实践。

1. 迁移前备份

# PostgreSQL 备份
pg_dump -h localhost -U postgres myapp > backup_$(date +%Y%m%d_%H%M%S).sql

# MySQL 备份
mysqldump -h localhost -u root -p myapp > backup_$(date +%Y%m%d_%H%M%S).sql

2. 使用 Dry Run 模式

在执行实际迁移之前,先看看迁移会做什么:

# goose 目前没有原生 dry-run 模式,但可以用事务预览
# 在测试环境执行相同的迁移来验证

# golang-migrate 支持 dry-run(部分版本)
# 你也可以自己写一个 dry-run 包装器

一个实用的 dry-run 方案:

package main

import (
	"database/sql"
	"fmt"
	"log"

	"github.com/pressly/goose/v3"
	_ "github.com/lib/pq"
)

func dryRunMigrations(db *sql.DB) error {
	// 开启事务
	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("failed to begin tx: %w", err)
	}

	// 在事务中执行迁移
	if err := goose.Up(tx, "migrations"); err != nil {
		tx.Rollback()
		return fmt.Errorf("migration failed (rolled back): %w", err)
	}

	// 检查状态
	version, err := goose.GetDBVersion(tx)
	if err != nil {
		tx.Rollback()
		return fmt.Errorf("failed to get version: %w", err)
	}
	fmt.Printf("Dry run successful. Would migrate to version: %d\n", version)

	// 回滚事务——这只是预览
	return tx.Rollback()
}

3. 长时间迁移的处理策略

对于需要很长时间执行的迁移(比如大表加索引),应该使用非阻塞操作

-- PostgreSQL: 使用 CONCURRENTLY 避免锁表
-- +goose Up
CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders(user_id);

-- +goose Down
DROP INDEX IF EXISTS idx_orders_user_id;

注意:CREATE INDEX CONCURRENTLY 不能在事务中执行,所以使用 goose 时应该选择 NoTx 版本的迁移函数。

4. 迁移锁和并发控制

在多实例部署中,多个实例可能同时尝试执行迁移。goose 内置了锁机制:

// 使用 goose 的锁机制确保只有一个实例执行迁移
store, err := goose.NewStore(db)
if err != nil {
    log.Fatal(err)
}

// 尝试获取锁
unlock, err := store.AcquireAdvisoryLock(ctx, "myapp-migrations")
if err != nil {
    log.Println("another instance is running migrations, skipping")
    return
}
defer unlock()

// 执行迁移
if err := goose.Up(db, "migrations"); err != nil {
    log.Fatal(err)
}

5. 监控和告警

在生产环境中,迁移的执行情况应该被监控:

package main

import (
	"database/sql"
	"fmt"
	"log"
	"time"

	"github.com/pressly/goose/v3"
	_ "github.com/lib/pq"
)

type MigrationLogger struct {
	startTime time.Time
}

func (l *MigrationLogger) Before(version int64) {
	l.startTime = time.Now()
	log.Printf("[migration] starting version %d", version)
}

func (l *MigrationLogger) After(version int64, err error) {
	duration := time.Since(l.startTime)
	if err != nil {
		log.Printf("[migration] FAILED version %d after %v: %v", version, duration, err)
		// 发送告警通知
		sendAlert(fmt.Sprintf("Migration v%d failed: %v", version, err))
	} else {
		log.Printf("[migration] completed version %d in %v", version, duration)
	}
}

func sendAlert(message string) {
	// 集成你的告警系统:PagerDuty、Slack、邮件等
	log.Printf("[ALERT] %s", message)
}

常见的迁移陷阱和解决方案

陷阱一:在迁移中使用 NOW()

-- ❌ 不好的做法:每次执行时间不同
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ DEFAULT NOW();

-- ✅ 好的做法:只在代码中设置默认值
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMPTZ;

在迁移中使用 NOW() 会导致测试数据不可预测,而且不同环境的执行时间不同。

陷阱二:忘记处理 NULL 值

-- ❌ 直接添加 NOT NULL 列会导致错误(如果表中已有数据)
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL;

-- ✅ 先添加允许 NULL 的列,填充数据后再改为 NOT NULL
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
UPDATE users SET phone = 'unknown' WHERE phone IS NULL;
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

陷阱三:大表上直接修改列类型

-- ❌ 大表上直接修改会锁表
ALTER TABLE logs ALTER COLUMN payload TYPE JSONB USING payload::jsonb;

-- ✅ 添加新列,分批迁移数据,最后切换
-- 迁移 1: 添加新列
ALTER TABLE logs ADD COLUMN payload_json JSONB;

-- 迁移 2: 分批迁移数据(Go 迁移文件)
-- 迁移 3: 切换列名(需要停机窗口或使用视图)

陷阱四:外键导致的死锁

-- ❌ 在有并发写入的表上添加外键可能会长时间锁表
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id);

-- ✅ 使用 NOT VALID 延迟验证
-- 迁移 1: 添加不验证的外键
ALTER TABLE orders ADD CONSTRAINT fk_orders_user
    FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;

-- 迁移 2: 验证外键(这个操作不会阻塞写入)
ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user;

陷阱五:索引命名冲突

-- ❌ 不指定索引名,数据库会自动生成不可预测的名字
CREATE INDEX ON users(email);

-- ✅ 始终使用有意义的索引名
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_orders_user_id_created_at ON orders(user_id, created_at);

多数据库支持

在实际项目中,你可能需要同时支持多种数据库。比如开发环境用 SQLite,生产环境用 PostgreSQL。

使用 goose 的条件方言

goose 支持在 SQL 文件中使用方言条件:

-- +goose Up

-- +goose NO TRANSACTION

-- PostgreSQL 专用语法
-- +goose postgres
CREATE INDEX CONCURRENTLY idx_users_search
ON users USING gin(to_tsvector('english', username));

-- MySQL 专用语法
-- +goose mysql
ALTER TABLE users ADD FULLTEXT INDEX idx_users_search (username);

-- SQLite 不需要特殊处理

使用 Go 迁移文件处理多数据库

package migrations

import (
	"context"
	"database/sql"

	"github.com/pressly/goose/v3"
)

func init() {
	goose.AddMigrationNoTxContext(upCreateSearchIndex, downCreateSearchIndex)
}

func upCreateSearchIndex(ctx context.Context, db *sql.DB) error {
	// 检测数据库类型
	driver := goose.GetDriver()

	switch driver {
	case "postgres":
		_, err := db.ExecContext(ctx, `
			CREATE INDEX CONCURRENTLY idx_users_search
			ON users USING gin(to_tsvector('english', username || ' ' || COALESCE(email, '')))
		`)
		return err
	case "mysql":
		_, err := db.ExecContext(ctx, `
			ALTER TABLE users ADD FULLTEXT INDEX idx_users_search (username, email)
		`)
		return err
	case "sqlite3":
		// SQLite 不支持全文索引,使用 FTS5 扩展
		_, err := db.ExecContext(ctx, `
			CREATE VIRTUAL TABLE IF NOT EXISTS users_search
			USING fts5(username, email, content='users', content_rowid='id')
		`)
		return err
	default:
		return nil
	}
}

func downCreateSearchIndex(ctx context.Context, db *sql.DB) error {
	driver := goose.GetDriver()
	switch driver {
	case "postgres":
		_, err := db.ExecContext(ctx, `DROP INDEX IF EXISTS idx_users_search`)
		return err
	case "mysql":
		_, err := db.ExecContext(ctx, `DROP INDEX idx_users_search ON users`)
		return err
	case "sqlite3":
		_, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS users_search`)
		return err
	}
	return nil
}

抽象 SQL 方言

更优雅的方式是创建一个方言抽象层:

package dialect

import "fmt"

type Dialect interface {
	CreateTableIfNotExists(name string) string
	AddColumnIfNotExists(table, column, colType string) string
	CreateIndexIfNotExists(name, table string, columns ...string) string
	DropIndexIfExists(name string) string
	Now() string
}

type PostgresDialect struct{}

func (d PostgresDialect) CreateTableIfNotExists(name string) string {
	return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s", name)
}

func (d PostgresDialect) AddColumnIfNotExists(table, column, colType string) string {
	return fmt.Sprintf("ALTER TABLE %s ADD COLUMN IF NOT EXISTS %s %s", table, column, colType)
}

func (d PostgresDialect) CreateIndexIfNotExists(name, table string, columns ...string) string {
	cols := ""
	for i, c := range columns {
		if i > 0 {
			cols += ", "
		}
		cols += c
	}
	return fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s(%s)", name, table, cols)
}

func (d PostgresDialect) DropIndexIfExists(name string) string {
	return fmt.Sprintf("DROP INDEX IF EXISTS %s", name)
}

func (d PostgresDialect) Now() string {
	return "NOW()"
}

type MySQLDialect struct{}

func (d MySQLDialect) CreateTableIfNotExists(name string) string {
	return fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s", name)
}

func (d MySQLDialect) AddColumnIfNotExists(table, column, colType string) string {
	// MySQL 没有原生的 ADD COLUMN IF NOT EXISTS(8.0 之前),需要检查 information_schema
	return fmt.Sprintf(`
		SET @exist := (SELECT COUNT(*) FROM information_schema.columns
			WHERE table_name = '%s' AND column_name = '%s');
		SET @sql := IF(@exist = 0, 'ALTER TABLE %s ADD COLUMN %s %s', 'SELECT 1');
		PREPARE stmt FROM @sql;
		EXECUTE stmt;
	`, table, column, table, column, colType)
}

func (d MySQLDialect) CreateIndexIfNotExists(name, table string, columns ...string) string {
	cols := ""
	for i, c := range columns {
		if i > 0 {
			cols += ", "
		}
		cols += c
	}
	return fmt.Sprintf("CREATE INDEX %s ON %s(%s)", name, table, cols)
}

func (d MySQLDialect) DropIndexIfExists(name string) string {
	return fmt.Sprintf("DROP INDEX %s", name)
}

func (d MySQLDialect) Now() string {
	return "NOW()"
}

// NewDialect 根据驱动名称返回对应的方言实现
func NewDialect(driver string) Dialect {
	switch driver {
	case "postgres", "pgx":
		return PostgresDialect{}
	case "mysql":
		return MySQLDialect{}
	default:
		return PostgresDialect{}
	}
}

迁移工具对比:goose vs golang-migrate

特性goosegolang-migrate
版本格式时间戳(20250725083000)递增数字(000001)
文件格式SQL + Go仅 SQL(Go 需自定义 source)
Up/Down 分离同一文件中分区两个独立文件
嵌入支持embed.FSiofs + embed.FS
事务控制可选择 NoTx默认事务
数据库支持PostgreSQL, MySQL, SQLite, SQL Server, ClickHouse, VerticaPostgreSQL, MySQL, SQLite, SQL Server, Cassandra, CockroachDB, ClickHouse, Firebird, MongoDB, Neo4j, Redshift, Spanner
社区活跃度高(Pressly 维护)中高(社区维护)
学习曲线
Dry Run需自行实现部分支持
Lock 机制内置 Advisory Lock依赖数据库锁
适用场景中小项目、需要 Go 迁移多数据库、需要丰富的数据库支持

如何选择?

选择 goose 的场景:

  • 你的项目只需要支持一种或少数几种数据库
  • 你需要在迁移中执行复杂的 Go 逻辑(数据迁移、API 调用等)
  • 你更喜欢时间戳版本号(多人协作时不容易冲突)
  • 你想要更简洁的工具链

选择 golang-migrate 的场景:

  • 你的项目需要支持很多种不同的数据库
  • 你更喜欢 Up 和 Down 文件分开的组织方式
  • 你的团队已经在使用 golang-migrate
  • 你需要 Cassandra、MongoDB 等非关系型数据库的迁移支持

第三选择:Atlas

如果你需要更强大的功能,Atlas 是一个值得关注的工具。它支持声明式(declarative)迁移——你只需要定义目标 schema,Atlas 会自动计算差异并生成迁移文件。这对于复杂的项目来说可以大大减少手动编写迁移的工作量。

# 安装 Atlas
curl -sSf https://atlasgo.sh | sh

# 检查当前 schema 和期望 schema 的差异
atlas schema diff \
  --from "postgres://localhost:5432/myapp" \
  --to "file://schema.sql"

总结

数据库迁移是现代软件开发中不可或缺的一环。通过这篇文章,我们从以下几个维度深入探讨了 Go 生态中的数据库迁移:

  1. 基础概念:理解了什么是迁移、为什么需要迁移
  2. 工具实战:掌握了 goose 和 golang-migrate 两大主流工具的使用
  3. 编写规范:学习了迁移文件的命名、幂等性、向前兼容等最佳实践
  4. 数据迁移:了解了大批量数据迁移的分批处理策略
  5. 测试策略:从单元测试到 testcontainers 集成测试
  6. CI/CD 集成:将迁移融入自动化流水线
  7. 生产实践:备份、Dry Run、并发控制、监控告警
  8. 避坑指南:识别并避开常见的迁移陷阱
  9. 多数据库:优雅地支持多种数据库后端

最后,分享一条最重要的原则:迁移应该是无聊的。如果你的迁移过程每次都让人心跳加速,那说明你的流程还有改进空间。好的迁移实践应该像日常喝水一样平淡无奇——执行、验证、继续工作。

祝你的每次迁移都顺利无阻!

继续阅读

探索更多技术文章

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

全部文章 返回首页