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

341 lines
12 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]
create time: 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`,这给了你完全的控制权。
## 关联类型概览
```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
GORM v1.x 有单独的 `PreloadWith` 方法,但在 v2.x 中已被整合到 `Preload` 的函数式参数里。如果你从旧文档看到 `PreloadWith`,请参考上面的「链式 Preload」写法。
## 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 详细示例
```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 ...
```
## HasMany 详细示例
```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)
```
## BelongsTo 详细示例
```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
```
## ManyToMany 详细示例
```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()
```
> [!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-性能优化]]