登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

在二进制中嵌入迁移文件并按版本顺序执行

来源:17golang原创

时间:2026-10-08 21:38:30 293浏览 收藏

最稳妥的做法是:用 //go:embed migrations/*.sql 把迁移文件编译进 embed.FS,启动时解析文件名中的版本号并显式排序,只执行 schema_migrations 中尚未登记的版本。每次迁移都应把“执行 SQL”和“写入已应用版本”放在同一个 sql.Tx 中,成功后再提交。

要点速览
  • 文件名使用固定宽度版本,例如 0001_init.sql,不要修改已经发布的迁移。
  • fs.Glob 返回匹配文件,但执行器仍应显式排序并校验版本,避免把命名习惯当成唯一保障。
  • schema_migrations 是数据库端的事实来源,二进制中的文件只是可用迁移集合。
  • 多语句 SQL、DDL 事务和并发部署都与数据库及驱动相关,生产环境要明确边界。

先把迁移文件变成可排序的内置资源

目录可以保持简单:migrations/0001_init.sql、migrations/0002_add_email.sql。四位数字让字典序与版本序一致,后面的名称用于阅读和审计。Go 的 embed 会在编译期把匹配文件放进二进制,因此部署时不需要再携带一个易丢失的 SQL 目录。

migrations 目录、SQL 文件、go embed、embed.FS、版本解析器与排序后迁移清单的静态结构图
图1:迁移资源与版本索引的静态结构图,不是运行截图。
package migrate

import (
	"embed"
	"fmt"
	"io/fs"
	"path"
	"sort"
	"strconv"
	"strings"
)

// 编译期把 migrations 目录中的 SQL 文件写入二进制。
//go:embed migrations/*.sql
var migrationFS embed.FS

type Migration struct {
	Version int
	Name    string
	Path    string
}

