N+1 查询问题是 ORM 最常见的性能杀手。一个简单的列表页,可能背后执行了几百条 SQL。GORM 的预加载功能就是来解决这个问题的。
假设有用户和订单两个表:
type User struct {
ID uint
Name string
Orders []Order
}
type Order struct {
ID uint
UserID uint
Amount float64
}
查询用户列表并显示订单:
// 先查用户:1 条 SQL
var users []User
db.Find(&users)
// 循环查订单:N 条 SQL
for i := range users {
db.Model(&users[i]).Association("Orders").Find(&users[i].Orders)
}
10 个用户就是 11 条 SQL,100 个用户就是 101 条。这就是 N+1 问题。
GORM 用 Preload 解决这个问题:
// 只需要 2 条 SQL
db.Preload("Orders").Find(&users)
// SELECT * FROM users;
// SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...);
GORM 先查用户,拿到所有用户 ID,再用一条 IN 查询把所有订单查出来,最后在内存中组装。
有时候不需要加载所有关联数据:
// 只加载已完成的订单
db.Preload("Orders", "status = ?", "completed").Find(&users)
// 更复杂的条件
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Where("status = ?", "completed").
Where("amount > ?", 100).
Order("created_at DESC")
}).Find(&users)
关联的关联也可以预加载:
type Order struct {
ID uint
UserID uint
Items []OrderItem
}
type OrderItem struct {
ID uint
OrderID uint
Product Product
}
// 预加载 Orders,再预加载 Orders.Items,再预加载 Orders.Items.Product
db.Preload("Orders.Items.Product").Find(&users)
type User struct {
ID uint
Orders []Order
Profile Profile
Posts []Post
}
db.Preload("Orders").
Preload("Profile").
Preload("Posts").Find(&users)
每个 Preload 都是独立的查询,注意控制数量。
对于一对一或属于关系,可以用 Joins 做内连接预加载:
type User struct {
ID uint
Profile Profile
}
// 一对一关系可以用 Joins
db.Joins("Profile").Find(&users)
// SELECT users.*, profiles.* FROM users LEFT JOIN profiles ON profiles.user_id = users.id
Joins 预加载只用一条 SQL,但只适合一对一关系。一对多关系用 Joins 会导致主记录重复。
预加载不是万能药,用不好也会出问题:
预加载太多关联
// 加载了太多数据
db.Preload("Orders").
Preload("Orders.Items").
Preload("Orders.Items.Product").
Preload("Orders.Items.Product.Category").
Preload("Profile").
Preload("Posts").
Preload("Posts.Comments").
Find(&users)
这种链式预加载可能产生笛卡尔积级别的数据量,内存爆炸。
预加载大量数据
// 每个用户有几千个订单
db.Preload("Orders").Find(&users) // 可能加载几十万条订单
这种情况考虑分页加载关联数据,或者只加载统计信息。
不是所有场景都需要预加载。如果只显示用户名,就没必要加载订单:
// 列表页:不需要关联数据
db.Select("id", "name").Find(&users)
// 详情页:才加载关联数据
db.Preload("Orders").First(&user, id)
GORM 支持延迟加载,用到时才查:
// 先查用户
db.Find(&users)
// 访问时才加载
for _, user := range users {
// 这里会触发查询
orders := user.Orders // 需要在模型中配置
}
延迟加载要配合 GORM 的字段标签使用,但一般不推荐,容易产生 N+1。
如果只需要数量,不要加载全部数据:
type User struct {
ID uint
Name string
OrderCount int `gorm:"-"` // 不是数据库字段
}
// 用子查询统计
db.Select("users.*, (SELECT COUNT(*) FROM orders WHERE orders.user_id = users.id) as order_count").
Find(&users)
预加载的本质是用空间换时间,把 N 条 SQL 变成 2 条或几条。但空间也不能无限用,要根据实际场景权衡。