跳到正文
知识库 / Go 博客教程 / 第 8 课返回主站 ↗

Go 博客教程 · 第 8 课 / 10

第 8 课:GORM 与关联查询

第 3 课你手写过这样的代码:

// internal/repository/post_repo.go —— 第 3 课手写版
for rows.Next() {
    var p model.Post
    var authorID sql.NullInt64        // NULL 列不能直接扫进 int64
    var summary  sql.NullString
    var pubAt    sql.NullTime
    if err := rows.Scan(&p.ID, &authorID, &p.Title, &p.Slug, &summary,
        &p.Content, &p.Status, &p.ViewCount, &pubAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
        return nil, err               // ★ 列顺序和 Scan 参数顺序必须严格一一对应
    }
    if authorID.Valid { p.AuthorID = &authorID.Int64 }
    if summary.Valid  { p.Summary  = &summary.String }
    if pubAt.Valid    { p.PublishedAt = &pubAt.Time }
    ps = append(ps, p)
}
return ps, rows.Err()                 // ★ 别忘了 rows.Err()

11 个列,11 个 &,一个都不能错位。 加一个字段要改三处(SQL、Scan 参数、NULL 转换)。这一课换成 GORM,然后开着 SQL 日志盯着它,看清它到底替我们干了什么。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 7 课:迁移到 Gin下一课:第 9 课:Redis 缓存与限流 →

① 本课目标

  1. 判断一个查询该用 GORM 还是该退回原生 SQL
  2. 按 GORM 命名约定定义模型,不符合约定时用 TableName() / column tag 覆盖。
  3. 说清 gorm.DeletedAt 加进结构体后,所有查询的语义发生了什么变化。
  4. 认出 GORM 头号坑:Updates 用 struct 忽略零值——并给出三种修法。
  5. Preload 把 N+1 变成常数条查询,并能在 SQL 日志里数出来变了几条。
  6. 用闭包事务写「创建文章 + 绑定标签」,并知道 tx 漏传会发生什么。
  7. 把 repository 换成 GORM,而 service 和 handler 一行不改——第 4 课接口设计的兑现时刻。

② 前置检查

cd ~/go-blog && go build ./... && echo "编译通过"
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/v1/posts   # 期望 200
mysql -uroot -p123456 blog_dev -e "SHOW TABLES;"   # comments post_tags posts tags users
go get gorm.io/gorm gorm.io/driver/mysql

★★ 装之前先看清包名

状态
gorm.io/gorm v2,当前唯一维护的版本
github.com/jinzhu/gorm v1,2020 年起停止维护

网上 2020 年以前的中文教程基本全是 v1,API 差异很大(Session/WithContext/Preload 语义都变了)。搜到的代码如果 import github.com/jinzhu/gorm直接跳过,别硬套。

本课实测版本:

组件 版本
Go / MySQL go1.25.0 darwin/arm64 / 8.4.5
gorm.io/gorm v1.31.2
gorm.io/driver/mysql v1.6.0
github.com/go-sql-driver/mysql v1.8.1(还是第 3 课那个驱动

注意最后一行:GORM 底下跑的还是 database/sql 和你第 3 课用过的 MySQL 驱动。它不是新的数据库栈,是 database/sql 上面的一层 SQL 生成器 + 反射映射器。这个认知能解释后面所有行为。

③ 核心概念

3.1 先问:该不该用?

对照三段式 ①:Scan vs 自动映射

// —— 第 3 课手写版
var p model.Post
var authorID sql.NullInt64
err := row.Scan(&p.ID, &authorID, &p.Title, &p.Slug, /* ...还有 7 个... */)
if authorID.Valid { p.AuthorID = &authorID.Int64 }

// —— GORM 版
var p model.Post
err := r.db.WithContext(ctx).First(&p, id).Error   // 就这一行
手写 database/sql GORM 代价
简单 CRUD 11 个 &,加字段改 3 处 一行,加字段改 struct 一处
NULL 处理 手写 sql.NullXxx 转换 *string / *int64 自动处理
看得见 SQL ✅ SQL 就在眼前 不开日志你不知道它发了什么 必须开 Logger
性能可预测 ✅ 一条 SQL 一次往返 Preload 可能悄悄多发几条 得盯日志
复杂 JOIN / 子查询 / 窗口函数 ✅ 直接写 SQL 链式 API 反而更难写更难读 db.Raw() 逃生
隐式行为 软删除、零值忽略、自动时间戳 ★ 本课一大半篇幅在讲这个
反射开销 每次查询反射解析 struct(有缓存但不为零) 极高 QPS 要测

建议(诚实版)

  • CRUD 用 GORM,报表用 Raw 这不是妥协,GORM 官方自己也这么推荐。
  • 学 GORM 期间 logger.Info 必开,看不见 SQL 就是盲飞。
  • 不要为了「用了 ORM 就不该写 SQL」而硬凑。五表 JOIN 的统计用链式 API 写出来,你三个月后也看不懂。

3.2 连接与配置

// internal/repository/gorm.go
package repository

import (
    "fmt"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
    "gorm.io/gorm/logger"
)

// OpenGorm 建立 GORM 连接。
// 参数只有 dsn:配置从外面传进来,这个函数不读环境变量(第 4 课的依赖注入原则)。
func OpenGorm(dsn string) (*gorm.DB, error) {
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
        // ★★ 学习期必开:每条生成的 SQL 打到 stdout。生产改成 logger.Warn。
        Logger: logger.Default.LogMode(logger.Info),

        // ★ 把 MySQL 1062 之类的驱动错误翻译成 gorm.ErrDuplicatedKey,
        //   否则 errors.Is(err, gorm.ErrDuplicatedKey) 恒为 false(实测,见 4.4)。
        TranslateError: true,

        // schema.sql 里已手写外键约束,让 GORM 也去建会和迁移脚本打架
        DisableForeignKeyConstraintWhenMigrating: true,
    })
    if err != nil {
        return nil, fmt.Errorf("gorm.Open: %w", err)
    }

    // ★★ 连接池 GORM 不管,还得自己设 —— 第 3 课那套参数原封不动搬过来。
    //    db.DB() 取回的就是标准库的 *sql.DB。
    sqlDB, err := db.DB()
    if err != nil {
        return nil, fmt.Errorf("db.DB(): %w", err)
    }
    sqlDB.SetMaxOpenConns(25)                    // 别超过 MySQL 的 max_connections
    sqlDB.SetMaxIdleConns(10)                    // 太小会频繁重连
    sqlDB.SetConnMaxLifetime(30 * time.Minute)   // 要小于 MySQL 的 wait_timeout
    sqlDB.SetConnMaxIdleTime(5 * time.Minute)

    // gorm.Open 是懒连接,不 Ping 的话 DSN 配错要到第一次查询才报错
    if err := sqlDB.Ping(); err != nil {
        return nil, fmt.Errorf("ping: %w", err)
    }
    return db, nil
}

对照三段式 ②:连接池

第 3 课 GORM 代价
开连接 sql.Open("mysql", dsn) gorm.Open(mysql.Open(dsn), cfg)
连接池 db.SetMaxOpenConns(25) 完全一样,db.DB() 取回来自己设 ★ GORM 不会替你设,不设就是驱动默认值(MaxIdleConns=2,高并发下疯狂建连接)

日志打开后每条查询长这样(实测):

2026/09/06 09:31:39 gorm_test.go:126
[3.545ms] [rows:1] INSERT INTO `posts` (`author_id`,`title`,`slug`,...) VALUES (NULL,'零值坑复现',...)
   ↑耗时    ↑影响行数  ↑真发出去的 SQL(参数已填进去,可直接复制到 mysql 客户端跑)

单条临时开日志用 db.Debug()只想看 SQL 不执行ToSQL(写复杂查询时很有用):

// scratch/tosql.go
sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
    return tx.Model(&Post{}).Where("status = ?", 1).Order("id DESC").Limit(10).Find(&[]Post{})
})
// SELECT * FROM `posts` WHERE status = 1 AND `posts`.`deleted_at` IS NULL ORDER BY id DESC LIMIT 10

④ 函数逐个精讲

4.1 模型定义 —— 以及和第 4 课分层的一次正面冲突

先把第 1/3/4 课定稿的领域模型抄过来:

