预加载优化

N+1 查询问题是 ORM 最常见的性能杀手。一个简单的列表页,可能背后执行了几百条 SQL。GORM 的预加载功能就是来解决这个问题的。

什么是 N+1 问题

假设有用户和订单两个表:

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 问题。

Preload 基础用法

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 预加载

对于一对一或属于关系,可以用 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)

预加载最佳实践

  1. 一对一关系优先用 Joins
  2. 一对多关系用 Preload
  3. 只预加载需要的关联
  4. 大数据量关联考虑分页或统计
  5. 列表页和详情页用不同的加载策略

预加载的本质是用空间换时间,把 N 条 SQL 变成 2 条或几条。但空间也不能无限用,要根据实际场景权衡。