Files
cs-note/hhs/GORM/05-关联查询.md
T
2026-05-24 11:42:38 +08:00

370 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 关联查询, Preload, Joins, HasOne, HasMany, BelongsTo, ManyToMany, Association]
create time: 2026-04-28 00:00
---
# 关联查询
## 概述
现实世界的数据从不孤立存在——用户有订单、订单包含商品、商品属于分类。本文介绍 GORM 中如何通过 struct tag 声明模型间关系,以及加载这些关联数据的两种核心策略:**预加载**(N+1 条 SQL)和 **JOIN 单查询**。内容覆盖 HasOne、HasMany、BelongsTo、ManyToMany 四种关联类型的定义与操作。
GORM 提供了两种主要的关联加载方式:
- **N+1 预加载**(`Preload`):额外发 N+1 条 SQL,批量填充关联
- **JOIN 单查询**(`Joins`):一条复杂 SQL 拿全量数据
理解它们的区别和适用场景,可以避免常见的性能陷阱。
> [!tip] 核心原则
> GORM 的关联查询有两种策略:**Eager Loading**(预加载)和 **Lazy Loading**(按需加载)。默认情况下,GORM **不会自动加载关联数据**——你必须显式调用 `Preload` 或 `Joins`,这给了你完全的控制权。
## 关联类型概览
```mermaid
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 里声明关联关系:
```go
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 语句批量填充关联字段。
### 基本用法
```go
// 加载 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
```go
// 同时加载多个层级的关联
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` 过滤:
```go
// 只加载金额大于 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
> [!warning] 版本注意
> GORM v1.x 有单独的 `PreloadWith` 方法用于设置预加载选项,但在 v2.x 中已被整合到 `Preload` 的函数式参数里。如果你从旧文档看到 `PreloadWith`,请参考上面的「带条件的 Preload」写法。
```go
// v1.x(已废弃)
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Where("amount > ?", 100)
}).Find(&users)
// v2.x(推荐 — 用第二个 Preload 调用传递选项)
db.Preload("Orders", "amount > ?", 100).Find(&users)
```
## Joins — 单次 JOIN 查询
与 `Preload` 的多条 SQL 不同,`Joins` 在一条 SQL 中通过 JOIN 获取关联数据:
### 基本用法
```go
// 只加载一层 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(嵌套)
```go
// 加载用户及其订单中的商品信息
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 条件过滤
```go
// 只 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 详细示例
HasOne 表示「一条对应一条」的关系,GORM 通过外键在被关联方查找唯一记录。
```go
// 查询用户的 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 ...
```
上面代码展示了两件事:一是用 `Preload` 加载后可以直接访问嵌套字段;二是 GORM 的 `Create` 会自动级联写入关联的记录——先插入主表再插入从表,你不需要手动分别调用两次 `Create`。
## HasMany 详细示例
HasMany 表示「一条对应多条」的关系,返回的是一个切片。注意 `Association("Orders").Count()` 统计的是关联记录的条数,而非业务意义上的「总和」。
```go
// 查询用户的所有订单
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)
```
这里特意展示了一个常见误区:`Association(...).Count()` 做的是 `COUNT(*)` 而非聚合计算。如果需要求和、平均等统计,应该走 `Model(&user).Select("SUM(amount)")` 的标准查询方式。
## BelongsTo 详细示例
BelongsTo 表示「属于」关系,外键在当前方(与 HasOne 相反)。加载时使用 `Preload`,更新时直接修改外键字段。
```go
// 查询订单所属的用户
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
```
这里注意:BelongsTo 的外键在**当前模型**的表里(即 `orders.user_id`),而不是在 Users 表里。这决定了 GORM 生成的是 `SELECT * FROM users WHERE id = ?` 来反向填充 User 字段。
## ManyToMany 详细示例
ManyToMany 是四种关联中最复杂的一种——GORM 自动创建一个中间表(join table)来维护关系。你可以像操作普通字段一样通过 `Association` API 增删改查。
```go
// 查询订单包含的商品
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()
```
这段代码展示了 Association API 的五个核心操作:**追加**(Append)、**替换**(Replace)、**删除**(Delete)、**清空**(Clear)和**计数**(Count)。其中 Replace 会先清除旧关联再写入新数据,适合「重新赋值」的场景;而 Append 只新增不删除,适合累积添加。
> [!important] Association API 一览
>
> | 方法 | 行为 | SQL 效果 |
> |------|------|---------|
> | `Append` | 追加新关联 | INSERT 中间表记录 |
> | `Replace` | 替换全部(删旧加新) | TRUNCATE + INSERT |
> | `Delete` | 删除指定关联 | DELETE 中间表记录 |
> | `Clear` | 清空所有关联 | DELETE 全部中间表记录 |
> | `Count` | 统计关联数量 | COUNT(*) |
## 关联查询决策图
```mermaid
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")` |
## 关联笔记
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[06-排序与分页]]
- [[15-性能优化]]