This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/GORM/15-性能优化.md
T
2026-04-28 20:23:33 +08:00

10 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
性能优化
N+1
Preload
Index
QueryPlan
2026-04-28 00:00

性能优化

概述

GORM 的功能丰富但有一定运行时开销——相比于手写 SQL,它的查询构建和反射机制会带来额外的 CPU/内存消耗。了解这些瓶颈并针对性优化,是 GORM 在生产环境中稳定运行的高并发场景下的关键。

flowchart TD
    A["🐌 慢的原因"] --> B{"类别"}
    B --> |查询次数多| SlowQuery["N+1 / 过多 SQL"]
    B --> |数据量大| LargeResult["未分页 / 全表扫描"]
    B --> |缺少索引| NoIndex["WHERE / JOIN / ORDER BY 无索引"]
    B --> |框架开销| FrameworkOverhead["反射 + 对象映射"]
    
    SlowQuery --> FixPreload["Preload 预加载"]
    LargeResult --> FixPaging["Limit / Offset"]
    NoIndex --> FixIndex["添加数据库索引"]
    FrameworkOverhead --> FixRaw["Raw SQL / Select 指定字段"]
    
    style A fill:#EF4444,color:#fff
    style FixPreload fill:#3B82F6,color:#fff
    style FixPaging fill:#4FC08D,color:#fff
    style FixIndex fill:#EAB308,color:#fff
    style FixRaw fill:#8B5CF6,color:#fff

N+1 问题排查与解决

什么是 N+1?

N+1 是最常见的 ORM 性能杀手——为了获取关联数据,先执行一条查询拿主记录,然后对每条主记录再发起一次查询取关联数据。

// ❌ N+1:查 100 个用户需要 101 条 SQL
users := []User{}
db.Find(&users) // SQL 1: SELECT * FROM users;
for _, u := range users {
    db.First(&u.Profile, u.ID) // SQL 2~101: SELECT * FROM profiles WHERE user_id = ?
}

// ✅ 解决方案一:Preload(变成 2 条 SQL)
var users []User
db.Preload("Profile").Find(&users)
// SQL 1: SELECT * FROM users;
// SQL 2: SELECT * FROM profiles WHERE user_id IN (1,2,3,...,100);

// ✅ 解决方案二:Joins(变成 1 条 SQL)
db.Joins("Profile").Find(&users)
// SELECT users.*, profiles.* FROM users LEFT JOIN profiles ON ...;

使用 Debug() 检测 N+1

db.Debug().Find(&users)
// 输出所有生成的 SQL:
// [info] ... [0.5ms] [rows:100] SELECT * FROM users
// [info] ... [1.2ms] [rows:100] SELECT * FROM profiles WHERE user_id IN (...)

[!tip] 如何判断是否该用 Preload 还是 Joins?

维度 Preload Joins
SQL 数量 N+1 通常 1
内存占用 适中 JOIN 膨胀时较高
控制力 可分别过滤关联 难以精细化
推荐场景 大多数情况 只需少量关联字段的简单场景

预加载策略优化

按需预加载(只查需要的字段)

// ❌ 加载整个 Profile,浪费带宽
db.Preload("Profile").Find(&users)

// ✅ 只取需要的列
db.Preload("Profile", "user_id, avatar_url").Find(&users)
// SELECT * FROM users;
// SELECT user_id, avatar_url FROM profiles WHERE user_id IN (...);

条件预加载

// 只预加载状态为 active 的订单
db.Preload("Orders", "status = ?", "active").Find(&users)

// 复杂条件
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
    return db.Where("amount > ?", 100).Order("created_at DESC").Limit(5)
}).Find(&users)

嵌套预加载的深度控制

// ⚠️ 四层嵌套:User → Orders → OrderItems → Product → Category
// SQL 数量 = 1 + 4 = 5 条,但每次 IN 列表可能非常大
// 当每层平均有 100 条记录时 → IN 列表可能有 10^6 个值!

// 解决方案:分层查询而非一次性全部预加载
func GetUserWithTopOrders(db *gorm.DB, id uint) (*User, error) {
    var user User
    if err := db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
        return db.Order("created_at DESC").Limit(10)
    }).First(&user, id).Error; err != nil {
        return nil, err
    }
    return &user, nil
}

[!warning] IN 列表大小限制 不同数据库对 IN 列表大小有限制:MySQL 默认 max_allowed_packet 约 4MB,超过会被截断或报错。对于大规模预加载,考虑分批查询:

func preloadInBatches(db *gorm.DB, ids []uint, dest any) error {
    batch := 500
    for i := 0; i < len(ids); i += batch {
        end := i + batch
        if end > len(ids) {
            end = len(ids)
        }
        chunk := ids[i:end]
        if err := db.Where("user_id IN ?", chunk).Find(dest).Error; err != nil {
            return err
        }
    }
    return nil
}

减少不必要的 SELECT

Select 白名单

// ❌ 加载所有字段(包括不想暴露的密码、内部标记等)
db.Find(&users)

// ✅ 只选需要的字段
db.Select("id", "name", "email").Find(&users)

// ✅ 甚至可以用别名做字段转换
db.Select("id AS user_id, name AS display_name").Find(&users)

Scan 替代 Find

// 只需要统计信息,没必要实例化整个模型
type UserCount struct {
    Count int64
}

var stat UserCount
db.Model(&User{}).Select("COUNT(*) as count").Scan(&stat)

启用预编译语句缓存

db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
    PrepareStmt: true, // 开启预编译语句缓存
})

