This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/05-关联查询.md
T
2026-04-28 20:23:33 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
关联查询
Preload
Joins
HasOne
HasMany
BelongsTo
ManyToMany
2026-04-28 00:00

关联查询

概述

现实世界的数据从不孤立存在——用户有订单、订单包含商品、商品属于分类。如何在一次或多次查询中高效地加载这些关联数据,是 ORM 的核心能力。

GORM 提供了两种主要的关联加载方式:

  • N+1 预加载(Preload):额外发 N+1 条 SQL,批量填充关联
  • JOIN 单查询(Joins):一条复杂 SQL 拿全量数据

理解它们的区别和适用场景,可以避免常见的性能陷阱。

[!tip] 核心原则 GORM 的关联查询有两种策略:Eager Loading(预加载)和 Lazy Loading(按需加载)。默认情况下,GORM 不会自动加载关联数据——你必须显式调用 Preload 或 Joins,这给了你完全的控制权。

关联类型概览

flowchart LR
    User["User"] -->|HasMany| Order["Order"]
    User -->|BelongsTo| Role["Role"]
    Order -->|HasOne| Detail["OrderDetail"]
    Order -->|ManyToMany| Product["Product"]
    
    style User fill:#3B82F6,color:#fff
    style Order fill:#4FC08D,color:#fff
    style Role fill:#F59E0B,color:#000
    style Detail fill:#A0AEC0,color:#fff
    style Product fill:#EC4899,color:#fff
    
    classDef foreignKey color:#EF4444;
关联类型 外键位置 关系描述 示例
HasOne 被关联方 A 有一条唯一的 B User → Profile
HasMany 被关联方 A 有多条 B User → Orders
BelongsTo 当前方 A 属于某个 B Order → User
ManyToMany 中间表 A 和 B 多对多 Order ↔ Product

前置准备:定义关联

在能查之前,先要在 struct tag 里声明关联关系:

type User struct {
    ID       uint      `gorm:"primaryKey"`
    Name     string    `gorm:"size:64"`
    Profile  Profile   `gorm:"foreignKey:UserID"`          // HasOne
    Orders   []Order   `gorm:"foreignKey:UserID"`          // HasMany
    Roles    []Role    `gorm:"many2many:user_roles"`       // ManyToMany
}

type Profile struct {
    ID        uint   `gorm:"primaryKey"`
    UserID    uint   `gorm:"index;uniqueIndex"`          // ForeignKey
    AvatarURL string `gorm:"size:256"`
}

type Order struct {
    ID       uint    `gorm:"primaryKey"`
    UserID   uint    `gorm:"index"`                       // ForeignKey(BelongsTo)
    Amount   float64
    Products []Product `gorm:"many2many:order_products"` // ManyToMany
}

type Product struct {
    ID        uint   `gorm:"primaryKey"`
    Name      string `gorm:"size:128"`
    Price     float64
}

type Role struct {
    ID   uint   `gorm:"primaryKey"`
    Name string `gorm:"size:64;uniqueIndex"`
}

[!note] GORM 的外键推断规则 如果没有显式指定 foreignKey,GORM 会用「关联模型名 + 主键」来推断。例如 Profile 模型的 UserID 会被认为是指向 User.ID 的外键。显式声明的好处是语义清晰、不易出错。

Preload — 预加载关联数据

Preload 是最常用的关联加载方式。它在原始查询之后,用额外的 SQL 语句批量填充关联字段。

基本用法

// 加载 User 及其 HasOne Profile
var user User
db.Preload("Profile").First(&user, 1)
// 执行的 SQL:
//   SELECT * FROM users WHERE id = 1 LIMIT 1;
//   SELECT * FROM profiles WHERE user_id = 1;

// 加载 User 及其 HasMany Orders
db.Preload("Orders").Find(&users)
// 执行的 SQL:
//   SELECT * FROM users;
//   SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...);

[!important] 为什么叫 N+1? 第 1 条 SQL 查主表(n 个用户),后面 N 条 SQL 查关联表。当关联只有一条时就是「2 条 SQL」;如果关联是多条且每个用户关联不同数据,就需要额外查询——所以需要控制关联的深度和范围。

