Go 语言 GORM 使用 Preload:从关联查询到嵌套预加载的完整实践

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 等常见问题。

相关推荐
dingzd952 小时前
TikTok账号登录态失效的Cookie字段排查路径
开发语言·php·跨境电商·营销策略
microrain2 小时前
把规则链存成文件之后,上线一条规则只需改一个字段
物联网·golang·规则引擎·sagooiot
曹牧3 小时前
C#:The operation has timed out
开发语言·c#
你看我名字3 小时前
数眼智能 Search + Reader 实战:联网问答怎样避免“搜到了,却引用错了”
android·java·开发语言
小嘉丶学长3 小时前
Day13 · 列表与元组
linux·运维·服务器·开发语言·python
努力努力再努力wz3 小时前
【Lua入门系列】从 C++ 迭代器到 Lua 迭代器:一文深入理解 Lua 有状态以及无状态迭代器与泛型for、闭包、ipairs、pairs 和 next
开发语言·c++·lua
兔头?3 小时前
JVM学习文档
jvm·学习
努力实干3 小时前
第二阶段 · 第 7 课:C++ 多线程与线程安全
开发语言·c++·安全
爱聊灵光的维C3 小时前
C语言初阶:关于函数设计的详细讲解和注意事项
c语言·开发语言·青少年编程·蓝桥杯·学习方法