1. 引言
在 Go 语言生态中,GORM 是最流行的 ORM 框架之一。它提供了链式 API、自动迁移、钩子函数等能力,其中 Preload 是处理关联数据时最常用也最容易踩坑的功能。
很多同学第一次写关联查询时,会遇到这样的问题:查出来的 User 结构体里 Orders 字段是空的,或者 Orders 有值但 Order.Items 又是空的。这通常不是数据库没数据,而是没有正确使用 Preload。
本文从关联关系建模出发,系统讲解 GORM Preload 的用法,包括:
- 一对一、一对多、多对多的预加载
- 嵌套预加载与条件预加载
Preload与Joins的区别与选型- 常见坑与性能优化建议
2. 环境准备
先准备一个可运行的示例环境。本文使用 GORM v2(gorm.io/gorm)与 SQLite 驱动,方便本地直接跑通。
bash
go mod init gorm-preload-demo
go get -u gorm.io/gorm
go get -u gorm.io/driver/sqlite
初始化数据库连接:
go
package main
import (
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var db *gorm.DB
func initDB() {
var err error
db, err = gorm.Open(sqlite.Open("demo.db"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatalf("open db failed: %v", err)
}
}
生产环境请替换为 MySQL / PostgreSQL 驱动,
Preload的用法完全一致。
3. 模型定义
为了覆盖常见场景,我们定义四个模型:User、Order、OrderItem、Role。
go
package main
import "time"
// User 用户
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:64;not null"`
Email string `gorm:"size:128;uniqueIndex"`
Profile Profile `gorm:"foreignKey:UserID"` // 一对一
Orders []Order `gorm:"foreignKey:UserID"` // 一对多
Roles []Role `gorm:"many2many:user_roles"` // 多对多
CreatedAt time.Time
UpdatedAt time.Time
}
// Profile 用户资料(一对一)
type Profile struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index"`
Bio string
Avatar string
}
// Order 订单(一对多)
type Order struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index"`
OrderNo string
Amount int64
Status string
Items []OrderItem `gorm:"foreignKey:OrderID"` // 订单项
CreatedAt time.Time
}
// OrderItem 订单项
type OrderItem struct {
ID uint `gorm:"primaryKey"`
OrderID uint `gorm:"index"`
ProductID uint
Quantity int
Price int64
}
// Role 角色(多对多)
type Role struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:32;uniqueIndex"`
}
自动迁移并写入一些测试数据:
go
func migrateAndSeed() {
if err := db.AutoMigrate(&User{}, &Profile{}, &Order{}, &OrderItem{}, &Role{}); err != nil {
log.Fatalf("migrate failed: %v", err)
}
admin := Role{Name: "admin"}
user := Role{Name: "user"}
db.Create(&admin)
db.Create(&user)
u := User{
Name: "张三",
Email: "zhangsan@example.com",
Profile: Profile{
Bio: "Go 后端工程师",
Avatar: "https://example.com/avatar.png",
},
Orders: []Order{
{
OrderNo: "NO2024001",
Amount: 19900,
Status: "paid",
Items: []OrderItem{
{ProductID: 1, Quantity: 2, Price: 5000},
{ProductID: 2, Quantity: 1, Price: 9900},
},
},
},
Roles: []Role{admin, user},
}
db.Create(&u)
}
4. Preload 基础用法
4.1 为什么需要 Preload
GORM 默认不会 自动加载关联数据。下面的查询只会填充 User 自身的字段:
go
var users []User
db.Find(&users)
// users[0].Orders 为空切片,users[0].Profile 为零值
要拿到关联数据,有两种方式:Preload(额外发一条 SQL 再组装)和 Joins(用 JOIN 一次查完)。先看 Preload。
4.2 预加载一对多
go
var users []User
err := db.Preload("Orders").Find(&users).Error
if err != nil {
log.Fatal(err)
}
for _, u := range users {
log.Printf("user=%s orders=%d", u.Name, len(u.Orders))
}
GORM 实际会执行两条 SQL:
sql
SELECT * FROM users;
SELECT * FROM orders WHERE user_id IN (1, 2, 3);
然后在内存中按 user_id 把订单挂到对应用户上。这就是 Preload 的核心机制:主查询 + 关联查询 + 内存组装。
4.3 预加载一对一
go
var users []User
db.Preload("Profile").Find(&users)
4.4 预加载多对多
go
var users []User
db.Preload("Roles").Find(&users)
多对多会额外查询中间表 user_roles,再查询 roles,共三条 SQL。
4.5 一次预加载多个关联
go
var users []User
db.Preload("Profile").
Preload("Orders").
Preload("Roles").
Find(&users)
也可以传入切片:
go
db.Preload(clause.Associations).Find(&users) // 预加载所有一级关联
clause.Associations 需要引入 gorm.io/gorm/clause,它只加载一级关联,嵌套关联仍需显式指定。
5. 嵌套预加载
实际业务中经常需要「用户 → 订单 → 订单项」这样的多级结构。用点号表示层级:
go
var users []User
err := db.Preload("Orders.Items").Find(&users).Error
if err != nil {
log.Fatal(err)
}
for _, u := range users {
for _, o := range u.Orders {
log.Printf("order=%s items=%d", o.OrderNo, len(o.Items))
}
}
对应的 SQL 是三条:users、orders、order_items。
多层嵌套继续用点号串联即可:
go
db.Preload("Orders.Items.Product").Find(&users)
如果只想预加载某一层的部分字段,可以配合 Select:
go
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "user_id", "order_no", "amount")
}).Find(&users)
注意:使用
Select限制字段时,务必保留外键字段(如user_id),否则 GORM 无法完成内存组装,关联数据会为空。
6. 条件预加载
Preload 的第二个参数是一个 func(*gorm.DB) *gorm.DB,可以像写普通查询一样加条件。
6.1 过滤关联数据
只加载已支付的订单:
go
var users []User
db.Preload("Orders", "status = ?", "paid").Find(&users)
或者用链式写法:
go
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "paid").Order("created_at DESC")
}).Find(&users)
6.2 关联排序与分页
go
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Order("amount DESC").Limit(10)
}).Find(&users)
6.3 嵌套条件预加载
对嵌套层级加条件,需要逐层写:
go
db.Preload("Orders", "status = ?", "paid").
Preload("Orders.Items", func(db *gorm.DB) *gorm.DB {
return db.Where("quantity > ?", 1)
}).
Find(&users)
6.4 自定义预加载(Preload + 手动赋值)
当关联逻辑比较复杂(比如需要跨库、聚合统计)时,可以用 Preload 配合自定义查询:
go
type UserWithCount struct {
User
OrderCount int64
}
var results []UserWithCount
db.Model(&User{}).
Select("users.*, (SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) AS order_count").
Find(&results)
这种方式适合只需要统计值、不需要完整关联对象的场景。
7. Preload 与 Joins 的对比
Joins 也能加载关联数据,但两者机制不同:
| 维度 | Preload | Joins |
|---|---|---|
| SQL 数量 | 主表 1 条 + 每个关联 1 条 | 1 条(含 JOIN) |
| 是否支持条件过滤主表 | 否(只过滤关联) | 是(可对关联表加 WHERE) |
| 是否支持嵌套 | 支持(点号) | 支持(Joins("Orders.Items")) |
| 数据组装 | GORM 内存组装 | 直接扫描到结构体 |
| 适用场景 | 展示型查询、关联数据量大 | 需要按关联字段筛选主表 |
示例:查询「有已支付订单的用户」,必须用 Joins,因为条件作用在主表筛选上:
go
var users []User
db.Joins("JOIN orders ON orders.user_id = users.id").
Where("orders.status = ?", "paid").
Group("users.id").
Find(&users)
而「查询所有用户及其已支付订单」用 Preload 更自然:
go
db.Preload("Orders", "status = ?", "paid").Find(&users)
选型建议 :优先 Preload,可读性和可维护性更好;只有在需要按关联表字段过滤主表、或对性能极度敏感且数据量可控时,才考虑 Joins。
8. 常见坑与注意事项
8.1 忘记 Preload 导致关联为空
最常见的问题。Find 不会自动加载关联,务必显式 Preload。
8.2 Select 丢外键导致组装失败
go
// 错误:缺少 user_id,Orders 会为空
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "order_no")
}).Find(&users)
正确写法要带上外键:
go
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "user_id", "order_no")
}).Find(&users)
8.3 N+1 问题
在循环里对每个用户单独查订单,就是典型的 N+1:
go
// 反例:N+1
for i := range users {
db.Where("user_id = ?", users[i].ID).Find(&users[i].Orders)
}
用 Preload 一次解决:
go
db.Preload("Orders").Find(&users)
8.4 关联数据量过大
如果一个用户有上万条订单,Preload 会一次性全部加载到内存。此时应改用条件预加载限制数量,或拆分为分页查询。
8.5 软删除与 Preload
如果模型使用了 gorm.DeletedAt,Preload 默认只加载未删除的关联记录。需要包含已删除数据时用 Unscoped():
go
db.Unscoped().Preload("Orders").Find(&users)
8.6 预加载顺序影响性能
多个 Preload 会串行执行。关联多时注意观察 SQL 日志,必要时用 Joins 合并查询。
9. 完整示例
把上面的内容整合成一个可运行的程序:
go
package main
import (
"log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
func main() {
db, err := gorm.Open(sqlite.Open("demo.db"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal(err)
}
if err := db.AutoMigrate(&User{}, &Profile{}, &Order{}, &OrderItem{}, &Role{}); err != nil {
log.Fatal(err)
}
// 1. 基础预加载
var users []User
if err := db.Preload("Profile").
Preload("Orders").
Preload("Roles").
Find(&users).Error; err != nil {
log.Fatal(err)
}
// 2. 嵌套预加载
var users2 []User
if err := db.Preload("Orders.Items").Find(&users2).Error; err != nil {
log.Fatal(err)
}
// 3. 条件预加载
var users3 []User
if err := db.Preload("Orders", "status = ?", "paid").
Preload("Orders.Items", func(db *gorm.DB) *gorm.DB {
return db.Where("quantity > ?", 1)
}).
Find(&users3).Error; err != nil {
log.Fatal(err)
}
// 4. 预加载所有一级关联
var users4 []User
if err := db.Preload(clause.Associations).Find(&users4).Error; err != nil {
log.Fatal(err)
}
log.Printf("done: %d users", len(users))
}
10. 总结
Preload 是 GORM 处理关联查询的核心能力,掌握以下几点即可覆盖绝大多数场景:
- 关联数据不会自动加载,必须显式
Preload; - 嵌套关联用点号
Orders.Items表示层级; - 条件预加载通过第二个参数传入
func(*gorm.DB) *gorm.DB; - 用
Select限制字段时务必保留外键; - 需要按关联表字段过滤主表时改用
Joins; - 关联数据量大时注意内存与 SQL 数量,必要时分页或改用聚合查询。
理解 Preload 的「主查询 + 关联查询 + 内存组装」机制,就能在写代码前预判 SQL 行为,避免关联为空、N+1 等常见问题。