前置知识: 零基础起步

ORM 与查询构建器

9 min入门

database/sql 之上的数据访问层三流派:GORM 链式 API、sqlc 先写 SQL 后生成类型安全代码、ent 图谱式建模——同一套账户与转账场景三种实现对比,附选型决策表与练习

知识点地图

  • 知识类别:数据访问层(DAL)。340-GoDatabase 讲零依赖的 database/sql(连接、事务、手写 Scan),本篇讲它之上的三套工程化方案: 运行时反射的 GORM、编译期代码生成的 sqlc 与 ent。
  • 解决什么问题:手写 rows.Scan 重复且易错(列序对不上、NULL 处理、 类型映射),表结构变更要同步改 N 处 SQL 与结构体。ORM/代码生成把 「数据库行 <-> Go 类型」的映射自动化,并各自附带事务、钩子、 关联加载等工程设施。
  • 什么时候用到:新服务选型数据访问层、评审「该不该用 ORM」、 老项目从原生 SQL 迁移、面试讲清三种流派的取舍而不是站队。

真实场景:把 340 的账户服务用三套方案各写一遍

340-GoDatabase 里有一个零依赖场景:账户表增删查 + 转账事务 (UPDATE accounts SET balance = balance - ? WHERE id = ? 两条更新包在 一个事务里,任何一步失败整体回滚)。那个版本要手写:

go
row := tx.QueryRowContext(ctx, "SELECT id, name, balance FROM accounts WHERE id = ?", id)
var a Account
if err := row.Scan(&a.ID, &a.Name, &a.Balance); err != nil { ... }

每个字段手工对位 Scan、每条 SQL 字符串裸奔、列改名编译期毫无感知。 本篇用同一场景走三套方案,对比三件事:代码量、类型安全出现的时机、 出错时离真相多远。场景约定:accounts 表(id, name, balance), 业务是「A 转 B 100 元,余额不足报错,全程一个事务」。

1. 三流派的心智模型

text
GORM  :运行时反射 + 链式 API      —— 对象关系映射,SQL 由库拼
sqlc  :先写 SQL,编译期生成代码   —— SQL 是第一公民,代码是产物
ent   :图谱式 schema + 代码生成   —— 实体与边建模,遍历式查询

三者的本质差异是「SQL 由谁负责、类型安全在哪个阶段建立」:

维度GORMsqlcent
SQL 由谁写库根据链式调用拼人手写 SQL库根据图遍历生成
类型安全时机运行时(标签写错不报编译错)编译期(生成代码)编译期(生成代码)
上手成本低(注解即表结构)低(会 SQL 就会 sqlc)中(先学 schema DSL)
复杂查询表达拼 API 容易失控直接写 SQL 无限制遍历 DSL,极端 SQL 要 fallback
迁移管理AutoMigrate自备(golang-migrate 等)自带 versioned migration
生态背景国内事实标准北美社区增长最快Meta 系,Facebook 出品

2. GORM:链式 API 与约定标签

2.1 模型与连接

go
type Account struct {
    ID        uint           `gorm:"primaryKey" json:"id"`
    Name      string         `gorm:"size:100;not null" json:"name"`
    Balance   int64          `gorm:"not null;default:0" json:"balance"`
    CreatedAt time.Time
    UpdatedAt time.Time
    DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 软删除标记
}

db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
    Logger: logger.Default.LogMode(logger.Warn), // Warn 级:慢查询与错误才打日志
})

逐行讲解:

  • 标签 gorm:"size:100;not null" 同时承担两职:AutoMigrate 建表时的 DDL 参数与ORM 的字段映射依据。换成别的写法会发生什么:标签里 只写映射不写约束,AutoMigrate 建出的表没有 NOT NULL,脏数据从 ORM 层面畅通无阻——约束要么进标签、要么手写迁移 SQL,不能没有;
  • DeletedAt gorm.DeletedAt 是软删除开关:带上这个字段后, 所有查询自动追加 WHERE deleted_at IS NULL,Delete 变成 UPDATE——查不到已删数据是自动的,但统计与唯一索引会踩坑 (软删行仍占用 email 唯一索引,见第 2.3 节坑点);
  • LogMode(Warn) 而不是全量日志:开发期全量、生产期 Warn, 全量 SQL 日志在高峰期本身就是性能与日志成本问题。

