370 lines
14 KiB
Markdown
370 lines
14 KiB
Markdown
|
|
---
|
|||
|
|
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-性能优化]]
|