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 不会执行的破坏性变更**(如删除列)。 + +### 常见坑点速查 | 问题 | 原因 | 解决方案 | |------|------|---------|