2.2 转账事务

go
func Transfer(db *gorm.DB, fromID, toID uint, amount int64) error {
    return db.Transaction(func(tx *gorm.DB) error {
        var from, to Account
        // SELECT ... FOR UPDATE 行锁,防止并发转账超扣
        if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
            First(&from, fromID).Error; err != nil {
            return err
        }
        if from.Balance < amount {
            return ErrInsufficient // 返回非 nil,GORM 自动 ROLLBACK
        }
        if err := tx.First(&to, toID).Error; err != nil {
            return err
        }
        if err := tx.Model(&from).Update("balance", from.Balance-amount).Error; err != nil {
            return err
        }
        return tx.Model(&to).Update("balance", to.Balance+amount).Error
        // 返回 nil 时 GORM 自动 COMMIT
    })
}

逐行讲解:db.Transaction 把「Begin/Commit/Rollback 三板斧」折叠成 一个闭包——闭包内必须用 tx 而不是外层 db,这是 GORM 最经典的 事故:用了 db 就等于绕开事务,两条 UPDATE 各自独立提交; clause.Locking{Strength: "UPDATE"} 生成 FOR UPDATE,对照 340 的 手写版本(在 SQL 字符串里拼 FOR UPDATE),效果相同但意图显式; 余额检查放事务内、加行锁之后——放在事务外是教科书级超扣漏洞 (两个并发请求同时读到余额 100,各转 100)。

2.3 GORM 的坑点清单

  • 坑 1:零值更新陷阱。 db.Model(&u).Updates(User{Balance: 0}) 会 跳过零值字段(GORM 用 struct 非零判定),余额改 0 静默失败。 解法:用 map[string]any{"balance": 0} 或 Select("balance").Updates(...)。
  • 坑 2:软删除与唯一索引打架。 email 有 uniqueIndex,用户 A 注销 (软删)后新用户 B 用同一 email 注册,唯一索引冲突——索引看不见 deleted_at。解法:唯一索引带上 deleted_at(PostgreSQL 的部分索引) 或改硬删。
  • 坑 3:链式复用污染。 q := db.Where("age > ?", 20) 复用两次会 累积条件;需要复用时先 Session(&gorm.Session{}) 拿干净副本。
  • 坑 4:RecordNotFound 与错误吞没。 First 找不到返回 gorm.ErrRecordNotFound,不判 errors.Is(err, gorm.ErrRecordNotFound) 会把「不存在」当系统错误上报告警。

3. sqlc:SQL 是第一公民

3.1 三件套:SQL 文件、配置、生成

sqlc 的流程反过来:先写 SQL,从 SQL 生成类型安全的 Go 代码。

sql
-- query.sql
-- name: GetAccount :one
SELECT * FROM accounts WHERE id = ? LIMIT 1;

-- name: DebitAccount :exec
UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?;

-- name: CreditAccount :exec
UPDATE accounts SET balance = balance + ? WHERE id = ?;
yaml
# sqlc.yaml
version: "2"
sql:
  - engine: "mysql"
    queries: "query.sql"
    schema: "schema.sql"
    gen:
      go:
        package: "db"
        out: "internal/db"
        emit_json_tags: true
bash
sqlc generate    # 生成 internal/db 包

3.2 生成代码的使用

go
func Transfer(ctx context.Context, q *db.Queries, fromID, toID uint, amount int64) error {
    // 事务句柄由 sqlc 生成的 DBTX 接口提供,连接管理仍用 database/sql
    tx, err := sqlDB.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback() // 提交后 Rollback 返回 ErrTxDone,无害

    qtx := q.WithTx(tx)
    from, err := qtx.GetAccount(ctx, fromID) // 返回 (Account, error),全类型安全
    if err != nil {
        return err
    }
    if from.Balance < amount {
        return ErrInsufficient
    }
    res, err := qtx.DebitAccount(ctx, db.DebitAccountParams{
        Balance: amount, ID: fromID, Balance_2: amount, // 参数按 SQL 占位序生成
    })
    ...
    return tx.Commit()
}

