跳转到正文
zeno's blog
返回

Go 数据库:sqlc 的 SQL-first 类型安全方案

Table of contents

Open Table of contents

TL;DR

sqlc 是一个编译器:输入 schema.sql(DDL)+ query.sql(带注解的 DML),输出类型安全的 Go 代码。不是 ORM,不是 query builder,是 SQL-first 的代码生成。


为什么用 sqlc,不用 ORM / query builder

维度ORM (GORM)Query Builder (squirrel)sqlc
SQL 控制力低,框架生成 SQL中,Go 代码拼 SQL完全控制,你写原始 SQL
类型安全运行时才知道字段对不对运行时编译时,字段不存在直接报错
学习曲线学框架 DSL学 builder API学 SQL 本身
性能可预测性低,N+1、隐式 JOIN,SQL 即所见即所得
schema 变更感知无,运行时 panic编译时报错

不用 sqlc 会怎样:你手写 rows.Scan(&a, &b, &c) 的参数数量、类型、顺序全靠人肉保证。加一列要改 N 处代码,漏了编译不报错,运行时才炸。

核心概念:三个文件的分工

schema.sql   → DDL:CREATE TABLE / INDEX / ENUM(数据库结构)
query.sql    → DML + 注解:SELECT / INSERT / UPDATE / DELETE(业务查询)
sqlc.yaml    → 配置:引擎、输入输出路径、代码生成选项

sqlc 读入前两者,按第三者的配置,生成 .go 文件。生成的代码永远不要手动编辑,再次 sqlc generate 会覆盖。

sqlc.yaml 配置

最小可用配置:

version: "2"
sql:
  - engine: "postgresql"
    queries: "query.sql" # 可以是目录:"queries/"
    schema: "schema.sql" # 可以是目录:"migrations/"
    gen:
      go:
        package: "db" # 生成代码的 Go package 名
        out: "internal/db" # 输出目录
        sql_package: "pgx/v5" # 底层驱动:pgx/v5 | pgx/v4 | database/sql

生产环境常用选项:

version: "2"
sql:
  - engine: "postgresql"
    queries: "queries/"
    schema: "migrations/"
    gen:
      go:
        package: "db"
        out: "internal/db"
        sql_package: "pgx/v5"
        emit_json_tags: true # struct 加 json tag
        emit_empty_slices: true # :many 返回空 slice 而非 nil
        emit_interface: true # 生成 Querier interface(方便 mock)
        query_parameter_limit: 3 # 超过 N 个参数自动生成 Params struct
        overrides:
          - db_type: "timestamptz"
            go_type: "time.Time"
          - db_type: "uuid"
            go_type:
              import: "github.com/google/uuid"
              type: "UUID"

关键配置说明:

配置项作用建议
sql_package底层 SQL 驱动新项目用 pgx/v5,性能好、功能全
emit_interface生成 Querier interface开启,方便单元测试 mock
emit_empty_slices:many 返回 []T{} 而非 nil开启,避免 JSON 序列化出 null
query_parameter_limit参数超过 N 个自动打包成 struct设 1-3,减少函数签名长度
overrides数据库类型 → Go 类型映射uuidtimestamptzjsonb 必配

Schema 怎么写

Schema 就是标准的 PostgreSQL DDL。sqlc 用它来理解你的表结构和类型。

-- schema.sql

-- 枚举类型:sqlc 会生成对应的 Go string 类型
CREATE TYPE user_role AS ENUM ('admin', 'member', 'guest');