// internal/model/post.go —— 第 1/3/4 课定稿,先原样看一眼
type Post struct {
    ID          int64
    AuthorID    *int64
    Title       string
    Slug        string
    Summary     string     // ★ 零值折叠:读 NULL → "",写 "" → NULL(第 3 课的 nullString)
    Content     string
    Status      Status     // ★ 第 1 课的 defined type,底层 int8
    ViewCount   int
    PublishedAt *time.Time
    CreatedAt   time.Time
    UpdatedAt   time.Time
    DeletedAt   *time.Time
}

能不能直接给 GORM 用? 我拿这个结构体原样跑了一遍,两个问题当场暴露:

1) Summary "" 写进去之后:  summary IS NULL = 0        ← 写成了空串,不是 NULL
   实际 SQL:INSERT INTO `posts` (...,`summary`,...) VALUES (...,'',...)

2) db.Delete(&Post{}, id) 发出的 SQL:
   DELETE FROM `posts` WHERE `posts`.`id` = 4          ← 真物理删除!
   Delete 之后表里还有这行吗? count=0
   后续 Find 也【没有】自动加 deleted_at IS NULL

两个都是静默的语义变化,编译器和测试都不会提醒你:

冲突 第 3 课手写版 GORM 直接用 model.Post 后果
Summary string nullString(p.Summary)""NULL 写成 '' 「零值折叠」约定破了;WHERE summary IS NULL 的查询全部失效
DeletedAt *time.Time repository 自己拼 UPDATE ... SET deleted_at=NOW() 软删除完全不生效,变成物理删除 数据真的没了

根因:GORM 的软删除是类型驱动的——它认的是 gorm.DeletedAt 这个具体类型(内部是 sql.NullTime + 一组 GORM 接口),光把字段命名成 DeletedAt 没有任何用。同理,string 类型在 Go 里没有「空」这个状态,GORM 只能老实写 ''

两条路,都要付代价

方案 A:把 model.Post.DeletedAt 改成 gorm.DeletedAt

// internal/model/post.go
import "gorm.io/gorm"        // ★ model 包从此依赖 ORM

type Post struct {
    // ...
    DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}

代价很直接:第 4 课那张依赖图破了model 是最底层、谁都 import 它的包,第 4 课专门讲过它不该沾任何基础设施。现在它 import 了 gorm.io/gorm——意味着你的领域模型、你的单元测试、甚至将来想抽出去复用的 model 包,全都拖着一个 ORM。而且 Summary 的 NULL 问题它还没解决(得再把 Summary 改成 *string,于是 service 层那些 strings.TrimSpace(req.Summary) 全要改)。

方案 B:在 repository 包里放一个持久化结构体,model 保持纯净。

// internal/repository/post_row.go —— ORM 的类型只存在于这个文件
package repository

// postRow 是 posts 表的【持久化表示】,只服务于 GORM。
// 小写开头 = 包外不可见,它绝不会泄露到 service/handler。
type postRow struct {
    ID          int64 `gorm:"primaryKey"`
    AuthorID    *int64
    Title       string
    Slug        string
    Summary     *string        // ★ 指针:让 "" ↔ NULL 的折叠成为可能
    Content     string
    Status      model.Status
    ViewCount   int
    PublishedAt *time.Time
    CreatedAt   time.Time
    UpdatedAt   time.Time
    DeletedAt   gorm.DeletedAt `gorm:"index"`   // ★ ORM 类型只出现在这里

    // ★★ TableName() 只改【表名】,不改关联列的推断 ——
    //    列名是从【结构体名】postRow 推的,会推出 post_row_id(实测)。
    //    所以这里必须用 joinForeignKey 显式钉死,否则 Preload 恒返回 0 个标签。
    Tags []Tag `gorm:"many2many:post_tags;joinForeignKey:post_id;joinReferences:tag_id"`
}

// TableName 让 postRow 映射到 posts 表(默认会推成 post_rows)
func (postRow) TableName() string { return "posts" }

// toRow 领域对象 → 持久化对象。零值折叠的【写方向】,和第 3 课 nullString 语义一致。
func toRow(p *model.Post) *postRow {
    r := &postRow{
        ID: p.ID, AuthorID: p.AuthorID, Title: p.Title, Slug: p.Slug,
        Content: p.Content, Status: p.Status, ViewCount: p.ViewCount,
        PublishedAt: p.PublishedAt,
    }
    if strings.TrimSpace(p.Summary) != "" {   // "" → nil → SQL NULL
        s := p.Summary
        r.Summary = &s
    }
    return r
}

// toDomain 持久化对象 → 领域对象。零值折叠的【读方向】。
func (r *postRow) toDomain() *model.Post {
    p := &model.Post{
        ID: r.ID, AuthorID: r.AuthorID, Title: r.Title, Slug: r.Slug,
        Content: r.Content, Status: r.Status, ViewCount: r.ViewCount,
        PublishedAt: r.PublishedAt, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt,
    }
    if r.Summary != nil {                     // NULL → ""
        p.Summary = *r.Summary
    }
    if r.DeletedAt.Valid {
        t := r.DeletedAt.Time
        p.DeletedAt = &t
    }
    return p
}

实测这一版全部正确:

1) Summary "" -> summary IS NULL = 1          ✅ 零值折叠保住了
2) db.Delete 之后物理行还在吗? count=1        ✅ 真的是软删除
   普通 First: err=record not found           ✅ 自动加了 deleted_at IS NULL
3) Unscoped 读回来 Summary=""  DeletedAt!=nil ✅ 双向映射对称
4) Preload 到 1 个标签                         ✅(前提是写了 joinForeignKey)

对照三段式 ③:谁来承担 ORM 的类型要求

第 3 课手写版 方案 A(model 用 gorm.DeletedAt 方案 B(持久化结构体)
映射代码 每个字段手写 Scan 两个函数约 30 行
model 包依赖 依赖 gorm.io/gorm
软删除 自己拼 SQL,每个查询自己加条件 自动 自动
换 ORM 的代价 改遍 model + 所有引用 只改 repository 一个包

GORM 让你在「领域模型纯净」和「不写映射代码」之间二选一。 说「用了 ORM 就不用写映射代码了」是不完整的——只有当你愿意让 ORM 的类型渗进领域模型时才成立。

本教程选方案 B,理由是它守住了第 4 课花一整课建立的依赖方向,而代价(30 行映射)是一次性的、可测的、局部的。但方案 A 在真实项目里非常常见,也不算错——如果你的 model 包本来就只服务这一个应用、且团队认了 GORM 作为长期选型,A 的性价比更高。关键是这个决定要被显式做出来,而不是因为「教程里就这么写的」而默认发生。

不选 B 的信号:如果你发现 postRowmodel.Post 字段完全一一对应、映射函数纯属搬运、而且半年都没换过 ORM——那 B 就是在交无谓的税,切回 A。

剩下的 tag 都是常规操作

后面几节为了聚焦 GORM 本身的行为,代码里直接写 Post你在实际项目里把它读作 postRow 即可,行为完全一样。

// internal/repository/post_row.go —— 常用 tag 一览
type postRow struct {
    ID       int64  `gorm:"column:id;primaryKey;autoIncrement"`
    Title    string `gorm:"size:200;not null"`
    Slug     string `gorm:"size:200;not null;uniqueIndex"`
    Content  string `gorm:"type:mediumtext;not null"`
    Status   model.Status `gorm:"default:0"`   // ★ defined type,GORM 认底层 int8,实测直接可用
    TempFlag bool   `gorm:"-"`                 // 完全不入库
    Computed int    `gorm:"->"`                // 只读(数据库生成的列)
    Secret   string `gorm:"<-:create"`         // 只写不读
}

实测确认model.Statustype Status int8不需要Scanner/Valuer——GORM 按底层类型推断出 DataType=intCreate / First / Updates(map) 全部正常,got.Status == model.StatusPublished 成立。

命名约定(实测于 gorm v1.31.2)

我用 gorm.Statement.Parse() 把推断结果打了出来:

模型 *model.Post -> 表名 posts
    字段 ID -> 列 id        字段 AuthorID -> 列 author_id
    字段 ViewCount -> 列 view_count       字段 PublishedAt -> 列 published_at
    关联 Tags type=many_to_many           关联 Comments type=has_many
模型 *model.Tag -> 表名 tags     模型 *model.Comment -> 表名 comments
推断 规则 例子
表名 结构体名 → snake_case → 复数 PostpostsPostTagpost_tags
列名 字段名 → snake_case AuthorIDauthor_id不是 author_i_dID 当一个词)
主键 名为 ID 的字段 别的名字要写 gorm:"primaryKey"
自动时间戳 CreatedAt 插入时填、UpdatedAt 插入和更新时填 改名就失效,或用 gorm:"autoCreateTime"
外键 <关联结构体名>ID Comment.PostIDPost.ID