逐行讲解:GetAccount 的返回类型 Account 由 sqlc 从 schema.sql 的表结构推导——改列名时重新 generate,编译立刻报错,这就是 「编译期类型安全」的含义:错误出现的位置从「运行时字段错位」提前到 「编译期类型不匹配」;DebitAccount 的 SQL 里带 AND balance >= ? 把余额校验下推到数据库(原子条件更新),应用层再检查一次是为了 给出友好错误——两层防御各司其职。

换成别的写法会发生什么:如果不用 WITH BALANCE >= ? 的条件更新, 两个并发事务的 SELECT 检查一样会超扣(除非再加 FOR UPDATE)。 sqlc 不会替你处理并发——它只管映射,不管语义,并发正确性仍在 你写的 SQL 里。

3.3 sqlc 的边界

  • 动态 SQL(按可选过滤条件拼 WHERE)是弱项:可以写 -- name: X :many 加 NULLIF/COALESCE 技巧或多个命名查询,复杂动态拼装不如 ORM 顺手;
  • 迁移文件自己管(配 golang-migrate/Atlas),sqlc 只消费 schema;
  • 生成代码是提交进仓库的产物(与 protobuf 同理),CI 里加 「generate 后 git diff 必须为空」的门禁防漂移。

4. ent:图谱式建模

4.1 schema 即代码

go
// ent/schema/account.go
type Account struct {
    ent.Schema
}

func (Account) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").NotEmpty().MaxLen(100),
        field.Int64("balance").Default(0).NonNegative(),
    }
}

func (Account) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("orders", Order.Type), // 账户 -> 订单的「边」
    }
}
bash
go generate ./ent    # 生成 ent 客户端、谓词、迁移

ent 把实体建模为「点」、外键建模为「边」,查询是图遍历:

go
accounts, err := client.Account.
    Query().
    Where(account.BalanceGTE(100)).
    WithOrders(func(q *ent.OrderQuery) {         // 预加载关联,防 N+1
        q.Where(order.StatusEQ("paid"))
    }).
    All(ctx)

逐行讲解:Where(account.BalanceGTE(100)) 的谓词由生成代码提供, 字段改名即编译错误——与 sqlc 同级的类型安全,但建模入口是 Go 代码 而不是 SQL;WithOrders 对应 SQL 的 JOIN 预加载,不写它然后遍历 a.QueryOrders() 会产生 N+1 查询——ent 不会自动防 N+1,防不防在人。 转账事务用 client.Tx(ctx) 获得 tx 客户端,闭包内所有操作换用 tx 客户端 (与 GORM 的 db/tx 分离同构)。

4.2 三流派同场景对比结论

转账场景三套实现的真实体感:

  • 代码量:GORM ≈ 30 行,sqlc ≈ 25 行 SQL+40 行调用,ent ≈ schema 15 行 + 25 行调用——差距不大,维护成本差距才大;
  • 改表体验:GORM 改 struct 即可(运行时才知对错);sqlc/ent 改 schema 后重新生成,编译期暴露所有受影响调用点;
  • review 视角:sqlc 的 PR 里能直接看到 SQL,DBA 友好;GORM 的 PR 里只有链式调用,要靠日志或 Debug() 才能看到真实 SQL。

5. 选型决策

text
团队强 SQL 背景 / 复杂查询密集(报表、分析)   -> sqlc
快速 CRUD / 运维工具 / 原型迭代               -> GORM
实体关系复杂 / 大型领域模型 / 需要 schema 治理  -> ent

混合策略完全成立且常见:核心交易链路用 sqlc(SQL 可审、行为可测), 管理后台用 GORM(CRUD 快)——按查询的「重要性」分配工具, 而不是全仓库一刀切。

