跳转到正文
zeno's blog
返回

GORM(二):关联与预加载

专题: GORM

Table of contents

Open Table of contents

TL;DR

GORM 支持四种关联类型(Belongs To / Has One / Has Many / Many To Many)和多态关联。默认是懒加载(不自动加载关联),用 Preload 做 eager loading(2 条 SQL 解决 N+1),用 Joins 做单条 JOIN 查询(适合按关联字段过滤)。Association Mode 提供 Find/Append/Replace/Delete/Clear/Count 六种操作管理关联关系。


一、四种关联类型

1. Belongs To(属于)

外键在当前模型上。每个 User 属于一个 Company:

type User struct {
    gorm.Model
    Name      string
    CompanyID int      // 外键(约定:关联类型名 + 主键名)
    Company   Company  // 关联字段
}

type Company struct {
    ID   int
    Name string
}

外键约定CompanyID = 关联类型名 Company + 主键名 ID

自定义外键

type User struct {
    gorm.Model
    Name         string
    CompanyRefer int
    Company      Company `gorm:"foreignKey:CompanyRefer"` // 用 CompanyRefer 而不是 CompanyID
}

自定义引用字段(不引用主键,引用其他字段):

type User struct {
    gorm.Model
    Name      string
    CompanyID string
    Company   Company `gorm:"references:Code"` // 引用 Company.Code 而不是 Company.ID
}

type Company struct {
    ID   int
    Code string
    Name string
}

外键约束(迁移时生效):

Company Company `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`

2. Has One(拥有一个)

外键在关联模型上。每个 User 有一张 CreditCard:

type User struct {
    gorm.Model
    CreditCard CreditCard // 关联字段
}

type CreditCard struct {
    gorm.Model
    Number string
    UserID uint   // 外键(约定:拥有者类型名 + 主键名)
}

与 Belongs To 的区别:外键的位置不同。Belongs To 的外键在声明关联的模型上(User.CompanyID),Has One 的外键在被关联的模型上(CreditCard.UserID)。

自引用

type User struct {
    gorm.Model
    Name      string
    ManagerID *uint
    Manager   *User
}

3. Has Many(拥有多个)

外键在关联模型上,一对多。每个 User 有多张 CreditCard:

type User struct {
    gorm.Model
    CreditCards []CreditCard // 切片 = Has Many
}

type CreditCard struct {
    gorm.Model
    Number string
    UserID uint   // 外键
}

自定义外键

type User struct {
    gorm.Model
    CreditCards []CreditCard `gorm:"foreignKey:UserRefer"`
}

type CreditCard struct {
    gorm.Model
    Number    string
    UserRefer uint // 自定义外键名
}

自定义引用字段

type User struct {
    gorm.Model
    MemberNumber string
    CreditCards  []CreditCard `gorm:"foreignKey:UserNumber;references:MemberNumber"`
}

type CreditCard struct {
    gorm.Model
    Number     string
    UserNumber string // 引用 User.MemberNumber
}

自引用(树形结构)

type User struct {
    gorm.Model
    Name      string
    ManagerID *uint
    Team      []User `gorm:"foreignkey:ManagerID"` // 一个 manager 有多个下属
}

4. Many To Many(多对多)

通过中间表关联。一个 User 可以说多种 Language,一种 Language 可以被多个 User 说:

type User struct {
    gorm.Model
    Languages []Language `gorm:"many2many:user_languages;"` // 指定中间表名
}

type Language struct {
    gorm.Model
    Name  string
    Users []*User `gorm:"many2many:user_languages;"` // 反向引用(可选)
}

AutoMigrate 会自动创建 user_languages 中间表,包含 user_idlanguage_id 两个外键。

自引用多对多(好友关系):

type User struct {
    gorm.Model
    Friends []*User `gorm:"many2many:user_friends"`
}
// 中间表 user_friends 包含 user_id 和 friend_id

自定义中间表(需要在中间表上加额外字段时):

type PersonAddress struct {
    PersonID  int            `gorm:"primaryKey"`
    AddressID int            `gorm:"primaryKey"`
    CreatedAt time.Time
    DeletedAt gorm.DeletedAt
}