// 效果:相同 SQL 的第二次执行会使用 cached statement
// 显著降低 SQL 解析和计划编译的开销

[!note] 缓存机制 GORM 内置一个基于 sync.Map 的简单缓存,存储已经编译好的 SQL 语句。适合重复执行的固定模式查询(如根据 ID 查用户)。不适合大量参数不同的动态查询——缓存命中率低会浪费内存。

索引优化建议

在 tag 中声明索引

type Article struct {
    ID        uint      `gorm:"primaryKey"`
    Title     string    `gorm:"size:128;not null;index:idx_title"`       // 普通索引
    Status    string    `gorm:"size:16;not null;index:idx_status"`        // 普通索引
    UserID    uint      `gorm:"index:idx_article_user"`                   // 外键索引
    CreatedAt time.Time `gorm:"index:idx_created_at"`                     // 时间索引
    
    // 复合索引
    Slug      string    `gorm:"size:128;uniqueIndex:idx_slug_owner"`      // 联合唯一索引
    
    // 覆盖索引(PostgreSQL 支持 INCLUDE)
    ViewCount int       `gorm:"index:idx_status_views,type:int"`
}

何时加索引

场景 是否需索引 理由
WHERE 高频查询的列 ✅ 避免全表扫描
JOIN 关联字段 ✅ 外键几乎都应该有索引
ORDER BY 排序字段 ✅ 避免 filesort
写频率高的字段 ❌ 谨慎 每个索引增加写入成本(INSERT/UPDATE 要维护索引树)
低基数列(如 gender) ❌ 区分度太低,优化器不走索引
LIKE '%xxx' 前通配 ❌ 索引失效

查看执行计划

// MySQL EXPLAIN
db.Debug().Where("status = ? AND created_at > ?", "active", cutoff).Find(&users)
// 从日志中找到生成的 SQL,在 MySQL 客户端执行:
// EXPLAIN SELECT * FROM users WHERE status = 'active' AND created_at > '...';

// 关注点:
// - type: 应该是 range 或 ref,不能是 ALL(全表扫描)
// - rows: 预估扫描行数,越小越好
// - Extra: 出现 Using filesort 说明需要额外排序;Using temporary 说明用了临时表

事务粒度优化

// ❌ 事务范围过大 —— HTTP 请求也包在里面
tx := db.Begin()
defer tx.Rollback()

user, _ := fetchFromAPI() // 网络请求 200ms
if err := tx.Create(&user).Error; err != nil {
    return err
}

order, _ := fetchFromAnotherAPI() // 另一个网络请求 300ms
if err := tx.Create(&order).Error; err != nil {
    return err
}

return tx.Commit().Error
// 总耗时:200 + DB + 300 + DB ≈ 500ms+
// 期间行锁一直持有,其他事务只能等待

// ✅ 事务范围仅包含 DB 操作
func SaveToDB(tx *gorm.DB, user, order any) error {
    if err := tx.Create(user).Error; err != nil {
        return err
    }
    return tx.Create(order).Error
}

// 外层只包数据库操作
func HandleRequest() error {
    user, _ := fetchFromAPI()
    order, _ := fetchFromAnotherAPI()
    
    return db.Transaction(func(tx *gorm.DB) error {
        return SaveToDB(tx, user, order)
    })
}
// 总耗时:DB + DB ≈ 几十 ms

[!tip] Transaction API 简洁写法 GORM 提供了一个闭包形式的 Transaction,比手动的 Begin/Rollback/Commit 更安全:

err := db.Transaction(func(tx *gorm.DB) error {
    // 返回非 nil 自动回滚
    if err := tx.Create(&user).Error; err != nil {
        return err
    }
    return tx.Create(&order).Error
})

性能优化检查清单

flowchart LR
    A["性能瓶颈"] --> Q1{"SQL 数量过多?"}
    Q1 -->|是| B["用 Preload / Joins 合并"]
    Q1 -->|否| Q2{"结果集太大?"}
    Q2 -->|是| C["加 Limit / 筛选字段"]
    Q2 -->|否| Q3{"扫描行数多?"}
    Q3 -->|是| D["检查索引"]
    Q3 -->|否| Q4{"单条 SQL 太慢?"}
    Q4 -->|是| E["EXPLAIN 分析执行计划"]
    Q4 -->|否| F{"框架开销占比高?"}
    F -->|是| G["启用 PrepareStmt / 改用 Raw SQL"]
    F -->|否| H["已达标 ✓"]
    
    style A fill:#EF4444,color:#fff
    style H fill:#4FC08D,color:#fff
✅ 优化项 收益 复杂度
消除 N+1(Preload) ⭐⭐⭐⭐⭐ 低
Select 白名单 ⭐⭐⭐⭐ 低
添加索引 ⭐⭐⭐⭐⭐ 低
缩小事务范围 ⭐⭐⭐⭐ 中
PrepareStmt 缓存 ⭐⭐⭐ 低
Raw SQL 替换热点查询 ⭐⭐⭐⭐⭐ 高
批量操作替代循环 ⭐⭐⭐⭐⭐ 低

常见坑点速查

问题 原因 解决方案
Preload 后接口极慢 IN 列表过大 限制关联数量或用 Join 替代
查询响应不稳定 没有索引导致偶发全表扫描 用 EXPLAIN 找出缺失索引
插入速度慢 逐条插入 + 事务未复用 CreateInBatches
连接池耗尽 事务内调用外部服务 将 IO 操作移出事务
预编译缓存泄漏 PrepareStmt=true 但 SQL 都是随机构造的 只对固定模式查询开启

关联笔记