常见陷阱与调试

  • 坑 1(通用):ORM 层测试连真库。 三套方案都支持接口注入 (repository 模式,见 560-GoDependencyInjection),单测用 sqlite 内存库 或 sqlmock,集成测试才连真库——方言差异(sqlite 不支持 FOR UPDATE)决定了「ORM 单测全绿」不等于「真库行为正确」, 关键路径必须有真库集成测试。
  • 坑 2(GORM):AutoMigrate 当迁移工具。 它只加列不删列、不改 类型(怕丢数据),表结构漂移无人知晓。正式项目用 versioned migration(golang-migrate/Atlas),AutoMigrate 只留给原型。
  • 坑 3(sqlc):忘记重新 generate。 手改了 query.sql 忘跑生成, 跑的还是旧代码。CI 加 sqlc diff 门禁(或 generate 后 diff 为空)。
  • 坑 4(ent):N+1 依赖 WithXX 的自觉。 code review 重点看遍历处 有没有先 WithXxx 再循环。

动手实践

任务:用 sqlc 完成转账服务,并验证「编译期类型安全」不是口号。

  1. 建表 schema.sql(id, name, balance)与三个命名查询(查询、扣款、 入账,扣款带 AND balance >= ? 条件);
  2. sqlc generate 后写 Transfer 函数(BeginTx + WithTx + Commit);
  3. 验证实验 A:把 query.sql 里的 balance 列改名 bal,重新 generate, 观察调用处编译错误——找出所有受影响的行;
  4. 验证实验 B:并发跑 100 个 goroutine 同时从余额 100 的账户各转 1 元, 断言最终余额为 0 且无一次超扣(失败重跑有超扣即为条件更新写错)。

提示:实验 B 用 sync.WaitGroup + 100 个 goroutine 并发调用 Transfer;「无超扣」的断言依据是总扣款 100 = 初始余额 100,若余额 出现负数说明条件更新没生效;MySQL 用户注意 DSN 里带 interpolateParams=true 减少往返。

参考实现(先自己写,再展开对照)
sql
-- schema.sql
CREATE TABLE accounts (
    id      BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    name    VARCHAR(100) NOT NULL,
    balance BIGINT NOT NULL DEFAULT 0
);

-- query.sql
-- name: GetAccount :one
SELECT * FROM accounts WHERE id = ? LIMIT 1;

-- name: Debit :exec
UPDATE accounts SET balance = balance - ? WHERE id = ? AND balance >= ?;

-- name: Credit :exec
UPDATE accounts SET balance = balance + ? WHERE id = ?;
go
func Transfer(ctx context.Context, sqlDB *sql.DB, q *db.Queries,
    fromID, toID uint, amount int64) error {

    tx, err := sqlDB.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    qtx := q.WithTx(tx)
    from, err := qtx.GetAccount(ctx, fromID)
    if err != nil {
        return fmt.Errorf("load source: %w", err)
    }
    if from.Balance < amount {
        return ErrInsufficient
    }
    if _, err := qtx.Debit(ctx, db.DebitParams{
        Amount: amount, ID: fromID, Balance: amount,
    }); err != nil {
        return fmt.Errorf("debit: %w", err)
    }
    if _, err := qtx.Credit(ctx, db.CreditParams{Amount: amount, ID: toID}); err != nil {
        return fmt.Errorf("credit: %w", err)
    }
    return tx.Commit()
}

逐段讲解:DebitParams 的三个字段按 SQL 占位符顺序生成(余额 参数出现两次就有两个字段),这正是「类型安全来自 schema 而非猜测」 的体现;defer tx.Rollback 在 Commit 之后调用只会返回 ErrTxDone 被忽略——这是 Go 事务的标准安全网写法(对照 340 的事务 模板,同一模式);实验 B 能全绿的前提是 Debit 的 AND balance >= ? 在行锁下串行化执行,你可以在失败路径里把条件去掉再跑一次, 亲眼看到负余额——这就是「应用层检查不可靠,原子条件更新才可靠」 的实证。

参考与致谢

  • GORM 官方文档(https://gorm.io/docs/ ,MIT 许可)
  • sqlc 官方文档(https://docs.sqlc.dev/ ,MIT 许可)
  • ent 官方文档(https://entgo.io/docs/ ,Apache-2.0 许可)
  • 场景素材:本仓库 340-GoDatabase 连接池与事务场景的续作, 590-GoWebDevelopmentMicroservice 原 §6.2 GORM 速写的扩写