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:56:51 +08:00

454 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [GORM, Go, ORM, 性能优化, N+1, Preload, Index, QueryPlan]
create time: 2026-04-28 00:00
---
# 性能优化
## 概述
GORM 的功能丰富但有一定运行时开销——相比于手写 SQL,它的查询构建和反射机制会带来额外的 CPU/内存消耗。了解这些瓶颈并针对性优化,是 GORM 在生产环境中稳定运行的高并发场景下的关键。
```mermaid
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 性能杀手——为了获取关联数据,先执行一条查询拿主记录,然后对每条主记录再发起一次查询取关联数据。
```go
// ❌ 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
```go
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 膨胀时较高 |
| 控制力 | 可分别过滤关联 | 难以精细化 |
| 推荐场景 | 大多数情况 | 只需少量关联字段的简单场景 |
## 预加载策略优化
### 按需预加载(只查需要的字段)
```go
// ❌ 加载整个 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 (...);
```
### 条件预加载
```go
// 只预加载状态为 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)
```
### 嵌套预加载的深度控制
```go
// ⚠️ 四层嵌套: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,超过会被截断或报错。对于大规模预加载,考虑分批查询:
> ```go
> func preloadInBatches(db *gorm.DB, ids []uint, dest any) error {
> // dest 必须是切片类型的指针,如 *[]Profile
> batch := 500
> slices := reflect.ValueOf(dest).Elem() // 获取切片本身
>
> for i := 0; i < len(ids); i += batch {
> end := min(i+batch, len(ids))
> chunk := ids[i:end]
>
> var batchResult []map[string]any
> if err := db.Where("user_id IN ?", chunk).Find(&batchResult).Error; err != nil {
> return err
> }
>
> // 追加到原有切片中
> for _, item := range batchResult {
> val := reflect.New(slices.Type().Elem())
> // 将 map 赋值到结构体字段...(此处略)
> slices.Set(reflect.Append(slices, val.Elem()))
> }
> }
> return nil
> }
> ```
>
> > [!tip] 更简单的替代方案
> > 如果关联数据量可控,直接用 Preload + Limit 限制每条主记录的关联数量,避免大规模 IN 查询:
> > ```go
> > db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
> > return db.Order("created_at DESC").Limit(20)
> > }).Find(&users)
> > ```
## 减少不必要的 SELECT
### Select 白名单
```go
// ❌ 加载所有字段(包括不想暴露的密码、内部标记等)
db.Find(&users)
// ✅ 只选需要的字段
db.Select("id", "name", "email").Find(&users)
// ✅ 甚至可以用别名做字段转换
db.Select("id AS user_id, name AS display_name").Find(&users)
```
### Scan 替代 Find
```go
// 只需要统计信息,没必要实例化整个模型
type UserCount struct {
Count int64
}
var stat UserCount
db.Model(&User{}).Select("COUNT(*) as count").Scan(&stat)
```
## 批量操作替代循环
### CreateInBatches
```go
// ❌ 逐条插入:N 次 INSERT
for _, user := range users {
db.Create(&user)
}
// ✅ 批量插入:单次 SQL(底层拆分为 INSERT INTO ... VALUES (...), (...), (...))
users := []User{{Name: "A"}, {Name: "B"}, {Name: "C"}}
db.CreateInBatches(users, 100) // 每批 100 条,共 3 条 SQL
```
> [!tip] 默认批次大小
> `CreateInBatches` 的第二个参数是**批次大小**而非总数。如果省略或用 0,GORM 会按模型切片长度自动决定——全量一次性插入可能超出 `max_allowed_packet`。建议显式指定 100~500。
### 批量更新与删除
```go
// 批量更新:使用 SELECT + CASE WHEN 实现多行不同值更新
db.Model(&Product{}).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "sku"}},
DoUpdates: clause.AssignmentColumns([]string{"price", "stock"}),
}).Create(&products)
// UPSERT 语义:存在则更新,不存在则插入
// 批量删除(条件一致时)
db.Where("status = ?", "cancelled").Delete(&Order{})
// DELETE FROM orders WHERE status = 'cancelled';
```
> [!note] 批量 vs 单条的性能对比
> | 操作 | 1000 条记录 | 说明 |
> |------|-----------|------|
> | 逐条 Create | ~800ms | N 次网络往返 + N 次事务提交 |
> | CreateInBatches(500) | ~50ms | 2 条 SQL,2 次提交 |
> | Raw SQL Batch | ~30ms | 绕过 ORM 映射,最快但有维护成本 |
## 启用预编译语句缓存
```go
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
PrepareStmt: true, // 开启预编译语句缓存
})
// 效果:相同 SQL 的第二次执行会使用 cached statement
// 显著降低 SQL 解析和计划编译的开销
```
> [!note] 缓存机制
> GORM 内置一个基于 `sync.Map` 的简单缓存,存储已经编译好的 SQL 语句。适合重复执行的固定模式查询(如根据 ID 查用户)。不适合大量参数不同的动态查询——缓存命中率低会浪费内存。
## 连接池配置
```go
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"time"
)
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
sqlDB, _ := db.DB() // 获取底层 *sql.DB
// ⚙️ 连接池调优
sqlDB.SetMaxOpenConns(50) // 最大打开连接数(含使用中 + 空闲)
sqlDB.SetMaxIdleConns(25) // 最大空闲连接数
sqlDB.SetConnMaxLifetime(time.Hour) // 连接最大存活时间,防 MySQL wait_timeout 断开
sqlDB.SetConnMaxIdleTime(30 * time.Minute) // Go 1.15+,空闲超时主动回收
```
> [!warning] 常见陷阱
> - **默认 MaxOpenConns = 0(无限制)**:高并发时可能瞬间创建数百连接,压垮数据库。务必显式设置!
> - **PreparedStmt 缓存不计入连接池**:开启 `PrepareStmt: true` 后,GORM 使用独立的缓存结构,但 `.DB()` 返回的还是底层连接池。
> - **连接泄漏**:事务忘记 Commit/Rollback 会占用连接。配合前面提到的「缩小事务粒度」一起使用效果最佳。
> [!tip] 如何确定合适的连接池大小?
> ```
> 推荐公式: MaxOpenConns = CPU核心数 × 2 + 磁盘数
> 例: 4核 2盘 → 4×2+2 = 10 (I/O 密集型可适当放宽到 20~50)
> ```
> 更准确的方式是通过压测观察数据库的活跃连接数和等待队列。
## 索引优化建议
### 在 tag 中声明索引
```go
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' 前通配 | ❌ | 索引失效 |
### 查看执行计划
```go
// 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 说明用了临时表
```
## 事务粒度优化
```go
// ❌ 事务范围过大 —— 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 更安全:
> ```go
> err := db.Transaction(func(tx *gorm.DB) error {
> // 返回非 nil 自动回滚
> if err := tx.Create(&user).Error; err != nil {
> return err
> }
> return tx.Create(&order).Error
> })
> ```
## 软删除的性能陷阱
GORM 的 `SoftDelete` 功能非常便捷,但在高频查询场景下会带来隐式开销。
### 自动注入 WHERE deleted_at IS NULL
```go
type User struct {
gorm.Model // 包含 DeletedAt *time.Time
}
// 无论你是否主动写条件,GORM 自动附加:
db.Where("status = ?", "active").Find(&users)
// 实际执行:SELECT * FROM users WHERE status = 'active' AND deleted_at IS NULL;
```
这意味着**每一个查询都多了一个索引列的判断**。
### 优化策略
```go
// ❌ 带软删除字段的表,WHERE 子句变复杂时走不了覆盖索引
// ✅ 策略一:为 (deleted_at, 查询字段) 建复合索引
type Article struct {
ID uint `gorm:"primaryKey"`
Status string `gorm:"size:16;not null;index:idx_status_deleted"`
DeletedAt time.Time `gorm:"index:idx_status_deleted"`
}
// ✅ 策略二:不需要软删除的场景用 Unscoped 跳过过滤
db.Unscoped().Where("id = ?", 1).First(&user)
// SELECT * FROM users WHERE id = 1; (不含 deleted_at 条件)
// ✅ 策略三:归档历史数据到独立表,避免主表膨胀
// archived_users 表不设软删除,定期将旧数据迁移过去
```
> [!danger] 数据量敏感
> 当单表超过千万级记录且开启软删除时,未加合适索引的查询会从 index scan 退化为全表扫描(需要扫描并判断每一行的 `deleted_at` 值)。务必用 `EXPLAIN` 验证核心查询路径。
## 性能优化检查清单
```mermaid
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 都是随机构造的 | 只对固定模式查询开启 |
## 关联笔记
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[05-关联查询]]
- [[08-事务管理]]
- [[11-批量操作]]