Go 数据库迁移:goose、golang-migrate 与最佳实践
你有没有经历过这样的噩梦:凌晨两点上线新版本,代码部署成功了,结果打开页面一片空白——因为数据库少了一个字段。或者更惨,回滚代码后发现数据库结构已经被改得面目全非,数据也丢了。又或者团队协作时,同事 A 加了一个索引,同事 B 删了一张表,合并代码的时候数据库结构已经乱成一锅粥。
如果你经历过其中任何一个场景,那你一定会 appreciate 数据库迁移(Database Migration)这个概念。它不是什么新鲜事物,但在很多 Go 项目中却常常被忽视。今天我们就来深入聊聊,如何在 Go 项目中优雅地管理数据库变更。
什么是数据库迁移?
在软件开发中,数据库迁移是指对数据库结构(schema)和数据(data)进行版本化管理的机制。简单来说,就像我们用 Git 管理代码变更一样,数据库迁移工具帮我们管理数据库的每一次变更。
为什么需要数据库迁移?
让我先讲一个真实的故事。某初创团队的数据库变更流程是这样的:
- 开发者 A 在本地手动执行了一条
ALTER TABLE语句 - 他把这条语句记在了 Slack 的一个频道里
- 部署的时候,运维人员需要去 Slack 里翻找这些 SQL 语句
- 有一次 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' 这个编译标签,你需要根据实际使用的数据库来指定。支持的标签包括 postgres、mysql、sqlite3、sqlserver 等。
创建迁移文件
golang-migrate 使用数字递增的版本号(而不是时间戳),并且要求 Up 和 Down 分别在两个文件中:
migrate create -ext sql -dir migrations -seq create_users_table
这会生成两个文件:
migrations/000001_create_users_table.up.sqlmigrations/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 中,一个常见的问题是先迁移数据库还是先部署代码。黄金法则是:
- 先迁移数据库,后部署代码(推荐):确保新代码运行时数据库已经准备好
- 迁移必须是向后兼容的:旧代码也能在新结构上正常运行
时间线:
[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
| 特性 | goose | golang-migrate |
|---|---|---|
| 版本格式 | 时间戳(20250725083000) | 递增数字(000001) |
| 文件格式 | SQL + Go | 仅 SQL(Go 需自定义 source) |
| Up/Down 分离 | 同一文件中分区 | 两个独立文件 |
| 嵌入支持 | embed.FS ✅ | iofs + embed.FS ✅ |
| 事务控制 | 可选择 NoTx | 默认事务 |
| 数据库支持 | PostgreSQL, MySQL, SQLite, SQL Server, ClickHouse, Vertica | PostgreSQL, 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 生态中的数据库迁移:
- 基础概念:理解了什么是迁移、为什么需要迁移
- 工具实战:掌握了 goose 和 golang-migrate 两大主流工具的使用
- 编写规范:学习了迁移文件的命名、幂等性、向前兼容等最佳实践
- 数据迁移:了解了大批量数据迁移的分批处理策略
- 测试策略:从单元测试到 testcontainers 集成测试
- CI/CD 集成:将迁移融入自动化流水线
- 生产实践:备份、Dry Run、并发控制、监控告警
- 避坑指南:识别并避开常见的迁移陷阱
- 多数据库:优雅地支持多种数据库后端
最后,分享一条最重要的原则:迁移应该是无聊的。如果你的迁移过程每次都让人心跳加速,那说明你的流程还有改进空间。好的迁移实践应该像日常喝水一样平淡无奇——执行、验证、继续工作。
祝你的每次迁移都顺利无阻!
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。