CREATE TABLE users (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email       TEXT NOT NULL UNIQUE,
    name        TEXT NOT NULL,
    role        user_role NOT NULL DEFAULT 'member',
    avatar_url  TEXT,                          -- 可空字段 → Go 生成 *string 或 sql.NullString
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE posts (
    id          BIGSERIAL PRIMARY KEY,
    author_id   UUID NOT NULL REFERENCES users(id),
    title       TEXT NOT NULL,
    content     TEXT NOT NULL,
    published   BOOLEAN NOT NULL DEFAULT false,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_created ON posts(created_at DESC);

schema 的核心原则:

  1. 就是标准 SQL DDL — 不需要任何 sqlc 特有语法,你写的就是能直接在 psql 里执行的 SQL
  2. 可空性决定 Go 类型NOT NULL 的列生成值类型(string),可空列生成指针(*string)或 pgtype 类型
  3. 枚举自动映射CREATE TYPE ... AS ENUM 会生成 Go 的 string 常量
  4. 可以用迁移文件schema: "migrations/" 指向目录,sqlc 会按文件名排序解析所有 .sql 文件

Schema 来源:迁移工具兼容

sqlc 不管理迁移,只 schema。你可以用任何迁移工具(golang-migrate、goose、atlas),把迁移目录指给 sqlc:

schema: "migrations/" # sqlc 会解析所有 .sql,提取 DDL

sqlc 能识别 goose / golang-migrate 的 -- +goose Up / -- +migrate Up 标记,只解析 Up 部分。

Query 怎么写

这是 sqlc 的核心。每个查询用注解告诉 sqlc:函数名叫什么、返回什么形状。

注解语法

-- name: <FunctionName> :<return_type>

返回类型一览

注解含义生成的 Go 返回值典型用途
:one返回一行(Model, error)SELECT ... WHERE id = $1
:many返回多行([]Model, error)SELECT ... WHERE ...
:exec不返回数据errorDELETE, UPDATE(不需要返回值)
:execrows返回影响行数(int64, error)需要知道改了几行
:execresult返回 sql.Result(sql.Result, error)需要 LastInsertId
:copyfromCOPY FROM 批量插入特殊批量接口大量数据导入(pgx only)
:batchone批量查询,每组返回一行批量接口pgx batch(pgx only)
:batchmany批量查询,每组返回多行批量接口pgx batch(pgx only)
:batchexec批量执行批量接口批量删除/更新(pgx only)

完整 Query 示例

-- queries/users.sql

-- name: GetUserByID :one
SELECT * FROM users
WHERE id = $1;

-- name: GetUserByEmail :one
SELECT * FROM users
WHERE email = $1;

-- name: ListUsers :many
SELECT * FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;

-- name: CreateUser :one
INSERT INTO users (email, name, role)
VALUES ($1, $2, $3)
RETURNING *;

-- name: UpdateUserName :exec
UPDATE users
SET name = $2, updated_at = now()
WHERE id = $1;

-- name: DeleteUser :exec
DELETE FROM users
WHERE id = $1;

-- name: CountUsers :one
SELECT count(*) FROM users;

参数传递方式

位置参数$1, $2, $3)— 简单查询直接用:

-- name: CreateUser :one
INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *;
-- 生成:func (q *Queries) CreateUser(ctx context.Context, email string, name string) (User, error)

命名参数sqlc.arg()@)— 参数多或语义不明确时用:

-- name: UpdateUserProfile :exec
UPDATE users SET
    name = @name,
    avatar_url = @avatar_url,
    updated_at = now()
WHERE id = @id;
-- 生成 UpdateUserProfileParams struct,字段名 = 你命名的名字

sqlc.arg('name')@name 等价,@ 更简洁(但 MySQL 不支持 @ 语法)。

可空参数sqlc.narg())— 强制参数为 nullable:

-- name: UpdateUserOptional :one
UPDATE users SET
    name = coalesce(sqlc.narg('name'), name),        -- 传 nil 则保持原值
    avatar_url = coalesce(sqlc.narg('avatar_url'), avatar_url)
WHERE id = @id
RETURNING *;
-- 生成的 Params 中 Name 是 *string,AvatarUrl 是 *string

JOIN 查询与 sqlc.embed

普通 JOIN 返回平铺的 Row struct:

-- name: GetPostWithAuthor :many
SELECT p.*, u.name as author_name, u.email as author_email
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.published = true
ORDER BY p.created_at DESC;
-- 生成 GetPostWithAuthorRow struct,包含 Post 的所有字段 + AuthorName + AuthorEmail

sqlc.embed 复用已有 model struct:

-- name: GetPostWithAuthorEmbed :many
SELECT sqlc.embed(posts), sqlc.embed(users)
FROM posts
JOIN users ON users.id = posts.author_id
WHERE posts.published = true;
// 生成的返回类型
type GetPostWithAuthorEmbedRow struct {
    Post Post    // 复用已生成的 Post struct
    User User    // 复用已生成的 User struct
}

sqlc.embed 的价值:避免 JOIN 查询各自生成大量重复字段的 Row struct,直接嵌入已有 model。

IN 查询与 sqlc.slice

pgx 原生支持传 slice,直接写 ANY

-- name: GetUsersByIDs :many
SELECT * FROM users
WHERE id = ANY($1::uuid[]);

如果用 database/sql 驱动(不支持 slice 参数),需要 sqlc.slice

-- name: GetUsersByIDs :many
SELECT * FROM users
WHERE id IN (sqlc.slice('ids'));
-- sqlc 在运行时展开为正确数量的 $1, $2, $3...

批量插入::copyfrom

大量数据插入时,COPY FROM 比逐条 INSERT 快一个数量级:

-- name: BulkCreatePosts :copyfrom
INSERT INTO posts (author_id, title, content, published)
VALUES ($1, $2, $3, $4);
// 生成的接口
func (q *Queries) BulkCreatePosts(ctx context.Context, arg []BulkCreatePostsParams) (int64, error)