不符合约定时覆盖:

// internal/model/post.go
func (Post) TableName() string { return "blog_posts" }   // 覆盖表名

type Post struct {
    AuthorID int64  `gorm:"column:writer_id"`   // 列名不符约定
    TempFlag bool   `gorm:"-"`                  // 完全不入库
    Computed int    `gorm:"->"`                 // 只读(数据库生成的列)
    Secret   string `gorm:"<-:create"`          // 只写不读
}

gorm.Model 嵌入 vs 自己写字段

GORM 提供了现成的 gorm.Model(含 ID uint / CreatedAt / UpdatedAt / DeletedAt)。本教程不用它,三条理由:

  1. 主键类型被钉死成 uint。我们的表是 BIGINT UNSIGNED,第 4 课的接口是 GetByID(ctx, id int64)——嵌了它你得到处 uint(id) / int64(p.ID) 来回转。
  2. 软删除是被偷偷塞进来的。上一节刚讲过软删除会改变全项目查询语义;用 gorm.Model 意味着这件大事被藏在一行嵌入里,新人根本看不见。显式写出 DeletedAt gorm.DeletedAt 才对得起它的影响面。
  3. JSON 序列化不可控。嵌入结构体的字段会平铺进 JSON,想给 DeletedAtjson:"-" 得重新定义。

(如果你选了方案 A,这三条同样适用——在 model.Post 里显式写四个字段,别嵌 gorm.Model。)

4.2 AutoMigrate 的真相与红线

// internal/repository/gorm.go —— 仅限本地开发
if os.Getenv("APP_ENV") == "local" {
    if err := db.AutoMigrate(&model.Post{}, &model.Tag{}, &model.Comment{}); err != nil {
        return nil, fmt.Errorf("automigrate: %w", err)
    }
}

它到底做什么? 我建了临时表实测——第一版有 Name varchar(32)Age,第二版删掉 Age、把 Name 缩到 8:

第二次 AutoMigrate 发出的 DDL:ALTER TABLE `mig_demos` MODIFY COLUMN `name` varchar(8)

之后的实际列:
  id   bigint  len=0
  name varchar len=8      ← ★ 长度被改小了!
  age  bigint  len=0      ← ★ 删掉的字段还在!
它会做 它不会做
✅ 建表、加列、加索引 ❌ 删列(结构体删了字段,列还在)
改列类型/长度MODIFY COLUMN ❌ 删索引、改列名(改字段名 = 加新列,老列留着)
❌ 数据迁移、回填、回滚

★ 红线:生产环境不要依赖 AutoMigrate

危险不在「只加不减」,恰恰在它真的会 MODIFY COLUMN

  • 有人把 size:8 提交上去,AutoMigrate 会在生产库ALTER TABLE ... MODIFY COLUMN name varchar(8)——已有长数据要么被截断、要么整条 ALTER 失败卡住启动
  • 大表 ALTER TABLE 会锁表/长时间重建,启动时跑等于自己制造停机。
  • 无版本记录,两个人同时改模型谁先跑谁说了算,不可审计、不可回滚

本教程的做法migrations/schema.sql唯一真相源AutoMigrate 只在 APP_ENV=local 跑,用来快速验证模型和表对不对得上。上生产用 golang-migrateAtlas 这类带版本号和 up/down 的工具。

想只读地检查「模型和线上表对上了吗」:

// scratch/check_schema.go —— 不执行任何 DDL
db.Migrator().HasColumn(&model.Post{}, "summary")   // true/false
cols, _ := db.Migrator().ColumnTypes(&model.Post{}) // 列出实际列

4.3 ★★ 软删除:加一个字段,改变全项目语义

DeletedAt gorm.DeletedAt 是本课最容易让人困惑的隐式行为,必须讲透。

// scratch/soft_delete_test.go
db.Create(p)                 // id=5
db.Delete(&Post{}, p.ID)     // ← 你以为的 DELETE

日志里实际发出去的:

UPDATE `posts` SET `deleted_at`='2026-09-06 09:31:51.104'
  WHERE `posts`.`id` = 5 AND `posts`.`deleted_at` IS NULL
  ↑ 是 UPDATE,不是 DELETE

然后每个查询都被改写了(实测):

db.First(&q, 5)
  → SELECT * FROM `posts` WHERE `posts`.`id` = 5 AND `posts`.`deleted_at` IS NULL ORDER BY id LIMIT 1
  → err = "record not found"

db.Model(&Post{}).Where("id = ?", 5).Count(&n)
  → SELECT count(*) FROM `posts` WHERE id = 5 AND `posts`.`deleted_at` IS NULL   → n = 0

db.Model(&Post{ID:5}).Updates(map[string]any{"status":0})
  → UPDATE `posts` SET `status`=0,... WHERE `posts`.`deleted_at` IS NULL AND `id` = 5
     ↑ 连 UPDATE 都带上了!软删除的行改不动

这意味着

  • Find / First / Count / Updates / Delete 全部自动追加 AND deleted_at IS NULL
  • MySQL 客户端里 SELECT * FROM posts WHERE id=5 看得见这行,但你的 Go 代码看不见——「数据明明在库里,程序说找不到」是这个坑最典型的现场。
  • uniqueIndex 会和软删除打架slug 是唯一索引,软删一篇 hello-go再创建同 slug 的文章会报 1062——那行还占着索引(练习 5 会让你复现并解决)。

逃生舱 Unscoped()

// internal/repository/gorm_post_repo.go —— 三种越过软删除的写法
db.Unscoped().First(&p, id)      // 查已删除的
//  → SELECT * FROM `posts` WHERE `posts`.`id` = 5 ORDER BY id LIMIT 1  (没有 deleted_at 条件)
//  → p.DeletedAt.Valid == true, p.DeletedAt.Time == 删除时间
db.Unscoped().Delete(&Post{}, id)                                  // 物理删除
//  → DELETE FROM `posts` WHERE `posts`.`id` = 5
db.Unscoped().Model(&Post{}).Where("id = ?", id).Update("deleted_at", nil)   // 恢复

对照三段式 ④:删除

第 3/4 课手写版 GORM 版 代价
软删除 自己写 UPDATE ... SET deleted_at=NOW()每个查询自己加 AND deleted_at IS NULL(漏一个就泄露已删数据) 加一个字段,全自动 ★ 太自动了:Delete 不是删;SQL 日志里看到 UPDATE 别以为是 bug
硬删除 默认行为 要显式 Unscoped() 语义反转了

建议:只在真的需要软删除的表上加 gorm.DeletedAtschema.sql 里只有 postscommentsdeleted_at 列——tagspost_tags 就不该有。

4.4 CRUD 与 GetWithTags

// internal/repository/gorm_post_repo.go —— API 速查(都实测过)
db.Create(&p)                                   // INSERT,成功后 p.ID 被自动回填
db.First(&p, 1)                                 // WHERE id=1 ORDER BY id LIMIT 1;找不到 → ErrRecordNotFound
db.Take(&p)                                     // LIMIT 1(★ 没有 ORDER BY,顺序不保证)
db.Last(&p)                                     // ORDER BY id DESC LIMIT 1
db.Find(&ps)                                    // 多条;★ 找不到【不报错】,切片为空

db.Where("status = ?", 1).Find(&ps)             // ? 占位,走预处理不拼字符串
db.Where(&Post{Status: 1}).Find(&ps)            // struct 条件 —— ★ 零值字段被忽略(和 Updates 同一个坑)
db.Where(map[string]any{"status": 0}).Find(&ps) // map 条件 —— 零值不会被忽略

db.Model(&Post{}).Where("id = ?", 1).Update("status", 1)            // 单字段
db.Model(&Post{}).Where("id = ?", 1).Updates(map[string]any{...})   // 多字段
db.Save(&p)                                     // 主键有值 → 全字段 UPDATE;主键为零 → INSERT
db.Delete(&Post{}, 1)                           // 有 DeletedAt → UPDATE;没有 → DELETE

Where(struct) 的零值陷阱实测:

db.Where(&Post{Status: 0}).Limit(2).Find(&ps)
  → SELECT * FROM `posts` WHERE `posts`.`deleted_at` IS NULL LIMIT 2      ← ★ status 条件消失了!
