vault backup: 2026-04-28 20:23:33

This commit is contained in:
2026-04-28 20:23:33 +08:00
parent 4f4abfd2c1
commit 39aaa384ba
15 changed files with 3992 additions and 14 deletions
+286
View File
@@ -0,0 +1,286 @@
---
tags: [GORM, Go, ORM, 查询, Where, Between, In, Like, Or, Not]
create time: 2026-04-28 00:00
---
# 条件查询
## 概述
条件查询是日常开发中最频繁的数据库操作——从「找出某个用户」到「筛选过去七天活跃的用户」,本质上都是构造 WHERE 子句。GORM 提供了链式的条件 API,让你用 Go 类型安全地构建 SQL 查询,而不是拼接脆弱的字符串。
> [!tip] 核心原则
> GORM 的每个条件方法(`Where` / `Or` / `Not` 等)都返回一个新的 `*gorm.DB` 实例——**不会修改原始 DB**。这意味着你可以反复复用同一个基础查询,像拼乐高一样叠加不同的过滤条件。
## Where 链式调用
### 基本用法
```go
// 单条件
db.Where("name = ?", "john").Find(&users)
// SELECT * FROM users WHERE name = 'john';
// 多条件(AND 关系)
db.Where("name = ? AND age >= ?", "john", 20).Find(&users)
// SELECT * FROM users WHERE name = 'john' AND age >= 20;
```
> [!warning] SQL 注入防护
> **永远不要**把用户输入直接拼进字符串:
> ```go
> // ❌ 危险!用户可注入 `' OR '1'='1`
> db.Where("name = '" + userInput + "'").Find(&users)
>
> // ✅ 正确 —— GORM 会自动参数化
> db.Where("name = ?", userInput).Find(&users)
> ```
### 结构体条件
GORM 支持直接用 struct 作为查询条件——非零值字段会被自动转为 `key = value`:
```go
// 查找姓名为 "john" 且年龄大于等于 20 的用户
db.Where(&User{Name: "john", Age: 20}).Find(&users)
// SELECT * FROM users WHERE name = 'john' AND age = 20;
// map 方式(键为字段名或列名)
db.Where(map[string]any{"name": "john", "age": 20}).Find(&users)
// SELECT * FROM users WHERE name = 'john' AND age = 20;
```
> [!question] 为什么结构体条件用的是 `=` 而非 `>=`?
> 因为 `20` 就是字面值,不存在比较运算符的问题。如果要做范围查询,需要用 Map 方式配合自定义 key:
> ```go
> db.Where("age > ?", 20).Find(&users)
> // 或者
> db.Where(map[string]any{"age >": 20}).Find(&users) // GORM 会把 ">"" 直接放到 SQL 中
> ```
### 结构化 WHERE 子句
对于复杂的嵌套条件,可以用 `map` + `[]any` 组合构建:
```go
// (name = john AND age >= 20) OR (role = admin)
db.Where(
"name = ? AND age >= ?", "john", 20,
).Or(
"role = ?", "admin",
).Find(&users)
// SELECT * FROM users WHERE (name = 'john' AND age >= 20) OR (role = 'admin');
```
> [!tip] 括号处理
> GORM 会自动为 `Where` + `Or` 组合加括号包裹 WHERE 部分,保证运算优先级正确。但如果你需要更精细的控制,可以手动写完整表达式:
> ```go
> db.Where("(name = ? OR name = ?) AND age > ?", "john", "jane", 18).Find(&users)
> ```
## 常用条件操作符
### In / Not In
```go
// IN 查询
db.Where("id IN (?)", []int{1, 2, 3}).Find(&users)
// SELECT * FROM users WHERE id IN (1, 2, 3);
// 批量删除
db.Where("id IN (?)", []int{1, 2, 3}).Delete(&User{})
// NOT IN
db.Not("id IN (?)", []int{1, 2, 3}).Find(&users)
// SELECT * FROM users WHERE id NOT IN (1, 2, 3);
```
> [!tip] 空切片陷阱
> `[]int{}`(空切片)传进 `IN (?)` 时,GORM 会生成 `IN ()` —— 这是非法 SQL。**使用前务必校验切片的长度**:
> ```go
> ids := []int{1, 2, 3}
> if len(ids) == 0 {
> return nil, nil // 没有 ID,直接返回空结果
> }
> db.Where("id IN (?)", ids).Find(&users)
> ```
### Between / Not Between
```go
// 范围查询
db.Where("age BETWEEN ? AND ?", 18, 60).Find(&users)
// SELECT * FROM users WHERE age BETWEEN 18 AND 60;
// Go 风格写法(等价)
db.Where("age >= ? AND age <= ?", 18, 60).Find(&users)
// 时间范围(按天查最近 7 天的订单)
startTime := time.Now().AddDate(0, 0, -7)
db.Where("created_at BETWEEN ? AND ?", startTime, time.Now()).Find(&orders)
```
### Like 模糊匹配
```go
// 前缀匹配
db.Where("name LIKE ?", "john%").Find(&users) // 以 john 开头
// SELECT * FROM users WHERE name LIKE 'john%';
// 后缀匹配
db.Where("email LIKE ?", "%@gmail.com").Find(&users) // gmail 邮箱
// 包含匹配
db.Where("title LIKE ?", "%go编程%").Find(&articles) // 标题包含关键词
```
> [!warning] LIKE 的性能问题
> `%xxx%` 这种前后通配会导致索引失效——数据库无法使用 B+ 树的有序性,退化成全表扫描。当数据量超过 10 万行时,考虑改用专门的搜索引擎(如 Elasticsearch)。
### Is NULL / Is NOT NULL
```go
// 查字段为 NULL 的记录
db.Where("deleted_at IS NULL").Find(&users)
// 注意:这其实是 GORM 软删除的默认行为,通常不用显式写
// 查字段不为 NULL
db.Where("phone IS NOT NULL").Find(&users)
// GORM 简洁写法
db.Where("phone ?", "not null").Find(&users)
```
### Not 排除查询
```go
// 排除特定状态的用户
db.Not("status", "banned").Find(&users)
// SELECT * FROM users WHERE status != 'banned';
// 排除多个值
db.Not([]string{"banned", "inactive"}).Find(&users)
// SELECT * FROM users WHERE status NOT IN ('banned', 'inactive');
// 多字段排除
db.Not(map[string]any{"name": "john", "role": "admin"}).Find(&users)
// SELECT * FROM users WHERE name != 'john' AND role != 'admin';
```
### Or 条件组合
```go
// 找 VIP 用户或注册时间超过一年的用户
db.Where("role = ?", "vip").Or("created_at < ?", time.Now().AddDate(-1, 0, 0)).Find(&users)
// SELECT * FROM users WHERE (role = 'vip') OR (created_at < '2025-04-28...');
```
> [!example] Where 和 Or 的执行顺序
> GORM 的条件是「左结合」的。下面两种写法的语义不同:
>
> ```go
> // 写法 A: (a AND b) OR c
> db.Where("a = 1").Where("b = 2").Or("c = 3")
>
> // 写法 B: a AND (b OR c)
> db.Where("a = 1").Or("b = 2 AND c = 3")
> ```
> 建议在复杂场景下用 `()` 显式控制优先级,让意图一目了然。
## 高级条件构造
### 原始 SQL
当 GORM 提供的 DSL 不够用时,可以直接写原生 SQL:
```go
// 使用 ? 占位符(安全,防止注入)
db.Where("UNIX_TIMESTAMP(created_at) > ?", time.Now().AddDate(0,0,-1).Unix()).Find(&users)
// 使用 gorm.Expr 处理更复杂的表达式
db.Where("price * ? > ?", 0.8, 100).Find(&products) // price * 0.8 > 100
```
### Row / Scan 直接执行
```go
// 执行任意查询并扫描到自定义变量
var count int
db.Raw("SELECT COUNT(*) FROM users WHERE status = ?", "active").Scan(&count)
// Query 返回 *sql.Row / *sql.Rows,适合更底层的操作
rows, err := db.Raw("SELECT id, name FROM users").Rows()
defer rows.Close()
for rows.Next() {
var id int
var name string
rows.Scan(&id, &name)
// ...
}
```
> [!important] 资源管理
> `Rows` 对象持有数据库连接,使用完毕后**必须**调用 `rows.Close()`,否则会导致连接泄漏。如果只需要单行结果,优先用 `Raw(...).Scan(&dest)`。
### Condition 对象(条件复用)
```go
cond := gorm.Condition.Where("status = ?", "active").Or("status = ?", "pending")
db.Where(cond).Find(&users)
// 多个条件组合复用
activeCount := db.Model(&User{}).Where(cond).Count()
activeUsers := db.Where(cond).Order("created_at DESC").Limit(10).Find(&users)
```
> [!tip] 何时使用 Condition 对象?
> 当你需要在多个查询中重复使用同一组条件时(比如项目中所有业务模块都要「只查活跃数据」),把它抽成全局 Condition 变量,避免到处复制粘贴 SQL 片段。
## 条件查询决策图
```mermaid
flowchart TD
Start[收到查询请求] --> Type{条件类型_}
Type --> |精确匹配| Exact["Where(field, value)"]
Type --> |范围查询| RangeQ{BETWEEN 还是 _}
RangeQ --> |GORM 自带| Between["BETWEEN ? AND ?"]
RangeQ --> |自定义> < |=LtGt["where field > ? AND field < ?"]
Type --> |集合| SetQ{IN 还是 NOT IN_}
SetQ --> |IN| InOp["In field VALUES"]
SetQ --> |NOT IN| NotInOp["Not In field VALUES"]
Type --> |模糊| LikeQ{前缀/后缀/全匹配_}
LikeQ --> |前缀%| PrefixLike["LIKE 'prefix%'"]
LikeQ --> |后缀%| SuffixLike["LIKE '%suffix'"]
LikeQ --> |全匹配%| FullLike["LIKE '%keyword%'"]
Type --> |NULL检查| NullQ{IS NULL 还是 _}
NullQ --> |IS NULL|IsNull["IS NULL"]
NullQ --> |IS NOT NULL| IsNotNull["IS NOT NULL"]
Type --> |复合条件| AndQ{简单AND 还是 OR混合_}
AndQ --> |简单AND| SimpleAnd["连续 Where 调用"]
AndQ --> |OR混合| OrChain["Where + Or 链式"]
Type --> |特殊SQL| Raw["Raw + Expr"]
style Start fill:#4FC08D,color:#fff
style Exact fill:#3B82F6,color:#fff
style LikeOp fill:#F59E0B,color:#000
style Raw fill:#EC4899,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 空切片传入 `Where("id IN (?)", ids)` | 生成 `IN ()` 非法 SQL | 先用 `len(ids) > 0` 判断 |
| LIKE 查询太慢 | 索引失效导致全表扫描 | 大数据量改用全文检索引擎 |
| 更新零值字段没生效 | `Updates(struct)` 跳过零值 | 改用 `Updates(map)` |
| Or 条件没有预期效果 | 运算符优先级问题 | 用 `()` 显式分组 |
| Rows 没有 Close | 连接泄漏 | `defer rows.Close()` |
## 关联笔记
- [[01-安装与初始化]]
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[05-关联查询]]
- [[06-排序与分页]]
+340
View File
@@ -0,0 +1,340 @@
---
tags: [GORM, Go, ORM, 关联查询, Preload, Joins, HasOne, HasMany, BelongsTo, ManyToMany]
create time: 2026-04-28 00:00
---
# 关联查询
## 概述
现实世界的数据从不孤立存在——用户有订单、订单包含商品、商品属于分类。如何在一次或多次查询中高效地加载这些关联数据,是 ORM 的核心能力。
GORM 提供了两种主要的关联加载方式:
- **N+1 预加载**(`Preload`):额外发 N+1 条 SQL,批量填充关联
- **JOIN 单查询**(`Joins`):一条复杂 SQL 拿全量数据
理解它们的区别和适用场景,可以避免常见的性能陷阱。
> [!tip] 核心原则
> GORM 的关联查询有两种策略:**Eager Loading**(预加载)和 **Lazy Loading**(按需加载)。默认情况下,GORM **不会自动加载关联数据**——你必须显式调用 `Preload` 或 `Joins`,这给了你完全的控制权。
## 关联类型概览
```mermaid
flowchart LR
User["User"] -->|HasMany| Order["Order"]
User -->|BelongsTo| Role["Role"]
Order -->|HasOne| Detail["OrderDetail"]
Order -->|ManyToMany| Product["Product"]
style User fill:#3B82F6,color:#fff
style Order fill:#4FC08D,color:#fff
style Role fill:#F59E0B,color:#000
style Detail fill:#A0AEC0,color:#fff
style Product fill:#EC4899,color:#fff
classDef foreignKey color:#EF4444;
```
| 关联类型 | 外键位置 | 关系描述 | 示例 |
|----------|---------|---------|------|
| **HasOne** | 被关联方 | A 有一条唯一的 B | User → Profile |
| **HasMany** | 被关联方 | A 有多条 B | User → Orders |
| **BelongsTo** | 当前方 | A 属于某个 B | Order → User |
| **ManyToMany** | 中间表 | A 和 B 多对多 | Order ↔ Product |
## 前置准备:定义关联
在能查之前,先要在 struct tag 里声明关联关系:
```go
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:64"`
Profile Profile `gorm:"foreignKey:UserID"` // HasOne
Orders []Order `gorm:"foreignKey:UserID"` // HasMany
Roles []Role `gorm:"many2many:user_roles"` // ManyToMany
}
type Profile struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index;uniqueIndex"` // ForeignKey
AvatarURL string `gorm:"size:256"`
}
type Order struct {
ID uint `gorm:"primaryKey"`
UserID uint `gorm:"index"` // ForeignKey(BelongsTo)
Amount float64
Products []Product `gorm:"many2many:order_products"` // ManyToMany
}
type Product struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:128"`
Price float64
}
type Role struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:64;uniqueIndex"`
}
```
> [!note] GORM 的外键推断规则
> 如果没有显式指定 `foreignKey`,GORM 会用「关联模型名 + 主键」来推断。例如 `Profile` 模型的 `UserID` 会被认为是指向 `User.ID` 的外键。显式声明的好处是**语义清晰、不易出错**。
## Preload — 预加载关联数据
`Preload` 是最常用的关联加载方式。它在原始查询之后,用额外的 SQL 语句批量填充关联字段。
### 基本用法
```go
// 加载 User 及其 HasOne Profile
var user User
db.Preload("Profile").First(&user, 1)
// 执行的 SQL:
// SELECT * FROM users WHERE id = 1 LIMIT 1;
// SELECT * FROM profiles WHERE user_id = 1;
// 加载 User 及其 HasMany Orders
db.Preload("Orders").Find(&users)
// 执行的 SQL:
// SELECT * FROM users;
// SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...);
```
> [!important] 为什么叫 N+1?
> 第 1 条 SQL 查主表(n 个用户),后面 N 条 SQL 查关联表。当关联只有一条时就是「2 条 SQL」;如果关联是多条且每个用户关联不同数据,就需要额外查询——所以需要控制关联的深度和范围。
### 链式 Preload
```go
// 同时加载多个层级的关联
db.Preload("Profile").
Preload("Orders").
Preload("Orders.Products"). // 级联加载 —— 订单里的商品
Find(&users)
// 执行的 SQL:
// 1. SELECT * FROM users;
// 2. SELECT * FROM profiles WHERE user_id IN (...);
// 3. SELECT * FROM orders WHERE user_id IN (...);
// 4. SELECT * FROM order_products WHERE order_id IN (...);
// 5. SELECT * FROM products WHERE id IN (...);
```
> [!tip] 层级命名
> `Orders.Products` 使用点号分隔层级——GORM 会自动遍历这条路径。嵌套可以无限深,但要小心**笛卡尔积爆炸**:深层嵌套 + 大量数据会生成极慢的 JOIN 查询(如果用 Joins 方式)或多次大结果集的 IN 查询(如果用 Preload)。
### 带条件的 Preload
并非所有关联数据都需要全量加载。可以用函数形式的 `Preload` 过滤:
```go
// 只加载金额大于 100 的订单
db.Preload("Orders", "amount > ?", 100).Find(&users)
// 更精细控制:WHERE + ORDER BY
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Where("amount > ?", 100).Order("created_at DESC")
}).Find(&users)
// 限制关联表的字段(减少传输开销)
db.Preload("Orders", "id, amount, status").
Preload("Orders.Products", "id, name").
Find(&users)
```
> [!question] 思考题
> 如果给 Preload 加了条件,但用户没有任何符合条件的关联记录会发生什么?
>
> > **答案**:关联字段会是空切片/零值结构体,不会报错。这是符合直觉的行为——只是「没找到」而非「出错了」。
## Preload vs PreloadWith
GORM v1.x 有单独的 `PreloadWith` 方法,但在 v2.x 中已被整合到 `Preload` 的函数式参数里。如果你从旧文档看到 `PreloadWith`,请参考上面的「链式 Preload」写法。
## Joins — 单次 JOIN 查询
与 `Preload` 的多条 SQL 不同,`Joins` 在一条 SQL 中通过 JOIN 获取关联数据:
### 基本用法
```go
// 只加载一层 Profile
var user User
db.Joins("Profile").First(&user, 1)
// SELECT users.*, profiles.* FROM users
// LEFT JOIN profiles ON profiles.user_id = users.id
// WHERE users.id = 1;
```
### 多层 Joins(嵌套)
```go
// 加载用户及其订单中的商品信息
db.Joins("Profile").
Joins("JOIN order_items ON order_items.user_id = users.id").
Joins("JOIN products ON products.id = order_items.product_id").
Where("users.id = ?", 1).
Find(&user)
```
> [!warning] Joins 的注意事项
> - 使用 LEFT JOIN 还是 INNER JOIN 取决于业务语义:想保留没有关联数据的记录用 `LEFT JOIN`,只想要有关联的用 `JOIN`(等价于 `INNER JOIN`)。
> - GORM 默认的 `Joins("关联名")` 生成的是 `LEFT JOIN`。
> - 深度关联下,JOIN 会导致行重复(一个用户有多个订单时会返回多行 User),需要手动去重。
### Joins 条件过滤
```go
// 只 Join 状态为 active 的 Profile
db.Joins("Profile", "profiles.status = ?", "active").First(&user, 1)
```
> [!example] Preload vs Joins 对比
| 维度 | Preload | Joins |
|------|---------|-------|
| SQL 数量 | N+1 条 | 通常 1 条 |
| 关联数据过滤 | 容易(WHERE 作为第二参数) | 较难(写在 JOIN 子句中) |
| 关联字段选择 | 支持(直接写列名) | 需自定义 SELECT |
| 适合场景 | 大多数场景,可控性好 | 只需少量关联字段的简单查询 |
| 大数据量风险 | 外层 IN (...) 过大 | 行膨胀、内存占用高 |
> [!tip] 经验法则
> 「**优先 Preload,必要时 Joins**。」Preload 的可控性和可预测性更好;只在确实需要减少 SQL 数量、且关联数据量很小时才用 Joins。
## HasOne 详细示例
```go
// 查询用户的 Profile
var user User
db.Preload("Profile").First(&user, 1)
fmt.Println(user.Profile.AvatarURL) // 可直接访问,无需二次查询
// 创建用户并同时创建 Profile
db.Create(&User{
Name: "Alice",
Profile: Profile{AvatarURL: "https://example.com/avatar.png"},
})
// INSERT INTO users ...; INSERT INTO profiles ...
```
## HasMany 详细示例
```go
// 查询用户的所有订单
var user User
db.Preload("Orders").First(&user, 1)
for _, order := range user.Orders {
fmt.Printf("订单 %d: ¥%.2f\n", order.ID, order.Amount)
}
// 查询某用户的所有订单金额总和
var total float64
db.Model(&user).Association("Orders").Count(&total) // 这会统计行数,不是金额总和
// 正确做法:
db.Model(&user).Select("COALESCE(SUM(amount), 0)").Scan(&total)
```
## BelongsTo 详细示例
```go
// 查询订单所属的用户
var order Order
db.Preload("User").First(&order, 1)
fmt.Println(order.User.Name) // Alice
// 更新订单的用户关联
order.UserID = 2
db.Save(&order) // UPDATE orders SET user_id=2 WHERE id=1
```
## ManyToMany 详细示例
```go
// 查询订单包含的商品
var order Order
db.Preload("Products").First(&order, 1)
// GORM 自动管理中间表 order_products:
// SELECT * FROM orders WHERE id = 1;
// SELECT * FROM products p
// INNER JOIN order_products op ON op.product_id = p.id
// WHERE op.order_id = 1;
// 给订单添加商品
product := Product{Name: "Go Programming", Price: 99}
db.Model(&order).Association("Products").Append(&product)
// INSERT INTO products ...; INSERT INTO order_products ...
// 批量添加
products := []Product{{Name: "SQL Essentials", Price: 79}, {Name: "Linux DevOps", Price: 59}}
db.Model(&order).Association("Products").Append(products)
// 替换全部商品(移除旧的关联)
newProducts := []Product{{Name: "Kubernetes in Action", Price: 109}}
db.Model(&order).Association("Products").Replace(newProducts)
// 删除特定关联
db.Model(&order).Association("Products").Delete(&product)
// 清理所有关联
db.Model(&order).Association("Products").Clear()
// 获取关联数
count, _ := db.Model(&order).Association("Products").Count()
```
> [!important] Association API 一览
>
> | 方法 | 行为 | SQL 效果 |
> |------|------|---------|
> | `Append` | 追加新关联 | INSERT 中间表记录 |
> | `Replace` | 替换全部(删旧加新) | TRUNCATE + INSERT |
> | `Delete` | 删除指定关联 | DELETE 中间表记录 |
> | `Clear` | 清空所有关联 | DELETE 全部中间表记录 |
> | `Count` | 统计关联数量 | COUNT(*) |
## 关联查询决策图
```mermaid
flowchart TD
Start[需要加载关联数据] --> Depth{关联层级_}
Depth --> |单层浅查询| SimpleQ{数据量大吗_}
SimpleQ --> |否| JoinsSingle["Joins — 单条 SQL"]
SimpleQ --> |是| PreloadSingle["Preload — 多条小 SQL"]
Depth --> |多层级联| MultiQ{每层数据量_}
MultiQ --> |都很小| DeepJoins["嵌套 Joins"]
MultiQ --> |某层较大| DeepPreload["Preload 链式 + 条件过滤"]
MultiQ --> |不清楚| PreloadSafe["Preload(安全首选)"]
Depth --> |只需要计数/特定字段| SpecificField["Selection 或 Count"]
style Start fill:#4FC08D,color:#fff
style PreloadSafe fill:#3B82F6,color:#fff
style JoinsSingle fill:#F59E0B,color:#000
style DeepPreload fill:#8B5CF6,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 预加载很慢 | N+1 导致过多 SQL 或 IN 列表过大 | 改用 `Joins` 或用 BatchQuery 分片 |
| Preload 后数据为空 | 关联字段名不匹配 struct 标签 | 检查 `foreignKey` / `_associationName` 拼写 |
| ManyToMany 新增已存在记录报错 | 唯一约束冲突 | 先用 `Count()` 判断或使用 `Clauses(clause.OnConflict{DoNothing: true})` |
| Joins 导致行重复 | LEFT JOIN 产生笛卡尔积 | 去重或用 `Distinct` |
| 加载了不必要的关联字段 | 浪费带宽和内存 | 用 Preload 字段白名单:`Preload("Orders", "id,status,amount")` |
## 关联笔记
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[06-排序与分页]]
- [[15-性能优化]]
+275
View File
@@ -0,0 +1,275 @@
---
tags: [GORM, Go, ORM, 排序, 分页, Order, Limit, Offset, Paginate]
create time: 2026-04-28 00:00
---
# 排序与分页
## 概述
排序和分页是面向用户的数据展示层的核心技能。不管后端查出多少数据,最终呈现在页面上的总是「一页」——理解 GORM 如何高效地完成这个任务,直接影响 API 的响应时间和用户体验。
## Order — 排序
### 基本用法
```go
// 单字段升序
db.Order("age ASC").Find(&users)
// SELECT * FROM users ORDER BY age ASC;
// 多字段排序(先按年龄降序,年龄相同则按创建时间升序)
db.Order("age DESC").Order("created_at ASC").Find(&users)
// SELECT * FROM users ORDER BY age DESC, created_at ASC;
```
> [!tip] 链式 Order 的行为
> 多次调用 `Order` 会**追加**排序条件,而不是覆盖。所以上面的写法等价于:
> ```go
> db.Order("age DESC, created_at ASC").Find(&users)
> ```
### 动态排序
当排序字段来自前端参数时,需要小心处理 SQL 注入:
```go
// ❌ 危险 —— 用户可传入 "age; DROP TABLE users;"
orderBy := "age" // 来自 query params
db.Order(orderBy + " DESC").Find(&users)
// ✅ 白名单校验
allowedOrders := map[string]bool{
"age": true,
"created_at": true,
"name": true,
"amount": true,
}
field := "created_at" // 来自前端
if !allowedOrders[field] {
field = "created_at" // 默认值
}
db.Order(field + " DESC").Find(&users)
```
> [!important] 为什么不能用占位符传列名?
> GORM 的 `?` 只支持**值参数化**,不支持标识符(表名、列名、ORDER BY 方向)。这是所有 SQL 驱动的限制——SQL 预编译机制只对参数有效,对结构部分无效。因此必须通过白名单或其他方式确保列名的安全性。
### 随机排序
```go
// MySQL
db.Order("RAND()").Find(&users)
// PostgreSQL
db.Order("RANDOM()").Find(&users)
// SQLite
db.Order("RANDOM()").Find(&users)
```
> [!warning] 随机排序的性能陷阱
> `ORDER BY RAND()` 会对整张表生成随机数再排序——O(n log n) 复杂度,百万级数据几乎不可用。如果只需要几条随机记录,改用更高效的方案:
> ```go
> // 方案一:OFFSET random
> var count int
> db.Model(&User{}).Count(&count)
> offset := rand.Intn(count)
> db.Limit(10).Offset(offset).Find(&users)
>
> // 方案二:先在 Go 中随机选几个 ID,再查
> ids := randomIDs(count, 10)
> db.Where("id IN ?", ids).Find(&users)
> ```
## Limit / Offset — 分页基础
### 基本原理
```go
// 每页 10 条,第 2 页(offset = (page-1) * limit)
perPage := 10
page := 2
offset := (page - 1) * perPage
var products []Product
db.Limit(perPage).Offset(offset).Order("id").Find(&products)
// SELECT * FROM products ORDER BY id LIMIT 10 OFFSET 10;
```
> [!example] 偏移量计算速查
> | 页码 | 每页数 | Offset 计算 | 结果 |
> |------|--------|------------|------|
> | 第 1 页 | 10 | (1-1) × 10 = **0** | 前 10 条 |
> | 第 2 页 | 10 | (2-1) × 10 = **10** | 第 11-20 条 |
> | 第 5 页 | 10 | (5-1) × 10 = **40** | 第 41-50 条 |
### 查询总行数
```go
var total int64
db.Model(&Product{}).Where("status = ?", "active").Count(&total)
perPage := 10
page := 2
offset := (page - 1) * perPage
var products []Product
db.Model(&Product{}).
Where("status = ?", "active").
Order("id").
Limit(perPage).
Offset(offset).
Find(&products)
// 总页数
totalPages := int(math.Ceil(float64(total) / float64(perPage)))
```
> [!question] 思考题
> 如果 offset 极大(比如第 10000 页),查询会变得很慢,为什么?有没有更好的方案?
>
> > **答案**:因为数据库仍然需要扫描并跳过前面大量的行,即使它们不会被返回。更好的方案是用 **游标分页(Keyset Pagination)**——按上一次最后一条记录的 id 来查,详见下面的游标分页章节。
## 游标分页(Keyset Pagination)
传统的 `LIMIT/OFFSET` 分页在深页性能急剧下降。游标分页通过「记住上一页最后一条记录的位置」来实现 O(log n) 的跳转:
```go
// 第一页:没有 cursor,直接取前 N 条
cursor := "" // 空表示从头开始
perPage := 20
var users []User
query := db.Model(&User{}).Order("id ASC").Limit(perPage + 1) // 多取 1 条判断是否有下一页
if cursor != "" {
// 查找 id > cursor 的记录
query = query.Where("id > ?", cursor)
}
err := query.Find(&users).Error
hasNextPage := false
if len(users) > perPage {
hasNextPage = true
users = users[:perPage] // 去掉多余的「探测」记录
cursor = fmt.Sprint(users[len(users)-1].ID) // 提取新的 cursor
}
```
> [!tip] 游标分页的优点
> - **速度恒定**:无论翻到哪页,都是查紧邻的下一段数据
> - **不会漏数据**:传统分页在插入新记录时可能漏掉;游标分页每次从明确位置继续
> - **天然防抖**:cursor 是不可预测的值,无法伪造页码
>
> **缺点**:不能跳页(不能说「直接看第 100 页」),适合列表类滚动加载场景。
## 完整分页辅助函数
实际项目中建议封装通用的分页工具:
```go
type PageResult struct {
Data any `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func Paginate[T any](db *gorm.DB, where any, order string, page, pageSize int) (*PageResult, error) {
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
var total int64
q := db.Model(new(T))
if where != nil {
q = q.Where(where)
}
if err := q.Count(&total).Error; err != nil {
return nil, err
}
var items []T
offset := (page - 1) * pageSize
q.Order(order)
if err := q.Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
return nil, err
}
return &PageResult{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// 使用示例
result, err := Paginate[User](db, "status = ?", "active", "created_at DESC", page, 20)
```
## DefaultPageSize 配置
GORM 提供了 `DefaultPageSize` 配置项作为全局兜底:
```go
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
DefaultPageSize: 20, // 不指定 Limit 时的默认上限
})
// 配合 Preload 使用时限制关联数量
db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
return db.Limit(db.Session(&gorm.Config{}).DB.Set("gorm:DefaultPageSize", 10).Session(&gorm.Config{}).Find)
})
```
> [!info] 注意
> `DefaultPageSize` **仅在没有显式 `Limit` 时生效**。如果你写了 `db.Limit(0)`,它等同于无限制(不走默认值)。建议在业务层统一做分页控制,不要依赖这个配置。
## 排序与分页决策图
```mermaid
flowchart TD
Start[需要分页/排序数据] --> SortQ{需要自定义排序_}
SortQ --> |否| DefaultSort["ORDER BY 主键 ASC<br/>GORM 默认排序"]
SortQ --> |是| Whitelist{"字段在白名单中?"}
Whitelist --> |是| CustomSort["db.Order(field + direction)"]
Whitelist --> |否| DefaultSort
DefaultSort --> PagesQ{要跳页还是滚动?}
CustomSort --> PagesQ
PagesQ --> |跳页| OffsetPaginate["LIMIT/OFFSET 分页<br/>简单直白"]
PagesQ --> |滚动加载| CursorPaginate["游标分页<br/>性能好、防漏数据"]
OffsetPaginate --> DeepPage{是否深翻页?}
DeepPage --> |浅页 ≤ 100| OK["直接用 ✓"]
DeepPage --> |深页 > 100| CursorRecommend["推荐改用游标分页"]
style Start fill:#4FC08D,color:#fff
style CursorPaginate fill:#3B82F6,color:#fff
style OK fill:#A0AEC0,color:#fff
style CursorRecommend fill:#EF4444,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 排序字段被 SQL 注入 | 直接拼接用户输入 | 白名单校验列名 |
| 深翻页极慢 | OFFSET 需要跳过大量行 | 改用游标分页 |
| 分页后总数不对 | 并发修改导致 Count 和查询不一致 | 事务内执行或使用快照隔离 |
| `Limit(0)` 没生效 | 0 被视为无限制 | 加最小校验:`if pageSize < 1 { pageSize = 1 }` |
| 多表 JOIN + Limit 结果异常 | Limit 作用于 JOIN 后的整行集合 | 拆分为子查询或用 Preload |
## 关联笔记
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[07-子查询与分组]]
- [[15-性能优化]]
+290
View File
@@ -0,0 +1,290 @@
---
tags: [GORM, Go, ORM, 子查询, Group, Having, SubQuery, 聚合]
create time: 2026-04-28 00:00
---
# 子查询与分组
## 概述
当简单的单表查询无法满足需求时,就需要用到**子查询**(SQL 里嵌套的 SELECT)和**分组聚合**(GROUP BY + HAVING)。这两类查询通常用于统计分析——比如「找出每个部门收入最高的员工」或「统计过去一个月每天的新增订单数」。
```mermaid
flowchart TD
Start[复杂查询需求] --> Type{哪种场景_}
Type --> |按维度汇总| GroupBy["GROUP BY + 聚合函数<br/>COUNT / SUM / AVG / MAX / MIN"]
Type --> |过滤聚合结果| Having["HAVING 条件<br/>替代 WHERE"]
Type --> |嵌套查询| SubQ{"子查询类型?"}
SubQ --> |IN/EXISTS 过滤| FilterSub["WHERE field IN (SELECT ...)"]
SubQ --> |关联计算| Correlated["相关子查询<br/>外层行影响内层查询"]
SubQ --> |派生表| DerivedTable["FROM (SELECT ...) AS t<br/>将子查询作为临时表"]
style Start fill:#4FC08D,color:#fff
style GroupBy fill:#3B82F6,color:#fff
style Having fill:#F59E0B,color:#000
style DerivedTable fill:#EC4899,color:#fff
```
## GROUP BY — 分组统计
### 基本用法
GORM 通过 `Select` + `Group` 实现分组:
```go
// 按部门统计人数
type DeptStats struct {
DeptID uint `gorm:"column:dept_id"`
DeptName string `gorm:"column:dept_name"`
Count int64 `gorm:"column:count"`
}
var stats []DeptStats
db.Model(&User{}).
Select("dept_id, dept_name, COUNT(*) as count").
Group("dept_id").
Find(&stats)
// SELECT dept_id, dept_name, COUNT(*) as count
// FROM users GROUP BY dept_id;
```
### 多字段分组
```go
// 按部门和月份统计活跃用户数
type MonthlyDeptStats struct {
DeptID uint `gorm:"column:dept_id"`
Month string `gorm:"column:month"`
ActiveCount int64 `gorm:"column:active_count"`
}
db.Model(&User{}).
Select("dept_id, DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as active_count").
Group("dept_id, DATE_FORMAT(created_at, '%Y-%m')").
Order("month DESC").
Scan(&monthlyStats)
```
> [!tip] Group 的陷阱
> GORM 在调用 `Group` 后**默认不再添加任何其他字段**到 SELECT——这是 SQL 标准要求。如果需要额外字段,必须在 `Select` 中显式声明:
> ```go
> // ❌ 错误 —— GORM 可能报错或产生不确定的 SELECT
> db.Model(&User{}).Group("dept_id").Find(&users)
>
> // ✅ 正确 —— 明确指定需要的字段
> db.Model(&User{}).Select("dept_id, MAX(age)").Group("dept_id").Scan(&stats)
> ```
## HAVING — 过滤分组结果
`WHERE` 过滤的是行级别,`HAVING` 过滤的是**分组后的聚合结果**。两者的执行顺序是 `WHERE → GROUP BY → HAVING`:
```go
// 找平均薪资大于 10000 的部门
type AvgDept struct {
DeptID uint `gorm:"column:dept_id"`
AvgSal float64 `gorm:"column:avg_salary"`
}
db.Model(&User{}).
Select("dept_id, AVG(salary) as avg_salary").
Group("dept_id").
Having("AVG(salary) > ?", 10000).
Scan(&result)
// 组合使用 WHERE 和 HAVING
// 先过滤入职满 1 年的员工,再按部门分组,最后筛选平均薪资超过 10000 的部门
db.Model(&User{}).
Select("dept_id, COUNT(*) as headcount, AVG(salary) as avg_salary").
Where("created_at < ?", time.Now().AddDate(-1, 0, 0)).
Group("dept_id").
Having("COUNT(*) >= 3"). // 至少 3 人
Having("AVG(salary) > ?", 10000). // 且平均薪资超过 10000
Scan(&qualifiedDepts)
```
> [!example] WHERE vs HAVING 的核心区别
| 特性 | WHERE | HAVING |
|------|-------|--------|
| 作用对象 | 单个行记录 | 分组后的聚合结果 |
| 能否用聚合函数 | ❌ 不能 | ✅ 可以 |
| 执行时机 | GROUP BY 之前 | GROUP BY 之后 |
| 性能差异 | 更优(提前过滤减少分组数据量) | 较次(需先完成分组再过滤) |
> [!important] HAVING 的简洁写法
> 如果你只需要最简单的 HAVING 条件(如 `HAVING COUNT(*) > 5`),也可以直接写字符串:
> ```go
> db.Model(&User{}).Group("dept_id").Having("COUNT(*) > ?", 5).Find(&results)
> ```
> 多个 `Having` 会自动用 AND 连接。
## 子查询
GORM 提供了几种方式来构建子查询。
### 派生表子查询(From Subquery)
将子查询作为一个临时表放入 FROM 子句:
```go
// 找出年龄大于全体员工平均年龄的用户
avgAgeSubQuery := db.Table("users").Select("AVG(age)")
var users []User
db.Where("age > (?)", avgAgeSubQuery).Find(&users)
// SELECT * FROM users WHERE age > (SELECT AVG(age) FROM users);
```
### IN 子查询
```go
// 找到有订单的用户
orderSubQuery := db.Model(&Order{}).Select("DISTINCT user_id")
var users []User
db.Where("id IN (?)", orderSubQuery).Find(&users)
// SELECT * FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);
```
> [!tip] 为什么用 DISTINCT?
> 一个用户可能有多个订单,如果不加 DISTINCT,子查询会返回重复的 user_id。虽然 `IN (...)` 能自动去重,但加上 DISTINCT 可以让数据库优化器更高效地处理。
### EXISTS 子查询
```go
// 找到至少有 1 个未支付订单的用户
db.Where("EXISTS (?)",
db.Model(&Order{}).Select("id").
Where("user_id = users.id AND status = 'pending'"),
).Find(&users)
// SELECT * FROM users
// WHERE EXISTS (SELECT id FROM orders WHERE user_id = users.id AND status = 'pending');
```
> [!note] EXISTS vs IN 的选择
> - **外表大、内表小** → 用 IN,子查询先查好再用
> - **外表小、内表大** → 用 EXISTS,找到一条就停止扫描
> - 在实际生产中,MySQL 优化器通常能自动选择最优方案,不用太纠结
### 相关子查询(Correlated Subquery)
内层查询引用外层表的列,每一行都重新执行一次内层查询:
```go
// 对每个用户,查出他的最新订单
var users []User
db.Select(`*,
(SELECT created_at FROM orders
WHERE user_id = users.id
ORDER BY created_at DESC LIMIT 1) as latest_order_date`).
Find(&users)
```
> [!warning] 相关子查询的性能警告
> 相关子查询每处理一行外层数据就要执行一次内层查询——复杂度接近 O(n × m)。如果数据量大,建议改写为 JOIN + GROUP BY:
> ```go
> // 改写为高效 JOIN:
> db.Table("users u").
> Select("u.*, o.created_at as latest_order_date").
> Joins("JOIN orders o ON o.id = (SELECT id FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 1)").
> Find(&users)
> ```
## 实用统计模式
### 按月趋势统计
```go
type DailyOrderStat struct {
Day string `gorm:"column:day"`
OrderNum int64 `gorm:"column:order_num"`
TotalAmt float64 `gorm:"column:total_amt"`
}
db.Model(&Order{}).
Select("DATE(created_at) as day, COUNT(*) as order_num, COALESCE(SUM(amount), 0) as total_amt").
Group("DATE(created_at)").
Order("day ASC").
Scan(&dailyStats)
```
### TOP N 问题
```go
// 找出消费金额最高的前 10 名用户
var topUsers []struct {
UserID uint
UserName string
TotalSpent float64
}
db.Table("users u").
Select("u.id as user_id, u.name as user_name, SUM(o.amount) as total_spent").
Joins("JOIN orders o ON o.user_id = u.id").
Group("u.id, u.name").
Order("total_spent DESC").
Limit(10).
Scan(&topUsers)
```
### CASE WHEN 条件聚合
```go
// 按状态统计订单数量
type StatusCount struct {
Pending int64 `gorm:"column:pending"`
Completed int64 `gorm:"column:completed"`
Cancelled int64 `gorm:"column:cancelled"`
}
var sc StatusCount
db.Raw(`
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled
FROM orders
`).Scan(&sc)
```
## 子查询决策图
```mermaid
flowchart TD
Start[需要子查询] --> Pattern{查询模式_}
Pattern --> |聚合统计| GroupByQ{是否要过滤聚合结果?}
GroupByQ --> |不需要| BasicGroup["GROUP BY + Select 聚合函数"]
GroupByQ --> |需要| HavingCheck["GROUP BY + HAVING"]
Pattern --> |条件过滤| FilterType{"IN 还是 EXISTS?"}
FilterType --> |精确匹配值集合| InSub["WHERE col IN (SELECT ...)"]
FilterType --> |存在性判断| ExistsSub["WHERE EXISTS (SELECT ...)"]
Pattern --> |数据源嵌套| FromSub["FROM (SELECT ...) AS alias"]
style Start fill:#4FC08D,color:#fff
style HavingCheck fill:#3B82F6,color:#fff
style InSub fill:#F59E0B,color:#000
style FromSub fill:#EC4899,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| Group 后字段丢失 | 未在 Select 中声明所需字段 | 显式列出所有 SELECT 字段 |
| Having 写了聚合函数但被 WHERE 过滤 | WHERE 在 GROUP BY 之前执行,无法访问聚合 | 把聚合条件移到 Having |
| 子查询导致笛卡尔积 | JOIN 没有正确的关联条件 | 检查外键关联,或用 EXISTS 替代 |
| ORDER BY + GROUP BY 顺序搞反 | SQL 语法要求 GROUP BY 在前 | 按 `WHERE → GROUP BY → HAVING → ORDER BY` 顺序写 |
| MySQL 5.7 ONLY_FULL_GROUP_BY | 非聚合字段不能在 SELECT 中出现 | 开启 SQL 严格模式或使用 MySQL 8.0+ |
## 关联笔记
- [[03-CRUD 操作]]
- [[04-条件查询]]
- [[05-关联查询]]
- [[06-排序与分页]]
- [[15-性能优化]]
+312
View File
@@ -0,0 +1,312 @@
---
tags: [GORM, Go, ORM, 事务, Tx, Commit, Rollback, Nested Transaction]
create time: 2026-04-28 00:00
---
# 事务管理
## 概述
事务是数据库操作的「安全网」——它保证一组操作要么全部成功,要么全部失败。在现实业务中,你几乎处处离不开事务:转账时「扣 A 加 B」、下单时「减库存 + 创建订单 + 生成流水」,这些都需要事务来保证数据一致性。
```mermaid
flowchart TD
Start[开始事务 tx = db.Begin] --> Op1["执行操作 1"]
Op1 --> OK1{"操作 1 成功?"}
OK1 --> |是| Op2["执行操作 2"]
OK1 --> |否| Rollback["tx.Rollback()"]
OK2{"操作 2 成功?"} --> |是| Op3["执行操作 3"]
Op2 --> OK2
OK2 --> |否| Rollback
Op3 --> OK3{"操作 3 成功?"}
OK3 --> |是| Commit["tx.Commit()"]
OK3 --> |否| Rollback
style Start fill:#4FC08D,color:#fff
style Commit fill:#3B82F6,color:#fff
style Rollback fill:#EF4444,color:#fff
```
> [!definition] 事务四大特性(ACID)
| 特性 | 含义 | 示例 |
|------|------|------|
| **原子性(Atomicity)** | 全部提交或全部回滚 | 转账:A 扣钱和 B 加钱不可分割 |
| **一致性(Consistency)** | 事务前后数据满足业务规则 | 转账前后总金额不变 |
| **隔离性(Isolation)** | 并发事务互不干扰 | 两个用户同时下单不会超卖 |
| **持久性(Durability)** | 提交后永不过期 | 断电后数据仍在 |
## 基本用法
### Begin / Commit / Rollback
```go
// 1. 开启事务
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback() // panic 时确保回滚
}
}()
// 2. 所有操作通过 tx 执行
user := User{Name: "Alice", Age: 30}
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
return err
}
order := Order{UserID: user.ID, Amount: 99.9}
if err := tx.Create(&order).Error; err != nil {
tx.Rollback()
return err
}
// 3. 全部成功,提交
if err := tx.Commit().Error; err != nil {
return err
}
return nil
```
> [!warning] defer Rollback 的注意事项
> `Commit()` 成功后再执行 `defer Rollback()` 会报错(事务已提交)。所以推荐**显式处理错误路径的回滚**,`defer` 只用于兜底 panic。
### 简洁写法(Err 链式判断)
```go
tx := db.Begin()
if err := tx.Error; err != nil {
return err
}
// ... 操作 ...
if err := tx.Commit().Error; err != nil {
tx.Rollback()
return err
}
```
> [!tip] 理解 tx.Transaction 返回值
> GORM 的 `Begin()` 返回 `*gorm.DB`,但内部包装了事务。这个实例只在事务范围内有效,不能跨事务使用。
## 事务中的错误处理
```go
func TransferMoney(fromID, toID uint, amount float64) error {
// 开启事务
tx := db.Begin()
// 查入账和出账账户(锁行!)
var fromAccount Account
if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&fromAccount, fromID).Error; err != nil {
tx.Rollback()
return fmt.Errorf("查询转出账户失败: %w", err)
}
var toAccount Account
if err := tx.First(&toAccount, toID).Error; err != nil {
tx.Rollback()
return fmt.Errorf("查询入账账户失败: %w", err)
}
// 余额不足
if fromAccount.Balance < amount {
tx.Rollback()
return errors.New("余额不足")
}
// 扣款
fromAccount.Balance -= amount
if err := tx.Save(&fromAccount).Error; err != nil {
tx.Rollback()
return fmt.Errorf("扣款失败: %w", err)
}
// 入账
toAccount.Balance += amount
if err := tx.Save(&toAccount).Error; err != nil {
tx.Rollback()
return fmt.Errorf("入账失败: %w", err)
}
return tx.Commit().Error
}
```
> [!important] FOR UPDATE 锁行
> 事务中的查询如果要用更新的数据做决策,必须用 `FOR UPDATE`(排他锁),否则可能读到未提交的旧数据:
> ```go
> // ❌ 竞态条件:两个并发请求可能都读到余额足够并扣款
> db.Where("id = ?", id).First(&account)
> account.Balance -= 100
> db.Save(&account)
>
> // ✅ 加锁读取,另一个事务只能等待
> db.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", id).First(&account)
> ```
## 嵌套事务
当代码结构有分层时,内部函数可能需要发起自己的事务。GORM 支持嵌套事务机制——内部实际上是保存点(Savepoint),而非真正的事务:
```go
func CreateOrderWithAudit(tx *gorm.DB, order Order) error {
// 检查外层是否已有事务
if !tx.IsTransaction() {
// 外层不是事务,自己开一个
tx = db.Begin()
defer func() {
if v := recover(); v != nil {
tx.Rollback()
panic(v)
}
}()
}
// 保存点:如果内部失败可以回滚到这里,不影响外部
tx.SavePoint("create_order")
// 主逻辑
if err := tx.Create(&order).Error; err != nil {
tx.RollbackTo("create_order") // 回滚到保存点
return err
}
// 审计日志记录
audit := Audit{Table: "orders", Action: "create", RecordID: order.ID}
if err := tx.Create(&audit).Error; err != nil {
tx.RollbackTo("create_order") // 审计失败也可以回滚
return err
}
// 成功 —— 如果不外层事务则提交,否则由外层统一提交
if !tx.IsTransaction() {
return tx.Commit().Error
}
return nil
}
```
> [!tip] 嵌套事务的本质
> MySQL/PostgreSQL 不支持真正的嵌套事务,它们用的是 **Savepoint**。这意味着:
> - 内部回滚只会回滚到最近的 savepoint,不会影响外层事务
> - 最终 `COMMIT` 仍然是一次性的,从外层事务发出
> - 如果外层事务 `ROLLBACK`,整个事务树(包括所有 savepoint)都会被回滚
### 事务级别控制
```go
import "database/sql"
tx := db.Session(&gorm.Session{
DryRun: false, // 模拟模式:构建 SQL 但不执行
}).Begin(&sql.TxOptions{
Isolation: sql.LevelRepeatableRead, // 设置隔离级别
ReadOnly: false, // 读写事务
})
// 或者针对特定查询
db.Set("gorm:prepare_stmt", true).Find(&users) // 预编译语句
```
> [!example] 隔离级别速查
| 级别 | 脏读 | 不可重复读 | 幻读 | 性能影响 |
|------|------|-----------|------|---------|
| Read Uncommitted | ❌ 允许 | ❌ 允许 | ❌ 允许 | 最快 |
| Read Committed | ✅ 阻止 | ❌ 允许 | ❌ 允许 | 快 |
| Repeatable Read(MySQL 默认) | ✅ 阻止 | ✅ 阻止 | ⚠️ 部分阻止 | 中等 |
| Serializable | ✅ 阻止 | ✅ 阻止 | ✅ 阻止 | 最慢 |
## 事务与中间件结合
在实际项目中,经常需要将事务与 Gin 等 Web 框架集成,实现自动事务管理:
```go
// Gin 中间件:每个 HTTP 请求自带一个事务
func TransactionMiddleware(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// 开始事务
tx := db.Begin()
c.Set("db", tx) // 存入 Context
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // 重新抛出让上层捕获
}
// 请求处理完毕且无错误,提交事务
if c.Writer.Status() >= 200 && c.Writer.Status() < 400 {
tx.Commit()
} else {
tx.Rollback()
}
}()
c.Next()
}
}
// 在 handler 中使用
func CreateUser(c *gin.Context) {
// 从 Context 获取当前事务
txVal, _ := c.Get("db")
tx := txVal.(*gorm.DB)
user := User{Name: c.PostForm("name")}
if err := tx.Create(&user).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(201, user)
}
```
## 事务决策流程图
```mermaid
flowchart TD
Start[需要多步数据操作] --> InTx{"是否在已有事务中?"}
InTx --> |是| SaveQ{需要局部回滚能力_}
InTx --> |否| NewTx{"操作数量?"}
NewTx --> |单条 SQL| SimpleTx["不需要事务<br/>直接操作即可"]
NewTx --> |多条 SQL| BeginTx["db.Begin()"]
SaveQ --> |需要| Savepoint["SavePoint('name')"]
SaveQ --> |不需要| Continue["继续执行"]
BeginTx --> Ops["依次执行各操作"]
Continue --> Ops
Savepoint --> Ops
Ops --> AllOK{"全部成功?"}
AllOK --> |是| Commit["Commit"]
AllOK --> |否| RollBack["Rollback"]
style Start fill:#4FC08D,color:#fff
style Commit fill:#3B82F6,color:#fff
style RollBack fill:#EF4444,color:#fff
style SimpleTx fill:#A0AEC0,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 忘记 Commit/Rollback | 漏了错误分支的回滚逻辑 | 对所有错误分支显式调用 Rollback |
| 事务内查询被其他事务修改 | 没加 FOR UPDATE | 关键查询加 `Set("gorm:query_option", "FOR UPDATE")` |
| 嵌套事务外层的 Commit 失效 | 内部使用了独立连接 | 始终复用同一个 `*gorm.DB` 对象 |
| 长事务导致行锁堆积 | 事务中包含耗时操作(RPC、HTTP) | 将耗时操作移出事务范围 |
| defer Rollback 导致已提交事务报错 | Commit 后再执行 deferred Rollback | 用 panic 兜底,正常路径不依赖 defer |
## 关联笔记
- [[01-安装与初始化]]
- [[03-CRUD 操作]]
- [[14-错误处理]]
- [[15-性能优化]]
+225
View File
@@ -0,0 +1,225 @@
---
tags: [GORM, Go, ORM, 钩子函数, Callback, BeforeCreate, AfterUpdate, Lifecycle]
create time: 2026-04-28 00:00
---
# 钩子函数
## 概述
钩子(Hook)是 GORM 生命周期回调机制,允许你在特定操作前后注入自定义逻辑。它们是「横切关注点」的理想载体——不需要在每个业务代码里重复写加密、日志、审计等通用逻辑。
```mermaid
flowchart TD
A[Create 调用] --> BC["BeforeCreate"]
BC --> CreateSQL["执行 INSERT"]
CreateSQL --> AC["AfterCreate"]
D[Update 调用] --> BU["BeforeUpdate"]
BU --> UpdateSQL["执行 UPDATE"]
UpdateSQL --> AU["AfterUpdate"]
E[Delete 调用] --> BD["BeforeDelete"]
BD --> DeleteSQL["执行 DELETE"]
DeleteSQL --> AD["AfterDelete"]
F[Find/First 调用] --> AF["AfterFind"]
style BC fill:#EAB308,color:#fff
style BU fill:#F59E0B,color:#000
style BD fill:#EF4444,color:#fff
style AF fill:#3B82F6,color:#fff
```
> [!note] GORM 的操作顺序
> 每个操作的执行流:`钩子(前置) → SQL 执行 → 钩子(后置)`。前置钩子返回错误可以**中断整个操作**。
## 完整钩子列表
### 创建阶段
```go
func (u *User) BeforeCreate(tx *gorm.DB) error {
// 在 INSERT 之前执行
u.CreatedAt = time.Now()
u.UpdatedAt = time.Now()
// 对密码做哈希处理
hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
u.Password = string(hashed)
// 生成唯一 ID
if u.UUID == "" {
u.UUID = generateUUID()
}
return nil // 返回错误可中断插入
}
func (u *User) AfterCreate(tx *gorm.DB) error {
// 在 INSERT 之后执行
// 例如:发送欢迎邮件、记录审计日志
log.Printf("用户 %s 创建成功,ID=%d", u.Name, u.ID)
return nil
}
```
### 更新阶段
```go
func (u *User) BeforeUpdate(tx *gorm.DB) error {
// 在 UPDATE 之前执行
u.UpdatedAt = time.Now()
// 检查乐观锁版本号
var prev User
tx.Select("version").First(&prev, u.ID)
if prev.Version != u.Version {
return errors.New("数据已被其他人修改,请刷新后重试")
}
return nil
}
func (u *User) AfterUpdate(tx *gorm.DB) error {
// 同步缓存
redis.Set(context.Background(), "user:"+strconv.Itoa(u.ID), u, time.Hour)
return nil
}
```
### 删除阶段
```go
func (u *User) BeforeDelete(tx *gorm.DB) error {
// 清理关联数据(比如清除 Redis Session)
sessionIDs, _ := getSessionIDsByUserID(u.ID)
for _, id := range sessionIDs {
redis.Del(context.Background(), "session:"+id)
}
return nil
}
func (u *User) AfterDelete(tx *gorm.DB) error {
// 如果用了软删除,这里可以做真正的物理清理(如文件、附件)
if tx.RowsAffected > 0 {
deleteUploadedFiles(u)
}
return nil
}
```
### 查询阶段
```go
func (u *User) AfterFind(tx *gorm.DB) error {
// 在 SELECT 之后执行——每次 Find/First 都会触发
// 适合用于敏感字段脱敏或格式化处理
// 示例:邮箱脱敏
if len(u.Email) > 4 {
masked := u.Email[:2] + "***" + u.Email[len(u.Email)-2:]
fmt.Println("已脱敏:", masked)
}
return nil
}
```
> [!question] AfterFind 的性能影响
> `AfterFind` 会在每次查询时对所有行都执行——如果一次查出 1000 条记录,钩子会运行 1000 次。不要在 `AfterFind` 中放重逻辑(如 RPC 调用)。
## 条件判断:根据操作类型选择行为
有时你希望同一个钩子在特定操作下才生效。可以通过 `tx.Statement` 判断当前操作类型:
```go
func (u *User) BeforeUpdate(tx *gorm.DB) error {
// 只在 Password 被修改时才重新哈希
if tx.Statement.Changed("Password") {
hashed, _ := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
u.Password = string(hashed)
}
return nil
}
```
> [!tip] Statement 常用方法
| 方法 | 作用 |
|------|------|
| `Changed(field)` | 该字段是否在 Updates 中被修改 |
| `Select()` | 获取显式 SELECT 的字段列表 |
| `Omit()` | 获取 OMIT 的字段列表 |
| `Deleted()` | 是否是 Delete 操作 |
| `Updated(field)` | 字段是否被更新且值发生变化 |
## 全局钩子 vs Model 级钩子
GORM 支持两种注册方式,优先级为 **Model 级 > 全局**:
```go
// Model 级钩子——写在 struct 的方法上(最常用)
type Order struct{}
func (Order) BeforeCreate(tx *gorm.DB) error { ... }
// 全局钩子——通过 Callback 注册,作用于所有模型
db.Callback().Create().Before("gorm:create").Register("set_created_at", func(tx *gorm.DB) {
if v, ok := tx.Get("created_at_overwrite"); ok {
if t, ok := v.(time.Time); ok {
tx.Statement.SetColumn("CreatedAt", t)
}
}
})
// 使用场景:给所有模型统一设置默认时间(测试用)
db.Session(&gorm.Session{Context: ctx}).Create(&order)
```
> [!warning] 全局钩子注意事项
> 全局钩子的注册时机必须在 `db.Open()` 之后、首次操作之前。而且全局钩子会影响**所有模型**——包括内置的 `gorm.Model`——使用时需格外小心。
## 钩子执行链示意图
```mermaid
sequenceDiagram
participant App as 应用代码
participant Hook as GORM 钩子系统
participant DB as 数据库
App->>Hook: db.Create(&user)
Hook->>Hook: BeforeCreate
Hook->>DB: INSERT INTO users...
DB-->>Hook: 返回影响行数
Hook->>Hook: AfterCreate
Hook-->>App: 返回结果
Note over Hook: Update: BeforeUpdate → SQL → AfterUpdate
Note over Hook: Delete: BeforeDelete → SQL → AfterDelete
Note over Hook: Find: BeforeQuery → SQL → AfterFind
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 钩子里调用了 db(而不是 tx) | 死锁——钩子内再开事务嵌套 | 钩子内部全部使用 `tx` 参数 |
| Changed() 在 Save 时总返回 false | Save 是全量写入,不追踪变化 | 用 `Updates(struct)` 配合 Changed() |
| 钩子返回了 nil 但想中断操作 | 零值 nil 不是错误 | `return errors.New("中断原因")` |
| 批量操作也会触发钩子 | `Create(&[]User{})` 每条都走钩子 | 如需跳过可用 `SkipHooks` session |
| AfterFind 脱敏污染了原始数据 | 直接修改结构体字段影响调用方 | 需要脱敏时在接口层处理,不要改原对象 |
## 钩子应用场景总结
| 场景 | 推荐钩子 | 说明 |
|------|---------|------|
| 自动填充 CreatedAt / UpdatedAt | `autoTime` tag 更简单 | 如果手动实现用 BeforeCreate / BeforeUpdate |
| 密码哈希 | BeforeCreate + BeforeUpdate(仅当 Changed) | 避免重复哈希 |
| 乐观锁 | BeforeUpdate(检查 version) | 并发安全的经典方案 |
| 审计日志 | AfterCreate / AfterUpdate / AfterDelete | 操作完成后异步记录 |
| 数据脱敏 | AfterFind | 对外输出前格式化 |
| 关联清理 | BeforeDelete | 删除前解除外部引用 |
## 关联笔记
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[11-批量操作]]
+270
View File
@@ -0,0 +1,270 @@
---
tags: [GORM, Go, ORM, 软删除, SoftDelete, Unscoped, DeletedAt]
create time: 2026-04-28 00:00
---
# 软删除
## 概述
软删除是一种「逻辑删除」策略——数据并没有真正消失,只是被标记为「已删除」。这是 Web 应用中处理「删除」操作的**默认推荐方式**,因为它提供了可恢复性和审计追踪能力。
```mermaid
flowchart TD
A[调用 db.Delete(&user)] --> SoftDel{"模型有<br/>DeletedAt 字段?"}
SoftDel --> |否| HardSQL["DELETE FROM users WHERE id = ?<br/>⚠️ 物理删除,不可恢复"]
SoftDel --> |是| UpdateSQL["UPDATE users SET deleted_at = NOW() WHERE id = ?<br/>✅ 软删除,数据保留"]
UpdateSQL --> Query{常规查询?}
Query --> |是| Filtered["WHERE deleted_at IS NULL<br/>已删除记录自动隐藏"]
Query --> |否| UnscopedQ{需要已删除数据?}
UnscopedQ --> |是| IncludeAll["Unscoped() → 全部返回"]
UnscopedQ --> |否| Filtered
style Start fill:#4FC08D,color:#fff
style Filtered fill:#3B82F6,color:#fff
style HardSQL fill:#EF4444,color:#fff
```
## DeletedAt 机制
### 声明式启用
在 struct 中嵌入 `gorm.Model` 或显式声明 `DeletedAt gorm.DeletedAt`:
```go
type User struct {
gorm.Model // 自动包含 ID, CreatedAt, UpdatedAt, DeletedAt
Name string `gorm:"size:64;not null"`
}
// 等价的手动声明
type Product struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:128;not null"`
Price float64 `gorm:"not null"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt // 软删除字段
}
```
> [!note] gorm.DeletedAt 的本质
> `gorm.DeletedAt` 是 `time.Time` 的类型别名,但它告诉 GORM 这是一个软删除标记。只有当这个字段存在时,GORM 才会开启软删除行为。
### 软删除 vs 物理删除
```go
var user User
// ===== 软删除(推荐)=====
db.Delete(&user) // UPDATE users SET deleted_at=... WHERE id=?
// 查询时自动过滤:SELECT * FROM users WHERE deleted_at IS NULL AND id=?
// ===== 物理删除 =====
db.Unscoped().Delete(&user) // DELETE FROM users WHERE id=?
// 彻底擦除,无法恢复!
// ===== 根据条件批量物理删除 =====
db.Unscoped().Where("deleted_at < ?", time.Now().AddDate(-90, 0, 0)).Delete(&User{})
// 清理超过 90 天未激活的软删除记录
```
## 查询已删除数据
### Unscoped — 忽略软删除过滤
`Unscoped()` 会关闭 GORM 自动附加的 `deleted_at IS NULL` 条件,让所有查询都能看到已删除的数据:
```go
// 查询所有用户(包括已删除的)
var allUsers []User
db.Unscoped().Find(&allUsers)
// SELECT * FROM users; -- 不再附加 deleted_at 条件
// 查找特定已删除的用户
var deletedUser User
db.Unscoped().Where("id = ? AND deleted_at IS NOT NULL", 42).First(&deletedUser)
// 统计已删除的记录数
var count int64
db.Model(&User{}).Unscoped().Where("deleted_at IS NOT NULL").Count(&count)
```
### 精确查询含/不含已删除数据
```go
// 只查正常用户
db.Where("deleted_at IS NULL").Find(&users)
// 只查已删除用户
db.Where("deleted_at IS NOT NULL").Find(&deletedUsers)
```
> [!tip] 为什么不用 Where + Unscoped?
> `Unscoped()` 是一个全局开关,一旦开启就影响当前链的所有后续操作:
> ```go
> // ❌ 意外行为 —— Unscoped 后面的 Find 也不过滤
> db.Where("name = 'john'").Unscoped().Find(&users)
> // SELECT * FROM users WHERE name = 'john'; -- 包括已删除的 john!
>
> // ✅ 精确控制
> db.Where("name = ? AND deleted_at IS NULL", "john").Find(&users)
> ```
## 软删除中的关联处理
### HasMany / BelongsTo
软删除模型的关联查询默认也会过滤已删除的关联项:
```go
type User struct {
gorm.Model
Name string
Orders []Order `gorm:"foreignKey:UserID"`
}
type Order struct {
gorm.Model
UserID uint
}
var user User
db.Preload("Orders").First(&user, 1)
// Preload 生成的 SQL 会自动加入 deleted_at IS NULL:
// SELECT * FROM orders WHERE user_id = 1 AND deleted_at IS NULL;
```
### ManyToMany
对于多对多关联,GORM 在操作中间表时也会考虑软删除状态:
```go
// 给订单添加商品(如果商品已被软删除,默认不会关联)
product := Product{Name: "Go Programming"}
db.Model(&order).Association("Products").Append(&product)
// 如果要强制关联已删除的商品
db.Model(&order).Clauses(clause.OnConflict{DoNothing: true}).
Association("Products").Append(&product)
```
> [!question] 思考题
> 如果一个父记录被软删除,它的子记录(HasMany)还能被查询到吗?
>
> > **答案**:取决于查询起点。以未删除的父记录查询,子记录的软删除仍然生效;但如果你先 Unscoped 查到了已删除的父记录,再 Preload 其关联,子记录的软删除状态由 Preload 决定。
## 唯一约束与软删除冲突
这是软删除最常见的坑点——唯一约束检查在 UPDATE 之前执行,而旧记录仍然占着唯一值:
```go
type Article struct {
gorm.Model
Slug string `gorm:"uniqueIndex;not null"` // 例如:my-first-post
}
// 第一次保存成功
db.Create(&Article{Slug: "my-first-post"})
// 更新 slug
db.Model(&Article{}).Where("id = ?", 1).Update("slug", "my-updated-post")
// UPDATE articles SET slug='my-updated-post', deleted_at=NULL WHERE id=1;
// ⚠️ MySQL 不会对已删除行检查唯一约束(InnoDB bug)
// ⚠️ PostgreSQL 和 SQLite 会报错:duplicate key value
```
### 解决方案
```go
// 方案一:使用部分唯一索引(PostgreSQL 支持)
// CREATE UNIQUE INDEX idx_articles_slug ON articles(slug) WHERE deleted_at IS NULL;
// GORM 暂不直接支持部分索引,需手动执行 SQL
// 方案二:额外加一个版本字段
type Article struct {
gorm.Model
Slug string `gorm:"not null"`
SlugHash string `gorm:"uniqueIndex;not null"` // slug + 随机哈希
}
// 方案三:用复合唯一索引覆盖软删除标记
// 在数据库层面创建 (slug, is_deleted) 联合唯一约束
```
> [!warning] 跨数据库行为不一致
> MySQL 的 InnoDB 引擎在处理软删除行的唯一约束时存在历史缺陷,不同版本行为可能不同。**不要依赖这种行为一致性**,应在应用层做好校验。
## 恢复已删除数据
```go
// 恢复单个记录
db.Model(&User{}).Where("id = ?", 42).Update("deleted_at", nil)
// UPDATE users SET deleted_at=NULL WHERE id=42;
// 批量恢复
result := db.Model(&User{}).
Where("deleted_at IS NOT NULL AND email LIKE '%@test.com'").
Update("deleted_at", nil)
fmt.Printf("恢复了 %d 条记录\n", result.RowsAffected)
```
> [!important] 恢复后记得重新设置 UpdatedAt
> 恢复操作本身也是一次 UPDATE,所以 `UpdatedAt` 会自动更新。如果你希望保留原始时间戳,需要在更新前保存到临时变量。
## 定时清理策略
软删除会导致数据无限膨胀,建议建立定期清理机制:
```go
// cron 定时任务:每月清理 90 天前的软删除记录
func CleanSoftDeletedRecords(tx *gorm.DB) error {
cutoff := time.Now().AddDate(0, 0, -90)
models := []any{&User{}, &Order{}, &Product{}}
for _, model := range models {
tx.Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
Delete(model)
}
return nil
}
```
## 软删除决策图
```mermaid
flowchart TD
Start[执行删除操作] --> TypeQ{业务需求_}
TypeQ --> |可恢复/需审计| SoftDel["软删除<br/>db.Delete()"]
TypeQ --> |合规要求/不需要恢复| HardDel["物理删除<br/>db.Unscoped().Delete()"]
SoftDel --> AfterSoft[查询时需要已删除数据?]
AfterSoft --> |是| UnscopedOn["db.Unscoped()"]
AfterSoft --> |否| NormalQ["普通查询<br/>自动过滤"]
HardDel --> AfterHard["数据永久移除"]
style Start fill:#4FC08D,color:#fff
style SoftDel fill:#3B82F6,color:#fff
style HardDel fill:#EF4444,color:#fff
style UnscopedOn fill:#F59E0B,color:#000
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 唯一约束冲突 | 旧软删除行仍占唯一值 | 加版本字段或用部分索引 |
| Unscoped 影响后续查询 | 开关是链式的 | 及时结束链或改用显式 Where |
| 预加载跳过已删除关联 | GORM 自动加 deleted_at 过滤 | 用 Unscoped + Preload 组合 |
| 软删除导致数据膨胀 | 没有定期清理策略 | 定时任务清理过期记录 |
| First/Take 找不到已删除记录 | 自动过滤了 deleted_at | Unscoped().First() |
## 关联笔记
- [[02-模型定义]]
- [[03-CRUD 操作]]
- [[05-关联查询]]
- [[08-事务管理]]
+293
View File
@@ -0,0 +1,293 @@
---
tags: [GORM, Go, ORM, 批量操作, Bulk Insert, Batch Update, Callback]
create time: 2026-04-28 00:00
---
# 批量操作
## 概述
当数据量达到千级以上时,逐条操作的性能问题变得不可忽视。批量操作用于在一次数据库往返中处理大量记录——无论是插入、更新还是删除,都能显著降低网络开销和事务粒度。
```mermaid
flowchart LR
Slow["N 条独立 SQL<br/>N 次网络往返"] -->|优化后| Fast["1 条批量 SQL<br/>1 次网络往返"]
style Slow fill:#EF4444,color:#fff
style Fast fill:#3B82F6,color:#fff
```
## Create — 批量插入
### 基本用法
```go
// 直接传入 slice —— GORM 自动生成多 VALUES INSERT
users := []User{
{Name: "Alice", Age: 25},
{Name: "Bob", Age: 30},
{Name: "Charlie", Age: 28},
}
result := db.Create(&users)
fmt.Println(result.RowsAffected) // 3
// 自动回填 ID
for _, u := range users {
fmt.Printf("ID=%d Name=%s\n", u.ID, u.Name)
}
```
> [!tip] 底层机制:拆批策略
> GORM 内部会将大 slice 拆成多个 `INSERT INTO ... VALUES (...), (...), ...` 语句执行,每批默认约 **256 条**。这样做是为了避免单条 SQL 过大导致内存溢出或数据库拒绝执行。你可以通过配置调整这个行为:
> ```go
> db.Session(&gorm.Session{FullSaveRecords: true}).Create(&hugeSlice)
> ```
### 高速批量插入(Exec)
对于超大批量(数万行),GORM 提供了更高效的执行方式——跳过模型解析,直接执行原始 SQL:
```go
// 方案一:使用 raw sql 手动拼接(推荐用于极大数据量)
values := make([]string, len(users))
args := make([]any, 0, len(users)*3)
for i, u := range users {
values[i] = fmt.Sprintf("(%d, %d, %s)",
u.CreatedAt.Unix(), u.Age, tx.Migrator().ColumnName(u, "name"))
args = append(args, u.Name)
}
db.Exec(fmt.Sprintf("INSERT INTO users (created_at, age, name) VALUES %s",
strings.Join(values, ", "), args...))
// 方案二:用 CreateInBatches(内置分批,更简单)
err := db.CreateInBatches(&users, 500).Error // 每批 500 条
```
> [!important] CreateInBatches vs Create
>
> | 特性 | Create | CreateInBatches |
> |------|--------|-----------------|
> | 参数 | 总数量 | 每批大小(batch size) |
> | 钩子触发 | 每条都触发 | 每条都触发 |
> | 适用场景 | 中小批量(≤10K) | 超大批量(>10K) |
> | 可控性 | GORM 内部决定批次 | 可自定义 batch size |
```go
// 演示差异
db.CreateInBatches(&users, 100) // 每批 100 条
// users 有 350 条 → 分成 4 批:100, 100, 100, 50
db.CreateInBatches(&users, 200) // 每批 200 条
// users 有 350 条 → 分成 2 批:200, 150
```
> [!question] 思考题
> 为什么 CreateInBatches 的第一个参数是 slice,第二个是 batch size?为什么不叫 ` batchSize` 而用 `total`?
>
> > **答案**:因为设计时参考的是「总共要处理的总数」语义。但在实际使用中,把它理解为「每批大小」更加直观。注意它不是总上限,而是批次阈值。
## Update — 批量更新
### map 方式(简洁但功能受限)
```go
// 将所有状态为 trial 的用户升级为 active
result := db.Model(&User{}).
Where("status = ?", "trial").
Updates(map[string]any{
"status": "active",
"updated_at": time.Now(),
})
fmt.Printf("更新了 %d 条记录\n", result.RowsAffected)
// UPDATE users SET status='active', updated_at=... WHERE status='trial';
```
### Select / Omit 控制字段
```go
// 只更新指定字段(不管 map 里写了多少 key,只更新白名单中的字段)
db.Model(&User{}).
Where("status = ?", "trial").
Select("status", "plan"). // 白名单
Updates(map[string]any{
"status": "active",
"plan": "pro",
"email": "should_not_change@test.com", // 被忽略
})
// 排除某些字段
db.Model(&User{}).
Where("status = ?", "trial").
Omit("password", "secret_key"). // 黑名单
Updates(map[string]any{
"status": "active",
"plan": "pro",
"password": "new_password_here", // 被忽略
"updated_at": time.Now(),
})
```
> [!tip] Select + Omit 的区别
> - `Select` 是白名单——只更新列出的字段
> - `Omit` 是黑名单——除了列出的字段,其他都更新
> - 两者可以组合使用,最终生效 = Select(若有)∩ Not(Omit)
### Struct 方式
```go
// 根据条件批量更新——注意只有非零值会被写入
db.Model(&User{}).Where("score < ?", 60).Updates(User{Status: "reminded"})
// UPDATE users SET status='reminded' WHERE score < 60;
// ❌ 陷阱:如果 Status 的值就是零值 "",则不会被写入
db.Model(&User{}).Where("role = ?", "admin").Updates(User{Role: ""})
// 这条不会生成任何 SET 子句!
// ✅ 解决:用 map 替代 struct
db.Model(&User{}).Where("role = ?", "admin").Updates(map[string]any{"role": ""})
```
## Delete — 批量删除
```go
// 批量删除满足条件的记录
result := db.Where("age < ?", 18).Delete(&User{})
fmt.Printf("删除了 %d 条记录\n", result.RowsAffected)
// DELETE FROM users WHERE age < 18;
// 物理批量删除(跳过软删除过滤)
db.Unscoped().Where("deleted_at < ?", time.Now().AddDate(-1, 0, 0)).Delete(&User{})
```
> [!warning] 批量删除的威力
> `Where(...).Delete()` 是极其危险的操作——一条错误的 WHERE 可能瞬间清空百万行。**执行前务必先用 SELECT 确认影响范围**:
> ```go
> // 先检查再删除
> var count int64
> db.Model(&User{}).Where("status = ?", "inactive").Count(&count)
> if count == 0 {
> return nil
> }
> log.Printf("即将删除 %d 条 inactive 记录...", count)
> if !confirm() {
> return errors.New("用户取消操作")
> }
> db.Where("status = ?", "inactive").Delete(&User{})
> ```
## Upsert — 存在则更新,不存在则插入
GORM 通过 `Clauses(clause.OnConflict{})` 实现 MySQL 的 ON DUPLICATE KEY UPDATE 和 PostgreSQL 的 ON CONFLICT:
### MySQL 语法
```go
// 用户注册时使用:已存在则更新最后登录时间,不存在则新建
users := []User{
{Email: "alice@example.com", LastLogin: time.Now()},
{Email: "bob@example.com", LastLogin: time.Now()},
}
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"last_login"}),
}).Create(&users)
// MySQL: INSERT INTO users (email, last_login) VALUES (...)
// ON DUPLICATE KEY UPDATE last_login=VALUES(last_login);
```
### PostgreSQL 语法
```go
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"last_login"}),
}).Create(&users)
// PostgreSQL: INSERT INTO users (email, last_login) VALUES (...)
// ON CONFLICT (email) DO UPDATE SET last_login = excluded.last_login;
```
### 常见冲突策略
| 策略 | 作用 | SQL 等价 |
|------|------|---------|
| `DoNothing` | 冲突时不操作 | `ON CONFLICT DO NOTHING` |
| `DoUpdates` | 冲突时更新指定字段 | `ON CONFLICT DO UPDATE SET ...` |
| `DoUpdates: clause.Assignments(expr)` | 自定义表达式 | `SET col = expr` |
```go
// 冲突时递增计数器而非覆盖
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"count"}),
}).Create(&KV{})
// 如果 key 已存在 → count = count + 1 (由 GORM 自动处理)
```
## Callback 机制——自定义批量行为
GORM 允许你在特定操作阶段注入自己的逻辑,实现真正的批量定制:
```go
// 在批量创建完成后自动发送通知
db.Callback().Create().After("gorm:create").Register("send_notifications", func(tx *gorm.DB) {
// tx.Statement.Dest 包含被创建的数据
if dest, ok := tx.Statement.Dest.([]User); ok {
for _, user := range dest {
sendWelcomeEmail(user.Email)
}
}
})
```
> [!tip] Callback 阶段排序
> GORM 的操作流程可拆解为明确的阶段,每个阶段都有对应的 Hook 点:
> ```
> CREATE: BeforeQuery → BeforeCreate → Create → AfterCreate → AfterQuery
> UPDATE: BeforeQuery → BeforeUpdate → Update → AfterUpdate → AfterQuery
> DELETE: BeforeQuery → BeforeDelete → Delete → AfterDelete → AfterQuery
> FIND: BeforeQuery → Query → AfterFind → AfterQuery
> ```
> 你可以在任意阶段的前后注册回调。
## 批量操作决策图
```mermaid
flowchart TD
Start[需要批量操作数据] --> OpType{操作类型_}
OpType --> |插入| InsertQ{"数据量?"}
InsertQ --> |< 10K| SimpleInsert["Create(slice)"]
InsertQ --> |≥ 10K| BatchInsert["CreateInBatches(batchSize)"]
OpType --> |更新| UpdateQ{"是否需要精准字段控制?"}
UpdateQ --> |不需要| MapUpdate["Updates(map)"]
UpdateQ --> |需要| FieldControl["Select/Omit + Updates"]
OpType --> |删除| DelCheck["先 Count 确认范围<br/>再 Delete"]
OpType --> |存在则更新| Upsert["Clauses(OnConflict)"]
style Start fill:#4FC08D,color:#fff
style BatchInsert fill:#3B82F6,color:#fff
style MapUpdate fill:#F59E0B,color:#000
style Upsert fill:#8B5CF6,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 批量插入太慢 | 单条 SQL 过大被数据库拒绝 | 用 CreateInBatches 控制批次大小 |
| 批量 Updates struct 跳过了零值 | struct 模式下零值被视为「未修改」 | 改用 Updates(map) |
| 误删大量数据 | WHERE 条件过于宽泛 | 先 SELECT COUNT 确认,再加 limit 逐步删除 |
| OnConflict 没生效 | 目标列没有唯一约束 | 确保冲突列上有 UNIQUE 或 PRIMARY KEY |
| 批量操作中钩子阻塞 | 每条都发 HTTP 请求 | 合并为一次性批量调用或使用异步队列 |
## 关联笔记
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[09-钩子函数]]
- [[11-批量操作]]
- [[15-性能优化]]
+263
View File
@@ -0,0 +1,263 @@
---
tags: [GORM, Go, ORM, 自定义类型, Scanner, Valuer, JSON, GORMDataType]
create time: 2026-04-28 00:00
---
# 自定义字段类型
## 概述
内置类型(int、string、time.Time 等)覆盖了大部分场景,但当需要存储特殊格式的数据时——比如将 Go 的 `map[string]any` 序列化为 JSON、或者使用领域模型中的 Value Object——就需要实现自定义类型与数据库列之间的双向转换。
```mermaid
flowchart LR
GoVal["Go 结构体值"] -->|序列化→| DBVal["数据库列值"]
DBVal -->|反序列化←| GoVal
style GoVal fill:#3B82F6,color:#fff
style DBVal fill:#EAB308,color:#fff
```
GORM 提供三组接口,分别解决不同层次的定制需求:
| 接口 | 方向 | 作用 | 适用场景 |
|------|------|------|---------|
| `driver.Valuer` + `sql.Scanner` | 读写双相 | 完全控制序列化/反序列化 | JSON 字段、加密字符串 |
| `GORMDataType()` | 建表时 | 覆盖 GORM 的类型推导 | 自定义枚举、位图 |
| `Scan` / `Value` | 驱动级 | 最底层的 database/sql 适配 | 需要兼容所有库的场景 |
> [!tip] 核心原则
> 这些接口的本质是**桥接**——让普通的 Go struct 能够被 database/sql 驱动「理解」。GORM 在读取和写入数据时会检测类型是否实现了相应接口,如果实现了就调用它们。
## ValueScanner 模式(推荐)
这是最常用也最灵活的方式——同时实现 `driver.Valuer`(Go → DB)和 `sql.Scanner`(DB → Go)两个接口:
### JSON 字段
```go
type JSONMap map[string]interface{}
// Value: Go → DB(序列化)
func (j JSONMap) Value() (driver.Value, error) {
if j == nil {
return nil, nil
}
bytes, err := json.Marshal(j)
if err != nil {
return nil, err
}
return string(bytes), nil
}
// Scan: DB → Go(反序列化)
func (j *JSONMap) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
str, ok := value.([]byte)
if !ok {
str = []byte(fmt.Sprintf("%v", value))
}
return json.Unmarshal(str, j)
}
// 使用示例
type Setting struct {
ID uint `gorm:"primaryKey"`
Key string `gorm:"size:64;uniqueIndex"`
Value JSONMap `gorm:"type:json"` // MySQL 5.7+ / PostgreSQL JSONB
}
// 写入
db.Create(&Setting{Key: "theme", Value: JSONMap{"color": "dark", "fontSize": 14}})
// 读取
var setting Setting
db.Where("key = ?", "theme").First(&setting)
fmt.Println(setting.Value["color"]) // "dark"
```
### 自定义枚举类型
```go
type Priority int8
func (p Priority) String() string {
names := map[Priority]string{
1: "urgent",
2: "high",
3: "normal",
4: "low",
}
return names[p]
}
func (p Priority) Value() (driver.Value, error) {
return int8(p), nil
}
func (p *Priority) Scan(value interface{}) error {
if value == nil {
*p = 3 // default normal
return nil
}
*p = Priority(value.(int64))
return nil
}
type Task struct {
ID uint `gorm:"primaryKey"`
Title string `gorm:"size:128"`
Priority Priority `gorm:"not null;default:3"`
}
// 赋值时使用类型安全的枚举
db.Create(&Task{Title: "Fix login bug", Priority: Priority(1)}) // urgent
// 查询时直接得到强类型值
var task Task
db.First(&task, 1)
fmt.Println(task.Priority.String()) // "urgent"
```
### IP 地址存储
```go
type IPPool net.IP
func (ip IPPool) Value() (driver.Value, error) {
return ip.To4(), nil
}
func (ip *IPPool) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("invalid IP type: %T", value)
}
*ip = IPPool(net.ParseIP(string(bytes)))
return nil
}
```
> [!question] 思考题
> 为什么 Value 和 Scan 的方法签名分别是 `(JSONMap)` 和 `(j *JSONMap)`?一个用值接收者、一个用指针接收者?
>
> > **答案**:`Value()` 只需要读取当前值来序列化,不需要修改原对象;而 `Scan()` 需要将数据库读出的值写回目标变量,所以必须用指针才能修改外部结构体的内容。
## GORMDataType — 仅覆盖类型
如果你只需要告诉 GORM「这个类型对应什么数据库列类型」,不需要做复杂的序列化逻辑:
```go
type Status byte // 0=pending 1=processing 2=done 3=failed
func (Status) GORMDataType() string {
return "TINYINT UNSIGNED" // 覆盖 GORM 默认的 INT 推导
}
type Ticket struct {
ID uint `gorm:"primaryKey"`
Status Status `gorm:"not null;default:0"`
}
```
> [!note] GORMDataType 的作用范围
> 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用这个类型创建列,但数据的序列化和反序列化仍由 `database/sql` 的标准处理完成。
## 结合 Use 注册全局解析器
对于不想在每个 struct 上实现接口的场景(比如第三方类型的扩展),可以用 `RegisterResolve`:
```go
// 为 net.IP 类型注册 GORM 解析逻辑
db.Use(clause.OnConflict{}, func(db *gorm.DB) error {
// 这个方式已经过时了...
return nil
})
// 更现代的方式是用 GORM 的 Resolver
type MyType string
// 注册一个自定义数据类型解析器
// 适用于无法给已有类型添加方法的情况
```
> [!info] 注意
> GORM 对全局类型注册的 API 在不同版本间有变化。推荐的实践是给类型加上方法实现 Valuer/Scanner——这样代码内聚性更好,依赖也更清晰。
## AutoMigrate 时的自定义类型
当你在 AutoMigrate 中使用自定义类型时,GORM 会根据以下步骤决定列类型:
```mermaid
flowchart TD
A[字段类型 T] --> B{T 实现<br/>GORMDataType?}
B -->|是| C["使用返回值<br/>作为列类型"]
B -->|否| D{"T 是已知内置类型?"}
D -->|是| E["使用默认映射"]
D -->|否| F["尝试 driver.Valuer<br/>推断类型"]
F --> G["使用 driver.Value<br/>的反射结果"]
style A fill:#4FC08D,color:#fff
style C fill:#3B82F6,color:#fff
style E fill:#A0AEC0,color:#fff
```
## 综合实战:配置项存储系统
实际项目中经常遇到需要存储任意 key-value 配置的场景:
```go
// 通用配置表——用 JSON 存动态字段
type AppConfig struct {
gorm.Model
Namespace string `gorm:"size:64;not null;index:idx_namespace_key"`
Key string `gorm:"size:128;not null"`
Data JSONMap `gorm:"type:json;not null"`
}
// 索引保证同一命名空间下 key 不重复
func (AppConfig) TableName() string {
return "app_configs"
}
// CRUD 示例
func SetConfig(db *gorm.DB, ns, key string, data map[string]any) error {
config := AppConfig{Namespace: ns, Key: key, Data: JSONMap(data)}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "namespace"}, {Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"data"}),
}).Create(&config).Error
}
func GetConfig(db *gorm.DB, ns, key string) (JSONMap, error) {
var config AppConfig
if err := db.Where("namespace = ? AND key = ?", ns, key).First(&config).Error; err != nil {
return nil, err
}
return config.Data, nil
}
```
> [!tip] 为什么 JSON 比拆多个列更好?
> - 字段动态增减不需要改表结构
> - 可以嵌套复杂数据结构
> - MySQL/PostgreSQL 都提供了 JSON 查询函数(如 `JSON_EXTRACT`),配合 GORM 也能使用
>
> **代价**:失去了行级的类型安全和部分字段的独立索引能力。
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| Scan 接收不到 nil 值 | 未处理 value == nil 分支 | Scan 开头检查 nil 并返回 nil |
| Value 序列化失败导致整个操作中断 | JSON marshal 出错 | 做好错误处理和防御性编码 |
| GORMDataType 没生效 | 忘记嵌入或别名方法声明 | 确保方法是 receiver 类型上的公开方法 |
| Postgres JSONB vs MySQL JSON 差异 | 驱动行为不一致 | 用 `Select` 显式指定 column 类型 |
## 关联笔记
- [[02-模型定义]]
- [[13-多数据库支持]]
+224
View File
@@ -0,0 +1,224 @@
---
tags: [GORM, Go, ORM, MySQL, PostgreSQL, SQLite, SQL Server]
create time: 2026-04-28 00:00
---
# 多数据库支持
## 概述
GORM 的「驱动适配器」架构让它能够无缝切换不同的数据库后端——你写同一份 GORM 代码,只需改变 `gorm.Open()` 的参数就能连接不同数据库。但要注意:**不同数据库的方言差异**可能导致某些功能表现不一致。
```mermaid
flowchart TD
Code["你的 GORM 代码"] --> Adapter{"Driver Adapter"}
Adapter --> |mysql| MySQL["MySQL / MariaDB"]
Adapter --> |postgres| PG["PostgreSQL"]
Adapter --> |sqlite| SQLite["SQLite"]
Adapter --> |mssql| MSSQL["SQL Server"]
style Code fill:#4FC08D,color:#fff
style Adapter fill:#EAB308,color:#fff
style MySQL fill:#3B82F6,color:#fff
style PG fill:#8B5CF6,color:#fff
style SQLite fill:#A0AEC0,color:#fff
style MSSQL fill:#F59E0B,color:#000
```
## 驱动安装与初始化
### MySQL / MariaDB
```go
import (
"gorm.io/driver/mysql"
)
dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
```
**关键 DSN 参数说明**:
| 参数 | 必填 | 说明 |
|------|------|------|
| `charset` | ✅ 推荐 | 必须设为 `utf8mb4`(支持 emoji) |
| `parseTime` | ✅ 推荐 | 自动将 DB 时间转为 Go `time.Time` |
| `loc` | ✅ 推荐 | `Local` 或 `Asia/Shanghai`,避免时区混乱 |
| `timeout` | 可选 | 连接超时时间,如 `30s` |
| `readTimeout` | 可选 | 读取超时 |
| `writeTimeout` | 可选 | 写入超时 |
### PostgreSQL
```go
import (
"gorm.io/driver/postgres"
)
dsn := "host=localhost user=gorm password=gorm dbname=gorm port=5432 sslmode=require TimeZone=Asia/Shanghai"
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
```
**PostgreSQL 特有注意**:
- `sslmode` 在开发环境可设为 `disable`,生产环境保持 `require` 或 `verify-full`
- `TimeZone` 要与服务端一致,否则时间会偏移
### SQLite
```go
import (
"gorm.io/driver/sqlite"
)
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
// 简单到只需要一个文件路径!
```
**SQLite 注意事项**:
- 默认是文件级锁,并发写入会有阻塞
- 高并发场景下建议用 `file:test.db?cache=shared&_journal=WAL` 启用共享缓存和 WAL 模式
- **不支持外键约束**——AutoMigrate 不会创建外键
### SQL Server
```go
import (
"gorm.io/driver/mssql"
)
dsn := "sqlserver://user:password@host:1433?database=dbname"
db, err := gorm.Open(mssql.Open(dsn), &gorm.Config{})
```
## 方言差异速查表
这是跨数据库迁移时最容易踩坑的部分——同样是 `time.Time`,在不同数据库中的列类型可能完全不同:
| 特性 | MySQL | PostgreSQL | SQLite | SQL Server |
|------|-------|-----------|--------|------------|
| `time.Time` | DATETIME / TIMESTAMP | TIMESTAMPTZ | TEXT(序列化后) | DATETIME2 |
| `string` | VARCHAR(n) | VARCHAR / TEXT | TEXT | NVARCHAR(n) |
| `uint` | INT UNSIGNED | BIGINT | INTEGER | INT |
| `float64` | DOUBLE | DOUBLE PRECISION | REAL | FLOAT |
| 自增主键 | AUTO_INCREMENT | SERIAL / BIGSERIAL | AUTOINCREMENT | IDENTITY(1,1) |
| NOW() 函数 | NOW() | NOW() | DATETIME('now') | GETUTCDATE() |
| LIMIT 语法 | LIMIT N OFFSET M | LIMIT N OFFSET M | LIMIT N OFFSET M | TOP N / OFFSET FETCH |
| 软删除默认行为 | ⚠️ InnoDB 唯一约束 bug | ✅ 正常 | ✅ 正常 | ✅ 正常 |
## CREATE TABLE 差异
同一个 struct 在不同数据库下生成的建表语句不同:
```go
type User struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"size:64;not null"`
Age int `gorm:"not null;default:0"`
CreatedAt time.Time `gorm:"autoTime"`
}
```
| 数据库 | 实际建表语句摘要 |
|--------|----------------|
| MySQL | `id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, age INT NOT NULL DEFAULT 0, created_at DATETIME` |
| PostgreSQL | `id BIGSERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL, age INT NOT NULL DEFAULT 0, created_at TIMESTAMP WITH TIME ZONE` |
| SQLite | `id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, created_at TEXT` |
> [!tip] 如何解决方言差异?
> 1. **选择目标数据库再编码**——如果你确定只部署在 MySQL 上,就用 MySQL 的特性;
> 2. **使用兼容模式**——优先使用 GORM 内置的类型映射,不要手动指定方言特有的类型;
> 3. **多数据库测试**——CI 中同时跑多个数据库版本的测试。
## AutoMigrate 跨数据库问题
```go
// MySQL
err := db.AutoMigrate(&User{}, &Order{})
// PostgreSQL
pgDB, _ := gorm.Open(postgres.Open(pgDSN), &gorm.Config{})
err = pgDB.AutoMigrate(&User{}, &Order{})
// 注意:同样一个 User struct,在 PG 中会自动用 BIGSERIAL 而非 AUTO_INCREMENT
```
> [!warning] 迁移不可逆操作
> 某些操作在不同数据库中表现不同:
> - MySQL `AUTO_INCREMENT` → Postgres `BIGSERIAL`:已有的 MySQL 表迁移到 PG 时,自增序列需要重新设置
> - SQLite 不支持 ALTER TABLE 添加非空列——`AutoMigrate` 只能新增列不能修改已有列的结构
>
> **最佳实践**:使用独立的迁移工具(如 Goose、golang-migrate),手写 SQL 脚本而非依赖 AutoMigrate。
## 查询语法差异
```go
// 分页:MySQL / SQLite vs PostgreSQL 基本一致
db.Limit(10).Offset(20).Find(&users)
// SQL Server 的分页语法不同——GORM 内部会自动转换
// db.Limit(10).Offset(20).Find(&users)
// → SELECT TOP 10 * FROM users WHERE id NOT IN (SELECT TOP 20 id FROM users)
// UUID 字段
type Order struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid()"` // PG
// 改为 type:char(36);default:newid() // SQL Server
// 改为 type:binary(16) // MySQL
}
```
## 最佳实践总结
### 开发环境 vs 生产环境
```go
func newDB(env string) (*gorm.DB, error) {
var dsn string
switch env {
case "dev":
dsn = "file:test_dev.db?cache=shared" // SQLite 快速迭代
case "staging":
dsn = os.Getenv("DATABASE_URL") // 共享 PG 实例
case "prod":
dsn = os.Getenv("PROD_DATABASE_URL") // 独立 PG 集群
}
driver := "sqlite"
if env != "dev" {
driver = "postgres"
}
return gorm.Open(getDriver(driver)(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), // 生产环境关闭详细日志
})
}
func getDriver(name string) func(string) gorm.Dialector {
switch name {
case "postgres":
return postgres.Open
case "mysql":
return mysql.Open
case "sqlite":
return sqlite.Open
case "mssql":
return mssql.Open
default:
panic("unknown driver: " + name)
}
}
```
> [!tip] 为什么推荐开发用 SQLite?
> - 零配置:不需要启动数据库服务
> - 单文件:版本控制友好(当然大表不适合 git)
> - 语法兼容性:大部分 SQL 标准都能正常工作
>
> **但**:SQLite 不支持并发写入和事务隔离级别调整,不能完全替代生产数据库做性能测试。
## 关联笔记
- [[01-安装与初始化]]
- [[02-模型定义]]
- [[17-迁移工具]]
+271
View File
@@ -0,0 +1,271 @@
---
tags: [GORM, Go, ORM, 错误处理, ErrRecordNotFound, ErrDuplicatedKey, TransactionFinished]
create time: 2026-04-28 00:00
---
# 错误处理
## 概述
GORM **从不 panic**——所有错误都通过 `.Error` 字段返回。这种设计让业务层可以在不中断程序的情况下优雅地处理异常,但也意味着开发者需要养成「始终检查 Error」的习惯。
```mermaid
flowchart TD
Result["db.XXX() → result"] --> HasErr{"result.Error != nil?"}
HasErr --> |否| Success["继续业务逻辑 ✓"]
HasErr --> |是| CheckType{判断错误类型}
CheckType --> |ErrRecordNotFound| NotFound["404 / 默认值"]
CheckType --> |ErrDuplicatedKey| Duplicate["409 / 提示用户修改"]
CheckType --> |TransactionFinished| TxErr["500 / 事务已被提交或回滚"]
CheckType --> |其他 errors.Is| Generic["记录日志 / 返回通用错误"]
style Start fill:#4FC08D,color:#fff
style Success fill:#3B82F6,color:#fff
style NotFound fill:#EF4444,color:#fff
```
## 核心错误常量
### gorm.ErrRecordNotFound
```go
var user User
result := db.First(&user, 1)
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// 记录不存在 —— 可以返回 404 或设置默认值
return http.NotFound(w, r)
} else if result.Error != nil {
// 真正的数据库错误
log.Printf("查询用户失败: %v", result.Error)
return err
}
// 成功分支
log.Printf("找到用户: %s", user.Name)
```
> [!tip] 为什么用 errors.Is 而不是 ==?
> `gorm.ErrRecordNotFound` 是一个 sentinal error(哨兵错误),Go 标准库的 `errors.Is()` 能正确处理包装过的错误链。直接用 `==` 在复杂场景中可能失效。
### gorm.ErrDuplicatedKey / gorm.ErrForeignKeyConstraintViolated
```go
result := db.Create(&User{Email: "alice@example.com"})
if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
// 唯一约束冲突 —— 通常是 Email 已注册
return fmt.Errorf("该邮箱已被注册")
}
// 外键约束失败
type Order struct { UserID uint }
result := db.Create(&Order{UserID: 999})
if errors.Is(result.Error, gorm.ErrForeignKeyConstraintViolated) {
return fmt.Errorf("指定的用户不存在")
}
```
### gorm.TransactionFinished
```go
tx := db.Begin()
tx.Commit() // 先提交
tx.Rollback() // ❌ 再回滚会报错!
// result.Error == gorm.ErrTransactionFinished
```
> [!warning] 常见触发场景
> 这个错误最常见于 `defer Rollback()` 模式——如果 Commit 成功了但 defer 仍会执行 Rollback:
> ```go
> func Example() error {
> tx := db.Begin()
> defer func() {
> if r := recover(); r != nil {
> tx.Rollback()
> panic(r)
> }
> }()
>
> // ... 操作 ...
>
> if err := tx.Commit().Error; err != nil {
> tx.Rollback()
> return err
> }
> // 到达这里说明 Commit 成功 —— defer 不会进 panic 分支
> return nil
> }
> ```
### 常用错误常量汇总
| 常量 | 含义 | 典型 HTTP 状态码 | 处理建议 |
|------|------|-----------------|---------|
| `gorm.ErrRecordNotFound` | 查询结果为空 | 404 | 返回友好提示或默认值 |
| `gorm.ErrDuplicatedKey` | 唯一约束冲突 | 409 | 提示用户修改输入 |
| `gorm.ErrForeignKeyConstraintViolated` | 外键约束失败 | 400 | 检查关联数据是否存在 |
| `gorm.ErrInvalidData` | 数据无效 | 400 | 校验输入后重试 |
| `gorm.ErrTransactionFinished` | 事务已提交/回滚 | 500 | 代码逻辑 BUG,修复代码 |
| `gorm.ErrUnsupportedDriver` | 不支持的驱动 | 配置错误 | 检查 dsn 和驱动导入 |
| `gorm.ErrInvalidTransaction` | 无效的事务操作 | 500 | 检查事务上下文是否正确 |
## 自定义错误判断
### 包装业务错误
```go
func GetUserByID(db *gorm.DB, id uint) (*User, error) {
var user User
if err := db.First(&user, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户 ID=%d 不存在: %w", id, err)
}
return nil, fmt.Errorf("查询用户失败: %w", err)
}
return &user, nil
}
```
### 自定义错误处理器
```go
// 统一错误解析器,适合中间件或框架层使用
func ParseDBError(err error) (int, string) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
return http.StatusNotFound, "资源未找到"
case errors.Is(err, gorm.ErrDuplicatedKey):
return http.StatusConflict, "数据冲突,请检查输入"
case errors.Is(err, gorm.ErrForeignKeyConstraintViolated):
return http.StatusBadRequest, "关联数据无效"
case errors.Is(err, gorm.ErrTransactionFinished):
return http.StatusInternalServerError, "系统内部错误"
default:
log.Printf("未分类数据库错误: %v", err)
return http.StatusInternalServerError, "数据库操作失败"
}
}
// 在 Gin handler 中使用
func HandleCreateUser(c *gin.Context) {
err := createUserService(c.Request)
if err != nil {
status, msg := ParseDBError(err)
c.JSON(status, gin.H{"error": msg})
return
}
c.JSON(201, gin.H{"message": "创建成功"})
}
```
## 错误检查的正确姿势
### ❌ 错误的写法
```go
// 1. 完全忽略错误
db.Create(&user)
// 2. 只检查非 nil(会误判 ErrRecordNotFound)
if err := db.First(&user, 1).Error; err != nil {
// ErrRecordNotFound 也会被当成一般错误处理
log.Printf("error: %v", err)
}
// 3. 先判断 Error != nil 再用 == 比较
err := db.First(&user, 1).Error
if err != nil && err == gorm.ErrRecordNotFound {
// 在某些情况下可能漏判
}
```
### ✅ 正确的写法
```go
// 模式一:errors.Is 优先
if err := db.First(&user, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 单独处理
} else {
// 其他数据库错误
}
}
// 模式二:先赋值再判断
result := db.First(&user, 1)
switch {
case errors.Is(result.Error, gorm.ErrRecordNotFound):
handleNotFound()
case result.Error != nil:
handleError(result.Error)
default:
handleSuccess(user)
}
// 模式三:简洁的单路径
if result := db.First(&user, 1); result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("user not found")
}
return nil, fmt.Errorf("db error: %w", result.Error)
}
```
## 连接错误与超时处理
```go
// 检测数据库是否可达
sqlDB, err := db.DB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err != nil {
log.Printf("数据库连接异常: %v", err)
// 可以尝试重连、切换从库等
return fmt.Errorf("database unavailable")
}
```
> [!tip] 结合健康检查
> 在生产环境中,应该定期执行 `PingContext` 作为 K8s liveness/readiness probe,确保服务知道数据库是否可用。
## 错误处理决策流程图
```mermaid
flowchart TD
Start[操作返回结果] --> CheckErr{"result.Error 非空?"}
CheckErr --> |否| Success["操作成功"]
CheckErr --> |是| FirstCheck{errors.Is...}
FirstCheck --> |ErrRecordNotFound| Handler1["处理: 返回默认值/404"]
FirstCheck --> |ErrDuplicatedKey| Handler2["处理: 提示重复"]
FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["处理: 检查关联"]
FirstCheck --> |ErrTransactionFinished| Handler4["处理: 代码缺陷修复"]
FirstCheck --> |都不匹配| DefaultHandler["处理: 通用错误/500"]
style Start fill:#4FC08D,color:#fff
style Success fill:#3B82F6,color:#fff
style DefaultHandler fill:#EF4444,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 忘记检查 `.Error` | 以为方法直接返回 error | 养成 `if err := xxx().Error; err != nil` 习惯 |
| 用 `==` 而非 `errors.Is` | 哨兵错误被 fmt.Errorf("%w") 包装后丢失 | 统一使用 `errors.Is(err, gorm.xxx)` |
| Commit 后再 Rollback | defer 无条件执行回滚 | 只在 panic 分支中 Rollback |
| 把所有错误当 RecordNotFound | 没做错误类型分层 | 先用 errors.Is 精确判断 |
| 数据库断开时没检测到 | 连接池复用旧连接 | PingContext 定期探测 + SetConnMaxLifetime |
## 关联笔记
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[16-日志与调试]]
+324
View File
@@ -0,0 +1,324 @@
---
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 {
> 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 白名单
```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)
```
## 启用预编译语句缓存
```go
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
PrepareStmt: true, // 开启预编译语句缓存
})
// 效果:相同 SQL 的第二次执行会使用 cached statement
// 显著降低 SQL 解析和计划编译的开销
```
> [!note] 缓存机制
> GORM 内置一个基于 `sync.Map` 的简单缓存,存储已经编译好的 SQL 语句。适合重复执行的固定模式查询(如根据 ID 查用户)。不适合大量参数不同的动态查询——缓存命中率低会浪费内存。
## 索引优化建议
### 在 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
> })
> ```
## 性能优化检查清单
```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-批量操作]]
+266
View File
@@ -0,0 +1,266 @@
---
tags: [GORM, Go, ORM, 日志, Debug, SlowQueryThreshold, Logger]
create time: 2026-04-28 00:00
---
# 日志与调试
## 概述
在生产环境中,你能看到的往往只有两条信息:**API 返回了什么**和**数据库执行了什么**。GORM 内置了灵活的日志系统,让你能够在不同环境下精准控制输出粒度,从「生产静默」到「开发透明」自由切换。
```mermaid
flowchart TD
A["你的代码"] --> B["GORM SQL 生成"]
B --> C{"Logger 配置"}
C --> |Panic| D["出错了才输出<br/>生产默认值"]
C --> |Error| E["只记录错误 SQL"]
C --> |Warn| F["慢查询 + 错误"]
C --> |Info| G["所有 SQL + 耗时<br/>开发推荐"]
style Start fill:#4FC08D,color:#fff
style Info fill:#3B82F6,color:#fff
style Panic fill:#EF4444,color:#fff
```
## 日志级别速览
GORM 的 `logger.Interface` 定义了四个级别:
| 级别 | 输出内容 | 适用环境 |
|------|---------|---------|
| `Silent` | 什么都不输出 | 压测、极端性能场景 |
| `Error` | 仅错误 | 稳定运行的生产环境 |
| `Warn` | 错误 + 慢查询 | 需要监控的生产环境 |
| `Info` | 所有 SQL + 参数 + 耗时 | 开发 / 调试环境 |
> [!tip] 默认级别是 `Panic`(等同于 Silent)
> GORM **不会在运行时产生任何日志**,除非你显式配置。这既保护隐私也减少磁盘 IO,但也意味着出了问题时需要临时调高日志级别来排查。
## 配置方式
### 基础用法
```go
import (
"gorm.io/gorm/logger"
"time"
)
newLogger := logger.New(
logger.Config{
SlowThreshold: 200 * time.Millisecond, // 超过 200ms 视为慢查询
Colorful: true, // 终端彩色输出
IgnoreRecordNotFoundError: false, // 记录 ErrRecordNotFound
LogLevel: logger.Info, // 日志级别
},
)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: newLogger,
})
```
### 自定义 Logger(对接 Zap / Logrus 等)
```go
// 用 Zap 替代默认日志
type ZapLogger struct {
logger.Interface
zapLogger *zap.Logger
}
func (z ZapLogger) LogMode(level logger.Level) logger.Interface {
newLogger := z
newLogger.Interface = z.Interface.LogMode(level)
return newLogger
}
func (z ZapLogger) Info(ctx context.Context, msg string, data ...interface{}) {
z.zapLogger.Sugar().Infow(msg, data...)
}
func (z ZapLogger) Warn(ctx context.Context, msg string, data ...interface{}) {
z.zapLogger.Sugar().Warnw(msg, data...)
}
func (z ZapLogger) Error(ctx context.Context, msg string, data ...interface{}) {
z.zapLogger.Sugar().Errorw(msg, data...)
}
// 使用
zapLogger, _ := zap.NewProduction()
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: ZapLogger{Interface: logger.Default, zapLogger: zapLogger},
})
```
## SQL 日志格式解析
当启用 `Info` 级别时,你会看到类似这样的输出:
```
[info] ... [0.12ms] [rows:-] SELECT * FROM users WHERE id = 1
│ │ │ │ │ │
│ │ │ │ │ └── SQL 语句
│ │ │ │ └── 影响行数 (- 表示查询不确定)
│ │ │ └── 执行耗时
│ │ └── 操作标签(SELECT/INSERT/UPDATE/DELETE)
│ └── 文件位置: src/model/user.go:15
└── 日志级别
```
### 关键指标解读
| 字段 | 含义 | 关注点 |
|------|------|--------|
| `[0.12ms]` | 单次执行耗时 | 超过 SlowThreshold 会用 WARN 标记 |
| `[rows:42]` | 影响/返回行数 | SELECT 时为正数,INSERT/UPDATE/DELETE 为影响行数 |
| `[rows:-]` | 行数未知(通常是 SELECT) | 无法提前知道,需执行后统计 |
## Debug — 临时开启详细日志
不想改全局配置的情况下,可以用 `Debug()` 方法临时对单个查询开启全量日志:
```go
// 只对这一条 SQL 输出详细信息
result := db.Debug().Where("status = ?", "active").Find(&users)
// 输出:[info] ... [1.23ms] [rows:150] SELECT * FROM users WHERE status='active'
// 对比:没有 Debug() 时可能完全看不到这条 SQL
result = db.Where("status = ?", "active").Find(&users)
// 无输出(如果 Logger 是 Silent/Error)
```
> [!tip] Debug 的实际用途
> - **快速定位问题 SQL**:在一个复杂链式调用中,找到是哪一步生成的 SQL 不对
> - **Code Review 时的验证**:确认 GORM 确实生成了预期的 SQL 语句
> - **单元测试**:临时开日志但不修改全局配置
## 慢查询监控
### 设置慢查询阈值
```go
// 超过 100ms 的 SQL 会被标记为 WARN
newLogger := logger.New(
logger.Config{
SlowThreshold: 100 * time.Millisecond,
Colorful: true,
LogLevel: logger.Warn,
},
)
```
### 结合 Prometheus 做量化监控
```go
import "github.com/prometheus/client_golang/prometheus"
var queryDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "gorm_query_duration_ms",
Help: "GORM query duration in milliseconds",
},
[]string{"operation", "table"},
)
// 在每个请求结束后采集(简化示例)
func RecordQueryDuration(op, table string, dur time.Duration) {
queryDuration.WithLabelValues(op, table).Observe(dur.Seconds() * 1000)
}
```
> [!question] 思考题
> 什么时候该关注慢查询?100ms、500ms 还是 1s?
>
> > **答案**:取决于业务 SLA。对于 API 网关场景,单条 SQL 建议控制在 **50ms 以内**;对于离线批处理任务,可以放宽到秒级。关键是建立基线——先跑一周看 P95/P99 数据,再设定阈值。
## 常见调试技巧
### 打印最终 SQL(不执行)
```go
// DryRun 模式:构建完整 SQL 并输出,但不实际执行
db.Session(&gorm.Session{DryRun: true}).First(&user, 1)
// 输出:[info] ... [rows:0] SELECT * FROM users WHERE id = 1 -- dry run
// 此时你可以拿到完整的 SQL 去客户端手动验证
```
### 获取生成的 SQL 字符串
```go
// 使用 Statement 直接构造并获取 SQL
stmt := db.Model(&User{}).Where("name = ?", "john").Statement
db.Statement.Build(stmt.DB.Build("WHERE"))
fmt.Println(stmt.SQL.String())
// SELECT * FROM users WHERE name = 'john'
```
### 日志中加入 TraceID(链路追踪)
```go
func WithTraceID(db *gorm.DB, traceID string) *gorm.DB {
return db.Session(&gorm.Session{
Context: context.WithValue(context.Background(), "trace_id", traceID),
})
}
// 自定义日志输出中包含 trace_id
type TracedLogger struct {
logger.Interface
}
func (t TracedLogger) Info(ctx context.Context, msg string, data ...interface{}) {
traceID, _ := ctx.Value("trace_id").(string)
if traceID != "" {
msg = fmt.Sprintf("[trace:%s] %s", traceID, msg)
}
t.Interface.Info(ctx, msg, data...)
}
```
## 日志配置决策图
```mermaid
flowchart TD
Start["确定日志需求"] --> Env{"运行环境?"}
Env --> |开发环境| Dev["Info 级别 + Colorful + SlowThreshold=200ms"]
Env --> |测试环境| Test["Warn 级别 + SlowThreshold=100ms"]
Env --> |生产环境| ProdQ{"需要实时监控?"}
ProdQ --> |不需要| ProdSilent["Error 级别(默认)"]
ProdQ --> |需要| ProdMonitor["Warn 级别 + 接入监控体系"]
Dev --> Check{"特定查询需要调试?"}
Test --> Check
ProdMonitor --> Check
Check --> |是| DebugOn["db.Debug() 临时开启"]
Check --> |否| Done["配置完成 ✓"]
style Start fill:#4FC08D,color:#fff
style Dev fill:#3B82F6,color:#fff
style ProdMonitor fill:#EAB308,color:#fff
style Done fill:#A0AEC0,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| 生产环境日志太多导致磁盘爆满 | Logger 级别设太高 | 生产用 Warn 或 Error |
| 日志中没有 TraceID 难以定位 | 没传 Context | 用 Session + Context 传递 |
| DryRun 拿到的 SQL 参数是 ? | 预编译语句的参数未展开 | 这是正常行为,参数化查询安全性的体现 |
| Debug() 改了全局状态 | Debug 返回新的 *gorm.DB,不影响原实例 | 放心使用,它是纯函数式的 |
| 慢查询阈值设太低 | 正常查询也被标记为 slow | 先收集基线数据再设定合理阈值 |
## 关联笔记
- [[01-安装与初始化]]
- [[03-CRUD 操作]]
- [[14-错误处理]]
- [[15-性能优化]]
+339
View File
@@ -0,0 +1,339 @@
---
tags: [GORM, Go, ORM, 迁移, AutoMigrate, Migrator, 版本化]
create time: 2026-04-28 00:00
---
# 迁移工具
## 概述
数据库迁移是应用演进的基础设施——每次修改模型结构都需要将变更同步到数据库。GORM 提供了原生的 `AutoMigrate`,但对于生产环境来说,通常推荐使用**版本化的迁移脚本**来获得更细粒度的控制和回滚能力。
```mermaid
flowchart LR
Code["Go struct 变更"] --> AutoMigrate["AutoMigrate<br/>自动同步 schema"]
Code --> MigrationFile["版本化 SQL 文件<br/> goose / golang-migrate"]
AutoMigrate --> Dev{"使用场景?"}
MigrationFile --> Dev
Dev --> |快速原型/个人项目| Quick["✅ AutoMigrate 够用"]
Dev --> |团队协作/生产环境| Strict["✅ 版本化迁移方案"]
style Code fill:#4FC08D,color:#fff
style Quick fill:#3B82F6,color:#fff
style Strict fill:#EAB308,color:#fff
```
## AutoMigrate — 一键建表
### 基本用法
```go
func migrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{},
&Order{},
&Product{},
)
}
```
AutoMigrate 会在以下情况下自动操作:
- **表不存在** → CREATE TABLE
- **列不存在** → ALTER TABLE ADD COLUMN
- **列类型不匹配** → ALTER TABLE MODIFY COLUMN
- **索引不存在** → CREATE INDEX
### 增量更新
```go
// 第一次:只有 User 和 Order
db.AutoMigrate(&User{}, &Order{})
// 第二次:给 User 加了 Email 字段
type User struct {
ID uint
Name string
Email string `gorm:"size:128;uniqueIndex"` // 新增字段
}
db.AutoMigrate(&User{})
// 不会删除已有的字段,只会添加新列和索引
```
> [!warning] AutoMigrate 的限制
> - **不能删除**不再出现在 struct 中的列(需要手动处理)
> - **不能重命名**列(需要先删除再创建)
> - **不能修改主键**定义
> - MySQL 下 `ALTER TABLE MODIFY COLUMN` 可能会重建整张表(锁表!)
> - 某些复杂的类型变更(如 varchar 改 int)可能不被支持
### DryRun 预览变更
```go
// 查看 AutoMigrate 会做什么,但不执行
err := db.Session(&gorm.Session{DryRun: true}).AutoMigrate(&User{})
if err != nil {
log.Printf("AutoMigrate 将产生的 SQL 错误(仅 DryRun): %v", err)
}
```
> [!tip] DryRun + AutoMigrate 的组合
> DryRun 模式下的 AutoMigrate 会构建 SQL 语句但**不执行**。结合这个特性可以做迁移前校验——在 CI 中检查新模型是否会导致破坏性变更。
## Migrator 接口 —— 细粒度控制
GORM 提供了 `Migrator` 接口来访问底层数据库的迁移能力:
```go
migrator := db.Migrator()
// 检查表是否存在
if migrator.HasTable(&User{}) {
fmt.Println("users 表已存在")
}
// 检查列是否存在
if migrator.HasColumn(&User{}, "email") {
fmt.Println("email 列已存在")
}
// 获取列的类型信息
column, err := migrator.ColumnType(&User{}, "email")
fmt.Printf("列名: %s, 类型: %s, 长度: %d\n",
column.Name(), column.DatabaseTypeName(), column.Length())
// 重命名列(如果驱动支持)
migrator.RenameColumn(&User{}, "name", "username")
// 删除列
migrator.DropColumn(&User{}, "old_field")
// 创建/删除索引
migrator.CreateIndex(&User{}, "IdxEmail")
migrator.HasIndex(&User{}, "IdxEmail")
migrator.DropIndex(&User{}, "IdxEmail")
```
> [!note] 不同驱动的 Migrator 实现
> 每个驱动都有自己的 `Migrator` 实现。不是所有操作在所有数据库上都受支持:
> ```go
> // 检查某个驱动是否支持特定操作
> if migrator, ok := db.Migrator().(*mysql.Migrator); ok {
> // MySQL 特有的迁移功能
> }
> ```
## 版本化迁移方案(生产推荐)
在生产环境中,推荐使用专门的迁移工具配合 GORM:
### 方案一:Goose
```bash
# 安装
go install github.com/pressly/goose/v3/cmd/goose@latest
# 创建迁移文件
goose create add_user_email sql
# 生成文件:20260101_001_add_user_email.sql
-- +goose Up
ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE;
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
-- +goose Down
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users DROP COLUMN email_verified;
```
与 GORM 集成:
```go
func migrateDB(db *gorm.DB) {
// 先让 GORM 做必要的表存在性检查和基础初始化
db.AutoMigrate(&User{})
// 然后用 goose 执行版本化迁移脚本
goose.Run("up", dsn)
}
```
### 方案二:golang-migrate
```bash
# 安装
go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# 创建迁移
migrate create -ext sql -dir migrations -seq add_user_email
# migrations/001_add_user_email.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- PG 特有
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(128);
ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email);
-- <向下迁移>
ALTER TABLE users DROP COLUMN IF EXISTS email;
```
```go
// 启动时执行迁移
func initDB() {
m, err := migrate.New(
"migrations/file://./migrations",
dsn,
)
if err != nil {
log.Fatal(err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
// 迁移成功后才连接 GORM
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
```
### 方案三:自建轻量级迁移表
适合小型项目——用一个表记录当前迁移版本号:
```go
type MigrationRecord struct {
ID uint `gorm:"primaryKey"`
Version string `gorm:"size:32;uniqueIndex;not null"`
Applied time.Time
}
// 迁移脚本库
var migrations = []struct {
Version string
SQL string
}{
{"v0.1", "CREATE TABLE IF NOT EXISTS users (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL)"},
{"v0.2", "ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE"},
{"v0.3", "ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP"},
}
func ApplyMigrations(tx *gorm.DB) error {
var appliedVersions []string
tx.Model(&MigrationRecord{}).Pluck("version", &appliedVersions)
for _, m := range migrations {
if contains(appliedVersions, m.Version) {
continue // 已执行过,跳过
}
log.Printf("执行迁移: %s", m.Version)
if err := tx.Exec(m.SQL).Error; err != nil {
return fmt.Errorf("迁移 %s 失败: %w", m.Version, err)
}
tx.Create(&MigrationRecord{Version: m.Version, Applied: time.Now()})
}
return nil
}
```
> [!question] 为什么生产环境不推荐只用 AutoMigrate?
| 维度 | AutoMigrate | 版本化迁移 |
|------|------------|-----------|
| 可预测性 | GORM 内部决定执行顺序 | SQL 完全可控 |
| 回滚能力 | ❌ 无内置回滚 | ✅ 有 Down 脚本 |
| 团队协作 | 容易冲突(各自改了 model 就 auto) | 合并 SQL 文件 |
| 数据库差异 | 自动处理方言 | 手写 SQL 需考虑目标 DB |
| CI/CD 集成 | 难判断是否有未执行的变更 | migration_version 表可做 gate |
| 审计追踪 | ❌ | ✅ 谁在什么时候改了什么 |
## 迁移策略对比
```mermaid
flowchart TD
ProjectSize{"项目规模?"}
ProjectSize --> |个人项目/<br/>快速原型| Small["AutoMigrate + DryRun 验证"]
ProjectSize --> |小型团队/<br/>单一数据库| Medium["自建迁移版本表<br/>+ AutoMigrate 兜底"]
ProjectSize --> |企业级/<br/>多环境/多数据库| Large["专业迁移工具<br/>goose / migrate"]
style Small fill:#A0AEC0,color:#fff
style Medium fill:#3B82F6,color:#fff
style Large fill:#4FC08D,color:#fff
```
## 迁移最佳实践
### 1. 幂等性设计
迁移脚本应该能够重复执行而不报错:
```sql
-- ✅ 幂等写法
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone);
-- ❌ 非幂等——重复执行会报 "column already exists" 错误
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
```
### 2. 大表加列的最佳方式
对线上大表(百万级以上)直接 `ALTER TABLE ADD COLUMN` 会锁表导致服务不可用:
```sql
-- 推荐方式:分步骤进行
-- Step 1: 添加可为 NULL 的新列(不阻塞写入)
ALTER TABLE large_table ADD COLUMN new_column INT;
-- Step 2: 后台任务分批回填数据
-- UPDATE large_table SET new_column = default_val WHERE ... LIMIT 10000;
-- (循环直到全部回填)
-- Step 3: 加上约束(此时数据已经合规了)
ALTER TABLE large_table MODIFY COLUMN new_column INT NOT NULL DEFAULT 0;
ALTER TABLE large_table ADD INDEX idx_new_col (new_column);
```
### 3. CI Pipeline 中的迁移检查
```yaml
# .github/workflows/db-check.yml
jobs:
check-migrations:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Check AutoMigrate consistency
run: |
# 创建一个空的临时 DB,运行 AutoMigrate,然后比较 schema
go run ./cmd/check-schema ${{ secrets.DB_DSN }}
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| AutoMigrate 删不掉旧字段 | GORM 设计上不支持 drop | 手动写迁移脚本或手动 DROP |
| 大表 ALTER 导致服务中断 | InnoDB 全表重建 | 用 pt-online-schema-change 或 gh-ost |
| 迁移脚本不幂等 | 重复部署报错 | 使用 IF NOT EXISTS 条件判断 |
| 本地环境与生产环境不一致 | 不同数据库方言 | Docker 容器里跑相同版本的 DB |
| 忘记执行 Down 脚本 | 无法回滚 | 建立迁移脚本审查流程 |
## 关联笔记
- [[01-安装与初始化]]
- [[02-模型定义]]
- [[08-事务管理]]
- [[13-多数据库支持]]
+14 -14
View File
@@ -19,26 +19,26 @@ GORM 是 Go 生态中最流行的 ORM 库,本知识库系统整理 GORM 的核
### 2. 查询篇
- **[04-条件查询](./04-条件查询/)** — Where 链式调用、Between / In / Like / 原生 SQL
- **[05-关联查询](./05-关联查询/)** — Preload / Joins、HasOne / HasMany / BelongsTo / ManyToMany
- **[06-排序与分页](./06-排序与分页/)** — Order / Limit / Offset / Paginate
- **[07-子查询与分组](./07-子查询与分组/)** — Group / Having / SubQuery / 嵌套查询
- **[04-条件查询](./04-条件查询)** — Where 链式调用、Between / In / Like / 原生 SQL
- **[05-关联查询](./05-关联查询)** — Preload / Joins、HasOne / HasMany / BelongsTo / ManyToMany
- **[06-排序与分页](./06-排序与分页)** — Order / Limit / Offset / Paginate
- **[07-子查询与分组](./07-子查询与分组)** — Group / Having / SubQuery / 嵌套查询
### 3. 进阶篇
- **[08-事务管理](./08-事务管理/)** — Tx 对象、Commit / Rollback、嵌套事务
- **[09-钩子函数](./09-钩子函数/)** — Before / After Save、Create、Update、Delete、Find
- **[10-软删除](./10-软删除/)** — SoftDelete 原理、Unscoped 强制查询
- **[11-批量操作](./11-批量操作/)** — Bulk Insert / Update、Callback 机制
- **[12-自定义字段类型](./12-自定义字段类型/)** — Scanner / Valuer 接口、JSON 字段
- **[13-多数据库支持](./13-多数据库支持/)** — MySQL / PostgreSQL / SQLite / SQL Server
- **[08-事务管理](./08-事务管理)** — Tx 对象、Commit / Rollback、嵌套事务
- **[09-钩子函数](./09-钩子函数)** — Before / After Save、Create、Update、Delete、Find
- **[10-软删除](./10-软删除)** — SoftDelete 原理、Unscoped 强制查询
- **[11-批量操作](./11-批量操作)** — Bulk Insert / Update、Callback 机制
- **[12-自定义字段类型](./12-自定义字段类型)** — Scanner / Valuer 接口、JSON 字段
- **[13-多数据库支持](./13-多数据库支持)** — MySQL / PostgreSQL / SQLite / SQL Server
### 4. 工程实践篇
- **[14-错误处理](./14-错误处理/)** — RecordNotFound、ErrRelatedExists、自定义错误判断
- **[15-性能优化](./15-性能优化/)** — N+1 问题排查、Preload 策略、预编译语句
- **[16-日志与调试](./16-日志与调试/)** — 慢查询监控、SQL 日志格式化
- **[17-迁移工具](./17-迁移工具/)** — AutoMigrate / Migrator、版本化迁移策略
- **[14-错误处理](./14-错误处理)** — RecordNotFound、ErrRelatedExists、自定义错误判断
- **[15-性能优化](./15-性能优化)** — N+1 问题排查、Preload 策略、预编译语句
- **[16-日志与调试](./16-日志与调试)** — 慢查询监控、SQL 日志格式化
- **[17-迁移工具](./17-迁移工具)** — AutoMigrate / Migrator、版本化迁移策略
## 核心架构图