func loadMigrations() ([]Migration, error) {
	// Glob 面向内置文件系统读取,不依赖部署机器上的工作目录。
	files, err := fs.Glob(migrationFS, "migrations/*.sql")
	if err != nil {
		return nil, fmt.Errorf("glob migrations: %w", err)
	}
	// 明确排序,使执行顺序在代码层可见。
	sort.Strings(files)

	items := make([]Migration, 0, len(files))
	seen := make(map[int]string, len(files))
	for _, file := range files {
		base := strings.TrimSuffix(path.Base(file), ".sql")
		parts := strings.SplitN(base, "_", 2)
		if len(parts) != 2 {
			return nil, fmt.Errorf("invalid migration name: %s", file)
		}
		version, err := strconv.Atoi(parts[0])
		if err != nil || version 

这段解析还做了两件容易被忽略的事:拒绝没有描述名的文件,并拒绝重复版本。即使两份文件的名称不同,只要版本相同,执行顺序就存在歧义,应在连接数据库前直接失败。

用版本表判断哪些迁移还没执行

版本表至少保存版本号、可读名称和应用时间。版本号做主键,可以阻止同一个数据库出现两条相同版本记录,但它不能单独解决两个实例同时执行 DDL 的竞态。

CREATE TABLE IF NOT EXISTS schema_migrations (
    version BIGINT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

启动时先查询每个版本是否存在。只有不存在时才打开事务;不要先执行结构变更,随后在另一个连接里补写版本记录,否则中途失败会让数据库结构和版本表失去一致性。

让结构变更和版本登记共享事务边界

迁移文件、版本号、sql.Tx、业务表结构和 schema_migrations 的静态数据关系图
图2:迁移事务与数据库状态关系图,不是运行结果。
package migrate

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

func Apply(ctx context.Context, db *sql.DB) error {
	items, err := loadMigrations()
	if err != nil {
		return err
	}

	for _, item := range items {
		var exists bool
		// 版本表是数据库端事实来源,已应用版本直接跳过。
		err := db.QueryRowContext(ctx,
			"SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = ?)",
			item.Version,
		).Scan(&exists)
		if err != nil {
			return fmt.Errorf("check migration %d: %w", item.Version, err)
		}
		if exists {
			continue
		}

		body, err := migrationFS.ReadFile(item.Path)
		if err != nil {
			return fmt.Errorf("read migration %d: %w", item.Version, err)
		}
		if err := applyOne(ctx, db, item, string(body)); err != nil {
			return err
		}
	}
	return nil
}

func applyOne(ctx context.Context, db *sql.DB, item Migration, statement string) (err error) {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("begin migration %d: %w", item.Version, err)
	}
	// Commit 成功前的任何返回都会尝试回滚。
	defer func() { _ = tx.Rollback() }()

	// 迁移 SQL 和版本登记必须使用同一个 tx。
	if _, err = tx.ExecContext(ctx, statement); err != nil {
		return fmt.Errorf("execute migration %d: %w", item.Version, err)
	}
	if _, err = tx.ExecContext(ctx,
		"INSERT INTO schema_migrations(version, name) VALUES (?, ?)",
		item.Version, item.Name,
	); err != nil {
		return fmt.Errorf("record migration %d: %w", item.Version, err)
	}
	if err = tx.Commit(); err != nil {
		return fmt.Errorf("commit migration %d: %w", item.Version, err)
	}
	return nil
}

示例中的占位符 ? 适用于部分驱动;PostgreSQL 一般使用 $1、$2。实际项目应把版本表 SQL 和占位符收敛到数据库方言层,而不是到处拼接。

三个生产边界必须提前决定

边界风险建议
一个文件多条语句驱动可能禁止一次 Exec 多语句,简单分号切割又会破坏函数体或字符串每个文件保持驱动支持的执行单元,或使用成熟 SQL 解析器/迁移库
DDL 事务部分数据库会对某些 DDL 隐式提交,Rollback 无法恢复按目标数据库确认语义,必要时拆分迁移并设计补偿
并发部署两个实例可能都看到“未执行”,随后同时改表只允许一个部署任务迁移,或使用数据库专用 advisory lock

版本主键只能在插入记录时发现冲突,不能保证之前的 DDL 没有被两个实例同时执行。因此并发控制必须放在迁移循环之外,并覆盖“检查、执行、登记”的完整区间。

迁移文件发布后只追加,不修改

内嵌资源会跟随二进制版本变化。如果修改已经在生产库执行过的 0002_add_email.sql,旧数据库的版本表仍显示 2 已应用,新数据库却会执行修改后的内容,最终形成同一版本号对应两种结构。正确做法是保留旧文件,新增 0003_...。对审计要求高的系统还可以在版本表保存文件哈希,并在启动时核对已应用版本的内容是否被改动。

上线前至少检查:版本号唯一且连续策略明确、文件名能按字典序稳定排序、迁移账号权限足够、失败后能安全重试、备份或回滚方案可用。若需求包括向下迁移、脏状态恢复、多数据库方言和复杂锁管理,直接采用成熟迁移库通常比继续扩展这个小执行器更可靠。

常见问题

为什么已经用四位数字命名,还要 sort.Strings?

固定宽度负责让字典序符合版本序,显式排序负责把执行器的假设写进代码。两者结合后,目录读取方式变化也不容易影响结果。

可以在程序每次启动时自动执行吗?

小型单实例服务可以,但多实例滚动发布更适合由唯一部署任务执行,应用实例只检查版本是否满足要求,避免启动风暴触发并发迁移。

为什么不直接把 SQL 按分号切开?

分号可能出现在字符串、存储过程或触发器定义中,简单切割会产生错误语句。需要多语句解析时应使用了解目标数据库语法的工具。

embed.FS 能在运行时替换迁移文件吗?

不能。内嵌文件在编译时确定,替换内容需要重新构建二进制。这正好让迁移集合与发布制品绑定,但不适合需要动态下发脚本的系统。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>