db.Where(map[string]any{"status": 0}).Limit(2).Find(&ps)
  → SELECT * FROM `posts` WHERE `posts`.`status` = 0 AND ... LIMIT 2      ← ✅

「找不到」的三种语义(实测):

db.First(&p, 999999)   → err = "record not found"      errors.Is(err, gorm.ErrRecordNotFound) == true
db.Find(&ps, 999999)   → err = <nil>  RowsAffected=0   len(ps)=0
db.Raw(...).Scan(&x)   → err = <nil>  RowsAffected=0   ★ Raw+Scan 也不报 NotFound

对照三段式 ⑤:找不到怎么表达

第 3 课 GORM
单条 sql.ErrNoRows gorm.ErrRecordNotFound
判断 errors.Is(err, sql.ErrNoRows) errors.Is(err, gorm.ErrRecordNotFound)
多条 rows.Next() 直接 false Find 无错误,RowsAffected == 0

代价:换了个哨兵错误名,repository 里所有 sql.ErrNoRows 判断都要改成 gorm.ErrRecordNotFound但 repository 之外一行不用改——第 4 课已经确立了「repository 负责把技术错误翻译成领域错误」,翻译目标 model.ErrNotFound 没变,上层感知不到底下换了 ORM。

// internal/repository/gorm_post_repo.go
package repository

import (
    "context"
    "errors"
    "fmt"

    "gorm.io/gorm"

    "blog/internal/model"
)

// GormPostRepo 实现第 4 课在 service 包里定义的 PostRepository 接口
// (外加本课新增的两个标签方法)。
// ★★ 它和第 3 课的 SQLPostRepo 是【平级的两个实现】,service 通过接口拿到谁都能跑。
type GormPostRepo struct{ db *gorm.DB }

func NewGormPostRepo(db *gorm.DB) *GormPostRepo { return &GormPostRepo{db: db} }

// GetWithTags 按 ID 查文章并带上标签。
// 参数 ctx:必须一路传下去,超时/取消才生效。GORM 里靠 WithContext 注入。
// 找不到时返回 model.ErrNotFound,【不把 gorm.ErrRecordNotFound 泄露给上层】
//   —— 第 4 课的分工:repository 负责把技术错误翻译成领域错误。
//   领域错误住在 model 包(不是 repository 包),否则 handler 要 errors.Is 就得
//   反向 import repository,依赖箭头就倒了 —— 这是第 4 课专门搬过一次家的原因。
func (r *GormPostRepo) GetWithTags(ctx context.Context, id int64) (*model.Post, error) {
    var row postRow            // ★ 查进【持久化结构体】,不是 model.Post

    err := r.db.
        WithContext(ctx).   // ★ 把 ctx 注入这条链,底下走的是 QueryContext
        Preload("Tags").    // ★ 顺带把关联标签查出来,见 4.6
        First(&row, id).    // 第二个参数是主键值,等价于 Where("id = ?", id)
        Error               // ★ 链式调用返回 *gorm.DB,错误挂在 .Error 上

    if errors.Is(err, gorm.ErrRecordNotFound) {
        // %w 包装:上层 errors.Is(err, model.ErrNotFound) 依然认得出来,
        // 同时保留了"是哪个 id 没找到"的上下文
        return nil, fmt.Errorf("GetWithTags(%d): %w", id, model.ErrNotFound)
    }
    if err != nil {
        // 包一层上下文,出问题时日志能看出是哪个函数、哪个 id
        return nil, fmt.Errorf("GetWithTags(%d): %w", id, err)
    }
    return row.toDomain(), nil  // ★ 出口处转回领域对象,postRow 不越过 repository 边界
}

生成的 SQL(3 条,原因见 4.6):

SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 6
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2)
SELECT * FROM `posts` WHERE `posts`.`id` = 6 AND `posts`.`deleted_at` IS NULL ORDER BY id LIMIT 1

易错点:唯一键冲突拿不到 gorm.ErrDuplicatedKey。实测默认配置:

err  = Error 1062 (23000): Duplicate entry 'hello-go' for key 'posts.uk_posts_slug'
类型 = *mysql.MySQLError
errors.Is(err, gorm.ErrDuplicatedKey) = false          ← ★ 是 false!

根因:GORM 默认把驱动原始错误原样抛出。修法gorm.ConfigTranslateError: true(3.2 已开),之后:

err = duplicated key not allowed        errors.Is(err, gorm.ErrDuplicatedKey) = true  ✅

还没完gorm.ErrDuplicatedKey 依然是 GORM 的错误,不能泄露给 service。按第 4 课的分工再翻译一次:

// internal/repository/gorm_post_repo.go —— 技术错误 → 领域错误,repository 的本职工作
func (r *GormPostRepo) Create(ctx context.Context, p *model.Post) error {
    row := toRow(p)
    err := r.db.WithContext(ctx).Create(row).Error
    switch {
    case err == nil:
        p.ID = row.ID                    // 自增 ID 回填到领域对象
        return nil
    case errors.Is(err, gorm.ErrDuplicatedKey):
        // ★ 翻译成领域错误。handler 的 httpStatusFor 认得 model.ErrDuplicate → 409,
        //   它【一行都不用改】—— 第 3 课那版翻译的是 MySQL 1062,目标是同一个哨兵。
        return fmt.Errorf("Create(slug=%q): %w", p.Slug, model.ErrDuplicate)
    default:
        return fmt.Errorf("Create: %w", err)
    }
}

对照三段式 ⑥:错误翻译

第 3 课手写版 GORM 版 代价
识别唯一键冲突 isDuplicateKey(err)errors.As*mysql.MySQLErrorNumber == 1062 errors.Is(err, gorm.ErrDuplicatedKey) 必须先开 TranslateError,默认不开时这行恒 false
翻译目标 model.ErrDuplicate 同一个
上层影响

注意「翻译目标不变」这一列:正因为第 4 课把领域错误定在 model 包,这一课换 ORM 时 service 和 handler 的错误处理一行都没动

4.5 ★★★ UpdateStatus —— GORM 头号坑:Updates 忽略零值

错误现象:你写了「把状态改成 0(草稿)」,接口返回 200,日志没有任何报错,数据库里的值纹丝不动

复现(实测 SQL 逐条贴出):

// scratch/zero_trap_test.go
p := &Post{Title: "零值坑复现", Slug: "zero-trap", Content: "x", Status: 1, ViewCount: 99}
db.Create(p)   // id=4, status=1

db.Model(&Post{ID: p.ID}).Updates(Post{Status: 0})   // 写法 A:Updates 传 struct
[2.185ms] [rows:0] UPDATE `posts` SET `updated_at`='2026-09-06 09:31:39.41'
                   WHERE `posts`.`deleted_at` IS NULL AND `id` = 4
                       ↑★ SET 里根本没有 status!只更新了 updated_at

<<< A 之后 status=1 (期望 0)
Updates(Post{Status:0})  RowsAffected=0  err=<nil>       ← ★ 无声无息

根因Updates 收到 struct 时用反射遍历字段,跳过所有零值字段0""falsenil、零 time.Time)。GORM 无法区分「你没打算改这个字段」和「你想把它改成 0」——和第 7 课 binding:"required" 的零值陷阱是同一个根本问题:Go 的零值不携带「有没有被设置过」的信息。

三种修法(全部实测)

// 修法 1:用 map —— key 存在就是"要改",没有零值歧义 ★ 推荐
db.Model(&Post{}).Where("id = ?", id).Updates(map[string]any{"status": 0})
// → UPDATE `posts` SET `status`=0,`updated_at`='...' WHERE ... AND `id` = 4   rows:1 ✅

// 修法 2:Select 显式点名要更新的列,被点名的列即使零值也会写
db.Model(&Post{}).Where("id = ?", id).Select("status").Updates(Post{Status: 0})
// → UPDATE `posts` SET `status`=0,`updated_at`='...' ...   rows:1 ✅
// 多个:Select("status", "view_count");"除了这几列全改":Select("*").Omit("created_at")

// 修法 3:单字段直接 Update(单数)—— 不走零值过滤
db.Model(&Post{}).Where("id = ?", id).Update("status", 0)
// → UPDATE `posts` SET `status`=0,`updated_at`='...' ...   rows:1 ✅

SaveUpdates 的差别

Save 更新所有字段,包括零值和 NULL(实测):

