From 0cc894a3cb31f53c35b9f2964abe1ab6d975e7f9 Mon Sep 17 00:00:00 2001
From: huanghaosheng <386998068@qq.com>
Date: Tue, 28 Apr 2026 20:56:51 +0800
Subject: [PATCH] vault backup: 2026-04-28 20:56:51
---
hhs/GORM/04-条件查询.md | 63 ++++---
hhs/GORM/05-关联查询.md | 53 ++++--
hhs/GORM/06-排序与分页.md | 100 +++++++---
hhs/GORM/07-子查询与分组.md | 133 ++++++++++---
hhs/GORM/08-事务管理.md | 212 ++++++++++++++++++---
hhs/GORM/09-钩子函数.md | 190 +++++++++++++++----
hhs/GORM/10-软删除.md | 90 +++++++--
hhs/GORM/11-批量操作.md | 82 ++++----
hhs/GORM/12-自定义字段类型.md | 39 ++--
hhs/GORM/13-多数据库支持.md | 339 +++++++++++++++++++++++++---------
hhs/GORM/14-错误处理.md | 184 +++++++++++++++---
hhs/GORM/15-性能优化.md | 139 +++++++++++++-
hhs/GORM/16-日志与调试.md | 162 +++++++++++++---
hhs/GORM/17-迁移工具.md | 220 ++++++++++++++++------
14 files changed, 1566 insertions(+), 440 deletions(-)
diff --git a/hhs/GORM/04-条件查询.md b/hhs/GORM/04-条件查询.md
index a5edd3e..519e011 100644
--- a/hhs/GORM/04-条件查询.md
+++ b/hhs/GORM/04-条件查询.md
@@ -90,8 +90,8 @@ db.Where("id IN (?)", []int{1, 2, 3}).Find(&users)
// 批量删除
db.Where("id IN (?)", []int{1, 2, 3}).Delete(&User{})
-// NOT IN
-db.Not("id IN (?)", []int{1, 2, 3}).Find(&users)
+// NOT IN —— GORM Not 方法接收字段名和切片,自动推断 NOT IN
+db.Not("id", []int{1, 2, 3}).Find(&users)
// SELECT * FROM users WHERE id NOT IN (1, 2, 3);
```
@@ -222,44 +222,49 @@ for rows.Next() {
> [!important] 资源管理
> `Rows` 对象持有数据库连接,使用完毕后**必须**调用 `rows.Close()`,否则会导致连接泄漏。如果只需要单行结果,优先用 `Raw(...).Scan(&dest)`。
-### Condition 对象(条件复用)
+### 条件复用(中间态 DB)
+
+把带条件的查询保存为一个 `*gorm.DB`,后续在不同业务场景上追加不同操作:
```go
-cond := gorm.Condition.Where("status = ?", "active").Or("status = ?", "pending")
-db.Where(cond).Find(&users)
+// 基础条件:只查活跃数据
+q := db.Model(&User{}).Where("status = ?", "active")
-// 多个条件组合复用
-activeCount := db.Model(&User{}).Where(cond).Count()
-activeUsers := db.Where(cond).Order("created_at DESC").Limit(10).Find(&users)
+// 统计活跃用户数
+activeCount := q.Count()
+
+// 分页查询最新活跃用户
+activeUsers := make([]User, 0)
+q.Order("created_at DESC").Limit(10).Find(&activeUsers)
```
-> [!tip] 何时使用 Condition 对象?
-> 当你需要在多个查询中重复使用同一组条件时(比如项目中所有业务模块都要「只查活跃数据」),把它抽成全局 Condition 变量,避免到处复制粘贴 SQL 片段。
+> [!tip] 何时使用中间态 DB?
+> 当你需要在多个查询中重复使用同一组条件时(比如项目中所有业务模块都要「只查活跃数据」),先构建好基础查询再分别追加 `Order` / `Limit` / `Select` 等操作,避免到处复制粘贴 SQL 片段。
## 条件查询决策图
```mermaid
flowchart TD
- Start[收到查询请求] --> Type{条件类型_}
+ 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"]
+ Type --> "精确匹配" --> Exact["Where field, value"]
+ Type --> "范围查询" --> RangeQ{"BETWEEN 还是 GT/LT"}
+ RangeQ --> "GORM 自带" --> Between["BETWEEN ? AND ?"]
+ RangeQ --> "自定义 < >" --> LtGt["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 还是 NOT"}
+ NullQ --> "IS NULL" --> IsNullOp["IS NULL"]
+ NullQ --> "IS NOT NULL" --> IsNotNullOp["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
diff --git a/hhs/GORM/05-关联查询.md b/hhs/GORM/05-关联查询.md
index 155eb12..10429bb 100644
--- a/hhs/GORM/05-关联查询.md
+++ b/hhs/GORM/05-关联查询.md
@@ -1,5 +1,5 @@
---
-tags: [GORM, Go, ORM, 关联查询, Preload, Joins, HasOne, HasMany, BelongsTo, ManyToMany]
+tags: [GORM, Go, ORM, 关联查询, Preload, Joins, HasOne, HasMany, BelongsTo, ManyToMany, Association]
create time: 2026-04-28 00:00
---
@@ -7,7 +7,7 @@ create time: 2026-04-28 00:00
## 概述
-现实世界的数据从不孤立存在——用户有订单、订单包含商品、商品属于分类。如何在一次或多次查询中高效地加载这些关联数据,是 ORM 的核心能力。
+现实世界的数据从不孤立存在——用户有订单、订单包含商品、商品属于分类。本文介绍 GORM 中如何通过 struct tag 声明模型间关系,以及加载这些关联数据的两种核心策略:**预加载**(N+1 条 SQL)和 **JOIN 单查询**。内容覆盖 HasOne、HasMany、BelongsTo、ManyToMany 四种关联类型的定义与操作。
GORM 提供了两种主要的关联加载方式:
- **N+1 预加载**(`Preload`):额外发 N+1 条 SQL,批量填充关联
@@ -152,9 +152,22 @@ db.Preload("Orders", "id, amount, status").
>
> > **答案**:关联字段会是空切片/零值结构体,不会报错。这是符合直觉的行为——只是「没找到」而非「出错了」。
+---
+
## Preload vs PreloadWith
-GORM v1.x 有单独的 `PreloadWith` 方法,但在 v2.x 中已被整合到 `Preload` 的函数式参数里。如果你从旧文档看到 `PreloadWith`,请参考上面的「链式 Preload」写法。
+> [!warning] 版本注意
+> GORM v1.x 有单独的 `PreloadWith` 方法用于设置预加载选项,但在 v2.x 中已被整合到 `Preload` 的函数式参数里。如果你从旧文档看到 `PreloadWith`,请参考上面的「带条件的 Preload」写法。
+
+```go
+// v1.x(已废弃)
+db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
+ return db.Where("amount > ?", 100)
+}).Find(&users)
+
+// v2.x(推荐 — 用第二个 Preload 调用传递选项)
+db.Preload("Orders", "amount > ?", 100).Find(&users)
+```
## Joins — 单次 JOIN 查询
@@ -209,6 +222,8 @@ db.Joins("Profile", "profiles.status = ?", "active").First(&user, 1)
## HasOne 详细示例
+HasOne 表示「一条对应一条」的关系,GORM 通过外键在被关联方查找唯一记录。
+
```go
// 查询用户的 Profile
var user User
@@ -223,8 +238,12 @@ db.Create(&User{
// INSERT INTO users ...; INSERT INTO profiles ...
```
+上面代码展示了两件事:一是用 `Preload` 加载后可以直接访问嵌套字段;二是 GORM 的 `Create` 会自动级联写入关联的记录——先插入主表再插入从表,你不需要手动分别调用两次 `Create`。
+
## HasMany 详细示例
+HasMany 表示「一条对应多条」的关系,返回的是一个切片。注意 `Association("Orders").Count()` 统计的是关联记录的条数,而非业务意义上的「总和」。
+
```go
// 查询用户的所有订单
var user User
@@ -240,8 +259,12 @@ db.Model(&user).Association("Orders").Count(&total) // 这会统计行数,不
db.Model(&user).Select("COALESCE(SUM(amount), 0)").Scan(&total)
```
+这里特意展示了一个常见误区:`Association(...).Count()` 做的是 `COUNT(*)` 而非聚合计算。如果需要求和、平均等统计,应该走 `Model(&user).Select("SUM(amount)")` 的标准查询方式。
+
## BelongsTo 详细示例
+BelongsTo 表示「属于」关系,外键在当前方(与 HasOne 相反)。加载时使用 `Preload`,更新时直接修改外键字段。
+
```go
// 查询订单所属的用户
var order Order
@@ -253,8 +276,12 @@ order.UserID = 2
db.Save(&order) // UPDATE orders SET user_id=2 WHERE id=1
```
+这里注意:BelongsTo 的外键在**当前模型**的表里(即 `orders.user_id`),而不是在 Users 表里。这决定了 GORM 生成的是 `SELECT * FROM users WHERE id = ?` 来反向填充 User 字段。
+
## ManyToMany 详细示例
+ManyToMany 是四种关联中最复杂的一种——GORM 自动创建一个中间表(join table)来维护关系。你可以像操作普通字段一样通过 `Association` API 增删改查。
+
```go
// 查询订单包含的商品
var order Order
@@ -288,6 +315,8 @@ db.Model(&order).Association("Products").Clear()
count, _ := db.Model(&order).Association("Products").Count()
```
+这段代码展示了 Association API 的五个核心操作:**追加**(Append)、**替换**(Replace)、**删除**(Delete)、**清空**(Clear)和**计数**(Count)。其中 Replace 会先清除旧关联再写入新数据,适合「重新赋值」的场景;而 Append 只新增不删除,适合累积添加。
+
> [!important] Association API 一览
>
> | 方法 | 行为 | SQL 效果 |
@@ -302,18 +331,18 @@ count, _ := db.Model(&order).Association("Products").Count()
```mermaid
flowchart TD
- Start[需要加载关联数据] --> Depth{关联层级_}
+ Start["需要加载关联数据"] --> Depth{"是否多层级联"}
- Depth --> |单层浅查询| SimpleQ{数据量大吗_}
- SimpleQ --> |否| JoinsSingle["Joins — 单条 SQL"]
- SimpleQ --> |是| PreloadSingle["Preload — 多条小 SQL"]
+ Depth --> |否,单层浅查询| SimpleQ{"数据量大吗"}
+ SimpleQ --> |否| JoinsSingle["Joins - 单条SQL"]
+ SimpleQ --> |是| PreloadSingle["Preload - 多条小SQL"]
- Depth --> |多层级联| MultiQ{每层数据量_}
- MultiQ --> |都很小| DeepJoins["嵌套 Joins"]
- MultiQ --> |某层较大| DeepPreload["Preload 链式 + 条件过滤"]
- MultiQ --> |不清楚| PreloadSafe["Preload(安全首选)"]
+ Depth --> |是,多层级联| MultiQ{"每层数据量"}
+ MultiQ --> |都很小| DeepJoins["嵌套Joins"]
+ MultiQ --> |某层较大| DeepPreload["Preload链式加条件过滤"]
+ MultiQ --> |不清楚| PreloadSafe["Preload安全首选"]
- Depth --> |只需要计数/特定字段| SpecificField["Selection 或 Count"]
+ Depth --> |只需要计数或特定字段| SpecificField["Selection或Count"]
style Start fill:#4FC08D,color:#fff
style PreloadSafe fill:#3B82F6,color:#fff
diff --git a/hhs/GORM/06-排序与分页.md b/hhs/GORM/06-排序与分页.md
index 2324311..7f46f1c 100644
--- a/hhs/GORM/06-排序与分页.md
+++ b/hhs/GORM/06-排序与分页.md
@@ -136,25 +136,32 @@ totalPages := int(math.Ceil(float64(total) / float64(perPage)))
传统的 `LIMIT/OFFSET` 分页在深页性能急剧下降。游标分页通过「记住上一页最后一条记录的位置」来实现 O(log n) 的跳转:
```go
+type User struct {
+ ID uint
+ Name string
+ Age int
+ CreatedAt time.Time
+}
+
// 第一页:没有 cursor,直接取前 N 条
-cursor := "" // 空表示从头开始
+cursor := uint(0) // 用主键类型更严谨
perPage := 20
var users []User
-query := db.Model(&User{}).Order("id ASC").Limit(perPage + 1) // 多取 1 条判断是否有下一页
+q := db.Model(&User{}).Order("id ASC").Limit(perPage + 1) // 多取 1 条判断是否有下一页
-if cursor != "" {
+if cursor != 0 {
// 查找 id > cursor 的记录
- query = query.Where("id > ?", cursor)
+ q = q.Where("id > ?", cursor)
}
-err := query.Find(&users).Error
+err := q.Find(&users).Error
hasNextPage := false
if len(users) > perPage {
hasNextPage = true
- users = users[:perPage] // 去掉多余的「探测」记录
- cursor = fmt.Sprint(users[len(users)-1].ID) // 提取新的 cursor
+ users = users[:perPage] // 去掉多余的「探测」记录
+ cursor = users[len(users)-1].ID // 提取新的 cursor
}
```
@@ -177,7 +184,7 @@ type PageResult struct {
PageSize int `json:"page_size"`
}
-func Paginate[T any](db *gorm.DB, where any, order string, page, pageSize int) (*PageResult, error) {
+func Paginate[T any](db *gorm.DB, where any, args ...any, order string, page, pageSize int) (*PageResult, error) {
if page < 1 {
page = 1
}
@@ -188,7 +195,7 @@ func Paginate[T any](db *gorm.DB, where any, order string, page, pageSize int) (
var total int64
q := db.Model(new(T))
if where != nil {
- q = q.Where(where)
+ q = q.Where(where, args...)
}
if err := q.Count(&total).Error; err != nil {
return nil, err
@@ -196,7 +203,7 @@ func Paginate[T any](db *gorm.DB, where any, order string, page, pageSize int) (
var items []T
offset := (page - 1) * pageSize
- q.Order(order)
+ q = q.Order(order)
if err := q.Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
return nil, err
}
@@ -209,10 +216,49 @@ func Paginate[T any](db *gorm.DB, where any, order string, page, pageSize int) (
}, nil
}
-// 使用示例
+// 无过滤条件
+result, err := Paginate[User](db, nil, "created_at DESC", page, 20)
+
+// 参数化条件查询
result, err := Paginate[User](db, "status = ?", "active", "created_at DESC", page, 20)
+
+// 结构体条件(GORM 自动处理字段名)
+result, err := Paginate[User](db, User{Status: "active"}, "created_at DESC", page, 20)
```
+## HTTP Handler 中的分页
+
+在实际项目中,分页参数通常来自 URL query string。以 Gin 框架为例:
+
+```go
+func ListUsers(c *gin.Context) {
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
+
+ // 白名单校验排序字段
+ order := "created_at DESC"
+ if field := c.Query("order_by"); field != "" {
+ if allowedOrder[field] {
+ order = field + " DESC"
+ }
+ }
+
+ result, err := Paginate[User](c.MustGet("db").(*gorm.DB), nil, order, page, pageSize)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(200, result)
+}
+// GET /users?page=2&page_size=10&order_by=name
+```
+
+> [!tip] 前端推荐参数名
+> - `page`:页码(从 1 开始)
+> - `page_size`:每页条数
+> - `order_by`:排序字段(配合后端白名单)
+
## DefaultPageSize 配置
GORM 提供了 `DefaultPageSize` 配置项作为全局兜底:
@@ -235,22 +281,24 @@ db.Preload("Orders", func(db *gorm.DB) *gorm.DB {
```mermaid
flowchart TD
- Start[需要分页/排序数据] --> SortQ{需要自定义排序_}
- SortQ --> |否| DefaultSort["ORDER BY 主键 ASC
GORM 默认排序"]
- SortQ --> |是| Whitelist{"字段在白名单中?"}
- Whitelist --> |是| CustomSort["db.Order(field + direction)"]
- Whitelist --> |否| DefaultSort
-
- DefaultSort --> PagesQ{要跳页还是滚动?}
+ Start["开始:需要分页/排序数据"] --> SortQ{"需要自定义排序?"}
+
+ SortQ -->|"否"| DefaultSort["ORDER BY 主键 ASC
GORM 默认排序"]
+ SortQ -->|"是"| Whitelist{"字段在白名单中?"}
+
+ Whitelist -->|"是"| CustomSort["db.Order(field + direction)"]
+ Whitelist -->|"否"| DefaultSort
+
+ DefaultSort --> PagesQ{"要跳页还是滚动加载?"}
CustomSort --> PagesQ
-
- PagesQ --> |跳页| OffsetPaginate["LIMIT/OFFSET 分页
简单直白"]
- PagesQ --> |滚动加载| CursorPaginate["游标分页
性能好、防漏数据"]
-
- OffsetPaginate --> DeepPage{是否深翻页?}
- DeepPage --> |浅页 ≤ 100| OK["直接用 ✓"]
- DeepPage --> |深页 > 100| CursorRecommend["推荐改用游标分页"]
-
+
+ PagesQ -->|"跳页"| OffsetPaginate["LIMIT / OFFSET 分页
简单直白,适合浅翻页"]
+ PagesQ -->|"滚动加载"| CursorPaginate["游标分页
性能好、防漏数据"]
+
+ 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
diff --git a/hhs/GORM/07-子查询与分组.md b/hhs/GORM/07-子查询与分组.md
index 514af7a..040e2c5 100644
--- a/hhs/GORM/07-子查询与分组.md
+++ b/hhs/GORM/07-子查询与分组.md
@@ -9,11 +9,19 @@ create time: 2026-04-28 00:00
当简单的单表查询无法满足需求时,就需要用到**子查询**(SQL 里嵌套的 SELECT)和**分组聚合**(GROUP BY + HAVING)。这两类查询通常用于统计分析——比如「找出每个部门收入最高的员工」或「统计过去一个月每天的新增订单数」。
+核心要理解的是:**GROUP BY 把多行合并成一行,HAVING 从这些合并后的结果中筛出感兴趣的分组,子查询则在查询内部再套一层查询来间接获取数据。**
+
+> [!question] 思考题
+>
+> 假设你要找「最近一次下单金额超过 500 的用户」——这个需求需要哪种技术?
+>
+> > **分析**:需要先对每个用户按时间排序找出最后一次下单(可以用相关子查询或 `ROW_NUMBER()`),然后再过滤金额。这实际上涉及了前面提到的两种技术的组合使用——后面的「实用统计模式」章节会覆盖更多这类复合场景。
+
```mermaid
flowchart TD
- Start[复杂查询需求] --> Type{哪种场景_}
+ Start["需要复杂查询"] --> Type{"按维度汇总?"}
- Type --> |按维度汇总| GroupBy["GROUP BY + 聚合函数
COUNT / SUM / AVG / MAX / MIN"]
+ Type --> |是| GroupBy["GROUP BY + 聚合函数
COUNT / SUM / AVG / MAX / MIN"]
Type --> |过滤聚合结果| Having["HAVING 条件
替代 WHERE"]
Type --> |嵌套查询| SubQ{"子查询类型?"}
SubQ --> |IN/EXISTS 过滤| FilterSub["WHERE field IN (SELECT ...)"]
@@ -54,11 +62,12 @@ db.Model(&User{}).
```go
// 按部门和月份统计活跃用户数
type MonthlyDeptStats struct {
- DeptID uint `gorm:"column:dept_id"`
- Month string `gorm:"column:month"`
+ DeptID uint `gorm:"column:dept_id"`
+ Month string `gorm:"column:month"`
ActiveCount int64 `gorm:"column:active_count"`
}
+var monthlyStats []MonthlyDeptStats
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')").
@@ -66,6 +75,8 @@ db.Model(&User{}).
Scan(&monthlyStats)
```
+Group 接受逗号分隔的多个表达式,GORM 会原样拼接到 SQL 的 `GROUP BY` 后面。当分组字段包含函数(如 `DATE_FORMAT`)时,注意 **Select 和 Group 里的函数必须完全一致**——否则数据库可能报语法错误或分组不准确。
+
> [!tip] Group 的陷阱
> GORM 在调用 `Group` 后**默认不再添加任何其他字段**到 SELECT——这是 SQL 标准要求。如果需要额外字段,必须在 `Select` 中显式声明:
> ```go
@@ -76,6 +87,10 @@ db.Model(&User{}).
> db.Model(&User{}).Select("dept_id, MAX(age)").Group("dept_id").Scan(&stats)
> ```
+> [!question] Select 和 Group 字段可以不一样吗?
+>
+> > **答案**:可以不一样,但必须遵守 SQL 规则——GROUP BY 里的每个字段都必须是「函数依赖」于 Group 参数的。换句话说,**Group 里的字段可以不出现在 Select 里**(你不想展示它),但 **Select 里的非聚合字段必须出现在 Group 里**。否则 MySQL 5.7+(开启 ONLY_FULL_GROUP_BY)会直接报错。
+
## HAVING — 过滤分组结果
`WHERE` 过滤的是行级别,`HAVING` 过滤的是**分组后的聚合结果**。两者的执行顺序是 `WHERE → GROUP BY → HAVING`:
@@ -104,7 +119,7 @@ db.Model(&User{}).
Scan(&qualifiedDepts)
```
-> [!example] WHERE vs HAVING 的核心区别
+> [!info] WHERE vs HAVING 的核心区别
| 特性 | WHERE | HAVING |
|------|-------|--------|
@@ -113,6 +128,10 @@ db.Model(&User{}).
| 执行时机 | GROUP BY 之前 | GROUP BY 之后 |
| 性能差异 | 更优(提前过滤减少分组数据量) | 较次(需先完成分组再过滤) |
+> [!question] 为什么 WHERE 里不能用 COUNT(*)?
+>
+> > SQL 的执行顺序是 `FROM → WHERE → GROUP BY → HAVING → SELECT`——当数据库解析到 WHERE 时,分组都还没发生,COUNT(*) 还没有意义。所以像「找出订单数超过 5 个的用户」这种需求,必须拆成两步:先用 WHERE 过滤基础行,GROUP BY 分组统计,最后用 HAVING 过滤聚合结果。
+
> [!important] HAVING 的简洁写法
> 如果你只需要最简单的 HAVING 条件(如 `HAVING COUNT(*) > 5`),也可以直接写字符串:
> ```go
@@ -122,11 +141,11 @@ db.Model(&User{}).
## 子查询
-GORM 提供了几种方式来构建子查询。
+GORM 提供了几种方式来构建子查询。核心思路是:**先创建一个独立的查询对象(subQuery),然后把它作为参数传入主查询的某个位置**。
### 派生表子查询(From Subquery)
-将子查询作为一个临时表放入 FROM 子句:
+将子查询作为一个临时表放入 FROM 子句或 WHERE 条件:
```go
// 找出年龄大于全体员工平均年龄的用户
@@ -137,6 +156,9 @@ db.Where("age > (?)", avgAgeSubQuery).Find(&users)
// SELECT * FROM users WHERE age > (SELECT AVG(age) FROM users);
```
+> [!tip] `(?)` 占位符的用法
+> 这里的 `(?)` 是一个占位符——外层括号确保子查询整体被包裹在圆括号中(SQL 语法要求),问号 `?` 让 GORM 知道这里要插入的是一个子查询对象,而不是普通参数。
+
### IN 子查询
```go
@@ -149,7 +171,10 @@ db.Where("id IN (?)", orderSubQuery).Find(&users)
```
> [!tip] 为什么用 DISTINCT?
-> 一个用户可能有多个订单,如果不加 DISTINCT,子查询会返回重复的 user_id。虽然 `IN (...)` 能自动去重,但加上 DISTINCT 可以让数据库优化器更高效地处理。
+> 一个用户可能有多个订单,如果不加 DISTINCT,子查询会返回重复的 user_id。虽然 `IN (...)` 能自动去重,但加上 DISTINCT 可以让数据库优化器更高效地处理——尤其是在内表数据量很大时。
+
+> [!warning] 避免超大 IN 列表
+> 当子查询结果超过数万条时,`IN (1, 2, 3, ...)` 会导致 SQL 语句超长,可能触发 MySQL 的 `max_allowed_packet` 限制。此时应改用 JOIN 或临时表方案。
### EXISTS 子查询
@@ -167,21 +192,29 @@ db.Where("EXISTS (?)",
> - **外表大、内表小** → 用 IN,子查询先查好再用
> - **外表小、内表大** → 用 EXISTS,找到一条就停止扫描
> - 在实际生产中,MySQL 优化器通常能自动选择最优方案,不用太纠结
+> - 但要注意:**EXISTS 的子查询里不能做聚合**(如 COUNT、SUM),而 IN 可以配合子查询中的聚合一起用
+> - EXISTS 语义是「是否存在」——只要内层查到任意一行就立即返回 true,不做全表扫描;IN 则是把所有结果集加载后再匹配。
### 相关子查询(Correlated Subquery)
-内层查询引用外层表的列,每一行都重新执行一次内层查询:
+内层查询引用外层表的列,每一行都重新执行一次内层查询。注意这里的 `users.id` 引用了外层表——数据库无法先执行子查询,必须**逐行扫描外层表并重新计算内层**。
```go
-// 对每个用户,查出他的最新订单
+// 对每个用户,查出他的最新订单日期
var users []User
db.Select(`*,
- (SELECT created_at FROM orders
- WHERE user_id = users.id
+ (SELECT created_at FROM orders
+ WHERE user_id = users.id
ORDER BY created_at DESC LIMIT 1) as latest_order_date`).
Find(&users)
```
+上面的 SQL 中,`(SELECT created_at FROM orders WHERE user_id = users.id ...)` 的每一行都会用到外层 `users.id` 的值来过滤——这就是「相关」的含义:内层和外层相互关联。
+
+> [!question] 这个子查询在什么情况下性能最差?
+>
+> > **分析**:当用户表和订单表都是百万级数据时,每条用户记录都要单独跑一次子查询(n × m 复杂度)。如果用户的最新订单恰好排在订单表的最后面,数据库甚至要做全表扫描。这种情况下应该改用窗口函数 `ROW_NUMBER()` 或 LEFT JOIN + GROUP BY 的方案——这在下文「实用统计模式」中有覆盖。
+
> [!warning] 相关子查询的性能警告
> 相关子查询每处理一行外层数据就要执行一次内层查询——复杂度接近 O(n × m)。如果数据量大,建议改写为 JOIN + GROUP BY:
> ```go
@@ -194,15 +227,18 @@ db.Select(`*,
## 实用统计模式
+这部分收集了生产中最常遇到的几种查询场景——从简单的按月统计到更复杂的全站 Top N 和条件聚合。每种模式都附带 GORM 的具体实现。
+
### 按月趋势统计
```go
type DailyOrderStat struct {
- Day string `gorm:"column:day"`
- OrderNum int64 `gorm:"column:order_num"`
- TotalAmt float64 `gorm:"column:total_amt"`
+ Day string `gorm:"column:day"`
+ OrderNum int64 `gorm:"column:order_num"`
+ TotalAmt float64 `gorm:"column:total_amt"`
}
+var dailyStats []DailyOrderStat
db.Model(&Order{}).
Select("DATE(created_at) as day, COUNT(*) as order_num, COALESCE(SUM(amount), 0) as total_amt").
Group("DATE(created_at)").
@@ -210,14 +246,16 @@ db.Model(&Order{}).
Scan(&dailyStats)
```
+注意 `COALESCE(SUM(amount), 0)` 的作用:如果某天没有任何订单,`SUM(amount)` 返回 NULL(而非 0),`COALESCE` 将其转换为 0,方便前端图表直接渲染。
+
### TOP N 问题
```go
// 找出消费金额最高的前 10 名用户
var topUsers []struct {
- UserID uint
- UserName string
- TotalSpent float64
+ UserID uint `json:"user_id"`
+ UserName string `json:"user_name"`
+ TotalSpent float64 `json:"total_spent"`
}
db.Table("users u").
@@ -229,7 +267,9 @@ db.Table("users u").
Scan(&topUsers)
```
-### CASE WHEN 条件聚合
+这里的关键是 `Group("u.id, u.name")`——MySQL 中非主键字段也需要加入 Group,否则在开启 `ONLY_FULL_GROUP_BY` 的严格模式下会报错。另外要注意,`LIMIT 10` 是在 GROUP BY 之后生效的,所以它限制的是「聚合后的组数」,而不是「每个组的行数」。
+
+### CASE WHEN 条件聚合(透视表)
```go
// 按状态统计订单数量
@@ -241,7 +281,7 @@ type StatusCount struct {
var sc StatusCount
db.Raw(`
- SELECT
+ 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
@@ -249,17 +289,55 @@ db.Raw(`
`).Scan(&sc)
```
+这种模式称为**行转列**(Pivot)——把原本多行的状态值变成一行中的多个列。适合做仪表盘概览。如果需要同时统计金额,可以在 SUM 内部嵌套条件:
+
+```sql
+-- 同时统计数量和金额
+SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_count,
+SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) as completed_amount
+```
+
+### ROW_NUMBER 取每组最大值
+
+这是最经典的「分组后取第一条」需求——比如「找每个部门收入最高的员工」。虽然这也可以用相关子查询解决,但用窗口函数效率更高:
+
+```go
+// 找到每个部门收入最高的员工
+type MaxSalaryDept struct {
+ DeptID uint `gorm:"column:dept_id"`
+ EmpName string `gorm:"column:emp_name"`
+ Salary float64 `gorm:"column:salary"`
+}
+
+var results []MaxSalaryDept
+db.Raw(`
+ SELECT dept_id, emp_name, salary
+ FROM (
+ SELECT dept_id, emp_name, salary,
+ ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) as rn
+ FROM employees
+ ) ranked
+ WHERE rn = 1
+`).Scan(&results)
+```
+
+核心思路:内层用 `ROW_NUMBER()` 为每个部门的员工按薪资排序并编号(最高薪为 rn=1),外层只取 rn=1 的记录。相比相关子查询,这种方式数据库只需扫描一次 employees 表,性能明显更好。
+
+> [!note] MySQL 版本兼容性
+> - **MySQL 8.0+** / PostgreSQL / SQLite 3.25+ 原生支持窗口函数(ROW_NUMBER、RANK 等)
+> - **MySQL 5.7 及以下**不支持窗口函数,需要退回相关子查询或自连接方案
+
## 子查询决策图
```mermaid
flowchart TD
- Start[需要子查询] --> Pattern{查询模式_}
+ Start["需要子查询"] --> Pattern{"查询模式"}
- Pattern --> |聚合统计| GroupByQ{是否要过滤聚合结果?}
+ Pattern --> |聚合统计| GroupByQ{"是否需要过滤聚合结果"}
GroupByQ --> |不需要| BasicGroup["GROUP BY + Select 聚合函数"]
GroupByQ --> |需要| HavingCheck["GROUP BY + HAVING"]
- Pattern --> |条件过滤| FilterType{"IN 还是 EXISTS?"}
+ Pattern --> |条件过滤| FilterType{"IN 还是 EXISTS"}
FilterType --> |精确匹配值集合| InSub["WHERE col IN (SELECT ...)"]
FilterType --> |存在性判断| ExistsSub["WHERE EXISTS (SELECT ...)"]
@@ -269,17 +347,22 @@ flowchart TD
style HavingCheck fill:#3B82F6,color:#fff
style InSub fill:#F59E0B,color:#000
style FromSub fill:#EC4899,color:#fff
+ style ExistsSub fill:#3B82F6,color:#fff
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
-| Group 后字段丢失 | 未在 Select 中声明所需字段 | 显式列出所有 SELECT 字段 |
+| 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+ |
+| MySQL 5.7 ONLY_FULL_GROUP_BY | 非聚合字段不能在 SELECT 中出现 | 确保 SELECT 中的非聚合字段都在 GROUP BY 里 |
+| IN 子查询结果过大 | 内表数据量太大导致 SQL 超长 | 改用临时表、JOIN 或分批查询 |
+| Preload 后关联为空 | 关联字段名不匹配 struct 标签 | 检查 `foreignKey` / 关联名的拼写和大小写 |
+| SubQuery 嵌套层级太深 | 数据库优化器对深层嵌套处理不佳 | 拆分为多个简单查询,用 Go 代码组装 |
+| COALESCE 返回类型不一致 | SUM 可能返回 NULL,与 int64 字段不兼容 | 始终用 `COALESCE(SUM(...), 0)` 兜底 |
## 关联笔记
diff --git a/hhs/GORM/08-事务管理.md b/hhs/GORM/08-事务管理.md
index 50129f6..637d256 100644
--- a/hhs/GORM/08-事务管理.md
+++ b/hhs/GORM/08-事务管理.md
@@ -12,13 +12,13 @@ create time: 2026-04-28 00:00
```mermaid
flowchart TD
Start[开始事务 tx = db.Begin] --> Op1["执行操作 1"]
- Op1 --> OK1{"操作 1 成功?"}
+ Op1 --> OK1{"操作 1
成功?"}
OK1 --> |是| Op2["执行操作 2"]
OK1 --> |否| Rollback["tx.Rollback()"]
- OK2{"操作 2 成功?"} --> |是| Op3["执行操作 3"]
- Op2 --> OK2
+ Op2 --> OK2{"操作 2
成功?"}
+ OK2 --> |是| Op3["执行操作 3"]
OK2 --> |否| Rollback
- Op3 --> OK3{"操作 3 成功?"}
+ Op3 --> OK3{"操作 3
成功?"}
OK3 --> |是| Commit["tx.Commit()"]
OK3 --> |否| Rollback
@@ -72,22 +72,57 @@ return nil
> [!warning] defer Rollback 的注意事项
> `Commit()` 成功后再执行 `defer Rollback()` 会报错(事务已提交)。所以推荐**显式处理错误路径的回滚**,`defer` 只用于兜底 panic。
-### 简洁写法(Err 链式判断)
+### GORM Transaction() 便捷方法
+
+手动写 `Begin` + `Commit/Rollback` 很繁琐,GORM 提供了 `db.Transaction()` —— 它接受一个回调函数,内部自动处理开启、提交和回滚:
```go
-tx := db.Begin()
-if err := tx.Error; err != nil {
- return err
-}
-
-// ... 操作 ...
-
-if err := tx.Commit().Error; err != nil {
- tx.Rollback()
- return err
-}
+// 一行代码搞定事务:GORM 自动管理生命周期
+err := db.Transaction(func(tx *gorm.DB) error {
+ // tx 已经处于事务中,直接操作即可
+
+ var fromAccount Account
+ if err := tx.First(&fromAccount, fromID).Error; err != nil {
+ return err // 返回 error → GORM 自动 Rollback
+ }
+
+ fromAccount.Balance -= amount
+ if err := tx.Save(&fromAccount).Error; err != nil {
+ return err // ← 同样自动 Rollback
+ }
+
+ // 返回 nil → GORM 自动 Commit
+ return nil
+})
```
+> [!tip] 为什么推荐 Transaction()?
+> - **自动清理**:回调内任何非 nil 的返回值都会触发 Rollback,不需要手动写
+> - **回调内的事务是同一个 tx**:在回调里不能再调 `Transaction()`,否则 panic
+> - **无法直接访问 tx 实例**:出了闭包就拿不到 tx 了,天然防止"事务泄漏"
+>
+> > 💡 思考题:如果回调中需要向外层传递某个计算结果(比如新生成的订单号),该怎么设计?
+> >
+> > 答案:将结果放入闭包捕获的外部变量,或在回调内 `Create` 后从数据库 `First` 取出。
+> > ```go
+> > var orderID uint
+> > err := db.Transaction(func(tx *gorm.DB) error {
+> > order := Order{Amount: 99.9}
+> > if err := tx.Create(&order).Error; err != nil {
+> > return err
+> > }
+> > orderID = order.ID // 通过闭包变量传出
+> > return nil
+> > })
+> > ```
+
+> [!warning] Transaction() vs 手动 Begin()
+> | 场景 | 推荐方式 |
+> |------|---------|
+> | 单个函数内的多步操作 | `db.Transaction()` 回调 |
+> | 跨函数/跨包的分布式事务 | 手动 `db.Begin()` 并传入 `*gorm.DB` |
+> | Web 请求级自动事务 | 中间件 + `defer` 模式 |
+
> [!tip] 理解 tx.Transaction 返回值
> GORM 的 `Begin()` 返回 `*gorm.DB`,但内部包装了事务。这个实例只在事务范围内有效,不能跨事务使用。
@@ -136,16 +171,23 @@ func TransferMoney(fromID, toID uint, amount float64) error {
```
> [!important] FOR UPDATE 锁行
-> 事务中的查询如果要用更新的数据做决策,必须用 `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)
+> account.Balance -= 100
+> db.Save(&account)
> ```
+>
+> > 💡 **思考题**:`FOR UPDATE` 在所有数据库中都叫这个名字吗?
+> > PostgreSQL 中同样使用 `SELECT ... FOR UPDATE`;但 SQLite 在事务期间默认就是串行执行的,不需要显式加锁。理解底层数据库的行为,才能写出可移植的代码。
## 嵌套事务
@@ -220,6 +262,123 @@ db.Set("gorm:prepare_stmt", true).Find(&users) // 预编译语句
| Repeatable Read(MySQL 默认) | ✅ 阻止 | ✅ 阻止 | ⚠️ 部分阻止 | 中等 |
| Serializable | ✅ 阻止 | ✅ 阻止 | ✅ 阻止 | 最慢 |
+## 事务与连接管理
+
+理解事务底层的连接行为,是写出高性能代码的关键。一个常被忽视的事实:**每个事务都会从连接池中独占一根连接**——这意味着高并发下连接池配置直接影响吞吐量。
+
+```go
+// 合理配置连接池参数
+db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
+db.DB().SetMaxOpenConns(100) // 最大连接数(含事务)
+db.DB().SetMaxIdleConns(20) // 空闲连接保留数
+db.DB().SetConnMaxLifetime(time.Hour)
+```
+
+> [!definition] 事务的连接行为
+
+| 关键点 | 说明 |
+|--------|------|
+| **独占连接** | 一个事务从连接池中取出一个连接,整个事务期间该连接归它独有 |
+| **并发限制** | 同一个 `*gorm.DB` 在同一时刻只能有一个活跃事务 |
+| **连接归还** | `Commit()` 或 `Rollback()` 后,连接才回到连接池(而非真正关闭) |
+| **MaxIdleConns 影响** | 高并发场景下如果 `MaxIdleConns` 太小,大量事务会排队等待连接 |
+
+> [!warning] 长事务是最常见的性能杀手
+>
+> 以下做法会把连接占用时间拖得很长:
+> ```go
+> tx := db.Begin()
+>
+> // ❌ 耗时操作放在事务内!其他事务可能要等这个锁很久
+> resp, _ := http.Get("http://external-api.com/validate")
+> tx.Create(&record)
+> tx.Commit()
+>
+> // ✅ 先获取外部数据,再开启事务
+> resp, _ := http.Get("http://external-api.com/validate")
+> tx := db.Begin()
+> tx.Create(&record)
+> tx.Commit()
+> ```
+>
+> **排查建议**:通过数据库监控观察 `information_schema.innodb_trx`(MySQL),找出运行时间过长的事务。
+
+### 手动管理事务中的错误回滚
+
+在实际项目中,你可能需要一个更简洁的错误处理模式来减少重复代码:
+
+```go
+// 通用的事务执行器
+func ExecTx(db *gorm.DB, fn func(tx *gorm.DB) error) error {
+ tx := db.Begin()
+ defer func() {
+ if r := recover(); r != nil {
+ tx.Rollback()
+ panic(r)
+ }
+ }()
+
+ if err := fn(tx); err != nil {
+ tx.Rollback()
+ return err
+ }
+ return tx.Commit().Error
+}
+
+// 使用方式
+err := ExecTx(db, func(tx *gorm.DB) error {
+ var fromAccount Account
+ if err := tx.Set("gorm:query_option", "FOR UPDATE").First(&fromAccount, fromID).Error; err != nil {
+ return fmt.Errorf("查询账户失败: %w", err)
+ }
+ if fromAccount.Balance < amount {
+ return errors.New("余额不足")
+ }
+ fromAccount.Balance -= amount
+ if err := tx.Save(&fromAccount).Error; err != nil {
+ return fmt.Errorf("扣款失败: %w", err)
+ }
+ // ... 入账逻辑
+ return nil
+})
+```
+
+> [!tip] 封装的代价与收益
+> - **收益**:消除每个函数里重复的 Begin/Rollback 样板代码,错误路径统一由 ExecTx 处理
+> - **代价**:无法在闭包外拿到 tx,调试时 log 某个中间状态稍不方便
+> - **建议**:简单场景用 `db.Transaction()`,复杂业务可考虑 `ExecTx` 封装
+
+## 什么时候不该用事务?
+
+过度使用事务是常见的性能陷阱。以下场景中你可以选择不使用事务:
+
+> [!tip] 免事务场景清单
+
+| 场景 | 理由 | 替代方案 |
+|------|------|---------|
+| 单条 `INSERT / UPDATE / DELETE` | 本身就是原子的 | 直接调用即可 |
+| 统计查询(COUNT、SUM) | 只读不涉及数据修改 | 普通查询 + 缓存 |
+| 写多读的流水表 | 追加即成功,无需原子性 | 批量 `CreateInBatches` |
+| 跨服务的分布式操作 | 涉及多个独立数据库 | Saga / Outbox Pattern |
+
+> 💡 **思考题**:「扣库存」场景一定要用事务吗?
+>
+> 不一定。如果你的并发量不高,可以使用**乐观锁**——在库存表加一个 `version` 字段,更新时带上版本条件:
+> ```go
+> // 不用事务,靠版本号保证一致性
+> db.Model(&Product{}).
+> Where("id = ? AND stock >= ? AND version = ?", id, qty, product.Version).
+> Updates(map[string]any{
+> "stock": gorm.Expr("stock - ?", qty),
+> "version": gorm.Expr("version + 1"),
+> })
+> ```
+> 如果影响行数为 0,说明发生了冲突,重试即可。这在高并发读多写少的场景下比事务效率高得多。
+>
+> > 🔍 **延伸方向**:比较「悲观锁」(FOR UPDATE)vs「乐观锁」(version 字段)的适用场景。
+> > - 悲观锁:写竞争激烈、冲突率高时使用
+> > - 乐观锁:读多写少、冲突率低时使用
+
## 事务与中间件结合
在实际项目中,经常需要将事务与 Gin 等 Web 框架集成,实现自动事务管理:
@@ -270,8 +429,8 @@ func CreateUser(c *gin.Context) {
```mermaid
flowchart TD
- Start[需要多步数据操作] --> InTx{"是否在已有事务中?"}
- InTx --> |是| SaveQ{需要局部回滚能力_}
+ Start[需要多步数据操作] --> InTx{"是否在已有
事务中?"}
+ InTx --> |是| SaveQ{"需要局部
回滚能力?"}
InTx --> |否| NewTx{"操作数量?"}
NewTx --> |单条 SQL| SimpleTx["不需要事务
直接操作即可"]
@@ -285,8 +444,8 @@ flowchart TD
Savepoint --> Ops
Ops --> AllOK{"全部成功?"}
- AllOK --> |是| Commit["Commit"]
- AllOK --> |否| RollBack["Rollback"]
+ AllOK --> |是| Commit["Commit()"]
+ AllOK --> |否| RollBack["Rollback()"]
style Start fill:#4FC08D,color:#fff
style Commit fill:#3B82F6,color:#fff
@@ -298,11 +457,14 @@ flowchart TD
| 问题 | 原因 | 解决方案 |
|------|------|---------|
-| 忘记 Commit/Rollback | 漏了错误分支的回滚逻辑 | 对所有错误分支显式调用 Rollback |
+| 忘记 Commit/Rollback | 漏了错误分支的回滚逻辑 | 对所有错误分支显式调用 Rollback,或用 `db.Transaction()` 回调 |
| 事务内查询被其他事务修改 | 没加 FOR UPDATE | 关键查询加 `Set("gorm:query_option", "FOR UPDATE")` |
| 嵌套事务外层的 Commit 失效 | 内部使用了独立连接 | 始终复用同一个 `*gorm.DB` 对象 |
| 长事务导致行锁堆积 | 事务中包含耗时操作(RPC、HTTP) | 将耗时操作移出事务范围 |
| defer Rollback 导致已提交事务报错 | Commit 后再执行 deferred Rollback | 用 panic 兜底,正常路径不依赖 defer |
+| 连接池耗尽,请求卡死 | MaxOpenConns 太小或事务未释放 | 调大 MaxOpenConns,排查泄露的事务 |
+| 并发更新同一条记录覆盖数据 | 没做版本控制或行锁 | 用乐观锁(version)或悲观锁(FOR UPDATE) |
+| SavePoint 名称冲突 | 多层嵌套用了相同的保存点名 | 使用唯一命名:`fmt.Sprintf("sp_%d", time.Now().UnixNano())` |
## 关联笔记
diff --git a/hhs/GORM/09-钩子函数.md b/hhs/GORM/09-钩子函数.md
index 89ccef9..8389b8d 100644
--- a/hhs/GORM/09-钩子函数.md
+++ b/hhs/GORM/09-钩子函数.md
@@ -11,23 +11,38 @@ create time: 2026-04-28 00:00
```mermaid
flowchart TD
- A[Create 调用] --> BC["BeforeCreate"]
- BC --> CreateSQL["执行 INSERT"]
- CreateSQL --> AC["AfterCreate"]
+ subgraph CREATE["🟢 Create"]
+ A[Create 调用] --> BC["BeforeCreate"]
+ BC --> CreateSQL["执行 INSERT"]
+ CreateSQL --> AC["AfterCreate"]
+ end
- D[Update 调用] --> BU["BeforeUpdate"]
- BU --> UpdateSQL["执行 UPDATE"]
- UpdateSQL --> AU["AfterUpdate"]
+ subgraph UPDATE["🟡 Update"]
+ D[Update 调用] --> BU["BeforeUpdate"]
+ BU --> UpdateSQL["执行 UPDATE"]
+ UpdateSQL --> AU["AfterUpdate"]
+ end
- E[Delete 调用] --> BD["BeforeDelete"]
- BD --> DeleteSQL["执行 DELETE"]
- DeleteSQL --> AD["AfterDelete"]
+ subgraph DELETE["🔴 Delete"]
+ E[Delete 调用] --> BD["BeforeDelete"]
+ BD --> DeleteSQL["执行 DELETE"]
+ DeleteSQL --> AD["AfterDelete"]
+ end
- F[Find/First 调用] --> AF["AfterFind"]
+ subgraph FIND["🔵 Find/Query"]
+ F[Find/First 调用] --> AF["AfterFind"]
+ end
- style BC fill:#EAB308,color:#fff
- style BU fill:#F59E0B,color:#000
+ style CREATE fill:#22C55E,color:#fff,stroke:#16A34A
+ style UPDATE fill:#EAB308,color:#000,stroke:#CA8A04
+ style DELETE fill:#EF4444,color:#fff,stroke:#DC2626
+ style FIND fill:#3B82F6,color:#fff,stroke:#2563EB
+ style BC fill:#22C55E,color:#fff
+ style AC fill:#22C55E,color:#fff
+ style BU fill:#EAB308,color:#000
+ style AU fill:#EAB308,color:#000
style BD fill:#EF4444,color:#fff
+ style AD fill:#EF4444,color:#fff
style AF fill:#3B82F6,color:#fff
```
@@ -86,6 +101,11 @@ func (u *User) AfterUpdate(tx *gorm.DB) error {
}
```
+> [!tip] Changed vs Updated
+> - `tx.Statement.Changed("field")`:只要调用过 `Updates(map)` 并包含该字段,就返回 true(无论新旧值是否相同)
+> - `tx.Statement.Updated("field")`:返回值与 Changed 一致,但额外校验「新值 ≠ 旧值」
+> - 如果直接通过结构体赋值再调 `Save()` / `UpdateColumn()`,这些方法都**不会追踪变化**,需用 `Updates` 才有效
+
### 删除阶段
```go
@@ -151,16 +171,32 @@ func (u *User) BeforeUpdate(tx *gorm.DB) error {
| `Deleted()` | 是否是 Delete 操作 |
| `Updated(field)` | 字段是否被更新且值发生变化 |
-## 全局钩子 vs Model 级钩子
+### Model 级 vs 全局 Callback
GORM 支持两种注册方式,优先级为 **Model 级 > 全局**:
```go
-// Model 级钩子——写在 struct 的方法上(最常用)
+// Model 级钩子——实现 gorm.LifecycleHooks 接口(最常用)
type Order struct{}
func (Order) BeforeCreate(tx *gorm.DB) error { ... }
+```
-// 全局钩子——通过 Callback 注册,作用于所有模型
+> [!note] LifecycleHooks 接口
+> GORM v2 定义了以下接口,struct 只要方法签名匹配即自动注册为钩子:
+> - `BeforeCreate(tx *gorm.DB) error`
+> - `AfterCreate(tx *gorm.DB) error`
+> - `BeforeUpdate(tx *gorm.DB) error`
+> - `AfterUpdate(tx *gorm.DB) error`
+> - `BeforeDelete(tx *gorm.DB) error`
+> - `AfterDelete(tx *gorm.DB) error`
+> - `AfterFind(tx *gorm.DB) error`
+>
+> 你也可以使用 `*gorm.State` 替代 `*gorm.DB`(gint-gorm 兼容模式),但在大多数场景下 `*gorm.DB` 更通用。
+
+#### 全局 Callback —— 作用于所有模型
+
+```go
+// 在 Create SQL 执行之前注册自定义逻辑
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 {
@@ -169,14 +205,27 @@ db.Callback().Create().Before("gorm:create").Register("set_created_at", func(tx
}
})
-// 使用场景:给所有模型统一设置默认时间(测试用)
-db.Session(&gorm.Session{Context: ctx}).Create(&order)
+// 在 Delete 之后追加逻辑(不阻塞后续 callback)
+db.Callback().Delete().After("gorm:delete").Register("cleanup_cache", func(tx *gorm.DB) {
+ // 清理缓存等后置操作
+})
+
+// 替换 GORM 内置回调(谨慎使用!)
+db.Callback().Create().Replace("gorm:create", func(tx *gorm.DB) {
+ // 完全接管 Create 流程
+})
+
+// 移除内置回调
+db.Callback().Create().Remove("gorm:create")
```
> [!warning] 全局钩子注意事项
-> 全局钩子的注册时机必须在 `db.Open()` 之后、首次操作之前。而且全局钩子会影响**所有模型**——包括内置的 `gorm.Model`——使用时需格外小心。
+> 1. 注册时机必须在 `db.Open()` 之后、首次操作之前
+> 2. 会影响**所有模型**——包括内置的 `gorm.Model`
+> 3. 按阶段排序:`Before(name)` / `After(name)` / `Register(name, fn)` / `Replace(name, fn)` / `Remove(name)`
+> 4. 频繁读写数据库的全局逻辑会拖慢所有模型,建议在钩子内用 `tx.Get()` 做条件过滤
-## 钩子执行链示意图
+## 执行时序
```mermaid
sequenceDiagram
@@ -190,32 +239,98 @@ sequenceDiagram
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 脱敏污染了原始数据 | 直接修改结构体字段影响调用方 | 需要脱敏时在接口层处理,不要改原对象 |
+下面展示一个电商订单模型,综合运用多个钩子处理真实场景:
-## 钩子应用场景总结
+```go
+type Order struct {
+ gorm.Model
+ UserID uint `gorm:"not null;index"`
+ Amount decimal.Decimal
+ Status string // pending → paid → shipped → completed
+ PaidAt *time.Time // 支付时间,nil = 未支付
+ Version int // 乐观锁版本号
+ CreatedBy string // 创建人标识
+}
-| 场景 | 推荐钩子 | 说明 |
+func (o *Order) BeforeCreate(tx *gorm.DB) error {
+ // ① 初始化状态和审计字段
+ if o.Status == "" {
+ o.Status = "pending"
+ }
+ o.CreatedBy = getCurrentUserID() // 从 context 获取
+ o.Version = 1
+ return nil
+}
+
+func (o *Order) BeforeUpdate(tx *gorm.DB) error {
+ // ② 版本号递增 + 状态机校验
+ o.Version++
+
+ validTransitions := map[string][]string{
+ "pending": {"paid"},
+ "paid": {"shipped"},
+ "shipped": {"completed"},
+ }
+ newStatus := tx.Statement.Schema.ValueOfField(tx.Statement.Schema.FieldsByName["Status"])
+ allowed, ok := validTransitions[o.Status]
+ if !ok || !contains(allowed, newStatus.String()) {
+ return fmt.Errorf("非法状态转换: %s → %s", o.Status, newStatus)
+ }
+ return nil
+}
+
+func (o *Order) AfterUpdate(tx *gorm.DB) error {
+ // ③ 状态变更时发送通知
+ if old, ok := tx.Data.(*Order); ok && old.Status != o.Status {
+ sendNotification(o.UserID, "订单状态变更为: "+o.Status)
+ }
+ return nil
+}
+
+func (o *Order) AfterDelete(tx *gorm.DB) error {
+ // ④ 软删除后清理缓存
+ redis.Del(context.Background(), "order:"+strconv.Itoa(int(o.ID)))
+ return nil
+}
+
+func contains(slice []string, s string) bool {
+ for _, v := range slice {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+```
+
+> [!question] 为什么 AfterDelete 能拿到被删的数据?
+> 因为 GORM 的软删除是先查再标记 `deleted_at`,AfterDelete 钩子中仍然可以通过 `tx.Statement.ReflectValue` 访问到原始对象数据。
+
+## 常用场景速查
+
+| 需求 | 推荐方案 | 说明 |
|------|---------|------|
-| 自动填充 CreatedAt / UpdatedAt | `autoTime` tag 更简单 | 如果手动实现用 BeforeCreate / BeforeUpdate |
-| 密码哈希 | BeforeCreate + BeforeUpdate(仅当 Changed) | 避免重复哈希 |
+| 自动填充 CreatedAt / UpdatedAt | 用 `autoTime` tag 更简单 | 手动实现则放 BeforeCreate / BeforeUpdate |
+| 密码哈希 | BeforeCreate + BeforeUpdate(仅 Changed) | 避免重复哈希 |
| 乐观锁 | BeforeUpdate(检查 version) | 并发安全的经典方案 |
-| 审计日志 | AfterCreate / AfterUpdate / AfterDelete | 操作完成后异步记录 |
-| 数据脱敏 | AfterFind | 对外输出前格式化 |
+| 审计日志 | AfterCreate / AfterUpdate / AfterDelete | 操作完成后记录 |
+| 数据脱敏 | AfterFind | 对外输出前格式化(注意性能) |
| 关联清理 | BeforeDelete | 删除前解除外部引用 |
+| 全局默认值 | Callback.Register() | 作用于所有模型,需条件过滤 |
+
+## 最佳实践
+
+> [!checklist] 使用钩子时的 Checklist
+> 1. **永远用 tx 不用 db** — 钩子内部不要调用 `db.Create()` / `db.Raw()`,必须使用传入的 `tx` 参数,否则会导致死锁
+> 2. **AfterFind 中不要修改原对象** — 直接改字段会影响所有调用方;需要脱敏时在接口层另行处理
+> 3. **不要在钩子里发送 HTTP 请求或做耗时 IO** — 会阻塞主流程;改用 channel + goroutine 异步处理
+> 4. **Changed/Updated 只在 Updates 时有效** — Save 是全量写入、Select/Omit 不会触发追踪
+> 5. **批量操作也会逐条触发** — `Create(&[]Model{})` 每条都走钩子;如想跳过用 `db.Session(&gorm.Session{SkipHooks: true}).Create(...)`
+> 6. **钩子返回 nil != 没返回值** — Go 中空指针 nil 不等于 error 零值,中断操作必须 `return errors.New("原因")`
## 关联笔记
@@ -223,3 +338,4 @@ sequenceDiagram
- [[03-CRUD 操作]]
- [[08-事务管理]]
- [[11-批量操作]]
+- [[10-字段标签]](autoTime / autoCreateTime 等内置标签)
diff --git a/hhs/GORM/10-软删除.md b/hhs/GORM/10-软删除.md
index 8c0626d..bde640b 100644
--- a/hhs/GORM/10-软删除.md
+++ b/hhs/GORM/10-软删除.md
@@ -11,18 +11,18 @@ create time: 2026-04-28 00:00
```mermaid
flowchart TD
- A[调用 db.Delete(&user)] --> SoftDel{"模型有
DeletedAt 字段?"}
+ A["调用 db.Delete(&user)"] --> SoftDel["模型有 DeletedAt 字段?"]
- SoftDel --> |否| HardSQL["DELETE FROM users WHERE id = ?
⚠️ 物理删除,不可恢复"]
- SoftDel --> |是| UpdateSQL["UPDATE users SET deleted_at = NOW() WHERE id = ?
✅ 软删除,数据保留"]
+ SoftDel --> |否|"HardSQL[DELETE FROM users WHERE id = ? ⚠️ 物理删除,不可恢复]"
+ SoftDel --> |是|"UpdateSQL[UPDATE users SET deleted_at=NOW() WHERE id=? ✅ 保留数据]"
- UpdateSQL --> Query{常规查询?}
- Query --> |是| Filtered["WHERE deleted_at IS NULL
已删除记录自动隐藏"]
- Query --> |否| UnscopedQ{需要已删除数据?}
+ UpdateSQL --> Query{"常规查询?"}
+ Query --> |是| Filtered["WHERE deleted_at IS NULL\n已删除记录自动隐藏"]
+ Query --> |否| UnscopedQ["需要已删除数据?"]
UnscopedQ --> |是| IncludeAll["Unscoped() → 全部返回"]
UnscopedQ --> |否| Filtered
- style Start fill:#4FC08D,color:#fff
+ style SoftDel fill:#4FC08D,color:#fff
style Filtered fill:#3B82F6,color:#fff
style HardSQL fill:#EF4444,color:#fff
```
@@ -39,7 +39,7 @@ type User struct {
Name string `gorm:"size:64;not null"`
}
-// 等价的手动声明
+// 等价的手动声明(需 import "time")
type Product struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"size:128;not null"`
@@ -66,11 +66,16 @@ db.Delete(&user) // UPDATE users SET deleted_at=... WHERE id=?
db.Unscoped().Delete(&user) // DELETE FROM users WHERE id=?
// 彻底擦除,无法恢复!
-// ===== 根据条件批量物理删除 =====
+// ===== 根据条件批量物理删除(配合清理策略使用)=====
db.Unscoped().Where("deleted_at < ?", time.Now().AddDate(-90, 0, 0)).Delete(&User{})
-// 清理超过 90 天未激活的软删除记录
+// 彻底清除超过 90 天的软删除记录(注意用 Unscoped 否则查不到已删除行)
```
+> [!tip] 关键区别
+> - `db.Delete()` → **UPDATE** 语句,触发 BeforeUpdate / AfterUpdate 钩子
+> - `db.Unscoped().Delete()` → **DELETE** 语句,触发 BeforeDelete / AfterDelete 钩子
+> 两者走不同的钩子链,设计时需要考虑清楚业务逻辑该放在哪个钩子里。
+
## 查询已删除数据
### Unscoped — 忽略软删除过滤
@@ -135,6 +140,23 @@ 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;
+// 如果需要已删除的关联项,使用 Unscoped().Preload()
+```
+
+### BelongsTo(反向)
+
+BelongsTo 端同样受过滤影响——当通过子记录查询父记录时,如果父记录已被软删除,默认也不会被加载:
+
+```go
+type Order struct {
+ gorm.Model
+ UserID uint
+ User User `gorm:"foreignKey:UserID"`
+}
+
+var order Order
+db.Preload("User").First(&order, 1)
+// 如果该 Order 对应的 User 已被软删除,User 字段将为空
```
### ManyToMany
@@ -146,7 +168,7 @@ db.Preload("Orders").First(&user, 1)
product := Product{Name: "Go Programming"}
db.Model(&order).Association("Products").Append(&product)
-// 如果要强制关联已删除的商品
+// 如果要强制关联已删除的商品(需 import "gorm.io/gorm/clause")
db.Model(&order).Clauses(clause.OnConflict{DoNothing: true}).
Association("Products").Append(&product)
```
@@ -194,6 +216,9 @@ type Article struct {
// 在数据库层面创建 (slug, is_deleted) 联合唯一约束
```
+> [!tip] 最推荐的方案
+> 方案一(部分唯一索引)是 PostgreSQL 用户的首选,只需一条 DDL 语句即可完美解决。MySQL 用户推荐使用**方案二**——用一个 `SlugVersion uint` 字段记录 slug 修改次数,保证同一 slug 不会同时出现在两条未删除记录中。
+
> [!warning] 跨数据库行为不一致
> MySQL 的 InnoDB 引擎在处理软删除行的唯一约束时存在历史缺陷,不同版本行为可能不同。**不要依赖这种行为一致性**,应在应用层做好校验。
@@ -211,8 +236,37 @@ result := db.Model(&User{}).
fmt.Printf("恢复了 %d 条记录\n", result.RowsAffected)
```
-> [!important] 恢复后记得重新设置 UpdatedAt
-> 恢复操作本身也是一次 UPDATE,所以 `UpdatedAt` 会自动更新。如果你希望保留原始时间戳,需要在更新前保存到临时变量。
+> [!note] 注意 UpdatedAt 的变化
+> 恢复操作本质是一次 `UPDATE`,GORM 会自动将 `UpdatedAt` 设为当前时间。如果审计要求记录原始创建信息而非恢复时间,这是正常行为。如需区分「创建」与「恢复」时间,可自行增加 `RestoredAt` 字段。
+
+## 生命周期钩子
+
+软删除触发的是一条 UPDATE 语句,因此 **BeforeUpdate / AfterUpdate** 会正常执行:
+
+```go
+type User struct {
+ gorm.Model
+ Name string
+ Email string
+}
+
+func (u *User) BeforeDelete(tx *gorm.DB) error {
+ // db.Delete() 走的是 UPDATE,不会触发 BeforeDelete
+ // 如果需要在此处做逻辑(如级联标记),需用 Unscoped().Delete()
+ return nil
+}
+
+func (u *User) AfterUpdate(tx *gorm.DB) error {
+ // 软删除发生时,这条钩子会被调用
+ if u.DeletedAt != nil {
+ fmt.Printf("用户 %s 被软删除\n", u.Name)
+ }
+ return nil
+}
+```
+
+> [!warning] BeforeDelete 在软删除时不会被调用
+> GORM 的默认 `db.Delete()` 只发送 UPDATE SQL,不经过物理删除的钩子链。如果需要在删除前做业务校验(如检查关联记录),可以使用 `Unscoped().Delete()` 或者在应用层自行实现检查逻辑。
## 定时清理策略
@@ -236,13 +290,13 @@ func CleanSoftDeletedRecords(tx *gorm.DB) error {
```mermaid
flowchart TD
- Start[执行删除操作] --> TypeQ{业务需求_}
- TypeQ --> |可恢复/需审计| SoftDel["软删除
db.Delete()"]
- TypeQ --> |合规要求/不需要恢复| HardDel["物理删除
db.Unscoped().Delete()"]
+ Start["执行删除操作"] --> TypeQ{业务需求}
+ TypeQ --> |可恢复 / 需审计| SoftDel["软删除 db.Delete()"]
+ TypeQ --> |合规要求 / 不需恢复| HardDel["物理删除 Unscoped().Delete()"]
- SoftDel --> AfterSoft[查询时需要已删除数据?]
+ SoftDel --> AfterSoft["查询时需要已删除数据?"]
AfterSoft --> |是| UnscopedOn["db.Unscoped()"]
- AfterSoft --> |否| NormalQ["普通查询
自动过滤"]
+ AfterSoft --> |否| NormalQ["普通查询\n自动过滤"]
HardDel --> AfterHard["数据永久移除"]
diff --git a/hhs/GORM/11-批量操作.md b/hhs/GORM/11-批量操作.md
index ee7882b..8de19d1 100644
--- a/hhs/GORM/11-批量操作.md
+++ b/hhs/GORM/11-批量操作.md
@@ -43,48 +43,33 @@ for _, u := range users {
> db.Session(&gorm.Session{FullSaveRecords: true}).Create(&hugeSlice)
> ```
-### 高速批量插入(Exec)
+### 高速批量插入(CreateInBatches)
-对于超大批量(数万行),GORM 提供了更高效的执行方式——跳过模型解析,直接执行原始 SQL:
+当数据量超过 10K 时,建议主动控制批次大小,避免单次 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
->
+> [!tip] CreateInBatches vs Create
> | 特性 | Create | CreateInBatches |
> |------|--------|-----------------|
-> | 参数 | 总数量 | 每批大小(batch size) |
-> | 钩子触发 | 每条都触发 | 每条都触发 |
-> | 适用场景 | 中小批量(≤10K) | 超大批量(>10K) |
-> | 可控性 | GORM 内部决定批次 | 可自定义 batch size |
+> | 内部实现 | GORM 自动拆批(默认约 256 条) | 按指定 batch size 拆分 |
+> | 适用场景 | 中小批量(≤10K) | 超大批量(>10K),可控分批 |
+> | 参数控制 | 不可调 | 可自定义每批大小 |
```go
-// 演示差异
-db.CreateInBatches(&users, 100) // 每批 100 条
-// users 有 350 条 → 分成 4 批:100, 100, 100, 50
+// 350 条数据 → 4 批:100, 100, 100, 50
+db.CreateInBatches(&users, 100).Error
-db.CreateInBatches(&users, 200) // 每批 200 条
-// users 有 350 条 → 分成 2 批:200, 150
+// 350 条数据 → 2 批:200, 150
+db.CreateInBatches(&users, 200).Error
```
> [!question] 思考题
-> 为什么 CreateInBatches 的第一个参数是 slice,第二个是 batch size?为什么不叫 ` batchSize` 而用 `total`?
->
-> > **答案**:因为设计时参考的是「总共要处理的总数」语义。但在实际使用中,把它理解为「每批大小」更加直观。注意它不是总上限,而是批次阈值。
+> 如果 users 只有 50 条记录,传入 batch size = 100,会发生什么?
+>
+> > **答案**:不会报错,只会执行一批——包含全部 50 条。`CreateInBatches` 的行为是「向上取整分批次」,没有足够数据也不会跳过执行。
## Update — 批量更新
@@ -229,15 +214,14 @@ db.Clauses(clause.OnConflict{
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)
- }
- }
+// GORM v2:通过 Session 注册回调
+db.Session(&gorm.Session{
+ DryRun: true, // 仅生成 SQL 不执行,用于调试
+}, func(tx *gorm.DB) error {
+ return tx.Create(&users).Error
})
+
+// 也可以直接在模型上定义钩子(见 [[09-钩子函数]])
```
> [!tip] Callback 阶段排序
@@ -245,29 +229,29 @@ db.Callback().Create().After("gorm:create").Register("send_notifications", func(
> ```
> CREATE: BeforeQuery → BeforeCreate → Create → AfterCreate → AfterQuery
> UPDATE: BeforeQuery → BeforeUpdate → Update → AfterUpdate → AfterQuery
-> DELETE: BeforeQuery → BeforeDelete → Delete → AfterDelete → AfterQuery
+> DELETE: BeforeQuery → BeforeDelete → Delete → AfterDelete → AfterQuery
> FIND: BeforeQuery → Query → AfterFind → AfterQuery
> ```
-> 你可以在任意阶段的前后注册回调。
+> 你可以在任意阶段的前后注册回调。对于超大批量,建议在事务层控制而非逐条回调,避免性能瓶颈。
## 批量操作决策图
```mermaid
flowchart TD
- Start[需要批量操作数据] --> OpType{操作类型_}
-
+ Start[需要批量操作数据] --> OpType{"操作类型"}
+
OpType --> |插入| InsertQ{"数据量?"}
- InsertQ --> |< 10K| SimpleInsert["Create(slice)"]
- InsertQ --> |≥ 10K| BatchInsert["CreateInBatches(batchSize)"]
-
+ InsertQ --> |< 10K| SimpleInsert["Create\\(slice\\)"]
+ InsertQ --> |≥ 10K| BatchInsert["CreateInBatches\\(batchSize\\)"]
+
OpType --> |更新| UpdateQ{"是否需要精准字段控制?"}
- UpdateQ --> |不需要| MapUpdate["Updates(map)"]
- UpdateQ --> |需要| FieldControl["Select/Omit + Updates"]
-
+ UpdateQ --> |不需要| MapUpdate["Updates\\(map\\)"]
+ UpdateQ --> |需要| FieldControl["Select / Omit + Updates"]
+
OpType --> |删除| DelCheck["先 Count 确认范围
再 Delete"]
-
- OpType --> |存在则更新| Upsert["Clauses(OnConflict)"]
-
+
+ OpType --> |存在则更新| Upsert["Clauses\\(OnConflict\\)"]
+
style Start fill:#4FC08D,color:#fff
style BatchInsert fill:#3B82F6,color:#fff
style MapUpdate fill:#F59E0B,color:#000
diff --git a/hhs/GORM/12-自定义字段类型.md b/hhs/GORM/12-自定义字段类型.md
index d1391f2..8c6d6f8 100644
--- a/hhs/GORM/12-自定义字段类型.md
+++ b/hhs/GORM/12-自定义字段类型.md
@@ -166,26 +166,25 @@ type Ticket struct {
> [!note] GORMDataType 的作用范围
> 它只在**建表(AutoMigrate)**时生效,不影响运行时读写行为。也就是说,GORM 会用这个类型创建列,但数据的序列化和反序列化仍由 `database/sql` 的标准处理完成。
-## 结合 Use 注册全局解析器
+## 全局类型解析器(Resolver)
-对于不想在每个 struct 上实现接口的场景(比如第三方类型的扩展),可以用 `RegisterResolve`:
+GORM v1.25+ 引入了 Resolver API,可以在不修改类型定义的前提下注册自定义解析:
```go
-// 为 net.IP 类型注册 GORM 解析逻辑
-db.Use(clause.OnConflict{}, func(db *gorm.DB) error {
- // 这个方式已经过时了...
- return nil
+// 创建 DBConfig,为指定类型注册序列化/反序列化逻辑
+db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ Resolver: resolver.FullSaveResolver{
+ Resolvers: map[reflect.Type]resolver.Resolver{
+ reflect.TypeOf(net.IP{}): &IPResolver{}, // 自定义 net.IP 解析器
+ },
+ },
})
-
-// 更现代的方式是用 GORM 的 Resolver
-type MyType string
-
-// 注册一个自定义数据类型解析器
-// 适用于无法给已有类型添加方法的情况
```
-> [!info] 注意
-> GORM 对全局类型注册的 API 在不同版本间有变化。推荐的实践是给类型加上方法实现 Valuer/Scanner——这样代码内聚性更好,依赖也更清晰。
+> [!tip] 何时使用 Resolver?
+> - **场景一**:第三方包提供的类型,无法添加方法实现 Valuer/Scanner
+> - **场景二**:项目中大量地方用到同一自定义类型,避免每个文件重复实现接口
+> - **首选方案**:仍然是直接给类型实现 `driver.Valuer` + `sql.Scanner`——代码内聚性更好,IDE 也能做类型检查
## AutoMigrate 时的自定义类型
@@ -193,12 +192,12 @@ type MyType string
```mermaid
flowchart TD
- A[字段类型 T] --> B{T 实现
GORMDataType?}
- B -->|是| C["使用返回值
作为列类型"]
- B -->|否| D{"T 是已知内置类型?"}
- D -->|是| E["使用默认映射"]
- D -->|否| F["尝试 driver.Valuer
推断类型"]
- F --> G["使用 driver.Value
的反射结果"]
+ A["字段类型 T"] --> B{"是否实现 GORMDataType?"}
+ B -- "是" --> C["使用返回值作为列类型"]
+ B -- "否" --> D{"T 是已知内置类型?"}
+ D -- "是" --> E["使用默认映射"]
+ D -- "否" --> F["尝试从 driver.Valuer 推断"]
+ F --> G["根据 driver.Value 反射确定"]
style A fill:#4FC08D,color:#fff
style C fill:#3B82F6,color:#fff
diff --git a/hhs/GORM/13-多数据库支持.md b/hhs/GORM/13-多数据库支持.md
index 0149e1a..2f4a175 100644
--- a/hhs/GORM/13-多数据库支持.md
+++ b/hhs/GORM/13-多数据库支持.md
@@ -7,7 +7,10 @@ create time: 2026-04-28 00:00
## 概述
-GORM 的「驱动适配器」架构让它能够无缝切换不同的数据库后端——你写同一份 GORM 代码,只需改变 `gorm.Open()` 的参数就能连接不同数据库。但要注意:**不同数据库的方言差异**可能导致某些功能表现不一致。
+GORM 采用「驱动适配器(Driver Adapter)」架构——你写一份 GORM 代码,通过切换 `gorm.Open()` 的 dialector 就能连接不同的数据库后端。**理想情况下**所有操作都应该无缝兼容,但现实是:**不同数据库的方言差异**会让某些功能表现出微妙甚至明显的行为不同。
+
+> [!question] 核心问题
+> 如果代码要在三种数据库上都能跑,那是不是意味着每个查询都要写三遍?答案当然是否定的——GORM 的优势恰恰在于"一次编写,多处运行"。那么问题来了:**哪些操作能保证一致?哪些地方需要特别注意?** 本章就是为了解答这些问题。
```mermaid
flowchart TD
@@ -26,75 +29,102 @@ flowchart TD
style MSSQL fill:#F59E0B,color:#000
```
+> [!tip] 驱动适配器的本质
+> GORM 内部会为每种数据库维护一个 **dialector(方言鉴别器)**。调用 `Create`、`Find` 等操作时,dialector 会负责将 GORM 的中间表达式翻译成对应数据库的 SQL 方言。理解这一点就能明白:为什么同样的 `db.Limit(10)` 在不同数据库中生成的 SQL 完全不同。
+
## 驱动安装与初始化
### MySQL / MariaDB
```go
-import (
- "gorm.io/driver/mysql"
-)
+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{})
+// 注意:MySQL 是最常见的选择,DSN 参数也是最多的
```
-**关键 DSN 参数说明**:
+**DSN 必配参数详解**:
-| 参数 | 必填 | 说明 |
-|------|------|------|
-| `charset` | ✅ 推荐 | 必须设为 `utf8mb4`(支持 emoji) |
-| `parseTime` | ✅ 推荐 | 自动将 DB 时间转为 Go `time.Time` |
-| `loc` | ✅ 推荐 | `Local` 或 `Asia/Shanghai`,避免时区混乱 |
-| `timeout` | 可选 | 连接超时时间,如 `30s` |
-| `readTimeout` | 可选 | 读取超时 |
-| `writeTimeout` | 可选 | 写入超时 |
+| 参数 | 优先级 | 推荐值 | 说明 |
+|------|--------|--------|------|
+| `charset` | ⭐⭐⭐ | `utf8mb4` | 必须用 `utf8mb4`,传统的 `utf8` 在 MySQL 中只支持 3 字节,无法存储 emoji |
+| `parseTime` | ⭐⭐⭐ | `True` | 自动把数据库的 datetime 转成 Go `time.Time`,否则你会拿到 `driver.Value` |
+| `loc` | ⭐⭐⭐ | `Local` 或 `Asia/Shanghai` | 解决北京时间偏移问题;不设置的话可能拿到 UTC 时间 |
+| `timeout` | ⭐⭐ | `30s` | TCP 握手阶段超时 |
+| `readTimeout` | ⭐⭐ | `30s` | 从服务器读取响应超时 |
+| `writeTimeout` | ⭐⭐ | `30s` | 向服务器发送请求超时 |
+
+> [!important] utf8 vs utf8mb4 常见陷阱
+> 很多开发者不知道 MySQL 的 `utf8` 实际上是 "utf8mb3"——它最多支持 3 字节的字符。遇到 emoji、生僻汉字时会直接报错。这就是为什么 DSN 里必须明确指定 `utf8mb4`(真正的全量 UTF-8)。
+>
+> ```go
+> // 如果你在代码中也设置了 CharacterSet,别忘了:
+> db.Set("gorm:table_options", "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4")
+> ```
### PostgreSQL
```go
-import (
- "gorm.io/driver/postgres"
-)
+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 使用标准的 Connection String 格式
```
**PostgreSQL 特有注意**:
-- `sslmode` 在开发环境可设为 `disable`,生产环境保持 `require` 或 `verify-full`
-- `TimeZone` 要与服务端一致,否则时间会偏移
+
+- `sslmode` 在开发环境可设为 `disable`,生产环境保持 `require`(最低要求)或 `verify-full`(最强校验)
+- `TimeZone` 要与服务端一致,否则 `time.Time` 字段会出现几小时的偏移
+- PG 对大小写敏感:未加引号的标识符会自动转为小写,这意味着 struct 字段名映射时要格外小心
+
+> [!warning] PostgreSQL 的大写陷阱
+> 如果你在建表时用的是双引号 `"UserName"`,那后续所有查询都必须也带上双引号,否则 PG 会去找 `username`(小写)。**建议:建表一律用小写,避免此坑。**
### SQLite
```go
-import (
- "gorm.io/driver/sqlite"
-)
+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 不会创建外键
+
+- 默认是文件级排他锁,并发写入会有阻塞
+- 高并发场景使用 WAL + 共享缓存模式:`file:test.db?cache=shared&_journal=WAL`
+- **不支持外键约束**——`AutoMigrate` 不会创建外键,需要在原始 SQL 中手动添加
+- 所有列本质上都是「无类型」的(type affinity),GORM 的类型映射在某些边界情况下可能有意外表现
+
+> [!tip] SQLite 开启 WAL 模式
+> WAL(Write-Ahead Logging)允许读写并发,性能提升显著:
+> ```go
+> db, _ := gorm.Open(sqlite.Open("file:test.db?cache=shared&_journal=WAL&_timeout=5000"), &gorm.Config{})
+> // cache=shared:允许多个连接共享缓存
+> // _journal=WAL:启用预写日志
+> // _timeout:锁定等待超时(毫秒)
+> ```
### SQL Server
```go
-import (
- "gorm.io/driver/mssql"
-)
+import "gorm.io/driver/mssql"
-dsn := "sqlserver://user:password@host:1433?database=dbname"
+dsn := "sqlserver://user:password@host:1433?database=dbname&encrypt=disable"
db, err := gorm.Open(mssql.Open(dsn), &gorm.Config{})
```
+**SQL Server 特有注意**:
+
+- 连接字符串使用 URI 格式,与其他驱动风格不同
+- SQL Server 默认强制 TLS 加密,开发环境下需加 `encrypt=disable`
+- 数据类型和自增逻辑与 MySQL/PG 都不同(详见下方方言差异表)
+
## 方言差异速查表
-这是跨数据库迁移时最容易踩坑的部分——同样是 `time.Time`,在不同数据库中的列类型可能完全不同:
+这是跨数据库迁移时最容易踩坑的部分。下面这张表汇总了最常见的差异场景:
| 特性 | MySQL | PostgreSQL | SQLite | SQL Server |
|------|-------|-----------|--------|------------|
@@ -105,11 +135,15 @@ db, err := gorm.Open(mssql.Open(dsn), &gorm.Config{})
| 自增主键 | 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 | ✅ 正常 | ✅ 正常 | ✅ 正常 |
+| 软删除索引 | ⚠️ InnoDB 唯一约束 Bug | ✅ 正常 | ✅ 正常 | ✅ 正常 |
+| ON DELETE | CASCADE / SET NULL 等 | CASCADE / SET NULL 等 | ❌ 不支持 | CASCADE / SET NULL 等 |
-## CREATE TABLE 差异
+> [!question] 为什么 uint 在 PG 中变成 BIGINT?
+> Go 的 `uint` 在 64 位系统上是 8 字节(与 `int64` 同宽),而 MySQL 有独立的 `INT UNSIGNED` 正好对齐。但 PostgreSQL 没有无符号整数类型,所以 GORM 将其映射为最大的匹配类型 `BIGINT`。这就引出了一个问题:**如果你的代码中写了 `uint`,迁移到 PG 后会不会出现溢出?** 答案是:只要数据范围在 BIGINT 内就没问题,但如果业务依赖的是 32 位上限,就需要改用 `uint32`。
-同一个 struct 在不同数据库下生成的建表语句不同:
+### CREATE TABLE 实际输出对比
+
+同一个 struct 在不同数据库中生成的建表语句差异很大:
```go
type User struct {
@@ -126,96 +160,233 @@ type User struct {
| 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 中同时跑多个数据库版本的测试。
+> [!warning] autoTime 的隐藏机制
+> `autoTime` tag 在 SQLite 中的表现尤其值得注意:因为 SQLite 没有原生的 DATETIME 类型,GORM 会把 `time.Time` 序列化为 RFC 3339 格式的字符串存入 TEXT 列。读出来的时候再反序列化回来。这意味着你在 SQLite 中无法使用数据库层面的时间函数(如 `WHERE created_at > '2024-01-01'` 来利用索引)。
-## AutoMigrate 跨数据库问题
+## AutoMigrate 跨数据库的注意事项
```go
-// MySQL
+// MySQL 下执行迁移
err := db.AutoMigrate(&User{}, &Order{})
-// PostgreSQL
+// 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` 只能新增列不能修改已有列的结构
+> [!warning] AutoMigrate 的本质局限
+> `AutoMigrate` 的设计原则是 **"只加不改"**——它能新增表和新增列,但**无法修改已有列的结构**(比如改列名、改类型、改约束)。这在任何数据库中都一样,但在 SQLite 下表现得最极端:SQLite 根本不支持 ALTER TABLE 的大部分操作,所以 AutoMigrate 实际上只能新建一张表然后把旧数据搬过来。
>
-> **最佳实践**:使用独立的迁移工具(如 Goose、golang-migrate),手写 SQL 脚本而非依赖 AutoMigrate。
+> **最佳实践**:使用专门的迁移工具(Goose、golang-migrate、Migration),手写版本化的 SQL 脚本。这样你可以精确控制每个版本的迁移和回滚,而不是依赖 AutoMigrate 的黑盒行为。
## 查询语法差异
+### LIMIT / OFFSET 分页
+
```go
-// 分页:MySQL / SQLite vs PostgreSQL 基本一致
+// 标准分页写法——GORM 会自动处理底层方言差异
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)
+// 底层翻译后的 SQL:
+// MySQL: SELECT * FROM users LIMIT 10 OFFSET 20
+// PostgreSQL: SELECT * FROM users LIMIT 10 OFFSET 20
+// SQLite: SELECT * FROM users LIMIT 10 OFFSET 20
+// SQL Server: SELECT * FROM users ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY
+```
-// UUID 字段
+> [!important] SQL Server 分页的性能陷阱
+> 老版本的 GORM 驱动会把 SQL Server 的分页转换为子查询方案(`TOP N WHERE id NOT IN (SELECT TOP M ...)`),在大数据量下性能较差。升级到最新版驱动后已改用 `OFFSET FETCH` 标准语法。升级前务必确认你用的驱动版本 ≥ v1.5.0。
+
+### UUID 与原生函数
+
+不同数据库生成 UUID 的方式完全不同,GORM 只能做基本映射,具体的默认值必须由你指定:
+
+```go
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
+ ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid()"` // PostgreSQL:内置函数 gen_random_uuid()
+ // MySQL 要用 type:char(36);default:(UUID())
+ // SQL Server 要用 type:uniqueidentifier;default:newid()
+ // SQLite 需要用 Hook 或在 Go 层生成
}
```
-## 最佳实践总结
+> [!tip] SQLite 下的 UUID 生成方案
+> 由于 SQLite 没有内置 UUID 函数,推荐的两种方式:
+> 1. **Go 层生成**:在 `BeforeCreate` Hook 中用 `github.com/google/uuid` 生成
+> 2. **触发器**:用 SQLite 的 BEFORE INSERT TRIGGER 自动生成
+>
+> ```go
+> func (o *Order) BeforeCreate(tx *gorm.DB) error {
+> if o.ID == uuid.Nil {
+> o.ID = uuid.New()
+> }
+> return nil
+> }
+> ```
-### 开发环境 vs 生产环境
+### 软删除与唯一索引
+
+这是最隐蔽的一个坑——MySQL InnoDB 引擎在处理软删除(`DeletedAt` 字段)时,**唯一约束不会自动排除已软删除的行**。换句话说:
+
+```go
+type Product struct {
+ gorm.Model
+ SKU string `gorm:"uniqueIndex"`
+}
+
+// 假设 SKU="ABC" 的记录被软删除了
+// 此时你还能再次插入 SKU="ABC" 的新记录吗?
+// MySQL: ✅ 可以(唯一的 bug 行为——软删除行不参与唯一约束检查)
+// PG: ❌ 不可以(正确行为——唯一约束包含软删除行)
+```
+
+> [!danger] MySQL 软删除唯一约束 Bug
+> 如果你用了软删除 + 唯一索引,在 MySQL 下可能出现"同一 SKU 多条有效记录"的数据不一致问题。
+>
+> **解决方案**:
+> 1. 用复合唯一索引:`uniqueIndex:idx_sku_active`,并在查询时总是带上 `DeletedAt` 条件
+> 2. 或者换用 PG/SQL Server——它们的行为是正确的
+
+## 高级用法
+
+### 多数据库连接管理
+
+生产环境中通常需要同时连接多个数据库(比如 MySQL 存业务数据、Redis 做缓存、ES 做搜索),以下是常见模式:
+
+```go
+var (
+ masterDB *gorm.DB // 主库
+ slaveDB *gorm.DB // 从库 / 其他数据库
+)
+
+func initMultiDB() error {
+ var err error
+
+ masterDB, err = gorm.Open(mysql.Open(os.Getenv("MASTER_DSN")), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Info),
+ })
+ if err != nil {
+ return fmt.Errorf("master db: %w", err)
+ }
+
+ slaveDB, err = gorm.Open(postgres.Open(os.Getenv("SLAVE_DSN")), &gorm.Config{
+ Logger: logger.Default.LogMode(logger.Silent),
+ })
+ if err != nil {
+ return fmt.Errorf("slave db: %w", err)
+ }
+
+ // 配置连接池(两种数据库都可以用 DB.SqlDB() 访问底层 *sql.DB)
+ sqlDB, _ := masterDB.DB()
+ sqlDB.SetMaxOpenConns(50)
+ sqlDB.SetMaxIdleConns(10)
+ sqlDB.SetConnMaxLifetime(time.Hour)
+
+ return nil
+}
+```
+
+> [!tip] 连接池调优经验值
+> - `MaxOpenConns`:根据 QPS × 平均查询耗时估算。一般 Web 应用 20~100 足够
+> - `MaxIdleConns`:设为 `MaxOpenConns` 的 10%~25%,过多空闲连接会浪费资源
+> - `ConnMaxLifetime`:建议设 1 小时,避免与数据库侧的连接超时策略冲突
+
+### 环境自适应连接
```go
func newDB(env string) (*gorm.DB, error) {
- var dsn string
+ var driverFunc func(string) gorm.Dialector
+
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 集群
+ driverFunc = func(dsn string) gorm.Dialector {
+ return sqlite.Open(dsn)
+ }
+ default:
+ driverFunc = func(dsn string) gorm.Dialector {
+ return postgres.Open(dsn)
+ }
}
- driver := "sqlite"
- if env != "dev" {
- driver = "postgres"
- }
+ dsn := map[string]string{
+ "dev": "file:test_dev.db?cache=shared",
+ "staging": os.Getenv("DATABASE_URL"),
+ "prod": os.Getenv("PROD_DATABASE_URL"),
+ }[env]
- return gorm.Open(getDriver(driver)(dsn), &gorm.Config{
- Logger: logger.Default.LogMode(logger.Silent), // 生产环境关闭详细日志
+ return gorm.Open(driverFunc(dsn), &gorm.Config{
+ Logger: ternary(env == "prod", logger.Default.LogMode(logger.Silent), logger.Default),
})
}
-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)
- }
+func ternary[T any](cond bool, a, b T) T {
+ if cond { return a }; return b
}
```
+### 多数据库事务
+
+当需要在一个事务中操作多个数据库时,GORM 本身不提供分布式事务支持,但可以分别管理各自的事务:
+
+```go
+// 分别在不同的 db 实例上开启事务
+tx1 := masterDB.Begin()
+tx2 := slaveDB.Begin()
+
+// 独立提交
+if err := tx1.Create(&product).Error; err != nil {
+ tx1.Rollback()
+ tx2.Rollback() // 两个都需要回滚
+ return err
+}
+if err := tx2.Create(&auditLog).Error; err != nil {
+ tx1.Rollback()
+ tx2.Rollback()
+ return err
+}
+
+tx1.Commit()
+tx2.Commit()
+```
+
+> [!important] 跨数据库事务不是 ACID 的
+> 上面的模式叫做 **"两阶段提交"的非正式实现**——本质上两个事务是独立的。如果 tx1 成功但 tx2 失败,你就有了数据不一致状态。真正的分布式事务需要使用 XA 协议或 Saga 模式,但这超出了 GORM 的能力范围。
+>
+> **建议**:能在一个数据库内完成的操作就不要跨库,减少一致性复杂度。
+
+## 最佳实践总结
+
+### 数据库选型决策矩阵
+
+| 场景 | 推荐数据库 | 理由 |
+|------|-----------|------|
+| 快速原型 / CLI 工具 | SQLite | 零配置,单文件 |
+| 个人项目 / 博客 | PostgreSQL | 免费、功能全、JSONB 强大 |
+| 企业级业务系统 | MySQL / PostgreSQL | 社区成熟,生态丰富 |
+| Windows 技术栈企业 | SQL Server | Active Directory 集成好 |
+
+### 开发环境 vs 生产环境
+
> [!tip] 为什么推荐开发用 SQLite?
-> - 零配置:不需要启动数据库服务
-> - 单文件:版本控制友好(当然大表不适合 git)
-> - 语法兼容性:大部分 SQL 标准都能正常工作
+>
+> - **零配置**:不需要启动任何数据库服务,`go run` 即可
+> - **单文件**:方便分享测试数据集,CI/CD 中直接用内存数据库
+> - **语法兼容性**:大部分标准 SQL 都能工作
>
-> **但**:SQLite 不支持并发写入和事务隔离级别调整,不能完全替代生产数据库做性能测试。
+> **但请记住**:SQLite **不能**替代生产数据库做压力测试——它不支持行级锁、事务隔离级别可调、复杂聚合优化器等关键特性。**开发用 SQLite 验证逻辑,上线前必须在真实数据库上做一轮完整回归测试。**
+
+### 防坑 Checklist
+
+在准备多数据库部署时,逐项核对:
+
+- [ ] 所有 `time.Time` 字段的时区设置是否已明确
+- [ ] 是否避免了 `uint` 类型(改用 `int` / `int64` 降低迁移风险)
+- [ ] 软删除 + 唯一索引的场景是否在目标数据库上过测
+- [ ] 自定义 SQL(`db.Raw()`)是否做了方言检查
+- [ ] `AutoMigrate` 之外是否有版本化的迁移脚本
+- [ ] 连接池参数是否针对生产环境调优
+- [ ] 是否在所有目标数据库上都跑过完整的 CI 测试
## 关联笔记
diff --git a/hhs/GORM/14-错误处理.md b/hhs/GORM/14-错误处理.md
index b14b06b..fc77046 100644
--- a/hhs/GORM/14-错误处理.md
+++ b/hhs/GORM/14-错误处理.md
@@ -7,22 +7,34 @@ create time: 2026-04-28 00:00
## 概述
-GORM **从不 panic**——所有错误都通过 `.Error` 字段返回。这种设计让业务层可以在不中断程序的情况下优雅地处理异常,但也意味着开发者需要养成「始终检查 Error」的习惯。
+GORM **从不 panic**——所有错误都通过 `result.Error` 字段返回。这种设计让业务层可以在不中断程序的情况下优雅地处理异常,但也意味着开发者需要养成「始终检查 Error」的习惯。
+
+核心要点:
+
+- **每个 GORM 方法返回 `*gorm.DB`**(链式调用),真正的结果在 `.Error` 字段里 —— 忘记检查 `.Error` 是新手最常见的坑。
+- **哨兵错误用 `errors.Is` 判断**,不要用 `==` ,因为 GORM 内部会用 `%w` 包装错误。
+- **数据库错误分三层**:应用层(RecordNotFound / DuplicateKey)、事务层(TransactionFinished)、基础设施层(连接断开 / SQL 错误)—— 每一层的处理策略不同。
+
+> [!question] 思考:如果 `db.Create(&user).Error` 是 nil,是不是就代表数据一定写入了?
+>
+> 不一定。如果当前事务处于回滚状态,或者事务最终 Rollback 了,创建操作虽然 "成功" 了但不会被持久化。**错误检查必须和事务生命周期配合考虑**。我们后面会详细讲。
```mermaid
flowchart TD
- Result["db.XXX() → result"] --> HasErr{"result.Error != nil?"}
- HasErr --> |否| Success["继续业务逻辑 ✓"]
+ Start[db.XXX() 操作] --> Result["返回 result"]
+ 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["记录日志 / 返回通用错误"]
+ 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
+ style Generic fill:#F59E0B,color:#fff
```
## 核心错误常量
@@ -37,7 +49,7 @@ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
// 记录不存在 —— 可以返回 404 或设置默认值
return http.NotFound(w, r)
} else if result.Error != nil {
- // 真正的数据库错误
+ // 真正的数据库错误(连接失败、SQL 语法问题等)
log.Printf("查询用户失败: %v", result.Error)
return err
}
@@ -46,6 +58,9 @@ if errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Printf("找到用户: %s", user.Name)
```
+> [!exemplar] 关键模式:先 `errors.Is` 精确判断,再兜底检查非空
+> 这段代码的精髓在于「**分层判断**」:第一层处理预期内的业务场景(找不到),第二层处理意外情况(连接断了)。如果反过来先检查 `nil`,会丢失对 `ErrRecordNotFound` 的精准处理能力。
+
> [!tip] 为什么用 errors.Is 而不是 ==?
> `gorm.ErrRecordNotFound` 是一个 sentinal error(哨兵错误),Go 标准库的 `errors.Is()` 能正确处理包装过的错误链。直接用 `==` 在复杂场景中可能失效。
@@ -59,14 +74,16 @@ if errors.Is(result.Error, gorm.ErrDuplicatedKey) {
return fmt.Errorf("该邮箱已被注册")
}
-// 外键约束失败
+// 外键约束失败:尝试创建一条指向不存在用户的订单
type Order struct { UserID uint }
-result := db.Create(&Order{UserID: 999})
+result = db.Create(&Order{UserID: 999})
if errors.Is(result.Error, gorm.ErrForeignKeyConstraintViolated) {
return fmt.Errorf("指定的用户不存在")
}
```
+> [!tip] `ErrDuplicatedKey` 和 `ErrForeignKeyConstraintViolated` 都来自数据库引擎本身(如 MySQL 的 errno 1062 / 1452),GORM 只是把它们包装成了哨兵错误。因此判断顺序没有严格要求,但一般**先判主键冲突再判外键**。
+
### gorm.TransactionFinished
```go
@@ -119,6 +136,7 @@ tx.Rollback() // ❌ 再回滚会报错!
func GetUserByID(db *gorm.DB, id uint) (*User, error) {
var user User
if err := db.First(&user, id).Error; err != nil {
+ // 保留原始哨兵错误,方便上层继续用 errors.Is 判断
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户 ID=%d 不存在: %w", id, err)
}
@@ -128,6 +146,8 @@ func GetUserByID(db *gorm.DB, id uint) (*User, error) {
}
```
+> [!exemplar] `%w` 是 Go 的错误包装语法 —— 它让外层错误仍然能被 `errors.Is()` 逐层识别。如果只写 `%v` ,上层的 `errors.Is(err, gorm.ErrRecordNotFound)` 将返回 false。
+
### 自定义错误处理器
```go
@@ -165,26 +185,26 @@ func HandleCreateUser(c *gin.Context) {
### ❌ 错误的写法
```go
-// 1. 完全忽略错误
+// 1. 完全忽略错误 —— 数据丢了你都不会知道
db.Create(&user)
-// 2. 只检查非 nil(会误判 ErrRecordNotFound)
+// 2. 只检查非 nil(会误把 ErrRecordNotFound 当成故障)
if err := db.First(&user, 1).Error; err != nil {
- // ErrRecordNotFound 也会被当成一般错误处理
+ // ErrRecordNotFound 也会被当成一般错误,导致用户看到 500 而不是 404
log.Printf("error: %v", err)
}
-// 3. 先判断 Error != nil 再用 == 比较
+// 3. 先判断 Error != nil 再用 == 比较 —— 可能被 fmt.Errorf("%w") 包装后漏判
err := db.First(&user, 1).Error
if err != nil && err == gorm.ErrRecordNotFound {
- // 在某些情况下可能漏判
+ // 在某些 GORM 版本或复杂场景中可能漏判
}
```
### ✅ 正确的写法
```go
-// 模式一:errors.Is 优先
+// 模式一:errors.Is 优先(推荐日常使用)
if err := db.First(&user, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// 单独处理
@@ -193,7 +213,7 @@ if err := db.First(&user, 1).Error; err != nil {
}
}
-// 模式二:先赋值再判断
+// 模式二:switch + result.Error(适合多分支场景)
result := db.First(&user, 1)
switch {
case errors.Is(result.Error, gorm.ErrRecordNotFound):
@@ -204,7 +224,7 @@ 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")
@@ -213,7 +233,12 @@ if result := db.First(&user, 1); result.Error != nil {
}
```
-## 连接错误与超时处理
+> [!tip] 什么时候用模式二(switch)?
+> 当你的 handler 需要同时处理多种业务错误时,`switch-case` 比嵌套 `if-else` **更清晰、更易扩展**。每新增一种错误类型只需加一个 case,不会让代码块不断缩进。
+
+## 连接错误与连接池管理
+
+### PingContext 探测
```go
// 检测数据库是否可达
@@ -233,25 +258,79 @@ if err := sqlDB.PingContext(ctx); err != nil {
```
> [!tip] 结合健康检查
-> 在生产环境中,应该定期执行 `PingContext` 作为 K8s liveness/readiness probe,确保服务知道数据库是否可用。
+> 在生产环境中,应该定期执行 `PingContext` 作为 K8s liveness/readiness probe,确保服务知道数据库是否可用。Gin 项目里可以注册一个 `/health` 路由,里面做这一步即可。
+
+### 连接池脏连接问题(重要!)
+
+即使 Ping 通过了,**从连接池借出的连接也可能已断开**——这被称为 "stale connection" 或 "dead connection"。典型场景:MySQL 的 `wait_timeout` 默认 8 小时,空闲连接被服务端主动关闭,但 Go 端连接池不知情。
+
+GORM v2 内置了 stale connection replacer(v2.0.9+),会自动重试一次死连接。但你仍需合理配置连接池参数:
+
+```go
+sqlDB, _ := db.DB()
+
+// 最大空闲连接数 —— 设太高会浪费资源
+sqlDB.SetMaxIdleConns(10)
+
+// 最大打开连接数 —— 包括正在使用的 + 空闲的
+sqlDB.SetMaxOpenConns(100)
+
+// 每个连接的存活时间 —— 必须小于 MySQL wait_timeout(默认 8h)
+// 建议设为 5min 以避免服务端超时踢掉连接
+sqlDB.SetConnMaxLifetime(5 * time.Minute)
+
+// 每个连接的闲置时间 —— 超过此时间的连接会被回收
+sqlDB.SetConnMaxIdleTime(5 * time.Minute)
+```
+
+> [!warning] `SetConnMaxLifetime` vs `SetConnMaxIdleTime`
+> - `SetConnMaxLifetime`: 连接的总寿命,超过后新请求不会再用这个连接。**主要用来避免服务端侧的超时断开**。
+> - `SetConnMaxIdleTime`: 连接闲置多久后被回收。主要用于控制空闲连接数量,节省资源。
+>
+> 两者都建议设置,且 IdleTime < Lifetime < 数据库 wait_timeout。
+
+### SQL 层级错误码判断
+
+有时你需要更细粒度地判断错误来源(比如区分 "表不存在" 和 "字段类型不匹配"):
+
+```go
+// 引入 MySQL 驱动以获取底层错误类型
+import "github.com/go-sql-driver/mysql"
+
+// 细粒度错误码判断
+result := db.Exec("ALTER TABLE users ADD COLUMN email VARCHAR(255)")
+if mysqlErr, ok := result.Error.(*mysql.MySQLError); ok {
+ switch mysqlErr.Number {
+ case 1060: // Duplicate column name
+ log.Println("列已存在,跳过")
+ case 1146: // Table doesn't exist
+ log.Println("表不存在,需要初始化")
+ default:
+ log.Printf("MySQL error %d: %v", mysqlErr.Number, mysqlErr.Message)
+ }
+}
+```
+
+> [!exemplar] 什么时候用 SQL 层错误码?
+> 一般业务逻辑不需要用到这一步。通常只在以下场景需要:**数据库迁移脚本**(幂等执行)、**建表/建库的自动初始化逻辑**、或者你需要区分不同 MySQL 错误号来做差异化重试策略时。
## 错误处理决策流程图
```mermaid
flowchart TD
- Start[操作返回结果] --> CheckErr{"result.Error 非空?"}
+ Start[db 操作返回结果] --> CheckErr{"result.Error
非空?"}
CheckErr --> |否| Success["操作成功"]
- CheckErr --> |是| FirstCheck{errors.Is...}
+ CheckErr --> |是| FirstCheck{errors.Is...?}
- FirstCheck --> |ErrRecordNotFound| Handler1["处理: 返回默认值/404"]
- FirstCheck --> |ErrDuplicatedKey| Handler2["处理: 提示重复"]
- FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["处理: 检查关联"]
- FirstCheck --> |ErrTransactionFinished| Handler4["处理: 代码缺陷修复"]
- FirstCheck --> |都不匹配| DefaultHandler["处理: 通用错误/500"]
+ FirstCheck --> |ErrRecordNotFound| Handler1["404 / 默认值"]
+ FirstCheck --> |ErrDuplicatedKey| Handler2["409 / 提示重复"]
+ FirstCheck --> |ErrForeignKeyConstraintViolated| Handler3["400 / 检查关联"]
+ FirstCheck --> |ErrTransactionFinished| Handler4["500 / 代码缺陷"]
+ FirstCheck --> |都不匹配| DefaultHandler["500 / 通用数据库错误"]
style Start fill:#4FC08D,color:#fff
style Success fill:#3B82F6,color:#fff
- style DefaultHandler fill:#EF4444,color:#fff
+ style DefaultHandler fill:#F59E0B,color:#fff
```
## 常见坑点速查
@@ -263,6 +342,57 @@ flowchart TD
| Commit 后再 Rollback | defer 无条件执行回滚 | 只在 panic 分支中 Rollback |
| 把所有错误当 RecordNotFound | 没做错误类型分层 | 先用 errors.Is 精确判断 |
| 数据库断开时没检测到 | 连接池复用旧连接 | PingContext 定期探测 + SetConnMaxLifetime |
+| 空闲连接被 MySQL 回收 | wait_timeout 与 Go 连接池不匹配 | 设置 ConnMaxLifetime < MySQL wait_timeout |
+
+## 实战:GORM Interceptor 统一错误处理
+
+在 Gin 项目中,可以通过 GORM 的 [Interceptor](https://gorm.io/docs/advanced.html#Interceptor) 机制或自定义 Middleware 统一处理数据库错误,避免在每个 handler 里重复写错误解析逻辑。
+
+```go
+// DBErrorLogger 记录所有非 NotFound 的数据库错误
+func DBErrorLogger(logger *log.Logger) db.Interceptor {
+ return &interceptor{logger: logger}
+}
+
+type interceptor struct {
+ logger *log.Logger
+}
+
+func (i *interceptor) Before(name string) db.Interceptor {
+ return i
+}
+
+func (i *interceptor) After(name string, result gorm.ResultInfo) gorm.ResultInfo {
+ // 注意: Interceptor 的 result.Error 已经是方法调用后的最终结果
+ if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
+ i.logger.Printf("[DB Error] operation=%s error=%v", name, result.Error)
+ }
+ return result
+}
+```
+
+```go
+// 注册到全局配置
+db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ Logger: customLogger,
+})
+_ = db.Use(DBErrorLogger(log.New(os.Stdout, "[GORM]", log.LstdFlags)))
+```
+
+> [!tip] Interceptor vs Handler 层错误处理
+> - **Interceptor(全局)**:负责日志记录和监控告警 —— "出错了我要知道"
+> - **Handler(业务层)**:负责业务语义转换 —— "这个错对用户意味着什么"
+>
+> 两者互补:Intercepter 让你不会错过任何异常,Handler 层决定怎么回复客户端。
+
+## 最佳实践清单
+
+1. **统一入口检查**:在 service 层入口处统一做 `err != nil` 检查,不要散落在业务逻辑中。
+2. **保留哨兵错误**:使用 `%w` 包装错误,让上层可以继续用 `errors.Is` 判断。
+3. **HTTP 状态码映射**:用 switch-case 把错误类型映射为 HTTP 状态码,参考 `ParseDBError` 函数模式。
+4. **日志分级**:`ErrRecordNotFound` 等预期内错误记 `info` / `debug`,真正故障记 `error`。
+5. **连接池参数**:务必设置 `ConnMaxLifetime` 和 `ConnMaxIdleTime`,避免脏连接引发随机报错。
+6. **事务安全**:优先使用 "Commit 成功才 return nil + defer panic only rollback" 模式,避免二次提交。
## 关联笔记
diff --git a/hhs/GORM/15-性能优化.md b/hhs/GORM/15-性能优化.md
index e2ea49d..8c699b8 100644
--- a/hhs/GORM/15-性能优化.md
+++ b/hhs/GORM/15-性能优化.md
@@ -121,20 +121,37 @@ func GetUserWithTopOrders(db *gorm.DB, id uint) (*User, error) {
> 不同数据库对 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 := i + batch
-> if end > len(ids) {
-> end = len(ids)
-> }
+> end := min(i+batch, len(ids))
> chunk := ids[i:end]
-> if err := db.Where("user_id IN ?", chunk).Find(dest).Error; err != nil {
+>
+> 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
@@ -163,6 +180,46 @@ 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
@@ -177,6 +234,38 @@ db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
> [!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 中声明索引
@@ -275,6 +364,46 @@ func HandleRequest() 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
diff --git a/hhs/GORM/16-日志与调试.md b/hhs/GORM/16-日志与调试.md
index 22c1b59..7b9860c 100644
--- a/hhs/GORM/16-日志与调试.md
+++ b/hhs/GORM/16-日志与调试.md
@@ -1,5 +1,5 @@
---
-tags: [GORM, Go, ORM, 日志, Debug, SlowQueryThreshold, Logger]
+tags: [GORM, Go, ORM, 日志, Debug, SlowQueryThreshold, Logger, TraceID, DryRun, ToSQL]
create time: 2026-04-28 00:00
---
@@ -7,7 +7,7 @@ create time: 2026-04-28 00:00
## 概述
-在生产环境中,你能看到的往往只有两条信息:**API 返回了什么**和**数据库执行了什么**。GORM 内置了灵活的日志系统,让你能够在不同环境下精准控制输出粒度,从「生产静默」到「开发透明」自由切换。
+在生产环境中,你能看到的往往只有两条信息:**API 返回了什么**和**数据库执行了什么**。GORM 内置了灵活的日志系统,让你能够在不同环境下精准控制输出粒度——从「生产静默」到「开发透明」自由切换。
```mermaid
flowchart TD
@@ -19,9 +19,11 @@ flowchart TD
C --> |Warn| F["慢查询 + 错误"]
C --> |Info| G["所有 SQL + 耗时
开发推荐"]
- style Start fill:#4FC08D,color:#fff
- style Info fill:#3B82F6,color:#fff
- style Panic fill:#EF4444,color:#fff
+ style A fill:#4FC08D,color:#fff
+ style D fill:#EF4444,color:#fff
+ style E fill:#F97316,color:#fff
+ style F fill:#EAB308,color:#fff
+ style G fill:#3B82F6,color:#fff
```
## 日志级别速览
@@ -65,7 +67,7 @@ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
### 自定义 Logger(对接 Zap / Logrus 等)
```go
-// 用 Zap 替代默认日志
+// 用 Zap 替代默认日志(需 import "context"、"zap")
type ZapLogger struct {
logger.Interface
zapLogger *zap.Logger
@@ -180,49 +182,154 @@ func RecordQueryDuration(op, table string, dur time.Duration) {
## 常见调试技巧
-### 打印最终 SQL(不执行)
+### 打印最终 SQL(不执行)—— DryRun 模式
+
+DryRun 模式会构建完整的 SQL 语句并输出到日志,但**不实际连接数据库执行**。这是最安全的调试方式:
```go
// DryRun 模式:构建完整 SQL 并输出,但不实际执行
db.Session(&gorm.Session{DryRun: true}).First(&user, 1)
-// 输出:[info] ... [rows:0] SELECT * FROM users WHERE id = 1 -- dry run
+// 输出:[info] ... [rows:0] SELECT * FROM users WHERE id = ?
// 此时你可以拿到完整的 SQL 去客户端手动验证
+
+// 配合复杂查询链 —— 确认 Join / Preload 生成的子查询是否正确
+db.Session(&gorm.Session{
+ DryRun: true,
+}).Preload("Orders").Joins("Profile").Where("status = ?", "active").Find(&users)
```
-### 获取生成的 SQL 字符串
+> [!question] DryRun vs Debug 有什么区别?
+>
+> | 特性 | `DryRun` | `Debug()` |
+> |------|---------|-----------|
+> | **是否执行 SQL** | ❌ 不执行 | ✅ 执行 |
+> | **适用场景** | 审计 SQL 语法、安全审查 | 排查运行时产生的错误 SQL |
+> | **性能开销** | 零(无网络 IO) | 有正常查询开销 |
+> | **能否看到关联表 SQL** | ✅ Preload/Joins 都输出 | ✅ 同上 |
+> >
+> > **答案**:用不同的场景。想确认 "这条链式调用会生成什么 SQL" → DryRun;想确认 "生产上跑的这条 SQL 到底慢在哪里" → Debug。实际项目中推荐在 CI/CD pipeline 中跑 DryRun 做 SQL 规范校验。
+
+#### DryRun 在单元测试中的妙用
```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'
+func TestUserQuerySQL(t *testing.T) {
+ // 用 SQLite 内存库创建独立 DB(不需要 MySQL/PG 服务)
+ sqlDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+
+ sql := sqlDB.ToSQL(func(tx *gorm.DB) *gorm.DB {
+ return tx.Model(&User{}).
+ Where("status = ?", "active").
+ Order("created_at DESC").
+ Limit(10).
+ Find(&User{})
+ })
+
+ // 断言 SQL 中包含关键片段
+ assert.Contains(t, sql, "WHERE status =")
+ assert.Contains(t, sql, "ORDER BY created_at DESC")
+ assert.Contains(t, sql, "LIMIT 10")
+}
```
+### 获取生成的 SQL 字符串(ToSQL)
+
+GORM v2.4+ 提供了 `ToSQL` 方法,无需开启 DryRun 也能拿到最终的 SQL:
+
+```go
+import "gorm.io/gorm/schema"
+
+// 构建一个独立的 DB 实例(不连接数据库)
+sqlDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+
+// ToSQL 返回执行后的 SQL 字符串(不含参数展开)
+sql := sqlDB.ToSQL(func(tx *gorm.DB) *gorm.DB {
+ return tx.Model(&User{}).Where("name = ?", "john").Find(&User{})
+})
+fmt.Println(sql)
+// SELECT * FROM `users` WHERE name = 'john'
+```
+
+> [!tip] 何时需要拿原始 SQL?
+> - **生成 SQL 后交给他人审计**:把 ORM 生成的语句导出给 DBA 审查索引使用情况
+> - **单元测试断言**:对比预期 SQL 是否符合安全规范(如是否包含未参数化的拼接)
+> - **ORM 到原生迁移**:性能优化时,从 GORM 逐步替换为 Raw SQL 的过渡手段
+
### 日志中加入 TraceID(链路追踪)
+在生产环境中,单条 SQL 日志的价值取决于能否关联到完整请求链路。通过 `context.Context` 传递 TraceID 是行业标准做法:
+
```go
-func WithTraceID(db *gorm.DB, traceID string) *gorm.DB {
- return db.Session(&gorm.Session{
- Context: context.WithValue(context.Background(), "trace_id", traceID),
- })
+type contextKey struct{}
+
+// 中间件:从 HTTP Header 提取 trace_id 注入 Context
+func TraceMiddleware(db *gorm.DB) func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ traceID := r.Header.Get("X-Trace-Id")
+ if traceID == "" {
+ traceID = generateUUID() // 兜底生成
+ }
+ ctx := context.WithValue(r.Context(), contextKey{}, traceID)
+
+ // 用带 trace_id 的 Context 创建新的 DB 实例
+ tracedDB := db.Session(&gorm.Session{Context: ctx})
+ // 这里可以将 tracedDB 存到 Context 供 handler 使用
+ _ = tracedDB
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
}
-// 自定义日志输出中包含 trace_id
-type TracedLogger struct {
+// GORM 自定义 Logger:在日志中附加 TraceID
+type TraceLogger struct {
logger.Interface
}
-func (t TracedLogger) Info(ctx context.Context, msg string, data ...interface{}) {
- traceID, _ := ctx.Value("trace_id").(string)
- if traceID != "" {
+func (t TraceLogger) Info(ctx context.Context, msg string, data ...interface{}) {
+ if traceID, ok := ctx.Value(contextKey{}).(string); ok && traceID != "" {
msg = fmt.Sprintf("[trace:%s] %s", traceID, msg)
}
t.Interface.Info(ctx, msg, data...)
}
```
-## 日志配置决策图
+> [!tip] OpenTelemetry 集成要点
+> 如果你已接入 OpenTelemetry,可以直接复用 SpanContext 中的 trace ID,无需自己定义 context key:
+> ```go
+> import "go.opentelemetry.io/otel/trace"
+>
+> func extractTraceID(ctx context.Context) string {
+> span := trace.SpanFromContext(ctx)
+> if span.SpanContext().IsValid() {
+> return span.SpanContext().TraceID().String()
+> }
+> return ""
+> }
+> ```
+
+### 数据库方言对日志输出的影响
+
+不同数据库驱动会影响最终生成的 SQL 语法和参数占位符:
+
+| 驱动 | 参数占位符 | 字符串引号 | 日期格式 |
+|------|-----------|-----------|---------|
+| MySQL (`go-sql-driver/mysql`) | `?` | `'单引号'` | `'2026-04-28'` |
+| PostgreSQL (`pgx`) | `$1`, `$2`... | `'单引号'` | `TIMESTAMP '...'` |
+| SQLite (`mattn/go-sqlite3`) | `?` | `'单引号'` 或 `"双引号"` | `'...'` |
+| SQL Server (`microsoft/mssql-go`) | `@p1`, `@p2`... | `'单引号'` | `DATETIME2 '...'` |
+
+> [!note] DryRun 结果对比示例
+>
+> ```go
+> // MySQL DryRun → SELECT * FROM users WHERE id = ?
+> // PostgreSQL DryRun → SELECT * FROM users WHERE id = $1
+> // SQL Server DryRun → SELECT * FROM users WHERE id = @p1
+> //
+> // 这意味着测试时要用对应驱动初始化 ToSQL/DryRun,否则拿到的 SQL 无法直接在其他数据库中执行。
+> ```
+
+## 常见调试技巧总结
```mermaid
flowchart TD
@@ -254,9 +361,12 @@ flowchart TD
|------|------|---------|
| 生产环境日志太多导致磁盘爆满 | Logger 级别设太高 | 生产用 Warn 或 Error |
| 日志中没有 TraceID 难以定位 | 没传 Context | 用 Session + Context 传递 |
-| DryRun 拿到的 SQL 参数是 ? | 预编译语句的参数未展开 | 这是正常行为,参数化查询安全性的体现 |
-| Debug() 改了全局状态 | Debug 返回新的 *gorm.DB,不影响原实例 | 放心使用,它是纯函数式的 |
+| DryRun 拿到的 SQL 参数是 `?` | 预编译语句的参数未展开 | 这是正常行为,参数化查询安全性的体现 |
+| Debug() 影响了其他查询的输出 | 以为会改全局配置 | `Debug()` 返回新 `*gorm.DB` 实例,不影响原对象 |
| 慢查询阈值设太低 | 正常查询也被标记为 slow | 先收集基线数据再设定合理阈值 |
+| PostgreSQL 下 `?` 占位符报错 | PG 使用 `$n` 占位符 | 切换驱动或注意不同驱动的 DryRun 输出差异 |
+| 自定义 Logger 丢失默认行为 | 忘记实现所有四个方法 | 继承 `logger.Interface`,只覆盖需要的方法 |
+| 并发场景下 Logger 线程不安全 | 多个 goroutine 同时写日志 | 使用 Zap、Logrus 等并发安全的日志库 |
## 关联笔记
diff --git a/hhs/GORM/17-迁移工具.md b/hhs/GORM/17-迁移工具.md
index 4fbb04f..4222d80 100644
--- a/hhs/GORM/17-迁移工具.md
+++ b/hhs/GORM/17-迁移工具.md
@@ -13,21 +13,23 @@ create time: 2026-04-28 00:00
flowchart LR
Code["Go struct 变更"] --> AutoMigrate["AutoMigrate
自动同步 schema"]
Code --> MigrationFile["版本化 SQL 文件
goose / golang-migrate"]
-
- AutoMigrate --> Dev{"使用场景?"}
- MigrationFile --> Dev
-
- Dev --> |快速原型/个人项目| Quick["✅ AutoMigrate 够用"]
- Dev --> |团队协作/生产环境| Strict["✅ 版本化迁移方案"]
-
+
+ AutoMigrate --> UseCase{"使用场景?"}
+ MigrationFile --> UseCase
+
+ UseCase --> |快速原型/个人项目| Quick["✅ AutoMigrate 够用"]
+ UseCase --> |团队协作/生产环境| Strict["✅ 版本化迁移方案"]
+
style Code fill:#4FC08D,color:#fff
style Quick fill:#3B82F6,color:#fff
style Strict fill:#EAB308,color:#fff
```
-## AutoMigrate — 一键建表
+## 正文
-### 基本用法
+### AutoMigrate — 一键建表
+
+#### 基本用法
```go
func migrate(db *gorm.DB) error {
@@ -39,13 +41,18 @@ func migrate(db *gorm.DB) error {
}
```
-AutoMigrate 会在以下情况下自动操作:
+以上代码展示了最基础的用法。调用时传入所有需要管理的模型即可——GORM 会依次处理每个模型对应的表,并执行以下四种操作:
+
- **表不存在** → CREATE TABLE
- **列不存在** → ALTER TABLE ADD COLUMN
- **列类型不匹配** → ALTER TABLE MODIFY COLUMN
- **索引不存在** → CREATE INDEX
-### 增量更新
+> [!question] AutoMigrate 每次运行都会重新建表吗?
+>
+> 不会。它基于 Go struct tag 与数据库 schema 的差异做**增量比对**:只在确实缺少某个东西时才发出 ALTER 语句,已存在的结构会被跳过。
+
+#### 增量更新
```go
// 第一次:只有 User 和 Order
@@ -61,6 +68,8 @@ db.AutoMigrate(&User{})
// 不会删除已有的字段,只会添加新列和索引
```
+理解了增量更新的特性后,需要了解它的**边界**——AutoMigrate 设计上是"只加不改"的:
+
> [!warning] AutoMigrate 的限制
> - **不能删除**不再出现在 struct 中的列(需要手动处理)
> - **不能重命名**列(需要先删除再创建)
@@ -68,7 +77,9 @@ db.AutoMigrate(&User{})
> - MySQL 下 `ALTER TABLE MODIFY COLUMN` 可能会重建整张表(锁表!)
> - 某些复杂的类型变更(如 varchar 改 int)可能不被支持
-### DryRun 预览变更
+#### DryRun 预览变更
+
+有时在正式执行迁移之前,你想知道 GORM 会生成哪些 SQL。DryRun 模式可以帮到你:
```go
// 查看 AutoMigrate 会做什么,但不执行
@@ -78,12 +89,12 @@ if err != nil {
}
```
-> [!tip] DryRun + AutoMigrate 的组合
-> DryRun 模式下的 AutoMigrate 会构建 SQL 语句但**不执行**。结合这个特性可以做迁移前校验——在 CI 中检查新模型是否会导致破坏性变更。
+> [!tip] 生产场景建议
+> 在团队项目中,可以将 DryRun + AutoMigrate 加入 CI Pipeline——每次 PR 提交时校验新模型不会引入破坏性变更。如果 DryRun 报错,说明 struct tag 变更无法在当前数据库环境下执行。
-## Migrator 接口 —— 细粒度控制
+### Migrator 接口 —— 细粒度控制
-GORM 提供了 `Migrator` 接口来访问底层数据库的迁移能力:
+AutoMigrate 适合自动化场景,但在某些情况下你需要对迁移过程进行**更精细的控制**。比如:在迁移之前先检查某个表是否已存在、获取列的元数据信息、或者手动重命名列。这时就可以通过 `db.Migrator()` 获取 Migrator 实例来调用具体的操作:
```go
migrator := db.Migrator()
@@ -115,29 +126,35 @@ migrator.HasIndex(&User{}, "IdxEmail")
migrator.DropIndex(&User{}, "IdxEmail")
```
+从代码可以看出,Migrator 暴露了**逐个字段级别**的操作能力——你可以精确地检查、创建、删除某个具体的列或索引。这对于在迁移脚本中执行复杂变更非常有用。
+
> [!note] 不同驱动的 Migrator 实现
-> 每个驱动都有自己的 `Migrator` 实现。不是所有操作在所有数据库上都受支持:
+> 每个驱动都有自己的 `Migrator` 实现(如 `*mysql.Migrator`、`*postgres.Migrator`)。并非所有操作在所有数据库上都受支持:
+>
> ```go
-> // 检查某个驱动是否支持特定操作
+> // 利用类型断言检查具体驱动
> if migrator, ok := db.Migrator().(*mysql.Migrator); ok {
> // MySQL 特有的迁移功能
> }
> ```
-## 版本化迁移方案(生产推荐)
+### 版本化迁移方案(生产推荐)
在生产环境中,推荐使用专门的迁移工具配合 GORM:
-### 方案一:Goose
+#### 方案一:Goose
```bash
# 安装
go install github.com/pressly/goose/v3/cmd/goose@latest
-# 创建迁移文件
+# 创建迁移文件(按时间戳命名)
goose create add_user_email sql
+```
-# 生成文件:20260101_001_add_user_email.sql
+生成的文件类似 `20260101_001_add_user_email.sql`,内容结构如下:
+
+```sql
-- +goose Up
ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE;
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
@@ -147,58 +164,109 @@ ALTER TABLE users DROP COLUMN email;
ALTER TABLE users DROP COLUMN email_verified;
```
-与 GORM 集成:
+> [!note] Goose 的注释指令
+> `-- +goose Up` 和 `-- +goose Down` 是 Goose 识别迁移方向的特殊注释标记。Goose 通过解析这些标记来执行对应方向的迁移。
+
+与 GORM 集成(从 GORM 实例中获取底层 `*sql.DB`,交给 goose 执行版本化迁移):
```go
-func migrateDB(db *gorm.DB) {
- // 先让 GORM 做必要的表存在性检查和基础初始化
- db.AutoMigrate(&User{})
+import (
+ "log"
+ "os"
+
+ "github.com/pressly/goose/v3"
+ "gorm.io/gorm"
+)
+
+func migrateDB(db *gorm.DB) error {
+ // 从 GORM DB 中取出底层的 *sql.DB 交给 goose 管理
+ sqlDB, err := db.DB()
+ if err != nil {
+ return err
+ }
- // 然后用 goose 执行版本化迁移脚本
- goose.Run("up", dsn)
+ goose.SetBaseDir("./migrations") // 指定迁移文件目录
+ goose.SetLogger(log.New(os.Stdout, "", 0))
+ return goose.Up(sqlDB) // 按版本号依次执行未运行的 Up 脚本
}
```
-### 方案二:golang-migrate
+> [!note] Goose 如何保证幂等?
+> Goose 内部维护了一张 `goose_db_version` 表记录已执行的迁移版本。每次运行 `goose Up` 时只会执行版本号大于当前记录的脚本——因此重复运行不会报错。这与 AutoMigrate 的行为互补:AutoMigrate 负责确保 struct 对应的表存在,Goose 负责应用后续的版本化变更。
+
+#### 方案二: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
+生成文件结构如下:
+
+```
+migrations/
+├── 001_add_user_email.up.sql
+└── 001_add_user_email.down.sql
+```
+
+`up` 脚本内容示例:
+
+```sql
+-- migrations/001_add_user_email.up.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);
+```
--- <向下迁移>
+`down` 脚本内容示例:
+
+```sql
+-- migrations/001_add_user_email.down.sql
+ALTER TABLE users DROP CONSTRAINT IF EXISTS unique_email;
ALTER TABLE users DROP COLUMN IF EXISTS email;
```
+与 GORM 集成——启动时先执行迁移再连接数据库:
+
```go
-// 启动时执行迁移
+import (
+ "log"
+ "os"
+
+ "github.com/golang-migrate/migrate/v4"
+ _ "github.com/golang-migrate/migrate/v4/database/mysql"
+ _ "github.com/golang-migrate/migrate/v4/source/file"
+ "gorm.io/driver/mysql"
+ "gorm.io/gorm"
+)
+
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{})
+ m, err := migrate.New(
+ "migrations/file://./migrations",
+ dsn,
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // ErrNoChange 表示当前已经是最新状态,不算错误
+ if err := m.Up(); err != nil && err != migrate.ErrNoChange {
+ log.Fatal(err)
+ }
+
+ // 迁移成功后才连接 GORM
+ db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
```
-### 方案三:自建轻量级迁移表
+> [!note] golang-migrate vs Goose
+> `golang-migrate` 的优势在于支持**命名迁移文件**(如 `001_add_user_email.up.sql`),文件名可读性更强。而 Goose 的默认行为是按时间戳生成序列号。两者都能与 GORM 配合使用,选择取决于团队偏好。
+
+#### 方案三:自建轻量级迁移表
适合小型项目——用一个表记录当前迁移版本号:
@@ -240,6 +308,8 @@ func ApplyMigrations(tx *gorm.DB) error {
```
> [!question] 为什么生产环境不推荐只用 AutoMigrate?
+>
+> 核心原因是**不可回滚**。当你的代码从 `git` checkout 了一个旧版本,AutoMigrate 无法"撤销"之前添加的列或索引——它只会跳过已存在的部分。而版本化迁移方案通过 Down 脚本可以轻松应对回退场景:
| 维度 | AutoMigrate | 版本化迁移 |
|------|------------|-----------|
@@ -250,7 +320,10 @@ func ApplyMigrations(tx *gorm.DB) error {
| CI/CD 集成 | 难判断是否有未执行的变更 | migration_version 表可做 gate |
| 审计追踪 | ❌ | ✅ 谁在什么时候改了什么 |
-## 迁移策略对比
+> [!tip] 实际开发中的常见做法
+> 很多团队会选择 **AutoMigrate + DryRun** 作为本地开发的默认方式,同时在 CI/CD pipeline 中引入专门的迁移工具来做生产部署。这样既享受了开发时的便利,又保证了生产环境的可控性。
+
+### 迁移策略对比
```mermaid
flowchart TD
@@ -265,9 +338,11 @@ flowchart TD
style Large fill:#4FC08D,color:#fff
```
-## 迁移最佳实践
+### 迁移最佳实践
-### 1. 幂等性设计
+在编写任何迁移脚本之前,建立一个核心理念:**每一次数据库变更都应该是可追踪、可回滚的**。下面从三个维度展开:
+
+#### 1. 幂等性设计
迁移脚本应该能够重复执行而不报错:
@@ -280,7 +355,7 @@ CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone);
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
```
-### 2. 大表加列的最佳方式
+#### 2. 大表加列的最佳方式
对线上大表(百万级以上)直接 `ALTER TABLE ADD COLUMN` 会锁表导致服务不可用:
@@ -298,7 +373,9 @@ 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 中的迁移检查
+#### 3. CI Pipeline 中的迁移检查
+
+在持续集成流程中加入迁移检查,可以防止模型变更未经审核就部署到生产环境:
```yaml
# .github/workflows/db-check.yml
@@ -315,13 +392,42 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Check AutoMigrate consistency
+ - name: Setup Go
+ uses: actions/setup-go@v5
+
+ - name: Run dependency download
+ run: go mod download
+
+ - name: DryRun 校验新模型变更
run: |
- # 创建一个空的临时 DB,运行 AutoMigrate,然后比较 schema
- go run ./cmd/check-schema ${{ secrets.DB_DSN }}
+ # DryRun 模式下 AutoMigrate 只构建 SQL 不执行
+ # 如果 struct tag 存在语法错误或类型不兼容,此处会报错
+ go run ./cmd/dryrun-check ${{ secrets.DB_DSN }}
```
-## 常见坑点速查
+配套的 Go 实现思路如下:
+
+```go
+// cmd/dryrun-check/main.go
+func main() {
+ dsn := os.Args[1]
+ db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
+
+ // DryRun 模式:构建但不执行任何 SQL
+ err := db.Session(&gorm.Session{DryRun: true}).AutoMigrate(
+ &User{}, &Order{}, &Product{},
+ )
+ if err != nil {
+ log.Fatalf("⚠️ 模型变更无法应用: %v\n", err)
+ }
+ fmt.Println("✅ 模型变更可以通过 AutoMigrate")
+}
+```
+
+> [!tip] 进阶方案:schema 快照对比
+> 可以结合 `godbcompare` 或手动导出目标库的 `SHOW CREATE TABLE` 结果,与 AutoMigrate 生成的结构做 diff——这样可以检测出 **AutoMigrate 不会执行的破坏性变更**(如删除列)。
+
+### 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|