// 在查询前设置
err := db.SetupJoinTable(&Person{}, "Addresses", &PersonAddress{})

自定义外键和引用

type User struct {
    gorm.Model
    Profiles []Profile `gorm:"many2many:user_profiles;foreignKey:Refer;joinForeignKey:UserReferID;References:UserRefer;joinReferences:ProfileRefer"`
    Refer    uint      `gorm:"index:,unique"`
}

四个 tag 的含义:

四种关联的判断口诀

关系外键在哪数量典型场景
Belongs To当前模型一对一User 属于 Company
Has One关联模型一对一User 有一张 CreditCard
Has Many关联模型一对多User 有多张 CreditCard
Many To Many中间表多对多User 说多种 Language

关键区分:Belongs To vs Has One 的唯一区别是外键的归属。外键在谁身上,谁就是 “belongs to” 的那一方。


二、多态关联(Polymorphic)

多态让不同类型的模型共享同一个关联表。GORM 通过 OwnerType 字段区分所属类型。仅支持 Has One 和 Has Many。

基础用法

type Dog struct {
    ID   int
    Name string
    Toys []Toy `gorm:"polymorphic:Owner;"` // 声明多态,前缀为 Owner
}

type Cat struct {
    ID   int
    Name string
    Toy  Toy `gorm:"polymorphic:Owner;"` // Has One 也支持
}

type Toy struct {
    ID        int
    Name      string
    OwnerID   int    // 多态外键(Owner + ID)
    OwnerType string // 多态类型字段(Owner + Type),值为表名如 "dogs"/"cats"
}

生成的 SQL

-- 创建 Dog 和它的 Toys
INSERT INTO `dogs` (`name`) VALUES ("dog1")
INSERT INTO `toys` (`name`,`owner_id`,`owner_type`) VALUES
  ("toy1", 1, "dogs"),
  ("toy2", 1, "dogs")

自定义多态标签

type Dog struct {
    ID   int
    Name string
    Toys []Toy `gorm:"polymorphicType:Kind;polymorphicId:OwnerID;polymorphicValue:master"`
}

type Toy struct {
    ID      int
    Name    string
    OwnerID int
    Kind    string // 类型字段改为 Kind
}
INSERT INTO `toys` (`name`,`owner_id`,`kind`) VALUES
  ("toy1", 1, "master"),  -- kind 存的是 "master" 而非 "dogs"
  ("toy2", 1, "master")

何时用多态

适用:多种不同类型的模型需要相同的关联结构(评论系统——文章、视频、帖子都能有评论)。

不适用:关联结构差异大、需要外键约束(多态无法建数据库级外键约束)、查询性能敏感(多一个 WHERE 条件过滤类型)。


三、Association Mode(关联模式)

Association Mode 提供了操作关联关系的统一接口,适用于所有关联类型。

var user User
db.First(&user, 1)

// 进入 Association Mode
assoc := db.Model(&user).Association("Languages")
if assoc.Error != nil {
    // 处理错误
}

六种操作

// 1. Find — 查找关联
var languages []Language
db.Model(&user).Association("Languages").Find(&languages)

// 带条件查找
codes := []string{"zh-CN", "en-US", "ja-JP"}
db.Model(&user).Where("code IN ?", codes).Association("Languages").Find(&languages)

// 2. Append — 添加关联
// many2many / has many:追加新关联
db.Model(&user).Association("Languages").Append([]Language{languageZH, languageEN})
// belongs to / has one:替换当前关联
db.Model(&user).Association("CreditCard").Append(&CreditCard{Number: "411111111111"})

// 3. Replace — 替换所有关联
db.Model(&user).Association("Languages").Replace([]Language{languageZH, languageEN})

// 4. Delete — 删除关联(仅删关系,不删记录;将外键设为 NULL)
db.Model(&user).Association("Languages").Delete(languageZH, languageEN)

// 5. Clear — 清空所有关联
db.Model(&user).Association("Languages").Clear()

// 6. Count — 计数
count := db.Model(&user).Association("Languages").Count()

批量操作

var users = []User{user1, user2, user3}

// 批量查找
db.Model(&users).Association("Role").Find(&roles)