UPDATE `posts` SET `author_id`=NULL,`title`='零值坑复现',`slug`='zero-trap',`summary`=NULL,
  `content`='x',`status`=2,`view_count`=0,`published_at`=NULL,`created_at`='2026-09-06 09:31:39',
  `updated_at`='...',`deleted_at`=NULL WHERE `posts`.`deleted_at` IS NULL AND `id` = 4
  ↑★ 每一个字段都写了,包括你没碰过的

Save 的危险:如果实体来自一个不完整的查询(如 Select("id, title").First(&got)),没查出来的字段在 struct 里是零值,Save把它们全写成零值——静默数据丢失。

Updates(struct) Updates(map) Save(&p)
零值字段 跳过
未提及的字段 跳过 跳过 写成当前 struct 的值
适用 只改非零字段(少见) ★ 日常首选 手里有完整实体、要整体覆盖
// internal/repository/gorm_post_repo.go

// UpdateStatus 改文章状态。
// ★ status 可能是 0(草稿),所以【绝对不能】用 Updates(model.Post{Status: status})。
// 用 map 是这个函数存在的全部理由,注释必须写清楚,否则下一个人会"顺手优化"成 struct。
func (r *GormPostRepo) UpdateStatus(ctx context.Context, id int64, status model.Status) error {
    tx := r.db.WithContext(ctx).
        Model(&model.Post{}).                       // 指定操作哪张表(不带主键,条件走 Where)
        Where("id = ?", id).
        Updates(map[string]any{"status": status})   // ★ map:status=0 也会真的写进去

    if tx.Error != nil {
        return fmt.Errorf("UpdateStatus(%d): %w", id, tx.Error)
    }
    // ★ RowsAffected == 0 有两种可能:行不存在(含被软删除),或新值和旧值一样。
    //   统一按"不存在"处理,让 handler 能返回 404。
    //   要严格区分得先 SELECT 一次,多一次往返,这里不值得。
    if tx.RowsAffected == 0 {
        return fmt.Errorf("UpdateStatus(%d): %w", id, model.ErrNotFound)
    }
    return nil
}

4.6 ★★ 关联与 Preload:亲眼看到 N+1

关系推断(实测)

Comments 关系类型 = has_many
    引用: posts.id -> comments.post_id

Tags 关系类型 = many_to_many
中间表推断名 = post_tags
    中间表字段 PostID -> 列 post_id      中间表字段 TagID -> 列 tag_id
    引用: 主表列=id -> 中间表列=post_id (OwnPrimaryKey=true)
    引用: 主表列=id -> 中间表列=tag_id  (OwnPrimaryKey=false)

多对多中间表的列名 = <两端结构体名>_<主键列名> 的 snake_case,即 post_id / tag_id——schema.sql 里手写的完全一致,什么都不用配。不一致时:

// internal/model/post.go —— 中间表列名和约定不一致时手动指定
Tags []Tag `gorm:"many2many:post_tags;joinForeignKey:article_id;joinReferences:label_id"`
//                                     ↑本侧在中间表的列    ↑对侧在中间表的列

schema.sql 里的联合主键为什么兼容? PRIMARY KEY (post_id, tag_id) 保证一对组合只出现一次。GORM 写中间表用的是 INSERT INTO post_tags (post_id, tag_id) VALUES (...) ON DUPLICATE KEY UPDATE post_id=post_id——它就指望有唯一约束来做幂等。没有这个联合主键,重复绑定同一标签会插出多行。

N+1 问题:先看病,再吃药

// scratch/n_plus_one_test.go —— ★ 反例:循环里查关联
var posts []Post
db.Where("id IN ?", ids).Find(&posts)                     // 第 1 条
for i := range posts {
    var tags []Tag
    db.Model(&posts[i]).Association("Tags").Find(&tags)   // 第 2..N+1 条
    posts[i].Tags = tags
}
SELECT * FROM `posts` WHERE id IN (6,7,8) AND `posts`.`deleted_at` IS NULL
SELECT `tags`.* FROM `tags` JOIN `post_tags` ON `post_tags`.`tag_id`=`tags`.`id` AND `post_tags`.`post_id` = 6
SELECT `tags`.* FROM `tags` JOIN `post_tags` ON `post_tags`.`tag_id`=`tags`.`id` AND `post_tags`.`post_id` = 7
SELECT `tags`.* FROM `tags` JOIN `post_tags` ON `post_tags`.`tag_id`=`tags`.`id` AND `post_tags`.`post_id` = 8

1 + 3 = 4 条。列表页一页 20 条就是 21 条;100 条就是 101 条。每条一次网络往返,接口从 5ms 变 200ms。

