preload 是 gorm 中解决 n+1 查询问题的默认正确姿势,必须用于一对多/多对一关联场景;未显式调用即掉入 n+1 陷阱,100 条记录触发 101 次查询,延迟翻倍。

Preload 是什么,什么时候必须用
Preload 不是“可选优化”,而是处理一对多/多对一关联时的默认正确姿势。只要你在循环里访问 user.Orders、post.Author 这类字段,又没提前加载,就已掉进 N+1 陷阱。
典型触发场景包括:渲染列表页(用户+头像+最近3条订单)、管理后台批量导出(文章+分类+作者)、API 返回嵌套 JSON。此时不加 Preload,100 条记录 = 101 次查询,延迟直接翻倍。
- 必须显式声明外键字段,比如
TownID int,否则Preload("Town")会静默失效 - 结构体中关联字段(如
Town Town)的 tag 必须写清楚foreignKey:TownID - 如果只查部分字段或加条件,要用函数式参数,而不是裸写
Preload("Orders")
Preload 带条件和字段筛选的写法
盲目 Preload("Orders") 很危险:它会把每个用户的全部订单全量拉下来,内存暴涨,还可能拖慢主查询。生产环境必须限制范围。
正确做法是用闭包控制子查询逻辑:
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Select("id, user_id, amount, status").
Where("status = ?", "paid").
Order("created_at DESC").
Limit(3)
}).Find(&users)
-
Select能大幅减少传输数据量,避免加载description或notes这类大文本字段 -
Where和Order必须写在闭包里,写在外面(如Where(...).Preload(...))无效 -
Limit对一对多特别关键,防止一个超级用户拖垮整页响应
什么时候该放弃 Preload,改用 Joins + Scan
Preload 本质是两条 SQL(主表 + IN 子查询),适合需要完整模型实例的业务逻辑;但如果你只是生成报表、统计看板、导出 Excel,或者只取几个字段做聚合,Joins + Scan 才是更优解。
它只发一次 JOIN 查询,无笛卡尔积风险(只要没漏 GROUP BY),性能通常比 Preload 高 30%~50%。
type UserOrderSummary struct {
UserID uint
UserName string
OrderCnt int
TotalAmt float64
}
var results []UserOrderSummary
db.Table("users").
Select("users.id as user_id, users.name as user_name, COUNT(orders.id) as order_cnt, SUM(orders.amount) as total_amt").
Joins("LEFT JOIN orders ON orders.user_id = users.id").
Group("users.id, users.name").
Scan(&results)
-
Joins不支持嵌套预加载(比如Joins("Orders.Product")),只能平铺一层 - 结果必须用自定义 struct 接收,不能复用原有 model,否则字段映射会错乱
- 注意 NULL 处理:LEFT JOIN 后,
orders.amount可能为 NULL,SUM会自动忽略,但COUNT(*)和COUNT(orders.id)行为不同
排查 N+1 的三个实操动作
别等线上报警才动手。每次加完关联逻辑,立刻验证是否真避开了 N+1。
- 打开 GORM 日志:
db = db.Debug(),看终端输出的 SQL 条数和模式是否符合预期 - 用数据库慢日志或
EXPLAIN看IN (1,2,3...)子查询是否命中town_id索引——没索引的外键会让 Preload 变慢十倍 - 压测时观察连接池指标:
sqlDB.Stats().OpenConnections是否随并发线性上涨,上涨即说明还有隐藏 N+1
最常被忽略的是外键字段没建索引,以及嵌套 Preload(如 Preload("Orders.Product"))导致三层查询爆炸。这两点不检查,光调 Preload 参数没用。











