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