// ✅ 正解
db.Preload("Tags").Where("id IN ?", ids).Find(&posts)
SELECT * FROM `posts` WHERE id IN (6,7,8) AND `posts`.`deleted_at` IS NULL
SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` IN (6,7,8)
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2,3,4)

3 条,而且和文章数量无关——20 条、100 条还是 3 条。

实测澄清Preload 一个 many2many3 条(主表 + 中间表 + 目标表);has_many2 条(主表 + 子表);同时 preload Tags + Comments 实测 4 条。数量固定、不随行数增长,这才是重点。

Preload 的各种用法

// internal/repository/gorm_post_repo.go
db.Preload("Tags").Preload("Comments").Find(&ps)              // 多个关联,实测 4 条 SQL
db.Preload(clause.Associations).First(&p, id)                 // 全部一级关联(import gorm.io/gorm/clause)
db.Preload("Tags.Posts").First(&p, id)                        // 嵌套,★ 实测 5 条 SQL,层层放大,慎用
db.Preload("Comments", func(tx *gorm.DB) *gorm.DB {           // 条件 Preload
    return tx.Order("created_at DESC")
}).Find(&ps)
db.Preload("Comments", "user_id = ?", uid).Find(&ps)          // 条件简写

★ 易错点:条件 Preload 里的 Limit全局的,不是「每篇一条」

想「每篇文章带最新 1 条评论」,很多人写成 tx.Order("created_at DESC").Limit(1)。实测生成:

SELECT * FROM `comments` WHERE `comments`.`post_id` IN (6,7,8)
  AND `comments`.`deleted_at` IS NULL ORDER BY created_at DESC LIMIT 1
                                                               ↑★ 整个结果集只取 1 行
结果:post[0] comments=1    post[1] comments=0    post[2] comments=0

根因Preload 就是一条 WHERE post_id IN (...) 的查询,LIMIT 作用在这条查询上,不是 per-parent。 修法:「每组前 N 条」得用窗口函数(ROW_NUMBER() OVER (PARTITION BY post_id ORDER BY created_at DESC))——这就是该用 db.Raw() 的场景(见 4.8)。

Preload vs Joins

// scratch/joins_test.go —— Joins:用关联表做【过滤】
db.Joins("JOIN post_tags pt ON pt.post_id = posts.id").
    Joins("JOIN tags t ON t.id = pt.tag_id").
    Where("t.name = ?", "go").Distinct("posts.*").Find(&joined)
SELECT DISTINCT posts.* FROM `posts`
  JOIN post_tags pt ON pt.post_id = posts.id JOIN tags t ON t.id = pt.tag_id
  WHERE t.name = 'go' AND `posts`.`deleted_at` IS NULL

实测:过滤出 3 篇,第一篇的 Tags 字段长度=0    ← ★ Joins 不填充关联字段!
Preload Joins
目的 加载关联数据到结构体字段 用关联表做过滤/排序
SQL 多条独立查询 一条 JOIN
填充关联字段 ❌(Tags 依然是空切片)
一对多时行数 主表行数不变 会因 JOIN 放大,要 Distinct
适用 「查文章,顺带带上标签」 「查带 go 标签的文章」

两者可以叠加db.Joins("...").Where("t.name = ?", "go").Preload("Tags").Find(&ps)——用 Joins 筛、用 Preload 填。

关联写入

// internal/repository/gorm_post_repo.go
assoc := db.Model(&p).Association("Tags")
assoc.Append(&tag)     // 加,不动已有的
assoc.Replace(tags)    // ★ 全量替换:多的插、少的删
assoc.Delete(&tag)     // 解绑单个(只删中间表行,不删 tag 本身)
assoc.Clear()          // 清空所有绑定

Replace 实测生成的 SQL:

INSERT INTO `tags` (...) VALUES ('go',...,1),('tag2',...,4) ON DUPLICATE KEY UPDATE `id`=`id`  ← 先确保标签存在
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (8,1),(8,4) ON DUPLICATE KEY UPDATE `post_id`=`post_id`  ← 建绑定(幂等)
UPDATE `posts` SET `updated_at`='...' WHERE ... AND `id` = 8              ← 顺手 touch 主表
DELETE FROM `post_tags` WHERE `post_tags`.`post_id` = 8 AND `post_tags`.`tag_id` NOT IN (1,4)  ← 删掉不在新列表里的

★ 易错点:FullSaveAssociations 与「关联对象不会被更新」

默认行为:GORM 只 upsert 关联关系,不更新关联对象本身。实测:

var tg Tag
db.Where(Tag{Name: "go"}).First(&tg)
tg.Name = "GO-改过的名字"                        // 改了内存里的
db.Model(&p).Association("Tags").Append(&tg)
// 再查:数据库里的 name 还是 "go"               ← ★ 没变

根因:那条 ON DUPLICATE KEY UPDATE id=id 是个 no-op upsert,主键冲突时什么都不改。这通常正是你想要的(不会因为绑个标签就把标签名改了)。

真想连关联对象一起更新,用 db.Session(&gorm.Session{FullSaveAssociations: true}).Updates(&p)——但这很危险:它会把 p.Tags 里每个 Tag 的所有字段都 UPDATE 一遍,一个陈旧的 p 会把标签名写坏。建议不用它,标签改名单独 db.Model(&Tag{}).Where("id=?", id).Update("name", newName)

反过来,创建文章时不要顺手创建标签,用 Omit(实测标签确实没被 INSERT):

db.Omit("Tags").Create(&p)
db.Omit(clause.Associations).Create(&p)   // 跳过所有关联

4.7 CreateWithTags —— 闭包事务

对照三段式 ⑦:事务

// internal/repository/post_repo.go —— 第 3 课手写版
tx, err := r.db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()          // ★ 必须 defer,否则 return 早退时连接泄漏
                             //   (Commit 之后再 Rollback 是 no-op,安全)
res, err := tx.ExecContext(ctx, "INSERT INTO posts ...", ...)
if err != nil { return err } // ← defer 兜底回滚
id, err := res.LastInsertId()
if err != nil { return err }
for _, name := range names { /* ...插 tags、查 id、插 post_tags... */ }
return tx.Commit()           // ★ 忘了这行,整个事务白干
// internal/repository/gorm_post_repo.go —— GORM 闭包版

// CreateWithTags 在【一个事务里】创建文章并绑定标签。
// 参数 p 是指针:GORM 会把自增 ID 回填进 p.ID,调用方拿得到。
func (r *GormPostRepo) CreateWithTags(ctx context.Context, p *model.Post, names []string) error {
    // Transaction 的语义:
    //   闭包 return nil   → 自动 COMMIT
    //   闭包 return error → 自动 ROLLBACK,并把这个 error 原样返回
    //   闭包 panic        → 自动 ROLLBACK,然后把 panic 继续往上抛
    // ★ 不用写 defer Rollback,也不会忘记 Commit。
    return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        // ★★★ 闭包里【每一次】数据库调用都必须用 tx,不能用 r.db。
        //     用 r.db 就是走另一条连接,那条语句根本不在事务里(见下面实测)。
        if err := tx.Create(p).Error; err != nil {
            return fmt.Errorf("create post: %w", err)
        }
        if len(names) == 0 {
            return nil                                    // 提前返回 nil = 提交,不是取消
        }

        tags := make([]model.Tag, 0, len(names))
        for _, name := range names {
            var t model.Tag
            // FirstOrCreate:先按条件查,查不到就用条件里的值创建。
            // Where 传 struct 时零值字段被忽略(和 Updates 同一套规则),
            // 这里只有 Name 一个非零字段,正好是我们要的语义。
            if err := tx.Where(model.Tag{Name: name}).FirstOrCreate(&t).Error; err != nil {
                return fmt.Errorf("firstOrCreate tag %q: %w", name, err)
            }
            tags = append(tags, t)
        }

        if err := tx.Model(p).Association("Tags").Replace(tags); err != nil {   // 全量替换,幂等
            return fmt.Errorf("bind tags: %w", err)
        }
        p.Tags = tags                                     // 回填,方便 handler 直接返回
        return nil                                        // ← COMMIT
    })
}

实测三种结局:

=== 成功提交 ===      提交结果 err=<nil> id=9  ✅
=== 返回 error ===    回滚结果 err=业务规则不满足;回滚后该 slug 的行数=0  ✅
=== panic ===         recover 到 panic: boom;panic 后该 slug 的行数=0  ✅
                      (panic 被重新抛出,你得自己在外层 recover)

★★ 高频坑:闭包里误用 db 而不是 tx

// ❌ 反例
db.Transaction(func(tx *gorm.DB) error {
	db.Create(&Post{Title: "漏网文章", Slug: "tx-leak"})   // ← 用了外层的 db!
	return fmt.Errorf("回滚")
})

实测:用 db 写的那条 err=回滚,回滚后行数=1★ 事务回滚了,这行还在。

错误现象:事务「回滚成功」,但部分数据留在库里,数据不一致。而且编译器完全不管——dbtx 都是 *gorm.DB,类型一模一样。

根因tx 绑定在某条具体连接上,db 会从连接池另拿一条。两条连接互不知道对方。

防范:① 闭包参数统一叫 tx,code review 时一眼扫出闭包里的 db.;② 把 repository 方法拆成「收 *gorm.DB 参数」的形式,让事务能一路传下去:

// internal/repository/gorm_post_repo.go
func (r *GormPostRepo) createTx(tx *gorm.DB, p *model.Post) error { /* 只用 tx */ }

func (r *GormPostRepo) Create(ctx context.Context, p *model.Post) error {
	return r.createTx(r.db.WithContext(ctx), p)        // 非事务:传 db
}
func (r *GormPostRepo) CreateWithTags(ctx context.Context, p *model.Post, names []string) error {
	return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		return r.createTx(tx, p)                       // 事务:传 tx
	})
}

4.8 逃生舱:RawExec

复杂查询直接写 SQL 是完全正当的选择,不是「没学会 GORM」。

// internal/repository/gorm_post_repo.go

// TagStat 只是这个查询的返回容器,不是模型,不用管表名约定。
type TagStat struct {
    TagName string `json:"tag_name"`   // 列 tag_name → 字段 TagName,靠 snake_case 反推
    Cnt     int64  `json:"cnt"`
}

// TopTags 统计标签使用次数。
// 用 Raw 的理由:GROUP BY + JOIN + ORDER BY 聚合值,用链式 API 写出来没人看得懂。
func (r *GormPostRepo) TopTags(ctx context.Context, limit int) ([]TagStat, error) {
    var out []TagStat
    err := r.db.WithContext(ctx).Raw(`
        SELECT t.name AS tag_name, COUNT(*) AS cnt
        FROM tags t
        JOIN post_tags pt ON pt.tag_id = t.id
        JOIN posts p      ON p.id = pt.post_id
        WHERE p.deleted_at IS NULL          -- ★ Raw 不会自动加软删除条件,得自己写
        GROUP BY t.name ORDER BY cnt DESC
        LIMIT ?`, limit).Scan(&out).Error   // ★ Raw 后面用 Scan,不是 Find
    if err != nil {
        return nil, fmt.Errorf("TopTags: %w", err)
    }
    return out, nil
}

// IncrView 原子自增,不要"先查再加再写"(有并发丢更新)
func (r *GormPostRepo) IncrView(ctx context.Context, id int64) error {
    tx := r.db.WithContext(ctx).Exec(
        "UPDATE posts SET view_count = view_count + 1 WHERE id = ? AND deleted_at IS NULL", id)
    if tx.Error != nil {
        return fmt.Errorf("IncrView(%d): %w", id, tx.Error)
    }
    if tx.RowsAffected == 0 {
        return fmt.Errorf("IncrView(%d): %w", id, model.ErrNotFound)
    }
    return nil
}

实测:rows=[{TagName:go Cnt:3} {TagName:tag0 Cnt:1} {TagName:tag1 Cnt:1} ...]

Raw 必须记住三件事:① 参数一律用 ? 占位,绝不 fmt.Sprintf 拼字符串(SQL 注入);② Raw 不加软删除条件,需要就自己写;③ Raw + Scan 找不到不报 ErrRecordNotFound(实测 err=<nil> RowsAffected=0),靠 RowsAffected 判断。

4.9 ★ 高光时刻:service 和 handler 一行都不用改

// internal/service/post_service.go —— 第 4 课写的,这一课【一个字都没动】
// ★ 接口定义在【消费方】(service 包),不在 repository 包 —— 第 4 课 3.5 节的原则。
//   repository 包完全不知道这个接口存在,它只管把方法写对。
type PostRepository interface {
    Create(ctx context.Context, p *model.Post) error
    GetByID(ctx context.Context, id int64) (*model.Post, error)
    List(ctx context.Context, f model.PostFilter) ([]model.Post, error)
    Update(ctx context.Context, p *model.Post) error
    SoftDelete(ctx context.Context, id int64) error
    ExistsBySlug(ctx context.Context, slug string) (bool, error)
}

第 4 课当时明说了「没有 CreateWithTags —— 那是第 8 课标签功能的事」。现在就是那一刻:标签功能上线,service 多了新需求,于是在消费方追加两个方法

// internal/service/post_service.go —— 本课【新增】的部分
type PostRepository interface {
    // ... 上面 6 个原样保留 ...

    // 本课新增:标签相关。加在这里而不是新建一个接口,
    // 是因为消费方(PostService)确实同时要用这 8 个方法。
    GetWithTags(ctx context.Context, id int64) (*model.Post, error)
    CreateWithTags(ctx context.Context, p *model.Post, names []string) error
    UpdateStatus(ctx context.Context, id int64, status model.Status) error
}

注意这里发生了什么:接口变了,是因为业务需求变了(要标签),不是因为换了 ORM。这两件事在这一课同时发生,但彼此独立——你完全可以先用 database/sql 实现这三个新方法,下周再换 GORM。

// internal/service/post_service.go —— 这一课【一个字都没动】
type PostService struct {
    repo PostRepository        // ★ 依赖的是接口,不是任何具体实现
}

这一课唯一要改的,是 main.go 里的两行

// cmd/server/main.go —— 换实现只改装配处
func main() {
    // ---- 第 3~7 课:database/sql 实现 ----
    // sqlDB, err := repository.OpenMySQL(cfg.DSN)
    // repo := repository.NewSQLPostRepo(sqlDB)

    // ---- 第 8 课:GORM 实现。★ 下面所有代码原封不动 ----
    gdb, err := repository.OpenGorm(cfg.DSN)
    if err != nil {
        log.Fatal(err)
    }
    repo := repository.NewGormPostRepo(gdb)     // ← 只有这两行变了

    svc := &service.PostService{Repo: repo}     // service 收接口,给它谁都行
    h := &handler.PostHandler{Svc: svc}
    auth := &middleware.AuthService{Secret: cfg.JWTSecret}
    r := handler.NewRouter(h, auth)             // 第 7 课的路由装配,没动
    log.Fatal(r.Run(cfg.Addr))
}

这就是第 4 课那堆「为什么要多写一个接口」的回报

handler 不知道有 GORM,service 不知道有 GORM,只有 repository 包 import 了 gorm.io/gorm(前提是你选了 4.1 的方案 B)。

更值得注意的是错误链路也一行没改

gorm.ErrRecordNotFound              ← GORM 的技术错误
  ↓ repository 用 %w 翻译
model.ErrNotFound                   ← 第 4 课定的领域错误,没变
  ↓ service 原样透传
  ↓ handler 的 httpStatusFor(err)   ← 第 4 课的纯函数,没变
404 {"code":"NOT_FOUND","message":"资源不存在"}   ← 第 2 课的契约,没变

实测确认这条链是通的:errors.Is(err, model.ErrNotFound) = true,curl 打出来就是 404 + NOT_FOUND

更实际的好处:可以同时留着两个实现,用配置切换,灰度验证行为一致后再删老的:

var repo service.PostRepository
if cfg.UseGorm {
	repo = repository.NewGormPostRepo(gdb)
} else {
	repo = repository.NewSQLPostRepo(sqlDB)
}

如果当初把 *sql.DB 直接传进 service,这一课就得改遍整个项目。架构的价值只在换东西的那一刻兑现,平时它看起来就是多余的样板代码。

⑤ 跑起来验证

关键动作:开着 SQL 日志跑,对照日志看 GORM 生成的每一条 SQL。

cd ~/go-blog && go build ./... && go run ./cmd/server 2>&1 | tee /tmp/gorm.log

如果 Logger 忘了设成 logger.Info回去设,本节没法验证

1) 创建带标签的文章 —— 看事务

curl -s -w ' <- %{http_code}\n' -X POST localhost:8080/api/v1/posts \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d "{\"title\":\"GORM 关联实战\",\"slug\":\"gorm-assoc-$(date +%s)\",\"content\":\"正文\",\"status\":1,\"tags\":[\"go\",\"gorm\",\"orm\"]}"
{"id":19,"title":"GORM 关联实战","tags":[{"id":1,"name":"go"},{"id":6,"name":"gorm"},{"id":7,"name":"orm"}]} <- 201

日志里应该看到一整个事务:

INSERT INTO `posts` (...) VALUES (NULL,'GORM 关联实战','gorm-assoc-1788712550',...)
SELECT * FROM `tags` WHERE `tags`.`name` = 'go'   ORDER BY `tags`.`id` LIMIT 1
SELECT * FROM `tags` WHERE `tags`.`name` = 'gorm' ORDER BY `tags`.`id` LIMIT 1
INSERT INTO `tags` (`name`,`created_at`) VALUES ('gorm',...)
SELECT * FROM `tags` WHERE `tags`.`name` = 'orm'  ORDER BY `tags`.`id` LIMIT 1
INSERT INTO `tags` (`name`,`created_at`) VALUES ('orm',...)
INSERT INTO `post_tags` (`post_id`,`tag_id`) VALUES (19,1),(19,6),(19,7) ON DUPLICATE KEY UPDATE ...
DELETE FROM `post_tags` WHERE `post_tags`.`post_id` = 19 AND `post_tags`.`tag_id` NOT IN (1,6,7)

观察:3 个标签产生了 3 次 SELECT——FirstOrCreate 是逐个查的,这也是一种 N+1。练习 3 会让你优化掉。

mysql -uroot -p123456 blog_dev -e \
 "SELECT p.id,p.title,p.status,GROUP_CONCAT(t.name) tags FROM posts p
  LEFT JOIN post_tags pt ON pt.post_id=p.id LEFT JOIN tags t ON t.id=pt.tag_id
  WHERE p.id=19 GROUP BY p.id;"
id  title            status  tags
19  GORM 关联实战     1       go,gorm,orm

2) 查详情 —— 数 Preload 的 SQL 条数

curl -s localhost:8080/api/v1/posts/19 | head -c 120; echo

日志里应该正好 3 条

SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 19
SELECT * FROM `tags` WHERE `tags`.`id` IN (1,6,7)
SELECT * FROM `posts` WHERE `posts`.`id` = 19 AND `posts`.`deleted_at` IS NULL ORDER BY id LIMIT 1
                                                  ↑★ 软删除条件是 GORM 自动加的

3) 列表 —— 确认 Preload 条数不随行数增长

curl -s 'localhost:8080/api/v1/posts?size=3'  > /dev/null
curl -s 'localhost:8080/api/v1/posts?size=20' > /dev/null

两次日志里的 SQL 条数应该都是 3 条。如果 size=20 那次变成 21 条,说明 List 里漏了 Preload,正在循环查关联。

4) ★ PATCH status=0 —— 验证零值坑修对了

curl -s -w ' <- %{http_code}\n' -X PATCH localhost:8080/api/v1/posts/19 \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{"status":0}'
mysql -uroot -p123456 blog_dev -e "SELECT id,status FROM posts WHERE id=19;"
{"id":19,"status":0} <- 200

UPDATE `posts` SET `status`=0,`updated_at`='...' WHERE `posts`.`deleted_at` IS NULL AND `id` = 19
                   ↑★ 日志里有这个才对

id  status
19  0        ← 真的改成 0 了

故意做错一次:把 UpdateStatus 里的 map 改成 Updates(model.Post{Status: status}) 重跑,接口依然返回 200,但日志变成 UPDATE posts SET updated_at='...' WHERE ...——status 消失了,数据库里还是旧值。亲手看一次这个现象,比记十遍规则管用。

5) 软删除

curl -s -w ' <- %{http_code}\n' -X DELETE localhost:8080/api/v1/posts/19 -H "Authorization: Bearer $TOK"
curl -s -w ' <- %{http_code}\n' localhost:8080/api/v1/posts/19
mysql -uroot -p123456 blog_dev -e "SELECT id,title,deleted_at FROM posts WHERE id=19;"
{"ok":true} <- 200
{"error":"文章不存在"} <- 404       ← 程序看不见了

id  title            deleted_at
19  GORM 关联实战     2026-09-06 09:31:51    ← 数据库里还在

日志里那条"删除"其实是:
UPDATE `posts` SET `deleted_at`='2026-09-06 09:31:51.104' WHERE `posts`.`id` = 19 AND `posts`.`deleted_at` IS NULL

⑥ TODO 练习

练习 1:把评论 repository 也换成 GORM

// internal/repository/gorm_comment_repo.go
// TODO(练习1): 实现 model.CommentRepository 接口的 GORM 版本:
//   - ListByPost(ctx, postID int64, page, size int) ([]Comment, error)
//   - Create(ctx, c *Comment) error
//   - SoftDelete(ctx, id int64) error
//   要求:a) 一律用 WithContext 传 ctx
//        b) 找不到时用 %w 包装 model.ErrNotFound,不泄露 gorm.ErrRecordNotFound
//        c) 【不许改】service 和 handler 的任何代码

验收标准grep -rn "gorm.io" internal/service internal/handler 输出为空;curl localhost:8080/api/v1/posts/1/comments 返回 200 且有数据;日志里 ListByPost 只产生 1 条 SQL。

练习 2:修一个零值 bug

// internal/repository/gorm_post_repo.go
// TODO(练习2): 下面这个函数有 bug —— 把 view_count 重置为 0 时不生效。
//   先【不改代码】跑一次,在 SQL 日志里找到证据(贴出那条 UPDATE),
//   再用本课三种修法里【任选两种】各写一版,并说明为什么选其中一种上线。
func (r *GormPostRepo) ResetViewCount(ctx context.Context, id int64) error {
    return r.db.WithContext(ctx).Model(&model.Post{}).Where("id = ?", id).
        Updates(model.Post{ViewCount: 0}).Error       // ← bug 在这里
}

验收标准:交出修之前那条 UPDATE ... SET updated_at=...(没有 view_count)的日志;修之后日志里有 SET view_count=0SELECT view_count FROM posts WHERE id=1 返回 0;说明里提到「RowsAffected=0err=nil」这个静默失败特征。

练习 3:干掉 FirstOrCreate 的 N+1

// internal/repository/gorm_post_repo.go
// TODO(练习3): 现在 CreateWithTags 里每个标签查一次(N 个标签 = N 次 SELECT + 最多 N 次 INSERT)。
//   改成【最多 2 条 SQL】:
//     1. 用 clause.OnConflict{DoNothing: true} 批量 INSERT 所有标签
//     2. 用 Where("name IN ?", names).Find(&tags) 一次全查回来
//   提示:import "gorm.io/gorm/clause"
//        tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&tags)

验收标准:传 5 个标签创建一篇文章,日志里标签相关 SQL 不超过 2 条(不含 post_tags 写入);重复创建同名标签不报 1062;SELECT COUNT(*) FROM tags WHERE name='go' 始终是 1。

练习 4:Preload vs Joins 各写一版

// internal/repository/gorm_post_repo.go
// TODO(练习4): 实现 ListByTag(ctx, tagName string, f model.PostFilter) ([]model.Post, error)
//   ——「查出所有带某个标签的文章,并且每篇都要带上它的【全部】标签」。
//   版本 A: 只用 Preload(想想:能做到吗?为什么?)
//   版本 B: Joins 做过滤 + Preload 做填充
//   在注释里贴出两版生成的 SQL,说明为什么 B 是对的。

验收标准:版本 B 的结果里每篇文章的 Tags 包含它的所有标签,不只是被筛选的那一个;注释里说清了「Preload("Tags", "name = ?", tagName) 为什么不是答案」(它筛的是加载哪些标签,不是筛哪些文章);版本 B 用了 Distinct 且 SQL 日志证明主表没有因 JOIN 出现重复行。

练习 5:软删除的唯一索引冲突

// docs/soft-delete-unique.md
// TODO(练习5): 复现并解决:
//   1. 创建 slug 为 "dup-test" 的文章 → 200
//   2. 软删除它 → 200
//   3. 再创建 slug 为 "dup-test" 的文章 → ?
//   把第 3 步的报错原文贴出来,分析根因,给出【至少两种】解决方案并说明各自代价。
//   (提示方向:slug 加删除后缀 / deleted_at 参与联合唯一索引 / 硬删除 + 归档表)

验收标准:贴出真实报错——实测是 Error 1062 (23000): Duplicate entry 'dup-test' for key 'posts.uk_posts_slug',开了 TranslateError 则是 duplicated key not allowed;两种方案都写清代价(比如「deleted_at 参与联合唯一索引」在 MySQL 里因为 NULL 不参与唯一性判断,需要用 0 而不是 NULL 表示未删除);选一种实现并跑通。

⑦ 自检清单

  • go get 的是 gorm.io/gorm,不是 github.com/jinzhu/gorm
  • 我知道 GORM 底下跑的还是 database/sql + 第 3 课那个 MySQL 驱动。
  • 我开了 logger.Info,并且真的读过它生成的 SQL。
  • 我用 db.DB() 取回 *sql.DB 自己设了连接池参数(GORM 不管这个)。
  • 我能说出 PostpostsAuthorIDauthor_id 的推断规则,以及怎么用 TableName() / column 覆盖。
  • 我没有用 gorm.Model,四个字段是自己写的,并且说得出三条理由。
  • 我知道 model.Post 直接给 GORM 用会有两个静默问题(Summary string 写成 '' 而非 NULL、DeletedAt *time.Time 让软删除完全失效变成物理删除),并且显式选择了方案 A 或方案 B,说得出自己这个选择的代价。
  • 如果我选了方案 B,我知道 TableName() 不改 many2many 的关联列推断(会推出 post_row_id),已经写了 joinForeignKey:post_id
  • 我的领域错误住在 model 包,repository 用 %wgorm.ErrRecordNotFound / gorm.ErrDuplicatedKey 翻译成 model.ErrNotFound / model.ErrDuplicate
  • 我知道 AutoMigrate 不删列但会改列类型,生产不依赖它,schema.sql 是唯一真相源。
  • 我能说清加了 gorm.DeletedAt 之后,Find / Count / Updates / Delete 各自的 SQL 变成了什么。
  • 我知道 Unscoped() 是软删除的逃生舱,也知道软删除会和唯一索引打架。
  • 我知道 Updates(struct)Where(struct) 都会静默跳过零值字段,能背出三种修法,项目里改状态用的是 map
  • 我知道 Save 会写所有字段,包括从不完整查询里来的零值。
  • 我用 errors.Is(err, gorm.ErrRecordNotFound) 判断单条找不到,并知道 Find / Raw+Scan 不报这个错。
  • 我开了 TranslateError: true,否则 errors.Is(err, gorm.ErrDuplicatedKey) 恒为 false。
  • 我在 SQL 日志里亲眼数过 N+1 的 N 条查询,也数过 Preload 之后固定的 3 条。
  • 我知道条件 Preload 里的 Limit 是全局的,「每组前 N 条」得用窗口函数 + Raw
  • 我知道 Joins 用来过滤、Preload 用来填充,Joins 不会填充关联字段。
  • 我的事务闭包里每一次数据库调用都用了 tx,没有一处 db.
  • 我知道复杂报表用 db.Raw() 是正当选择,Raw 里参数用 ? 占位、软删除条件自己写。
  • 这一课改完之后,internal/service/internal/handler/ 一行代码都没动,只改了 main.go 的两行装配(service 里的接口只是追加了三个标签方法,那是业务需求变了,不是换 ORM 逼的)。
  • 我能画出「gorm.ErrRecordNotFoundmodel.ErrNotFoundhttpStatusFor → 404 NOT_FOUND」这条链,并说清中间哪几段是换框架时零改动的。

下一课预告:第 9 课加 Redis 缓存。你会发现一个新问题——GORM 的软删除、UpdatedAt 自动更新这些「隐式行为」,在加了缓存之后会变成缓存一致性的坑(数据库里悄悄变了,缓存不知道)。我们会用第 3 课学的「显式优于隐式」原则来处理它。