《Go 语言编程入门》14.3 schema 迁移与 sqlc

表结构不能靠手工执行 SQL,本节讲 schema 迁移:用带版本号的 SQL 文件记录变更、用 embed 把迁移编进二进制、按版本顺序在事务中逐条应用。随后介绍 sqlc——从 SQL 语句自动生成类型安全的 Go 代码的工具,说明它生成的代码形态与适用场景,并如实标注本机未安装、示例未经实测。

14.3 schema 迁移与 sqlc

上一节写完了仓储层,但有个前提被我们悄悄跳过了:tasks 表是谁建的?开发时你可能手动敲一条 CREATE TABLE,可线上环境、测试环境、同事的机器怎么办?靠人肉同步表结构,迟早出事。本节讲两件让数据库结构「可版本化、可复现」的工具。

本节把 TaskAPI 推进到:用带版本号的迁移文件管理 schema 变更,并把它们用 embed 编进二进制、按序应用;随后引入 sqlc 这一代码生成思路,把 SQL 变成类型安全的 Go 方法。

14.3.1 手工建表的三个坑

先把问题摆清楚。假设你在本地手敲:

CREATE TABLE tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT FALSE
);

这在单机上没问题,一到多人协作就暴露三个坑:

  1. 不可复现——新同事拉下代码,不知道要执行哪些建表语句。
  2. 不可追溯——三个月后没人记得某列是什么时候、为什么加的。
  3. 不可回滚——上线出问题时,没有「撤销这次变更」的脚本。

迁移(migration)就是把「每一次 schema 变更」变成一个有版本号、可顺序执行、可追溯的文件。

14.3.2 迁移文件的命名约定

最通用的约定是 <版本号>_<描述>.sql:

migrations/
  0001_create_tasks.sql
  0002_add_priority.sql

0001_create_tasks.sql:

CREATE TABLE tasks (
  id BIGSERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  done BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

0002_add_priority.sql:

ALTER TABLE tasks ADD COLUMN priority INT NOT NULL DEFAULT 0;

版本号用零填充的定宽数字(0001 而非 1),这样字符串排序与数字排序结果一致,避免 10 排在 2 前面这种事故。迁移一旦发布就不许改——要修就追加一个新版本,因为别人的库里 0002 已经执行过了,你改内容它不会重跑。

14.3.3 用 embed 把迁移编进二进制

迁移文件不能只躺在开发者的硬盘上,得跟着程序走。Go 1.16 起的 //go:embed 能把整个目录塞进二进制:

import "embed"

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

这样编译出来的 TaskAPI 单文件里就带着全部迁移,部署时不需要额外拷贝 .sql 文件,也不会出现「程序更新了但迁移文件忘了同步」。这是 Go 部署体验的一大优势——一个二进制就是全部。

14.3.4 迁移加载器:发现 + 排序

从 embed.FS 里读出所有迁移,按版本号排序:

package main

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

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

type migration struct {
	Version int
	Name    string
	SQL     string
}

func loadMigrations(fsys fs.FS) ([]migration, error) {
	entries, err := fs.ReadDir(fsys, "migrations")
	if err != nil {
		return nil, err
	}
	var out []migration
	for _, e := range entries {
		if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
			continue
		}
		base := strings.TrimSuffix(e.Name(), ".sql")
		idx := strings.IndexByte(base, '_')
		if idx <= 0 {
			return nil, fmt.Errorf("migration %q: 文件名需为 <version>_<name>.sql", e.Name())
		}
		ver, err := strconv.Atoi(base[:idx])
		if err != nil {
			return nil, fmt.Errorf("migration %q: 版本号不是数字", e.Name())
		}
		data, err := fs.ReadFile(fsys, "migrations/"+e.Name())
		if err != nil {
			return nil, err
		}
		out = append(out, migration{Version: ver, Name: base[idx+1:], SQL: string(data)})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
	return out, nil
}

实测输出(本机真实运行,只有加载与排序这一步,不连数据库):

loaded migrations: 2
  v0001 create_tasks   CREATE TABLE tasks (
  v0002 add_priority   ALTER TABLE tasks ADD COLUMN priority INT NOT NULL DEFAULT 0;
applyOne 需真实数据库;本机未连接

加载器把文件名解析成「版本 + 名字 + SQL 内容」三元组,并强制校验命名格式。这一步是纯文件操作,不需要数据库,所以能在本机完整实测。

14.3.5 在事务中应用迁移

真正「执行」迁移要连数据库。核心逻辑是:查已应用的版本、跳过它们、对每个未应用的迁移在事务里执行 SQL 并记录版本:

func applyOne(db *sql.DB, m migration) error {
	tx, err := db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	if _, err := tx.Exec(m.SQL); err != nil {
		return fmt.Errorf("apply %04d_%s: %w", m.Version, m.Name, err)
	}
	if _, err := tx.Exec(
		"INSERT INTO schema_migrations(version, name) VALUES(?, ?)",
		m.Version, m.Name); err != nil {
		return err
	}
	return tx.Commit()
}

schema_migrations 是一张记录「哪些迁移已经跑过」的元数据表,通常由工具自己建:

CREATE TABLE schema_migrations (
  version INT PRIMARY KEY,
  name    TEXT NOT NULL,
  applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

启动时的完整流程是:确保 schema_migrations 存在 → 读已应用版本集合 → 对每个未应用的迁移调 applyOne → 记录日志。把迁移和「记录版本」放进同一个事务,才能保证「要么 SQL 和版本记录都成功,要么都不做」,不会出现「SQL 跑了但版本没记,下次重复执行」的灾难。

需要提醒的是:并非所有数据库都支持事务化 DDL(例如某些数据库的 ALTER TABLE 会自动提交),此时迁移的原子性由数据库保证程度决定,工具的「回滚」能力也会打折。这一点在选数据库时要留意。

14.3.6 现成的迁移工具

自己写加载器适合学习,生产里更常用成熟工具。两个主流选择:

工具特点迁移文件位置
golang-migrate支持众多数据库,CLI + 库两种用法migrations/
goose支持 Go 函数形式的迁移,嵌入友好migrations/

它们的共同点是:迁移文件格式和本节讲的一致(<版本>_<名字>.sql),都维护自己的版本表,都支持 up/down。down(回滚)脚本通常写成 0002_add_priority.down.sql,内容是 ALTER TABLE tasks DROP COLUMN priority;。

这些工具不在卷一范围内——本卷坚持只用标准库,所以本节不引入它们,只讲清原理。理解了原理,将来用任何工具都能一眼看懂它在做什么。

14.3.7 sqlc:把 SQL 变成类型安全的 Go

迁移解决了「表怎么建」,还有一个长期痛点:SQL 字符串与 Go 结构体是脱节的。你写 SELECT id, title, done FROM tasks,然后手工 Scan(&t.ID, &t.Title, &t.Done),一旦列名改了、少写一个字段,编译期毫无察觉,只有运行时才炸。

sqlc 的思路是:你写 SQL,它生成对应的 Go 代码。工作流是:

schema.sql + query.sql  ──sqlc generate──▶  db.go + models.go + query.sql.go

你在一个 sqlc.yaml 里配置输入输出:

version: "2"
sql:
  - engine: "postgresql"
    schema: "migrations/"
    queries: "internal/store/queries.sql"
    gen:
      go:
        package: "store"
        out: "internal/store"

然后 queries.sql 里写带名字的查询:

-- name: GetTask :one
SELECT id, title, done FROM tasks WHERE id = $1;

-- name: ListTasks :many
SELECT id, title, done FROM tasks ORDER BY id;

-- name: CreateTask :one
INSERT INTO tasks (title, done) VALUES ($1, $2) RETURNING id, title, done;

sqlc generate 之后,你会得到类似这样的 Go 代码:

type Task struct {
	ID    int64
	Title string
	Done  bool
}

func (q *Queries) GetTask(ctx context.Context, id int64) (Task, error) {
	row := q.db.QueryRowContext(ctx, getTask, id)
	var i Task
	err := row.Scan(&i.ID, &i.Title, &i.Done)
	return i, err
}

func (q *Queries) ListTasks(ctx context.Context) ([]Task, error) { /* ... */ }

注意几个特点:Task 结构体由 sqlc 依据 schema 自动生成,字段类型与列类型严格对应;每个方法名、参数、返回值都从你写的 SQL 推导出来;Scan 的目标顺序与 SELECT 列顺序自动对齐——你再也不可能写错 Scan。这就是「类型安全」的含义:列改名后重新生成,编译期就能发现所有受影响的地方。

14.3.8 sqlc 的适用边界

sqlc 不是银弹,用一张表说明它适合谁:

场景适合 sqlc更适合手写
SQL 复杂、要精细控制✅ 你写原生 SQL—
想要编译期列名检查✅ 生成代码强绑定—
需要动态拼接 WHERE—✅ sqlc 不支持动态 SQL
团队不想学代码生成—✅ 直接 database/sql
用到数据库特有语法✅ 原样写在 SQL 里—

一句话:sqlc 让你继续写 SQL,但把「SQL 与 Go 的映射」自动化了。它和 ORM 的哲学相反——ORM 让你不写 SQL,sqlc 让你必须写好 SQL,只是省掉了样板代码。

诚实声明:本节示例需先安装 sqlc(go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest),本机未安装,因此 14.3.7 的生成结果未经实测。上面展示的生成代码形态来自 sqlc 的公开文档与通用用法,用于说明「生成什么」;具体字段名、方法签名会随版本和配置略有差异。而 14.3.4 的迁移加载器是本机真实跑过的,输出如上所示。

14.3.9 小结

  • 手工建表不可复现、不可追溯、不可回滚,迁移把 schema 变更变成有版本的文件。
  • 命名用 <版本>_<描述>.sql,版本号零填充定宽;已发布的迁移不许改,只许追加。
  • //go:embed migrations/*.sql 把迁移编进二进制,部署只带一个文件。
  • 加载器解析文件名 → 排序 → 在事务里逐条应用,并写入 schema_migrations 版本表。
  • 事务化 DDL 不是所有数据库都支持,迁移原子性受此限制。
  • golang-migrate / goose 是成熟的现成工具,卷一不引入,但原理与本节一致。
  • sqlc 从 SQL 生成类型安全的 Go 代码,杜绝 Scan 错位;不支持动态 SQL 是它的边界。

第 14 章到此结束,TaskAPI 有了持久化能力。但它的「装配」还散落在各处:数据库 DSN 写死在代码里、Repo 在哪 new、配置从哪来。下一章解决这些问题——配置、依赖注入与统一错误响应。

阅读导航:上一节:14.2 SQL 增删改查与事务 · 下一节:15.1 配置加载(flag/env/文件) 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练