Go语言:sqlc类型安全的数据库查询

Go 的数据库访问一直在两个极端之间摇摆:database/sql 太原始(手写 SQL + 手动 Scan),GORM 太魔法(运行时反射、隐式行为多)。sqlc 走了一条不同的路:你写 SQL,它生成类型安全的 Go 代码。SQL 优先,编译时校验,零反射。

sqlc vs 其他方案

维度 database/sql sqlx GORM sqlc
SQL 控制力 完全 完全 完全
类型安全 部分 部分 完全
代码生成
学习成本
运行时反射
编译时校验

我用 GORM 遇到过的坑:Where 条件拼错字段名运行时才报错、Preload 嵌套查询生成的 SQL 效率差、复杂查询最后还是得写原生 SQL。sqlx 好一些,但字段映射还是靠 struct tag,拼错了编译不报错。

sqlc 的思路是反过来:SQL 是一等公民,Go 代码是生成的。你写的 SQL 在生成阶段就被解析和校验,字段名写错直接报错。

安装与项目配置

# 安装 sqlc
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest

# 或用 brew
brew install sqlc

项目结构:

myproject/
├── sqlc.yaml           # sqlc 配置
├── sql/
│   ├── schema.sql      # 表结构
│   └── query.sql       # 查询定义
├── db/                 # 生成的 Go 代码(自动)
│   ├── db.go
│   ├── models.go
│   └── query.sql.go
├── main.go
└── go.mod

sqlc.yaml 配置

version: "2"
sql:
  - engine: "postgresql"    # 支持 postgresql / mysql / sqlite
    queries: "sql/query.sql"
    schema: "sql/schema.sql"
    gen:
      go:
        package: "db"
        out: "db"
        sql_package: "pgx/v5"   # 使用 pgx 驱动
        emit_json_tags: true     # 生成 JSON tag
        emit_empty_slices: true  # 空结果返回 [] 而非 nil

关键配置项:

  • engine:数据库类型,决定了 SQL 语法校验规则
  • sql_package:底层驱动,PostgreSQL 推荐 pgx/v5,MySQL 用 database/sql
  • emit_json_tags:生成的 struct 自带 json tag,方便直接序列化为 API 响应

Schema 定义

-- sql/schema.sql

CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    username    VARCHAR(50)  NOT NULL UNIQUE,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,
    avatar_url  TEXT,
    role        VARCHAR(20)  NOT NULL DEFAULT 'user',
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE TABLE posts (
    id          BIGSERIAL PRIMARY KEY,
    author_id   BIGINT       NOT NULL REFERENCES users(id),
    title       VARCHAR(200) NOT NULL,
    content     TEXT         NOT NULL,
    status      VARCHAR(20)  NOT NULL DEFAULT 'draft',
    view_count  INT          NOT NULL DEFAULT 0,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status);

查询定义

sqlc 用注释中的特殊标记来指定查询类型:

  • :one — 返回一行
  • :many — 返回多行
  • :exec — 不返回数据(INSERT/UPDATE/DELETE)
  • :execresult — 返回受影响行数
  • :execrows — 返回受影响行数(整数)
-- sql/query.sql

-- name: GetUser :one
SELECT id, username, email, avatar_url, role, created_at, updated_at
FROM users
WHERE id = $1;

-- name: GetUserByUsername :one
SELECT id, username, email, avatar_url, role, created_at, updated_at
FROM users
WHERE username = $1;

-- name: ListUsers :many
SELECT id, username, email, role, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2;

-- name: CreateUser :one
INSERT INTO users (username, email, password, role)
VALUES ($1, $2, $3, $4)
RETURNING id, username, email, role, created_at;

-- name: UpdateUser :exec
UPDATE users
SET username = $2, email = $3, avatar_url = $4, updated_at = NOW()
WHERE id = $1;

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

-- name: GetPost :one
SELECT p.id, p.title, p.content, p.status, p.view_count,
       p.created_at, p.updated_at,
       u.username as author_name
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = $1;

-- name: ListPostsByAuthor :many
SELECT id, title, status, view_count, created_at
FROM posts
WHERE author_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3;

-- name: CreatePost :one
INSERT INTO posts (author_id, title, content, status)
VALUES ($1, $2, $3, $4)
RETURNING id, title, status, created_at;

-- name: UpdatePostStatus :exec
UPDATE posts SET status = $2, updated_at = NOW()
WHERE id = $1;

-- name: IncrementViewCount :exec
UPDATE posts SET view_count = view_count + 1
WHERE id = $1;

-- name: CountPostsByStatus :many
SELECT status, COUNT(*) as count
FROM posts
WHERE author_id = $1
GROUP BY status;

生成代码

sqlc generate

看看生成了什么:

// db/models.go — 自动生成的 struct
type User struct {
    ID        int64              `json:"id"`
    Username  string             `json:"username"`
    Email     string             `json:"email"`
    Password  string             `json:"password"`
    AvatarUrl *string            `json:"avatar_url"`
    Role      string             `json:"role"`
    CreatedAt pgtype.Timestamptz `json:"created_at"`
    UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}

