Go 基础体系 · 第 59/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go sqlc 实战:从 SQL 生成类型安全的数据访问代码
本文以 Go 1.26.4、sqlc v1.30.0、PostgreSQL 17 和 pgx/v5 v5.7.6 为基准。这里的版本是本文采用并固定的稳定版本,不表示永远最新;升级生成器时必须审查生成差异并重新执行数据库集成测试。
sqlc 不在运行时把结构体“翻译”为 SQL。它在构建前读取 schema、带注释的查询和配置,解析 SQL 后生成普通 Go 类型与方法。上线的二进制不需要 sqlc,但仍依赖数据库驱动、连接池、事务与目标数据库的真实语义。类型安全可以提前发现列名、参数和扫描类型错误,却不能证明索引合理、锁不会等待或业务事务正确。
1. 生成链路与责任边界
一次完整链路包含三类输入:迁移后的 schema 描述数据形状,query 文件描述应用允许执行的 SQL,sqlc.yaml 决定引擎、生成包和类型映射。sqlc generate 生成模型、参数结构体、结果结构体、Queries 和 DBTX 接口,Go 编译器再检查调用方。
migrations/*.sql + query/*.sql + sqlc.yaml
│
▼
sqlc generate
│
▼
internal/dbgen/*.sql.go ──► go test / go build
sqlc 能检查 SQL 是否引用存在的表和列,并推导参数、NULL 与返回行数。它不知道线上数据分布、执行计划、权限、触发器副作用和并发冲突。数据库升级、扩展类型或复杂方言也可能让解析器与服务端存在差异,所以“生成成功”只是第一道门。
2. 项目布局与固定工具版本
生成输入和输出应有清楚所有权。生成文件通常提交仓库,使评审能看到类型变化;团队也可以构建时生成,但必须固定工具和检查可重复性。
article-service/
├── db/migrations/000001_create_articles.up.sql
├── db/query/articles.sql
├── internal/dbgen/ # 生成文件,禁止手改
├── sqlc.yaml
├── go.mod
└── tools.go # 或 CI 镜像记录生成器版本
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate
gofmt -w internal/dbgen
go test ./...
git diff --exit-code -- internal/dbgen
不要使用 @latest。生成器升级应单独提交,先记录旧版和新版,再阅读 diff;字段类型、方法签名或 import 变化都可能影响 API。生成结果含 “DO NOT EDIT” 时只能修改输入并重新生成。
3. 先让 schema 表达真实不变量
以下 PostgreSQL schema 使用数据库生成 ID、受限状态、唯一 slug 和稳定分页索引。金额、时区和可空性应在 schema 中先决定,而不是等生成 Go 类型后补救。
CREATE TABLE articles (
article_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
slug text NOT NULL,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
summary text,
status text NOT NULL CHECK (status IN ('draft', 'published', 'archived')),
version bigint NOT NULL DEFAULT 1,
published_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, slug)
);
CREATE INDEX articles_feed_idx
ON articles (tenant_id, published_at DESC, article_id DESC)
WHERE status = 'published';
summary 和 published_at 允许 NULL,因此生成类型必须表达缺失。status 的 CHECK 是最终防线,Go 代码中的枚举只是便利。所有租户查询都必须带 tenant_id,否则类型正确的 SQL 仍会越权。
4. sqlc.yaml 决定生成契约
版本 2 配置显式选择 PostgreSQL、pgx/v5、输入路径和输出包。路径相对配置文件解析,CI 应从固定工作目录运行。
version: "2"
sql:
- engine: "postgresql"
schema: "db/migrations"
queries: "db/query"
gen:
go:
package: "dbgen"
out: "internal/dbgen"
sql_package: "pgx/v5"
emit_json_tags: true
emit_empty_slices: true
emit_interface: false
emit_pointers_for_null_types: true
overrides:
- db_type: "pg_catalog.timestamptz"
go_type:
import: "time"
type: "Time"
emit_pointers_for_null_types 是否合适取决于领域约定。指针简洁,但不能区分“未提供”和“明确设为 NULL”的更新输入;pgtype.Text 等类型更适合需要三态的持久层。覆盖类型会影响所有匹配列,必须用生成 diff 和扫描测试确认。
5. 命名查询与返回基数
查询注释的名字成为 Go 方法,后缀决定返回契约。:one 返回一行,零行通常得到 pgx.ErrNoRows;:many 返回切片;:exec 只返回错误;:execrows 返回影响行数;:execresult 暴露 driver 结果。选择错误的基数不会让业务自动正确。
-- name: CreateArticle :one
INSERT INTO articles (tenant_id, slug, title, summary, status)
VALUES ($1, $2, $3, $4, 'draft')
RETURNING article_id, tenant_id, slug, title, summary, status,
version, published_at, created_at, updated_at;
-- name: GetArticle :one
SELECT article_id, tenant_id, slug, title, summary, status,
version, published_at, created_at, updated_at
FROM articles
WHERE tenant_id = $1 AND article_id = $2;
-- name: DeleteDraft :execrows
DELETE FROM articles
WHERE tenant_id = $1 AND article_id = $2 AND status = 'draft';
列清单应显式写出。SELECT * 会让无关 schema 变化扩散到生成结果,也容易改变扫描顺序。删除返回零行可能表示不存在、租户不匹配或状态不允许,上层要按领域需求决定是否补查询,不能一律报告成功。
6. 参数结构体与生成模型
多个参数会生成 CreateArticleParams 等结构体,单参数查询可能直接接收标量。调用方使用字段名初始化,避免生成字段调整后位置含义漂移。
func (s *Store) Create(ctx context.Context, input CreateInput) (Article, error) {
row, err := s.queries.CreateArticle(ctx, dbgen.CreateArticleParams{
TenantID: input.TenantID,
Slug: input.Slug,
Title: input.Title,
Summary: input.Summary,
})
if err != nil {
return Article{}, fmt.Errorf("create article: %w", err)
}
return mapArticle(row), nil
}
生成模型是数据库契约,不必直接成为 HTTP 或领域模型。分层映射能集中处理 NULL、时区、枚举和敏感字段,也避免 schema 改名破坏公开 JSON。只有简单内部服务且边界完全一致时,直接返回生成类型才可能值得。
7. DBTX、Queries 与连接池生命周期
生成代码通常定义包含 Exec、Query、QueryRow 的 DBTX 接口。pgxpool.Pool、pgx.Conn 和 pgx.Tx 可在相应版本下满足它,dbgen.New(pool) 创建轻量 Queries。长期对象应复用同一个池和 Queries,进程退出时关闭池。
func OpenStore(ctx context.Context, dsn string) (*Store, error) {
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse database config: %w", err)
}
config.MaxConns = 20
config.MinConns = 2
config.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create database pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return &Store{pool: pool, queries: dbgen.New(pool)}, nil
}
创建池不一定验证网络可达,因此启动依赖数据库时要 Ping。池大小按“单实例上限乘实例数”纳入数据库容量,不能用扩大连接数掩盖慢 SQL。Queries 不持有单条连接,通常可并发使用;事务对象则绑定连接,不应由多个 goroutine 任意共享。
8. 事务必须统一走 WithTx
WithTx 返回绑定该事务的新 Queries。事务内若误用原来的 s.queries,语句可能在另一连接自动提交。事务 runner 能集中 Begin、Rollback 和 Commit,同时把生成细节限制在数据层。
func (s *Store) Publish(ctx context.Context, tenantID, articleID int64) error {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
if err != nil {
return fmt.Errorf("begin publish transaction: %w", err)
}
defer func() { _ = tx.Rollback(context.Background()) }()
qtx := s.queries.WithTx(tx)
row, err := qtx.PublishArticle(ctx, dbgen.PublishArticleParams{
TenantID: tenantID,
ArticleID: articleID,
})
if err != nil {
return fmt.Errorf("publish article: %w", err)
}
if err := qtx.InsertOutbox(ctx, dbgen.InsertOutboxParams{
ArticleID: row.ArticleID,
Version: row.Version,
}); err != nil {
return fmt.Errorf("insert publish outbox: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit publish transaction: %w", err)
}
return nil
}
兜底 Rollback 的错误在成功 Commit 后可以忽略;若业务失败路径需要区分回滚失败,应显式合并错误。事务中不要调用 HTTP 或等待无界任务。序列化冲突和死锁应识别 PostgreSQL 错误码后重试整个事务,并受总 deadline 与最大次数限制。
9. Context、取消与结果未知
生成方法的第一个参数是 context.Context,应直接传递请求 context。deadline 可能覆盖等待池连接、发送查询、服务端执行和读取结果;驱动能否及时取消及服务端何时停止仍需真实测试。
func (s *Store) Find(ctx context.Context, tenantID, articleID int64) (Article, error) {
queryCtx, cancel := context.WithTimeout(ctx, 700*time.Millisecond)
defer cancel()
row, err := s.queries.GetArticle(queryCtx, dbgen.GetArticleParams{
TenantID: tenantID,
ArticleID: articleID,
})
switch {
case errors.Is(err, pgx.ErrNoRows):
return Article{}, ErrNotFound
case err != nil:
return Article{}, fmt.Errorf("get article: %w", err)
default:
return mapArticle(row), nil
}
}
不要在数据层用 context.Background() 丢弃取消。提交返回网络错误时结果可能未知:数据库可能已提交但确认包丢失。写操作要用业务唯一键、幂等命令或事后查询判定,不能把 timeout 自动解释成回滚。
10. NULL、数组、JSON 与自定义类型
SQL NULL、空字符串和零时间语义不同。生成指针时读取前检查 nil;使用 pgtype 时检查 Valid。JSONB 默认可能生成 []byte 或 json.RawMessage,应在领域边界验证大小和 schema,不能把任意 JSON 原样传播。
-- name: PatchSummary :one
UPDATE articles
SET summary = CASE
WHEN sqlc.arg(set_summary)::boolean THEN sqlc.narg(summary)::text
ELSE summary
END,
updated_at = now(),
version = version + 1
WHERE tenant_id = sqlc.arg(tenant_id)
AND article_id = sqlc.arg(article_id)
RETURNING article_id, summary, version;
sqlc.arg 产生稳定字段名,sqlc.narg 强制 nullable 参数。额外的 set_summary 表达补丁是否出现,因此能够区分“不修改”与“设置 NULL”。数组要限制元素数量;数据库 enum、自定义域和 UUID 覆盖应在一处配置并写扫描测试。
11. 动态筛选与分页
参数只能代替值,不能代替列名和排序方向。动态排序应映射到有限的预定义查询;筛选组合较少时写多条明确 SQL,比拼接任意条件更易审查。sqlc 的可选参数模式适合有限组合,但 OR $1 IS NULL 可能影响索引选择,必须查看真实执行计划。
-- name: ListPublished :many
SELECT article_id, slug, title, published_at
FROM articles
WHERE tenant_id = $1
AND status = 'published'
AND (published_at, article_id) < ($2, $3)
ORDER BY published_at DESC, article_id DESC
LIMIT $4;
游标使用与排序一致的复合键,避免时间相同导致重复或遗漏。LIMIT 设置服务端上限,调用方不能传无界数量。深 offset 会扫描并丢弃大量行,且并发插入时页面不稳定;后台导出可按主键分段并记录进度。
12. 并发、批量与锁
池和普通 Queries 可供多 goroutine 使用,但一个 pgx.Tx 在业务上应顺序执行。并发写同一文章不能靠 Go 锁保护多实例,应把版本条件写进 SQL。
-- name: RenameArticle :execrows
UPDATE articles
SET title = $1, version = version + 1, updated_at = now()
WHERE tenant_id = $2 AND article_id = $3 AND version = $4;
影响行数为零表示版本冲突或对象不存在,调用方映射为领域冲突。批量插入可用 :copyfrom(受引擎和驱动支持限制),但要限制批大小、错误策略和内存。FOR UPDATE 会持锁到事务结束;锁顺序不一致会死锁,不能因代码是生成的就忽略。
13. 与迁移协作的 expand/contract
部署新 schema 与新二进制时会短暂存在多个应用版本。安全流程通常是先 expand:新增 nullable 列、兼容索引或新表;再部署能兼容新旧结构的应用并回填;最后切换读取,确认旧版本退出后 contract 删除旧列。
若先把列改成 NOT NULL 再部署写入逻辑,旧实例会失败。若先删除列,旧生成查询会立刻报错。CI 应从空库执行全量迁移并生成,再从上一生产版本升级到当前版本跑契约测试。sqlc 使用的 schema 输入必须与迁移顺序一致,不能维护一份无人验证的手写 schema.sql 快照。
14. 错误分类、安全与可观测性
保留 PostgreSQL 结构化错误码,唯一冲突、外键冲突、序列化失败和取消需要不同处理。包装错误时写操作上下文但不泄漏 DSN、token 或用户正文。日志记录查询名称、耗时、返回行数和错误类别,不记录完整参数。
多租户条件必须成为每条查询的审查项;数据库角色遵循最小权限,应用账户不拥有 schema。可进一步使用 PostgreSQL RLS 作为纵深防御,但 session 租户变量必须在事务内可靠设置与清理。慢查询诊断结合应用 trace、池统计、pg_stat_activity、锁等待和 EXPLAIN (ANALYZE, BUFFERS),不要只看平均延迟。
15. 测试与生成漂移检查
纯 Go 单元测试适合映射、错误分类和 service 行为;SQL 正确性必须连接与生产同主版本的 PostgreSQL。测试库每次应用真实迁移,不依赖开发者机器残留 schema。
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 vet
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.30.0 generate
git diff --exit-code -- internal/dbgen
go test ./...
go test -race ./...
集成用例至少覆盖零行、NULL、唯一冲突、版本冲突、事务回滚、context 超时和分页边界。sqlmock 无法验证 PostgreSQL 语法、类型转换和锁语义,只适合检查调用流程。测试失败时保存生成器版本、迁移版本、数据库版本、查询名和数据库日志。
16. 性能与生产部署清单
生成代码本身通常只是薄包装,性能由 SQL、网络往返、扫描量、锁和池等待决定。避免 N+1,使用批量查询;只选择需要的列;为真实过滤与排序建立索引;以 EXPLAIN 和负载测试验证,不根据 ORM 印象猜测。
生产发布前应固定 Go、sqlc、驱动和数据库版本;确认生成目录无差异;迁移先于依赖新结构的应用;为池、语句、事务和关闭设置预算;监控池等待、查询 P95/P99、错误码、锁等待和复制延迟。连接凭据来自秘密管理系统,启用 TLS 并轮换;制品记录提交与迁移版本。出现故障时先判定是池饱和、数据库执行、锁、网络还是扫描,随后才决定限流、回滚应用或前向修复 schema。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go sqlx 使用指南:保留 SQL 控制力并减少扫描样板
- 下一篇:Go XORM 与 Bun:常见 ORM 方案、查询风格和选型比较
- 延伸:Go database/sql 基础:连接池、事务、Context 与 NULL
- 延伸:Go 数据库迁移:golang-migrate、Atlas、回滚与零停机变更
- 延伸:Go 项目工程化:目录、依赖注入、代码生成与质量门禁
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论