// 批量追加(每个 user 分别追加对应的关联)
db.Model(&users).Association("Team").Append(&userA, &userB, &[]User{userA, userB, userC})

// 批量替换
db.Model(&users).Association("Team").Replace(&userA, &userB, &[]User{userA, userB, userC})

// 批量计数
db.Model(&users).Association("Team").Count()

真正删除关联记录

默认行为:Replace/Delete/Clear 只是把外键设为 NULL,不删除记录。用 Unscoped() 真正删除:

// 删除关联关系并物理删除记录
db.Unscoped().Model(&user).Association("Languages").Unscoped().Clear()

Association Tags 速查

Tag作用
foreignKey当前模型中作为外键的字段
references关联模型中被引用的字段
polymorphic声明多态,指定前缀
polymorphicValue自定义多态类型值(默认表名)
many2many指定多对多中间表名
joinForeignKey中间表中引用当前模型的列
joinReferences中间表中引用关联模型的列
constraint外键约束(OnUpdate:CASCADE,OnDelete:SET NULL

四、Preloading(预加载)——核心话题

N+1 问题:怎么产生的

GORM 默认懒加载——查询 User 不会自动加载关联的 Orders。如果你循环访问每个 User 的 Orders:

var users []User
db.Find(&users)              // 1 条 SQL:SELECT * FROM users
for _, u := range users {
    db.Model(&u).Association("Orders").Find(&u.Orders)  // 每个 user 1 条 SQL
}
// 10 个用户 = 1 + 10 = 11 条 SQL(N+1)

Preload 如何解决 N+1

var users []User
db.Preload("Orders").Find(&users)
// SQL 1: SELECT * FROM users;
// SQL 2: SELECT * FROM orders WHERE user_id IN (1,2,3,...);
// 总共 2 条 SQL,无论多少用户

内部执行流程(8 步):

  1. Preload("Orders") 把关联名存入 Statement.Preloads map
  2. 主查询执行,获取所有 User
  3. Preload 回调触发
  4. 从 User 结果中提取所有外键值(UserID),构建 Identity Map{"1": &User{ID:1}, "2": ...}
  5. IN 子句查询关联表:SELECT * FROM orders WHERE user_id IN (1,2,3,...)
  6. 用 Identity Map 做 O(1) 匹配,把 Order 分配到对应的 User
  7. 设置 User 的 Orders 字段

性能对比

场景不用 Preload用 Preload优化幅度
100 个 User,各 5 个 Order101 条 SQL2 条 SQL98%
嵌套:100 User → Orders → OrderItems10,101 条 SQL3 条 SQL99.97%

多个关联的 Preload

db.Preload("Orders").Preload("Profile").Preload("Role").Find(&users)
// SQL 1: SELECT * FROM users;
// SQL 2: SELECT * FROM orders WHERE user_id IN (...);     -- has many
// SQL 3: SELECT * FROM profiles WHERE user_id IN (...);   -- has one
// SQL 4: SELECT * FROM roles WHERE id IN (...);           -- belongs to

嵌套 Preload(用点号表示)

// 加载 User → Orders → OrderItems → Product
db.Preload("Orders.OrderItems.Product").Preload("CreditCard").Find(&users)
// N 层嵌套 = N+1 条 SQL(线性增长,不是指数增长)

嵌套带条件:

db.Preload("Orders", "state = ?", "paid").Preload("Orders.OrderItems").Find(&users)
// 只预加载 state="paid" 的 Orders,以及这些 Orders 的 OrderItems

条件 Preload

// 方式一:直接传条件
db.Preload("Orders", "state NOT IN (?)", "cancelled").Find(&users)

// 方式二:传闭包(更灵活)
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
    return db.Order("orders.amount DESC")
}).Find(&users)

// 与主查询条件组合
db.Where("state = ?", "active").
   Preload("Orders", "state NOT IN (?)", "cancelled").
   Find(&users)

Preload All

// 预加载所有一级关联
db.Preload(clause.Associations).Find(&users)

// 先指定嵌套,再 Preload All(嵌套不会被 Associations 覆盖)
db.Preload("Orders.OrderItems.Product").Preload(clause.Associations).Find(&users)