type Post struct {
    ID         int64              `json:"id"`
    AuthorID   int64              `json:"author_id"`
    Title      string             `json:"title"`
    Content    string             `json:"content"`
    Status     string             `json:"status"`
    ViewCount  int32              `json:"view_count"`
    CreatedAt  pgtype.Timestamptz `json:"created_at"`
    UpdatedAt  pgtype.Timestamptz `json:"updated_at"`
}
// db/query.sql.go — 自动生成的查询方法(部分)

// GetUser 的参数和返回值都是强类型
func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
    // ...
}

// CreateUser 的参数是生成的 struct
type CreateUserParams struct {
    Username string `json:"username"`
    Email    string `json:"email"`
    Password string `json:"password"`
    Role     string `json:"role"`
}

func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) {
    // ...
}

// JOIN 查询的返回值也是单独生成的 struct
type GetPostRow struct {
    ID         int64              `json:"id"`
    Title      string             `json:"title"`
    Content    string             `json:"content"`
    Status     string             `json:"status"`
    ViewCount  int32              `json:"view_count"`
    CreatedAt  pgtype.Timestamptz `json:"created_at"`
    UpdatedAt  pgtype.Timestamptz `json:"updated_at"`
    AuthorName string             `json:"author_name"`  // JOIN 的字段
}

注意 GetPostRow:因为 GetPost 查询 JOIN 了 users 表并取了 author_name,sqlc 自动生成了一个包含这个字段的专用 struct。字段类型、可空性(*string)都是从 schema 推导的。

在项目中使用

// main.go
package main

import (
    "context"
    "fmt"
    "log"

    "myproject/db"

    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()

    pool, err := pgxpool.New(ctx, "postgres://user:pass@localhost:5432/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer pool.Close()

    queries := db.New(pool)

    // 创建用户 — 参数和返回值都是强类型
    user, err := queries.CreateUser(ctx, db.CreateUserParams{
        Username: "zhangsan",
        Email:    "zhangsan@example.com",
        Password: "$2a$10$hashed_password_here",
        Role:     "user",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Created user: %d %s\n", user.ID, user.Username)

    // 查询用户 — 返回强类型 struct,不需要手动 Scan
    u, err := queries.GetUser(ctx, user.ID)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("User: %s (%s)\n", u.Username, u.Email)

    // 创建文章
    post, err := queries.CreatePost(ctx, db.CreatePostParams{
        AuthorID: user.ID,
        Title:    "Hello sqlc",
        Content:  "This is my first post.",
        Status:   "published",
    })
    if err != nil {
        log.Fatal(err)
    }

    // JOIN 查询 — 返回的 GetPostRow 包含 AuthorName 字段
    p, err := queries.GetPost(ctx, post.ID)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Post: %s by %s\n", p.Title, p.AuthorName)

    // 列表查询 — 分页
    posts, err := queries.ListPostsByAuthor(ctx, db.ListPostsByAuthorParams{
        AuthorID: user.ID,
        Limit:    10,
        Offset:   0,
    })
    if err != nil {
        log.Fatal(err)
    }
    for _, p := range posts {
        fmt.Printf("  - %s (%s)\n", p.Title, p.Status)
    }
}

与现有项目集成

如果项目已经在用 GORM 或 sqlx,不需要全盘替换。sqlc 可以和它们共存:

  1. 新查询用 sqlc,旧代码保留:逐步迁移,不影响现有功能
  2. 复杂查询用 sqlc,简单 CRUD 用 GORM:取两者之长
  3. 共享数据库连接:sqlc 生成的 Queries 可以用 *sql.DB 初始化,和 GORM 用同一个连接池
// sqlc 和 GORM 共存
import (
    "database/sql"
    "gorm.io/gorm"
    "myproject/db"
)

sqlDB, _ := sql.Open("postgres", dsn)
gormDB, _ := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}))
queries := db.New(sqlDB)

// 简单 CRUD 用 GORM
gormDB.Create(&SomeModel{...})

// 复杂查询用 sqlc
stats, _ := queries.CountPostsByStatus(ctx, userID)

sqlc 的局限

  • 动态查询不方便:条件不固定的筛选查询(比如搜索页面,用户可能填也可能不填某个条件),sqlc 处理起来比较笨,每种组合可能需要单独写一个查询
  • 数据库方言绑定:PostgreSQL 的 sqlc 支持最好,MySQL 支持在改善中但还有些 edge case
  • schema 变更需要重新生成:改了表结构要跑一次 sqlc generate,需要集成到开发流程中
  • 不支持 ORM 的关联加载:没有 Preload 这类自动加载关联的功能,JOIN 查询自己写

这些局限决定了 sqlc 不适合完全替代 ORM。但对于性能敏感、查询逻辑固定、需要精确控制 SQL 的场景,它是目前 Go 生态里最好的选择之一。