生成的代码结构

运行 sqlc generate 后,输出目录包含:

internal/db/
├── db.go         # New() 构造函数、DBTX interface、Queries struct
├── models.go     # 表 → Go struct 映射
├── users.sql.go  # users 相关查询的 Go 方法
├── posts.sql.go  # posts 相关查询的 Go 方法
└── querier.go    # Querier interface(如果开启 emit_interface)

使用方式:

package main

import (
    "context"
    "github.com/jackc/pgx/v5/pgxpool"
    "yourproject/internal/db"
)

func main() {
    ctx := context.Background()
    pool, _ := pgxpool.New(ctx, "postgres://localhost:5432/mydb")
    defer pool.Close()

    queries := db.New(pool)

    // 类型安全的调用
    user, err := queries.CreateUser(ctx, db.CreateUserParams{
        Email: "alice@example.com",
        Name:  "Alice",
        Role:  db.UserRoleMember,
    })

    users, err := queries.ListUsers(ctx, db.ListUsersParams{
        Limit:  20,
        Offset: 0,
    })
}

事务支持

sqlc 生成的 New() 接受 DBTX interface,*pgxpool.Poolpgx.Tx 都实现了它:

func transferFunds(ctx context.Context, pool *pgxpool.Pool, from, to uuid.UUID, amount int64) error {
    tx, err := pool.Begin(ctx)
    if err != nil {
        return err
    }
    defer tx.Rollback(ctx)

    qtx := db.New(tx)    // 用事务创建 Queries
    // qtx 的所有操作都在同一个事务里
    qtx.DeductBalance(ctx, ...)
    qtx.AddBalance(ctx, ...)

    return tx.Commit(ctx)
}

文件组织最佳实践

db/
├── migrations/           # DDL 迁移文件(goose / golang-migrate)
│   ├── 001_users.sql
│   ├── 002_posts.sql
│   └── 003_add_index.sql
├── queries/              # 按领域拆分 query 文件
│   ├── users.sql
│   ├── posts.sql
│   └── comments.sql
├── sqlc.yaml
└── internal/db/          # 生成的代码(不要手动编辑)
    ├── db.go
    ├── models.go
    ├── users.sql.go
    ├── posts.sql.go
    └── querier.go

拆分原则:

Pitfalls

1. SELECT * 的隐患

SELECT * 在 sqlc 里是安全的(编译时检查列),但加列后所有用 SELECT * 的查询都会重新生成 struct。如果你只需要部分字段,显式列出列名可以避免不必要的代码变更:

-- 明确列出需要的字段,减少变更影响面
-- name: ListUserNames :many
SELECT id, name FROM users ORDER BY name;

2. 可空列的类型陷阱

可空列默认生成 pgtype.Text(pgx)或 sql.NullString(database/sql),使用起来很啰嗦。用 overridesemit_pointers_for_null_types 转成指针:

gen:
  go:
    emit_pointers_for_null_types: true # 可空列 → *string 而非 pgtype.Text

或者在 schema 设计阶段尽量 NOT NULL DEFAULT '',从根源避免。

3. sqlc.slice 不能用于预编译语句

sqlc.slice 在运行时动态生成 placeholder,所以不兼容 pgx 的 prepared statement 模式。如果用 pgx,优先用 ANY($1::type[]) 语法。

4. 迁移文件顺序错误导致编译失败

sqlc 按文件名排序解析 schema。如果 002_posts.sql 引用了 users 表但 001_users.sql 不存在,sqlc 会报错。迁移文件命名必须保证依赖顺序

5. 忘记重新 generate

改了 schema 或 query 后忘记跑 sqlc generate,Go 代码和 SQL 不同步。解决方案:

# CI 中检查生成代码是否最新
sqlc diff
# 如果有差异,exit code 非零,CI 失败

6. 批量操作的驱动限制

:copyfrom:batchone:batchmany:batchexec 只支持 pgx 驱动。如果用 database/sql,这些注解会报错。

与 gRPC/protobuf 工作流的类比

sqlc 和 protobuf 的设计哲学一脉相承:

维度protobufsqlc
输入.proto 文件.sql 文件
编译器protoc / bufsqlc
输出类型安全的 Go 代码类型安全的 Go 代码
核心思想接口契约即源代码SQL 即源代码
不要编辑*.pb.go*.sql.go

两者都是 schema-first / contract-first 的代码生成方案——用声明式语言定义契约,编译器保证类型安全。


分享这篇文章:

上一篇
GORM(一):核心用法与设计决策
下一篇
C++ 工程化(二):命名规范没有统一标准但有底线规则