注意clause.Associations 只加载一级关联,不会递归加载嵌套关联。

Joins Preloading(JOIN 预加载)

LEFT JOIN 代替独立查询,一条 SQL 搞定。只适合一对一关系(Has One / Belongs To):

db.Joins("Company").Find(&users)
// SQL: SELECT users.*, Company.* FROM users LEFT JOIN companies AS Company ON ...

// 带条件的 Joins
db.Joins("Company", db.Where(&Company{Alive: true})).Find(&users)

// 嵌套 Joins
db.Joins("Manager").Joins("Manager.Company").Find(&users)

Preload vs Joins:什么时候用哪个

维度PreloadJoins
SQL 数量多条(每个关联一条)1 条
适用关系所有(Has Many, Many2Many 尤其适合)只适合一对一(Has One, Belongs To)
结果集重复无重复Has Many 会导致父记录重复行
按关联字段过滤主模型不行可以(WHERE 条件)
匹配方式内存 Identity Map(O(1))数据库 JOIN
典型场景加载关联数据用于展示按关联条件筛选主记录

核心判断

// 典型混合用法:找有活跃公司的用户,同时预加载 Orders
db.Joins("Company", db.Where(&Company{Alive: true})).
   Preload("Orders").
   Find(&users)

Many2Many Preload 的内部细节

Many2Many 的 Preload 比一般关联多一步——需要查中间表:

  1. 查中间表:SELECT * FROM user_languages WHERE user_id IN (...)
  2. 查关联表:SELECT * FROM languages WHERE id IN (中间表中的 language_id)
  3. 通过转换后的 Identity Map 把 Language 映射回 User

所以 Many2Many 的 Preload 实际是 3 条 SQL(主表 + 中间表 + 关联表)。

内存注意事项


五、高级查询技巧

Scopes(可复用查询条件)

Scope 是 func(*gorm.DB) *gorm.DB 签名的函数,用于封装可复用的查询逻辑:

func AmountGreaterThan1000(db *gorm.DB) *gorm.DB {
    return db.Where("amount > ?", 1000)
}

func PaidWithCreditCard(db *gorm.DB) *gorm.DB {
    return db.Where("pay_mode_sign = ?", "C")
}

// 带参数的 Scope(返回闭包)
func OrderStatus(status []string) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        return db.Where("status IN (?)", status)
    }
}

// 使用
db.Scopes(AmountGreaterThan1000, PaidWithCreditCard).Find(&orders)
db.Scopes(AmountGreaterThan1000, OrderStatus([]string{"paid", "shipped"})).Find(&orders)

Scope 的价值:把业务查询逻辑从 handler 层抽出来,Scope 可以单元测试、可组合、可复用。

SubQuery(子查询)

*gorm.DB 对象作为参数传入,GORM 自动生成子查询:

// 金额大于平均值的订单
db.Where("amount > (?)", db.Table("orders").Select("AVG(amount)")).Find(&orders)
// SELECT * FROM orders WHERE amount > (SELECT AVG(amount) FROM orders);

// FROM 子查询
db.Table("(?) as u", db.Model(&User{}).Select("name", "age")).
   Where("age = ?", 18).Find(&User{})
// SELECT * FROM (SELECT `name`,`age` FROM `users`) as u WHERE `age` = 18

// 多个 FROM 子查询
subQuery1 := db.Model(&User{}).Select("name")
subQuery2 := db.Model(&Pet{}).Select("name")
db.Table("(?) as u, (?) as p", subQuery1, subQuery2).Find(&User{})

Named Arguments(命名参数)

// 用 sql.Named
db.Where("name1 = @name OR name2 = @name", sql.Named("name", "jinzhu")).Find(&user)

// 用 map
db.Where("name1 = @name OR name2 = @name", map[string]interface{}{"name": "jinzhu"}).First(&user)

// Raw SQL 也支持
db.Raw("SELECT * FROM users WHERE name1 = @name OR name2 = @name2",
    sql.Named("name", "jinzhu1"), sql.Named("name2", "jinzhu2")).Find(&user)