链式 Preload

// 同时加载多个层级的关联
db.Preload("Profile").
    Preload("Orders").
    Preload("Orders.Products").   // 级联加载 —— 订单里的商品
    Find(&users)

// 执行的 SQL:
//   1. SELECT * FROM users;
//   2. SELECT * FROM profiles WHERE user_id IN (...);
//   3. SELECT * FROM orders WHERE user_id IN (...);
//   4. SELECT * FROM order_products WHERE order_id IN (...);
//   5. SELECT * FROM products WHERE id IN (...);

[!tip] 层级命名 Orders.Products 使用点号分隔层级——GORM 会自动遍历这条路径。嵌套可以无限深,但要小心笛卡尔积爆炸:深层嵌套 + 大量数据会生成极慢的 JOIN 查询(如果用 Joins 方式)或多次大结果集的 IN 查询(如果用 Preload)。

带条件的 Preload

并非所有关联数据都需要全量加载。可以用函数形式的 Preload 过滤:

// 只加载金额大于 100 的订单
db.Preload("Orders", "amount > ?", 100).Find(&users)

// 更精细控制:WHERE + ORDER BY
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
    return db.Where("amount > ?", 100).Order("created_at DESC")
}).Find(&users)

// 限制关联表的字段(减少传输开销)
db.Preload("Orders", "id, amount, status").
    Preload("Orders.Products", "id, name").
    Find(&users)

[!question] 思考题 如果给 Preload 加了条件,但用户没有任何符合条件的关联记录会发生什么?

答案:关联字段会是空切片/零值结构体,不会报错。这是符合直觉的行为——只是「没找到」而非「出错了」。

Preload vs PreloadWith

GORM v1.x 有单独的 PreloadWith 方法,但在 v2.x 中已被整合到 Preload 的函数式参数里。如果你从旧文档看到 PreloadWith,请参考上面的「链式 Preload」写法。

Joins — 单次 JOIN 查询

与 Preload 的多条 SQL 不同,Joins 在一条 SQL 中通过 JOIN 获取关联数据:

基本用法

// 只加载一层 Profile
var user User
db.Joins("Profile").First(&user, 1)
// SELECT users.*, profiles.* FROM users 
// LEFT JOIN profiles ON profiles.user_id = users.id 
// WHERE users.id = 1;

多层 Joins(嵌套)

// 加载用户及其订单中的商品信息
db.Joins("Profile").
    Joins("JOIN order_items ON order_items.user_id = users.id").
    Joins("JOIN products ON products.id = order_items.product_id").
    Where("users.id = ?", 1).
    Find(&user)

[!warning] Joins 的注意事项

  • 使用 LEFT JOIN 还是 INNER JOIN 取决于业务语义:想保留没有关联数据的记录用 LEFT JOIN,只想要有关联的用 JOIN(等价于 INNER JOIN)。
  • GORM 默认的 Joins("关联名") 生成的是 LEFT JOIN。
  • 深度关联下,JOIN 会导致行重复(一个用户有多个订单时会返回多行 User),需要手动去重。

Joins 条件过滤

// 只 Join 状态为 active 的 Profile
db.Joins("Profile", "profiles.status = ?", "active").First(&user, 1)

[!example] Preload vs Joins 对比

维度 Preload Joins
SQL 数量 N+1 条 通常 1 条
关联数据过滤 容易(WHERE 作为第二参数) 较难(写在 JOIN 子句中)
关联字段选择 支持(直接写列名) 需自定义 SELECT
适合场景 大多数场景,可控性好 只需少量关联字段的简单查询
大数据量风险 外层 IN (...) 过大 行膨胀、内存占用高

[!tip] 经验法则 「优先 Preload,必要时 Joins。」Preload 的可控性和可预测性更好;只在确实需要减少 SQL 数量、且关联数据量很小时才用 Joins。

HasOne 详细示例

// 查询用户的 Profile
var user User
db.Preload("Profile").First(&user, 1)
fmt.Println(user.Profile.AvatarURL) // 可直接访问,无需二次查询

