查询优化

写 GORM 代码时,能跑通只是第一步。当数据量上来后,一条慢查询就能拖垮整个服务。这一章聊聊查询优化的实战经验。

只查需要的字段

最常见的坑就是 SELECT *。GORM 默认查询所有字段,但很多时候你只需要几个字段:

// 不好的做法
db.Find(&users)

// 好的做法:只查需要的字段
db.Select("id", "name", "email").Find(&users)

// 单条记录也一样
db.Select("id", "status").First(&user, 1)

字段越多,数据库要读的数据页就越多,网络传输也越大。特别是有 TEXT、BLOB 类型字段时,影响更明显。

避免 SELECT *

除了显式指定字段,还要注意关联查询时的隐式全字段查询:

// 预加载时会查询所有字段
db.Preload("Orders").Find(&users)

// 可以给 Preload 指定字段
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
    return db.Select("id", "user_id", "amount")
}).Find(&users)

分页查询的坑

分页查询看起来简单,但 OFFSET 在大数据量时性能很差:

// 小数据量没问题
db.Offset(10).Limit(10).Find(&users)

// 大数据量时,OFFSET 100000 会扫描前 100010 条记录
db.Offset(100000).Limit(10).Find(&users)  // 慢!

更好的做法是用 WHERE 替代 OFFSET:

// 记住上一页最后一条的 ID
lastID := 100000
db.Where("id > ?", lastID).Limit(10).Find(&users)

这种方式不管翻到第几页,查询复杂度都是 O(1)。

用索引覆盖查询

如果查询的字段都在索引上,数据库直接从索引读取,不用回表:

// 假设有索引 idx_user_status (status, name)
type User struct {
    ID     uint
    Name   string `gorm:"index:idx_user_status"`
    Status string `gorm:"index:idx_user_status"`
    Email  string
}

// 这个查询可以用索引覆盖
db.Select("id", "name", "status").Where("status = ?", "active").Find(&users)

避免 LIKE 前缀模糊查询

// 这个用不了索引
db.Where("name LIKE ?", "%张%").Find(&users)

// 前缀匹配可以用索引
db.Where("name LIKE ?", "张%").Find(&users)

如果确实需要全文搜索,考虑用专门的搜索引擎,或者数据库的全文索引功能。

用 EXPLAIN 分析查询

GORM 可以直接打印 SQL,配合数据库的 EXPLAIN 分析:

// 打印 SQL
db.Debug().Where("status = ?", "active").Find(&users)

// 或者用 Raw 执行 EXPLAIN
var result []map[string]interface{}
db.Raw("EXPLAIN SELECT * FROM users WHERE status = ?", "active").Scan(&result)

关注 EXPLAIN 输出的 type、key、rows、Extra 字段,type 为 ALL 说明全表扫描,需要加索引。

合理使用 Count

Count 查询在大表上很慢,尤其是带条件的 Count:

// 全表 Count 很慢
var count int64
db.Model(&User{}).Count(&count)

// 带复杂条件的 Count 更慢
db.Model(&User{}).Where("status = ? AND created_at > ?", "active", lastWeek).Count(&count)

一些优化思路:

  • 缓存 Count 结果,定期更新
  • 用估算值代替精确值(很多数据库提供估算函数)
  • 维护一个计数表,用触发器或应用层更新

批量查询代替循环查

经典的 N+1 问题:

// 糟糕的做法
for _, order := range orders {
    db.First(&user, order.UserID)
}

应该用 IN 查询:

// 收集所有 UserID
userIDs := make([]uint, len(orders))
for i, order := range orders {
    userIDs[i] = order.UserID
}

// 一次查询
var users []User
db.Where("id IN ?", userIDs).Find(&users)

或者用预加载:

db.Preload("User").Find(&orders)

控制返回数量

查询前想清楚需要多少数据:

// 检查是否存在,用 Limit 1
var exists int64
db.Model(&User{}).Where("email = ?", email).Limit(1).Count(&exists)

// 或者用 First 的变体
var user User
err := db.Where("email = ?", email).Select("id").First(&user).Error
exists := err == nil

总结

查询优化的核心原则:

  1. 只查需要的字段
  2. 用索引,让查询走索引
  3. 避免 OFFSET 大翻页
  4. 批量查询代替循环
  5. 用 EXPLAIN 分析慢查询

这些优化不需要等出问题再做,养成习惯,代码质量自然就上去了。