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
);
这在单机上没问题,一到多人协作就暴露三个坑:
- 不可复现——新同事拉下代码,不知道要执行哪些建表语句。
- 不可追溯——三个月后没人记得某列是什么时候、为什么加的。
- 不可回滚——上线出问题时,没有「撤销这次变更」的脚本。
迁移(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/文件) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。