// 创建用户并同时创建 Profile
db.Create(&User{
    Name: "Alice",
    Profile: Profile{AvatarURL: "https://example.com/avatar.png"},
})
// INSERT INTO users ...; INSERT INTO profiles ...

HasMany 详细示例

// 查询用户的所有订单
var user User
db.Preload("Orders").First(&user, 1)
for _, order := range user.Orders {
    fmt.Printf("订单 %d: ¥%.2f\n", order.ID, order.Amount)
}

// 查询某用户的所有订单金额总和
var total float64
db.Model(&user).Association("Orders").Count(&total) // 这会统计行数,不是金额总和
// 正确做法:
db.Model(&user).Select("COALESCE(SUM(amount), 0)").Scan(&total)

BelongsTo 详细示例

// 查询订单所属的用户
var order Order
db.Preload("User").First(&order, 1)
fmt.Println(order.User.Name) // Alice

// 更新订单的用户关联
order.UserID = 2
db.Save(&order) // UPDATE orders SET user_id=2 WHERE id=1

ManyToMany 详细示例

// 查询订单包含的商品
var order Order
db.Preload("Products").First(&order, 1)
// GORM 自动管理中间表 order_products:
//   SELECT * FROM orders WHERE id = 1;
//   SELECT * FROM products p 
//   INNER JOIN order_products op ON op.product_id = p.id 
//   WHERE op.order_id = 1;

// 给订单添加商品
product := Product{Name: "Go Programming", Price: 99}
db.Model(&order).Association("Products").Append(&product)
// INSERT INTO products ...; INSERT INTO order_products ...

// 批量添加
products := []Product{{Name: "SQL Essentials", Price: 79}, {Name: "Linux DevOps", Price: 59}}
db.Model(&order).Association("Products").Append(products)

// 替换全部商品(移除旧的关联)
newProducts := []Product{{Name: "Kubernetes in Action", Price: 109}}
db.Model(&order).Association("Products").Replace(newProducts)

// 删除特定关联
db.Model(&order).Association("Products").Delete(&product)

// 清理所有关联
db.Model(&order).Association("Products").Clear()

// 获取关联数
count, _ := db.Model(&order).Association("Products").Count()

[!important] Association API 一览

方法 行为 SQL 效果
Append 追加新关联 INSERT 中间表记录
Replace 替换全部(删旧加新) TRUNCATE + INSERT
Delete 删除指定关联 DELETE 中间表记录
Clear 清空所有关联 DELETE 全部中间表记录
Count 统计关联数量 COUNT(*)

关联查询决策图

flowchart TD
    Start[需要加载关联数据] --> Depth{关联层级_}
    
    Depth --> |单层浅查询| SimpleQ{数据量大吗_}
    SimpleQ --> |否| JoinsSingle["Joins — 单条 SQL"]
    SimpleQ --> |是| PreloadSingle["Preload — 多条小 SQL"]
    
    Depth --> |多层级联| MultiQ{每层数据量_}
    MultiQ --> |都很小| DeepJoins["嵌套 Joins"]
    MultiQ --> |某层较大| DeepPreload["Preload 链式 + 条件过滤"]
    MultiQ --> |不清楚| PreloadSafe["Preload(安全首选)"]
    
    Depth --> |只需要计数/特定字段| SpecificField["Selection 或 Count"]
    
    style Start fill:#4FC08D,color:#fff
    style PreloadSafe fill:#3B82F6,color:#fff
    style JoinsSingle fill:#F59E0B,color:#000
    style DeepPreload fill:#8B5CF6,color:#fff

常见坑点速查

问题 原因 解决方案
预加载很慢 N+1 导致过多 SQL 或 IN 列表过大 改用 Joins 或用 BatchQuery 分片
Preload 后数据为空 关联字段名不匹配 struct 标签 检查 foreignKey / _associationName 拼写
ManyToMany 新增已存在记录报错 唯一约束冲突 先用 Count() 判断或使用 Clauses(clause.OnConflict{DoNothing: true})
Joins 导致行重复 LEFT JOIN 产生笛卡尔积 去重或用 Distinct
加载了不必要的关联字段 浪费带宽和内存 用 Preload 字段白名单:Preload("Orders", "id,status,amount")

关联笔记