// 用 struct
type NamedArgument struct {
    Name  string
    Name2 string
}
db.Raw("SELECT * FROM users WHERE name1 = @Name AND name2 = @Name2",
    NamedArgument{Name: "jinzhu", Name2: "jinzhu2"}).Find(&user)

Smart Select(智能字段选择)

查询结果扫描到字段更少的结构体时,GORM 自动只 SELECT 目标结构体有的字段:

type User struct {
    ID     uint
    Name   string
    Age    int
    Gender string
}

type APIUser struct {
    ID   uint
    Name string
}

db.Model(&User{}).Limit(10).Find(&APIUser{})
// SQL: SELECT `id`, `name` FROM `users` LIMIT 10

不需要手动写 Select("id", "name"),减少数据传输。

Raw SQL 和 Exec

当 GORM 的链式 API 无法表达复杂查询时,用 Raw SQL:

// 查询
var result Result
db.Raw("SELECT id, name, age FROM users WHERE id = ?", 3).Scan(&result)

// 聚合
var age int
db.Raw("SELECT SUM(age) FROM users WHERE role = ?", "admin").Scan(&age)

// 执行(不返回数据)
db.Exec("UPDATE orders SET shipped_at = ? WHERE id IN ?", time.Now(), []int64{1, 2, 3})

// 用 gorm.Expr 嵌入表达式
db.Exec("UPDATE users SET money = ? WHERE name = ?",
    gorm.Expr("money * ? + ?", 10000, 1), "jinzhu")

DryRun 模式(生成 SQL 不执行)

调试利器——看 GORM 生成了什么 SQL:

stmt := db.Session(&gorm.Session{DryRun: true}).First(&user, 1).Statement
stmt.SQL.String() // => SELECT * FROM `users` WHERE `id` = $1 ORDER BY `id`
stmt.Vars         // => []interface{}{1}

ToSQL 更简洁:

sql := db.ToSQL(func(tx *gorm.DB) *gorm.DB {
    return tx.Model(&User{}).Where("id = ?", 100).Limit(10).Order("age desc").Find(&[]User{})
})
// => SELECT * FROM "users" WHERE id = 100 AND "users"."deleted_at" IS NULL ORDER BY age desc LIMIT 10

注意:ToSQL 生成的 SQL 不提供安全保证(参数没有转义),仅用于调试。


六、性能优化要点

// 1. 禁用默认事务(读操作密集时提升显著)
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
    SkipDefaultTransaction: true,
})

// 2. 预编译语句缓存
db, err := gorm.Open(sqlite.Open("gorm.db"), &gorm.Config{
    PrepareStmt: true,
})

// 3. Index Hints
db.Clauses(hints.UseIndex("idx_user_name")).Find(&User{})
db.Clauses(hints.ForceIndex("idx_user_name", "idx_user_id").ForJoin()).Find(&User{})

七、常见误区(Pitfalls)

  1. Joins 加载 Has Many 导致重复行:JOIN 一对多时父记录会重复。如果只是要加载关联数据,用 Preload,不要用 Joins。

  2. clause.Associations 不递归Preload(clause.Associations) 只加载第一层关联。嵌套关联必须用点号显式声明 Preload("Orders.OrderItems")

  3. Preload 条件不会过滤主模型db.Preload("Orders", "state = ?", "paid").Find(&users) 返回所有 User,只是每个 User 的 Orders 中只包含 paid 的。如果要只返回有 paid order 的 User,用 Joins。

  4. 忘记 SetupJoinTable:自定义中间表模型必须在查询前调用 db.SetupJoinTable(),否则 GORM 不知道用你的自定义结构。

  5. 多态无法建数据库外键约束OwnerType + OwnerID 是应用层约束,数据库层没有 FK。数据一致性完全靠应用保证。

  6. Association Mode 要求主键非零db.Select("Account").Delete(&User{}) 不会删除关联,因为 User 的主键是零值。必须 &User{ID: 1}

  7. Preload 内存消耗:大量数据(10,000+)的 Preload 会在内存中构建 Identity Map。生产环境务必分页。


参考来源


分享这篇文章:

上一篇
Linux I/O(一):epoll 高性能的本质与使用要点
下一篇
GORM(一):核心用法与设计决策