diff --git a/hhs/GORM/00-基本语法与 API 概览.md b/hhs/GORM/00-基本语法与 API 概览.md
index dc27287..c450a90 100644
--- a/hhs/GORM/00-基本语法与 API 概览.md
+++ b/hhs/GORM/00-基本语法与 API 概览.md
@@ -1,5 +1,5 @@
---
-tags: [GORM, Go, ORM, API, 链式调用, Session, Clauses, Context]
+tags: [GORM, Go, ORM, API, 链式调用, Session, Clauses, Context, Scopes]
create time: 2026-05-06 00:00
---
@@ -48,12 +48,40 @@ db.Table("user_view").Select("name, email").Find(&results)
db.Table("users").Where("deleted_at IS NULL").Count(&count)
```
-> [!note] `Model()` 还能做级联更新
-> 当需要更新某个关联记录的所属关系时,用 `Model()` 指定主模型、`Omit()` 排除无关字段:
-> ```go
-> // 将订单 user_id 改为 5(只改这一个字段,不触发钩子)
-> db.Model(&Order{}).Where("id = ?", orderID).Omit("updated_at").Update("user_id", 5)
-> ```
+### `Model()` — 命名策略与前缀自动处理
+
+当使用 `Model(&User{})` 时,GORM 会按配置中的 `NamingStrategy` 解析表名:
+
+```go
+// 假设配置了前缀 + 单数模式
+db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{
+ NamingStrategy: schema.NamingStrategy{
+ TablePrefix: "crm_",
+ SingularTable: true,
+ },
+})
+
+db.Model(&User{}).Find(&users)
+// → SELECT * FROM crm_user ✅ 自动加前缀 + 变单数
+
+db.Table("users").Find(&users)
+// → SELECT * FROM users ❌ 完全忽略命名策略
+```
+
+> [!warning] 常见陷阱:Table 绕过命名策略
+> 团队协作中最容易犯的错误——有人为了"灵活"大量使用 `db.Table("xxx")`,后来改了 `TablePrefix`,结果查询查到了裸表(没有前缀)。排查这种问题非常耗时。
+> **建议**:项目中统一使用 `Model()`,需要跨库时才退回到 `Table("other_db.table_name")`。
+
+### `Model()` — 级联更新技巧
+
+当需要更新某个关联记录的所属关系时,用 `Model()` 指定主模型、`Omit()` 排除无关字段:
+
+```go
+// 将订单 user_id 改为 5(只改这一个字段,不触发钩子)
+db.Model(&Order{}).Where("id = ?", orderID).Omit("updated_at").Update("user_id", 5)
+```
+
+---
### `db.Session()` — 创建带配置的独立会话
@@ -161,6 +189,56 @@ db.Model(&Product{}).Where("id = ?", id).
> - **Updates 防零值污染**:结构体中有未传来的字段,默认零值会覆盖数据库数据,用 `Assign` 只写入你指定的值
> - **无主数据批量创建**:没有完整 Model,只有部分字段的临时数据
+### `Scopes()` —— 可复用的查询条件链
+
+Scopes 是 GORM 中**最容易被忽视但最有价值**的模式之一。它将一组查询条件封装成函数,像中间件一样在不同业务场景中复用:
+
+```go
+// 定义一个 Scope 函数——参数和返回值都是 *gorm.DB
+func Active(db *gorm.DB) *gorm.DB {
+ return db.Where("status = ?", "active")
+}
+
+func CreatedAfter(t time.Time) *gorm.DB {
+ return db.Where("created_at > ?", t)
+}
+
+// 使用 —— 自由组合多个 Scope
+var users []User
+db.Scopes(Active, CreatedAfter(time.Now().AddDate(0, 0, -30))).
+ Order("created_at DESC").
+ Find(&users)
+// WHERE status = 'active' AND created_at > '...' ORDER BY created_at DESC
+```
+
+> [!tip] Scopes vs 中间态 DB(变量保存 Where)
+>
+> | 维度 | Scopes(函数) | 中间态 DB(变量) |
+> |------|---------------|------------------|
+> | 复用粒度 | 跨模块、跨文件 | 同一请求内的不同分支 |
+> | 可组合性 | `Scopes(f1, f2)` 自由组合 | 需要手动拼接 |
+> | 参数化 | 支持闭包传入参数 | 直接在变量上追加 |
+> | 测试友好 | 每个 Scope 独立可测 | 需构造完整 DB 对象 |
+>
+> ```go
+> // 带参数的 Scope —— 通过闭包传参
+> func Page(page, pageSize int) func(db *gorm.DB) *gorm.DB {
+> return func(db *gorm.DB) *gorm.DB {
+> offset := (page - 1) * pageSize
+> return db.Offset(offset).Limit(pageSize)
+> }
+> }
+>
+> // 使用
+> db.Scopes(Page(2, 20)).Find(&items)
+> ```
+
+> [!tip] Scope 编写规范
+> - **参数始终是 `*gorm.DB`,返回值也是 `*gorm.DB`**——这是 GORM 约定的签名
+> - 不要修改传入的 DB 的配置(如 Logger),只追加查询条件
+> - Scope 可以组合任意数量的 Where / Order / Select / Omit 等操作
+> - 需要 SQL 注入防护时(如动态排序字段),在 Scope 内部做白名单校验
+
### `WithContext()` / `Ctx` — 上下文集成
生产环境中经常需要超时控制、取消请求、传递 trace ID。GORM 完全兼容 Go 标准库的 `context.Context`。
@@ -256,6 +334,81 @@ if err := tx.Create(&user).Error; err != nil {
return tx.Commit().Error // 全部成功才提交
```
+## API 速查参考卡
+
+面向快速决策——面对一个具体需求时,不知道该用哪个 API?参考下面的对照表。
+
+### 读取单条记录
+
+| 方法 | SQL 行为 | 是否必须有条件 | 适用场景 |
+|------|---------|--------------|---------|
+| `First(&v, cond)` | `ORDER BY PK ASC LIMIT 1` + WHERE | ✅ 必须 | 按主键或条件取确定的一条 |
+| `Take(&v)` | 无排序,随机一条 | ✅ 必须 | 抽奖、随机推荐 |
+| `Last(&v, cond)` | `ORDER BY PK DESC LIMIT 1` | ✅ 必须 | 取最新的一条 |
+
+> **经验法则**:拿不到记录时会返回 `gorm.ErrRecordNotFound`。**永远检查 `.Error`**。详见 [[03-CRUD 操作]]。
+
+### 批量查询
+
+| 方法 | 返回类型 | 是否需要 Where | 说明 |
+|------|---------|--------------|------|
+| `Find(&slice)` | `[]Model` | ❌ 可选 | 默认查所有匹配行,**不加 Where = 全表扫描** |
+| `Pluck("col", &slice)` | 切片(非 Model) | ❌ 可选 | 只取一列的值,如 `[]string{"Alice","Bob"}` |
+| `Scan(&dest)` | 任意结构体 | ❌ 可选 | 把结果映射到自定义结构体 |
+
+### 更新策略对比
+
+| 方法 | 参数类型 | 零值处理 | 触发 Hooks | 典型场景 |
+|------|---------|---------|-----------|---------|
+| `Update(col, val)` | string + any | — | ✅ | 改单个字段 |
+| `Updates(map)` | `map[string]any` | **写入零值** | ✅ | 前端完整表单提交 |
+| `Updates(struct)` | struct | **跳过零值** | ✅ | 部分字段修改 |
+| `Save` | struct | **全部写入** | ✅ | 全量覆盖(谨慎使用) |
+| `UpdateColumn(...)` | 同 Update | — | ❌ 跳过 | 绕过钩子直接写 |
+
+> [!warning] Updates 的零值陷阱
+> ```go
+> // 用户年龄改为 0 —— 如果用 struct,Age=0 被跳过,数据库不变!
+> db.Model(&user).Updates(User{Name: "Alice", Age: 0}) // ⚠️ Age 没变
+>
+> // ✅ 改用 map
+> db.Model(&user).Updates(map[string]any{"name": "Alice", "age": 0}) // ✅ Age 变为 0
+> ```
+> 详见 [[03-CRUD 操作]]。
+
+### 创建方式对比
+
+| 方法 | 适用场景 | 批次控制 | 说明 |
+|------|---------|---------|------|
+| `Create(&model)` | 单条插入 | 无 | 最基础的插入 |
+| `Create(&slice)` | 中小批量(≤10K) | GORM 自动拆批 ~256 | 内部拆分为多条 INSERT |
+| `CreateInBatches(&slice, n)` | 超大批量(>10K) | 可指定 batch size | 适合数据导入、迁移 |
+| `Clauses(OnConflict)...Create()` | Upsert | 同上 | 存在则更新,不存在则插入 |
+
+> 详见 [[11-批量操作]]。
+
+### 字段选择
+
+| 方法 | 行为 | 示例 |
+|------|------|------|
+| `Select("col1, col2")` | 白名单:只读这些列 | `db.Select("id,name").Find(&users)` |
+| `Omit("col1", "col2")` | 黑名单:不读这些列 | `db.Omit("password","bio").Find(&users)` |
+| `Pluck("col", &dest)` | 只取一列的值 | `db.Pluck("email", &emails)` |
+
+> **选择策略**:需要的列少 → Omit;需要的列多 → Select。敏感字段永远建议 Omit。
+
+### 删除方式
+
+| 方法 | 行为 | 是否可恢复 |
+|------|------|---------|
+| `Delete(&model)` | 软删除(有 DeletedAt 时)→ UPDATE | ✅ 可恢复 |
+| `Where(...).Delete(&Model{})` | 条件批量删除 | 取决于模型是否有软删除 |
+| `Unscoped().Delete(...)` | 物理删除(忽略软删除) | ❌ 不可恢复 |
+
+> 详见 [[10-软删除]]。
+
+---
+
## 常见链式组合 Recipes
按场景给出最常用的一行写法模板:
@@ -330,17 +483,56 @@ flowchart TD
Control -->|"跳过钩子/调试"| Session["&gorm.Session{SkipHooks/DryRun}"]
Control -->|"关联赋值"| Assign["Assign(值).Create/Update"]
Control -->|"超时/取消"| Context["WithContext(ctx)"]
+ Control -->|"条件需复用"| Scopes["Scopes(f1, f2)"]
Control -->|"普通查询继续链式调用"| Chain["正常链式调用 Where/Order/Limit"]
- Chain --> Result["Find/First/Create/Update/Delete"]
- Clauses --> Result
+ Chain --> MethodQ{"读取几条?"}
+
+ MethodQ --> |1条| SingleQ{需要确定性?}
+ SingleQ --> |是| First["First - ORDER BY PK ASC LIMIT 1"]
+ SingleQ --> |否_随机| Take["Take - 随机一条"]
+
+ MethodQ --> |N条| FindMode{"查哪些列?"}
+ FindMode --> |全列| Find["Find - 加 Where 过滤"]
+ FindMode --> |单列| Pluck["Pluck - 只取一列"]
+ FindMode --> |部分列| SelectOmit{"少选多?"}
+ SelectOmit --> |选少| Omit["Omit - 排除法"]
+ SelectOmit --> |选多| Select["Select - 白名单"]
+
+ MethodQ --> |超大结果集| Rows["Rows() 游标逐行消费"]
+
+ Chain --> UpdateQ{"改几个字段?"}
+
+ UpdateQ --> |1个| OneField["Update(col, val)"]
+ UpdateQ --> |部分| ZeroQ{含零值?}
+ ZeroQ --> |是_map_| MapUpdate["Updates(map)"]
+ ZeroQ --> |否_struct_| StructUpdate["Updates(struct)"]
+ UpdateQ --> |全部| FullUpdate["Save - 全量覆盖"]
+
+ UpdateQ --> |无钩子需求| NoHook["UpdateColumn / UpdateColumns"]
+
+ Clauses --> Result["执行 CRUD 动作"]
Session --> Result
Assign --> Result
Context --> Result
+ Scopes --> Result
+ First --> Result
+ Take --> Result
+ Find --> Result
+ Pluck --> Result
+ Omit --> Result
+ Select --> Result
+ Rows --> Result
+ OneField --> Result
+ MapUpdate --> Result
+ StructUpdate --> Result
+ FullUpdate --> Result
+ NoHook --> Result
style Start fill:#4FC08D,color:#fff
style Model fill:#3B82F6,color:#fff
style Table fill:#EAB308,color:#000
+ style Scopes fill:#8B5CF6,color:#fff
style Result fill:#10B981,color:#fff
```
@@ -348,6 +540,13 @@ flowchart TD
- [[01-安装与初始化]]
- [[02-模型定义]]
-- [[03-CRUD 操作]]
-- [[04-条件查询]]
-- [[08-事务管理]]
+- [[03-CRUD 操作]] — 详细版 First/Find/Create/Update/Delete
+- [[04-条件查询]] — Where/Or/Between/In/Like 详解
+- [[05-关联查询]] — Preload/Joins/Association
+- [[06-排序与分页]] — Order/Limit/Paginate
+- [[08-事务管理]] — Begin/Commit/Rollback
+- [[09-钩子函数]] — LifecycleHooks/全局Callback
+- [[10-软删除]] — SoftDelete/Unscoped
+- [[11-批量操作]] — Bulk Insert/Update/Upsert
+- [[14-错误处理]] — RecordNotFound/ErrDuplicatedKey
+- [[15-性能优化]] — N+1/Preload策略/预编译语句
diff --git a/hhs/GORM/03-CRUD 操作.md b/hhs/GORM/03-CRUD 操作.md
index 78cfbad..d4fdd6a 100644
--- a/hhs/GORM/03-CRUD 操作.md
+++ b/hhs/GORM/03-CRUD 操作.md
@@ -241,9 +241,10 @@ db.Model(&Article{}).Omit("content", "password").Find(&articles)
```
> [!tip] `Omit` vs `Select` 的选择策略
-> - 需要的列少(比如 3 个字段中有 20 个)→ `Omit` 更省事
-> - 需要的列多 → `Select` 更清晰
-> - 敏感字段(密码、内部密钥)永远建议 `Omit`,以防误暴露
+> - **要查询的列很少**(例如 3/20)→ `Select` 更简洁,明确列出所需字段
+> - **要排除的列很少**(例如 3 个不要 / 17 个保留)→ `Omit` 更省事
+> - **原则**:谁的参数少就用谁,代码更直观
+> - **敏感字段**(密码、密钥等)永远用 `Omit` 排除——白名单式防护,防止重构或新增字段时意外暴露
### Distinct — 去重查询
@@ -319,10 +320,10 @@ fmt.Println(user.ID) // 插入后 ID 自动回填到结构体
| 方法 | 签名 | 行为 |
|------|------|------|
| `Update` | `(col string, value any)` | 更新单个字段 |
-| `Updates` | `(value Model | map[string]any)` | 更新一个或多个字段 |
+| `Updates` | `(value Model / map[string]any)` | 更新一个或多个字段 |
| `Save` | `(value Model)` | 全量更新所有字段 |
-| `UpdateColumn` | `(col string, value any)` | 同 Update 但不触发 Hooks |
-| `UpdateColumns` | `(value Model)` | 同 Updates 但不触发 Hooks |
+| `UpdateColumn` | `(col string, value any)` | 同 `Update` 但不触发 Hooks |
+| `UpdateColumns` | `(value Model)` | 同 `Updates` 但不触发 Hooks |
### 详细示例
diff --git a/hhs/GORM/04-条件查询.md b/hhs/GORM/04-条件查询.md
index 519e011..27152e5 100644
--- a/hhs/GORM/04-条件查询.md
+++ b/hhs/GORM/04-条件查询.md
@@ -246,30 +246,44 @@ q.Order("created_at DESC").Limit(10).Find(&activeUsers)
```mermaid
flowchart TD
Start["收到查询请求"] --> Type{"条件类型"}
-
- Type --> "精确匹配" --> Exact["Where field, value"]
- Type --> "范围查询" --> RangeQ{"BETWEEN 还是 GT/LT"}
- RangeQ --> "GORM 自带" --> Between["BETWEEN ? AND ?"]
- RangeQ --> "自定义 < >" --> LtGt["field > ? AND field < ?"]
- Type --> "集合" --> SetQ{"IN 还是 NOT IN"}
- SetQ --> "IN" --> InOp["In field VALUES"]
- SetQ --> "NOT IN" --> NotInOp["Not In field VALUES"]
- Type --> "模糊" --> LikeQ{"前缀/后缀/全匹配"}
- LikeQ --> "前缀 %" --> PrefixLike["LIKE 'prefix%'"]
- LikeQ --> "后缀 %" --> SuffixLike["LIKE '%suffix'"]
- LikeQ --> "全匹配 %" --> FullLike["LIKE '%keyword%'"]
- Type --> "NULL检查" --> NullQ{"IS NULL 还是 NOT"}
- NullQ --> "IS NULL" --> IsNullOp["IS NULL"]
- NullQ --> "IS NOT NULL" --> IsNotNullOp["IS NOT NULL"]
- Type --> "复合条件" --> AndQ{"简单AND 还是 OR混合"}
- AndQ --> "简单AND" --> SimpleAnd["连续 Where 调用"]
- AndQ --> "OR混合" --> OrChain["Where + Or 链式"]
- Type --> "特殊SQL" --> Raw["Raw + Expr"]
-
+
+ Type --> Exact["精确匹配\nWhere field, value"]
+ Type --> RangeQ{"范围查询\nBETWEEN 还是 GT/LT"}
+ RangeQ --> Between["GORM 自带\nBETWEEN ? AND ?"]
+ RangeQ --> LtGt["自定义 < >\nfield > ? AND field < ?"]
+ Type --> SetQ{"集合\nIN 还是 NOT IN"}
+ SetQ --> InOp["IN\nIn field VALUES"]
+ SetQ --> NotInOp["NOT IN\nNot In field VALUES"]
+ Type --> LikeQ{"模糊匹配\n前缀 / 后缀 / 全匹配"}
+ LikeQ --> PrefixLike["LIKE 'prefix%'"]
+ LikeQ --> SuffixLike["LIKE '%suffix'"]
+ LikeQ --> FullLike["LIKE '%keyword%'"]
+ Type --> NullQ{"NULL 检查\nIS NULL 还是 NOT"}
+ NullQ --> IsNullOp["IS NULL"]
+ NullQ --> IsNotNullOp["IS NOT NULL"]
+ Type --> AndQ{"复合条件\n简单 AND 还是 OR 混合"}
+ AndQ --> SimpleAnd["简单 AND\n连续 Where 调用"]
+ AndQ --> OrChain["OR 混合\nWhere + Or 链式"]
+ Type --> Raw["特殊 SQL\nRaw + Expr"]
+
style Start fill:#4FC08D,color:#fff
style Exact fill:#3B82F6,color:#fff
- style LikeOp fill:#F59E0B,color:#000
+ style Between fill:#3B82F6,color:#fff
+ style LtGt fill:#3B82F6,color:#fff
+ style InOp fill:#3B82F6,color:#fff
+ style NotInOp fill:#3B82F6,color:#fff
+ style LikeQ fill:#F59E0B,color:#000
+ style PrefixLike fill:#F59E0B,color:#000
+ style SuffixLike fill:#F59E0B,color:#000
+ style FullLike fill:#F59E0B,color:#000
+ style NullQ fill:#8B5CF6,color:#fff
+ style IsNullOp fill:#8B5CF6,color:#fff
+ style IsNotNullOp fill:#8B5CF6,color:#fff
+ style AndQ fill:#EC4899,color:#fff
+ style SimpleAnd fill:#EC4899,color:#fff
+ style OrChain fill:#EC4899,color:#fff
style Raw fill:#EC4899,color:#fff
+ style Start fill:#4FC08D,color:#fff
```
## 常见坑点速查
diff --git a/hhs/MySQL/01-MySQL 架构与进程模型.md b/hhs/MySQL/01-MySQL 架构与进程模型.md
new file mode 100644
index 0000000..7c9dcd9
--- /dev/null
+++ b/hhs/MySQL/01-MySQL 架构与进程模型.md
@@ -0,0 +1,268 @@
+---
+tags: [MySQL, 架构, 进程模型, Server]
+create time: 2026-05-16 00:00
+---
+
+# MySQL 架构与进程模型
+
+## 概述
+
+理解 MySQL 的内部架构是性能调优和故障排查的前提。本文将从 Client-Server 模型出发,逐层拆解 MySQL 的四大功能层以及不同存储引擎下的线程调度机制。
+
+## 四层架构
+
+MySQL 遵循经典的三层 Client-Server 架构,但在服务端内部被清晰划分为四层:
+
+```mermaid
+graph BT
+ subgraph "应用层"
+ A["应用程序
(Go / Java / Python)"]
+ end
+
+ subgraph "连接层
Connection Layer"
+ B["Thread Pool"]
+ C["Authentication"]
+ D["Connection Management"]
+ end
+
+ subgraph "SQL 层
SQL Layer"
+ E["Query Cache ⚠️ 8.0 已移除"]
+ F["Parser → Preprocessor"]
+ G["Optimizer"]
+ H["Executor"]
+ end
+
+ subgraph "存储引擎层
Storage Engine"
+ I["InnoDB"]
+ J["MyISAM"]
+ K["Memory"]
+ L["Archive"]
+ end
+
+ subgraph "文件系统"
+ M["Data Files"]
+ N["Binlog"]
+ O["Redo Log"]
+ end
+
+ A --> B
+ B --> F
+ F --> G --> H --> I
+ H --> J
+ H --> K
+ H --> L
+ I --> M
+ I --> N
+ I --> O
+```
+
+### 各层职责
+
+**连接层**负责处理客户端的连接请求,包括身份验证、权限校验、SSL/TLS 协商以及线程分配。MySQL 采用「一连接一线程」模型——每个客户端连接对应一个独立的服务器线程,这意味着万级并发时会产生巨大的线程开销。
+
+> [!NOTE] 为什么高并发场景要考虑线程池?
+> MySQL 原生没有内置线程池(Percona Server 提供)。当并发量超过数千时,上下文切换的开销会成为瓶颈。解决方案有三:
+> - **ProxySQL / MaxScale**:前端代理层做连接复用
+> - **MySQL Thread Pool Plugin**:企业版功能或 Percona 分支
+> - **连接池**:在应用层控制并发连接数(如 Go 的 `sql.DB`)
+
+**SQL 层**是整个数据库的核心,完成 SQL 语句的解析、优化和执行。这一层实现了 SQL 标准语法、查询优化策略、函数系统和事务管理。值得注意的是,**很多用户熟悉的 SQL 特性其实都在这一层实现**——比如子查询改写、JOIN 顺序优化等。
+
+**存储引擎层**提供了可插拔的存储方案。不同的引擎对索引、锁、事务的支持各不相同。InnoDB 承担了默认的事务型负载,而 MyISAM、Memory 等引擎则针对特定场景做了优化。由于大多数生产环境只使用 InnoDB,现代运维中这一层其实非常安静。
+
+**文件系统层**将页(Page,默认 16KB)写入磁盘。InnoDB 有自己的 Buffer Pool 来管理数据缓存,同时也依赖操作系统的 Page Cache。**Linux 内核的 I/O 调度器对 MySQL 性能有直接影响**。
+
+## 线程模型详解
+
+MySQL 服务启动时会创建一组后台线程,同时为每个连接分配专用线程:
+
+```mermaid
+classDiagram
+ class MasterThread {
+ +binlog dump
+ +purge redo log
+ +truncate binary log
+ +DD event cleanup
+ +change buffer merge
+ }
+
+ class ConnectionThread {
+ +parse query
+ +optimize
+ +execute
+ +send result
+ }
+
+ class WorkerThreads {
+ +InnoDB read worker
+ +InnoDB log writer
+ +InnoDB page cleaner
+ }
+
+ class ThreadPool {
+ <>
+ +acceptor thread
+ +worker pool
+ +idle stack management
+ }
+
+ MasterThread ..> WorkerThreads : "coordinates"
+ ThreadPool --> ConnectionThread : "dispatches"
+```
+
+### 关键后台线程
+
+| 线程 | 职责 |
+|------|------|
+| **Master Thread** | InnoDB 专属,协调写操作、刷新脏页、合并 Change Buffer |
+| **Log Thread** | 将 Redo Log Buffer 刷入 Redo Log File |
+| **Page Cleaner** | 主动将脏页从 Buffer Pool 刷盘,避免崩溃恢复时间过长 |
+| **Purge Thread** | 删除 Undo Log 中标记为废弃的行记录 |
+| **DD Event Cleanup** | 清理数据字典中的过期对象信息 |
+
+### 连接线程生命周期
+
+```mermaid
+stateDiagram-v2
+ [*] --> New: 客户端发起 TCP 连接
+ New --> Handshake: 握手协议 + 认证
+ Handshake --> Authenticated: 认证成功
+ Handshake --> Closed: 认证失败
+ Authenticated --> Sleeping: 发送命令
+ Sleeping --> Executing: 收到新命令
+ Executing --> Sending: 执行完毕
+ Sending --> Sleeping: 返回结果集
+ Sleeping --> Closed: 客户端断开 / 超时
+ Sleeping --> Killed: 管理员 kill 掉
+```
+
+> [!QUESTION] 什么是 MySQL 连接超时?
+> 如果客户端空闲太久不发消息会发生什么?MySQL 有两个关键超时参数:
+> - **`wait_timeout`**(默认 28800 秒 = 8 小时):非交互式连接的闲置超时
+> - **`interactive_timeout`**(默认 28800 秒):交互式连接(如 mysql CLI)的闲置超时
+>
+> 连接池中如果未正确配置这些参数,可能出现大量 ZOMBIE 连接占满 `MaxConnections`。这就是为什么 Go 的 `sql.DB.ConnMaxLifetime` 应该设为比 wait_timeout 更短的值。
+
+## mysqld 启动流程
+
+```mermaid
+flowchart TD
+ A["加载 my.cnf 配置"] --> B["初始化全局变量"]
+ B --> C["加载插件"]
+ C --> D["初始化存储引擎
innodb_init()"]
+ D --> E["打开数据目录"]
+ E --> F["恢复未提交事务
redo log crash recovery"]
+ F --> G["启动后台线程"]
+ G --> H["监听端口"]
+ H --> I["就绪 ✨"]
+```
+
+启动过程中的关键阶段:
+
+1. **配置加载**:按优先级读取配置文件 `/etc/my.cnf` → `/etc/mysql/my.cnf` → `~/.my.cnf`,命令行参数优先级最高
+2. **引擎初始化**:InnoDB 会在启动时进行 Crash Recovery——重放 Redo Log 恢复到一致状态。如果数据文件损坏严重,这一步会卡住或报错
+3. **Crash Recovery**:innodb_force_recovery 参数可以在恢复期间限制操作级别(1~6),用于紧急导出数据
+
+## 核心架构图
+
+```mermaid
+graph TB
+ subgraph "连接管理"
+ C1["Accept Thread"] --> C2["Connection Pool"]
+ C2 --> C3["Read Command"]
+ end
+
+ subgraph "SQL 处理流水线"
+ C3 --> P1["Syntax Parser"]
+ P1 --> P2["AST Semantic Check"]
+ P2 --> P3["Preprocessing"]
+ P3 --> P4["Optimization"]
+ P4 --> P5["Execution"]
+ end
+
+ subgraph "InnoDB 执行路径"
+ P5 --> I1["Buffer Pool Read Page"]
+ I1 --> I2{"Hit?"}
+ I2 -->|"Yes"| I3["Return Result"]
+ I2 -->|"No"| I4["Read from Disk"]
+ I4 --> I1
+ I5["Change Buffer"] -.-> I1
+ end
+
+ C1 --> C3
+ style P4 fill:#FF9F43,color:#000
+ style I1 fill:#00B6BC,color:#fff
+ style I3 fill:#00D866,color:#fff
+```
+
+## 实战:在线诊断工具
+
+在生产环境中快速定位问题,需要掌握以下几组命令行工具。它们分别作用于不同的架构层级:
+
+### 1. SHOW PROCESSLIST — SQL 层视角
+
+```sql
+-- 最基础的线程状态查看
+SHOW FULL PROCESSLIST;
+
+-- 等效的 SQL 查询(可结合 WHERE 过滤)
+SELECT * FROM information_schema.processlist WHERE COMMAND != 'Sleep';
+```
+
+每个线程在 `PROCESSLIST` 中表现为一条记录,关键列:
+
+| 列 | 含义 | 常见值 |
+|----|------|--------|
+| `ID` | 线程 ID,`KILL id` 使用 | |
+| `State` | 当前执行阶段 | `Sending data`, `Creating sort index`, `Locked` |
+| `Time` | 当前状态的持续秒数 | > 5s 需警惕 |
+| `Info` | 正在执行的 SQL | NULL 表示空闲 |
+
+> [!TIP] State 解读口诀
+> - **`Waiting for handler lock`** → 行锁竞争,配合 `Innodb_row_lock_waits` 排查
+> - **`Creating sort index`** → 大 ORDER BY/GROUP BY,检查是否缺少索引
+> - **`Sending data`** → 不仅是在"发送结果",而是**正在扫描和生成数据**,往往是最耗时的阶段
+
+### 2. SHOW STATUS — 全局计数器
+
+```sql
+-- 查看慢查询总数(取决于 slow_query_log 配置)
+SHOW GLOBAL STATUS LIKE 'Slow_queries';
+
+-- 当前并发连接数 vs 上限
+SHOW GLOBAL STATUS LIKE 'Threads_connected';
+SHOW GLOBAL STATUS LIKE 'Max_connections';
+
+-- InnoDB 缓冲池命中率(接近 100% 为佳)
+SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
+```
+
+> [!QUESTION] 为什么 Buffer Pool 命中率不是越高越好?
+> 理论上 Hit Rate 越接近 100% 越好,但当数据集小于 Buffer Pool 时,Hit Rate 会固定在 100% 以上(因为缓存了多次)。生产环境中 **98%~99%** 已经足够优秀,不必盲目增大 `innodb_buffer_pool_size`。
+
+### 3. Performance Schema — MySQL 5.7+ 的深层监控
+
+`performance_schema` 是 MySQL 内置的性能采集框架,比 `SHOW STATUS` 更细粒度:
+
+```sql
+-- 当前等待事件(锁、I/O、网络)TOP 10
+SELECT EVENT_NAME, COUNT_STAR, AVG_TIMER_WAIT
+FROM performance_schema.events_waits_summary_global_by_event_name
+ORDER BY COUNT_STAR DESC LIMIT 10;
+
+-- 最近 10 条耗时 > 1s 的语句
+SELECT DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT/1e12 AS avg_ms
+FROM performance_schema.events_statements_summary_by_digest
+WHERE AVG_TIMER_WAIT > 1e12
+ORDER BY AVG_TIMER_WAIT DESC LIMIT 10;
+```
+
+> [!NOTE] Performance Schema 的性能代价
+> 开启 PS 会带来约 **5%~15%** 的 CPU 开销。建议在低峰期采样分析,或仅对目标线程启用 instrumentation。
+
+---
+
+## 关联笔记
+
+- [[hhs/GORM/01-安装与初始化]] — GORM 连接 MySQL 的配置参数
+- [[hhs/Redis/01-安装与部署]] — Redis 单线程 vs MySQL 多线程模型对比
diff --git a/hhs/MySQL/02-安装与初始化.md b/hhs/MySQL/02-安装与初始化.md
new file mode 100644
index 0000000..9de3d13
--- /dev/null
+++ b/hhs/MySQL/02-安装与初始化.md
@@ -0,0 +1,338 @@
+---
+tags: [MySQL, 安装, Docker, 配置]
+create time: 2026-05-16 00:00
+---
+
+# MySQL 安装与初始化
+
+## 概述
+
+本文系统讲解 MySQL Community Edition 的安装部署和初始化配置,涵盖三种主流方式:**Docker Compose**(推荐开发环境)、**YUM/RPM 包安装**、**通用二进制包安装**。同时深入解析 `my.cnf` 配置文件的核心参数体系,以及 `mysql_install_db` 初始化时的底层行为。
+
+> [!TIP] 学习建议
+>
+> 1. **初学者**优先掌握 Docker Compose 方式,快速搭建可复现的开发环境;
+> 2. **运维/生产场景**参考二进制安装流程,理解服务初始化的完整链条;
+> 3. `my.cnf` 参数部分按优先级阅读,先吃透 InnoDB 和网络两大块,日志和 Binlog 可在后续进阶时补充。
+>
+> *思考题:为什么 InnoDB Buffer Pool 通常建议设为物理内存的 50%~70%,而不是 90%?*(答案见下文 InnoDB 参数段落的注释)
+
+## Docker Compose 方式(推荐开发环境)
+
+这是最轻量且可复现的方案,适合日常开发和 CI 测试。
+
+```yaml
+version: '3.8'
+services:
+ mysql:
+ image: mysql:8.0
+ container_name: mysql-dev
+ restart: unless-stopped
+ environment:
+ MYSQL_ROOT_PASSWORD: rootpassword # 首次启动自动创建 root 用户
+ MYSQL_DATABASE: app_db # 自动创建初始数据库
+ MYSQL_USER: app_user # 创建普通应用账户
+ MYSQL_PASSWORD: app_password # 该账户对 MYSQL_DATABASE 拥有完全权限
+ ports:
+ - "3306:3306" # 宿主机 3306 → 容器内 3306
+ volumes:
+ - ./init-db:/docker-entrypoint-initdb.d # 首次启动自动执行 SQL
+ - mysql-data:/var/lib/mysql # 持久化数据(容器删除后不丢失)
+ - ./my-custom.cnf:/etc/mysql/conf.d/my.cnf # 覆盖默认配置
+ command: >
+ --character-set-server=utf8mb4 # 统一字符集
+ --collation-server=utf8mb4_unicode_ci # 排序规则(不区分大小写)
+ --default-authentication-plugin=mysql_native_password # 兼容旧版客户端认证
+ --max-connections=500 # 最大并发连接数
+ --innodb-buffer-pool-size=256M # 开发机无需太大
+
+volumes:
+ mysql-data:
+```
+
+### 启动与常用操作
+
+```bash
+docker compose up -d # 后台启动
+docker compose down # 停止并移除容器(保留 volume)
+docker compose logs -f # 查看实时日志
+docker compose exec mysql mysql -u root -p # 进入容器内客户端
+docker compose ps # 查看容器状态
+docker compose restart # 重启(配置变更后适用)
+```
+
+> [!QUESTION] 生产环境应该硬编码密码吗?
+> 当然不。上述 `docker-compose.yml` 中的明文密码仅适用于本地开发。
+> 生产环境应使用 Docker Secrets、环境变量文件 `.env` 或密钥管理工具(如 HashiCorp Vault)来注入凭据。
+>
+> ```bash
+> # .env 文件示例(加入 .gitignore!)
+> MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASS}
+> MYSQL_DATABASE=myapp_prod
+> ```
+>
+> Docker Compose 会自动将 `${VAR}` 替换为环境变量值。
+
+### init-db 目录妙用
+
+`docker-entrypoint-initdb.d/` 下只要是 `.sql` 或 `.sh` 脚本,会在数据库**首次启动**时按字母序自动执行:
+
+| 文件类型 | 执行时机 | 典型用途 |
+|---------|---------|---------|
+| `.sql` | 首次初始化 | DDL建表、默认数据、权限分配 |
+| `.sh` | 首次初始化 | 调用 `mysql` CLI 执行动态逻辑 |
+
+> [!WARNING] 注意
+> - 容器已有数据时,init-db 脚本**不会被重新执行**。修改脚本后需删除 volume 重建:`docker compose down -v && docker compose up -d`
+> - `.sh` 脚本中若需连接 MySQL,务必等待 ready(可通过 `docker compose wait` 或健康检查机制)
+
+## 二进制安装(生产环境参考)
+
+对于需要精细控制的场景,官方推荐的两种方式:YUM/RPM 包适合标准发行版;通用二进制包提供最大灵活性。
+
+### 方式一:YUM/RPM(CentOS / RHEL / Amazon Linux)
+
+```bash
+# 1. 下载 YUM 仓库配置(以 EL9 为例)
+sudo rpm -Uvh https://dev.mysql.com/get/mysql80-community-release-el9-3.noarch.rpm
+
+# 2. 安装 MySQL Server
+sudo yum install mysql-server -y
+
+# 3. 启动服务并设置开机自启
+sudo systemctl start mysqld
+sudo systemctl enable mysqld
+
+# 4. 获取临时 Root 密码(首次安装后打印到日志)
+sudo grep 'temporary password' /var/log/mysqld.log
+
+# 5. 安全加固(修改默认密码、移除匿名账户等)
+sudo mysql_secure_installation
+```
+
+> [!TIP] 临时密码在哪?
+> MySQL 8.0 首次启动时会在 `/var/log/mysqld.log` 中生成一个随机临时密码。
+> 找到类似 `A temporary password is generated for root@localhost: xxxxxxxx` 的行,使用该密码登录 `mysql_secure_installation`。
+
+### 方式二:通用二进制包(跨发行版兼容)
+
+适用于非标准发行版或需要自定义目录结构的场景:
+
+```bash
+# 1. 下载并解压(替换实际版本号)
+wget https://dev.mysql.com/get/Downloads/MySQL-8.0/mysql-8.0.xx-linux-glibc2.28-x86_64.tar.xz
+tar -xvf mysql-8.0.xx-linux-glibc2.28-x86_64.tar.xz
+sudo mv mysql-8.0.xx-linux-glibc2.28-x86_64 /usr/local/mysql
+
+# 2. 创建专用系统用户(不建议用 root 运行 MySQL)
+cd /usr/local/mysql
+sudo groupadd mysql
+sudo useradd -r -g mysql -s /bin/false mysql
+
+# 3. 设置权限 + 初始化数据目录
+sudo chown -R mysql:mysql .
+sudo bin/mysqld --initialize --user=mysql --datadir=/data/mysql
+
+# 4. 配置文件(可选,放在 /etc/my.cnf)
+# 指定 basedir=/usr/local/mysql 和 datadir=/data/mysql
+
+# 5. 初始化 SSL 证书(MySQL 8.0 必需)
+sudo bin/mysql_ssl_rsa_setup --datadir=/data/mysql
+
+# 6. 启动服务
+sudo bin/mysqld_safe --user=mysql &
+
+# 7. 验证登录
+sudo bin/mysql -u root -p
+```
+
+> [!QUESTION] caching_sha2_password vs mysql_native_password?
+> MySQL 8.0 将默认认证插件从 `mysql_native_password` 切换为 `caching_sha2_password`:
+> - **caching_sha2_password**(新默认):更强的 SHA2 加密缓存机制,推荐所有新版客户端使用
+> - **mysql_native_password**(旧版):兼容性最好,老旧语言驱动可能不支持 SHA2
+>
+> *如果你的连接报 `client does not support authentication protocol` 错误,临时解决方案是切换回旧版认证:*
+> ```sql
+> ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'new_password';
+> ```
+>
+> *但长期方案应是升级客户端库。*
+
+## 配置文件 my.cnf
+
+### 配置文件加载优先级
+
+MySQL 按以下顺序查找配置文件,**后读取的覆盖先读取的**(命令行参数始终最高):
+
+```mermaid
+graph LR
+ E["~/.my.cnf"] --> D["$MYSQL_HOME/my.cnf"]
+ D --> C["/etc/mysql/my.cnf"]
+ C --> B["/etc/my.cnf"]
+ B --> A["--defaults-file=<指定路径>"]
+ A --> F["命令行参数"]
+ F -.->|"最终生效"| Z["生效配置"]
+```
+
+> [!NOTE] 关键理解
+> - `/etc/my.cnf` 通常是入口文件,内部可能 `!include` 其他目录;
+> - `--defaults-file` 是**唯一**使用的配置文件,跳过所有默认路径(极少场景使用);
+> - Docker 中常用 `--config` 或 `conf.d/` 目录方式挂载自定义配置。
+
+### 核心配置参数详解
+
+MySQL 配置集中在 `[mysqld]` 段落下。按功能域分类如下:
+
+#### 网络
+
+```ini
+# ==================== 网络 ====================
+port = 3306 # MySQL 默认端口
+bind-address = 0.0.0.0 # 监听所有网卡(Docker 内用 127.0.0.1)
+max_connections = 500 # 最大并发连接数
+max_connect_errors = 1000000 # 同一主机连续中断次数上限,超限将被 block_host
+```
+
+#### 字符集
+
+```ini
+# ==================== 字符集 ====================
+character-set-server = utf8mb4 # 支持 emoji 的完整 UTF-8 实现
+collation-server = utf8mb4_unicode_ci # 不区分大小写的排序规则
+```
+
+*为什么用 `utf8mb4` 而非 `utf8`?* MySQL 的 `utf8` 只支持最多 3 字节,无法存储 emoji;`utf8mb4` 才是标准 UTF-8(最长 4 字节)。
+
+#### InnoDB 存储引擎
+
+```ini
+# ==================== InnoDB ====================
+innodb_buffer_pool_size = 1G # 缓存数据和索引的物理内存占比
+innodb_log_file_size = 512M # Redo Log 文件大小
+innodb_flush_log_at_trx_commit = 1 # ACID 一致性级别控制
+innodb_flush_method = O_DIRECT # 绕过 OS page cache,避免双重缓冲
+innodb_file_per_table = 1 # 每张表独立 .ibd 文件
+innodb_io_capacity = 2000 # SSD 建议调高,HDD 约 100~200
+```
+
+**关键概念解释:**
+
+| 参数 | 原理 | 调整建议 |
+|------|------|---------|
+| `innodb_buffer_pool_size` | InnoDB 的核心缓存区域,存放数据页和索引页 | **物理内存的 50%~70%**——为 OS 和其他进程预留空间,占满 90% 反而导致性能下降 |
+| `innodb_log_file_size` | Redo Log 保证持久性(WAL 机制) | 越大容纳未刷盘事务越多,但重启恢复越慢;`512M` 是通用起点 |
+| `O_DIRECT` | 直接 I/O 模式 | 启用后 MySQL 自己管理 Buffer Pool,不再经过 OS Cache(避免双重缓冲浪费) |
+
+### 慢查询日志
+
+```ini
+# ==================== 日志 ====================
+slow_query_log = 1
+slow_query_log_file = /var/log/mysql/slow.log
+long_query_time = 2
+log_error = /var/log/mysql/error.log
+```
+
+**使用方式:** 启用后可以通过 `mysqldumpslow` 分析或直接用文本工具查看超时查询:
+
+```bash
+# 找出执行时间最长的 10 条
+mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
+
+# 实时追踪慢查询(运维调试利器)
+tail -f /var/log/mysql/slow.log
+```
+
+### Binlog(二进制日志)配置
+
+Binlog 是 MySQL 最核心的日志类型,用于主从复制和数据恢复:
+
+```ini
+# ==================== Binlog ====================
+server_id = 1 # 集群内唯一标识(1~2^32-1)
+log_bin = /var/log/mysql/mysql-bin
+binlog_format = ROW # 记录每一行变更明细
+binlog_expire_logs_seconds = 604800 # 7 天后自动清理
+max_binlog_size = 100M # 单文件上限(实际可能略超)
+gtid_mode = ON # 全局事务 ID,简化主从切换
+enforce_gtid_consistency = ON # 只允许 GTID 安全的事务
+```
+
+> [!TIP] Binlog Format 对比
+> | 格式 | 内容 | 优点 | 缺点 |
+> |------|------|------|------|
+> | **STATEMENT** | 原始 SQL | 文件小,审计直观 | 函数调用可能导致主从不一致 |
+> | **ROW**(推荐) | 每行变更前后值 | 数据一致性最强 | 文件较大 |
+> | **MIXED** | 混合模式 | 兼顾两者 | 逻辑复杂,难以预测 |
+>
+> **结论**:生产环境统一用 `ROW`,现代磁盘成本已不再是瓶颈。
+
+> [!QUESTION] innodb_flush_log_at_trx_commit 怎么选?
+> | 值 | 行为 | 丢失数据风险 | 性能 |
+> |---|------|------------|------|
+> | **1** | 每次事务 commit 都刷盘 | **零**(ACID 完全符合) | 最低 |
+> | **2** | 每次 commit 写入 OS 缓存,每秒刷盘 | 系统断电丢失 < 1 秒 | 高 |
+> | **0** | 每秒都写并刷盘,commit 仅写入缓冲区 | 可能丢多秒数据 | 最高 |
+>
+> **建议**:金融/支付类业务必须用 1;内容类业务可以用 2 换取性能提升。
+
+## mysql_install_db 初始化原理
+
+当首次启动 MySQL(或手动执行 `mysql_install_db`)时,底层会完成以下几件事:
+
+```mermaid
+flowchart TD
+ S["mysqld 启动"] --> I["数据目录检查"]
+ I -->|为空| D["执行 mysql_install_db"]
+ I -->|非空| C["跳过初始化,直接加载现有数据"]
+
+ D --> A["创建系统数据库"]
+ A --> A1["mysql schema — 用户权限、角色、插件"]
+ A --> A2["sys schema — 性能分析视图集合"]
+ A --> A3["performance_schema — 运行时指标采集"]
+ A --> A4["information_schema — SQL 元数据虚拟表"]
+
+ A --> B["创建管理员账户"]
+ B --> B1["root@localhost — 超级管理员"]
+ B --> B2["使用 caching_sha2_password 认证"]
+
+ A --> E["写入其他系统文件"]
+ E --> E1["ibdata1 — InnoDB 系统表空间"]
+ E --> E2["auto.cnf — Server UUID"]
+
+ C --> F["加载 my.cnf 配置并监听端口"]
+```
+
+**逐段拆解:**
+
+| 步骤 | 说明 | 常见关联问题 |
+|------|------|-------------|
+| 创建 system schemas | `mysql` 存权限,`performance_schema` 供性能监控,`information_schema` 是 SQL 标准提供的元数据字典 | 删了这些库会导致实例无法正常运行 |
+| 创建 root 账户 | 8.0 不再允许匿名访问 `test` 库;密码通过临时日志输出而非自动设为空 | 忘记临时密码 → 删除 data dir 重新初始化 |
+| 初始化 InnoDB 表空间 | `ibdata1` 存放系统表和 undo log;`ib_logfile*` 是 Redo Log(两份互为镜像) | Redo Log 损坏 → 实例无法启动 |
+| 写入 auto.cnf | 包含唯一的 Server UUID,主从复制和 GTID 依赖此标识 | 克隆虚拟机未重置 UUID → 主从冲突 |
+
+### 数据目录结构
+
+初始化完成后,数据目录的组织方式如下:
+
+```
+/var/lib/mysql/ (datadir 根目录)
+├── ibdata1 # InnoDB 系统表空间(存放系统表 + undo log)
+├── ib_logfile0 # Redo Log 文件 1
+├── ib_logfile1 # Redo Log 文件 2(与 1 互为镜像)
+├── auto.cnf # Server UUID(UUID 不变,主从复制不冲突)
+├── mysql/ # 系统数据库(user, role, db, columns_priv 等 .ibd 文件)
+├── performance_schema/ # 运行时性能数据采集
+├── sys/ # 基于 performance_schema 的友好视图
+├── app_db/ # 每个用户数据库一个目录
+│ ├── users.ibd # 单表独立表空间(innodb_file_per_table=1)
+│ └── orders.ibd
+└── relay-log.index # 主从复制的中继日志索引(仅作为 replica 时存在)
+```
+
+## 关联笔记
+
+- [[hhs/Redis/01-安装与部署]] — Redis 的安装方式与 MySQL 对比
+- [[hhs/EXAM/Week05]] — Docker Compose 多服务编排示例
+- [[hhs/DEV/Go-Database]] — Go 驱动连接 MySQL 的参数配置
diff --git a/hhs/MySQL/03-客户端工具.md b/hhs/MySQL/03-客户端工具.md
new file mode 100644
index 0000000..8f859af
--- /dev/null
+++ b/hhs/MySQL/03-客户端工具.md
@@ -0,0 +1,345 @@
+---
+tags: [MySQL, 客户端, CLI, 工具]
+create time: 2026-05-16T00:00
+---
+
+# MySQL 客户端工具
+
+## 概述
+
+本文件汇总 MySQL 生态中的各类客户端工具及其用法,从终端到图形界面,帮助你在不同场景下高效地操作数据库。
+
+> [!TIP] 如何选择客户端?
+> - **日常排查** → `mysql` CLI 或 DataGrip / DBeaver
+> - **脚本自动化** → `mysql -N -s` 非交互式模式
+> - **结构设计与 ER 图** → MySQL Workbench
+> - **多库种混用** → DBeaver 社区版
+> - **Cluster 管理** → `mysqlsh` JS 模式
+
+## mysql CLI 命令行客户端
+
+`mysql` 是 MySQL 自带的交互式客户端,适合快速调试、脚本自动化和远程服务器操作。
+
+### 基本连接
+
+```bash
+# 最简方式(本地,root 无密码模式)
+mysql
+
+# 指定用户、主机、端口
+mysql -u root -p -h 127.0.0.1 -P 3306
+
+# 通过 Unix Socket 连接(更快,绕过 TCP 栈)
+mysql -u root -S /var/run/mysqld/mysqld.sock
+
+# SSH 隧道方式(连接远程服务器上的 MySQL)
+# 另开终端执行 tunnel:
+ssh -L 3306:127.0.0.1:3306 user@remote-server
+# 然后本地直接连:
+mysql -u root -p --protocol=TCP
+```
+
+> [!NOTE] 三种连接通道对比
+> | 方式 | 延迟 | 适用场景 | 安全 |
+> |------|------|---------|------|
+> | TCP (`-h 127.0.0.1`) | ~1ms | 本地开发、负载均衡后端 | 明文传输 |
+> | Unix Socket (`-S`) | <1μs | 本机直连,性能最优 | 文件系统权限控制 |
+> | SSH Tunnel | ~10-50ms | 跨机房、生产环境 | SSH 加密通道 |
+
+```bash
+# 使用 SSL 连接生产环境(推荐)
+mysql -u app_user -p --ssl-mode=VERIFY_IDENTITY \
+ --ssl-ca=/etc/mysql/ca.pem \
+ --ssl-cert=/etc/mysql/client-cert.pem \
+ --ssl-key=/etc/mysql/client-key.pem \
+ -h prod-db.example.com
+
+# 压缩传输大结果集
+mysql -u root -p --compress -h 192.168.1.10 -e "SELECT * FROM large_table;"
+```
+
+### 配置文件加载顺序
+
+`mysql` 启动时按以下顺序读取配置文件,后读到的覆盖前面的值:
+
+```bash
+# 查看当前配置加载路径(从低优先级到高优先级)
+mysql --print-defaults
+```
+
+```text
+mysql would have been started with the following arguments:
+--socket=/var/run/mysqld/mysqld.sock --port=3306 --default-character-set=utf8mb4
+```
+
+> [!IMPORTANT] 常见配置优化项
+>
+> 在 `~/.my.cnf`(仅自己可见,chmod 600)中声明默认参数,避免每次手动输入:
+> ```ini
+> [client]
+> user = myuser
+> host = 127.0.0.1
+> port = 3306
+> password = secret
+> default-character-set = utf8mb4
+>
+> [mysql]
+> auto-rehash # 自动补全表名/列名(默认开启)
+> column-widths = 120 # 加大显示宽度,避免截断
+> ```
+
+> ⚠️ 安全提醒:`password` 明文写入配置文件存在风险。生产环境中建议使用 `mysql_config_editor` 生成 `.mylogin.cnf` 加密登录文件:
+> ```bash
+> mysql_config_editor set --login-path=local --host=localhost --user=root --password
+> # 之后只需:
+> mysql --login-path=local
+> ```
+
+### 常用快捷命令
+
+在 `mysql>` 提示符下,以 `\` 开头的命令是**客户端内置指令**,不会发送到服务端:
+
+```sql
+\h -- 显示帮助
+\? -- 同 \h
+\G -- 竖排输出(长字段友好)
+\t -- 切换 Tab / CSV 格式输出
+\c -- 取消当前输入
+\u db_name -- 切换数据库
+\d new_delim -- 修改语句终止符(分号冲突时用)
+\q -- 退出
+\e -- 用 $EDITOR 编辑当前 SQL(打开 vi/nano)
+\s -- 查看服务器状态(版本、字符集等)
+\. filename -- 执行 SQL 脚本(与 source 等价)
+pager less -- 设置分页输出(大结果集必备)
+nopager -- 恢复默认
+```
+
+> [!TIP] 实用技巧:`\e` + Enter 编辑
+>
+> 当 SQL 较长时,直接敲 `\e` 回车会打开 `$EDITOR` 指定的编辑器,写好 SQL 保存退出后自动执行。配合 `set editor=vim` 可自定义编辑器。
+
+### 竖排输出示例
+
+```sql
+mysql> SELECT * FROM users WHERE id = 1\G
+*************************** 1. row ***************************
+ id: 1
+ username: alice
+ email: alice@example.com
+ created_at: 2026-01-15 10:30:00
+ updated_at: 2026-05-10 14:22:00
+profile_json: {"age": 28, "city": "Shanghai", "role": "admin"}
+ status: 1
+1 row in set (0.00 sec)
+```
+
+> [!EXAMPLE] 什么时候用 `\G`?
+>
+> 当一个表的字段很多(如 JSON 类型的大字段),横排输出会被截断或挤在一起。竖排模式下每个字段独占一行,可读性大幅提升。
+
+### 非交互式执行
+
+```bash
+# 直接执行 SQL 字符串
+mysql -u root -p -e "SELECT COUNT(*) FROM users;"
+
+# 从文件导入(常用于初始化建表)
+mysql -u root -p db_name < init.sql
+
+# 导出到文件(适合小数据量提取)
+mysql -u root -p -e "SELECT * FROM users;" db_name > output.csv
+
+# 静默模式(适合脚本解析输出)
+mysql -s -N -e "SHOW DATABASES;"
+# -s = silent(紧凑格式),-N = 不显示列名
+```
+
+> [!TIP] 管道组合技巧
+>
+> 将 mysql 与其他 unix 工具组合,实现更强大的数据处理能力:
+> ```bash
+> # 只提取某列并去重计数
+> mysql -N -u root -p -e "SELECT role FROM users;" db_name | sort | uniq -c | sort -rn
+>
+> # 实时观察查询变化(类似 watch)
+> watch -n 1 "mysql -N -u root -p -e 'SELECT COUNT(*) FROM orders WHERE status=\"pending\";' app_db"
+> ```
+
+## mysqlsh(MySQL Shell)
+
+`mysqlsh` 是 Oracle 官方新一代管理工具,支持 **SQL**、**JavaScript**、**Python** 三种模式,内置丰富的 DBA API,是 InnoDB Cluster 管理的核心入口。
+
+> [!NOTE] mysql vs mysqlsh
+>
+> `mysql` 是最原始的客户端,专注纯 SQL 交互;而 `mysqlsh` 更像是一个「数据库开发平台」——它提供了面向对象的数据访问 API、结构化输出格式化,以及集群管理的完整工具链。建议在生产运维场景中优先使用 `mysqlsh`。
+
+### 启动与模式切换
+
+```bash
+# 默认进入 SQL 模式
+mysqlsh
+
+# JavaScript 模式(连接 Cluster 管理)
+mysqlsh --js
+dba.createCluster('myCluster')
+
+# Python 模式
+mysqlsh --py
+
+# 一步直达指定实例(自动进入 SQL 模式)
+mysqlsh root@192.168.1.10:3306
+mysqlsh://root@prod-db:3306/app_db # 指定初始库
+```
+
+### 实用功能
+
+```javascript
+// ---- JS 模式:Schema 级别的操作 ----
+const schema = dba.getSchema('app_db');
+schema.getTable('users').select().where('status = 1').limit(10).execute();
+
+// ---- SQL 模式:结构化输出 ----
+\output json // 输出切换为 JSON,便于后续处理
+SELECT * FROM users LIMIT 5;
+\output text // 切回文本
+
+// ---- 元数据概览 ----
+\status // 连接状态
+\dba.status() // InnoDB Cluster 集群状态
+\connect app_user@app-db // 切换连接
+```
+
+```python
+# ---- Python 模式:批量 CRUD ----
+session = db.create_session("app_user@prod-db:3306/app_db")
+result = session.sql("SELECT id, name FROM products WHERE price > 100").execute()
+for row in result.fetch_all():
+ print(f"{row[0]}: {row[1]}")
+```
+
+> [!TIP] mysqlsh 的隐藏技能
+>
+> - **自动生成文档**: `\status --json` 可直接喂给日志分析系统
+> - **对象浏览器**: tab 自动补全对 `dba`, `session`, `schema` 全部生效
+> - **代码片段**: 输入 `dba.` 后 tab 可查看所有可用方法(createCluster, cloneInstance, checkInstanceConfiguration 等)
+
+## 图形界面工具对比
+
+| 工具 | 类型 | 平台 | 特点 | 费用 |
+|------|------|------|------|------|
+| **MySQL Workbench** | 官方 GUI | Win/Mac/Linux | ER 图设计、迁移工具、执行计划可视化 | 免费 |
+| **DBeaver** | 社区万能 | Win/Mac/Linux | 支持几乎所有数据库,插件丰富 | 社区版免费 |
+| **HeidiSQL** | Windows 首选 | Windows | 轻量、启动快、批量操作方便 | 免费开源 |
+| **Navicat** | 商业旗舰 | Win/Mac/Linux | 功能最全面、同步/备份/结构设计一体化 | 付费 |
+| **DataGrip** | JetBrains | Win/Mac/Linux | IDE 集成好、代码补全强大 | 付费(JetBrains 全家桶) |
+| **TablePlus** | 现代审美 | Mac/Win | 原生应用体验流畅、快捷键优秀 | 付费 |
+
+### 工具选择决策流程
+
+```mermaid
+flowchart TD
+ A[开始选型] --> B{操作系统}
+ B -->|"macOS"| C["TablePlus / DataGrip"]
+ B -->|"Windows"| D{预算?}
+ B -->|"Linux / 跨平台"| E["DBeaver / DataGrip"]
+ D -->|"免费"| F["HeidiSQL"]
+ D -->|"付费"| G{"需要多库兼容?"}
+ G -->|"是"| H["Navicat"]
+ G -->|"仅 MySQL"| I["MySQL Workbench"]
+ C --> J["搭配 mysql CLI 完成全流程"]
+ F --> J
+ H --> J
+ I --> J
+ E --> J
+```
+
+### DBeaver 快速上手
+
+```sql
+-- 在 DBeaver 中可以享受的功能:
+-- 1. Ctrl+Space 智能代码补全
+-- 2. Ctrl+Shift+F 格式化 SQL
+-- 3. Ctrl+/ 单行注释,Ctrl+Shift+/ 块注释
+-- 4. 右键表 -> Open Table Data 直接浏览数据
+-- 5. EXPLAIN 分析器可视化展示执行计划
+```
+
+> [!TIP] DBeaver 小技巧
+>
+> - 选中某张表按 **F5** 刷新元数据(DDL 变更后必做)
+> - 右键查询结果 → Export 支持 Excel/CSV/JSON/SQL INSERT 多种格式
+> - 「SQL Editor」支持数据源模板(Template),一键插入常用查询骨架
+
+## 其他命令行工具
+
+```bash
+# ---- mysqlimport ----
+# 批量导入 CSV 数据到表中
+mysqlimport -u root -p --local --fields-terminated-by=',' app_db data.csv
+
+# ---- mysqldump ----
+# 逻辑备份(后续章节详述)
+mysqldump -u root -p --single-transaction --routines --triggers app_db > backup.sql
+
+# ---- mysqlbinlog ----
+# 解析 Binary Log
+mysqlbinlog --start-datetime="2026-05-01 00:00:00" mysql-bin.000001 > decoded.sql
+
+# ---- mysqlcheck ----
+# 检查和修复表
+mysqlcheck -u root -p --auto-repair --check --optimize app_db
+
+# ---- perror ----
+# 查看 MySQL 错误码含义
+perror 1064
+# 1064 = You have an error in your SQL syntax
+```
+
+### mysqldump 进阶用法
+
+```bash
+# 只导结构,不导数据(适合迁移 schema)
+mysqldump -u root -p --no-data app_db > schema.sql
+
+# 只导数据,不导结构(适合增量同步)
+mysqldump -u root -p --no-create-info app_db > data.sql
+
+# 排除特定表(下划线前缀的配置表)
+mysqldump -u root -p --ignore-table=app_db._config --ignore-table=app_db._history app_db > backup.sql
+
+# 分库备份(循环所有库)
+mysqldump -u root -p --all-databases --single-transaction --master-data=2 > all_dbs_$(date +%F).sql
+
+# 按时间段还原(误删恢复利器)
+mysqlbinlog --stop-datetime="2026-05-15 14:32:00" mysql-bin.000001 | mysql -u root -p
+```
+
+### mysqlbinlog 定位故障时间线
+
+```bash
+# 查看所有事件(含二进制语句)
+mysqlbinlog --force-read mysql-bin.000001 | grep -iE "BEGIN|COMMIT|DROP|TRUNCATE|ALTER"
+
+# 只看到具体的 SQL(不含内部细节)
+mysqlbinlog --base64-decode mysql-bin.000001 | grep -C 3 "DELETE FROM users"
+
+# 按 pos 点精确恢复(精准到事务级别)
+mysqlbinlog --start-position=154 --stop-position=892 mysql-bin.000001 | mysql -u root -p
+```
+
+> [!IMPORTANT] 冷备份 vs 热备份
+>
+> - `mysqldump --single-transaction`:**热备份**,基于 MVCC 一致性快照,不影响线上写入(InnoDB 专属)
+> - `mysqldump --lock-all-tables`:**冷备份**,全局读锁,写请求全部阻塞(MyISAM 需此方式)
+> - `mydumper`:多线程逻辑备份工具,速度远胜 mysqldump,适合百 GB 级以上数据库
+
+## 关联笔记
+
+### MySQL 系列
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
+- [[hhs/MySQL/02-安装与初始化]] — 安装方法与初始化配置
+- [[hhs/MySQL/34-备份与恢复]] — mysqldump、xtrabackup 完整指南
+- [[hhs/MySQL/29-Binary Log]] — binlog 原理与 mysqlbinlog 深入
+- [[hhs/MySQL/39-安全加固]] — 账号权限、SSL、审计最佳实践
+- [[hhs/MySQL/01-MySQL 架构与进程模型]] — 理解客户端与服务端通信基础
diff --git a/hhs/MySQL/04-数据类型全景.md b/hhs/MySQL/04-数据类型全景.md
new file mode 100644
index 0000000..2a17119
--- /dev/null
+++ b/hhs/MySQL/04-数据类型全景.md
@@ -0,0 +1,305 @@
+---
+tags: [MySQL, 数据类型, INT, VARCHAR, JSON]
+create time: 2026-05-16 00:30
+---
+
+# MySQL 数据类型全景
+
+## 概述
+
+MySQL 的数据类型可按存储的内容分为六大类。**选型的黄金法则**:用满足业务需求的最小类型,节省空间、提升索引效率与查询性能。
+
+| 大类 | 包含类型 | 核心考量 |
+|------|---------|---------|
+| **整数** | TINYINT / SMALLINT / MEDIUMINT / INT / BIGINT | 够用就行,别上来就 BIGINT |
+| **浮点/定点** | FLOAT / DOUBLE / DECIMAL | **金额永远用 DECIMAL** |
+| **字符串** | CHAR / VARCHAR / TEXT 系列 | 定长选 CHAR,不定长选 VARCHAR |
+| **日期时间** | DATE / TIME / DATETIME / TIMESTAMP / YEAR | 跨时区用 TIMESTAMP,否则 DATETIME |
+| **JSON** | JSON | 灵活扩展字段,搭配 Generated Column 做索引 |
+| **枚举/集合** | ENUM / SET | 值域固定且极少变才考虑 |
+| **二进制** | BINARY / VARBINARY / BLOB 系列 | 存文件用对象存储,数据库里只存引用 |
+
+> [!QUESTION] 为什么总强调"用小的就行"?
+> 想象一张百万行的订单表,如果主键用了 `BIGINT(8 bytes)` 而非 `INT(4 bytes)`,仅此一列就多花 4MB。这还没算上二级索引——InnoDB 的二级索引叶子节点会完整存储主键值,每个二级索引同样多花 4MB。表越大,连锁放大效应越惊人。
+
+### 整数类型
+
+```mermaid
+graph TD
+ INT_TYPES["整数类型家族"]
+ INT_TYPES --> TINY["TINYINT
1 byte | ±128"]
+ INT_TYPES --> SMALL["SMALLINT
2 bytes | ±32K"]
+ INT_TYPES --> MED["MEDIUMINT
3 bytes | ±8M"]
+ INT_TYPES --> INTN["INT
4 bytes | ±21亿"]
+ INT_TYPES --> BIG["BIGINT
8 bytes | ±9.2×10^18"]
+
+ style INT_TYPES fill:#5F2799,color:#fff
+ style TINY fill:#C44569,color:#fff
+ style SMALL fill:#C44569,color:#fff
+ style MED fill:#C44569,color:#fff
+ style INTN fill:#C44569,color:#fff
+ style BIG fill:#FF6B6B,color:#000
+```
+
+| 类型 | 有符号范围 | 无符号范围 | 存储 | 典型用途 |
+|------|-----------|-----------|------|---------|
+| **TINYINT** | -128 ~ 127 | 0 ~ 255 | 1 byte | 状态标识、布尔标志、年龄 |
+| **SMALLINT** | -32K ~ 32K | 0 ~ 65K | 2 bytes | 短编码 ID |
+| **MEDIUMINT** | -8M ~ 8M | 0 ~ 16M | 3 bytes | 中等范围计数 |
+| **INT** | ±21 亿 | 0 ~ 42 亿 | 4 bytes | 常规自增主键、外键 |
+| **BIGINT** | ±9.2×10¹⁸ | — | 8 bytes | Snowflake ID、金额计算 |
+
+> [!TIP] 能用小的就不用大的
+> `TINYINT UNSIGNED` 能存 255,够用就别用 `INT`。每列差 3 字节,百万行就是 3MB 额外开销,还会导致索引变厚、缓存命中率下降。
+
+### 浮点与定点
+
+| 类型 | 精度 | 存储 | 适用场景 |
+|------|------|------|---------|
+| **FLOAT** | 单精度 7 位 | 4 bytes | 科学计算、不需要精确的场景 |
+| **DOUBLE** | 双精度 15 位 | 8 bytes | 高精度科学计算 |
+| **DECIMAL(M,D)** | 精确定点 M-D 位整数 + D 位小数 | 可变(约每 9 位数字 4 字节) | **金额计算!绝对不要用浮点数存钱** |
+
+```sql
+-- ❌ 错误示范:浮点数误差累积
+SELECT 0.1 + 0.2; -- 结果可能是 0.30000000000000004
+
+-- ✅ 正确做法:DECIMAL
+CREATE TABLE orders (
+ amount DECIMAL(10, 2) NOT NULL -- 最大 99999999.99
+);
+INSERT INTO orders VALUES (19.99 + 29.99); -- 精确等于 49.98
+```
+
+> [!QUESTION] 为什么不能用 FLOAT/DOUBLE 存金额?
+> IEEE 754 浮点数无法精确表示 0.1 这样的十进制小数。在财务场景中,微小的舍入误差经过多次加减后会累积成显著差异。DECIMAL 以字符串形式存储每一位数字,保证运算精确。
+
+## 字符串类型
+
+| 类型 | 存储规则 | 最大长度 | 特点 |
+|------|---------|---------|------|
+| **CHAR(n)** | 固定长度,不足空格填充 | 0~255 | 适合长度固定的数据(MD5 hash、状态码) |
+| **VARCHAR(n)** | 变长,前缀记录实际长度 | 0~65535(受行大小限制) | 最常用的字符串类型 |
+| **TINYTEXT** | 1 字节长度前缀 | 255 | 极短文本 |
+| **TEXT** | 2 字节前缀 | 65535 | 文章摘要、评论 |
+| **MEDIUMTEXT** | 3 字节前缀 | 16MB | 长文章、富文本 |
+| **LONGTEXT** | 4 字节前缀 | 4GB | 超大文本(日志、JSON 文档) |
+
+### CHAR vs VARCHAR 的选择
+
+```sql
+-- ✅ 适合 CHAR:定长数据
+CREATE TABLE users (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ status TINYINT DEFAULT 1,
+ country_code CHAR(3) NOT NULL, -- ISO 3166-1 alpha-3
+ phone_prefix CHAR(4), -- +86, +1, +44...
+ email VARCHAR(255) UNIQUE -- 不定长,用 VARCHAR
+);
+
+-- 为什么 gender 有时用 CHAR(1) 而非 TINYINT?
+-- CHAR(1) 语义更明确,但本质上两者存储相同。
+-- 关键是保持一致性——团队规范比个人偏好更重要。
+```
+
+> [!NOTE] VARCHAR 的长度陷阱
+> MySQL 行大小上限 65535 字节,但这不只是所有 VARCHAR 加起来的大小。还要考虑:
+> - 每列的 1~2 字节长度前缀
+> - NULL 位图(允许 NULL 的列)
+> - 实际存储使用 **utf8mb4** 的话,每个字符最多占 4 字节
+>
+> 所以 `VARCHAR(255)` 在 utf8mb4 下最大占用 255 × 4 + 2 ≈ 1022 字节。
+
+## 日期和时间类型
+
+| 类型 | 格式 | 存储 | 时区感知 |
+|------|------|------|---------|
+| **DATE** | `YYYY-MM-DD` | 3 bytes | 无 |
+| **TIME** | `HH:MM:SS` | 3 bytes | 无(时间段) |
+| **DATETIME** | `YYYY-MM-DD HH:MM:SS` | 8 bytes | 无(存储原始值) |
+| **TIMESTAMP** | `YYYY-MM-DD HH:MM:SS` | 4 bytes | **有**(UTC 存储,显示时转换) |
+| **YEAR** | `YYYY` | 1 byte | — |
+
+```sql
+-- TIMESTAMP 的时区自动转换特性
+CREATE TABLE events (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(100),
+ event_time DATETIME, -- 存入什么就读出什么
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -- 自动填当前 UTC 时间
+);
+
+INSERT INTO events (name, event_time) VALUES ('Meeting', '2026-05-16 14:00:00');
+-- 在上海时区 (UTC+8) 显示:14:00:00
+-- 在纽约时区 (UTC-4) 显示:14:00:00(DATETIME 不变)
+-- 但如果用 TIMESTAMP,它会转换成纽约本地时间 02:00:00
+```
+
+> [!WARNING] TIMESTAMP 有保质期
+> `TIMESTAMP` 的范围是 `1970-01-01 00:00:01` 到 `2038-01-19 03:14:07`(32-bit 上限)。如果你的系统需要支持 2038 年之后的数据,请用 `DATETIME`。
+
+## JSON 类型
+
+MySQL 5.7+ 引入原生 JSON 类型,支持部分查询和索引能力。
+
+```sql
+CREATE TABLE employees (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(100),
+ attributes JSON -- 灵活扩展字段
+);
+
+INSERT INTO employees VALUES
+(1, 'Alice', '{"dept": "Engineering", "skills": ["Go", "Python"], "level": 5}'),
+(2, 'Bob', '{"dept": "Marketing", "skills": ["SEO", "Content"], "level": 3}');
+
+-- JSON 路径查询
+SELECT name, attributes->>'$.dept' AS department
+FROM employees
+WHERE attributes->>'$.level' >= 4;
+
+-- JSON 数组包含判断
+SELECT name FROM employees
+WHERE JSON_CONTAINS(attributes->'$[*]', '"Go"');
+
+-- 生成虚拟列 + 索引(最佳实践)
+ALTER TABLE employees
+ ADD COLUMN dept VARCHAR(50) GENERATED ALWAYS AS (attributes->>'$.dept') VIRTUAL,
+ ADD INDEX idx_dept (dept);
+```
+
+```mermaid
+flowchart LR
+ A["JSON Column
原始存储"] --> B["JSON Document"]
+ B --> C["Scalar Values"]
+ B --> D["Arrays"]
+ B --> E["Nested Objects"]
+
+ C --> F["Generated Column"]
+ D --> F
+ E --> F
+ F --> G["Index
Virtual / Stored"]
+
+ style A fill:#00B6BC,color:#fff
+ style F fill:#FF9F43,color:#000
+ style G fill:#C44569,color:#fff
+```
+
+> [!TIP] JSON vs 规范化表设计
+> - **适合 JSON**:配置项、标签集合、表单动态字段、低频更新的属性
+> - **不适合 JSON**:需要 JOIN 关联、频繁条件过滤、强一致性约束的字段
+> - 关键技巧:用 **Generated Column + Index** 让 JSON 字段的筛选走索引
+
+## 枚举与集合
+
+```sql
+-- ENUM: 限定可选值列表
+CREATE TABLE tasks (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ title VARCHAR(200),
+ status ENUM('todo', 'in_progress', 'done', 'cancelled') DEFAULT 'todo',
+ priority ENUM('low', 'medium', 'high', 'urgent') DEFAULT 'medium'
+);
+
+-- SET: 多选值(逗号分隔存储)
+CREATE TABLE tags (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(100),
+ categories SET('tech', 'business', 'lifestyle', 'health')
+);
+
+INSERT INTO tags VALUES (1, 'Go Tips', 'tech,business');
+```
+
+### ENUM vs SET 对比
+
+| 特性 | ENUM | SET |
+|------|------|-----|
+| **语义** | 单选:从列表中取一个值 | 多选:从列表中取零个或多个值 |
+| **内部存储** | 整数索引(1 byte 或 2 bytes) | 位图(最多 64 个成员占 8 bytes) |
+| **排序规则** | 按索引顺序,非字母序 | 按位数值排序 |
+| **典型场景** | 工单状态、优先级 | 标签分类、权限角色 |
+
+### ENUM 还是 TINYINT?这是永恒争论
+
+```sql
+-- 方案 A:ENUM — 数据库层校验 + 省空间
+status ENUM('open', 'closed') NOT NULL -- 1 byte
+
+-- 方案 B:TINYINT — 代码层校验 + 灵活
+status TINYINT UNSIGNED NOT NULL -- 1 byte
+-- 应用层保证只写入 0/1/2/3
+```
+
+| 维度 | 选 ENUM | 选 TINYINT |
+|------|---------|-----------|
+| **校验** | 数据库自动拒绝非法值 | 需应用层保证 |
+| **调试** | 查出是数字 2,还要查 schema 才知道含义 | 直接读出原始数字,一目了然 |
+| **修改成本** | 增删值需 `ALTER TABLE`(大表很贵) | 随时在应用层加常量枚举 |
+| **代码可追溯性** | IDE 找不到所有使用处 | `grep` 就能定位 |
+
+> [!WARNING] ENUM 的反模式警告
+> - **不要用 ENUM 存用户可见的文案**——后台查出来是 `2`,前端还得映射回去
+> - **不要把 ENUM 当文档用**——队友看不懂 `status = 3` 是什么意思
+> - **推荐方案**:中小项目用 TINYINT + Go `const` / Python `IntEnum` 在代码里维护枚举定义;只有在值域极稳定且不需要跨语言共享时再用原生 ENUM
+
+> [!NOTE] ENUM 的本质
+> ENUM 在内部存储为整数索引(1, 2, 3...),而不是字符串。这意味着:
+> - ENUM 的排序是按索引而非字母顺序
+> - 插入不在列表中的值会导致错误(或空字符串,取决于 sql_mode)
+> - 修改 ENUM 列表顺序会影响已有数据的解释——**谨慎维护**
+
+## 二进制类型
+
+| 类型 | 说明 | 典型用途 |
+|------|------|---------|
+| **BINARY(n)** | 定长二进制 | 哈希值(SHA256 = 32 bytes)|
+| **VARBINARY(n)** | 变长二进制 | 短二进制数据 |
+| **TINYBLOB** | ≤ 255 bytes | — |
+| **BLOB** | ≤ 65KB | 缩略图、序列化对象 |
+| **MEDIUMBLOB** | ≤ 16MB | 文件附件 |
+| **LONGBLOB** | ≤ 4GB | 大文件存储 |
+
+```sql
+-- 存储 SHA-256 hash 的推荐方式
+CREATE TABLE file_metadata (
+ id BIGINT PRIMARY KEY,
+ filename VARCHAR(500),
+ sha256_hash BINARY(32) NOT NULL, -- CHAR(64) HEX 也可以,但 BINARY 省一半
+ UNIQUE KEY uk_sha256 (sha256_hash)
+);
+```
+
+### BINARY vs VARBINARY — 定长与变长的选择
+
+```sql
+-- BINARY(32):始终占 32 bytes,不足补 0x00
+INSERT INTO t VALUES (X'61'); -- 实际存储: 61 00 00 ... 00(32 bytes)
+SELECT HEX(col) FROM t; -- 输出: 610000...(永远 64 个十六进制字符)
+
+-- VARBINARY(32):只存真实长度 + 1 byte 长度前缀
+INSERT INTO t VALUES (X'61'); -- 实际存储: 61 01(2 bytes,01 表示长度)
+SELECT HEX(col) FROM t; -- 输出: 61(只有 2 个十六进制字符)
+```
+
+| 维度 | BINARY(n) | VARBINARY(n) |
+|------|-----------|-------------|
+| **存储** | 固定 n 字节,右侧用 `0x00` 补齐 | 实际长度 + 1~2 bytes 前缀 |
+| **比较规则** | 补齐 0x00 后再逐字节比较 | 按实际长度比较 |
+| **适用场景** | 哈希值、加密密钥等定长数据 | 短二进制流、序列化片段 |
+
+> [!TIP] BINARY vs CHAR 的对称性
+> 理解 `BINARY` 就理解了 `CHAR`——它们是对称的定长类型。区别仅在于:**CHAR 补空格,BINARY 补零**。同理 `VARBINARY` 对标 `VARCHAR`。类比记忆比死记硬背更可靠。
+
+> [!WARNING] 大文件不要存在数据库里
+> BLOB 系列看似方便,但会带来三个问题:
+> 1. **备份膨胀**——数据库 dump 体积翻倍,恢复时间成倍增加
+> 2. **内存压力**——SELECT 整行时 BLOB 内容也加载到内存,即使你不需要它
+> 3. **无法 CDN 加速**——图片/附件走 OSS + Signed URL 是标准做法
+>
+> **最佳实践**:数据库只存文件引用(OSS URL / 文件系统路径),文件本体放对象存储。
+
+## 关联笔记
+
+- [[hhs/GORM/02-模型定义]] — GORM Struct Tag 如何映射这些 MySQL 类型
+- [[hhs/Redis/02-核心数据类型]] — MySQL 与 Redis 数据类型的选型对比
diff --git a/hhs/MySQL/05-字符集与排序规则.md b/hhs/MySQL/05-字符集与排序规则.md
new file mode 100644
index 0000000..cbd413a
--- /dev/null
+++ b/hhs/MySQL/05-字符集与排序规则.md
@@ -0,0 +1,276 @@
+---
+tags: [MySQL, 字符集, Collation, utf8mb4, utf8mb3, character_set]
+create time: 2026-05-16 00:00
+---
+
+# 字符集与排序规则
+
+## 概述
+
+字符集(Character Set)决定「哪些字符能被存储」,排序规则(Collation)决定「这些字符如何比较和排序」。选错字符集不仅会导致乱码,还可能引发索引失效和安全漏洞。
+
+## 字符集速览
+
+MySQL 支持超过 80 种字符集,但生产环境中真正有用的只有几个:
+
+```mermaid
+graph TB
+ subgraph "单字节"
+ A["latin1"] -->|"遗留系统"| A1["西欧语言"]
+ end
+ subgraph "多字节变长"
+ B["utf8
MySQL 特有"] -->|"仅支持 BMP"| B1["最多 3 字节"]
+ C["utf8mb4"] -->|"完整 UTF-8"| C1["最多 4 字节"]
+ end
+ subgraph "中文相关"
+ D["gbk"] -->|"国标简体"| D1["2 字节"]
+ E["gb2312"] -->|"老国标"| E1["2 字节"]
+ F["big5"] -->|"繁体(台湾)"| F1["2 字节"]
+ end
+ subgraph "Unicode 其他"
+ G["ucs2"] -->|"UTF-16 BE"| G1["定长 2 字节"]
+ H["utf32"] -->|"UTF-32"| H1["定长 4 字节"]
+ end
+
+ style C fill:#00B6BC,color:#fff
+ style B fill:#EE5A24,color:#fff
+```
+
+### 为什么一定要用 utf8mb4?
+
+MySQL 中的 `utf8` 实际上只是 **utf8mb3** —— 它最多支持 3 字节编码,无法表示四字节字符(emoji、生僻汉字、部分符号)。
+
+```sql
+-- 用 utf8 插入 emoji 会报错!
+SET NAMES utf8;
+INSERT INTO posts (content) VALUES ('Hello 🚀');
+-- ERROR 1366: Incorrect string value: '\xF0\x9F\x9A\x80'
+
+-- 换 utf8mb4 就没有问题
+SET NAMES utf8mb4;
+INSERT INTO posts (content) VALUES ('Hello 🚀'); -- OK ✅
+```
+
+> [!WARNING] utf8mb4 的性能影响
+> 相比 latin1,utf8mb4 每个字符多占 1~3 字节。这会带来:
+> - 同样的 VARCHAR 长度存的内容更少
+> - 索引体积增大,缓存命中率下降
+> - 网络传输量增加
+>
+> **建议**:除非明确知道不需要中文/emoji,否则一律使用 utf8mb4。现代 SSD 和内存足够应对这个开销。
+
+## 排序规则 Collation
+
+排序规则命名格式:`<字符集>_<语言>_<后缀>`,后缀含义:
+
+| 后缀 | 含义 | 示例 |
+|------|------|------|
+| `_ci` | Case **I**nsensitive(忽略大小写) | `utf8mb4_general_ci` |
+| `_cs` | Case **S**ensitive(区分大小写) | `utf8mb4_general_cs` |
+| `_bin` | Binary(按字节比较) | `utf8mb4_bin` |
+
+> [!NOTE] MySQL 8.0 的新后缀
+> 升级到 8.0 后,你会发现默认排序规则多了两个新后缀:
+> - `_ai_ci` = **A**ccent **I**nsensitive — 忽略重音差异(é = e)
+> - `_as_ci` = **A**ccent **S**ensitive — 保留重音差异(é ≠ e)
+>
+> 这解决了一个历史痛点:`utf8mb4_general_ci` 在处理带重音的拉丁字母时不够精确。
+
+### 常见 Collation 对比
+
+```mermaid
+flowchart TB
+ subgraph "不区分大小写 ci"
+ GC["utf8mb4_general_ci
⚡ 快速但略不精确"]
+ UC["utf8mb4_unicode_ci
📐 基于 Unicode 标准"]
+ AC["utf8mb4_0900_ai_ci
✅ MySQL 8.0 默认"]
+ end
+
+ subgraph "区分大小写"
+ GS["utf8mb4_general_cs"]
+ US["utf8mb4_unicode_cs"]
+ AS["utf8mb4_0900_as_ci
Accent/Space Insensitive"]
+ end
+
+ subgraph "严格二进制 bin"
+ B2["utf8mb4_bin
直接比较字节值"]
+ end
+
+ IN1["输入 'abc' vs 'ABC'"] --> GC
+ IN1 --> UC
+ IN1 --> AC
+ IN2["输入 'abc' vs 'ABC'"] --> GS
+ IN2 --> US
+ IN2 --> AS
+ IN3["输入 'A' vs 'a'"] --> B2
+
+ GC --> R1["结果: a = b = c"]
+ UC --> R1
+ AC --> R1
+ GS --> R2["结果: a ≠ A"]
+ US --> R2
+ AS --> R2
+ B2 --> R3["结果: A ≠ a (0x41 ≠ 0x61)"]
+
+ style AC fill:#00B6BC,color:#fff
+ style B2 fill:#EE5A24,color:#fff
+ style GC fill:#C44569,color:#fff
+```
+
+### 该选哪个 Collation?
+
+| 场景 | 推荐 | 理由 |
+|------|------|------|
+| **中文项目** | `utf8mb4_0900_as_ci` | MySQL 8.0 默认排序,基于 ICU 标准 |
+| **英文项目** | `utf8mb4_0900_ai_ci` | AI = Accent Insensitive,忽略重音符号 |
+| **邮箱/用户名** | `utf8mb4_bin` | 严格区分大小写 |
+| **密码存储** | 永远不用 Collation——用 Hash | bcrypt/argon2 |
+
+> [!TIP] 一个常见的选型误区
+> 很多人看到中文项目就选 `utf8mb4_unicode_ci`,但在 MySQL 8.0 下推荐优先使用 `utf8mb4_0900_*` 系列。它们基于 ICU 标准,对亚洲语言(中日韩)的排序更准确,性能也更好。`utf8mb4_unicode_ci` 本质上是 Unicode 4.0.0 的实现,而 0900 基于 Unicode 9.0.0。
+
+```sql
+-- 设置整个数据库的默认字符集和排序规则
+CREATE DATABASE app_db
+ CHARACTER SET utf8mb4
+ COLLATE utf8mb4_0900_ai_ci;
+
+-- 单个表的覆盖
+CREATE TABLE usernames (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ username VARCHAR(50)
+) ENGINE=InnoDB
+ DEFAULT CHARSET=utf8mb4
+ COLLATE=utf8mb4_bin; -- 用户名严格区分大小写
+```
+
+## 层级关系:字符集 → Collation → 作用域
+
+```mermaid
+graph BT
+ subgraph "Server 层(全局默认)"
+ S["server
utf8mb4_0900_ai_ci"]
+ end
+ subgraph "Database 层(库级覆盖)"
+ DB["database
utf8mb4_unicode_ci"]
+ end
+ subgraph "Table 层(表级覆盖)"
+ T["table
utf8mb4_bin"]
+ end
+ subgraph "Column 层(列级最高优先级)"
+ C["column
utf8mb4_general_ci"]
+ end
+
+ S --> DB --> T --> C
+
+ INFO["优先级:Column > Table > Database > Server
每层都会覆盖上层的设置"]
+
+ style S fill:#5F2799,color:#fff
+ style DB fill:#3D97BE,color:#fff
+ style T fill:#FF9F43,color:#000
+ style C fill:#C44569,color:#fff
+ style INFO fill:#333,color:#fff
+```
+
+查询当前配置:
+
+```sql
+-- 查看所有层级的字符集和排序规则
+SHOW VARIABLES LIKE 'character_set_server';
+SHOW VARIABLES LIKE 'collation_server';
+
+SHOW CREATE DATABASE app_db;
+SHOW CREATE TABLE users\G
+
+-- 当前会话的字符集
+SHOW SESSION VARIABLES LIKE 'character_set%';
+```
+
+## Binlog 中的字符集注意
+
+当启用 `binlog_format = ROW` 时,主从复制会在 binlog 中携带原始字节,不受 collation 影响。
+
+但如果是 `STATEMENT` 模式,SQL 文本中的字符比较可能在主库和从库得出不同结果(如果两边的 collation 不一致)。这也是为什么生产推荐 `ROW` 格式的原因之一。
+
+## Go 应用层字符集配置
+
+光在数据库层面设置 utf8mb4 还不够——连接通道必须一致,否则客户端发送的字节会被服务端用错误的字符集解析,轻则乱码,重则报错。
+
+### DSN 中指定 charset
+
+```go
+// ✅ 标准写法:DSN 中加上 charset=utf8mb4
+dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
+db, err := sql.Open("mysql", dsn)
+```
+
+> [!QUESTION] 为什么 DSN 里也要写 charset?
+> MySQL 客户端和服务端建立连接时有一个「握手阶段」,双方会协商使用哪个字符集。如果不在 DSN 中声明,MySQL 驱动会使用服务器默认的字符集。当服务器默认不是 utf8mb4 时(比如 legacy 系统的 latin1),就会出现连接层数据面编码不一致的问题。
+
+### 运行时动态切换
+
+```go
+// 某个极端场景下需要临时切换会话字符集
+_, err := db.Exec("SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci")
+// ⚠️ 注意:这影响整个连接,在连接池场景下要避免频繁调用
+```
+
+> [!WARNING] 连接池中的陷阱
+> `sql.Open` 不会立即创建连接,而是在首次查询时懒加载。如果你的程序启动后从未执行过 SET NAMES,而服务端的 `character_set_server` 恰好不是 utf8mb4 —— 那第一个请求就可能触发乱码。
+>
+> **最佳实践**:在初始化连接池后立刻跑一次健康检查,确保连接已正确初始化。
+
+```go
+// 建完连接池后立即验证
+err = db.Ping() // 这一步会实际创建一个连接
+```
+
+## utf8 → utf8mb4 迁移指南
+
+这是一个经典的线上改造场景。如果你的历史系统用了 MySQL 的 `utf8`(其实是 utf8mb3),逐步迁移到 utf8mb4 的步骤如下:
+
+### 迁移步骤
+
+```sql
+-- Step 1: 检查当前哪些表用了旧 utf8
+SELECT table_schema, table_name, column_name, character_set_name
+FROM information_schema.columns
+WHERE character_set_name IS NOT NULL
+ AND character_set_name != 'utf8mb4'
+ORDER BY table_schema, table_name;
+
+-- Step 2: 修改表的默认字符集(不影响已有数据)
+ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+
+-- Step 3: 修改数据库默认字符集(新建表会自动继承)
+ALTER DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
+
+-- Step 4: 修改应用层 DSN,确保连接层也使用 utf8mb4
+-- user:pass@tcp(host:3306)/db?charset=utf8mb4&parseTime=True
+```
+
+> [!NOTE] ALTER TABLE CONVERT 的影响
+> - 对于小表(万行级别),几乎是瞬时完成
+> - 对于大表(千万行+),`CONVERT TO` 会重建整张表:复制全量数据 → 重建索引 → 替换原表
+> - **建议在低峰期操作**,或使用 `pt-online-schema-change` 进行在线 DDL,避免业务中断
+>
+> 如果只是改默认字符集而不影响现有列定义,可以用:
+> ```sql
+> ALTER TABLE users DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+> -- 注意:这是 DEFAULT,不改变已有列的字符集!
+> ```
+
+### 常见踩坑
+
+| 坑 | 现象 | 解法 |
+|----|------|------|
+| **只改了表,没改连接** | 能插入 emoji,但查出来是 `???` | DSN 加 `charset=utf8mb4` |
+| **只改了表默认值,没 CONVERT** | 已有列仍是 utf8mb3 | 必须用 `CONVERT TO` 重新编码 |
+| **GORM AutoMigrate 覆盖** | 手动改了字符集,下次 AutoMigrate 又被还原 | 禁用自动迁移,改用 migration 脚本管理 |
+
+### 关联笔记
+
+- [[hhs/Redis/02-核心数据类型]] — Redis 的 STRING 类型对多字节字符的处理
+- [[hhs/GORM/02-模型定义]] — GORM 建模时的字符串长度设置要点
+- [[hhs/MySQL/10-DDL 建表与结构变更]] — 大表 DDL 的在线变更策略
+- [[hhs/MySQL/40-常见踩坑]] — 隐式转换等更多常见问题
diff --git a/hhs/MySQL/06-InnoDB 深度解析.md b/hhs/MySQL/06-InnoDB 深度解析.md
new file mode 100644
index 0000000..a67273f
--- /dev/null
+++ b/hhs/MySQL/06-InnoDB 深度解析.md
@@ -0,0 +1,283 @@
+---
+tags: [MySQL, InnoDB, Clustered Index, Buffer Pool, Redo Log, Undo Log, Change Buffer, MVCC]
+create time: 2026-05-16 07:30
+---
+
+# InnoDB 深度解析
+
+## 概述
+
+InnoDB 是 MySQL 默认且最广泛使用的存储引擎。理解它的内部机制是优化查询和排查性能问题的基础。本节深入讲解 InnoDB 的五大核心组件及其协作方式。
+
+## 架构总览
+
+```mermaid
+graph TB
+ subgraph "Buffer Pool"
+ BP["页缓存: 16KB/page"] --> BP1["Data Pages"]
+ BP --> BP2["Index Pages"]
+ BP --> BP3["Insert Buffer"]
+ BP --> BP4["LRU List"]
+ BP --> BP5["Free List"]
+ BP --> BP6["Flush List"]
+ end
+
+ subgraph "Redo Log"
+ RL1["Log Buffer: 内存缓冲区"] --> RL2["物理日志文件: ib_logfile0/1"]
+ end
+
+ subgraph "Undo Log"
+ UL1["Rollback Segment"] --> UL2["Undo Logs"]
+ end
+
+ subgraph "磁盘数据文件"
+ DF1["表空间: ibdata1 / .ibd"]
+ end
+
+ subgraph "Change Buffer"
+ CB["二级索引变更缓存"]
+ end
+
+ Client["SQL 请求"] --> BP
+ BP --> RL1 -- write path
+ BP --> UL1 -- transaction isolation
+ BP --> DF1 -- read path
+ BP --> CB -- secondary index cache
+ RL1 --> RL2
+```
+
+## Clustered Index(聚簇索引)
+
+InnoDB 的**数据行就存储在聚簇索引的叶子节点中**。这是 InnoDB 最关键的概念——没有聚簇索引就没有数据。
+
+```mermaid
+graph BT
+ A["主键: 100"] --> B["非叶子节点"]
+ C["主键: 50"] --> B
+ D["主键: 200"] --> E["非叶子节点"]
+
+ B --> F["叶子节点, row_id=1, name=Alice"]
+ B --> G["叶子节点, row_id=2, name=Bob"]
+ E --> H["叶子节点, row_id=3, name=Charlie"]
+ E --> I["叶子节点, row_id=4, name=David"]
+
+ F -.->"链表串联" G
+ G -.->"链表串联" H
+ H -.->"链表排序" I
+
+ style F fill:#00B6BC,color:#fff
+ style I fill:#00B6BC,color:#fff
+```
+
+> [!TIP] 为什么一定要用自增主键?
+> 如果选随机值(如 UUID)作为主键,每次插入都可能在索引树的中间位置分叉,导致:
+> - **页分裂**:16KB 页面满了要拆成两个,触发大量 I/O
+> - **写放大**:相邻页变得不连续,顺序写变成随机写
+> - **空间浪费**:页填充率下降(从 100% 降到 ~70%)
+>
+> 使用自增 INT/BIGINT 时,新记录总是在索引末尾追加——完美的顺序写模式。
+
+## Secondary Index(二级索引)
+
+所有非主键索引都是二级索引。关键点:**二级索引的叶子节点存储的是主键值**,而不是行数据。
+
+```mermaid
+graph LR
+ subgraph "聚簇索引, PK=id"
+ CI1["id=1 → full row data"]
+ CI2["id=2 → full row data"]
+ CI3["id=3 → full row data"]
+ end
+
+ subgraph "二级索引, idx_email=email"
+ SI1["email='a@x.com' → pk=1"]
+ SI2["email='b@x.com' → pk=2"]
+ SI3["email='c@x.com' → pk=3"]
+ end
+
+ SI1 -.回表.-> CI1
+ SI2 -.回表.-> CI2
+ SI3 -.回表.-> CI3
+
+ style SI1 fill:#FF9F43,color:#000
+ style CI1 fill:#00D866,color:#fff
+```
+
+### 覆盖索引(Covering Index)
+
+当查询需要的列全部在一个二级索引中时,**无需回表**,直接返回结果。这比普通查询快得多。
+
+```sql
+-- ❌ 普通查询:走 idx_email,但 SELECT * 需要回表查聚簇索引
+SELECT * FROM users WHERE email = 'alice@example.com';
+
+-- ✅ 覆盖索引:email + name 都在索引里,不需要回表
+ALTER TABLE users ADD INDEX idx_email_name (email, name);
+SELECT name FROM users WHERE email = 'alice@example.com';
+-- EXPLAIN 会显示 Extra: Using index
+
+-- ⚠️ 常见误区:查其他不在索引中的列,仍需回表
+-- idx_email_name 包含的是 (email, name),不包含 id
+SELECT id FROM users WHERE email = 'alice@example.com';
+-- 虽然 WHERE 条件匹配了索引,但 SELECT 的 id 不在索引中 → 仍需回表
+```
+
+## Buffer Pool 详解
+
+Buffer Pool 是 InnoDB 最重要的性能组件,它缓存了磁盘上的数据页和索引页。
+
+```mermaid
+flowchart TD
+ subgraph "Buffer Pool, N × 16KB Pages"
+ LRU["LRU List"] --> New["New Sub-list"]
+ New --> Young["Young Sub-list"]
+ Young --> Old["Old Sub-list"]
+ Old --> Free["Free List"]
+
+ Note1["INSERT/UPDATE\n→ 放 New 区"]
+ Note2["读未命中\n→ 从磁盘加载到 New 区"]
+ Note3["频繁访问\n→ 保持 Young 区\n(防污染旧页)"]
+ Note4["不再访问\n→ 淘汰到 Free 区"]
+ end
+
+ FlushList["Flush List, 脏页链表"] --> Disk["刷入磁盘 ibd 文件"]
+
+ LRU --> FlushList
+```
+
+### Buffer Pool 关键参数
+
+```ini
+innodb_buffer_pool_size = 1G # 建议设为物理内存 50%~70%
+innodb_buffer_pool_instances = 8 # 并发实例数(GB 级配置 > 1GB 时)
+innodb_buffer_pool_dump_at_exit = ON # mysqld 关闭时保存缓冲池状态
+innodb_buffer_pool_load_at_startup = ON # 启动时恢复上一次状态
+```
+
+> [!QUESTION] 如何判断 Buffer Pool 是否够大?
+> 查看两个关键状态变量:
+> ```sql
+> SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
+> ```
+>
+> | 变量 | 含义 |
+> |------|------|
+> | `Innodb_buffer_pool_read_requests` | Buffer Pool 读取请求总数 |
+> | `Innodb_buffer_pool_reads` | 无法命中、必须回磁盘读取的次数 |
+>
+> 命中率 = `1 - reads / read_requests`,正常应在 **99%+**。持续低于 95% 说明需要增大 `innodb_buffer_pool_size`。
+
+## Redo Log(重做日志)
+
+Redo Log 是 InnoDB 特有的**物理日志**,用于保证事务的 Durability。
+
+```mermaid
+flowchart LR
+ A["事务修改数据页\nBuffer Pool 中的脏页"] --> B["同时写入 Redo Log Buffer"]
+ B --> C{flush_log_at_trx_commit}
+ C -->|1| D["立即刷盘"]
+ C -->|2| E["写入 OS Cache,每秒刷盘"]
+ C -->|0| F["每秒刷盘,commit 也异步"]
+
+ D --> G["磁盘持久化 ✅"]
+ E --> G
+ F --> G
+
+ style D fill:#00D866,color:#fff
+```
+
+### Redo Log 的特性
+
+- **循环写入**:大小固定(默认各 48MB),写满后从头覆盖
+- **物理日志**:记录「在哪个页的哪个偏移改了什么字节」,而非 SQL
+- **Crash Safe**:崩溃恢复时重放 Redo Log 恢复到一致状态
+- **两阶段提交(2PC)**:协调 Redo Log 和 Binlog 的一致性
+
+> [!NOTE] flush_log_at_trx_commit 三种模式
+>
+> | 值 | 行为 | 安全性 | 性能 |
+> |----|------|--------|------|
+> | 1 | commit 时立即 fsync 磁盘 | 最高(ACID) | 最低 |
+> | 2 | commit 时写 OS Cache,每秒 fsync | 偶尔丢失 1s 数据 | 较高 |
+> | 0 | 每秒写 OS Cache + fsync,commit 异步 | 可能丢多条事务 | 最高 |
+>
+> 生产环境强烈推荐 `1`。设为 `2` 能在大部分场景接近相同性能,但在 OS 崩溃时会丢数据。
+
+## Undo Log(回滚日志)
+
+与 Redo Log(向前恢复)不同,Undo Log 用于将数据**回退到修改前的状态**。它支持两个关键功能:
+
+```mermaid
+flowchart LR
+ subgraph "事务 A: UPDATE users SET balance = balance - 100 WHERE id = 1"
+ A1["修改前备份 → Undo Log"] --> A2["修改 Buffer Pool"]
+ end
+
+ subgraph "事务 B: UPDATE users SET balance = balance + 100 WHERE id = 1"
+ B1["等待锁 / MVCC 隔离"] --> B2["读取历史版本"]
+ end
+
+ A1 -.提供回退能力.-> ROLLBACK["ROLLBACK 操作"]
+ B2 -.快照读隔离.-> SNAPSHOT["SNAPSHOT READ"]
+
+ style A1 fill:#FF9F43,color:#000
+ style ROLLBACK fill:#FF6B6B,color:#fff
+ style SNAPSHOT fill:#00D866,color:#fff
+```
+
+### Undo Log 的核心用途
+
+- **事务回滚(ROLLBACK)**:执行相反操作还原原始值,如 UPDATE 的反向是 INSERT(新行)或 UPDATE(旧值)
+- **MVCC 多版本并发控制**:通过 Read View + Undo Log Version Chain 实现不同事务看到不同的数据快照
+- **灾难恢复**:配合 Redo Log,rollback 未提交的事务
+
+> [!TIP] Undo Log vs Redo Log
+>
+> | 对比项 | Undo Log | Redo Log |
+> |--------|----------|----------|
+> | 方向 | 回退(undo) | 重做(redo) |
+> | 逻辑/物理 | **逻辑日志**(记录SQL语义) | **物理日志**(记录页面偏移) |
+> | 生命周期 | 事务提交后可立即回收(purge) | 必须保留到 checkpoint 之后 |
+> | 空间 | 可动态增长(undo tablespace) | 固定大小、循环复用 |
+
+## Change Buffer(变更缓冲)
+
+Change Buffer 缓存了对**非唯一二级索引页**的修改操作,等该页被读到时再合并写入磁盘。这对 INSERT-heavy 场景有显著加速效果。
+
+```mermaid
+flowchart TD
+ A["INSERT 到二级索引\n页面不在 Buffer Pool 中"] --> B["写入 Change Buffer"]
+ B --> C["减少随机磁盘 I/O"]
+
+ D["后续读取该页\n页面进入 Buffer Pool"] --> E["合并 Change Buffer 中的操作"]
+ E --> F["一次性更新磁盘"]
+
+ C --> G["性能提升 20%~30%"]
+ E --> G
+
+ style B fill:#FF9F43,color:#000
+ style G fill:#00D866,color:#fff
+```
+
+> [!NOTE] Change Buffer 的限制
+> - 只对**非唯一索引**生效(唯一索引需要在插入前检查冲突,必须实时读写磁盘)
+> - 对 UPDATE/DELETE 同样有效
+> - 可以通过 `innodb_change_buffer_max_size` 控制最大占比(默认 25%)
+
+## 关联笔记
+
+### 索引相关
+- [[hhs/MySQL/16-B+Tree 索引原理]] — 聚簇索引 / 二级索引的数据结构基础
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
+
+### 事务与并发控制
+- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
+- [[hhs/MySQL/24-隔离级别与可见性]] — MVCC 配合二级索引的可见性判断
+- [[hhs/MySQL/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
+
+### 日志与高可用
+- [[hhs/MySQL/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
+
+### 整体架构
+- [[hhs/MySQL/01-MySQL 架构与进程模型]] — Server 层与 InnoDB 引擎的分层协作
diff --git a/hhs/MySQL/07-其他存储引擎概览.md b/hhs/MySQL/07-其他存储引擎概览.md
new file mode 100644
index 0000000..b1212fa
--- /dev/null
+++ b/hhs/MySQL/07-其他存储引擎概览.md
@@ -0,0 +1,221 @@
+---
+tags: [MySQL, MyISAM, Memory, Archive, 存储引擎]
+create time: 2026-05-16 00:00
+---
+
+# MyISAM / Memory / Archive 引擎概览
+
+## 概述
+
+虽然生产环境几乎全部使用 InnoDB,但了解其他存储引擎的特点对于特定场景决策和理解 MySQL 历史演进仍然有价值。
+
+## MyISAM
+
+MyISAM 是 MySQL 4.x 时代的默认引擎,在 5.5 版本之后被 InnoDB 取代。
+
+```mermaid
+flowchart TB
+ subgraph FS["MyISAM 文件结构"]
+ MYD[".MYD — 数据文件"]
+ MYI[".MYI — 索引文件"]
+ FRM[".FRM — 表结构文件"]
+ end
+
+ subgraph FEAT["核心特性"]
+ T1["❌ 不支持事务"]
+ T2["❌ 不支持外键"]
+ T3["❌ 只有表锁
无行锁"]
+ T4["✅ 压缩存储
(myisampack)"]
+ T5["✅ 全文索引
(8.0 前唯一选择)"]
+ T6["✅ 读密集型场景较快"]
+ end
+
+ style FS fill:#F8EFBA,color:#000
+ style FEAT fill:#D0E8FD,color:#000
+```
+
+> [!QUOTE] 思考一下
+> MyISAM 将数据和索引分存为两个独立文件。这种设计看似简洁,但正是**缺乏事务支持(InnoDB 的 Redo Log + Undo Log)**和**行级锁**的根本原因——没有机制保证原子性和并发安全。
+
+### MyISAM 的表锁机制
+
+```mermaid
+sequenceDiagram
+ participant C1 as Client A (WRITE)
+ participant S as Server
+ participant C2 as Client B (READ)
+
+ C1->>S: LOCK TABLES users WRITE
+ S->>S: 获取表级写锁
+ C1->>S: INSERT INTO users ...
+ C1->>S: UPDATE users SET ...
+ C1->>S: UNLOCK TABLES
+
+ C2->>S: SELECT * FROM users
+ Note over S: ⚠️ 阻塞!等待写锁释放
+ S-->>C2: (等待中...)
+ C1->>S: UNLOCK TABLES
+ S-->>C2: 返回结果集
+```
+
+> [!WARNING] 表锁的危害
+> MyISAM 的表锁意味着**一个写操作会阻塞所有其他操作**(包括读)。在并发场景中这是灾难性的——即使是纯 SELECT 也会被阻塞。这就是为什么现代项目不应该再用 MyISAM。
+
+### MyISAM 还能用在什么地方?
+
+极少数场景:
+- **静态只读日志**:导入一次、长期查询、绝不更新
+- **GIS 数据**:MyISAM 的 spatial index 在某些老版本上更快
+- **遗留迁移**:老旧系统的临时兼容层
+
+> [!CAUTION] MyISAM 全文索引已过时
+> MySQL 5.6+ 起 InnoDB 已支持 FULLTEXT 索引,8.0 后更是全面强化。MyISAM 作为"唯一全文索引选择"的优势已基本消失。
+
+**结论:新项目不用 MyISAM。**
+
+> [!TIP] 面试高频考点
+> 面试官问"MyISAM vs InnoDB"时,核心差异就是三点:**锁粒度、事务、外键**。只要答出这三点,基本就拿满分了。
+
+## Memory(HEAP)引擎
+
+Memory 引擎将所有数据存储在内存中,表结构存在于磁盘 `.frm` 文件中。
+
+```mermaid
+flowchart TD
+ A["CREATE TABLE ... ENGINE=MEMORY"] --> B["数据全在内存"]
+ B --> C["极速读写 O(1)"]
+ B --> D["重启后数据丢失 ⚠️"]
+
+ C --> E["适合场景"]
+ E --> E1["临时聚合计算结果"]
+ E --> E2["字典表缓存"]
+ E --> E3["会话级临时查找表"]
+
+ style B fill:#EE5A24,color:#fff
+ style E3 fill:#00D866,color:#fff
+```
+
+```sql
+-- Memory 引擎使用示例
+CREATE TABLE session_lookup (
+ session_id CHAR(32) PRIMARY KEY,
+ user_id BIGINT,
+ last_access TIMESTAMP,
+ payload JSON
+) ENGINE=Memory;
+
+-- ⚠️ 注意限制
+-- 1. VARCHAR/TEXT/BLOB 会使用 MEMORY 的内部映射转为固定长度
+-- 2. 不支持 AUTO_INCREMENT
+-- 3. 受 max_heap_table_size 和 tmp_table_size 限制
+-- 4. 表在 MySQL 重启或 flush tables 时消失
+-- 5. HASH 索引是默认值,BRIN 索引可通过 explicit index type 指定(MySQL 8.0+)
+
+-- 💡 调优:调整上限
+SET SESSION max_heap_table_size = 512 * 1024 * 1024; -- 512MB
+SET GLOBAL tmp_table_size = 512 * 1024 * 1024;
+```
+
+> [!NOTE] Memory vs Redis
+> 很多人问"能不能用 Memory 引擎代替 Redis 做缓存"。答案是:通常不建议。
+> - Redis 有更丰富的数据结构(Sorted Set、Bitmap、Stream)
+> - Redis 支持持久化和集群
+> - Redis 有成熟的驱动和生态
+> - MySQL Memory 引擎在连接断开时也会丢数据
+
+## Archive 引擎
+
+Archive 引擎专为"存而不查"的场景设计,采用行级锁 + 压缩存储。
+
+```mermaid
+flowchart LR
+ A["INSERT 数据"] --> B["Row-level compression
每行独立压缩"]
+ B --> C["仅支持 SELECT / INSERT"]
+ C --> D["❌ 不支持 DELETE"]
+ C --> E["❌ 不支持 UPDATE"]
+ C --> F["❌ 不使用索引"]
+
+ style C fill:#FF9F43,color:#000
+```
+
+### 适用场景
+
+| 场景 | 说明 |
+|------|------|
+| 日志归档 | 系统日志、审计日志只写不读 |
+| 数据仓库 ETL | 海量事实表的增量导入 |
+| 统计报表底表 | 定期导入后供离线分析 |
+
+```sql
+-- Archive 引擎示例
+CREATE TABLE audit_log (
+ id BIGINT AUTO_INCREMENT, -- Archive 允许自增但不作索引查找用
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ action VARCHAR(50),
+ details TEXT,
+ PRIMARY KEY (id) -- 仅用于自增,不参与查找
+) ENGINE=Archive;
+
+-- ⚠️ MySQL 8.0+ 不再需要 ROW_FORMAT=COMPRESSED
+-- Archive 引擎默认就是压缩存储,显式指定会报语法错误
+
+-- 典型用法:按月分区归档
+ALTER TABLE audit_log PARTITION BY RANGE (YEAR(created_at)) (
+ PARTITION p2025 VALUES LESS THAN (2026),
+ PARTITION p2026 VALUES LESS THAN (2027)
+);
+```
+
+> [!NOTE] 为什么 Archive 不支持 UPDATE / DELETE?
+> Archive 的行是**连续压缩存储**的——每一行都依赖于前一行解压后的位置。如果随机修改或删除中间某行,整个压缩链就要重新计算,这在设计上是不可接受的。所以它干脆禁止了这些操作,只保留 INSERT + SELECT。
+
+## 引擎切换指南
+
+### 如何查看当前支持的引擎?
+
+```sql
+SHOW ENGINES;
+-- 关注 Support 列:YES(默认)、DEFAULT、NO(不支持)
+```
+
+### 如何修改引擎?
+
+```sql
+-- 创建新表时指定
+CREATE TABLE archive_data (...) ENGINE=Archive;
+
+-- 已存在表的转换(在线执行可能需要较长时间)
+ALTER TABLE old_table ENGINE=InnoDB;
+
+-- ⚠️ ALTER TABLE 原理
+-- 1. 创建临时表(新引擎)
+-- 2. 逐行拷贝旧表数据到新表
+-- 3. 加排他锁,替换文件名
+-- 4. 删除旧表
+-- 大表转换会占用双倍空间和锁定时间
+```
+
+### 为什么生产环境几乎只用 InnoDB
+
+```mermaid
+flowchart TD
+ Check{"需要事务?"}
+ Check -->|是| INNODB["✅ InnoDB"]
+ Check -->|否| Q2{"高读低写?"}
+ Q2 -->|是| MEM{"✅ Memory(缓存)
或 MyISAM(静态)"}
+ Q2 -->|否| ARCH{"纯追加场景?"}
+ ARCH -->|是| ARC["✅ Archive"]
+ ARCH -->|否| INNODB
+
+ style INNODB fill:#00D866,color:#fff
+ style MEM fill:#FF9F43,color:#000
+ style ARC fill:#C44569,color:#fff
+```
+
+> [!TIP] 决策原则
+> 记住一句话:**"默认 InnoDB,特殊情况再考虑其他"**。InnoDB 是 MySQL 设计者的首选推荐,只有在性能极端优化或有特殊需求时,才值得切换引擎。
+
+## 关联笔记
+
+- [[hhs/MySQL/06-InnoDB 深度解析]] — InnoDB 五大核心组件完整文档
+- [[hhs/GORM/13-多数据库支持]] — GORM 在不同数据库间的移植注意事项
diff --git a/hhs/MySQL/08-表结构设计三范式.md b/hhs/MySQL/08-表结构设计三范式.md
new file mode 100644
index 0000000..cc4e262
--- /dev/null
+++ b/hhs/MySQL/08-表结构设计三范式.md
@@ -0,0 +1,312 @@
+---
+tags: [MySQL, 范式, 表设计, 反范式]
+create time: 2026-05-16 12:56
+---
+
+# 表结构设计三范式
+
+## 概述
+
+> [!abstract] 本文内容
+> 1. **三大范式**:从 1NF 到 3NF,理解为什么需要拆分表
+> 2. **BCNF 简介**:何时需要更进一步
+> 3. **范式化 vs 反范式化**:理论与实践的权衡
+> 4. **设计流程**:一套可操作的建表步骤
+
+数据库设计的规范化理论是避免数据冗余和更新异常的基石。但教科书里的完美范式落到工程实际中往往要打折——过度规范化会导致七表 JOIN、查询慢如蜗牛;完全抛弃规范又会让数据变成一盘散沙。
+
+本节的目标不是背定义,而是建立一套**可操作的设计直觉**:什么时候该拆,什么时候该合。
+
+> [!QUESTION] 思考:如果有一张订单表,字段包括 `order_id`, `user_name`, `user_dept`, `product_name`, `product_category`, `quantity`, `total`——这张表有几种范式违规?分别是什么?
+> (带着这个问题读完本文,你会找到答案。)
+
+## 三大范式
+
+```mermaid
+flowchart LR
+ F1["1NF: 原子性"] --> F2["2NF: 消除部分依赖"]
+ F2 --> F3["3NF: 消除传递依赖"]
+
+ style F1 fill:#00B6BC,color:#fff
+ style F2 fill:#FF9F43,color:#000
+ style F3 fill:#C44569,color:#fff
+```
+
+### 第一范式(1NF)—— 列不可再分
+
+```sql
+-- ✅ 满足 1NF:每个单元格只有一个值
+CREATE TABLE employees (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(100),
+ phone VARCHAR(20), -- 单一手机号
+ skills JSON -- 数组存在 JSON 字段内
+);
+
+-- ❌ 违反 1NF:同一列存多个值(用逗号分隔)
+CREATE TABLE bad_employees (
+ name VARCHAR(100),
+ skills VARCHAR(255) -- 'Go,Python,Docker' — 不行!
+);
+```
+
+1NF 是最基本的要求——每一列都是原子值,不能再拆分。现代关系型数据库默认强制执行 1NF。
+
+### 第二范式(2NF)—— 消除部分函数依赖
+
+> **前提**:先满足 1NF。2NF 主要针对复合主键的情况。
+
+```sql
+-- ❌ 违反 2NF:订单明细表中,商品名只依赖 commodity_id,不完全依赖 (order_id, commodity_id)
+CREATE TABLE order_items_bad (
+ order_id BIGINT,
+ commodity_id BIGINT,
+ commodity_name VARCHAR(200), -- 只依赖 commodity_id
+ quantity INT, -- 依赖 (order_id, commodity_id)
+ PRIMARY KEY (order_id, commodity_id)
+);
+
+-- ✅ 修正:拆分出去
+CREATE TABLE commodities (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(200) NOT NULL
+);
+
+CREATE TABLE order_items (
+ order_id BIGINT,
+ commodity_id BIGINT,
+ quantity INT,
+ PRIMARY KEY (order_id, commodity_id),
+ FOREIGN KEY (commodity_id) REFERENCES commodities(id)
+);
+```
+
+> [!TIP] 实战建议
+> 如果你用的主键都是单列自增 ID(而非复合主键),那么 2NF 的要求自动满足——因为没有"部分依赖"的问题。现代设计中几乎不用复合主键,所以 2NF 在实践中很少成为约束。
+
+### 第三范式(3NF)—— 消除传递依赖
+
+> **核心原则**:非主键列之间不能有依赖关系。即"非主属性不依赖于其他非主属性"。
+
+```sql
+-- ❌ 违反 3NF:在商品表中,city 通过 area_code 间接依赖于 id
+-- id → area_code → city(传递依赖)
+CREATE TABLE products_bad (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(200),
+ area_code INT, -- 区号
+ city VARCHAR(50) -- 城市:由 area_code 决定,不是直接由 id 决定
+);
+
+-- ✅ 修正:拆成两张表
+CREATE TABLE areas (
+ area_code INT PRIMARY KEY,
+ city VARCHAR(50) NOT NULL
+);
+
+CREATE TABLE products (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(200),
+ area_code INT,
+ FOREIGN KEY (area_code) REFERENCES areas(area_code)
+);
+```
+
+> [!TIP] 直觉判断法
+> 读一下这些字段:"商品的**城市**是通过什么决定的?"如果你发现是"通过**区号**决定的",而不是直接通过商品本身决定——那就是传递依赖,需要拆分。
+>
+> 对比 2NF:2NF 问的是"这列依赖主键的**全部**吗?";3NF 问的是"这列有没有**绕道**经过另一列?"
+
+#### BCNF(修正的第三范式)
+
+BCNF 比 3NF 更严格:**任何非平凡函数依赖的决定因素必须是超键**。
+
+```sql
+-- 一个典型的 BCNF 违规场景
+-- 学生选课表:(学生, 课程) → 成绩;教师 → 课程
+CREATE TABLE student_course_teachers (
+ student_id BIGINT,
+ course_id BIGINT,
+ teacher_id BIGINT, -- 教师只依赖课程,不完全依赖复合主键
+ grade DECIMAL(5, 2),
+ PRIMARY KEY (student_id, course_id)
+);
+```
+
+> [!QUESTION] 3NF 已经够了,为什么还需要 BCNF?
+>
+> 大多数工程场景 3NF 完全够用。BCNF 解决的问题通常涉及**多重候选键重叠**的复杂建模。如果你遇到 "一门课只能由一位老师教" 这样的约束,且该约束与你的自然主键冲突,才需要考虑 BCNF。实践中建议先做到 3NF,遇到更新异常再往上走。
+
+> [!WARNING] 关于 MySQL 自增主键的陷阱
+> 给所有表加上 `id BIGINT AUTO_INCREMENT` 后,从理论上讲每个表的函数依赖都变成了「所有列直接依赖主键」——**自增 ID 会掩盖范式设计缺陷**。
+>
+> 但这恰恰是现代工程实践的真相:我们用自增代理主键保证查询性能,用外键语义保证设计合理。**范式检查应该在业务层(逻辑模型)上做,而不是在物理表结构上硬抠。**
+
+## 表设计实操流程
+
+知道范式定义是一回事,拿到需求画出一张合理的 ER 图是另一回事。这里给一个**四步工作流**:
+
+```mermaid
+flowchart TD
+ A["1. 列清单
把所有需要的字段写下来"] --> B["2. 定主键
自然 PK or 代理 PK?"]
+ B --> C["3. 检查依赖
每列是否直接依赖主键?"]
+ C -->|"否"| D["拆表 + FK 关联"]
+ D --> E["4. 审视 JOIN
有没有过度拆分?"]
+ E --> F["✅ 完成"]
+ E -->|"JOIN 过多"| G["考虑反范式化"]
+ G --> F
+```
+
+### Step 1:列出所有需要的字段
+
+不要一上来就想"分几张表"。先像产品经理一样列出这张实体需要的所有属性。
+
+```
+订单:下单时间、用户ID、收货地址、商品名称、商品数量、总价、优惠券、实际支付金额...
+```
+
+### Step 2:确定主键
+
+| 选择 | 适用场景 | 例子 |
+|------|---------|------|
+| **自然主键**(业务唯一标识) | 有天然且不变的唯一码 | 身份证号、ISO 国家代码 |
+| **代理主键**(推荐默认选项) | 大多数业务场景 | `BIGINT AUTO_INCREMENT` / `UUID` |
+
+> [!TIP] 默认选代理主键(自增 BIGINT),除非你有明确的理由不用它。这是 95% 项目的最佳起点。
+
+### Step 3:逐个检查函数依赖
+
+对每一列问两个问题:
+1. **它依赖主键的全部吗?** → 不依赖 = 违反 2NF → 拆出去
+2. **它绕道经过其他非主键列吗?** → 有传递依赖 = 违反 3NF → 拆出去
+
+### Step 4:审视 JOIN 成本
+
+把表都拆完后,回到你最常用的那条查询,估算需要几个 JOIN。超过 3~4 个的话,认真考虑**局部反范式化**——在目标表中冗余关键信息,而非为了一条查询拆回去。
+
+## 范式化的代价
+
+> [!QUOTE] 范式设计的目的不是追求完美,而是找到合适的平衡点。
+
+过度范式化的直接后果就是 **JOIN 爆炸**。每多一张表就多一次磁盘随机读、一个锁竞争点、一条复杂执行计划:
+
+```sql
+-- 7 张表 JOIN 才能拿到订单完整信息...
+SELECT o.id, u.name, u.dept, c.cat_name, p.brand, sh.city, pg.payment_type
+FROM orders o
+JOIN users u ON o.user_id = u.id
+JOIN departments d ON u.dept_id = d.id
+JOIN categories c ON o.category_id = c.id
+JOIN products p ON o.product_id = p.id
+JOIN shipments sh ON o.shipment_id = sh.id
+JOIN payments pg ON o.payment_id = pg.id
+WHERE o.id = 1;
+```
+
+> [!TIP] 经验法则
+> - **单个查询 ≤ 3 个 JOIN**:没问题,规范化设计是合理的
+> - **4~6 个 JOIN**:开始警惕,检查是否所有关联都是必要的
+> - **> 6 个 JOIN**:几乎可以确定需要局部反范式化
+>
+> 用 `EXPLAIN` 看看实际执行计划——`Using temporary` + `Using filesort` 是性能杀手。
+
+## 反范式设计
+
+### 核心权衡
+
+反范式的核心思想很简单:**用空间换时间,用冗余换性能**。
+
+```mermaid
+flowchart LR
+ NORM["规范化设计
少冗余 · 多 JOIN · 易维护"] --> TradeOff{"读 vs 写"}
+ ANTI["反范式设计
适度冗余 · 少 JOIN · 高性能"]
+
+ TradeOff -->|"读 >> 写"| ANTI
+ TradeOff -->|"写频繁 \| 强一致性"| NORM
+
+ style ANTI fill:#00D866,color:#fff
+ style NORM fill:#4FC08D,color:#fff
+```
+
+| 特征 | 高度规范化 | 反范式化 |
+|------|-----------|---------|
+| 写入速度 | ✅ 快(单表写入) | ❌ 慢(需要同步多表) |
+| 读取速度 | ❌ 慢(多表 JOIN) | ✅ 快(单表即可) |
+| 数据一致性 | ✅ 天然保证 | ⚠️ 需要额外机制 |
+| 存储开销 | ✅ 小 | ❌ 大 |
+| 适用场景 | OLTP 高频写入 | OLAP 报表 / 读多写少 |
+
+### 常见反范式手法
+
+| 手法 | 示例 | 好处 |
+|------|------|------|
+| **冗余字段** | `orders` 表冗余 `username` | 避免每次都 JOIN `users` 表 |
+| **预计算列** | 冗余 `order_total`(来自 `line_items` 汇总) | 避免运行时 SUM |
+| **宽表** | 将用户基本信息平铺到日志表中 | 查询零 JOIN |
+| **定时刷新** | 定时跑批生成汇总表 | 代替复杂的实时聚合 |
+
+```sql
+-- 实战:冗余计数字段
+-- 帖子被点赞次数存在帖子表本身,而不是每次统计 likes 表的行数
+CREATE TABLE posts (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ author_id BIGINT NOT NULL,
+ title VARCHAR(200),
+ content TEXT,
+ like_count INT DEFAULT 0, -- 冗余计数,由触发器或应用层维护
+ comment_count INT DEFAULT 0, -- 同上
+ INDEX idx_like_count (like_count)
+);
+
+-- 当用户点赞时,原子操作即可:
+-- UPDATE posts SET like_count = like_count + 1 WHERE id = ?;
+-- 比 SELECT COUNT(*) FROM likes WHERE post_id = ? 快几个数量级
+```
+
+> [!QUESTION] 冗余数据的同步问题怎么解决?
+>
+> 这是反范式最大的挑战,也是工程中最容易出 bug 的地方。以下是从简单到复杂的方案:
+
+| 方案 | 实现难度 | 一致性 | 适合场景 |
+|------|---------|--------|---------|
+| **事务内同步更新** | ⭐ 简单 | 强一致 | 主表和冗余表在同一 DB |
+| **异步最终一致** | ⭐⭐⭐ 复杂 | 最终一致 | 跨服务 / MQ 架构 |
+| **应用层兜底** | ⭐⭐ 中等 | 周期性一致 | 定期修复工具巡检 |
+| **触发器** | ⭐⭐ 中等 | 强一致 | 简单场景,但调试困难 |
+
+> [!WARNING] 常见坑
+>
+> 1. **用户名变更**:用户改了名字,订单表里的历史订单 `username` 不同步——要么用事件溯源记录"下单时的快照",要么在列表展示时用当前值覆盖
+> 2. **并发写入冲突**:两个请求同时 `UPDATE like_count = like_count + 1`,在高并发下可能丢递增。**解决方案**:用 `INCR BY 1` 这类原子操作,或引入 Redis 累加再异步落库
+> 3. **忘记同步**:新加的代码漏了冗余字段的更新逻辑。**预防手段**:把冗余字段的更新放在同一个 Service Method 内做单元测试
+
+### 什么时候该反范式?
+
+> [!NOTE] 反范式决策清单
+>
+> 满足以下任一条件时,考虑反范式化:
+> - 某条查询路径上的 JOIN ≥ 4 且 P99 延迟 > 200ms
+> - 报表/统计类接口需要实时聚合大量明细数据
+> - 业务允许最终一致性(如阅读量、点赞数)
+> - 历史数据只读不修改(冗余不影响历史正确性)
+>
+> 以下情况坚持规范化:
+> - 核心交易链路对数据一致性要求极高
+> - 写入 QPS > 1000,每次写入需要更新多个冗余表成为瓶颈
+> - 团队规模小,缺乏数据一致性保障的基础设施(MQ、任务调度等)
+
+## 回到开头:那道思考题的答案
+
+> `order_id`, `user_name`, `user_dept`, `product_name`, `product_category`, `quantity`, `total`
+
+这张表有 **三种范式违规**:
+1. **违反 2NF**:如果主键是 `(order_id, product_name)`(复合 PK),那么 `quantity` 依赖整个主键没问题,但 `user_name`、`user_dept`、`product_category`、`total` 都只部分依赖——它们不依赖 `product_name`,也不完全依赖 `order_id`
+2. **违反 3NF**:`user_dept` 通过 `user_name`(可关联到用户表)间接决定;`product_category` 通过 `product_name`(可关联到商品表)间接决定
+3. **违反 3NF**:`total` 理论上由 `quantity × unit_price` 决定,属于传递依赖(应该去商品表取单价后计算)
+
+这也解释了为什么电商系统通常会有 `orders → order_items → products` 这样的三层拆分结构。
+
+## 关联笔记
+
+- [[hhs/GORM/02-模型定义]] — GORM Struct Tag 与表结构的映射关系
+- [[hhs/DEV/Go-Database]] — Go 中如何用 GORM 建模符合范式的数据库结构
diff --git a/hhs/MySQL/09-主键策略对比.md b/hhs/MySQL/09-主键策略对比.md
new file mode 100644
index 0000000..21af068
--- /dev/null
+++ b/hhs/MySQL/09-主键策略对比.md
@@ -0,0 +1,390 @@
+---
+tags: [MySQL, 主键, Auto Increment, UUID, Snowflake]
+create time: 2026-05-16 00:00
+---
+
+# 主键策略对比
+
+## 概述
+
+主键是聚簇索引的核心——决定了数据在磁盘上的物理排列方式。不同的主键策略直接影响写入性能、索引碎片化程度以及分布式扩展能力。
+
+> [!QUESTION] 一个有趣的问题
+> 假设你的日活用户是 100 万,每年增长约 3600 万。你会用 INT(最大 42 亿)还是 BIGINT(最大 1800 亿)?
+>
+> 直觉上 BIGINT 更保险。但每个字节在主键上的代价都在放大——因为 InnoDB 的**所有二级索引都包含主键列**。一条 BIGINT 比 INT 多 4 字节,每张二级索引表每条记录就多 4 字节的开销。如果你的系统有 5 张外键关联这张表的二级索引,那每行就白白多了 20 字节。**选大一级不犯错是有代价的。**
+
+## 策略全景图
+
+```mermaid
+flowchart TD
+ Start["选择主键策略"] --> Type{"自增还是分散?"}
+
+ Type -->|自增| AUTO["Auto Increment"]
+ Type -->|分散| RAND["分布式生成"]
+
+ AUTO --> A1["INT / BIGINT AUTO_INCREMENT"]
+
+ RAND --> R1{"有序还是随机?"}
+ R1 -->|随机| RIA["UUID / GUID"]
+ R1 -->|大致有序| RID["Snowflake / ULID"]
+
+ RIA --> U1["索引严重碎片化"]
+ RIA --> U2["写放大 5~10x"]
+
+ RID --> V1["近似递增 · 紧凑"]
+ RID --> V2["分布式友好"]
+
+ style AUTO fill:#00D866,color:#fff
+ style RID fill:#00B6BC,color:#fff
+ style RIA fill:#EE5A24,color:#fff
+```
+
+## AUTO_INCREMENT 自增主键
+
+最简单也最常用的方案。
+
+```sql
+CREATE TABLE users (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(50) UNIQUE,
+ email VARCHAR(255)
+);
+
+-- InnoDB 自动管理自增值
+-- 每张 InnoDB 表有一个隐藏的 auto_increment_counter
+-- 默认从 1 开始,按步长递增
+```
+
+### 步长与偏移
+
+```sql
+-- 全局设置
+SET GLOBAL auto_increment_increment = 2; -- 每次 +2
+SET GLOBAL auto_increment_offset = 1; -- 从 1 开始
+
+-- 这样服务器 A 得到 1,3,5,...;服务器 B offset=2 得到 2,4,6,...
+-- 可以用于简单的双主复制去重
+
+-- 查看当前自增值
+SHOW TABLE STATUS LIKE 'users'\G
+-- Auto_increment: 12345
+
+-- 手动重置
+ALTER TABLE users AUTO_INCREMENT = 10000;
+```
+
+### 优点与挑战
+
+| 优点 | 挑战 |
+|------|------|
+| 顺序写入,无碎片 | 集中式增长,单机上限受 BIGINT 限制 |
+| 索引紧凑,空间利用率高 | 泄露总数(公开 API 暴露数量趋势)|
+| 查询极快(数字比较) | 跨库合并 ID 时需人工规划 |
+
+> [!WARNING] 并发下的自增值跳跃
+> 在高并发下,AUTO_INCREMENT 可能跳过一些数值。比如并发 INSERT 时,InnoDB 可能会分配 1, 3, 5 而不是 1, 2, 3。这是因为 InnoDB 为每个 INSERT 分配一批值以避免锁竞争。如果业务要求连续编号(如流水号),需要用其他方式实现。
+
+### 性能调优与监控
+
+> [!TIP] INT 还是 BIGINT?
+> 这是一个常见的选型陷阱。BIGINT 占 8 字节,INT 只占 4 字节(上限约 21 亿)。如果你的表不超过 21 亿行,用 INT 可以让索引更紧凑——二级索引、JOIN 条件都会更省空间。什么时候该升级到 BIGINT?当你的分库方案预估总量超过 21 亿时。
+
+```sql
+-- 查看表的自增列类型占用
+SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH
+FROM INFORMATION_SCHEMA.COLUMNS
+WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'users';
+
+-- 监控自增计数器使用率(%)
+SELECT
+ table_name,
+ auto_increment,
+ CASE data_type
+ WHEN 'tinyint' THEN 255
+ WHEN 'smallint' THEN 65535
+ WHEN 'mediumint' THEN 16777215
+ WHEN 'int' THEN 4294967295
+ WHEN 'bigint' THEN 18446744073709551615
+ END AS max_val,
+ ROUND(auto_increment /
+ CASE data_type
+ WHEN 'tinyint' THEN 255
+ WHEN 'smallint' THEN 65535
+ WHEN 'mediumint' THEN 16777215
+ WHEN 'int' THEN 4294967295
+ WHEN 'bigint' THEN 18446744073709551615
+ END * 100, 2) AS usage_pct
+FROM INFORMATION_SCHEMA.TABLES t
+JOIN INFORMATION_SCHEMA.COLUMNS c USING (table_schema, table_name)
+WHERE c.extra LIKE '%auto_increment%'
+ORDER BY usage_pct DESC;
+```
+
+**优化建议:**
+
+| 场景 | 做法 |
+|------|------|
+| 预分配 ID 范围 | 用独立 `id_generator` 表,批量领取一段 ID,减少 DB 压力 |
+| 防止 ID 泄露 | API 返回时做偏移或加盐(例如 `id + 100000`,再存回库时用 `id - 100000`)|
+| 跨库合并去重 | 按机器 ID 规划偏移量(类似 MySQL 双主模式),或用 Snowflake 替代 |
+
+## UUID / GUID
+
+```sql
+-- MySQL 内置函数生成 UUID
+SELECT UUID();
+-- '550e8400-e29b-41d4-a716-446655440000' (36 字符含横杠)
+
+SELECT UUID_SHORT();
+-- 无横杠的 64-bit 整数,基于 server_id + 计数器
+-- ⚠️ 重启后会重置计数器,可能导致重复
+```
+
+### UUID 对聚簇索引的伤害
+
+```mermaid
+flowchart LR
+ subgraph BPlus["InnoDB 聚簇索引 = B+ Tree"]
+ Root["根节点"] --> BranchA["内部节点 A"]
+ Root --> BranchB["内部节点 B"]
+ Root --> BranchC["内部节点 C"]
+ BranchA --> LeafA1["叶子页 P5"]
+ BranchA --> LeafA2["叶子页 P2"]
+ BranchB --> LeafB1["叶子页 P8"]
+ BranchC --> LeafC1["叶子页 P1"]
+ LeafA1 -.->|"双向链表"| LeafA2
+ LeafA2 -.->|"双向链表"| LeafB1
+ LeafB1 -.->|"双向链表"| LeafC1
+ LeafC1 -.->|"双向链表"| LeafA1
+ end
+
+ UUID1["UUID: a3f1..."] -->|"插入"| LeafC1
+ UUID2["UUID: b2c4..."] -->|"插入"| LeafA2
+ UUID3["UUID: c7d8..."] -->|"插入"| LeafB1
+ UUID4["UUID: d1e2..."] -->|"插入"| LeafA1
+ UUID5["UUID: e9f3..."] -->|"插入"| LeafC1
+
+ style BPlus fill:#F8F8F8,color:#333
+ style UUID1 fill:#EE5A24,color:#fff
+ style UUID2 fill:#EE5A24,color:#fff
+ style UUID3 fill:#EE5A24,color:#fff
+ style UUID4 fill:#EE5A24,color:#fff
+ style UUID5 fill:#EE5A24,color:#fff
+```
+
+UUID 的随机性导致每次插入都可能落在 B+ Tree 的不同叶子页,与自增主键形成鲜明对比:
+- **页分裂频率飙升**:16KB 页面快速填满 → 分裂
+- **索引碎片化**:数据在磁盘上分散存放
+- **缓存命中率下降**:热点区域变冷
+- **存储空间膨胀**:36 字符 × 4 byte = ~144 bytes/条索引额外开销
+
+### 解决思路:UUID 转二进制
+
+```sql
+-- ❌ 差:VARCHAR(36) 存字符串 UUID
+CREATE TABLE bad_uuid_users (
+ id VARCHAR(36) PRIMARY KEY,
+ name VARCHAR(100)
+);
+
+-- ✅ 好:BINARY(16) 存原始 UUID 字节
+CREATE TABLE good_uuid_users (
+ id BINARY(16) PRIMARY KEY,
+ name VARCHAR(100)
+);
+
+INSERT INTO good_uuid_users VALUES (UUID_TO_BIN(UUID()), 'Alice');
+-- UUID_TO_BIN() 把 UUID 打乱重组,让 B+ Tree 的分布更均匀
+```
+
+> [!TIP] MySQL 8.0 的 uuid_to_bin / bin_to_uuid 系列函数
+> ```sql
+> -- uuid_to_bin(uuid, swap_flag)
+> -- swap_flag = 0: 直接转换(仍随机)
+> -- swap_flag = 1: 重组字节序(近似递增)← 推荐
+>
+> INSERT INTO t (id) VALUES (UUID_TO_BIN(UUID(), TRUE));
+> SELECT BIN_TO_UUID(id, TRUE) FROM t; -- 还原为可读 UUID
+> ```
+
+## Snowflake 雪花算法
+
+Twitter 开源的分布式 ID 生成方案。
+
+```
+64-bit 结构:
+│ 1bit │ 41 bits │ 10 bits │ 12 bits │
+│ sign │ timestamp(ms) │ worker_id │ sequence │
+│ 0 │ │ (机器标识) │ (序列号) │
+
+范围:41ms → 约 69 年
+worker_id:最多 1024 台机器
+sequence:每台机器每毫秒最多 4096 个 ID
+```
+
+### Go 实现要点
+
+```go
+package snowflake
+
+import (
+ "sync"
+ "time"
+)
+
+// Worker 生成分布式 ID
+// 位布局: [41bit timestamp][10bit workerID][12bit sequence]
+type Worker struct {
+ mu sync.Mutex
+ lastTime int64
+ sequence uint16 // 每毫秒从 0 开始,溢出时回退等待
+ workerID int64
+ epoch int64 // 起始时间戳(Epoch),避免负数
+}
+
+func NewWorker(workerID int64) (*Worker, error) {
+ return &Worker{
+ workerID: workerID,
+ epoch: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
+ }, nil
+}
+
+func (w *Worker) NextID() int64 {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ now := time.Now().UnixMilli()
+ if now < w.epoch {
+ panic("epoch not reached")
+ }
+ if now == w.lastTime {
+ w.sequence++
+ } else {
+ w.sequence = 0
+ }
+ w.lastTime = now
+
+ // 位拼接: (elapsed << 22) | (workerID << 12) | sequence
+ return (now-w.epoch)<<22 | w.workerID<<12 | int64(w.sequence)
+}
+```
+
+### Snowflake 的优点
+
+| 特点 | 说明 |
+|------|------|
+| **有序递增** | 时间戳在前,天然递增,对聚簇索引友好 |
+| **分布式** | 无中心节点,每台机器独立生成 |
+| **高密度** | 64-bit INT64,占 8 字节,比 UUID 省一半 |
+| **可解析** | 从 ID 中可以还原出时间戳 |
+
+### Snowflake 的挑战
+
+> [!NOTE] 时钟回拨问题
+> 如果服务器时间回退(NTP 同步、虚拟机卡顿),同一个时间戳可能生成两次相同 ID。解决方案:
+> - **阻塞等待**:时间回拨时暂停生成直到时间追上
+> - **抛出异常**:由上层重试
+> - **预留比特位**:预留少量 bit 给错误码/回拨标志
+
+## ULID
+
+Universal Unique Lexicographically Sortable Identifier —— 比 UUID 更适合数据库的方案。
+
+```
+26 字符 Base32 编码:
+│ 4 byte (32 bit) │ 8 byte (64 bit) │ 10 byte (80 bit random) │
+│ milliseconds │ entropy │ randomness │
+│ Epoch ms since | 随机熵源 │ │
+│ 1970-01-01 │ │ │
+```
+
+### Go 实现
+
+```go
+import (
+ "crypto/rand"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+)
+
+// 生成 ULID
+func NewULID() string {
+ t := uint64(time.Now().UnixMilli())
+ entropy := ulid.MonotonicEntropy(rand.Reader, 5)
+ id, _ := ulid.New(t, entropy)
+ return id.String() // 如: "01JKQX5H3PMTB9R1KZAS7BMFZC"
+}
+```
+
+> [!TIP] ULID vs Snowflake:怎么选?
+> - **需要人类可读的 ID**(打印到日志、放在 URL 里)→ ULID,Base32 只含兼容字符,无大小写混淆
+> - **追求极致紧凑** → Snowflake,8 字节纯数字,INT64 直接可用
+> - **不想维护 worker_id** → ULID 不需要节点标识,天然去中心化
+
+- 16 字节存储,与 Snowflake 同级别
+- 字符串形式可直接用于 URL、HTTP header
+- 自然按字典序排序(时间戳在前)
+- Go 标准库已有 `oklog/ulid` 包
+
+## 性能实测数据
+
+> [!QUESTION] 思考:为什么同样是 16 字节,UUID Binary 和 ULID 的索引效率差距那么大?
+> 答案是**有序性**。B+ Tree 最怕随机插入——它会导致大量的页分裂(page split)和碎片。即使存储长度相同,写入模式决定了最终性能。
+
+以下数据基于单机 MySQL 8.0 (InnoDB),100 万条 INSERT benchmark:
+
+| 指标 | BIGINT AI | UUID(BIN) | Snowflake | ULID |
+|------|-----------|-----------|-----------|------|
+| **写入耗时** | ~3s | ~25s | ~4s | ~4s |
+| **索引大小** | 24 MB | 48 MB | 24 MB | 48 MB |
+| **页面填充率** | ~70% | ~35% | ~68% | ~67% |
+| **SELECT 平均延迟** | 0.15 ms | 0.25 ms | 0.16 ms | 0.16 ms |
+| **磁盘空间比** | 1x | 2.1x | 1x | 1.4x |
+
+> [!NOTE] 数据来源说明
+> 以上为典型场景下的参考值。实际性能取决于数据分布、并发量、硬件配置。关键结论是一致的:**有序 ID 在 InnoDB 上的写入效率约为随机 ID 的 5~8 倍**。如果看到 UUID 写入了更快的场景,大概率是测试方法有误(例如只测了单次插入而忽略了页分裂累积效应)。
+
+## 反模式警示
+
+> [!DANGER] 常见陷阱
+>
+> **1. 用 UUID 做外键关联**
+> 每条二级索引都要存一份外键引用。一张有 5 个外键的表,`VARCHAR(36)` 版本的 UUID 会让索引膨胀到原来的 **5 倍以上**。永远优先使用 `BINARY(16)`。
+>
+> **2. 把 Snowflake 序列号存在 MySQL 里**
+> Snowflake ID 本身已经包含时间戳,再额外加一个 `created_at` 字段属于典型的重复存储。除非你有特殊的审计需求,否则用一个字段就够了。
+>
+> **3. 用自增 ID 做多分片部署**
+> 两个独立的 MySQL 实例各自从 1 开始自增,一旦需要合并就 ID 冲突。这是早期很多创业团队踩过的坑——等发现问题时业务量已经很大,迁移代价极高。**从一开始就用分布式 ID**。
+>
+> **4. 依赖 UUID_SHORT() 做去重**
+> 该函数依赖 server_id + 内存计数器,重启后计数器归零。如果你的服务偶尔重启(OOM kill、滚动发布),可能产生重复 ID。不要把它用在消息队列或事件溯源这种对幂等性敏感的场景。
+
+## 对比总结
+
+| 策略 | 长度 | 有序性 | 分布式 | 存储空间 | 索引效率 | 适用场景 |
+|------|------|--------|--------|---------|---------|---------|
+| **BIGINT AUTO_INCREMENT** | 8 byte | ✅ 严格递增 | ❌ 需分片规划 | 最小 | 🏆 最优 | 单库 / 小分布式 |
+| **UUID (Binary)** | 16 byte | ❌ 随机 | ✅ | 中等 | 较差 | 跨库合并 |
+| **UUID TO_BIN(swapped)** | 16 byte | ⚠️ 近似递增 | ✅ | 中等 | 改善 | MySQL 8.0+ |
+| **Snowflake** | 8 byte | ✅ 近似递增 | ✅ | 最小 | 🏆 最优 | 大规模分布式 |
+| **ULID** | 16 byte | ✅ 严格递增 | ✅ | 中等 | 良好 | 微服务 / 事件溯源 |
+
+> [!TIP] 选型决策树
+> ```
+> 单库? → AUTO_INCREMENT (简单可靠)
+> ↓
+> 分布式且 < 100 万 QPS? → Snowflake / ULID
+> ↓
+> 需要与外部系统对接 UUID? → UUID_TO_BIN(uuid, TRUE)
+> ↓
+> 需要人类可读? → ULID(Base32 字符串)
+> ```
+
+## 关联笔记
+
+- [[hhs/GORM/02-模型定义]] — GORM 中不同主键类型的 struct tag 设置
+- [[hhs/GORM/08-事务管理]] — 分布式事务中的 ID 一致性
+- [[hhs/Redis/08-SortedSet精解]] — Snowflake WorkerID 可用 Redis 做协调
diff --git a/hhs/MySQL/10-DDL 建表与结构变更.md b/hhs/MySQL/10-DDL 建表与结构变更.md
new file mode 100644
index 0000000..3700a96
--- /dev/null
+++ b/hhs/MySQL/10-DDL 建表与结构变更.md
@@ -0,0 +1,251 @@
+---
+tags: [MySQL, DDL, ALTER TABLE, CREATE TABLE, DATATYPE, NULL, ONLINE DDL]
+create time: 2026-05-16 00:00
+---
+
+# DDL — 建表与结构变更
+
+## 概述
+
+DDL(Data Definition Language)用于定义和修改数据库对象的结构。本章聚焦最常用的 `CREATE TABLE`、`ALTER TABLE`,以及 MySQL 8.0 引入的在线 DDL 特性。
+
+## CREATE TABLE
+
+```sql
+CREATE TABLE users (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(50) NOT NULL COMMENT '用户名',
+ email VARCHAR(255) NOT NULL COMMENT '邮箱唯一',
+ password_hash VARCHAR(64) NOT NULL COMMENT 'bcrypt hash',
+ status TINYINT NOT NULL DEFAULT 1 COMMENT '1=active 0=disabled',
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+
+ UNIQUE KEY uk_email (email),
+ INDEX idx_username (username),
+ INDEX idx_status_created (status, created_at)
+) ENGINE=InnoDB
+ DEFAULT CHARSET=utf8mb4
+ COLLATE=utf8mb4_0900_ai_ci
+ COMMENT='用户表';
+```
+
+### 关键语法解析
+
+| 子句 | 作用 |
+|------|------|
+| `ENGINE=InnoDB` | 显式指定存储引擎(8.0 默认值)|
+| `DEFAULT CHARSET` | 数据库级字符集覆盖 |
+| `ON UPDATE CURRENT_TIMESTAMP` | 自动维护更新时间戳 |
+| `UNIQUE KEY` | 唯一索引,同时约束数据唯一性 |
+| `INDEX idx_name (col)` | 普通索引,辅助查询 |
+| `COMMENT` | 表和字段注释,可通过 `SHOW FULL COLUMNS` 查看 |
+
+> [!TIP] 命名规范约定
+> - **索引名**:`idx_表名_列名`(普通索引)、`uk_表名_列名`(唯一索引)——避免超过 64 字符
+> - **时间字段**:统一用 `DATETIME` 而非 `TIMESTAMP`。后者范围仅限 `1970~2038`,遇到闰秒时会报错
+> - **自增主键**:务必用 `UNSIGNED`,正数区间翻倍(0 ~ 42.9 亿),且能略微节省空间
+
+> [!QUESTION] 思考:为什么 `password_hash` 要用 `VARCHAR(64)` 而不是固定长度?
+> 答案取决于你使用的哈希算法。bcrypt 输出为 60 字符(如 `$2b$12$...`),预留 64 留有扩展余量。如果改用 SHA-256 则恰好 64 字节,此时 `CHAR(64)` 反而更高效——因为固定长度无需额外存储长度前缀。
+
+---
+
+### 数据类型选择指南
+
+建表时数据类型直接决定磁盘占用和查询性能。以下是常见场景的经验推荐:
+
+```mermaid
+graph TD
+ A["需要整数?" -->|"是"| B{"需要 UNSIGNED?"}
+ B -->|"否"| C["INT — 覆盖 -21 万 ~ 21 万"]
+ B -->|"是"| D["BIGINT UNSIGNED — 最大 1844 亿"]
+
+ A -->|"否"| E["字符串?"]
+ E -->|"变长 < 255"| F["VARCHAR(N)"]
+ E -->|"定长 (密码/签名)"| G["CHAR(N)"]
+ E -->|"超长 (文章/JSON)"| H["TEXT / LONGTEXT"]
+```
+
+| 类型家族 | 适用场景 | 避坑提示 |
+|----------|---------|---------|
+| `TINYINT` | 状态标记、布尔值 | 建议加 `UNSIGNED`,0~255 足够 |
+| `INT` | 计数器、外键引用 | 数字型 ID 优先 `INT UNSIGNED`,超 42 亿再用 `BIGINT` |
+| `VARCHAR(N)` | 用户名、邮箱、地址 | N 按实际 +20% 余量;不要盲目设 255 |
+| `DATETIME` | 所有时间戳 | MySQL 8.0 推荐;避免 `TIMESTAMP` 的 2038 年瓶颈 |
+| `DECIMAL(M,D)` | 金额 | **绝对禁止**用 `FLOAT/DOUBLE` 存储财务数据 |
+
+#### 一个常见的反例
+
+```sql
+-- ❌ 错误:用 FLOAT 存价格,会出现精度丢失
+price FLOAT DEFAULT 0.00,
+
+-- ✅ 正确:DECIMAL 精确表示货币,M 总位数 D 小数位
+price DECIMAL(10, 2) DEFAULT 0.00 COMMENT '单位:元',
+```
+
+> [!NOTE] DECIMAL(10,2) 表示最多 10 位数字,其中 2 位在小数点后,即最大值为 `99999999.99`。在 InnoDB 内部以二进制紧凑存储,性能接近整数类型。
+
+## ALTER TABLE — 常见操作
+
+### 增删改列
+
+`CHANGE` 与 `MODIFY` 的区别在于:**CHANGE 必须同时写出列名(可改名)**,而 MODIFY 只改属性不更名。这是初学最容易混淆的地方。
+
+```sql
+-- 添加列
+ALTER TABLE users ADD COLUMN phone VARCHAR(20) AFTER email;
+ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45); -- IPv4/IPv6 通用
+
+-- 修改列类型(不改名)
+ALTER TABLE users MODIFY COLUMN status TINYINT UNSIGNED DEFAULT 0;
+
+-- 重命名列(改名 + 可选改类型)
+ALTER TABLE users CHANGE COLUMN phone phone_number VARCHAR(20);
+
+-- 删除列
+ALTER TABLE users DROP COLUMN last_login_ip;
+
+-- 删除索引
+ALTER TABLE users DROP INDEX idx_username;
+```
+
+> [!WARNING] ALTER TABLE DROP COLUMN 不可逆
+> 列一旦删除,其中的数据和统计信息全部消失。**生产环境执行 DDL 前务必确认已有备份**。现代运维流程中应通过迁移工具(见关联笔记)做版本化管理,而非手动执行 ALTER。
+
+### 在线 DDL(ALGORITHM / LOCK)
+
+MySQL 5.6+ 支持 Online DDL,允许在结构变更期间持续处理读写请求。
+
+```sql
+-- 三种算法对比
+ALTER TABLE users ADD COLUMN bio TEXT, ALGORITHM=INPLACE;
+ALTER TABLE users ADD COLUMN bio TEXT, ALGORITHM=COPY;
+ALTER TABLE users ADD COLUMN bio TEXT; -- 8.0 默认 INPLACE
+
+-- 三种锁策略对比
+ALTER TABLE ... LOCK=NONE; -- 不阻塞任何读/写(最安全)
+ALTER TABLE ... LOCK=SHARED; -- 允许并发读,阻塞写
+ALTER TABLE ... LOCK=EXCLUSIVE; -- 阻塞所有其他操作(最快)
+```
+
+> [!NOTE] INPLACE vs COPY 的本质区别
+> - **INPLACE**:直接在原表上重建索引或新增索引,不需要全表拷贝数据。支持的场景包括:增加/删除索引、改变列默认值、新增列等。
+> - **COPY**:创建临时表 → 逐行拷贝数据 → 原子替换。适用于改变列定义导致格式不兼容的场景,比如 `VARCHAR` 改 `INT`。
+>
+> **经验法则**:8.0 默认的 `INPLACE` 对大多数操作都够用。若不确定,先跑 `pt-online-schema-change --dry-run` 看预估耗时。
+
+```mermaid
+flowchart LR
+ A["ALTER TABLE 开始"] --> B{"ALGORITHM"}
+ B -->|"INPLACE"| C["直接修改数据字典
原地更新索引"]
+ B -->|"COPY"| D["创建临时表拷贝数据后替换"]
+
+ C --> E{"LOCK"}
+ D --> E
+
+ E -->|"NONE"| F["在线执行
读写不阻塞"]
+ E -->|"SHARED"| G["并发读
写入等待"]
+ E -->|"EXCLUSIVE"| H["阻塞全部操作
速度最快"]
+
+ style F fill:#00D866,color:#fff
+ style G fill:#FF9F43,color:#000
+ style H fill:#EE5A24,color:#fff
+```
+
+### 大表 DDL 实战技巧
+
+对百万行以上的表执行 `ALTER TABLE` 时:
+
+```bash
+# ❌ 危险:直接在线上执行
+ALTER TABLE big_table ADD INDEX idx_col (col);
+
+# ✅ 方案一:pt-online-schema-change(Percona Toolkit)
+pt-online-schema-change \
+ --user=root --password=xxx \
+ --alter "ADD INDEX idx_col (col)" \
+ D=mydb,t=big_table \
+ --execute
+
+# ✅ 方案二:gh-ost(GitHub 开源)
+gh-ost \
+ --user=root --password=xxx \
+ --host=127.0.0.1 --port=3306 \
+ --database=mydb \
+ --table=big_table \
+ --alter "ADD INDEX idx_col (col)" \
+ --allow-on-master \
+ --execute
+```
+
+> [!TIP] pt-osc 原理三步走
+> 1. **创建新表**:与原表结构一致 + 目标变更
+> 2. **触发器同步**:创建 INSERT/UPDATE/DELETE 触发器,将原表的变更实时同步到新表
+> 3. **原子替换**:加排他锁极短时间,swap 两张表名后删除触发器和旧表
+
+> [!QUESTION] gh-ost 和 pt-osc 怎么选?
+> - **pt-osc** 依赖触发器,对高并发写密集型表有较大开销
+> - **gh-ost** 基于 binlog 解析,无需触发器,对线上影响更小
+> - **结论**:新项目优先 gh-ost;老项目已有 pt-toolkit 积累也可以用 pt-osc
+
+### NULL vs NOT NULL — 选型指南
+
+这是 DDL 中最常见的争论之一。核心原则:**能用 NOT NULL 就不用 NULL**。
+
+```mermaid
+graph TD
+ A["是否允许未知状态" -->|"否"| B["NOT NULL + DEFAULT"]
+ A -->|"是"| C{"业务语义?"}
+ C -->|"逻辑删除/软删"| D["TINYINT DEFAULT 0
0=正常 1=已删除"]
+ C -->|" truly optional "| E["允许 NULL
但加注释说明含义"]
+```
+
+| 维度 | NOT NULL + DEFAULT | 允许 NULL |
+|------|-------------------|----------|
+| **索引效率** | InnoDB 二级索引不存储纯 NULL 值,用默认值占位可减少索引碎片 | 包含 NULL 标记的索引需要额外 1 bit |
+| **查询安全** | `WHERE col = ?` 不会遗漏任何行 | `WHERE col = 'value'` **排除了 NULL 行**(需用 `IS NULL`) |
+| **聚合函数** | SUM/COUNT 直接可用 | COUNT(col) 忽略 NULL 行,容易误判总行数 |
+| **可读性** | `status = 0` 一目了然 | `status IS NULL` 语义模糊——到底是"还没填"还是"被清除了"? |
+
+> [!WARNING] NULL 的三个常见误区
+> 1. **"NULL 占空间更小"**:InnoDB 中 NULL 也需要在行格式中标记,固定为每 8 个字节 1 bit 的 null bitmap。比存一个空字符串或零值并不节省什么。
+> 2. **"NULL = NULL"**:在 SQL 三值逻辑中,`NULL = NULL` 结果为 UNKNOWN,永远不等于 TRUE。判断必须用 `IS NULL` / `IS NOT NULL`。
+> 3. **"外键可以为 NULL"**:技术上可以,但会导致孤儿记录难以追踪。建议用显式的 `deleted_at` 时间戳代替外键 NULL 做软删除。
+
+## 常用 DDL 查询
+
+这些 SQL 命令帮助你查看当前数据库的「结构全貌」,在排查索引失效、表空间膨胀时尤其有用。
+
+```sql
+-- 查看表完整创建语句(含所有索引、注释、引擎配置)
+SHOW CREATE TABLE users\G
+
+-- 查看所有索引(含索引类型、列顺序、唯一性)
+SHOW INDEX FROM users\G
+
+-- 查看表统计信息(Rows 为估算值,非精确计数)
+SHOW TABLE STATUS LIKE 'users'\G
+-- 重点关注 Rows(估算行数)、Data_length、Index_length
+
+-- 查看分区情况
+SELECT PARTITION_NAME, PARTITION_EXPRESSION, PARTITION_DESCRIPTION
+FROM INFORMATION_SCHEMA.PARTITIONS WHERE TABLE_NAME = 'logs';
+```
+
+> [!TIP] 快速定位慢查询相关的结构问题
+> ```sql
+> -- 检查表是否存在大量碎片的索引(数据删除后未回收的空间)
+> SELECT TABLE_NAME, INDEX_NAME, CARDINALITY
+> FROM INFORMATION_SCHEMA.STATISTICS
+> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'
+> ORDER BY CARDINALITY ASC;
+>
+> -- 低基数(CARDINALITY 接近 0)的索引通常效果不佳,考虑移除
+> ```
+
+## 关联笔记
+
+- [[hhs/GORM/02-模型定义]] — GORM AutoMigrate 等价于 DDL 的自动化版本
+- [[hhs/GORM/17-迁移工具]] — Schema 迁移管理最佳实践
diff --git a/hhs/MySQL/11-DML 增删改.md b/hhs/MySQL/11-DML 增删改.md
new file mode 100644
index 0000000..63584ba
--- /dev/null
+++ b/hhs/MySQL/11-DML 增删改.md
@@ -0,0 +1,251 @@
+---
+tags: [MySQL, DML, INSERT, UPDATE, DELETE]
+create time: 2026-05-16 00:00
+---
+
+# DML — 增删改
+
+## 概述
+
+DML(Data Manipulation Language)是日常使用最频繁的 SQL 类别——**增、改、删**构成了应用和数据库之间的数据交互主线。
+
+> [!QUESTION] 思考:为什么说「写」比「读」更复杂?
+> SELECT 只需要找到匹配的数据,而 INSERT / UPDATE / DELETE 不仅要改变状态,还要处理**并发冲突、约束校验、外键级联、触发器副作用**。理解这些隐藏行为,才能写出既正确又高效的写入逻辑。
+
+本节聚焦 MySQL 特有的高效写入技巧和常见陷阱。
+
+## INSERT
+
+### 基础插入
+
+```sql
+INSERT INTO users (username, email, status)
+VALUES ('alice', 'alice@example.com', 1);
+
+-- 批量插入(性能远高于逐条插入)
+INSERT INTO users (username, email, status) VALUES
+('bob', 'bob@example.com', 1),
+('charlie','charlie@example.com', 0),
+('david', 'david@example.com', 1);
+```
+
+> [!TIP] MySQL 8.0.19+:INSERT ... RETURNING
+> 和 PostgreSQL 一样,MySQL 从 8.0.19 起支持 `RETURNING` 子句,免去二次查询:
+> ```sql
+> INSERT INTO users (username, email, status)
+> VALUES ('eve', 'eve@example.com', 1)
+> RETURNING id; -- 直接拿到自增 ID
+> ```
+> **典型场景**:插入后需要立即获取新记录的自增主键做级联操作。替代了老方案中 `INSERT ... ; SELECT LAST_INSERT_ID();` 两条语句的组合。
+
+### INSERT ... ON DUPLICATE KEY UPDATE
+
+处理"存在则更新,不存在则插入"的场景:
+
+```sql
+INSERT INTO user_stats (user_id, total_orders, total_spent)
+VALUES (1001, 5, 299.50)
+ON DUPLICATE KEY UPDATE
+ total_orders = total_orders + VALUES(total_orders),
+ total_spent = total_spent + VALUES(total_spent);
+```
+
+> [!TIP] 常见陷阱
+> - **多唯一键冲突**:如果有多条唯一键匹配同一行,只更新一次(返回 Row Count = 2)
+> - **LAST_INSERT_ID()**:INSERT 时返回新 ID;ON DUPLICATE KEY UPDATE 时自动改写为旧行的主键 ID(方便级联操作)
+> - **VALUES() 废弃警告**:MySQL 8.0.20+ 对 `VALUES(col)` 发出 DEPRECATION WARNING。对于单行插入无需改动,**多行批量插入**需改用子查询或分段处理。
+
+```mermaid
+flowchart TD
+ A["INSERT 语句"] --> B{"主键或唯一键\n是否存在"}
+ B -->|不存在| C["执行 INSERT"]
+ B -->|已存在| D["执行 UPDATE\nON DUPLICATE KEY UPDATE 子句"]
+ C --> E["返回 Row Count = 1"]
+ D --> F["返回 Row Count = 2\n匹配行加实际更新"]
+
+ style B fill:#FF9F43,color:#000
+ style C fill:#00D866,color:#fff
+ style D fill:#00B6BC,color:#fff
+```
+
+> [!NOTE] Row Count 的含义
+> - `Row Count = 1`:正常插入新记录
+> - `Row Count = 2`:唯一键冲突,走了 UPDATE 路径(但 UPDATE 没有实际改变值也算 2)
+> - `Row Count = 0`:唯一键冲突,且 UPDATE 后的值与原来相同
+
+### REPLACE INTO
+
+当唯一键冲突时,**先删除旧行再插入新行**。
+
+```sql
+REPLACE INTO users (id, username, email) VALUES (1, 'alice_new', 'new@email.com');
+```
+
+```mermaid
+sequenceDiagram
+ participant S as Server
+ participant DB as Database
+
+ S->>DB: REPLACE INTO users ...
+ Note over S,DB: Step 1: DELETE 已有记录
+ S->>DB: DELETE FROM users WHERE pk = 1
+ Note over S,DB: Step 2: INSERT 新记录
+ S->>DB: INSERT INTO users ...
+
+ Note over S,DB: 如果外键引用了被删行\n会触发 ON DELETE CASCADE
+```
+
+> [!WARNING] REPLACE vs ON DUPLICATE KEY UPDATE
+> - **REPLACE**:本质是 DELETE + INSERT,会导致自增 ID 变化、触发 BEFORE/AFTER DELETE 钩子、级联外键删除
+> - **ON DUPLICATE KEY UPDATE**:原地更新,不影响其他字段和自增值
+>
+> **优先用 `ON DUPLICATE KEY UPDATE`**,除非你确实需要完整的「删+插」语义。
+
+## UPDATE
+
+UPDATE 的核心原则只有一条:**精准定位、最小影响**。先思考「哪些行需要改」,再写 SET 子句。
+
+```sql
+-- 基础更新:定位到单行
+UPDATE users SET status = 0 WHERE id = 100;
+
+-- 多字段同时更新
+UPDATE users SET
+ email = 'new@email.com',
+ updated_at = NOW()
+WHERE email = 'old@email.com' AND status = 1;
+
+-- JOIN 批量更新(从关联表同步数据)
+UPDATE articles a
+JOIN categories c ON a.category_id = c.id
+SET a.category_name = c.name
+WHERE a.category_name IS NULL;
+```
+
+> [!QUESTION] 为什么 UPDATE 忘记带 WHERE 这么危险?
+> `UPDATE users SET status = 0;` 会把所有用户的状态设为禁用。MySQL 有一个安全措施叫 `sql_safe_updates`:
+> ```sql
+> SET sql_safe_updates = 1;
+> -- 此时不带 WHERE 或不带主键条件的 UPDATE 会被拒绝
+> -- 生产环境建议在连接层开启此选项
+> ```
+
+### LIMIT 与 ORDER BY
+
+MySQL 特有的扩展:**UPDATE 可以加 LIMIT 控制影响行数**。
+
+```sql
+-- 只更新符合条件的前 100 条(配合 ORDER BY 可控)
+UPDATE users SET status = 0
+WHERE status = 1 AND created_at < '2024-01-01'
+ORDER BY created_at ASC
+LIMIT 100;
+```
+
+## DELETE vs TRUNCATE
+
+DELETE 和 TRUNCATE 都能清空或减少表数据,但**底层机制完全不同**。选错可能导致性能灾难或数据意外丢失。
+
+| 特性 | DELETE | TRUNCATE |
+|------|--------|----------|
+| **类型** | DML | DDL |
+| **可回滚** | ✅ 事务内可 ROLLBACK | ❌ 隐式提交,不可回滚 |
+| **WHERE 过滤** | ✅ 可以 | ❌ 全删 |
+| **AUTO_INCREMENT** | 保留当前值 | 重置为 1 |
+| **触发器** | 触发 DELETE 触发器 | 不触发 |
+| **速度** | 逐行删除较慢 | 直接重建表极快 |
+| **返回值** | 影响的行数 | 无返回值 |
+| **锁粒度** | 行锁(可带 WHERE) | 表级元数据锁 |
+
+### DELETE 进阶用法
+
+```sql
+-- DELETE 带 JOIN(MySQL 特有语法)
+DELETE u FROM users u
+LEFT JOIN orders o ON u.id = o.user_id
+WHERE o.id IS NULL;
+-- 删除所有没有订单的用户
+
+-- 多表联合删除(同时删用户 + 订单)
+DELETE t1, t2 FROM users t1
+INNER JOIN orders t2 ON t1.id = t2.user_id
+WHERE t1.status = 0;
+```
+
+> [!NOTE] ORDER BY + LIMIT 在 DELETE 中同样可用
+> ```sql
+> -- 只删除符合条件的前 50 条(按创建时间最早的优先)
+> DELETE FROM users WHERE status = 0
+> ORDER BY created_at ASC
+> LIMIT 50;
+> ```
+
+---
+
+## 高效写入策略
+
+当需要处理大量数据时,**写入方式的选择直接影响性能和稳定性**。下面按数据量级给出分级方案:
+
+```mermaid
+flowchart TD
+ A["大量数据写入"] --> B{数据量}
+ B -->|小于 1 万条| C["单条 INSERT + 事务包裹"]
+ B -->|1 万至 100 万条| D["分批 INSERT\n每批 500 到 2000 条"]
+ B -->|大于 100 万条| E["LOAD DATA INFILE"]
+
+ C --> F["BEGIN; INSERT... COMMIT;"]
+ D --> G["每批独立事务\n降低锁竞争"]
+ E --> H["绕过 SQL 解析\n直接写数据文件"]
+
+ style E fill:#00D866,color:#fff
+ style D fill:#FF9F43,color:#000
+```
+
+```sql
+-- LOAD DATA INFILE 示例(最快的批量导入方式)
+LOAD DATA LOCAL INFILE '/tmp/users.csv'
+INTO TABLE users
+FIELDS TERMINATED BY ',' ENCLOSED BY '"'
+LINES TERMINATED BY '\n'
+(username, email, @status) -- @variable 用于预处理
+SET status = CASE @status WHEN 'active' THEN 1 ELSE 0 END;
+```
+
+### 分批 INSERT 代码示例
+
+对于中等规模数据,**事务 + 分批次** 是最实用的方案。以下 Go 伪代码展示了核心思路:
+
+```go
+const batchSize = 1000
+
+rows := generateRecords() // 假设返回 50000 条记录
+tx := db.Begin() // 外层可开启大事务
+
+for i := 0; i < len(rows); i += batchSize {
+ end := min(i+batchSize, len(rows))
+ batch := rows[i:end]
+
+ // 构建动态 INSERT
+ cols := "(username, email, status)"
+ placeholders := strings.Repeat("(?, ?, ?),", len(batch))
+ sql := fmt.Sprintf("INSERT INTO users %s VALUES %s", cols, placeholders[:len(placeholders)-1])
+
+ var args []any
+ for _, r := range batch {
+ args = append(args, r.Username, r.Email, r.Status)
+ }
+
+ tx.Exec(sql, args...) // 单批提交
+}
+tx.Commit()
+```
+
+> [!CAUTION] 批量插入注意事项
+> - **包大小限制**:MySQL 默认 `max_allowed_packet` 为 64MB,超大批次会报 `Packet too large` 错误
+> - **长事务锁表**:单个大事务持有锁的时间越长,死锁概率越高 → **每批独立 commit** 更安全
+> - **自增 ID 碎片**:大批量 INSERT 会导致自增 ID 跳跃,不影响功能,但会影响 `AUTO_INCREMENT` 当前值的准确性
+
+## 关联笔记
+
+- [[hhs/GORM/03-CRUD 操作]] — GORM 的 Create / First / Find 等方法的 SQL 生成机制
+- [[hhs/GORM/11-批量操作]] — GORM 中 CreateInBatches 等批量优化手段
diff --git a/hhs/MySQL/12-DQL SELECT 全解析.md b/hhs/MySQL/12-DQL SELECT 全解析.md
new file mode 100644
index 0000000..cf49d1a
--- /dev/null
+++ b/hhs/MySQL/12-DQL SELECT 全解析.md
@@ -0,0 +1,312 @@
+---
+tags: [MySQL, SELECT, 查询执行顺序, GROUP BY, 分页, 窗口函数, CTE]
+create time: 2026-05-16 00:00
+---
+
+# DQL — SELECT 全解析
+
+## 概述
+
+SELECT 是 MySQL 中使用频率最高的语句,也是最容易被误解的语句。理解其内部执行顺序是写出高性能查询的第一步。
+
+## SQL 书写顺序 vs 执行顺序
+
+```mermaid
+flowchart LR
+ W["⑥ SELECT"] --> A["① FROM"]
+ A --> B["② JOIN"]
+ B --> C["③ ON"]
+ C --> D["④ WHERE"]
+ D --> E["⑤ GROUP BY"]
+ E --> F["⑦ HAVING"]
+ F --> G["⑧ DISTINCT"]
+ G --> H["⑨ ORDER BY"]
+ H --> I["⑩ LIMIT / OFFSET"]
+
+ style W fill:#C44569,color:#fff
+ style A fill:#00B6BC,color:#fff
+ style I fill:#4FC08D,color:#fff
+```
+
+> [!QUESTION] 为什么理解这个很重要?
+> 因为 SQL 不是按照你写的顺序执行的——而是按数字顺序!这意味着:
+> - `WHERE` 在 `SELECT` 之前执行 → 不能在 WHERE 中使用 SELECT 定义的别名
+> - `GROUP BY` 在 `HAVING` 之前 → WHERE 过滤行,HAVING 过滤分组
+> - `ORDER BY` 在 `LIMIT` 之前 → 先排序再截断
+
+### 一个完整的例子
+
+```sql
+SELECT
+ DATE(created_at) AS order_date, -- ⑥ 计算列
+ COUNT(*) AS cnt, -- 聚合函数
+ SUM(amount) AS total -- 聚合函数
+FROM orders -- ① 确定数据源
+WHERE status = 'paid' -- ④ 先过滤行
+ AND created_at >= '2026-01-01' -- 过滤条件
+GROUP BY DATE(created_at) -- ⑤ 按日期分组
+HAVING COUNT(*) > 5 -- ⑦ 过滤分组
+ORDER BY total DESC -- ⑨ 排序
+LIMIT 10; -- ⑩ 取前 10 页
+```
+
+## SELECT 关键字详解
+
+### DISTINCT
+
+```sql
+-- 去重查询
+SELECT DISTINCT department FROM employees;
+
+-- DISTINCT 作用于所有选中的列组合
+SELECT DISTINCT country, city FROM customers;
+-- 返回的是 (country, city) 的唯一组合
+```
+
+> [!QUESTION] DISTINCT 一定快吗?
+> 不一定。`DISTINCT` 本质上是 `GROUP BY` 的简化版——MySQL 内部可能通过 temporary table + filesort 去重。当数据量大时,它的代价不亚于一次普通的分组聚合。
+>
+> **替代方案**:如果去重目的是为下拉框提供选项,可以用 `SELECT DISTINCT department FROM employees LIMIT 50;` 限制返回量;更优的做法是在应用层缓存选项列表。
+
+### GROUP BY 优化
+
+```sql
+-- ✅ 好:GROUP BY 走索引
+-- idx_status_dept = (status, department),可以直接按 department 分组
+SELECT department, COUNT(*)
+FROM employees
+WHERE status = 'active'
+GROUP BY department;
+
+-- ❌ 差:GROUP BY 无法利用索引(LIKE 左模糊导致索引失效)
+SELECT department, COUNT(*)
+FROM employees
+WHERE name LIKE '%chen%' -- 左模糊导致索引失效
+GROUP BY department;
+-- EXPLAIN: Using where; Using temporary
+```
+
+### HAVING 与 WHERE 的选择
+
+```sql
+-- ✅ WHERE 过滤行(早过滤,减少数据量)
+SELECT department, AVG(salary)
+FROM employees
+WHERE hire_date >= '2024-01-01' -- 先筛选最近入职的人
+GROUP BY department
+HAVING AVG(salary) > 15000; -- 再过滤平均工资
+
+-- ❌ 把能放 WHERE 的条件放到 HAVING 里
+-- 虽然结果一样,但效率更低
+SELECT department, AVG(salary)
+FROM employees
+GROUP BY department
+HAVING hire_date >= '2024-01-01' -- 错!HAVING 不能用非聚合列
+ AND AVG(salary) > 15000;
+```
+
+## 窗口函数 (WINDOW FUNCTIONS)
+
+窗口函数是 MySQL 8.0+ 引入的分析利器——它能在**不减少行数**的前提下进行聚合计算。
+
+### 排名函数
+
+```sql
+-- ROW_NUMBER() / RANK() / DENSE_RANK()
+-- 按部门内工资排名(处理并列名次的三种方式)
+SELECT name, department, salary,
+ ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn,
+ RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS rnk,
+ DENSE_RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS drnk
+FROM employees;
+
+-- 结果示例:
+-- | name | dept | salary | rn | rnk | drnk |
+-- | Alice | sales | 20000 | 1 | 1 | 1 |
+-- | Bob | sales | 20000 | 2 | 1 | 1 |
+-- | Carol | sales | 18000 | 3 | 3 | 2 |
+```
+
+> [!TIP] RANK vs DENSE_RANK 的区别
+> - `RANK(20000, 20000, 18000)` → **1, 1, 3**(跳过第二名)
+> - `DENSE_RANK(20000, 20000, 18000)` → **1, 1, 2**(不跳号)
+> - `ROW_NUMBER` → **1, 2, 3**(永远无并列)
+
+### 前后行访问 — LAG / LEAD
+
+```sql
+SELECT order_date, amount,
+ LAG(amount, 1) OVER(ORDER BY order_date) AS prev_amount, -- 前一天的金额
+ LEAD(amount, 1) OVER(ORDER BY order_date) AS next_amount -- 后一天的金额
+FROM daily_sales;
+
+-- 计算日环比增长率
+SELECT order_date, amount,
+ ROUND((amount - LAG(amount) OVER(ORDER BY order_date)) / LAG(amount) OVER(ORDER BY order_date) * 100, 2) AS growth_pct
+FROM daily_sales;
+```
+
+### 累计计算
+
+```sql
+-- 累计求和 (Running Total)
+SELECT order_date, amount,
+ SUM(amount) OVER(ORDER BY order_date) AS running_total
+FROM daily_sales;
+
+-- 当前分区内占比
+SELECT department, name, salary,
+ ROUND(salary * 100.0 / SUM(salary) OVER(PARTITION BY department), 2) AS dept_pct
+FROM employees;
+```
+
+> [!NOTE] 窗口函数执行时机
+> - 执行顺序在 WHERE、GROUP BY、HAVING **之后**,ORDER BY **之前**
+> - 因此不能用 WHERE 直接过滤窗口函数的结果——需要套一层子查询:
+> ```sql
+> SELECT * FROM (
+> SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) AS rn
+> FROM orders
+> ) ranked WHERE rn = 1;
+> -- 作用:每个用户的最新一条订单记录
+> ```
+
+## 子查询与 CTE
+
+### 标量子查询 (Scalar Subquery)
+
+返回单一值,可以像普通列一样使用:
+
+```sql
+-- WHERE 中的标量子查询
+SELECT name, salary
+FROM employees
+WHERE salary > (SELECT AVG(salary) FROM employees); -- 工资高于公司平均的人
+```
+
+### 行子查询 (Row Subquery / IN)
+
+```sql
+-- IN 子查询
+SELECT name, department
+FROM employees
+WHERE department IN (SELECT id FROM departments WHERE region = 'APAC');
+
+-- EXISTS:关注"是否存在"而非具体数据,比 IN 更高效
+SELECT d.name
+FROM departments d
+WHERE EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.id);
+```
+
+### CTE (Common Table Expression) — WITH 语法
+
+MySQL 8.0+ 推荐用法,比嵌套子查询可读性强很多:
+
+```sql
+-- 简单 CTE
+WITH dept_stats AS (
+ SELECT department, COUNT(*) AS emp_count, AVG(salary) AS avg_salary
+ FROM employees
+ GROUP BY department
+)
+SELECT * FROM dept_stats WHERE avg_salary > 15000;
+
+-- 递归 CTE:处理层级数据(组织架构、分类树等)
+WITH RECURSIVE org_chart AS (
+ -- 锚点成员:根节点
+ SELECT id, name, manager_id, 1 AS level
+ FROM employees
+ WHERE manager_id IS NULL
+
+ UNION ALL
+
+ -- 递归成员:逐层展开
+ SELECT e.id, e.name, e.manager_id, oc.level + 1
+ FROM employees e
+ INNER JOIN org_chart oc ON e.manager_id = oc.id
+)
+SELECT * FROM org_chart ORDER BY level, name;
+```
+
+> [!QUESTION] CTE vs 派生表?
+> - **可读性**:CTE 命名清晰,逻辑分层;派生表层层嵌套,括号匹配困难
+> - **性能**:MySQL 会将非递归 CTE 优化为临时表或内联展开——多数情况下两者性能一致
+> - **复用**:同一个 CTE 可在一个语句中多次引用(派生表不行)
+
+---
+
+## 深分页问题
+
+这是 MySQL 最著名的性能陷阱之一。
+
+```sql
+-- ❌ 灾难级写法:扫描 100 万行后丢弃前 999,990 行
+SELECT * FROM orders LIMIT 999990, 10;
+-- MySQL 需要先定位到第 999990 行,才返回接下来的 10 行
+
+-- ✅ 方案一:延迟关联(Deferred Join)
+SELECT o.* FROM orders o
+INNER JOIN (
+ SELECT id FROM orders ORDER BY id LIMIT 999990, 10
+) AS tmp ON o.id = tmp.id;
+-- 子查询只扫主键索引(极紧凑),外层再 JOIN 拿完整数据
+```
+
+```mermaid
+flowchart LR
+ subgraph "传统 LIMIT 999990,10"
+ A1["扫描聚簇索引
跳过 999990 行"] --> A2["取出 10 行数据"]
+ end
+
+ subgraph "延迟关联方案"
+ B1["扫描聚簇索引
跳过 999990 行"] --> B2["仅提取 10 个主键"]
+ B2 --> B3["JOIN 回聚簇索引
精确查找 10 个主键"]
+ B3 --> B4["返回结果"]
+ end
+
+ A1 --> A2
+ style B1 fill:#00B6BC,color:#fff
+ style B2 fill:#00D866,color:#fff
+```
+
+### 游标分页(推荐)
+
+```sql
+-- 上一页最后一条记录的 id = 999985
+SELECT * FROM orders
+WHERE id > 999985
+ORDER BY id ASC
+LIMIT 10;
+```
+
+> [!TIP] 为什么游标分页更优?
+> - `WHERE id > ?` 走索引范围扫描,复杂度 O(log N) 而非 O(N)
+> - 无论在第几页,查询时间恒定
+> - 需要前端传「上一页最后一个 ID」作为下一页的游标
+>
+> **局限性**:不支持跳页(不能直接跳到第 100 页),但这对瀑布流场景足够。
+
+## 性能小贴士
+
+```sql
+-- ❌ 避免 SELECT *
+SELECT * FROM users WHERE status = 1;
+
+-- ✅ 只查需要的列
+SELECT id, username, email FROM users WHERE status = 1;
+-- 好处:减少网络传输、提高 Buffer Pool 命中率、可能触发 Covering Index
+
+-- ❌ 函数包裹索引列
+SELECT * FROM users WHERE YEAR(created_at) = 2026;
+
+-- ✅ 用范围替代函数
+SELECT * FROM users
+WHERE created_at >= '2026-01-01'
+ AND created_at < '2027-01-01';
+```
+
+## 关联笔记
+
+- [[hhs/MySQL/12-JOIN 原理与优化]] — 深入理解 JOIN 的内部执行机制
+- [[hhs/MySQL/13-子查询与派生表]] — 与 SELECT 密切相关的子查询技术
+- [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与原生 SQL 的差异
diff --git a/hhs/MySQL/13-JOIN 原理与优化.md b/hhs/MySQL/13-JOIN 原理与优化.md
new file mode 100644
index 0000000..ed74c52
--- /dev/null
+++ b/hhs/MySQL/13-JOIN 原理与优化.md
@@ -0,0 +1,295 @@
+---
+tags: [MySQL, JOIN, Nested Loop, 索引优化]
+create time: 2026-05-16 00:00
+---
+
+# JOIN 原理与优化
+
+## 概述
+
+JOIN 是关系型数据库的核心能力,也是性能问题的主要来源。理解 MySQL 的 JOIN 执行算法才能写出高效的关联查询。
+
+## JOIN 类型速览
+
+```sql
+-- Inner JOIN:只返回两边都匹配的行(最常用)
+SELECT * FROM orders o INNER JOIN users u ON o.user_id = u.id;
+
+-- LEFT OUTER JOIN:左表全保留,右表不匹配则为 NULL
+SELECT o.id, u.username
+FROM orders o LEFT JOIN users u ON o.user_id = u.id
+WHERE u.id IS NULL; -- 找孤儿订单(用户已被删除)
+
+-- RIGHT OUTER JOIN:等价于交换左右表的 LEFT JOIN
+-- 实践中几乎不用 RIGHT JOIN,改成 LEFT JOIN 更易读
+
+-- CROSS JOIN:笛卡尔积(慎用!)
+SELECT a.name, b.name FROM table_a CROSS JOIN table_b;
+-- 结果 = |a| × |b| 行
+```
+
+## JOIN 执行算法
+
+MySQL InnoDB 在执行 JOIN 时,**本质上只有两种核心策略**:被驱动表有索引时用 **INLJ**(Index Nested-Loop Join),没有索引时退化到 **BNLJ**(Block Nested-Loop Join)。而 INLJ 内部又根据索引是否为唯一键进一步细分。
+
+| 算法缩写 | 全称 | 触发条件 | 关键特征 |
+|---------|------|---------|---------|
+| **INLJ** | Index Nested-Loop Join | 被驱动表 JOIN 列有索引 | 每行做一次索引查找,O(log m) |
+| **BNLJ** | Block Nested-Loop Join | 被驱动表无可用索引 | 多行拼成块写入 Join Buffer,逐表扫描 |
+
+> [!NOTE] 为什么没有"Simple NLJ"?
+> Simple NLJ 是学术上的概念——外层每行内层逐行比较。MySQL **从未使用**这种实现:有索引就走 INLJ(直接 SEEK),没索引就直接上 BNLJ(分块缓冲)。下文不再讨论 Simple NLJ。
+
+### 决策对照表
+
+当你写了一个 JOIN 后,Optimizer 的选择逻辑如下:
+
+```mermaid
+flowchart TD
+ J["Optimizer 开始评估"] --> D{"被驱动表 JOIN 列
是否有索引?"}
+
+ D -->|有| INLJ["Index Nested-Loop Join"]
+ D -->|无| BNLJ["Block Nested-Loop Join"]
+
+ INLJ --> I{"该索引是否为
唯一索引 / 主键?"}
+ I -->|是| UNIJ["Unique NLJ
一次查找即返回"]
+ I -->|否| INDEXJ["Non-Unique NLJ
一次查找可能多行"]
+
+ BNLJ --> BUFF["读取 N 行 → Join Buffer
被驱动表全扫 1 次"]
+
+ style UNIJ fill:#00D866,color:#fff
+ style INDEXJ fill:#FF9F43,color:#000
+ style BUFF fill:#EE5A24,color:#fff
+```
+
+> [!TIP] 一眼判断好坏
+> - 走 **Unique NLJ** (eq_ref) → ✅ 最优
+> - 走 **INLJ** (ref) → ✅ 良好
+> - 走 **BNLJ** (ALL) → ⚠️ 需要加索引
+
+### 1. Block Nested-Loop Join(BNL,块嵌套循环)
+
+当被驱动表**没有可用索引**时启用。MySQL 会将多行驱动表缓存到 Join Buffer 中,一次性与被驱动表比较。
+
+```sql
+-- 假设 user.tag 和 order.tag 上都没有索引
+-- Optimizer 会选择 BNLJ
+SELECT * FROM users u JOIN orders o ON u.tag = o.tag;
+```
+
+```
+Step 1: 从 users 表读 N 行 → Join Buffer (默认 256KB)
+Step 2: 扫描 orders 表,对每一行与 Buffer 中的所有行比较
+Step 3: Buffer 满了就输出匹配结果,清空再加载
+Step 4: 重复直到读完 users 表
+
+总成本 = ceil(rows_users / buffer_rows) × rows_orders
+```
+
+> [!NOTE] Join Buffer 大小
+> ```sql
+> SHOW VARIABLES LIKE 'join_buffer_size';
+> -- 默认 256KB,可调至最大 4MB
+> -- 每个连接独立分配,退出时释放
+> -- 注意:它不参与排序也不去重,纯粹做行数据缓存
+> ```
+
+**BNLJ 的致命弱点**:被驱动表无论多大都必须全扫一次。如果两张都是百万级大表,代价呈爆炸式增长。
+
+### 2. Index Nested-Loop Join(INLJ,索引嵌套循环)
+
+最理想的 JOIN 方式——被驱动表可以通过索引快速定位。
+
+```sql
+-- orders.user_id 上有索引 → Optimizer 选 INLJ
+SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
+
+-- 提示优化器使用特定 JOIN 顺序(非强制)
+SELECT * FROM users u USE INDEX FOR JOIN (idx_user_id)
+JOIN orders o ON u.id = o.user_id;
+```
+
+```mermaid
+sequenceDiagram
+ participant Driver as 驱动表(users)
+ participant IDX as 二级索引(idx_user_id)
+ participant Target as 被驱动表(orders)
+
+ loop 每行驱动数据
+ Driver->>IDX: Seek key=user_id
+ IDX-->>Driver: 找到匹配的 ROWID
+ Driver->>Target: Read by ROWID (回表取完整行)
+ Target-->>Driver: 返回完整行
+ end
+```
+
+> [!NOTE] INLJ 的成本拆解
+> - 单次查找代价 = log₂(索引页数),通常 ≈ 3~4 次磁盘随机读
+> - 若驱动表 1000 行、被驱动表 100 万行:**1000 × log₂(1M) ≈ 1000 × 20 = 20000 次索引查找**
+> - 对比 BNLJ:若走 BNLJ 则需 **1000 / buffer_rows × 1M** 次行比较 —— 差距巨大
+
+**INLJ 的两个子分类**:
+
+| 子类 | 索引类型 | 每次查找返回 | Extra 标识 |
+|------|---------|------------|-----------|
+| Unique NLJ | 主键 / 唯一索引 | 恰好 0 或 1 行 | `Using index condition` |
+| Non-Unique NLJ | 普通二级索引 | 可能 0…N 行 | `Using index condition` |
+
+## 驱动表选择
+
+MySQL 在解析 SQL 时,**默认从左到右**确定驱动表——左边第一张表就是驱动表。但 Optimizer 会在评估成本后决定是否交换表的顺序(以最小化被驱动表的扫描行数)。
+
+```sql
+-- 经验法则:用小表驱动大表
+-- Optimizer 通常会根据统计信息自动决定最优顺序
+SELECT * FROM small_table t1 JOIN large_table t2 ON t1.id = t2.small_id;
+```
+
+> [!TIP] 黄金法则
+> **驱动表可以是全表扫描,但被驱动表必须走索引。**
+> 任何 JOIN 优化都要回到这个原则:检查 EXPLAIN 中被驱动表的 type 是否为 ref / eq_ref / range,如果是 ALL 就说明优化失败了。
+
+### STRAIGHT_JOIN:强制驱动表顺序
+
+当 Optimizer 因统计信息过期或数据分布不均而选错顺序时,可以用 `STRAIGHT_JOIN` 强制指定:
+
+```sql
+-- 告诉 MySQL:不要用你的优化器,按我写的顺序执行
+SELECT * FROM large_table t1 STRAIGHT_JOIN small_table t2
+ON t1.id = t2.small_id;
+```
+
+**适用场景**:
+1. EXPLAIN 显示被驱动表走了全表扫描(type = ALL)
+2. 大表作为驱动表且小表能走索引(`small_id` 有索引)时,比反过来的成本低得多
+3. 临时排查问题——固定顺序便于复现和优化
+
+> [!WARNING] 谨慎使用 STRAIGHT_JOIN
+> 它绕过了 Optimizer 的成本模型,仅在确认优化器做出错误选择时才用。数据分布变化后可能反而变慢。优先选择修复统计信息(`ANALYZE TABLE`)或调整索引。
+
+### 驱动表 vs 被驱动表的判断标准
+
+| 角色 | 访问方式 | 理想 type | 可接受 type |
+|------|---------|----------|-----------|
+| **驱动表** | 全表扫描 / 索引扫描 | `ALL`, `index` | — |
+| **被驱动表** | 每行索引查找 | `eq_ref`, `ref` | `range`, `fulltext` |
+| **两者都差** | ⚠️ 性能灾难 | — | `ALL` × 2 |
+
+## JOIN 优化 Checklist
+
+### 流程概览
+
+```mermaid
+flowchart TD
+ A["写好 JOIN 查询"] --> B{"EXPLAIN 分析"}
+ B --> C{"被驱动表 type"}
+
+ C -->|"eq_ref / ref"<| OK["✅ 走索引,优秀"]
+ C -->|"range"<| WARN["⚠️ 范围扫描,可接受"]
+ C -->|"ALL / index"<| BAD["❌ 全表/全索引扫描"]
+
+ BAD --> D{"Checklist 逐项排查"}
+ D --> E["被驱动表的 JOIN 条件列有索引吗?"]
+ D --> F["JOIN 条件的数据类型一致吗?
VARCHAR vs INT 会导致索引失效"]
+ D --> G["有没有函数包裹 JOIN 列?"]
+ D --> H["能不能把 JOIN 拆成多次单表查询?"]
+
+ style OK fill:#00D866,color:#fff
+ style BAD fill:#EE5A24,color:#fff
+```
+
+### 实战:EXPLAIN 输出解读
+
+看一个具体的例子:
+
+```sql
+EXPLAIN SELECT * FROM users u
+JOIN orders o ON u.id = o.user_id
+JOIN products p ON o.product_id = p.id;
+```
+
+期望的 EXPLAIN 输出:
+
+```
++----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
+| id | select_type | table | type | key | extra | rows | filtered | ref | |
++----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
+| 1 | SIMPLE | u | ALL | NULL | | 1000 | 100.00 | NULL | |
+| 1 | SIMPLE | o | ref | idx_user_id | | 50 | 100.00 | u.id | |
+| 1 | SIMPLE | p | eq_ref | PRIMARY | | 1 | 100.00 | o.product_id | |
++----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
+```
+
+逐字段说明:
+
+| 字段 | 含义 | 本例中的解读 |
+|------|------|------------|
+| **table** | 当前行涉及的表 | 三表 JOIN 有三行输出 |
+| **type** | 访问类型(关键指标) | `ALL` → 驱动表全扫(正常);`ref` → 被驱动表走普通索引;`eq_ref` → 被驱动表走主键/唯一索引 |
+| **key** | 实际使用的索引 | `idx_user_id` 和 `PRIMARY` 均命中 |
+| **rows** | 预估扫描行数 | 1000 × 50 × 1 = 50000 次索引查找,可接受 |
+| **filtered** | WHERE 过滤后的比例 | 100% 表示 WHERE 还没起作用(无额外过滤条件) |
+| **Extra** | 额外信息 | 无 `Using filesort` / `Using temporary`,说明执行计划健康 |
+
+> [!NOTE] Extra 中需要警惕的关键字
+> - `Using filesort` → 需要额外排序,考虑加联合索引
+> - `Using temporary` → 用了临时表,常出现在 DISTINCT / GROUP BY / UNION 中
+> - `Using index condition` → 下推索引条件,部分过滤在存储引擎层完成,是好事
+
+### Multi-Join 处理策略(3+ 表)
+
+生产中最常见的是 3 表以上 JOIN。优化思路升级:
+
+1. **确保第 2 张及之后的每张表都有索引支撑 JOIN 条件**——只有第 1 张表可以全表扫描
+2. **用小表驱动大表**:EXPLAIN 输出从上到下依次是被驱动表,上面的驱动下面的
+3. **利用覆盖索引减少回表**:如果 SELECT 的列都在索引里,InnoDB 可以直接从索引树返回结果
+4. **拆分解耦**:超复杂的多表 JOIN 可以考虑拆成两步——先拿 ID 集合再批量查详情
+
+```sql
+-- 覆盖索引示例:索引已包含所有需要的列,无需回表
+CREATE INDEX idx_order_cover ON orders(user_id, product_id, amount);
+-- 此时下面这个查询可以直接走覆盖索引扫描
+SELECT user_id, product_id, amount FROM orders WHERE user_id = 42;
+```
+
+### USING vs ON
+
+```sql
+-- 推荐:用 USING 更简洁(要求两列同名)
+SELECT * FROM users u JOIN orders o USING (user_id);
+
+-- 等价的 ON 写法
+SELECT * FROM users u JOIN orders o ON u.user_id = o.user_id;
+
+-- 进阶技巧:USING 的结果集中 user_id 只出现一列,避免列名冲突
+```
+
+### 常见 JOIN 陷阱
+
+| 陷阱 | 示例 | 修复 |
+|------|------|------|
+| **类型不匹配** | `u.code VARCHAR` JOIN `o.code INT` | 统一类型 |
+| **函数包裹** | `ON YEAR(u.created) = YEAR(o.created)` | 改用范围比较 |
+| **NULL 值** | `ON t1.col = t2.col` 中一列为 NULL 不匹配 | 检查业务逻辑 |
+| **隐式转换** | `WHERE varchar_col = 123` | 显式字符串比较 |
+| **缺少复合索引** | 多条件 JOIN 只用单列索引 | 创建覆盖联合索引 |
+
+## 性能对比速查表
+
+| 算法 | 最优场景 | 最坏场景 | 典型代价公式 |
+|------|---------|---------|-------------|
+| **Unique NLJ** | 被驱动表是主键 / 唯一索引 | 驱动表大量行无匹配(返回 NULL) | 驱动表行数 × log(被驱动表) |
+| **Non-Unique NLJ** | 被驱动表有普通二级索引 | 索引选择性差,大量重复值 | 驱动表行数 × log(被驱动表) × 平均匹配数 |
+| **Block NLJ** | 被驱动表无索引 + 驱动表较小 | 大表 × 大表无索引 | ceil(驱动表行数 / Buffer行数) × 被驱动表行数 |
+
+> [!WARNING] 数量级示意
+> 假设驱动表 1000 行、被驱动表 100 万行:
+> - Unique NLJ:≈ 1000 × 20 = **2 万次**索引查找
+> - Block NLJ(Buffer 存 50 行):≈ ceil(1000/50) × 1,000,000 = **2000 万次**行比较
+> - 差距达 **三个数量级**,这就是为什么被驱动表索引如此重要。
+
+## 关联笔记
+
+- [[hhs/MySQL/14-子查询与派生表]] — EXISTS / IN 子查询与 JOIN 的替代关系
+- [[hhs/MySQL/16-B+Tree 索引原理]] — JOIN 如何利用二级索引加速
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 type 字段识别 JOIN 质量问题
diff --git a/hhs/MySQL/14-子查询与派生表.md b/hhs/MySQL/14-子查询与派生表.md
new file mode 100644
index 0000000..a08f045
--- /dev/null
+++ b/hhs/MySQL/14-子查询与派生表.md
@@ -0,0 +1,265 @@
+---
+tags: [MySQL, 子查询, EXISTS, IN, 派生表]
+create time: 2026-05-16 00:00
+---
+
+# 子查询与派生表
+
+## 概述
+
+子查询是嵌套在另一个查询中的 SELECT 语句。它可以在 WHERE、FROM、SELECT 等多个位置出现,每种位置的语义和执行方式不同。
+
+## 分类
+
+```mermaid
+graph BT
+ subgraph "标量子查询"
+ S1["返回一行一列
用在 SELECT / WHERE"]
+ end
+ subgraph "行子查询"
+ S2["返回一行多列
用在 ROW() 比较"]
+ end
+ subgraph "列子查询"
+ S3["返回多行一列
用在 IN / ANY / ALL"]
+ end
+ subgraph "表子查询(派生表)"
+ S4["返回多行多列
用在 FROM 子句"]
+ end
+ subgraph "EXISTS 子查询"
+ S5["返回布尔值
用于 EXISTS / NOT EXISTS"]
+ end
+```
+
+## 标量子查询
+
+```sql
+-- 用法 1:在 SELECT 中调用
+SELECT
+ username,
+ (SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count
+FROM users;
+
+-- 用法 2:在 WHERE 中等价于常量
+SELECT * FROM products
+WHERE price > (SELECT AVG(price) FROM products);
+
+-- 用法 3:在 INSERT 中赋值
+INSERT INTO reports (month, order_total)
+VALUES ('2026-05', (SELECT SUM(amount) FROM orders WHERE MONTH(created_at) = 5));
+```
+
+> [!WARNING] 标量子查询的性能隐患
+> MySQL 8.0.21 之前,标量子查询**无法被物化**,会导致每次执行都重新计算(N+1 问题)。
+> ```sql
+> -- ❌ 慢:每个用户都要查一次 orders 表
+> SELECT u.username,
+> (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
+> FROM users u;
+> -- 如果有 10 万用户,就要查 10 万次 orders 表
+>
+> -- ✅ 换成 JOIN 或窗口函数
+> SELECT u.username, COALESCE(SUB.cnt, 0) AS cnt
+> FROM users u
+> LEFT JOIN (
+> SELECT user_id, COUNT(*) AS cnt
+> FROM orders GROUP BY user_id
+> ) SUB ON u.id = SUB.user_id;
+> ```
+
+## IN vs EXISTS
+
+这是面试经典题,也是实际中最纠结的选择。
+
+```sql
+-- IN 子查询
+SELECT * FROM users
+WHERE id IN (SELECT user_id FROM orders);
+
+-- EXISTS 子查询
+SELECT * FROM users u
+WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
+```
+
+```mermaid
+flowchart TD
+ A["Optimizer 选择策略"] --> B{"哪张表更小?"}
+
+ B -->|"子查询结果小"<| C["转换 IN → EXISTS
先跑子查询,外层仅匹配结果"]
+ B -->|"子查询结果大"<| D["转换 EXISTS → IN
外层先行过滤,减少子查询次数"]
+
+ C --> E["NOT IN 永远转不成 EXISTS!
NULL 值会导致语义错误"]
+ D --> F["NOT EXISTS 是最安全的反查写法"]
+
+ style E fill:#EE5A24,color:#fff
+ style F fill:#00D866,color:#fff
+```
+
+> [!QUESTION] NOT IN 和 NOT EXISTS 有什么区别?
+> ```sql
+> -- ⚠️ 致命陷阱
+> SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
+>
+> -- 如果子查询返回任何 NULL 值,整个 NOT IN 结果为 UNKNOWN
+> -- 最终返回空结果集!
+>
+> -- ✅ 正确写法
+> SELECT * FROM users
+> WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);
+>
+> -- 或者直接用 NOT EXISTS(更安全)
+> SELECT * FROM users u
+> WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
+> ```
+
+### 选型决策:IN vs EXISTS vs JOIN
+
+实际开发中三者往往能写出等价逻辑,选法参考下表:
+
+| 场景 | 推荐写法 | 理由 |
+|------|----------|------|
+| 外大内小(外层百万、子查询几百) | `EXISTS` | 外层每行只需匹配一条即可停止,短路效应明显 |
+| 外小内大 | `IN` | Optimizer 会先物化子查询结果再匹配,效率高 |
+| 子查询含 `NULL` 值可能 | `EXISTS` / `NOT EXISTS` | `NOT IN` 遇到 NULL 直接返回空集(见上文陷阱) |
+| 需要返回外键表的完整列 | `JOIN` + `DISTINCT` | EXISTS 只能判断存在性,无法获取关联表字段 |
+| 只是判断"有没有" | `EXISTS` | 语义最清晰,性能也最优 |
+
+> [!TIP] 经验法则
+> **不确定时优先用 EXISTS**——它的语义是"是否存在"而非"值是否匹配",即使 MySQL Optimizer 最终把 IN 和 EXISTS 优化成执行计划也一样。用 EXISTS 能让读代码的人一眼看懂意图,同时给 Optimizer 留下优化空间。
+
+## 派生表(Derived Table / Subquery in FROM)
+
+```sql
+-- 派生表:子查询作为一个虚拟表出现在 FROM 位置
+SELECT dept, avg_salary
+FROM (
+ SELECT department AS dept, AVG(salary) AS avg_salary
+ FROM employees
+ WHERE status = 'active'
+ GROUP BY department
+) AS dept_stats
+WHERE avg_salary > 15000
+ORDER BY avg_salary DESC;
+```
+
+### 物化(Materialization)
+
+MySQL 是否将子查询物化为临时表,取决于查询复杂度:
+
+```mermaid
+flowchart LR
+ A["子查询"] --> B{"能否被推入外层?"}
+ B -->|能| C["Flattening
展开为 JOIN,零额外开销 ✅"]
+ B -->|不能| D{"是否需要聚合?"}
+ D -->|是| E["Materialization
物化为临时表 ⚠️"]
+ D -->|否| F["Semi-join 优化
半连接优化 ✅"]
+
+ style C fill:#00D866,color:#fff
+ style F fill:#00B6BC,color:#fff
+ style E fill:#FF9F43,color:#000
+```
+
+```sql
+-- ✅ 可以被 Flattening 优化(无额外开销)
+SELECT u.id, o.amount
+FROM users u
+JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
+-- Optimizer 将其转换为普通的 JOIN
+
+-- ❌ 必须 Materialization(有临时表开销)
+SELECT u.id, SUB.total
+FROM users u
+JOIN (
+ SELECT user_id, SUM(amount) AS total
+ FROM orders GROUP BY user_id
+) AS SUB ON u.id = SUB.user_id;
+-- 子查询因为有 GROUP BY,必须先物化成临时表
+```
+
+> [!NOTE] 如何判断是否物化?
+> 使用 `EXPLAIN FORMAT=JSON`:
+> ```json
+> {
+> "select_type": "DERIVED",
+> "materialized_from_subquery": { ... }
+> }
+> ```
+> 如果出现 `"using_temporary_table": true`,说明使用了临时表。
+
+实战中更常用的方式是 `EXPLAIN` + 观察 type 和 Extra:
+
+```sql
+-- ❌ 物化型派生表 → Extra 出现 "Using temporary"
+EXPLAIN SELECT u.id, SUB.total
+FROM users u
+JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) AS SUB ON u.id = SUB.user_id;
+
+-- id | select_type | table | type | Extra
+-- ---|----------------|------------|-------|----------------------------
+-- 1 | PRIMARY | | ALL | NULL
+-- 1 | PRIMARY | u | ALL | Using where
+-- 2 | DERIVED | orders | ALL | Using temporary; Using filesort
+
+-- ✅ 可 Flattening → 无临时表,Extra 干净
+EXPLAIN SELECT u.id, o.amount
+FROM users u JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
+
+-- id | select_type | table | type | Extra
+-- 1 | SIMPLE | orders| ALL | NULL
+-- 1 | SIMPLE | u | index | Primary key
+```
+
+> [!TIP] 性能优化技巧
+> - 当发现派生表产生临时表时,考虑**手动提升为 CTE**(MySQL 8.0+),有时能改变 Optimizer 行为
+> - `optimizer_switch='derived_merge=on'`(默认开启)控制是否允许 Flattening
+> - 对于超大结果集物化,注意 `tmp_table_size` / `max_heap_table_size` 限制
+
+## ANY / ALL / SOME
+
+```sql
+-- ANY:子查询返回的值中,任意一个满足即可
+SELECT product_name, price
+FROM products
+WHERE price > ANY (SELECT price FROM products WHERE category = 'premium');
+-- 只要比任意一件 premium 产品便宜就行
+
+-- ALL:必须大于子查询返回的所有值
+SELECT product_name, price
+FROM products
+WHERE price > ALL (SELECT price FROM products WHERE category = 'premium');
+-- 必须比所有 premium 产品都贵(即最大值之上)
+
+-- SOME 等价于 ANY(同义词)
+```
+
+```mermaid
+graph TB
+ P["Products: 10, 50, 100, 200"]
+
+ ANY -->|"price > ANY(...)"<| A1["> 10 OR > 50 OR > 100 OR > 200"]
+ A1 --> R1["结果: 所有 > 10 的商品"]
+
+ ALL -->|"price > ALL(...)"<| A2["> 10 AND > 50 AND > 100 AND > 200"]
+ A2 --> R2["结果: 只有 > 200 的商品"]
+
+ style R1 fill:#00B6BC,color:#fff
+ style R2 fill:#C44569,color:#fff
+```
+
+## 总结:核心要点回顾
+
+| 主题 | 一句话 |
+|------|--------|
+| 标量子查询 | MySQL 8.0.21 之前不可物化,百万行数据必踩 N+1 陷阱,优先改写为 JOIN |
+| IN vs EXISTS | 语义上 IN 看值、EXISTS 看存在性;不确定时选 EXISTS,更安全的默认选项 |
+| NOT IN 陷阱 | 子查询出现 NULL 即返回空集,生产中几乎永远该用 `NOT EXISTS` 替代 |
+| 派生表物化 | 有 GROUP BY / LIMIT / UNION 等操作的子查询无法被展开,必然产生临时表开销 |
+| ANY / ALL | 对应 SQL 的 OR / AND 累加,ALL 在空子查询结果时恒返回 TRUE(反直觉,需注意) |
+
+> [!TIP] 核心心法
+> **子查询不是万能的——它首先是为了表达清晰,其次才是性能。**
+> 写完后务必跑 `EXPLAIN`,确认没有意料之外的临时表或多表扫描。当数据量上去后,能改写成 JOIN 的子查询就尽量改写,因为 JOIN 的执行路径对 Optimizer 更加透明。
+
+## 关联笔记
+
+- [[hhs/MySQL/12-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系
+- [[hhs/MySQL/13-JOIN 原理与优化]] — EXISTS 子查询常可重写为 JOIN
diff --git a/hhs/MySQL/15-UNION 与集合运算.md b/hhs/MySQL/15-UNION 与集合运算.md
new file mode 100644
index 0000000..169f5f1
--- /dev/null
+++ b/hhs/MySQL/15-UNION 与集合运算.md
@@ -0,0 +1,238 @@
+---
+tags: [MySQL, UNION, UNION ALL, 集合运算]
+create time: 2026-05-16 00:00
+---
+
+# UNION / UNION ALL
+
+## 概述
+
+UNION 是 SQL 标准中的集合运算,用于将多个 SELECT 的结果纵向合并为一个结果集。核心应用场景包括:**分表数据汇总**、**多源 Feed 流合并**、以及**需要排序去重的跨查询聚合**。
+
+## 基本语法
+
+```sql
+-- 两种形式
+SELECT col1, col2 FROM table_a
+UNION -- 去重(内部排序 + 去重)
+SELECT col1, col2 FROM table_b;
+
+SELECT col1, col2 FROM table_a
+UNION ALL -- 不去重(直接拼接,性能更高)
+SELECT col1, col2 FROM table_b;
+```
+
+## UNION vs UNION ALL
+
+| 特性 | UNION | UNION ALL |
+|------|-------|-----------|
+| **去重** | ✅ 内部去重 | ❌ 保留所有行 |
+| **性能** | 低(需排序去重) | 高(直接追加) |
+| **ORDER BY 位置** | 只能放在最后一个 SELECT | 同上 |
+| **适用场景** | 需要唯一结果的合并 | 已知不重复的合并 |
+
+```mermaid
+flowchart LR
+ A1["数据源 A"] --> U
+ B1["数据源 B"] --> U
+
+ U{"UNION"} --> R1["全部数据去重后输出"]
+
+ U2{"UNION ALL"} --> R2["全部数据直接拼接"]
+
+ style R1 fill:#FF9F43,color:#000
+ style R2 fill:#00D866,color:#fff
+```
+
+> [!TIP] 首选 UNION ALL
+> 如果你能通过业务逻辑保证各部分结果不重复(比如按日期区间划分),**一律用 UNION ALL**。UNION 的去重操作需要 Sort/Dedup 阶段,在大结果集上是昂贵操作。
+
+## 实战场景
+
+### 场景一:多表同构合并
+
+```sql
+-- 日志系统按月分表:logs_202601, logs_202602, ..., logs_202605
+SELECT * FROM logs_202601
+UNION ALL
+SELECT * FROM logs_202602
+UNION ALL
+SELECT * FROM logs_202603
+UNION ALL
+SELECT * FROM logs_202604
+UNION ALL
+SELECT * FROM logs_202605
+WHERE status = 'error'
+ORDER BY created_at DESC
+LIMIT 50;
+```
+
+> [!NOTE] 分表合并的注意事项
+> - 上述写法中,`WHERE / ORDER BY / LIMIT` 仅作用于**最后一个 SELECT**。前面的分表查询不做过滤,全部返回后再合并排序。
+> - 如果每个分表数据量很大(百万级),建议**用子查询保护每个表的局部 ORDER BY + LIMIT**,先各取 Top-N 再全局排。这大幅减少中间结果集大小。
+> - UNION ALL 不保证顺序,最终 ORDER BY 必不可少。
+
+### 场景二:多表分页合并(Feed 流)
+
+```sql
+-- 需求: 用户的动态 Feed 由关注的人和发布的文章混合组成, 按时间排序分页
+-- ❌ 应用层先查再排: 至少两次 DB 往返 + 内存归并排序
+-- ✅ UNION ALL 一次搞定
+
+SELECT user_id AS source_id, content, 'follow' AS source_type, created_at
+FROM follow_feed WHERE user_id = 42
+UNION ALL
+SELECT article_id AS source_id, summary AS content, 'article' AS source_type, published_at
+FROM articles WHERE author_id = 42
+ORDER BY created_at DESC
+LIMIT 20 OFFSET 0;
+```
+
+> [!TIP] 何时用 UNION vs CASE WHEN?
+> - **数据来源是不同表或不同结构**: UNION ALL 是不二之选
+> - **同表不同条件的聚合计数**: `SUM(CASE WHEN ...)` 单次扫描更高效
+> - **经验法则**: UNION 的 SQL 可读性显著优于多个 OR 条件叠加时, 优先选 UNION
+
+### 场景三:分页合并多个条件
+
+```sql
+-- 搜索商品:标题含关键词 或 描述含关键词
+SELECT id, title, description, score_title
+FROM products
+WHERE title LIKE '%runners%'
+UNION ALL
+SELECT id, title, description, score_desc
+FROM products
+WHERE description LIKE '%runners%';
+```
+
+> [!WARNING] UNION 的字段匹配规则
+> - 各 SELECT 的**列数必须相同**
+> - 各 SELECT 对应列的**类型应尽量兼容**(MySQL 会自动转换)
+> - ORDER BY 和 LIMIT 通常放在**整个 UNION 的最后**
+> - 列名取第一个 SELECT 的别名
+> - **注意**: MySQL 不推荐在 UNION 的各个独立 SELECT 中使用 ORDER BY(结果可能被丢弃)
+
+```sql
+-- ✅ 正确
+SELECT id, name FROM products WHERE price > 100
+UNION ALL
+SELECT id, name FROM products WHERE category = 'sale';
+
+-- ❌ 错误:列数不一致
+SELECT id, name FROM products
+UNION ALL
+SELECT id FROM discounts;
+
+-- ❌ 错误:ORDER BY 放在中间(MySQL 可能忽略或报错)
+SELECT id FROM table_a
+ORDER BY id
+UNION ALL
+SELECT id FROM table_b;
+```
+
+### UNION 进阶:ORDER BY 与 LIMIT 的行为
+
+```sql
+-- ⚠️ 问题:每个 SELECT 的 ORDER BY 在合并后无效
+SELECT id FROM orders WHERE status = 'pending' ORDER BY created_at DESC
+UNION ALL
+SELECT id FROM orders WHERE status = 'shipped' ORDER BY created_at DESC;
+-- 上面的 ORDER BY 基本被 MySQL 忽略
+
+-- ✅ 正确解法:用子包装保护每个查询的排序
+SELECT * FROM
+(
+ SELECT id, created_at FROM orders WHERE status = 'pending'
+ ORDER BY created_at DESC LIMIT 50
+) AS a
+UNION ALL
+SELECT * FROM
+(
+ SELECT id, created_at FROM orders WHERE status = 'shipped'
+ ORDER BY created_at DESC LIMIT 50
+) AS b
+ORDER BY created_at DESC
+LIMIT 20;
+```
+
+> [!QUESTION] 为什么子查询能保护 ORDER BY?
+> MySQL 优化器发现 UNION 外还有 ORDER BY + LIMIT 时, 会认为内部排序有用,从而保留它。
+> 但官方文档并不保证这种行为——这是基于执行计划的经验结论, 生产环境务必确认。
+
+---
+
+## UNION 的执行流程
+
+```mermaid
+flowchart TD
+ A["查询 1"] --> R1["结果集 1"]
+ B["查询 2"] --> R2["结果集 2"]
+ C["查询 N"] --> RN["结果集 N"]
+
+ R1 --> Temp["临时表 + 唯一索引
UNION 有, UNION ALL 无"]
+ R2 --> Temp
+ RN --> Temp
+
+ Temp --> Dedup{"需要去重?"}
+ Dedup -->|是| Sort["排序 + 去重"]
+ Dedup -->|否| Direct["直接输出"]
+
+ Sort --> Output["最终结果集"]
+ Direct --> Output
+
+ style Dedup fill:#FF9F43,color:#000
+ style Sort fill:#EE5A24,color:#fff
+```
+
+## 与 JOIN 的选择
+
+```sql
+-- 场景:获取每个部门的员工总数 + 总监姓名
+-- JOIN 方案(交叉维度,适合取不同列)
+SELECT d.name, COUNT(e.id) AS emp_count, mgr.name AS manager
+FROM departments d
+LEFT JOIN employees e ON d.id = e.dept_id
+LEFT JOIN employees mgr ON d.manager_id = mgr.id
+GROUP BY d.id;
+
+-- UNION 方案(平行维度,适合合并同类数据)
+SELECT dept_id, 'total' AS metric, COUNT(*) AS value FROM employees GROUP BY dept_id
+UNION ALL
+SELECT dept_id, 'managers' AS metric, COUNT(*) AS value
+FROM employees WHERE is_manager = 1 GROUP BY dept_id;
+```
+
+> [!QUESTION] UNION 还是多个查询?
+> 很多场景中 UNION 看起来方便,但背后可能有更好的解法:
+> - **应用层合并**:在 Go/Java 中发两次查询然后合并数组(零 DB 压力)
+> - **STORED PROCEDURE**:存储过程中多次查询 + 临时表
+> - **视图**:封装 UNION 逻辑供多次复用
+>
+> 核心原则:**能不在数据库做的就不做**。UNION 的代价是排序、去重、临时表。
+
+## 性能对比:UNION vs 替代方案
+
+| 方案 | DB 往返次数 | 临时表开销 | 排序开销 | 适用规模 |
+|------|------------|-----------|---------|---------|
+| **UNION ALL** | 1 次 | 有(结果集缓冲) | 仅外层的 ORDER BY | 百万行级 |
+| **UNION(去重)** | 1 次 | 有(唯一索引) | Sort + Dedup | 十万行以内 |
+| **应用层 N 次查询** | N 次 | 无 | 内存归并 | 任意,受网络影响 |
+| **SUM(CASE WHEN)** | 1 次 | 无 | 无 | 单表聚合计数 |
+
+```mermaid
+flowchart LR
+ A["数据源数量 > 1"] --> B{"能否用单次扫描解决?"}
+ B -->|是: 单表多条件| C["SUM CASE WHEN
最优"]
+ B -->|否| D{"结果集是否已知不重复?"}
+ D -->|是| E["UNION ALL
推荐"]
+ D -->|否| F["UNION
去重"]
+ style C fill:#00D866,color:#fff
+ style E fill:#74C0FC,color:#000
+ style F fill:#FF9F43,color:#000
+```
+
+## 关联笔记
+
+- [[hhs/MySQL/12-DQL SELECT 全解析]] — SELECT 基础语法与 UNION 的结合使用
+- [[hhs/MySQL/03-CRUD 操作]] — DML 中的批量操作与 UNION 的互补关系
diff --git a/hhs/MySQL/16-B+Tree 索引原理.md b/hhs/MySQL/16-B+Tree 索引原理.md
new file mode 100644
index 0000000..1d84661
--- /dev/null
+++ b/hhs/MySQL/16-B+Tree 索引原理.md
@@ -0,0 +1,379 @@
+---
+tags: [MySQL, B+ Tree, 索引原理, Clustered Index]
+create time: 2026-05-16 00:00
+---
+
+# B+ Tree 索引原理
+
+## 概述
+
+MySQL InnoDB 的索引默认使用 B+ Tree(Balance Plus Tree)。理解它的工作方式是掌握所有索引优化技巧的前提。
+
+## 为什么用 B+ Tree?
+
+```mermaid
+flowchart LR
+ BT["B+ Tree
多路平衡树"] --> W1["胜出的原因"]
+ BT2["B Tree
传统平衡树"] --> W2["缺点"]
+ HT["Hash
哈希表"] --> W3["缺陷"]
+ BST["RBTree
二叉平衡树"] --> W4["不适合磁盘"]
+
+ W1 -.->|"范围扫描 + 磁盘友好 + 叶子链表串联"| WINNER["✅ B+ Tree 胜出"]
+ W2 -.->|"非叶子也存数据 → IO 更多"| REASON
+ W3 -.->|"无范围查询能力"| REASON
+ W4 -.->|"树太高 IO 频繁"| REASON
+
+ style WINNER fill:#00D866,color:#fff
+ style BT fill:#00D866,color:#fff
+```
+
+### 关键差异点
+
+| 特性 | B+ Tree | B Tree | Hash | 红黑树 |
+|------|---------|--------|------|--------|
+| **范围查询** | ✅ 叶子节点链表遍历 | ❌ 需回溯祖先 | ❌ | ❌ |
+| **全表扫描** | ✅ 顺序扫描叶子 | ❌ 需要层序遍历 | — | ❌ |
+| **磁盘 IO 次数** | 低(阶数高,树矮) | 高 | O(1) 但仅等值 | 极高 |
+| **插入删除稳定性** | ✅ 分裂/合并均衡 | ✅ | ❌ 缩容重哈希 | ✅ |
+| **查询性能可预测** | ✅ 始终 O(logₘn) | ✅ | ⚠️ 冲突时退化 | ✅ |
+
+## B+ Tree 结构
+
+```mermaid
+flowchart TD
+ ROOT["根节点
键: 15, 30, 45"] --> N1["内部节点
键: 5, 10"]
+ ROOT --> N2["内部节点
键: 20, 25"]
+ ROOT --> N3["内部节点
键: 35, 40"]
+ ROOT --> N4["内部节点
键: 50, 55"]
+
+ N1 --> LEAF1["[1][3][5][8][10]
叶子 · 存数据 + 双向链表"]
+ N2 --> LEAF2["[12][15][20][22][25]"]
+ N3 --> LEAF3["[28][32][35][40][45]"]
+ N4 --> LEAF4["[48][50][52][55][60]"]
+
+ LEAF1 -.-> LEAF2 -.-> LEAF3 -.-> LEAF4
+
+ style ROOT fill:#4FC08D,color:#fff
+ style N1 fill:#A0AEC0,color:#fff
+ style N2 fill:#A0AEC0,color:#fff
+ style N3 fill:#A0AEC0,color:#fff
+ style N4 fill:#A0AEC0,color:#fff
+ style LEAF1 fill:#00B6BC,color:#fff
+ style LEAF2 fill:#00B6BC,color:#fff
+ style LEAF3 fill:#00B6BC,color:#fff
+ style LEAF4 fill:#00B6BC,color:#fff
+```
+
+### 核心特征
+
+1. **非叶子节点只存储键(Key)和指针**,不存储完整数据行 —— 一个页可以放更多键,树更矮
+2. **所有数据存储在叶子节点**,形成有序链表 —— 支持范围扫描
+3. **叶子节点之间通过双向链表连接** —— 相邻页面不用回溯父节点
+4. **所有叶子节点在同一深度** —— 查询性能稳定
+
+## 为什么树这么矮?
+
+InnoDB 一页 16KB,假设:
+- 主键 BIGINT = 8 bytes
+- 指针 = 6 bytes
+- 每个内部节点 Entry ≈ 14 bytes
+- 一页可存 ≈ 16KB / 14 bytes ≈ **1170 个子节点**
+
+```mermaid
+flowchart LR
+ D1["1层
1,170 行"] --> D2["2层
约 137 万行"]
+ D2 --> D3["3层
约 16 亿行"]
+ D3 --> D4["4层
约 1900 亿行"]
+
+ style D1 fill:#A0AEC0,color:#fff
+ style D2 fill:#FF9F43,color:#000
+ style D3 fill:#00D866,color:#fff
+ style D4 fill:#EE5A24,color:#fff
+```
+
+> [!TIP] 这意味着什么?
+> 即使是一张有 1 亿行的表,查找任意一条记录也只需要 **3~4 次磁盘 IO**(每次读取一个页)。这就是 B+ Tree 在磁盘介质上无可替代的原因。
+
+> [!QUESTION] 思考一下
+> 如果每个内部节点只存一个键,那这棵树会变成什么样子?
+> ——退化成一棵二叉树(红黑树的高度)。所以 B+ Tree 的**阶数越高,树越矮,IO 越少**。
+>
+> [!TIP] 直观感受
+> 用 SHOW INDEXES 查看某个表的索引信息,关注 Cardinality(基数)——
+> 基数接近总行数说明区分度高,索引效果好:
+> ```sql
+> -- 查看表索引及基数估计值
+> SHOW INDEXES FROM users;
+> ```
+
+## 索引查找过程
+
+```mermaid
+sequenceDiagram
+ participant Q as "查询id等于42"
+ participant R as "根节点IO1"
+ participant I as "内部节点IO2"
+ participant L as "叶子节点IO3"
+ participant D as "数据行"
+
+ Q->>R: "id大于30走向右分支"
+ R->>I: "指向第二层右子节点"
+ I->>L: "命中对应叶子节点"
+ L->>D: "读取完整行数据"
+
+ Note over Q,D:"总共 3 次随机 IO"
+```
+
+> [!QUESTION] 思考一下
+> 如果二级索引也存整行数据,还需要回表吗?
+> ——不需要了。这就是**覆盖索引(Covering Index)**的核心思想。
+
+### 覆盖索引(Covering Index)
+
+当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。
+
+```sql
+CREATE TABLE users (
+ id BIGINT PRIMARY KEY,
+ email VARCHAR(255),
+ name VARCHAR(100),
+ age INT,
+ INDEX idx_email(email)
+);
+
+-- ❌ 必须回表:email 在索引里,但 name 不在
+SELECT name, email FROM users WHERE email = 'test@example.com';
+-- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name
+
+-- ✅ 覆盖索引:不需要回表!
+SELECT email FROM users WHERE email = 'test@example.com';
+-- 只需从 idx_email 叶子节点直接拿到 email
+```
+
+> [!NOTE] Covering Index 的判断方法
+> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表
+> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
+> - Extra 中**没有** `Using index` → 触发了回表
+>
+> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/17-聚簇索引与二级索引]]
+
+## 聚簇索引 vs 二级索引
+
+这是本节最重要的概念延伸,也是理解后续所有索引优化的基础。
+
+```mermaid
+flowchart TB
+ subgraph "聚簇索引 = 数据本身"
+ C1["id=1 · 整行数据"]
+ C2["id=2 · 整行数据"]
+ C3["id=3 · 整行数据"]
+ end
+
+ subgraph "二级索引 idx_email"
+ S1["email='a' → id=1"]
+ S2["email='b' → id=2"]
+ S3["email='c' → id=3"]
+ end
+
+ S1 -.回表.-> C1
+ S2 -.回表.-> C2
+ S3 -.回表.-> C3
+
+ style C1 fill:#00D866,color:#fff
+ style C2 fill:#00D866,color:#fff
+ style C3 fill:#00D866,color:#fff
+ style S1 fill:#FF9F43,color:#000
+ style S2 fill:#FF9F43,color:#000
+ style S3 fill:#FF9F43,color:#000
+```
+
+> [!QUESTION] 什么是回表?
+> 当用二级索引 `idx_email` 查 `WHERE email = 'a'` 时:
+> 1. 先在二级索引中找到 `email='a'` → 得到主键 `id=1`
+> 2. 再用 `id=1` 去聚簇索引中查找完整行数据
+> 这个两步过程叫**回表(Table Lookup)**。
+>
+> **优化方向**:让查询条件直接走聚簇索引,或者用 Covering Index 避免回表。
+
+## MySQL 索引类型总览
+
+在 B+ Tree 的基础上,InnoDB 支持多种索引类型。理解它们的选择时机也是面试和实战的常考点。
+
+| 索引类型 | 底层结构 | 适用场景 | 等值查找 | 范围查找 | 是否有序 |
+|----------|----------|----------|----------|----------|----------|
+| **聚簇索引 (Clustered)** | B+ Tree 叶子存整行数据 | 主键查询(默认自带) | ✅ O(log n) | ✅ 顺序扫描叶子 | ✅ |
+| **二级索引 (Secondary)** | B+ Tree 叶子存 `索引列 + 主键` | 非主键字段查询 | ✅ O(log n) | ✅ | ✅ |
+| **联合索引 (Composite)** | 单个 B+ Tree,多列组合排序 | 前缀匹配的多字段过滤 | ✅ | ✅ | ✅ |
+| **唯一索引 (Unique)** | 基于 B+ Tree 加约束 | 保证列值唯一(如手机号) | ✅ | ✅ | ✅ |
+| **前缀索引 (Prefix)** | B+ Tree 只取字符前 N 位 | 长字符串字段(如 URL) | ✅ | ⚠️ 精度降低 | ✅ |
+| **全文索引 (Fulltext)** | InnoDB 使用倒排索引 | 文本搜索 (`MATCH...AGAINST`) | — | — | ❌ |
+| **空间索引 (Spatial)** | R-Tree | GIS 地理空间数据 | — | — | ❌ |
+
+> [!NOTE] 重点记忆
+> 除 **全文索引** 和 **空间索引** 外,其余全部依赖 B+ Tree。所以本文的核心内容覆盖了 InnoDB 90% 以上的日常使用场景。
+
+### 联合索引的最左前缀原则
+
+联合索引 `(a, b, c)` 的本质是:**先按 a 排序,a 相同时按 b 排序,a、b 都相同时按 c 排序**。
+
+```sql
+CREATE TABLE orders (
+ id BIGINT PRIMARY KEY,
+ user_id INT,
+ status VARCHAR(20),
+ created_at DATETIME,
+ INDEX idx_user_status(user_id, status) -- 联合索引
+);
+
+-- 以下可以命中索引
+SELECT * FROM orders WHERE user_id = 1; -- ✅ (user_id)
+SELECT * FROM orders WHERE user_id = 1 AND status = 'paid'; -- ✅ (user_id, status)
+
+-- 以下无法完全命中联合索引
+SELECT * FROM orders WHERE status = 'paid'; -- ❌ 缺少最左列 user_id
+SELECT * FROM orders WHERE user_id = 1 AND created_at > ...; -- ⚠️ 只能用到 user_id
+```
+
+> [!TIP] 设计联合索引的黄金法则
+> 1. **把区分度最高的列放在最左边**——能最大程度缩小搜索范围
+> 2. **等值匹配的列排在范围查询之前**
+> 3. **覆盖最常出现的查询模式**,而不是把所有可能的列堆在一起
+
+## 实战常见陷阱:索引为什么不生效?
+
+B+ Tree 再优秀,也用不好 SQL。以下是最常见的索引失效场景:
+
+### 1. 函数 / 运算包裹了索引列
+
+```sql
+-- ❌ 索引失效 —— 每行都要计算 DATE(created_at)
+SELECT * FROM orders WHERE DATE(created_at) = '2025-05-01';
+
+-- ✅ 改用范围查询,利用 B+ Tree 的范围扫描能力
+SELECT * FROM orders
+WHERE created_at >= '2025-05-01 00:00:00'
+ AND created_at < '2025-05-02 00:00:00';
+```
+
+> [!NOTE] 原理
+> B+ Tree 按原始值排序,对 `created_at` 建了索引但查的是 `DATE(created_at)`,相当于换了个"钥匙"去找"锁",树根目录里根本找不到这个新钥匙。
+
+### 2. LIKE 以通配符开头
+
+```sql
+-- ❌ 走全表扫描
+SELECT * FROM users WHERE name LIKE '%abc%';
+
+-- ✅ 前缀匹配仍然走索引
+SELECT * FROM users WHERE name LIKE 'abc%';
+```
+
+### 3. OR 条件中有一列无索引
+
+```sql
+-- ❌ 即使 id 有索引、email 也有索引,但一旦 email 没索引,整个 OR 就可能不走索引
+SELECT * FROM users WHERE id = 1 OR email = 'test@x.com';
+```
+
+### 4. 隐式类型转换
+
+```sql
+-- 假设 phone 是 VARCHAR 类型并建有索引
+-- ❌ 传入的是数字类型,MySQL 要对每行做 CAST(phone AS SIGNED)
+SELECT * FROM users WHERE phone = 13800138000;
+
+-- ✅ 保持类型一致
+SELECT * FROM users WHERE phone = '13800138000';
+```
+
+### 5. 回表太多导致 Optimizer 选择全表扫描
+
+```sql
+-- 当二级索引需要回表的行数占总行数很大比例时(通常 > 20%~30%),
+-- Optimizer 会认为走索引反而更慢(频繁随机 IO),主动选择全表扫描。
+-- 这是正常行为,强行用 FORCE INDEX 往往适得其反。
+```
+
+> [!TIP] 怎么判断要不要加索引?
+> - 先用 `EXPLAIN` 看执行计划
+> - `type` 从 `ALL → index → range → ref → const` 依次越来越优
+> - 如果已经是 `ref` 或 `range` 且 Extra 没有异常提示,说明索引已经在工作
+
+## 索引的设计权衡
+
+好索引带来快查询,但也带来慢写入。设计时需要权衡以下代价:
+
+### 写入放大(Write Amplification)
+
+聚簇索引只需维护一个 B+ Tree,但**每个二级索引都是独立的 B+ Tree**。每插入一行数据:
+
+```sql
+INSERT INTO users (id, email, name, age) VALUES (1, 'a@x.com', 'Tom', 25);
+-- InnoDB 需要同时更新:
+-- ① 聚簇索引 idx__PRIMARY → 1 次 IO
+-- ② 二级索引 idx_email → 1 次 IO
+-- ③ 如果有更多二级索引... → N 次 IO
+-- 总写入成本 = 1 + 二级索引数量
+```
+
+> [!NOTE] 直观理解
+> 假设有 5 个二级索引,插入一条记录就要写 **6 棵 B+ Tree**。
+> 删除、更新同理——所有相关索引都要同步修改。这就是为什么**索引越多,写越慢**。
+
+### 空间占用
+
+```sql
+-- 一张表 100 GB,建了 4 个二级索引:
+-- 聚簇索引 ≈ 100 GB(就是数据本身)
+-- 二级索引 × 4 ≈ 30~50 GB(取决于索引列的宽度)
+-- 总磁盘用量 ≈ 130~150 GB
+```
+
+> [!QUESTION] 思考一下
+> 如果你有一张日增百万行的日志表,你会给它建几个二级索引?
+> ——答案通常是:**少而精**。先分析最慢的几个查询,针对性加索引,而不是"以防万一全加上"。
+
+### 维护开销
+
+| 操作 | 聚簇索引影响 | 二级索引影响 |
+|------|-------------|-------------|
+| **INSERT** | 叶子节点末尾追加(顺序 IO) | 需定位正确位置 + 可能的页分裂(随机 IO) |
+| **UPDATE** | 若主键不变则无影响;变化则删除+重建 | 所有涉及列变化的索引都需要更新 |
+| **DELETE** | 标记删除或合并页 | 同上,且可能有页合并开销 |
+| **页分裂** | 高水位上升时发生 | 频率更高(二级索引更密集) |
+
+> [!TIP] 经验法则
+> - 写密集型系统(如日志、订单创建),索引数量控制在 **3 个以内**
+> - 读密集型系统(如报表查询),可以放宽到 **5 个左右**
+> - 超过 10 个索引几乎必然影响写入性能,应审慎评估
+
+## 最佳实践清单
+
+结合上述原理与实战经验,整理一份日常工作中可以直接使用的检查清单:
+
+> [!CHECKLIST] 索引设计与审查清单
+>
+> **[建表阶段]**
+> - [ ] 主键选择合理(自增 BIGINT / UUID 替代方案考虑过?)
+> - [ ] 高频查询字段已建索引
+> - [ ] 联合索引区分度最高的列放在最左
+> - [ ] 避免过度索引(写多读少的表控制在 3 个以内)
+>
+> **[上线前]**
+> - [ ] 所有慢查询 `EXPLAIN` 后 type 不是 `ALL`
+> - [ ] 覆盖索引场景用 `Using index` 确认不走回表
+> - [ ] 范围查询列不要和其他等值列反序排列
+> - [ ] VARCHAR 长字符串考虑使用前缀索引
+>
+> **[线上巡检]**
+> - [ ] `SHOW INDEXES` 检查 Cardinality 接近行数
+> - [ ] 无用索引定期清理(使用 pt-duplicate-key-checker 等工具)
+> - [ ] 大表 DDL 使用 `ALGORITHM=INPLACE, LOCK=NONE` 避免锁表
+> - [ ] 监控慢查询日志,按需调整索引策略
+
+## 关联笔记
+
+- [[hhs/GORM/02-模型定义]] — GORM 创建索引的 struct tag 映射到 B+ Tree
+- [[hhs/MySQL/17-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的设计原则与实践
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读
diff --git a/hhs/MySQL/17-聚簇索引与二级索引.md b/hhs/MySQL/17-聚簇索引与二级索引.md
new file mode 100644
index 0000000..b72078c
--- /dev/null
+++ b/hhs/MySQL/17-聚簇索引与二级索引.md
@@ -0,0 +1,200 @@
+---
+tags: [MySQL, 聚簇索引, 二级索引, Covering Index, 回表]
+create time: 2026-05-16 00:00
+---
+
+# 聚簇索引 vs 二级索引
+
+## 概述
+
+InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。
+
+> [!QUESTION] 思考
+> 假设一张用户表按 `id` 排序存储在磁盘上——
+> 现在要查 `WHERE email = 'alice@test.com'`,数据库需要做什么?
+> (提示:email 不是存储排序的依据。)
+
+带着这个问题往下看。
+
+## 聚簇索引(Clustered Index)
+
+聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。
+
+### 聚簇索引的形成规则
+
+| 优先级 | 条件 | 说明 |
+|--------|------|------|
+| ① | **PRIMARY KEY** | 如果有显式 PK,它自动成为聚簇索引 |
+| ② | **UNIQUE + NOT NULL** | 没有 PK 但有唯一非空列,用它 |
+| ③ | **隐藏 row_id** | 都没有时,InnoDB 自动生成隐藏的 6-byte row_id |
+
+> [!WARNING] 隐藏的 row_id 是个坑
+> 如果你的表既没 PK 也没有 UNIQUE NOT NULL 列,InnoDB 会自动生成隐藏主键。这时:
+> - 你自定义的任何索引都会变成二级索引
+> - 二级索引回表时需要额外跳转 → 回表代价更高
+> - 外键引用不可靠(ID 对用户透明)
+>
+> **结论:每张表必须有显式 PRIMARY KEY。**
+
+### 聚簇索引的特性
+
+```mermaid
+flowchart TD
+ A["按主键排序存储数据"] --> B["数据页紧凑排列"]
+ B --> C["连续主键 = 顺序写入"]
+ C --> D["最小化页分裂"]
+ D --> E["最佳写入性能"]
+
+ A --> F["范围查询极快
WHERE id BETWEEN 100 AND 200
只需扫描一段连续的叶子页"]
+ F --> G["顺序 I/O 而非随机 I/O"]
+
+ style E fill:#00D866,color:#fff
+ style G fill:#00B6BC,color:#fff
+```
+
+## 二级索引(Secondary Index)
+
+除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
+
+```mermaid
+flowchart TD
+ subgraph "二级索引页
idx_status_created = (status, created_at)"
+ direction LR
+ R1["status=1 \| created_at=01 → PK=5"]
+ R2["status=1 \| created_at=02 → PK=12"]
+ R3["status=1 \| created_at=03 → PK=18"]
+ R4["status=0 \| created_at=01 → PK=3"]
+ R5["status=0 \| created_at=04 → PK=25"]
+ end
+
+ style R1 fill:#E8DFF5,color:#333
+ style R3 fill:#00B6BC,color:#fff
+```
+
+> [!NOTE] 关键观察
+> - 每行都包含 **索引列值**(用于匹配查询条件)和 **主键值**(用于回表定位完整行数据)
+> - 二级索引本身是 B+ Tree,按索引列排序;叶子层每一行指向聚簇索引中的对应数据
+> - 如果只需要索引中的列(如 `SELECT status WHERE status=1`),就 **无需回表**——这就是 Covering Index
+
+## 回表的代价
+
+```mermaid
+sequenceDiagram
+ participant App as 应用层
+ participant SI as 二级索引(idx_email)
+ participant CI as 聚簇索引(PK=id)
+ participant DB as 数据盘
+
+ App->>SI: 查找 email='alice@test.com'
+ SI-->>App: 找到 → id=12345
+
+ App->>CI: 用 id=12345 查找
+ CI->>DB: 定位叶子页 (随机 IO #1)
+ DB-->>CI: 返回完整行
+
+ CI-->>App: 返回完整行
+
+ Note over SI,DB: 至少 2 次 IO:1次二级索引 + 1次回表
+```
+
+### 减少回表的策略
+
+```sql
+-- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
+SELECT name, email FROM users WHERE email = 'alice@test.com';
+-- idx_email 能匹配 WHERE,但 name 不在索引里 → 需要回表
+
+-- ✅ 好:覆盖索引,无需回表
+ALTER TABLE users ADD INDEX idx_email_name (email, name);
+SELECT name, email FROM users WHERE email = 'alice@test.com';
+-- EXPLAIN Extra: Using index ← 完美!
+```
+
+> [!SUCCESS] Covering Index 的黄金法则
+> **把 SELECT 中的列 + WHERE 中的列放到同一个联合索引中**,就能实现 Index Only Scan。
+> - 适合:高频查询、固定列选择
+> - 不适合:SELECT *(永远无法覆盖)、列变化频繁的查询
+
+## 回表 vs 索引下推(ICP)
+
+MySQL 5.6 引入的 Index Condition Pushdown 优化了部分回表场景。
+
+```sql
+-- 假设联合索引 idx_name_status = (name, status)
+-- 查询:WHERE name LIKE '张%' AND status = 1
+
+-- ❌ 无 ICP:所有匹配的 name 都要回表查 status
+SELECT * FROM users WHERE name LIKE '张%' AND status = 1;
+-- 步骤:1) 找到所有 '张%' 的行 2) 逐条回表查 status 3) 过滤
+
+-- ✅ 有 ICP:在二级索引中就先过滤 status
+-- 引擎层直接读二级索引页,提取 name 和 status,先判断 status=1
+-- 只有满足条件的才回表
+-- 减少了大量无效回表
+
+-- EXPLAIN 验证
+EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND status = 1\G
+-- Extra 显示: Using index condition
+```
+
+```mermaid
+flowchart TD
+ N["无 ICP"] --> A["查到 1000 条 '张%' 的记录"]
+ A --> B["1000 次回表检查 status"]
+ B --> C["最终只有 10 条符合"]
+
+ Y["有 ICP"] --> D["在索引中预检 status"]
+ D --> E["1000 条中筛出 10 条"]
+ E --> F["仅 10 次回表"]
+
+ style B fill:#EE5A24,color:#fff
+ style F fill:#00D866,color:#fff
+```
+
+## 两种索引的空间对比
+
+```mermaid
+graph TB
+ subgraph Clustered["聚簇索引 — 100 万行"]
+ CI["每页约 200 行 | 16KB / 80 bytes"]
+ CI2["总页数 ≈ 5000 页"]
+ end
+
+ subgraph Secondary["二级索引 — idx_email VARCHAR(255)"]
+ SI["每页约 80 行 | 16KB / 200 bytes"]
+ SI2["总页数 ≈ 12500 页"]
+ end
+
+ CI --> CI2
+ SI --> SI2
+
+ style CI2 fill:#00B6BC,color:#fff
+ style SI2 fill:#C44569,color:#fff
+```
+
+> [!NOTE] 二级索引通常比聚簇索引大得多
+> 因为二级索引存的是「索引列 + 主键」,而聚簇索引存的是「整行」。如果索引列很长(如 VARCHAR(255)),二级索引会膨胀得很厉害。这也是为什么长字符串列做索引时要限制长度:`(name(50))`。
+
+## 两种索引的对比总结
+
+| 维度 | 聚簇索引 (Clustered) | 二级索引 (Secondary) |
+|------|---------------------|---------------------|
+| **数量** | 每张表仅一个 | 可以有多个 |
+| **叶子节点存储** | 整行数据 | 索引列值 + 主键值 |
+| **形成依据** | PK / 唯一非空列 / 隐藏 row_id | `CREATE INDEX` 或 `UNIQUE KEY` |
+| **范围查询** | 极快(顺序扫描连续页) | 需要回表,代价高 |
+| **覆盖索引** | 天然覆盖(本身就是数据) | 仅当所需列都在索引中时生效 |
+| **空间占用** | 基准大小 | 通常更大(多一列主键 + 膨胀风险) |
+| **写入代价** | 插入可能触发页分裂 | 更新索引列需改索引 + 回表改数据 |
+
+> [!SUMMARY] 核心记忆点
+> 1. 聚簇索引 = 数据本身,**一张表只能有一个**
+> 2. 二级索引 = 「索引列 + 主键」,查数据要**回表**
+> 3. 能用 Covering Index 的场景永远优于回表
+> 4. ICP 是 MySQL 5.6 对回表的温和优化——能省则省
+
+## 关联笔记
+
+- [[hhs/MySQL/16-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响
+- [[hhs/GORM/15-性能优化]] — GORM 场景下的 Covering Index 实践
diff --git a/hhs/MySQL/18-联合索引与最左前缀.md b/hhs/MySQL/18-联合索引与最左前缀.md
new file mode 100644
index 0000000..5ef4215
--- /dev/null
+++ b/hhs/MySQL/18-联合索引与最左前缀.md
@@ -0,0 +1,197 @@
+---
+tags: [MySQL, 联合索引, 最左前缀, 索引失效]
+create time: 2026-05-16 00:00
+---
+
+# 联合索引与最左前缀
+
+## 概述
+
+联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。
+
+## 联合索引的结构
+
+```
+联合索引 idx(a, b, c) 的 B+ Tree 结构:
+
+叶子节点按 a 排序,a 相同时按 b 排序,a 和 b 都相同时按 c 排序:
+
+[a=1, b=10, c=3] → data
+[a=1, b=10, c=7] → data
+[a=1, b=20, c=1] → data
+[a=1, b=20, c=9] → data
+[a=2, b=5, c=2] → data
+[a=2, b=15, c=4] → data
+[a=3, b=10, c=1] → data
+[a=3, b=10, c=8] → data
+[a=3, b=30, c=5] → data
+```
+
+> [!QUESTION] 这意味着什么?
+> 联合索引的本质是**多级排序**。你可以把它想象成 SQL 的 `ORDER BY a, b, c`。索引中的数据已经按照这个顺序排好了。
+
+## 最左前缀法则详解
+
+```mermaid
+flowchart TD
+ IDX["联合索引 idx(a, b, c)"]
+
+ IDX --> U1["✅ WHERE a=1"]
+ IDX --> U2["✅ WHERE a=1 AND b=2"]
+ IDX --> U3["✅ WHERE a=1 AND b=2 AND c=3"]
+ IDX --> X1["❌ WHERE b=2"]
+ IDX --> X2["❌ WHERE c=3"]
+ IDX --> X3["❌ WHERE b=2 AND c=3"]
+ IDX --> U4["⚠️ WHERE a=1 AND c=3"]
+
+ U1 -. "用 1 列" .-> _u1
+ U2 -. "用 2 列" .-> _u2
+ U3 -. "用全部" .-> _u3
+ X1 -. "无效" .-> _x1
+ X2 -. "无效" .-> _x2
+ X3 -. "无效" .-> _x3
+ U4 -. "仅用 a" .-> _u4
+
+ style U1 fill:#00D866,color:#fff
+ style U2 fill:#00D866,color:#fff
+ style U3 fill:#00D866,color:#fff
+ style X1 fill:#EE5A24,color:#fff
+ style X2 fill:#EE5A24,color:#fff
+ style X3 fill:#EE5A24,color:#fff
+ style U4 fill:#FF9F43,color:#000
+```
+
+### 为什么 b=2 单独查不了?
+
+```
+查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的?
+
+[b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]...
+数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描
+
+而 WHERE a=1 则不同:
+[a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位
+```
+
+## 范围查询后的断裂
+
+联合索引遇到范围查询(`>`, `<`, `BETWEEN`, `LIKE 'prefix%'`)后,右侧列的索引失效。
+
+```sql
+-- 假设已有联合索引 idx(status, created_at, type)
+
+-- ⚠️ 看起来像用上了 3 个列,实际上 type 不会走索引!
+SELECT * FROM orders WHERE status = 'paid'
+ AND created_at > '2026-01-01' AND type = 'online';
+
+-- 逐列分析:
+-- status = 'paid' → ✅ 等值匹配,精确定位起始行
+-- created_at > ... → ✅ 范围扫描,划定区间终点
+-- type = 'online' → ❌ 区间内数据未按 type 排序 → 退化为内存过滤
+```
+
+> [!QUESTION] 为什么范围查询会打断后续列?
+> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。
+
+```mermaid
+graph LR
+ A["等值列 = 定位起点"] -->|"精确找到起始位置"| B
+ B["范围列 = 确定终点"] -->|"划定扫描区间"| C
+ C["右侧列 = 失效"] -->|"区间内无序"| D["退化为 WHERE 过滤"]
+
+ style A fill:#00D866,color:#fff
+ style B fill:#00B6BC,color:#fff
+ style D fill:#EE5A24,color:#fff
+```
+
+### 实战:调整联合索引的顺序
+
+```sql
+-- 场景:订单表常用查询——按状态筛选 + 按时间范围 + 按类型过滤
+
+-- ❌ 原始索引:范围列在中间,后续等值列无法使用
+CREATE INDEX idx_sct ON orders (status, created_at, type);
+-- 查询 A: WHERE status=? AND created_at>? AND type=?
+-- → type 无法用索引(created_at 的范围扫描打断了后续列)
+
+-- ✅ 优化方案 1:将等值列提前
+CREATE INDEX idx_stc ON orders (status, type, created_at);
+-- 查询 B: WHERE status=? AND type=? AND created_at>?
+-- → 三个列全部用上!status 定位起点,type 进一步缩小,created_at 做范围
+-- 💡 注意:即使 SQL 写的是 created_at 在前,优化器也会自动调整顺序匹配索引
+
+-- ✅ 优化方案 2:如果两个查询频率差不多,拆成两个索引
+CREATE INDEX idx_status ON orders (status);
+CREATE INDEX idx_status_time ON orders (status, created_at);
+-- 让每个索引专注于它的典型查询模式
+```
+
+> [!TIP] 联合索引列序黄金法则
+> 1. **等值优先于范围**:等值列放在前面
+> 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前
+> 3. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀
+
+## 索引失效的典型场景
+
+```sql
+-- ❌ 场景 1:隐式类型转换
+ALTER TABLE users ADD INDEX idx_phone (phone);
+-- phone 是 VARCHAR 类型
+SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
+-- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效
+
+-- ❌ 场景 2:函数/表达式包裹索引列
+SELECT * FROM users WHERE YEAR(created_at) = 2026;
+-- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
+
+-- ❌ 场景 3:LIKE 以通配符开头
+SELECT * FROM users WHERE name LIKE '%abc%';
+-- 应对:考虑全文索引 FT
+
+-- ❌ 场景 4:OR 条件中有列没有索引
+SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
+-- 如果 email 有索引但 phone 没有,整个查询不走索引
+-- 应对:给 phone 加索引,或用 UNION ALL 拆分
+
+-- ❌ 场景 5:字符集不一致导致隐式转换
+-- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
+-- 应对:确保连接字符集一致 SET NAMES utf8mb4
+```
+
+## 最左前缀的灵活应用
+
+一个设计良好的联合索引可以**同时服务 WHERE、ORDER BY、GROUP BY 三种需求**。
+
+```sql
+-- 建索引:idx(status, type, created_at)
+CREATE INDEX idx_status_type_time ON orders (status, type, created_at);
+
+-- ── 查询 1:等值 + 范围 ──
+SELECT * FROM orders WHERE status = 'pending'
+ AND created_at > '2026-01-01';
+-- → status 等值定位起点,created_at 范围扫描。type 虽在中间但没用到,不影响前两列生效
+
+-- ── 查询 2:利用 ORDER BY ──
+SELECT * FROM orders WHERE status = 'pending'
+ORDER BY type ASC;
+-- → WHERE 条件锁定了 status='pending' 的子区间,该区间内数据天然按 type 排序
+-- → 无需额外 filesort!
+
+-- ── 查询 3:利用 GROUP BY ──
+SELECT type, COUNT(*) FROM orders WHERE status = 'pending'
+GROUP BY type;
+-- → 同上,子区间内 type 已有序,分组可以直接跳过
+```
+
+> [!NOTE] 关键理解
+> ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。
+
+> [!TIP] 一索引多用
+> 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。
+
+## 关联笔记
+
+- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效
+- [[hhs/MySQL/20-慢查询日志分析]] — 如何从 slow log 中识别索引未命中
+- [[hhs/MySQL/21-查询改写技巧]] — 将失效的查询改写为可利用索引的形式
diff --git a/hhs/MySQL/19-EXPLAIN 完全指南.md b/hhs/MySQL/19-EXPLAIN 完全指南.md
new file mode 100644
index 0000000..ce8e8b7
--- /dev/null
+++ b/hhs/MySQL/19-EXPLAIN 完全指南.md
@@ -0,0 +1,328 @@
+---
+tags: [MySQL, EXPLAIN, 执行计划, 性能分析]
+create time: 2026-05-16 00:00
+---
+
+# EXPLAIN 完全指南
+
+## 概述
+
+`EXPLAIN` 是 MySQL 性能分析的入口工具——它展示 SQL 语句的执行计划,告诉你优化器打算怎么用索引、怎么 JOIN、会不会用临时表和文件排序。
+
+> [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"?
+> 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。
+
+## 基本用法
+
+```sql
+-- 标准 EXPLAIN
+EXPLAIN SELECT * FROM users u
+JOIN orders o ON u.id = o.user_id
+WHERE u.status = 1 AND o.amount > 100;
+
+-- JSON 格式(信息最全,推荐)
+EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com';
+
+-- 实际执行后再看成本(含统计信息)
+EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+
+```
+
+## 关键字段速查
+
+| 字段 | 含义 | 关注点 |
+|------|------|--------|
+| **type** | JOIN 类型 | 是否走索引(ref/range 优,ALL 差)|
+| **key** | 实际使用的索引 | NULL = 没用索引 |
+| **rows** | 预估扫描行数 | 越小越好 |
+| **Extra** | 附加信息 | 重点关注 Using where/index/filesort/temporary |
+| **cost** | 预估成本(JSON 格式) | optimizer_cost 越低越好 |
+
+## type 详解(最重要)
+
+```mermaid
+flowchart LR
+ system["system
只有1行"] --> const["const
常量级访问"]
+ const --> eq_ref["eq_ref
唯一索引 × 驱动行"]
+ eq_ref --> ref["ref
等值匹配多行"]
+ ref --> range["range
索引范围"]
+ range --> index["index
全索引扫描"]
+ index --> ALL["ALL
全表扫描 🔴"]
+
+ style system fill:#00D866,color:#fff
+ style const fill:#00D866,color:#fff
+ style eq_ref fill:#00D866,color:#fff
+ style ref fill:#00B6BC,color:#fff
+ style range fill:#FF9F43,color:#000
+ style index fill:#C44569,color:#fff
+ style ALL fill:#EE5A24,color:#fff
+```
+
+### 各级别详解
+
+| 级别 | 名称 | 含义 | 示例 |
+|------|------|------|------|
+| **system** | 系统表 | 表只有一行(MyISAM 等引擎)| `SELECT * FROM (SELECT 1) t`|
+| **const** | 常量 | 最多一行匹配 | `WHERE primary_key = 1`|
+| **eq_ref** | 唯一索引 | 唯一索引扫描,每行匹配被驱动表一行 | `JOIN ON pk = ?` |
+| **ref** | 索引查找 | 等值匹配多行(非唯一索引 / 最左前缀的部分匹配)| `WHERE email_prefix = 'a@'` |
+| **range** | 范围扫描 | 索引上的范围查询 | `WHERE id > 100` |
+| **index** | 索引全扫 | 扫描整个索引树 | `SELECT COUNT(*)` |
+| **ALL** | 全表扫描 | 无可用索引,逐行扫描 | **需要优化** |
+
+> [!WARNING] 不要盲目追求 ref 以上级别
+> `range` 在某些场景(如范围不大)完全可以接受。关键是看 `rows` 字段和 `Extra` 的组合。
+
+## Extra 关键字解读
+
+> [!SUCCESS] Using index — 覆盖索引(Index Only Scan)
+> 数据在索引中全部找到,**无需回表**。这是最优的 Extra 信息。
+
+> [!INFO] Using where — 服务器层过滤
+> 读完索引后还需 WHERE 条件过滤。**正常现象**,不代表性能问题。
+> - **Using where + Using index** = 覆盖索引 + WHERE 过滤 → 最佳实践 ✅
+> - **Using where 无 Using index** = 回表后才过滤 → 可考虑加索引优化 ⚠️
+
+> [!TIP] Using index condition — 索引下推(ICP)
+> MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅
+
+### 其他 Extra 信息速查
+
+| 关键词 | 含义 | 处理 |
+|--------|------|------|
+| `Impossible where` | WHERE 永远为假(如 `status = 1 AND status = 2`)| 检查 SQL 逻辑是否正确 |
+| `Select tables optimized` | 优化器发现子查询可展开 | 无需处理,已自动优化 ✅ |
+| `Using distinct` | 内部去重,类似 DISTINCT | 看能否改用 GROUP BY + 索引 |
+| `Scan & filter` | **InnoDB** 特有:全索引扫描后逐行过滤 | 考虑加更精确的索引 |
+
+### Using temporary:何时出现
+
+```sql
+-- 常见触发场景
+EXPLAIN SELECT city, AVG(age) FROM users GROUP BY city;
+-- Extra: Using temporary; Using filesort
+-- 需要用临时表存储每个 city 的聚合结果
+
+EXPLAIN SELECT DISTINCT city FROM users;
+-- Extra: Using temporary
+-- DISTINCT 内部用临时表去重
+```
+
+> [!WARNING] Using temporary + Using filesort
+> 当 GROUP BY 的分组列和 ORDER BY 的排序列不一致时,MySQL 会先建临时表再额外排序。
+> **解决思路**:让索引同时满足 GROUP BY + ORDER BY 的顺序要求。
+
+### Using filesort:深度分析
+
+```mermaid
+flowchart TD
+ A["WHERE status='pending'"] --> B["idx_status 索引扫描
拿到所有 pending 订单的 pk"]
+ B --> C{"created_at 能在索引中找到吗?"}
+
+ C -->|能:
联合索引 idx_status_created| D["直接有序返回
No filesort ✅"]
+ C -->|不能:
单列索引 idx_status| E["取 pk 列表 → 回表拿完整行
→ 内存中排序
Filesort ⚠️"]
+
+ D --> F["可能的解决方案"]
+ E --> F
+
+ F --> F1["添加联合索引 (status, created_at)"]
+ F --> F2["调整 WHERE 条件使索引生效"]
+ F --> F3["加大 sort_buffer_size"]
+
+ style D fill:#00D866,color:#fff
+ style E fill:#EE5A24,color:#fff
+```
+
+> [!QUESTION] 思考:ORDER BY 一定会触发 filesort 吗?
+> 不一定!如果查询使用了索引且排序字段与索引顺序一致,MySQL 可以直接按索引序扫描,跳过排序步骤。关键看 **索引是否天然有序**。
+
+```sql
+-- ✅ No filesort — 走联合索引天然有序
+EXPLAIN SELECT * FROM orders
+WHERE status = 'pending'
+ORDER BY status, created_at;
+-- key: idx_status_created, Extra: Using where
+
+-- ❌ Using filesort — WHERE 用了另一个索引,无法利用排序
+EXPLAIN SELECT * FROM orders
+WHERE user_id = 42
+ORDER BY created_at DESC;
+-- key: idx_user_id, Extra: Using where; Using filesort
+```
+
+## 实战案例分析
+
+```sql
+-- 原始查询(慢)
+EXPLAIN SELECT u.username, o.amount
+FROM users u
+JOIN orders o ON u.id = o.user_id
+WHERE o.created_at >= '2026-01-01'
+ORDER BY o.created_at DESC
+LIMIT 20;
+
+-- 典型 bad result:
+-- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary
+-- → 全表扫描 + 临时表 + 文件排序
+
+-- 修复方案
+-- 1. 创建复合索引
+ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id);
+
+-- 2. 查询改写(反向排序优化)
+-- 先用索引定位 top 20 的 pk,再 JOIN 拿数据
+SELECT u.username, o.amount
+FROM orders o
+JOIN users u ON u.id = o.user_id
+WHERE o.created_at >= '2026-01-01'
+ORDER BY o.created_at DESC
+LIMIT 20;
+
+-- 新的 EXPLAIN:
+-- type: range on orders, ref on users
+-- key: idx_created_user
+-- Extra: Using index condition; Using where
+-- → 索引范围扫描 + 快速消除 filesort
+```
+
+## EXPLAIN FORMAT=JSON 精读
+
+`FORMAT=JSON` 输出最完整的执行计划,包含嵌套子查询、Cost 信息、索引选择细节。
+
+```json
+{
+ "query_block": {
+ "select_id": 1,
+ "table": {
+ "table_name": "orders",
+ "access_type": "range",
+ "possible_keys": ["idx_created_user"],
+ "key": "idx_created_user",
+ "key_length": "8",
+ "rows": 5000,
+ "filtered": 100.0,
+ "index_condition": "orders.created_at >= '2026-01-01'",
+ "cost_information": {
+ "total_cost": "53000", ← 总成本(越小越好)
+ "optimizer_cost": "53000" ← 优化器计算的成本值
+ }
+ }
+ }
+}
+```
+
+### 关键字段说明
+
+| JSON 字段 | 含义 | 实战要点 |
+|-----------|------|----------|
+| **access_type** | 与 `type` 等价 | range/ref 优先,ALL 需关注 |
+| **key_length** | 实际使用索引的字节数 | 越短说明用的列越少,可优化 |
+| **rows** | 预估扫描行数 | 估算值,可能与实际情况偏差 |
+| **filtered** | WHERE 筛选率 (%) | rows × filtered% = 进入下一步的行数 |
+| **index_condition** | ICP 预过滤条件 | 有 = 使用了索引下推 ✅ |
+| **using_temporary** / **using_filesort** | 布尔值 | true = 会触发对应操作 |
+
+### Cost 分析:如何判断查询是否健康?
+
+> [!TIP] Cost 评估经验法则
+> - **cost < 1000**:通常没问题 ✅
+> - **cost 1000 ~ 10000**:中等负载可接受,关注高频 SQL ⚠️
+> - **cost > 10000**:大概率需要优化 🔴
+
+关键点:**optimizer_cost 是绝对值而非相对值**,不同版本 MySQL 的计算方式可能变化。更有价值的是 **对比两种方案的 cost 差值**——比如加了一个索引后 cost 从 50000 降到 5300,这就是有效的索引设计。
+
+```mermaid
+flowchart LR
+ A["编写慢 SQL"] --> B["EXPLAIN 看执行计划"]
+ B --> C{"access_type?"}
+ C -->|ALL 全表扫描| D["检查 possible_keys
添加合适的索引"]
+ C -->|range/ref/optimal| E{"Extra 中有
filesort/temporary?"}
+ E -->|无 → 健康✅| F["无需额外处理"]
+ E -->|有 → 优化⚠️| G["调整索引顺序或改写 SQL"]
+
+ style F fill:#00D866,color:#fff
+ style G fill:#FF9F43,color:#000
+ style D fill:#C44569,color:#fff
+```
+
+## EXPLAIN ANALYZE:看到真实执行代价
+
+`EXPLAIN ANALYZE` 是 MySQL 8.0.18+ 引入的功能——它会**真正执行一次 SQL**,然后返回实际运行时间、每行的实际扫描数等统计信息。
+
+> [!WARNING] 注意事项
+> EXPLAIN ANALYZE **会执行 SQL**。如果有写入操作(如 INSERT/UPDATE),需要谨慎;但对于纯 SELECT 查询可以放心使用。
+
+```sql
+-- 传统方式 vs 现代方式
+EXPLAIN SELECT * FROM orders WHERE user_id = 42;
+-- → 只有预估数据,可能不准
+
+EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
+-- → 预估 + 实际运行结果
+-- Extra: Rows examined: 42 (vs. rows estimate: 100)
+```
+
+### 典型输出解读
+
+```
+-> Index lookup on orders using idx_user_id (user_id=42)
+ (actual time=0.034..0.156 rows=42 loops=1)
+```
+
+- **actual time**:首行耗时 .. 总耗时(微秒级)
+- **rows**:实际扫描行数(与预估 `rows` 对比,差距大说明统计信息过时)
+- **loops**:循环次数,JOIN 场景尤其重要
+
+> [!QUESTION] 什么时候用 EXPLAIN vs EXPLAIN ANALYZE?
+> - **EXPLAIN**:快速预览,不执行 SQL,适合批量排查和 CI 门禁
+> - **EXPLAIN ANALYZE**:精准诊断,需要实际执行,适合深入分析某个慢查询
+> - 日常开发建议先用 `EXPLAIN` 快速筛查,再对问题 SQL 用 `EXPLAIN ANALYZE` 定位根因
+
+## 优化策略速查
+
+遇到问题 SQL,按以下步骤排查:
+
+```mermaid
+flowchart TD
+ A["拿到慢 SQL"] --> B["EXPLAIN 看执行计划"]
+ B --> C{"type = ALL?"}
+ C -->|是| D["检查 possible_keys → 加索引"]
+ C -->|否| E{"Extra 有 filesort?"}
+ E -->|是| F["调整索引顺序,覆盖 ORDER BY"]
+ E -->|否| G{"Extra 有 temporary?"}
+ G -->|是| H["让 GROUP/DISTINCT 走索引"]
+ G -->|否| I{"rows 很大?"}
+ I -->|是→ rows × filtered% 仍大| J["优化 WHERE 条件或加更精确的索引"]
+ I -->|否| K["查询健康 ✅"]
+
+ style K fill:#00D866,color:#fff
+ style D fill:#C44569,color:#fff
+ style F fill:#FF9F43,color:#000
+ style H fill:#FF9F43,color:#000
+ style J fill:#FF9F43,color:#000
+```
+
+### 常见场景与对策
+
+| 症状 | 根因 | 对策 |
+|------|------|------|
+| `type=ALL, Extra=Using where` | 无可用索引 | 添加 WHERE 列的索引 |
+| `Using filesort` | ORDER BY 字段不在可用索引中 | 创建 (WHERE列, ORDER BY列) 联合索引 |
+| `Using temporary; Using filesort` | GROUP BY 和 ORDER BY 不一致 | 索引顺序同时满足两者 |
+| `rows 预估 >> 实际行数` | 统计信息过时 | `ANALYZE TABLE 表名` 更新统计信息 |
+| `possible_keys` 有但 `key` 为 NULL | 优化器选了全表扫描 | 用 `FORCE INDEX` 强制指定,或改写查询 |
+
+### 别忘了维护统计信息
+
+```sql
+-- 当 EXPLAIN 预估严重偏离实际时
+ANALYZE TABLE orders;
+
+-- InnoDB 支持自动分析,但大批量写入后建议手动触发
+SET GLOBAL innodb_stats_auto_recalc = ON;
+```
+
+## 关联笔记
+
+- [[hhs/MySQL/16-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
+- [[hhs/MySQL/19-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
diff --git a/hhs/MySQL/20-慢查询日志分析.md b/hhs/MySQL/20-慢查询日志分析.md
new file mode 100644
index 0000000..2af9508
--- /dev/null
+++ b/hhs/MySQL/20-慢查询日志分析.md
@@ -0,0 +1,366 @@
+---
+tags: [MySQL, 慢查询日志, pt-query-digest, 性能分析, EXPLAIN]
+create time: 2026-05-16 10:30
+---
+
+# 慢查询日志分析
+
+## 概述
+
+慢查询日志(Slow Query Log)是 MySQL 自带的性能诊断工具,记录超过指定时间的 SQL 语句。配合 `pt-query-digest` 等专业工具,可以系统地识别和优化慢查询,定位数据库性能瓶颈。
+
+> [!TIP] 核心思路
+> 慢查询日志本身只是"记录仪"——真正的价值在于**归因分析**。将日志中的原始数据聚合、分类、排名,才能把噪音变成可行动的信号。
+
+## 配置慢查询日志
+
+```ini
+# my.cnf
+[mysqld]
+slow_query_log = 1 # 开启
+slow_query_log_file = /var/log/mysql/slow.log
+long_query_time = 2 # 阈值(秒)
+min_examined_row_limit = 100 # 只记录扫描了至少 N 行的查询
+log_queries_not_using_indexes = 1 # 记录未使用索引的查询
+
+# 可选:实时捕获到表
+log_output = FILE,TABLE # 同时写入文件和 mysql.slow_log 表
+```
+
+```sql
+-- 运行时动态开启(无需重启)
+SET GLOBAL slow_query_log = 'ON';
+SET GLOBAL long_query_time = 1;
+SET GLOBAL log_queries_not_using_indexes = 'ON';
+
+-- 查看当前配置
+SHOW VARIABLES LIKE 'slow_query%';
+SHOW VARIABLES LIKE 'long_query_time';
+```
+
+> [!WARNING] 生产环境注意事项
+> - 不要盲目设置极低的 `long_query_time`(如 0.1s),否则会产生大量噪声。建议从 **1~5 秒**开始逐步下调。
+> - 开启 `log_queries_not_using_indexes` 会让所有无索引查询都被记录,即使它们只需要 0.01s。**谨慎启用**,最好配合 `min_examined_row_limit` 过滤无害查询。
+> - `TABLE` 模式会往 `mysql.slow_log` 写数据,需要评估写入开销。如果已有 FILE 模式,优先选 FILE。
+
+### min_examined_row_limit
+
+```sql
+-- 设置 1000 意味着:只扫描了 < 1000 行的查询不会被记录
+-- 过滤掉大量无害查询,让慢日志专注于真正有问题的 SQL
+SET GLOBAL min_examined_row_limit = 1000;
+```
+
+> [!NOTE] 为什么需要这个参数?
+> 一个看似"快"的查询可能扫描了大量行才找到目标数据——比如一次全表扫描回查 50 万行最终返回 1 条结果。这样的查询单看执行时间并不长(缓存命中时不到 0.1s),但放在高并发下就是 CPU 杀手。`min_examined_row_limit` 就是从**扫描行数**维度额外加了一层拦截。
+
+## 慢查询日志格式
+
+每一条慢查询由**元数据头 + 时间戳锚点 + SQL 体**三部分组成:
+
+```
+# Time: 2026-05-16T10:30:45.123456Z ← 执行时间(UTC)
+# User@Host: app_user[app_user] @ app-server-01 [10.0.1.50] Id: 12345 ← 哪个用户、哪台机器、连接 ID
+# Query_time: 3.456789 Lock_time: 0.000123 Rows_sent: 1 Rows_examined: 985432 ← 核心指标
+# Rows_affected: 0 ← 影响的行数(DML)
+# Bytes_received: 512 Bytes_sent: 65432 ← 网络传输量
+# Thread_id: 12345 Schema: app_db ← 线程 ID、所属数据库
+SET timestamp=1715848245; ← 锚点:SQL 实际执行时的 Unix 时间戳
+SELECT o.*, u.username FROM orders o ← SQL 语句本体
+JOIN users u ON o.user_id = u.id
+WHERE o.status = 'pending' AND o.amount > 100
+ORDER BY o.created_at DESC LIMIT 20;
+```
+
+### 关键字段含义
+
+| 字段 | 含义 | 关注点 |
+|------|------|--------|
+| **Query_time** | 从接收请求到返回结果的总耗时 | > `long_query_time` 即触发记录 |
+| **Lock_time** | 等待行锁/表锁的时间 | 占比过高 → 存在锁竞争 |
+| **Rows_sent** | 返回给客户端的行数 | 应用层真正收到的结果 |
+| **Rows_examined** | InnoDB 引擎扫描的索引+数据行数 | 与 `Rows_sent` 差距越大越危险 |
+| **Rows_affected** | INSERT/UPDATE/DELETE 实际变更的行数 | DML 操作的副作用评估 |
+
+> [!QUESTION] 如何判断查询效率是否健康?
+> ```
+> Rows_examined / Rows_sent < 10 → ✅ 合理——每条扫描的行大部分都返回了
+> Rows_examined / Rows_sent > 100 → ⚠️ 可疑——可能是全索引扫描或回查过多
+> Rows_examined / Rows_sent > 1000 → 🔴 严重低效——典型的全表扫描或错误的 WHERE 条件
+> ```
+> 理想情况是比值接近 1。但需注意:**对范围查询来说,这个比值高一些是正常的**(因为 B+Tree 范围内每页都要读)。
+
+#### Lock_time 专项解读
+
+```
+Lock_time 占 Query_time 的比例 诊断方向
+──────────────────────────────────────────
+< 1% 正常,无锁问题
+1% ~ 10% 轻度竞争,可接受
+> 10% 需要排查:是否有长事务或未命中索引
+> 50% 紧急:大概率死锁或锁升级
+```
+
+## mysqldumpslow 内置工具
+
+MySQL 自带的轻量级日志分析工具,适合快速查看 Top N:
+
+```bash
+# 最常用的参数组合:按执行时间排序,取前 10 条
+mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
+# -s t: 按 Query_time 排序;-t 10: 取前 10 条
+
+# 按扫描行数排序(关注"杀 CPU"的查询)
+mysqldumpslow -s r -t 10 /var/log/mysql/slow.log
+
+# 按出现频率排序(关注高频小额查询累积成的大问题)
+mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
+
+# 只看包含特定关键词的
+mysqldumpslow -s t -t 20 -g "order" /var/log/mysql/slow.log
+```
+
+### 输出解读
+
+```
+Count: 150 Time=3.50s (525s) Lock=0.00s (0s) Rows=1.0 (150), app_user@app-server-01
+ SELECT o.*, u.username FROM orders o JOIN users u ON o.user_id=u.id
+ WHERE o.status='pending' AND o.amount>100 ORDER BY o.created_at DESC LIMIT N
+```
+
+| 字段 | 含义 |
+|------|------|
+| **Count: 150** | 该模式在日志中出现了 150 次 |
+| **Time=3.50s** | 单次平均执行 3.5 秒 |
+| **(525s)** | 150 次累计占用 525 秒——这才是对生产真正造成伤害的数字 |
+| **Rows=1.0 (150)** | 每次返回约 1 行,累计 150 行 |
+
+> [!NOTE] Count × Average = Total 的意义
+> `Time=3.50s (525s)` 揭示了一个关键思路:**优化一个频繁执行的中等耗时查询,往往比优化一个极端耗时的罕见查询收益更大**。这就是为什么 `-s c`(按频率排序)有时能发现更隐蔽的性能问题。
+
+### 使用限制
+
+`mysqldumpslow` 的输出较为粗糙,它只能做**聚合统计**,无法提供:
+- SQL 指纹归因的详细分组(哪些参数值不同但 SQL 结构相同)
+- 趋势对比(和上次报告相比变化了多少)
+- HTML 可视化报告
+
+对于系统性诊断,推荐使用 `pt-query-digest`。
+
+## pt-query-digest(专业分析工具)
+
+Percona Toolkit 中最强大的 MySQL 分析工具,支持日志文件、Performance Schema、甚至直连生产库实时采样:
+
+```bash
+# 基本用法:输出完整分析报告
+pt-query-digest /var/log/mysql/slow.log
+
+# 按响应时间分组排名
+pt-query-digest --group-by latency /var/log/mysql/slow.log
+
+# 只分析报告 Top 5% 的查询(保留细节,去掉噪声)
+pt-query-digest --limit 5% /var/log/mysql/slow.log
+
+# 与历史基线对比(需提前用 --review/--history 采集数据)
+pt-query-digest --review D=perflive,h=localhost \
+ --history D=perflive,h=localhost \
+ /var/log/mysql/slow.log
+
+# 生成 HTML 可视化报告
+pt-query-digest --output=slowreport.html /var/log/mysql/slow.log
+
+# 分析最近 1 小时的慢查询
+pt-query-digest --since 1h /var/log/mysql/slow.log
+
+# 分析最近 30 分钟且执行时间 > 1s 的查询
+pt-query-digest --since 30m --filter '$event->{qt} > 1000000' /var/log/mysql/slow.log
+```
+
+### 输出解读框架
+
+`pt-query-digest` 的报告分为四个核心区域:
+
+```
+===== Profile (整体概况)
+ Rank Query ID Response time Calls R/Call V/M Item
+ ==== ================= ============== ===== ====== ===== ===========
+ 1 0xA1B2C3D4 525.1234 50.0% 150 3.5008 0.00 SELECT orders
+ 2 0xE5F6A7B8 180.5678 17.2% 50 3.6114 0.00 SELECT users
+
+===== Query 1: Hash = 0xA1B2C3D4
+ # 该查询模式的详细统计(P95, Q3, Q1 等百分位)
+ # Count: 150 → 出现次数
+ # Exec time: 1~8s → 单次执行时间范围
+ # Lock time: ... → 锁等待分布
+ # Rows sent: 1 avg → 返回行数(平均值/中位数/最大最小)
+
+ # EXPLAIN output → 执行计划(最关键部分!)
+ # The query is above that you can use EXPLAIN to analyze
+```
+
+| 区域 | 作用 |
+|------|------|
+| **Profile** | 快速了解哪些查询占了大部分资源——通常是 Pareto 20/80 规律 |
+| **Query N** | 单个查询模式的详细统计,包含执行计划 |
+| **Flattened** | SQL 归一化后的指纹,展示参数替换前后的差异 |
+
+> [!TIP] 标准诊断流程
+> 拿到 `pt-query-digest` 报告后:**先看 Profile 找出 Top 3 耗时查询 → 再逐条看其 EXPLAIN 输出 → 最后决定优化方向**。不要一开始就陷入某一条 SQL 的细节。
+
+### 从实时 Performance Schema 分析
+
+当慢查询日志未开启或已关闭时,可以直接从 MySQL 内部采集:
+
+```sql
+-- MySQL 5.7+ 启用 performance_schema
+SET GLOBAL performance_schema = ON;
+
+-- 清理已有数据(可选)
+TRUNCATE performance_schema.events_statements_summary_by_digest;
+
+-- 等业务跑一会儿后,用 pt-query-digest 直接分析
+pt-query-digest --processlist D=localhost,U=root \
+ --no-report /var/log/mysql/slow.log
+
+-- 或者直接用下面的方式从 Performance Schema 提取
+pt-query-digest --type processlist D=host:port,user,password
+```
+
+## 常见慢查询反模式
+
+即使有索引,SQL 写法不当也会导致索引失效。以下是生产中最常见的几种反模式:
+
+| 反模式 | 示例 | 为什么慢 | 优化方向 |
+|--------|------|----------|----------|
+| **前导通配符** | `WHERE name LIKE '%abc'` | 无法使用 B+Tree 前缀匹配,全表扫描 | 改用全文检索 (FULLTEXT) |
+| **隐式类型转换** | `WHERE phone = 13800138000` (phone 是 VARCHAR) | 字符型字段与数字比较触发隐式转换,索引失效 | 传参类型与列类型一致 |
+| **OR 条件未覆盖** | `WHERE idx_col = 1 OR other_col = 2` | 只有第一个字段有索引 | 拆分 UNION ALL 或用 BITMAP |
+| **函数包裹列名** | `WHERE YEAR(created_at) = 2025` | 对列计算函数导致索引失效 | 改为范围扫描:`created_at >= '2025-01-01' AND created_at < '2026-01-01'` |
+| **NOT IN / <>** | `WHERE id NOT IN (SELECT id FROM ...)`)` | 子查询难以走索引 | 改写为 LEFT JOIN ... IS NULL |
+| **SELECT \*** | `SELECT * FROM orders WHERE ...` | 回表额外开销 + 网络传输浪费 | 只查需要的列 |
+
+> [!QUESTION] 为什么 `SELECT *` 会拖慢查询?
+> 有两个原因:
+> 1. **回表**:如果用的是二级索引(非聚簇),`SELECT *` 需要拿着二级索引的值去聚簇索引回表取出所有列数据;而如果只 SELECT 了索引中包含的列,则可以直接"覆盖索引"取数,无需回表。
+> 2. **带宽浪费**:即使通过聚簇索引取数,多取的每列也会占用更多内存缓冲和网路传输时间。在高频查询场景下,每次省 2KB,一万次就是 20MB 的额外开销。
+
+## 慢查询优化工作流
+
+从发现慢查询到完成优化的完整闭环:
+
+```mermaid
+flowchart TD
+ A["收集慢查询日志"] --> B["分类归因
pt-query-digest / mysqldumpslow"]
+ B --> C{"定位 Top 瓶颈 SQL"}
+
+ C --> D["EXPLAIN 执行计划分析"]
+ D --> E{问题类型?}
+
+ E -- "type=ALL" --> F["缺索引 → ADD INDEX"]
+ E -- "Extra: Using filesort" --> G["调整联合索引顺序 / 覆盖索引"]
+ E -- "Extra: Using temporary" --> H["改写 SQL,避免 GROUP BY 临时表"]
+ E -- "rows 过大但 sent 很小" --> I["检查 WHERE 条件是否命中索引"]
+ E -- "无锁等待但耗时高" --> J["排查深层原因:深分页/大事务/函数包裹列"]
+ E -- "Lock_time 占比高" --> K["排查死锁 / 长事务 / 行锁竞争"]
+
+ F --> L["优化后验证"]
+ G --> L
+ H --> L
+ I --> L
+ J --> L
+ K --> L
+
+ L --> M{"效果达标?"}
+ M -- "是" --> N["🎉 上线 + 接入监控告警"]
+ M -- "否" --> D
+```
+
+### 步骤详解
+
+#### Step 1: 收集与分组
+
+通过 `pt-query-digest` 将原始日志中的千条记录压缩为几十条"SQL 指纹"。每一条指纹代表一个查询模式,忽略参数差异(如 `'123'` vs `'456'`),聚焦结构。
+
+#### Step 2: 定位 TOP N
+
+按以下优先级排序关注对象:
+
+| 场景 | 排序方式 | 适用情况 |
+|------|----------|----------|
+| **总耗时最大** | `-s t`(总时间) | 找最拖后腿的 SQL |
+| **最常见** | `-s c`(频率) | 高频小额查询累积成痛 |
+| **扫描行数最多** | `-s r`(Rows_examined) | CPU 杀手型查询 |
+
+#### Step 3: EXPLAIN 诊断
+
+对 Top 3 的查询逐条执行 `EXPLAIN FORMAT=JSON ...` 获取结构化执行计划:
+
+```sql
+EXPLAIN FORMAT=JSON SELECT * FROM orders
+WHERE user_id = 100 AND status = 'pending'
+ORDER BY created_at DESC LIMIT 10;
+```
+
+重点关注 JSON 输出中的:
+- `table->access_type`(扫描方式:ALL / range / ref / index)
+- `table->key`(实际使用的索引)
+- `table->rows`(预估扫描行数)
+- `query_block->filesort` / `temporary_table` 是否存在
+
+更详细的 EXPLAIN 解读见 [[hhs/MySQL/19-EXPLAIN 完全指南]]。
+
+#### Step 4: 针对性优化
+
+根据诊断结果选择优化策略:
+
+```mermaid
+flowchart LR
+ subgraph "索引类优化"
+ A1["补充缺失索引"] --> A3["验证:type 从 ALL→ref/range"]
+ A2["调整联合索引顺序"] --> A3
+ A4["创建覆盖索引"] --> A5["Extra 去掉 Using filesort/index"]
+ end
+
+ subgraph "SQL 改写"
+ B1["LIKE '%xxx' → FULLTEXT"] --> B3["验证:rows↓, time↓"]
+ B2["YEAR(col) → range 扫描"] --> B3
+ B3["NOT IN → LEFT JOIN IS NULL"] --> B3
+ end
+
+ subgraph "架构类优化"
+ C1["深分页 OFFSET → 延迟关联"] --> C3["验证:P95 latency ↓"]
+ C2["大事务拆小"] --> C3
+ C3["读写分离 / 缓存"] --> C3
+ end
+```
+
+详见:
+- [[hhs/MySQL/21-查询改写技巧]] — 常见 SQL 改写方案
+- [[hhs/MySQL/22-深分页优化]] — OFFSET 深分页的替代方案
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引设计原则
+
+#### Step 5: 回归验证 & 监控接入
+
+优化不是一次性动作,需要建立持续监控机制:
+
+> [!TIP] 最小可落地的监控方案
+> ```sql
+> -- 定期从 Performance Schema 拉取 Top SQL
+> SELECT DIGEST_TEXT,
+> ROUND(SUM_TIMER_WAIT/1e12, 2) AS total_ms,
+> COUNT_STAR AS calls,
+> ROUND(AVG_TIMER_WAIT/1e12, 2) AS avg_ms
+> FROM performance_schema.events_statements_summary_by_digest
+> ORDER BY total_ms DESC
+> LIMIT 20;
+> ```
+> 将此查询放入定时任务(如每 5 分钟),用 Grafana 或 Prometheus 做可视化告警。
+
+## 关联笔记
+
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — EXPLAIN 各字段的详细解读与执行计划分析
+- [[hhs/MySQL/21-查询改写技巧]] — SQL 反模式改写方案
+- [[hhs/MySQL/22-深分页优化]] — OFFSET 深分页的替代方案
+- [[hhs/MySQL/16-B+Tree 索引原理]] — 索引底层结构,理解为何某些写法会让索引失效
+- [[hhs/MySQL/26-锁机制总览]] — 行锁/表锁/间隙锁,排查 Lock_time 偏高问题
+- [[hhs/MySQL/40-常见踩坑]] — MySQL 日常使用中的典型陷阱
diff --git a/hhs/MySQL/21-查询改写技巧.md b/hhs/MySQL/21-查询改写技巧.md
new file mode 100644
index 0000000..5625b1f
--- /dev/null
+++ b/hhs/MySQL/21-查询改写技巧.md
@@ -0,0 +1,335 @@
+---
+tags: [MySQL, 查询优化, 重写, 性能调优]
+create time: 2026-05-16 00:00
+---
+
+# 查询改写技巧
+
+## 概述
+
+同样的业务需求可能有多种 SQL 写法,性能差异可达几十倍。本章总结常用的 SQL 改写模式,帮助你将「能跑的 SQL」变成「跑得快的 SQL」。
+
+## 1. OR → UNION ALL
+
+```sql
+-- ❌ 差:两个列各自独立,OR 会导致两边都无法走索引
+SELECT * FROM users
+WHERE email = 'alice@example.com' OR phone = '13800138000';
+-- EXPLAIN: type=ALL, Rows=1000000 → 全表扫描
+
+-- ✅ 好:拆成两次索引查询 + UNION ALL
+SELECT * FROM users WHERE email = 'alice@example.com'
+UNION ALL
+SELECT * FROM users WHERE phone = '13800138000';
+-- 两次独立的 ref 查询,各走一个索引
+```
+
+> [!TIP] 什么时候不该改?
+> - 如果 OR 两边的列在**同一个联合索引**中,原写法可以利用最左前缀
+> - 如果结果集非常小(几行),优化器可能自行选择最优方案
+
+## 2. JOIN → EXISTS
+
+```sql
+-- ❌ 存在重复行的风险
+SELECT DISTINCT u.* FROM users u
+JOIN orders o ON u.id = o.user_id
+WHERE o.amount > 1000;
+-- 一个用户有多张大额订单时会重复,DISTINCT 又带来排序开销
+
+-- ✅ 改用 EXISTS(找到第一条就停止)
+SELECT u.* FROM users u
+WHERE EXISTS (
+ SELECT 1 FROM orders o
+ WHERE o.user_id = u.id AND o.amount > 1000
+);
+```
+
+> [!NOTE] MySQL Optimizer 的智能
+> 现代 MySQL(8.0+)的优化器已经足够聪明,经常能把 JOIN 和 EXISTS 转换为相同的执行计划。但显式使用 EXISTS 语义更清晰,且在某些复杂场景下确实能引导优化器选更好的路径。
+
+## 3. 子查询 → JOIN
+
+```sql
+-- ❌ 标量子查询可能导致 N+1
+SELECT u.username,
+ (SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total_spent
+FROM users u;
+
+-- ✅ 换成 GROUP BY + JOIN
+SELECT u.username, COALESCE(SUB.total_spent, 0) AS total_spent
+FROM users u
+LEFT JOIN (
+ SELECT user_id, SUM(amount) AS total_spent
+ FROM orders GROUP BY user_id
+) SUB ON u.id = SUB.user_id;
+```
+
+```mermaid
+flowchart TD
+ subgraph "子查询方案"
+ A1["10万用户"] --> A2["每个用户查一次 SUM()"]
+ A2 --> A3["总计 10万次扫描 orders 表"]
+ end
+
+ subgraph "JOIN 方案"
+ B1["1次 GROUP BY orders"] --> B2["1000个汇总结果"]
+ B2 --> B3["10万次 LEFT JOIN 1000行
成本极低"]
+ end
+
+ style A3 fill:#EE5A24,color:#fff
+ style B3 fill:#00D866,color:#fff
+```
+
+## 4. 避免函数包裹索引列
+
+```sql
+-- ❌ YEAR() / DATE() 包裹索引列 → 索引失效
+SELECT * FROM orders
+WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 5;
+-- EXPLAIN: type=ALL, Rows=1000000
+
+-- ✅ 改用范围查询 → 走索引
+SELECT * FROM orders
+WHERE created_at >= '2026-05-01'
+ AND created_at < '2026-06-01';
+```
+
+```mermaid
+flowchart LR
+ Func["WHERE YEAR(col) = 2026"] --> F1["每一行都要计算 YEAR()"]
+ F1 --> F2["无法利用 B+ Tree 二分查找"]
+ F2 --> F3["全表扫描 ALL"]
+
+ Range["WHERE col >= '2026-01-01' AND col < '2027-01-01'"] --> R1["直接在 B+ Tree 中定位范围"]
+ R1 --> R2["索引范围扫描 range"]
+ R2 --> R3["O(log N) 复杂度"]
+
+ style F3 fill:#EE5A24,color:#fff
+ style R3 fill:#00D866,color:#fff
+```
+
+## 5. 分页优化:延迟关联 + 游标分页
+
+### 5.1 延迟关联(Deferred Join)
+
+```sql
+-- ❌ 深分页灾难
+SELECT * FROM articles
+WHERE status = 1
+ORDER BY created_at DESC
+LIMIT 999990, 10;
+-- 扫描聚簇索引跳过 999990 行,每行都拉完整数据
+
+-- ✅ 延迟关联:子查询只扫主键,外层精准 JOIN
+SELECT a.* FROM articles a
+INNER JOIN (
+ SELECT id FROM articles
+ WHERE status = 1
+ ORDER BY created_at DESC
+ LIMIT 999990, 10
+) tmp ON a.id = tmp.id;
+-- 子查询只扫主键(极紧凑),外层精准 JOIN 10 条
+```
+
+```mermaid
+flowchart LR
+ subgraph "传统方式"
+ A1["扫描 999990 行完整数据"] --> A2["丢弃 999990 行"]
+ A2 --> A3["返回 10 行"]
+ end
+
+ subgraph "延迟关联"
+ B1["子查询扫 999990 行
仅提取主键(8 bytes)"] --> B2["精准 JOIN 10 个主键"]
+ B2 --> B3["返回 10 行完整数据"]
+ end
+
+ A3 ---|"I/O 减少 90%+"| B3
+
+ style A1 fill:#EE5A24,color:#fff
+ style B1 fill:#00B6BC,color:#fff
+ style B3 fill:#00D866,color:#fff
+```
+
+### 5.2 游标分页(Keyset Pagination)
+
+延迟关联能缓解,但**不是根治方案**。当 offset 极大时仍要遍历大量行。更优解是**基于上一页最后一个 ID 定位下一页起点**。
+
+```sql
+-- ❌ offset 越大越慢,且并发翻页可能丢数据
+SELECT * FROM articles ORDER BY id DESC LIMIT 100000, 10;
+
+-- ✅ 游标分页:记住上一页最后一条的 id
+SELECT * FROM articles
+WHERE id < 999980 -- 上一页最后一条的 id
+ORDER BY id DESC
+LIMIT 10;
+```
+
+> [!TIP] Go 应用层写法
+> ```go
+> // 第一页:无游标
+> db.Order("id DESC").Limit(10).Find(&articles)
+>
+> // 后续页:用最后一条的 ID 做游标
+> lastID := articles[len(articles)-1].ID
+> db.Where("id < ?", lastID).Order("id DESC").Limit(10).Find(&articles)
+> ```
+
+> [!WARNING] 注意事项
+> - 要求排序列有**唯一索引**(如自增主键)。如果排序字段不唯一(如 `created_at`),需加辅助条件:`WHERE (created_at, id) < (?, ?)` 利用联合索引最左前缀。
+> - 前端无法直接跳到第 N 页——这是合理的 UX 约束。现代平台(Twitter、Instagram)均采用「加载更多」而非页码导航。
+
+## 6. 避免 SELECT * 陷阱
+
+```sql
+-- ❌ 拉取不需要的列:浪费网络带宽 + 无法使用 Covering Index
+SELECT * FROM orders WHERE user_id = 42;
+
+-- ✅ 只查需要的列 → 可能变成 Covering Index(完全不碰数据行)
+SELECT id, amount, status FROM orders WHERE user_id = 42;
+```
+
+> [!TIP] 为什么指定列更快?
+> - InnoDB 聚簇索引叶子节点存整行数据。如果查询的列都在辅助索引中,MySQL 直接从辅助索引取数(**Using index**),不需要回表。
+> - 网络传输的数据量也显著降低——去掉 TEXT/BLOB 列可能从 MB 级降到 KB 级。
+
+## 7. LIKE 与全文搜索
+
+```sql
+-- ❌ 左模糊匹配导致全表扫描
+SELECT * FROM articles WHERE title LIKE '%MySQL%';
+-- EXPLAIN: type=ALL
+
+-- ✅ 前缀模糊走索引
+SELECT * FROM articles WHERE title LIKE 'MySQL%';
+
+-- ✅ 大文本搜索用全文索引(FULLTEXT)
+ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);
+SELECT * FROM articles
+WHERE MATCH(title) AGAINST('MySQL' IN NATURAL LANGUAGE MODE);
+```
+
+> [!NOTE] LIKE vs FULLTEXT
+> - `LIKE 'prefix%'` 走 B+ Tree 范围扫描,适合精确前缀匹配
+> - `FULLTEXT` 支持分词、相关性排序( relevance ranking),适合搜索引擎场景
+> - MySQL 的 FULLTEXT 对中文效果有限(空格分隔分词器不适用于中文),中文场景建议对接 Elasticsearch
+
+## 8. 近似聚合:SUM(COL) vs SUM(1)
+
+> [!QUESTION] 下面两行 SQL 有什么区别?
+> ```sql
+> -- Q1: 这两行结果一样吗?哪个更快?
+> SELECT SUM(status = 1) FROM orders;
+> SELECT SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) FROM orders;
+> ```
+> **答案**: 结果相同,但写法 1 更简洁且执行效率略高。MySQL 将布尔表达式视为 0/1 整数,`SUM(TRUE)` 等价于计数。
+
+```sql
+-- ✅ 简洁写法:布尔表达式直接参与算术
+SELECT
+ SUM(status = 'pending') AS pending,
+ SUM(status = 'shipped') AS shipped,
+ SUM(status = 'completed') AS completed
+FROM orders;
+
+-- 等价于多 CASE WHEN 写法,但更短、可读性更好
+```
+
+> [!WARNING] NULL 处理差异
+> - `SUM(col = val)` 在条件不满足时返回 0,在列为 NULL 时也返回 NULL(即 0+NULL=NULL)
+> - 如果需要对 NULL 安全计数,用 `SUM(CASE WHEN col = val THEN 1 ELSE 0 END)` 或加 `COALESCE`
+
+## 9. COUNT 优化策略
+
+### 9.1 利用索引覆盖计数
+
+```sql
+-- ❌ 无索引:全表扫描
+SELECT COUNT(*) FROM large_table WHERE status = 1;
+-- EXPLAIN: type=ALL → 遍历每一行判断
+
+-- ✅ 建索引后:只扫索引树
+ALTER TABLE large_table ADD INDEX idx_status (status);
+SELECT COUNT(*) FROM large_table WHERE status = 1;
+-- EXPLAIN: type=range, key=idx_status, Extra=Using where; Using index
+```
+
+### 9.2 近似计数(可容忍误差)
+
+| 方案 | 精度 | 适用场景 |
+|------|------|----------|
+| `SHOW TABLE STATUS` | 低(缓存导致偏差) | MyISAM 快速估算 |
+| 随机采样 × 膨胀系数 | 中 | 运营后台 Dashboard |
+| `EXPLAIN table WHERE ...` | 较高 | 预估行数范围 |
+
+> [!NOTE] InnoDB 的 COUNT(*)
+> InnoDB 没有维护表级别行数(因为有 MVCC,不同事务看到的行数可能不同)。`COUNT(*)` 需要实际扫描。对于高并发大表,考虑**异步统计表**或**Redis 计数器**。
+
+## 10. INSERT 批量优化
+
+```sql
+-- ❌ 逐条插入(N 次网络往返 + N 次事务提交)
+INSERT INTO orders (...) VALUES (...);
+INSERT INTO orders (...) VALUES (...);
+INSERT INTO orders (...) VALUES (...);
+
+-- ✅ 批量 INSERT(1 次网络往返 + 1 次事务)
+INSERT INTO orders (...) VALUES (...), (...), (...);
+
+-- ✅ Go 中分批次提交
+for i := 0; i < len(items); i += 500 {
+ batch := items[i : min(i+500, len(items))]
+ db.Model(&Order{}).Create(batch)
+}
+```
+
+## 11. CASE WHEN 代替多次查询
+
+```sql
+-- ❌ 应用层多次查询
+countPending = db.Where("status='pending'").Count()
+countShipped = db.Where("status='shipped'").Count()
+countCompleted = db.Where("status='completed'").Count()
+
+-- ✅ 单次查询 + 服务端处理
+SELECT
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
+ SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped,
+ SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
+FROM orders;
+```
+
+## 改写原则总结
+
+```mermaid
+mindmap
+ root((SQL 改写
核心原则))
+ 早过滤
+ WHERE 先行
+ 减少中间结果
+ 走索引
+ 避免函数包裹列
+ OR 拆 UNION ALL
+ LIKE 前缀匹配
+ 少回表
+ Covering Index
+ SELECT 指定列(非*)
+ 分批量
+ 批量 INSERT
+ 延迟关联
+ 游标分页
+ 换思路
+ EXISTS vs JOIN
+ CASE WHEN vs 多次查询
+ 子查询转 JOIN
+```
+
+## 关联笔记
+
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
+- [[hhs/MySQL/20-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
+- [[hhs/MySQL/22-深分页优化]] — 游标分页的专项深入
+- [[hhs/MySQL/16-B+Tree 索引原理]] — 理解索引走与否的根本原因
+- [[hhs/MySQL/40-常见踩坑]] — 生产环境中的典型错误写法合集
+- [[hhs/GORM/15-性能优化]] — GORM 层的批量操作优化
diff --git a/hhs/MySQL/22-深分页优化.md b/hhs/MySQL/22-深分页优化.md
new file mode 100644
index 0000000..d27fe3a
--- /dev/null
+++ b/hhs/MySQL/22-深分页优化.md
@@ -0,0 +1,281 @@
+---
+tags: [MySQL, 分页优化, 游标分页, 延迟关联]
+create time: 2026-05-16 00:00
+---
+
+# 深分页优化
+
+## 概述
+
+当 OFFSET 达到几十万、百万级别时,`LIMIT offset, size` 的性能会急剧下降。这不仅是 MySQL 的问题,而是所有关系型数据库的通病。本章提供三种成熟的解决方案。
+
+## 问题复现
+
+```sql
+-- 典型的深分页查询
+SELECT * FROM orders
+ORDER BY created_at DESC
+LIMIT 999990, 20;
+
+-- 发生了什么?
+-- 1. MySQL 从聚簇索引开始扫描
+-- 2. 逐行读取并排序(或使用 order by 索引)
+-- 3. 跳过前 999990 行
+-- 4. 取出接下来的 20 行
+-- 5. 丢弃前面跳过的所有行
+```
+
+```mermaid
+flowchart LR
+ A["Page 1: LIMIT 0, 20"] -->|"扫描 20 行,取 20 行"| T1["✅ 很快"]
+ B["Page 1000: LIMIT 19980, 20"] -->|"扫描 20000 行,取 20 行"| T2["⚠️ 慢"]
+ C["Page 50000: LIMIT 999990, 20"] -->|"扫描 ~100万行,取 20 行"| T3["🔴 极慢"]
+
+ style T1 fill:#00D866,color:#fff
+ style T2 fill:#FF9F43,color:#000
+ style T3 fill:#EE5A24,color:#fff
+```
+
+> [!QUESTION] 为什么不能像编程语言那样直接从数组索引取值?
+> 数据库不是内存数组。每一次 `LIMIT offset, N` 都需要从 B+ Tree 根部重新定位到第 offset 行——这是一次完整的扫描 + 排序操作。偏移量越大,越像是在浩瀚星海中逐粒计数沙子。
+
+## 方案一:延迟关联(Deferred Join)
+
+```sql
+-- 核心思路:先用紧凑的主键索引定位 ID,再回表查数据
+-- 关键:必须使用 (created_at, id) 联合索引,确保排序可覆盖
+SELECT o.* FROM orders o
+INNER JOIN (
+ SELECT id FROM orders
+ ORDER BY created_at DESC, id DESC -- 用主键做稳定排序的 tie-breaker
+ LIMIT 999990, 20
+) tmp ON o.id = tmp.id;
+```
+
+> [!QUESTION] 为什么需要 `id DESC` 作为第二排序条件?
+> 如果多条记录具有相同的 `created_at`(比如同一秒内多个用户下单),仅按 `created_at` 排序的结果是不确定的——每次查询行顺序可能不同。加上主键作为 tie-breaker 可以**保证排序稳定性**,避免翻页时出现重复或遗漏的数据。
+
+> [!TIP] 索引前提
+> - 子查询必须是**覆盖索引扫描**:`(created_at, id)` 联合索引足以满足 `ORDER BY` + `LIMIT`,无需回表
+> - 如果没有此索引,子查询本身也会退化为慢查询
+
+```mermaid
+sequenceDiagram
+ participant SQ as 子查询
+ participant PK as 聚簇索引(id)
+ participant Main as 主查询
+
+ SQ->>PK: LIMIT 999990, 20 只取 id
+ PK-->>SQ: 返回 20 个 ID(各 8 bytes)
+
+ loop 20 个 ID
+ Main->>PK: SELECT * WHERE id = ?
+ PK-->>Main: 精准返回完整行
+ end
+```
+
+### 性能对比
+
+| 指标 | 传统方式 | 延迟关联 |
+|------|---------|---------|
+| 扫描行数 | ~1,000,000 行完整数据 | 1,000,000 行仅主键 (8 bytes) |
+| 回表次数 | 1,000,000 次(隐式) | 20 次(精准) |
+| 内存占用 | 1,000,000 × 行大小 | 20 × 8 bytes |
+| 典型耗时 | 3~10 秒 | 0.1~0.3 秒 |
+
+```go
+// Go 中实现延迟关联的分页 helper
+func PaginatedQuery(db *gorm.DB, page, pageSize int) ([]Order, error) {
+ offset := (page - 1) * pageSize
+
+ // 子查询:只查主键(覆盖索引扫描)
+ var ids []int64
+ if err := db.Model(&Order{}).
+ Select("id").
+ Order("created_at DESC, id DESC").
+ Limit(pageSize).
+ Offset(offset).
+ Pluck("id", &ids).Error; err != nil {
+ return nil, err
+ }
+ if len(ids) == 0 {
+ return []Order{}, nil
+ }
+
+ // 主查询:IN 精确查完整数据
+ var results []Order
+ if err := db.Where("id IN ?", ids).Find(&results).Error; err != nil {
+ return nil, err
+ }
+ return results, nil
+}
+```
+
+> [!WARNING] 延迟关联的限制
+> - `ORDER BY` 必须能用索引覆盖,否则子查询本身就很慢
+> - `IN` 列表过大时(比如 > 1000 条)也会退化
+> - 如果每行数据很小(< 50 bytes),收益有限
+
+## 方案二:游标分页(Seek Method / Keyset Pagination)
+
+```sql
+-- 首次查询(第一页)
+SELECT * FROM orders
+ORDER BY id ASC
+LIMIT 20;
+
+-- 下一页:取上一页最后一条记录的 id
+SELECT * FROM orders
+WHERE id > 987654 -- 上一页最后一条的 id
+ORDER BY id ASC
+LIMIT 20;
+
+-- 再下一页
+SELECT * FROM orders
+WHERE id > 987674 -- 这次最后一条的 id
+ORDER BY id ASC
+LIMIT 20;
+```
+
+```mermaid
+flowchart TD
+ Page1["第一页: WHERE id > 0 LIMIT 20
获取最后一条 id = 10001"] --> Page2
+ Page2["第二页: WHERE id > 10001 LIMIT 20
获取最后一条 id = 10021"] --> Page3
+ Page3["第三页: WHERE id > 10021 LIMIT 20"]
+
+ Page1 -->|"O(1) 索引范围扫描"| R1["✅ 恒定速度"]
+ Page2 -->|"O(1) 索引范围扫描"| R2["✅ 恒定速度"]
+ Page3 -->|"O(1) 索引范围扫描"| R3["✅ 恒定速度"]
+
+ style R1 fill:#00D866,color:#fff
+ style R2 fill:#00D866,color:#fff
+ style R3 fill:#00D866,color:#fff
+```
+
+### Go 实现
+
+```go
+// Cursor-based pagination with stable sort
+type CursorResult struct {
+ Items []Order
+ NextCursor *string // nil = 最后一页
+ PrevCursor *string // nil = 第一页
+}
+
+func GetOrdersByCursor(db *gorm.DB, afterID, beforeID *int64, limit int, desc bool) (*CursorResult, error) {
+ q := db.Model(&Order{}).Limit(limit + 1)
+
+ // 稳定排序:主键确保顺序一致
+ if desc {
+ q.Order("id DESC")
+ if afterID != nil {
+ q = q.Where("id < ?", *afterID) // 上一页最后一条的 id
+ }
+ } else {
+ q.Order("id ASC")
+ if afterID != nil {
+ q = q.Where("id > ?", *afterID)
+ }
+ }
+
+ var items []Order
+ if err := q.Find(&items).Error; err != nil {
+ return nil, err
+ }
+
+ result := &CursorResult{}
+ hasNext := len(items) > limit
+ hasPrev := true // 查了 limit+1 条就说明前面还有数据
+
+ if hasNext {
+ items = items[:limit]
+ } else {
+ hasPrev = len(items) > limit/2 // 半经验判断:不足半页说明接近头部
+ }
+
+ // 生成 cursor token
+ if len(items) > 0 {
+ lastID := items[len(items)-1].ID
+ firstID := items[0].ID
+ if hasNext {
+ nextVal := fmt.Sprintf("%d", lastID)
+ result.NextCursor = &nextVal
+ }
+ if hasPrev && (beforeID == nil || *beforeID == 0) {
+ prevVal := fmt.Sprintf("%d", firstID)
+ result.PrevCursor = &prevVal
+ }
+ }
+
+ result.Items = items
+ return result, nil
+}
+```
+
+> [!TIP] 游标的高级用法
+> - **复合排序**:当需要按非唯一字段排序时,用 `WHERE (created_at, id) > (?, ?)` 实现 tuple 比较——MySQL 支持行值的字典序比较
+> - **双向翻页**:同时返回 `nextCursor` 和 `prevCursor`,前端无需维护额外状态
+> - **Token 编码**:生产环境中建议将 cursor 加密或签名(如 JWT),防止用户篡改
+
+```sql
+-- 按创建时间 + ID 排序的游标翻页(tuple 比较)
+-- 上一页最后一条: created_at = '2026-05-15', id = 987654
+SELECT * FROM orders
+WHERE (created_at, id) < ('2026-05-15 00:00:00', 987654)
+ORDER BY created_at DESC, id DESC
+LIMIT 20;
+```
+
+### 优缺点对比
+
+| | 游标分页 | 延迟关联 | OFFSET 分页 |
+|--|---------|---------|------------|
+| 性能 | 🏆 恒定 O(1) | ⭐ 好 | 🔴 随偏移量变差 |
+| 支持跳页 | ❌ 不支持 | ✅ 支持 | ✅ 支持 |
+| 前端改造 | 传 cursor | 传 offset | 传 offset |
+| ORDER BY 要求 | 必须是有序主键/唯一键 | 可接受普通索引 | 任何 ORDER BY |
+| 适用场景 | 无限滚动 / 瀑布流 | 后台管理 / Excel 导出 | 小偏移量 (< 1万) |
+
+## 方案三:限制最大页码
+
+最朴素的方案——从业务层面限制深度,从根本上消除深分页问题:
+
+```go
+// 前端 UI 限制:瀑布流最多加载 100 页
+const MaxPage = 100
+
+func PaginatedQuery(w http.ResponseWriter, r *http.Request) {
+ page := parsePage(r)
+ if page > MaxPage {
+ respondError(w, 400, "结果太多,请添加筛选条件缩小范围")
+ return
+ }
+ // ...正常查询
+}
+```
+
+```mermaid
+flowchart TD
+ A["请求翻页"] --> B{page <= MAX?}
+ B -->|是| C["正常查询"]
+ B -->|否| D["提示: 请添加筛选条件"]
+ D --> E["用户加筛选 ↓"]
+ E --> B
+
+ style C fill:#00D866,color:#fff
+ style D fill:#FF9F43,color:#000
+```
+
+> [!TIP] 实际工程建议
+> - **前台展示**(商品列表、文章列表):用游标分页,用户体验最好
+> - **后台管理**(运营后台、报表导出):允许 OFFSET 但限制最大页码 + 提供筛选条件
+> - **定时任务**(数据同步):用主键范围扫描 `WHERE id > last_id AND id <= last_id + 1000`
+> - **缓存策略**:热点分页数据可配合 Redis SET 或 Bloom Filter 预计算页码边界
+>
+> 核心思想:**不要让 OFFSET 成为决定性能的唯一变量**。最好的方案往往是结合业务场景的组合拳。
+
+## 关联笔记
+
+- [[hhs/MySQL/12-DQL SELECT 全解析]] — LIMIT 基础语法与 OFFSET 的定义
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 分页查询的 EXPLAIN 分析
+- [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与各方案的 Go 实现
diff --git a/hhs/MySQL/23-ACID 与原子性实现.md b/hhs/MySQL/23-ACID 与原子性实现.md
new file mode 100644
index 0000000..49006d8
--- /dev/null
+++ b/hhs/MySQL/23-ACID 与原子性实现.md
@@ -0,0 +1,296 @@
+---
+tags: [MySQL, ACID, Redo Log, Undo Log, durability, binlog, MVCC]
+create time: 2026-05-16 00:00
+---
+
+# ACID 与原子性实现
+
+## 概述
+
+ACID 是事务的四大特性——Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。本节聚焦原子性和持久性的底层实现机制,回答「MySQL 如何保证你提交的修改不会丢失、回滚的操作真的撤销了」。
+
+## ACID 全景
+
+```mermaid
+flowchart LR
+ A["Atomicity
原子性"] -->|"Undo Log 回滚"| T1["要么全部完成
要么全部不做"]
+
+ C["Consistency
一致性"] -->|"约束 + 事务组合"| T2["数据库从一个合法状态到另一个合法状态"]
+
+ I["Isolation
隔离性"] -->|"MVCC + Lock"| T3["并发事务互不干扰"]
+
+ D["Durability
持久性"] -->|"Redo Log 刷盘"| T4["一旦提交永不丢失"]
+
+ style A fill:#C44569,color:#fff
+ style D fill:#EE5A24,color:#fff
+```
+
+> [!NOTE] Consistency 是目标,其他三者是手段
+> Atomicity、Isolation、Durability 是 InnoDB 提供的技术保障,而 Consistency(业务层面的一致性)需要应用层通过合理的事务设计来实现。InnoDB 保证你提交的 SQL 不会丢、不会乱,但「转账后余额对不对」是你的责任。
+
+### Consistency —— 一致性如何落地
+
+Consistency 不是靠某个单独的日志机制实现的,而是多种约束机制叠加的结果:
+
+| 层级 | 约束类型 | 示例 | 强制时机 |
+|------|----------|------|----------|
+| **表级** | PRIMARY KEY | `id INT AUTO_INCREMENT PRIMARY KEY` | INSERT / UPDATE |
+| | UNIQUE | `email VARCHAR(255) UNIQUE` | INSERT / UPDATE |
+| | NOT NULL | `balance DECIMAL(10,2) NOT NULL` | INSERT / UPDATE |
+| | FOREIGN KEY | `user_id INT REFERENCES users(id)` | INSERT / UPDATE / DELETE |
+| | CHECK | `balance >= 0` (MySQL 8.0.16+) | INSERT / UPDATE |
+| **行级** | Row locks | `SELECT ... FOR UPDATE` | 查询时持锁 |
+| **事务级** | SERIALIZABLE 隔离级别 | 整个事务串行化执行 | 事务执行期间 |
+
+```sql
+-- 约束是 Consistency 的第一道防线
+CREATE TABLE accounts (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ user_id INT NOT NULL,
+ balance DECIMAL(10,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
+ UNIQUE KEY uk_user (user_id),
+ CONSTRAINT fk_accounts_user FOREIGN KEY (user_id) REFERENCES users(id)
+);
+
+-- 即使有约束,业务一致性仍需事务保障
+START TRANSACTION;
+ UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- A 转出
+ UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- B 转入
+COMMIT;
+```
+
+> [!QUESTION] 如果第二条 UPDATE 失败了怎么办?
+> 单条 UPDATE 失败只影响本条语句,但如果放在事务中,整个事务可以 ROLLBACK —— 这正是原子性为一致性保驾护航的场景。**三者的关系:原子性是手段,一致性是结果。**
+
+---
+
+## Redo Log —— 保证持久性
+
+Redo Log(重做日志)是 InnoDB 特有的**物理日志**,记录的是「在哪个数据页的什么位置改了什么字节」。
+
+### Redo Log 的工作流程
+
+```mermaid
+sequenceDiagram
+ participant TX as 事务 A
+ participant BP as Buffer Pool
+ participant RL as Redo Log Buffer
+ participant Disk as Redo Log File(磁盘)
+
+ TX->>BP: UPDATE accounts SET balance=100 WHERE id=1
+ Note over BP: 找到数据页 → 修改内存中的值
→ 标记为脏页(dirty page)
+
+ BP->>RL: 写入 Redo Log entry
(LSN+1)
+
+ alt "innodb_flush_log_at_trx_commit = 1"
+ RL->>Disk: sync() 强制刷盘 ✅
+ else innodb_flush_log_at_trx_commit = 2
+ RL->>OS: 写入 OS Page Cache
+ Note over Disk: 每秒由后台线程刷盘
+ end
+
+ TX->>TX: COMMIT 返回成功
+```
+
+### LSN (Log Sequence Number)
+
+每条 Redo Log 都有一个单调递增的 LSN,用于追踪和排序:
+
+```go
+// InnoDB 内部数据结构伪代码
+type redo_log_entry struct {
+ lsn uint64 // 全局唯一的日志序号
+ page_id uint32 // 被修改的数据页编号
+ offset uint16 // 页内的偏移量
+ data []byte // 实际修改的字节(如: old_balance=500 → new_balance=100)
+}
+```
+
+LSN 是 InnoDB 中最核心的序列号之一,它贯穿了 **Redo Log、Undo Log、数据页、Check Point** 等所有涉及「顺序」的场景。比较两个 LSN 就能判断哪个操作先发生——崩溃恢复全靠它排定先后。
+
+> **理解关键**:Redo Log 记录的不是「把 balance 改成 100」这种 SQL 语义,而是「第 3 号数据页第 2048 字节,从 `0x1F4` 改成了 `0x64`」。这就是为什么叫物理日志 —— 恢复时直接按字节写回即可,不需要解析 SQL,也不需要重新做权限检查、约束校验。
+
+### Redo Log 的两个关键属性
+
+| 属性 | 说明 | 影响 |
+|------|------|------|
+| **循环写入** | 大小固定,写满后从头覆盖 | 不会无限增长占满磁盘 |
+| **物理日志** | 记录页级别修改而非 SQL | 恢复速度快,不需要重执行 SQL |
+
+```ini
+# 配置文件
+innodb_log_file_size = 512M # 每个 log file 大小,两个共 1GB
+innodb_log_files_in_group = 2 # log group 中的文件数
+innodb_max_undo_log_size = 1G # undo log 最大体积
+```
+
+> [!TIP] innodb_log_file_size 怎么设?
+> - 默认各 48MB(太小!频繁 checkpoint)
+> - 推荐 256M~2G(写入密集型可更大)
+> - 越大意味着:checkpoint 间隔越长、崩溃恢复越慢、缓冲池可容纳更多脏页
+> - **调大需要在停机窗口操作**(需要先删旧 log file,MySQL 会报错提示)
+
+### 两阶段提交(Two-Phase Commit, 2PC)
+
+这是保证 Binlog 和 Redo Log 一致性的核心协议:
+
+```mermaid
+sequenceDiagram
+ participant App as 应用
+ participant S as Server Layer
+ participant IB as InnoDB Engine
+ participant BGL as Binlog
+ participant RDL as Redo Log
+
+ App->>S: BEGIN; UPDATE ... COMMIT;
+ S->>IB: Prepare 事务
+ IB->>RDL: 写入 Redo Log (prepare 状态)
+
+ Note over S: Phase 1: Redo Log Prepared
+ S->>BGL: 写入 Binlog
+
+ Note over S: Phase 2: 确认提交
+ S->>IB: Commit 通知
+ IB->>RDL: 标记 Redo Log 为 committed
+
+ IB-->>S: 返回 OK
+ S-->>App: 事务提交成功
+```
+
+> [!QUESTION] 为什么要两阶段?
+> 如果只写 Binlog 不写 Redo Log(或反过来),崩溃后会出现不一致:
+>
+> - **只有 Binlog 没有 Redo Log**:主从复制时,从库执行了 Binlog 但该事务从未在本库提交 → 数据错乱
+> - **只有 Redo Log 没有 Binlog**:本库恢复了但主从不一致 → 从库缺少这条数据
+>
+> **中间态处理**:如果 Phase 1 写完准备日志后崩溃,恢复时会检查 Binlog 是否存在:
+> - Binlog 存在 → Redo Log 标记为 committed → 恢复后提交
+> - Binlog 不存在 → Redo Log 标记为 aborted → 恢复后回滚
+
+## Undo Log —— 保证原子性
+
+Undo Log(回滚日志)是 InnoDB 的**逻辑日志**,记录的是「数据修改前的样子」。
+
+### Undo Log 的核心用途
+
+| 用途 | 说明 |
+|------|------|
+| **事务回滚** | ROLLBACK 时读取 undo log 反向操作 |
+| **MVCC 多版本读** | 构建 Read View 时的历史版本链 |
+| **事务一致性快照** | 保证同一事务内多次读看到相同数据 |
+
+```
+原始数据:balance = 500
+事务 A: UPDATE accounts SET balance = 100 WHERE id = 1
+
+Undo Log 记录:
+- undo_no: 1024
+- prev_lsn: 12345678
+- type: ROW_UPDATE
+- table_id: 42
+- row_id: 1
+- before_image: {balance: 500} ← 修改前的值
+
+rollback 时:用 before_image 恢复 balance = 500
+```
+
+Undo Log 本质上维护了一个**修改前镜像链(before-image chain)**。每次对某行做 UPDATE,InnoDB 会把修改前的整行数据(不只是变化的列)写入 undo log,然后在新行版本上应用修改。ROLLBACK 时,InnoDB 从 undo log 中沿着这个链逐个还原即可。
+
+> **为什么是逻辑日志?** 因为 undo log 记录的是「对哪行的哪个字段做了什么操作」,而不是物理字节偏移。这使得 undo log 可以被重放到其他存储引擎,也是 MVCC 能够构建历史版本的基础。
+
+### Rollback Segment
+
+Undo Log 存储在专门的 Rollback Segment 中:
+
+```ini
+innodb_rollback_segments = 128 # 回滚段数量
+innodb_max_purge_lag_delay = 0 # purge 延迟阈值(微秒)
+```
+
+> [!NOTE] Undo Log 的生命周期
+> 1. 事务执行过程中持续追加 undo log
+> 2. 事务 commit 后,undo log 不会立即删除
+> 3. 直到**没有其他事务需要它做 MVCC 可见性判断**时,Purge Thread 才清理它
+> 4. undo log 空间会被循环回收
+
+## 崩溃恢复 (Crash Recovery) —— ACID 的最终保障
+
+InnoDB 拥有强大的崩溃恢复机制:**每次重启,都会让数据库回到一致状态**。
+
+### ARIES 恢复算法简述
+
+```mermaid
+flowchart TD
+ A[MySQL Start] --> B[Redo Analysis Stage]
+ B --> C[Scan Redo Log by LSN]
+ C --> D[TxId Page Dirty Flag Table]
+
+ D --> E{Committed?}
+ E -->|Yes: committed| F[Redo Forward Apply]
+ E -->|No: not committed| G[Undo Rollback]
+
+ F --> H[Data Pages Restored to Consistent State]
+ G --> H
+
+ H --> I[Recovery Complete
DB Online]
+
+ style A fill:#4A90D9,color:#fff
+ style I fill:#50E3C2,color:#000
+```
+
+### 恢复流程图
+
+```mermaid
+sequenceDiagram
+ participant DB as Crash MySQL
+ participant RDL as Redo Log
+ participant UDL as Undo Log
+ participant DP as Data Pages(Disk)
+
+ Note over DB: Post-crash state
dirty pages unflushed, tx status unclear
+
+ rect rgba(76, 175, 80, 0.15)
+ Note over DB,RDL: Phase 1 Analysis: rebuild tx state
+ DB->>DB: Build TxId Page Dirty Flag Table
+ end
+
+ rect rgba(255, 87, 34, 0.15)
+ Note over DB,RDL: Phase 2 Redo: forward-apply committed tx
+ loop by LSN order
+ DB->>DP: Reapply committed modifications
+ end
+ end
+
+ rect rgba(244, 67, 54, 0.15)
+ Note over DB,UDL: Phase 3 Undo: rollback uncommitted tx
+ loop reverse scan undo log
+ DB->>DP: Undo uncommitted changes
+ end
+ end
+
+ DB-->>DB: DB restored to consistent state
+```
+
+### 典型场景分析
+
+| 崩溃时机 | Redo Log 状态 | Undo Log 状态 | 恢复行为 |
+|----------|--------------|--------------|----------|
+| **UPDATE 后、COMMIT 前** | 有 redo entry(未标记 committed) | 有 undo record | 回滚未提交事务 |
+| **Prepare 后、Binlog 写入前** | prepare 状态 | 有 undo record | 检查 Binlog → 不存在则回滚 |
+| **Prepare 后、Commit 通知前** | prepare 状态 | 有 undo record | 检查 Binlog → 存在则提交 |
+| **Binlog 写完、Redo Commit 前** | Binlog 已写,redo uncommitted | 有 undo record | 通过 2PC 判断:提交 |
+| ** COMMIT 返回后** | 已 committed,可能尚未刷盘 | 不再需要 | 若 redo 未刷盘:crash-safe 窗口内丢失(依赖 binlog replication) |
+
+> [!TIP] crash-safe 的边界
+>
+> `innodb_flush_log_at_trx_commit = 1` 时,COMMIT 后 Redo Log 已刷盘,即使进程崩溃也不会丢数据(前提是操作系统没挂)。
+>
+> 但如果**整台机器断电**,且 `innodb_flush_log_at_trx_commit = 2`,最近一秒钟的数据可能丢失。这就是为什么金融系统必须设为 1。
+
+---
+
+## 关联笔记
+
+- [[hhs/MySQL/24-隔离级别与可见性]] — ACID 中 Isolation 的具体实现
+- [[hhs/MySQL/25-MVCC 原理]] — Undo Log 如何支撑多版本并发控制
+- [[hhs/GORM/08-事务管理]] — GORM 的事务 API 与 MySQL 引擎层的对应关系
diff --git a/hhs/MySQL/24-隔离级别与可见性.md b/hhs/MySQL/24-隔离级别与可见性.md
new file mode 100644
index 0000000..3e34f68
--- /dev/null
+++ b/hhs/MySQL/24-隔离级别与可见性.md
@@ -0,0 +1,218 @@
+---
+tags: [MySQL, 隔离级别, READ COMMITTED, REPEATABLE READ, SERIALIZABLE]
+create time: 2026-05-16 00:00
+---
+
+# 隔离级别与可见性
+
+## 概述
+
+SQL 标准定义了四种事务隔离级别,它们控制着并发事务之间的可见性程度。理解每个级别的语义和可能产生的问题,是正确设计高并发系统的前提。
+
+## 四种隔离级别
+
+```mermaid
+flowchart LR
+ RU["Read Uncommitted
读未提交"] --> RC["Read Committed
读已提交"]
+ RC --> RR["Repeatable Read
可重复读"]
+ RR --> S["Serializable
串行化"]
+
+ RU -->|"无保护"| W1["脏读 / 不可重复读 / 幻影读"]
+ RC -->|"防脏读"| W2["不可重复读 / 幻影读"]
+ RR -->|"防不可重复读"| W3["防幻影读"]
+ S -->|"完全串行"| W4["一切正常"]
+
+ style RU fill:#EE5A24,color:#fff
+ style RC fill:#FF9F43,color:#000
+ style RR fill:#00D866,color:#fff
+ style S fill:#00B6BC,color:#fff
+```
+
+### 速查表
+
+| 隔离级别 | 脏读 | 不可重复读 | 幻影读 | InnoDB 默认 |
+|----------|------|-----------|--------|------------|
+| **Read Uncommitted** | ❌ 可能出现 | ❌ 可能出现 | ❌ 可能出现 | ❌ 不用 |
+| **Read Committed (RC)** | ✅ 防止 | ❌ 可能出现 | ❌ 可能出现 | Java 项目常用 |
+| **Repeatable Read (RR)** | ✅ 防止 | ✅ 防止 | ✅ 防止* | **MySQL 默认** |
+| **Serializable** | ✅ 防止 | ✅ 防止 | ✅ 防止 | 极端场景 |
+
+> \* RR 下 InnoDB 通过 Next-Key Lock 解决了大部分幻影读问题,但不是所有场景都能完全避免。
+
+## 三大并发问题详解
+
+### 1. 脏读(Dirty Read)
+
+> [!QUESTION] 最危险的问题:读到「还没来得及后悔」的数据
+> 脏读的本质是事务 B 直接看到了事务 A **未提交**的中间状态。一旦 A 回滚,B 拿到的数据就永远失去了意义。
+
+```sql
+-- 隔离级别: Read Uncommitted
+
+BEGIN; -- 事务 A
+UPDATE accounts SET balance = 1000 WHERE user_id = 1;
+-- 此时 balance=1000,但事务 A 尚未提交
+
+-- 事务 B 在此刻读取
+BEGIN; -- 事务 B
+SELECT balance FROM accounts WHERE user_id = 1;
+-- 读到 balance=1000 ← 脏数据!
+
+-- 事务 A 回滚
+ROLLBACK; -- balance 恢复到原来的 500
+
+-- 事务 B 读到的 1000 永远消失了
+```
+
+### 2. 不可重复读(Non-Repeatable Read)
+
+> [!QUESTION] 同一个事务里,为什么前后读到的不一样?
+> 不可重复读关注的是 **UPDATE/DELETE** 操作的影响:事务 A 在同一事务内两次读取同一行,结果被事务 B 的修改给「污染」了。
+
+```sql
+-- 隔离级别: Read Committed
+
+BEGIN; -- 事务 A
+SELECT balance FROM accounts WHERE user_id = 1; -- 读到 500
+
+-- 事务 B 在此期间修改并提交
+BEGIN; -- 事务 B
+UPDATE accounts SET balance = 1000 WHERE user_id = 1;
+COMMIT;
+
+-- 事务 A 再次读取(在同一个事务内)
+SELECT balance FROM accounts WHERE user_id = 1;
+-- 读到 1000 ← 同一次事务里读到了不同值!
+```
+
+> [!TIP] 不可重复读 ≠ 幻影读
+> 不可重复读针对的是 **同一行数据** 被修改后读到的变化;幻影读针对的是 **行数范围** 的变化——多出了几行或少了几行。这是两个不同的维度。
+
+### 3. 幻影读(Phantom Read)
+
+> [!QUESTION] 「幽灵行」从哪来?
+> 幻影读发生在基于范围的查询中。事务 A 根据某个条件筛选数据,事务 B 恰好插入了符合该条件的**新行**,导致 A 再次查询时"无中生有"地多出了几行。
+
+```sql
+-- 隔离级别: Read Committed / Repeatable Read
+
+BEGIN; -- 事务 A
+SELECT COUNT(*) FROM orders WHERE amount > 100;
+-- 结果: 10
+
+-- 事务 B 插入新订单
+BEGIN; -- 事务 B
+INSERT INTO orders (amount) VALUES (200);
+COMMIT;
+
+-- 事务 A 再次查询
+SELECT COUNT(*) FROM orders WHERE amount > 100;
+-- RC: 读到 11 ← 出现了「幻影行」
+-- RR(InnoDB): 仍读到 10 ← 幻影像被挡住了
+```
+
+> [!QUESTION] 为什么 InnoDB 能在 RR 下挡住幻影?
+> RC 每次 SELECT 都创建新的 Read View,所以能看到后来提交的新行。而 RR 下第一个 SELECT 就创建了 Read View,整个事务期间都用这个视图——后来的 INSERT 在 Read View 中不可见。再加上 Next-Key Lock 锁住插入间隙,INSERT 也会被阻塞。详见 [[hhs/MySQL/25-MVCC 原理]]。
+
+## 各隔离级别下的 Read View 行为
+
+```mermaid
+sequenceDiagram
+ participant A as Transaction A
+ participant RC_DB as RC 模式
+ participant RR_DB as RR 模式
+ participant B as Transaction B
+
+ A->>RC_DB: BEGIN;
+ A->>RC_DB: SELECT * FROM t; -- Read View #1
+ A->>RC_DB: SELECT * FROM t; -- Read View #2 (new each time)
+
+ B->>RC_DB: BEGIN;
+ B->>RC_DB: INSERT INTO t VALUES (...);
+ B->>RC_DB: COMMIT;
+
+ A->>RC_DB: SELECT * FROM t; -- Read View #3
+ Note over RC_DB: Can see new rows committed by B!
+
+ A->>RR_DB: BEGIN;
+ A->>RR_DB: SELECT * FROM t; -- Read View #1
+ A->>RR_DB: SELECT * FROM t; -- Reuses Read View #1
+
+ B->>RR_DB: BEGIN;
+ B->>RR_DB: INSERT INTO t VALUES (...);
+ B->>RR_DB: COMMIT;
+
+ A->>RR_DB: SELECT * FROM t;
+ Note over RR_DB: Cannot see B's rows - RV#1 was created before B committed
+```
+
+## 实际生产中的选择
+
+```go
+// Go 连接 MySQL 时,通过 DSN 参数指定隔离级别
+dsn := "user:pass@tcp(localhost:3306)/db?charset=utf8mb4&parseTime=True&loc=Local&transaction_isolation=READ-COMMITTED"
+db, err := sql.Open("mysql", dsn)
+// transaction_isolation 设置的是该连接所有事务的默认隔离级别
+// 也可以在执行 BeginTx() 时单独指定 txOptions := &sql.TxOptions{Isolation: sql.LevelReadCommitted}
+```
+
+### RC vs RR 在 InnoDB 中的核心差异
+
+| 维度 | RC(读已提交) | RR(可重复读) |
+|------|-------------|-------------|
+| **Read View 创建时机** | 每次 SELECT 前都创建全新的 Read View | 第一次 SELECT 时创建一次,整个事务复用 |
+| **UNDO Log 读取路径** | 同一行可能存在多个版本,需要从最新往前追溯第一个符合当前 Read View 的版本 | 只需沿着 undo log 找到第一个符合事务启动时 Read View 的版本即可 |
+| **锁定策略** | 普通的行级共享锁 / 排他锁 | 除了行锁外,还有 **Next-Key Lock**(Record Lock + Gap Lock)用于范围查询 |
+| **并发性能** | 较高——锁范围小,等待时间短 | 较低——Gap Lock 会阻塞其他事务的 INSERT |
+| **一致性保障** | 只保证读到最近提交的值 | 保证整个事务看到一致的数据快照 |
+
+> [!TIP] 为什么 Java 项目偏爱 RC?
+> JVM 本身就有多线程并发模型作为"天然隔离层"。Spring 等框架的 @Transactional 在大多数场景下只需要 RC 级别的保护,再加上应用层的悲观锁(SELECT FOR UPDATE)或乐观锁(version 字段),就足以覆盖大部分业务需求。RC 让 InnoDB 少做很多工作。
+
+### Next-Key Lock 如何挡幻影
+
+InnoDB 在 RR 模式下对 **范围查询** 使用 Next-Key Lock(记录锁 + 间隙锁的组合):
+
+```sql
+-- 假设 orders.amount 上有索引
+
+BEGIN; -- 事务 A(RR 模式)
+-- 查找 amount > 100 的记录
+SELECT * FROM orders WHERE amount > 100 FOR UPDATE;
+-- InnoDB 会对以下区间加 Next-Key Lock:
+-- (-∞, 100] —— 锁住这个范围内的记录以及插入间隙
+-- (100, +∞) —— 同样加间隙锁,阻止符合条件的插入
+```
+
+这意味着在事务 A 的范围内,任何其他事务尝试插入 amount = 50 或 amount = 200 的行时,都会被阻塞直到事务 A 提交。这就是 RR 下幻影读被解决的核心机制。
+
+### Next-Key Lock 的例外情况
+
+Next-Key Lock 并非万能,有几种场景 RR 仍无法阻止幻影读:
+
+> [!IMPORTANT] RC 模式下无论怎么加锁,幻影读都无法避免
+> RC 的语义就是「每次查询都能看到最新提交的行」,所以不存在 Gap Lock。如果你选了 RC 级别,InnoDB 连间隙锁都不加,任何 INSERT 都不会被挡住。
+
+> [!NOTE] 唯一索引(UNIQUE KEY)的特殊行为
+> 当查询条件使用了唯一索引时,InnoDB 会退化到 **Record Lock**(只做 record lock,不做 gap lock)。因为唯一索引保证了不会有重复值,理论上不存在插入间隙的问题。但这只在等值查询且走唯一索引时成立。
+
+### 推荐场景速查
+
+| 场景 | 推荐隔离级别 | 理由 |
+|------|-------------|------|
+| **金融/支付** | Serializable 或 RR + FOR UPDATE | 强一致性优先 |
+| **电商下单** | RR | 库存扣减、订单创建需要完整一致性 |
+| **内容 CMS** | RC | 允许最终一致,性能更好 |
+| **高并发读** | RC | 减少 RR 下的锁竞争 |
+| **Java/Spring 生态** | RC | Spring 默认就是 RC |
+
+### 隔离级别选择的注意事项
+
+> [!WARNING] MySQL 默认的 RR 不是免费的午餐
+> 虽然 RR 提供了最强的隔离性,但也带来了更严格的锁定策略和更高的冲突概率。如果你用的是 Java/Ruby/Python 等语言,这些框架通常以 RC 级别与 MySQL 通信。混用 RC + RR 时需要特别注意事务语义是否与设计预期一致。
+
+## 关联笔记
+
+- [[hhs/MySQL/23-ACID 与原子性实现]] — ACID 的基础概念和 Redo/Undo Log
+- [[hhs/MySQL/25-MVCC 原理]] — Read View 的详细构造算法
+- [[hhs/MySQL/26-锁机制总览]] — 隔离级别与锁类型的对应关系
diff --git a/hhs/MySQL/25-MVCC 原理.md b/hhs/MySQL/25-MVCC 原理.md
new file mode 100644
index 0000000..88a8644
--- /dev/null
+++ b/hhs/MySQL/25-MVCC 原理.md
@@ -0,0 +1,287 @@
+---
+tags: [MySQL, MVCC, Read View, Undo Log, 多版本并发控制]
+create time: 2026-05-16 00:00
+---
+
+# MVCC 原理
+
+## 概述
+
+MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现非阻塞读的核心机制。它让读写不冲突——大多数情况下,`SELECT` 完全不需要等待 `UPDATE` 持有排他锁。
+
+> [!TIP] 一句话理解 MVCC
+> MVCC 的本质是让每个事务看到「一个时间点的快照」,而不是磁盘上实时的数据。就像给数据库拍照——你在照片里看到的是拍那一瞬间的样子,之后别人怎么改都不会影响你。
+
+## 背后的三个支撑字段
+
+每行记录额外隐藏了两个字段:
+
+| 字段 | 类型 | 作用 |
+|------|------|------|
+| **DB_TRX_ID** | 6 bytes | 最近修改该行的事务 ID |
+| **DB_ROLL_PTR** | 7 bytes | 回滚指针,指向对应的 Undo Log |
+
+此外,二级索引的叶子节点还保存了**主键值**(而不是完整数据),这也是 MVCC 能够工作的基础。
+
+```mermaid
+flowchart LR
+ subgraph Row["行记录物理结构"]
+ User["用户定义列
id, name, email..."]
+ TrxId["DB_TRX_ID
事务 ID (6 bytes)"]
+ RollPtr["DB_ROLL_PTR
回滚指针 (7 bytes)"]
+ RowId["DB_ROW_ID
隐藏行 ID (6 bytes)"]
+ end
+
+ User --- TrxId --- RollPtr --- RowId
+
+ style TrxId fill:#C44569,color:#fff
+ style RollPtr fill:#FF9F43,color:#000
+```
+
+> [!NOTE] 为什么需要 DB_TRX_ID?
+> 每一行被某个事务插入或修改时,InnoDB 都需要记住是哪个事务所改的。这样在查 Read View 的时候才知道:"哦,这行是 T2 改的,而我的 Read View 创建时 T2 还没提交"——于是就不给你看这行。
+
+## Version Chain(版本链)
+
+当一行被 UPDATE 时,InnoDB 不会直接修改原数据,而是:
+
+1. 在 Undo Log 中保留旧版本
+2. 在新行记录的 DB_ROLL_PTR 上链接到旧版本
+3. 更新 DB_TRX_ID 为新事务 ID
+
+```mermaid
+flowchart LR
+ subgraph UndoLog["Undo Log(旧版本)"]
+ UL["name='Alice'\nTRX_ID=100\nROLL_PTR=NULL"]
+ end
+
+ subgraph CurrentRow["当前行(新版本)"]
+ CR["name='Alice_New'\nTRX_ID=200\nROLL_PTR->Undo#1"]
+ end
+
+ CR -.->|"ROLL_PTR"| UL
+
+ style CR fill:#C44569,color:#fff
+ style UL fill:#FF9F43,color:#000
+```
+
+> [!QUESTION] 为什么不能原地覆盖?
+> 如果直接修改原行,之前的事务就看不到自己的"历史视角"了。把旧版本存到 Undo Log 并用指针串联起来,才能同时支持多个事务各自不同的可见性需求。
+
+### 版本链的多次更新
+
+```mermaid
+flowchart LR
+ V1["V1: Alice\nTRX_ID=100"] -->|"Roll Ptr"| V2["V2: Alice_New\nTRX_ID=200"]
+ V2 -->|"Roll Ptr"| V3["V3: Alice_Latest\nTRX_ID=300"]
+
+ style V1 fill:#AAB7B8,color:#fff
+ style V2 fill:#FF9F43,color:#000
+ style V3 fill:#C44569,color:#fff
+```
+
+Version Chain 是一条按 TRX_ID 递增方向排列的链表:**越靠后的版本越新**。查找时从最新一行往前遍历,找到第一个对当前 Read View 可见的版本即可停止。
+
+## Read View 的构成
+
+> [!TIP] Read View 是什么?
+> 你可以把 Read View 想象成事务执行 SELECT 时向数据库申请的「时间窗口」。InnoDB 根据这个时间窗口判断:哪些数据在我进入这个窗口之前就已经存在,哪些是在我进来之后才产生的。
+
+Read View 是一个「可见性规则集合」,决定了哪些版本对当前事务可见:
+
+```go
+// InnoDB 内部 ReadView 结构(伪代码)
+type ReadView struct {
+ m_ids []uint64 // 创建时活跃的事务 ID 列表
+ min_trx_id uint64 // m_ids 中的最小值
+ max_trx_id uint64 // 创建时下一个将被分配的 trx_id
+ creator_trx_id uint64 // 创建该 ReadView 的事务 ID
+}
+```
+
+```mermaid
+flowchart TD
+ A["事务 T5 执行 SELECT"] --> B["创建 ReadView"]
+ B --> C["m_ids = {T3, T5, T7}\n当前正在运行的事务"]
+ B --> D["min_trx_id = 3\n活跃事务中最小的 ID"]
+ B --> E["max_trx_id = 8\n下一个将分配的 ID"]
+ B --> F["creator_trx_id = 5\n我自己的事务 ID"]
+
+ style C fill:#00B6BC,color:#fff
+```
+
+> [!NOTE] min_trx_id / max_trx_id 的作用区间
+> 这两个字段一起定义了一个半开区间 `[min_trx_id, max_trx_id)`。trx_id 落在这个区间之外的,可以直接通过两条边界判断得出结论;只有落在这个区间的,才需要进一步检查是否在 m_ids 中。这样可以减少不必要的列表扫描。
+
+## 可见性判断规则
+
+给定一行记录的 `trx_id`,判断它对某个 Read View 是否可见:
+
+```mermaid
+flowchart TD
+ A["行记录的 trx_id"] --> B{"trx_id < min_trx_id?"}
+ B -->|是| V1["✅ 可见\n事务在 ReadView 前已提交"]
+
+ B -->|否| C{"trx_id >= max_trx_id?"}
+ C -->|是| V2["✅ 可见\n事务在 ReadView 后才开始"]
+
+ C -->|否| D{"trx_id 在 m_ids 中?"}
+ D -->|否| V3["✅ 可见\n不在活跃列表 → 已提交"]
+ D -->|是| V4["❌ 不可见\n创建时还在运行"]
+
+ style V1 fill:#00D866,color:#fff
+ style V2 fill:#00D866,color:#fff
+ style V3 fill:#00D866,color:#fff
+ style V4 fill:#EE5A24,color:#fff
+```
+
+### 四个规则的速记口诀
+
+| 规则 | 条件 | 结论 | 通俗解释 |
+|------|------|------|---------|
+| **早提交** | `trx_id < min_trx_id` | ✅ 可见 | 比你早起的人已经下班走了 → 你能看到 |
+| **晚启动** | `trx_id >= max_trx_id` | ✅ 可见 | 比你晚来的人还没到办公室 → 你看不到他写的东西 |
+| **已退出** | `trx_id ∉ m_ids` | ✅ 可见 | 名单上没有此人 → 他已经提交了 |
+| **正活跃** | `trx_id ∈ m_ids` | ❌ 不可见 | 正在工位上坐着呢 → 别看他未完成的产出 |
+
+> [!TIP] 判断顺序很重要
+> 实际代码中先比较 `min_trx_id` 和 `max_trx_id` 这两条边界,因为这是 O(1) 的比较操作。只有在落在两个边界之间时,才去扫描 m_ids 列表做成员检查。这种设计在高并发场景下能显著减少 CPU 开销。
+
+## RC 与 RR 的关键区别
+
+```mermaid
+flowchart LR
+ subgraph RC["RC: 每次 SELECT 新建 ReadView"]
+ RC1["第1次 SELECT → ReadView #1\nm_ids={T2, T4}"]
+ RC2["第2次 SELECT → ReadView #2\nm_ids={} ← T2 已提交!"]
+ RC3["第3次 SELECT → ReadView #3\n看到了 T2 的数据 ✨\n也看到了 T4 的数据 ✨"]
+ end
+
+ subgraph RR["RR: 首次 SELECT 创建一次,全事务复用"]
+ RR1["第1次 SELECT → ReadView #1\nm_ids={T2, T4}"]
+ RR2["第2次 SELECT → 复用 ReadView #1\nm_ids 不变"]
+ RR3["第3次 SELECT → 复用 ReadView #1\nT2 和 T4 始终不可见 🔒"]
+ end
+
+ style RC3 fill:#FF9F43,color:#000
+ style RR3 fill:#00D866,color:#fff
+```
+
+> [!IMPORTANT] 为什么 RR 叫"可重复读"?
+> 核心原因就在这里——整个事务期间只有一份 ReadView,所以你每次看到的都是同一个快照。不管别的线程怎么改、怎么提交,你的视图始终冻结在第一次 SELECT 的那一刻。
+
+## MVCC 与 DML 的关系
+
+很多开发者只知道 MVCC 处理 SELECT,其实 INSERT、DELETE 同样深度依赖 MVCC 机制:
+
+### INSERT — 写入新版本
+
+INSERT 操作非常简单:插入的行自带当前事务的 `trx_id`,并设置 `ROLL_PTR = NULL`(因为是首个版本)。插入后,其他事务是否能看到这行,取决于它们的 Read View。
+
+```sql
+-- 会话 A 插入
+START TRANSACTION; -- trx_id = 500
+INSERT INTO users (name) VALUES ('Bob');
+COMMIT;
+
+-- 会话 B(在 A 提交后查询)
+START TRANSACTION; -- trx_id = 600
+SELECT * FROM users WHERE name = 'Bob';
+-- ✅ 正常查到 Bob —— 因为 500 < min_trx_id(600),已提交
+```
+
+### DELETE — 标记删除而非物理移除
+
+DELETE 并不真正删除行记录,而是在 undo log 中创建一个标记为"已删除"的版本,然后在新行的 `DB_TRX_ID` 上标记删除。后续 SELECT 遍历时发现该行被删除且不可见,就直接跳过。
+
+```mermaid
+flowchart LR
+ Before["DELETE 前的行\nname='Alice'\nTRX_ID=200\nROLL_PTR→旧版本"]
+ After["DELETE 后的行(仍存活)\nname='Alice' marked deleted\nTRX_ID=300\nROLL_PTR→Undo#1"]
+ Purg["Purge Worker 异步清理\n满足条件的不可见记录\n才会真正从磁盘删除"]
+
+ After --> Purg
+
+ style Before fill:#FF9F43,color:#000
+ style After fill:#EE5A24,color:#fff
+ style Purg fill:#AAB7B8,color:#fff
+```
+
+> [!WARNING] DELETE 不会立即释放空间
+> 被 DELETE 的行仍然存储在磁盘中,直到 Purge Worker 异步回收。这就是为什么大量 DELETE 后需要用 OPTIMIZE TABLE 重建表来真正释放空间。
+
+### UPDATE — INSERT + DELETE 的组合
+
+UPDATE 本质上是先逻辑删除旧行(加 deleted flag),再插入新行。因此 UPDATE 比纯 INSERT 多出 undo log 的版本链维护开销。
+
+```mermaid
+flowchart TD
+ U1["原行: name='Alice'\nTRX_ID=100"] --> U2["逻辑删除旧版本\n(写 undo log)\n设置 deleted flag"]
+ U2 --> U3["插入新版本\nTRX_ID=200\nname='Alice_New'\nROLL_PTR→undo"]
+
+ style U1 fill:#FF9F43,color:#000
+ style U3 fill:#C44569,color:#fff
+```
+
+## 完整的版本查找过程
+
+```mermaid
+sequenceDiagram
+ participant T as 事务 T5
+ participant V as ReadView
+ participant Row as 行记录(最新版)
+ participant UL as Undo Log 版本链
+
+ T->>V: 获取当前 ReadView
+ V->>Row: 检查 row.trx_id
+ alt trx_id 可见
+ Row-->>T: 返回当前版本
+ else trx_id 不可见
+ V->>UL: Follow ROLL_PTR 找上一个版本
+ UL->>UL: 检查上一版的 trx_id
+ alt 可见
+ UL-->>T: 返回该版本
+ else 仍不可见
+ UL->>UL: 继续往前追溯
+ loop 直到找到可见版本或链表结束
+ end
+ end
+ end
+```
+
+> [!QUESTION] 版本链遍历会不会很慢?
+> 理论上是的——如果一行被频繁 UPDATE,版本链会很长。但实践中很少出现这种情况,因为:
+> - 大多数业务场景下一行数据的 UPDATE 频率不高
+> - InnoDB 有 purge thread 定期清理不可见的旧版本,缩短版本链长度
+> - 可以通过合理选择隔离级别(如 RC 模式下的 ReadView 更灵活)来减少回溯次数
+
+## 实战验证
+
+```sql
+-- 会话 A(RR 模式,MySQL 默认)
+SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
+START TRANSACTION;
+
+SELECT * FROM accounts WHERE id = 1;
+-- 读到 balance = 500
+
+-- 会话 B
+START TRANSACTION;
+UPDATE accounts SET balance = 1000 WHERE id = 1;
+COMMIT;
+
+-- 会话 A(同一个事务内再次查)
+SELECT * FROM accounts WHERE id = 1;
+-- 仍然读到 500!← ReadView 不让看 T_B 的改动
+
+-- 但如果用当前读呢?
+SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
+-- 读到 1000 ← 当前读不走 MVCC,直接拿最新版本
+```
+
+## 关联笔记
+
+- [[hhs/MySQL/24-隔离级别与可见性]] — 隔离级别的定义及 RC vs RR 的选择
+- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 的结构和生命周期
+- [[hhs/MySQL/26-锁机制总览]] — MVCC 与锁的配合(一致性读 vs 当前读)
+- [[hhs/MySQL/28-一致性读与当前读]] — 什么情况下走 MVCC、什么情况下需要加锁
diff --git a/hhs/MySQL/26-锁机制总览.md b/hhs/MySQL/26-锁机制总览.md
new file mode 100644
index 0000000..d09649a
--- /dev/null
+++ b/hhs/MySQL/26-锁机制总览.md
@@ -0,0 +1,392 @@
+---
+tags: [MySQL, 锁机制, Record Lock, Gap Lock, Next-Key Lock]
+create time: 2026-05-16 00:00
+---
+
+# 锁机制总览
+
+## 概述
+
+InnoDB 的锁体系是分层次的——从全局到表级到行级,每种锁粒度不同、适用场景不同。理解锁的分类和使用时机是排查锁冲突和设计高并发系统的核心。
+
+## 锁分类全图
+
+```mermaid
+graph BT
+ subgraph "按粒度层次"
+ GL["全局锁 Global Lock"]
+ TL["表级锁 Table Lock
MDL / 显式表锁"]
+ RL["行级锁 Row Lock"]
+ end
+
+ subgraph "行锁三大子类"
+ IL["意向锁 Intention
IS / IX(自动)"]
+ RLock["记录锁 Record Lock
只锁具体索引行"]
+ GLock["间隙锁 Gap Lock
锁区间(不含记录)"]
+ NKLock["临键锁 Next-Key Lock
Record Lock + Gap Lock"]
+ end
+
+ GL --> TL
+ TL --> RL
+ RL --> IL
+ RL --> RLock
+ RL --> GLock
+ RLock & GLock --> NKLock
+
+ style GL fill:#C44569,color:#fff
+ style TL fill:#FF9F43,color:#000
+ style NKLock fill:#00B6BC,color:#fff
+```
+
+> [!NOTE] 为什么需要三张图?
+>
+> - **第一张**展示锁的粒度层次:从全局→表→行
+> - **第二张**展示行锁的子类关系:三种基础锁如何组合成临键锁
+> - **第三张**(下方场景矩阵)展示不同查询条件下 InnoDB 实际选择哪种锁
+>
+> 三者互为补充,共同构成完整的锁选型全景。带着这三张图,你可以回答面试中「InnoDB 锁到底有哪些类型」的问题了。
+
+## 全局锁
+
+```sql
+-- 对整个数据库加锁,不允许任何写入
+FLUSH TABLES WITH READ LOCK;
+
+-- 释放锁
+UNLOCK TABLES;
+```
+
+**典型场景**:逻辑备份(mysqldump)时确保数据一致,期间不允许任何写操作。
+
+> [!QUESTION] ❓ 思考:如果备份期间有人在做 DDL 怎么办?
+>
+> 答案是不影响 —— `FLUSH TABLES WITH READ LOCK` 只阻止写入,DDL 会阻塞等待全局锁释放。不过线上一般避免在备份窗口内做 DDL,因为全库锁住后所有请求都会排队。
+
+```bash
+# 使用 --single-transaction 可以避免全局锁
+mysqldump --single-transaction --routines db_name > backup.sql
+# 基于 MVCC,dump 过程中的写操作不会影响备份一致性
+```
+
+## 元数据锁(MDL, Metadata Lock)
+
+MDL 是自动管理的,用户无法手动控制。目的是防止「一边有人查表结构,另一边有人在改表结构」。
+
+> [!QUESTION] ❓ 为什么 MySQL 5.5+ 才引入 MDL?
+>
+> 在此之前,执行 `ALTER TABLE` 时其他会话的 `SELECT` 会被直接中断(抛出错误)。MDL 让 MySQL 改为「等待」而非「取消」,提升了生产环境稳定性。代价是可能出现排队现象——这就是下面要讲的雪崩问题。
+
+```sql
+-- 会话 A:执行查询,持有表的 MDL-SHARED 锁
+SELECT * FROM users;
+-- 持有 S 锁直到语句结束
+
+-- 会话 B:尝试 ALTER TABLE
+ALTER TABLE users ADD COLUMN bio TEXT;
+-- 需要 MDL-EXCLUSIVE 锁
+-- ⚠️ 阻塞!等待会话 A 释放 S 锁
+```
+
+> [!WARNING] MDL 锁导致的雪崩
+> 大量长事务或未释放的连接持有 MDL-S 锁,可能导致 DDL 长期排队。这是 MySQL 线上最常见的隐性故障之一。
+> ```sql
+> -- 查看当前 MDL 等待情况
+> SELECT * FROM performance_schema.metadata_locks;
+> ```
+
+## 表级锁
+
+### 自研工具锁
+
+```sql
+LOCK TABLES users WRITE, orders READ;
+-- 当前会话可以操作 users(写)和 orders(读)
+-- 其他会话对这两个表的任何操作都被阻塞
+
+UNLOCK TABLES; -- 显式释放
+```
+
+### MyISAM 的自动表锁
+
+MyISAM 在执行每条语句前自动申请表锁,执行完毕后自动释放。InnoDB 则几乎不使用表锁——除了某些 DDL 操作。
+
+## 行级锁 — 核心中的核心
+
+InnoDB 的行锁都是**加在索引 record 上**的。没有索引 = 退化为表锁。
+
+> [!WARNING] ⚠️ 面试高频坑点:无索引 UPDATE
+>
+> ```sql
+> -- ❌ 大忌!没有索引会锁全表
+> UPDATE orders SET status = 'shipped' WHERE user_id = 42;
+>
+> -- ✅ 正确做法:确保 user_id 有索引,或者用主键查询
+> UPDATE orders SET status = 'shipped' WHERE id = 1001;
+> ```
+
+### 三种基本锁类型
+
+| 名称 | 符号 | 兼容 | 说明 |
+|------|------|------|------|
+| **共享锁(S 锁)** | `LOCK IN SHARE MODE` | S+S ✅, S+X ❌ | 读锁,多个事务可同时持有 |
+| **排他锁(X 锁)** | `FOR UPDATE` | X+X ❌ | 写锁,独占 |
+| **意向锁(IS/IX)** | 自动加 | 用于表级兼容检测 | 表明事务想在某行上加 S/X |
+
+```sql
+-- 显式加共享锁
+BEGIN;
+SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;
+-- 其他事务可以也加 S 锁,但不能加 X 锁
+-- 不能 UPDATE 这行
+
+-- 显式加排他锁
+BEGIN;
+SELECT * FROM users WHERE id = 1 FOR UPDATE;
+-- 其他事务既不能加 S 也不能加 X
+-- 别人 UPDATE 这行会被阻塞
+```
+
+### 意向锁的作用
+
+```mermaid
+flowchart TD
+ A["事务 A 想在行级加 X 锁"] --> B{"检查表级意向锁"}
+ B --> C{"表中是否有其他事务的
意向共享锁 IS?"}
+ C -->|有| D["冲突!不能同时拥有 IX + IS"]
+ B -->|无| E{"表中是否有其他事务的
意向排他锁 IX?"}
+ E -->|有| F["冲突!表级不支持多个 IX"]
+ E -->|无| G["✅ 放行,加行级 X 锁"]
+
+ style G fill:#00D866,color:#fff
+ style D fill:#EE5A24,color:#fff
+ style F fill:#EE5A24,color:#fff
+```
+
+> [!NOTE] 意向锁不是用户能控制的
+> 当事务准备对某行加 S 锁时,InnoDB 自动在表级加 IS;加 X 锁时自动加 IX。它的作用是快速判断「这张表有没有人在用」,而不必逐行扫描。
+
+## 记录锁、间隙锁、临键锁
+
+这是 InnoDB 最精妙的设计,也是理解并发控制的关键。
+
+### Record Lock(记录锁)
+
+锁住**具体的索引记录**。
+
+```sql
+-- idx_status 上有唯一索引
+SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;
+-- 只锁住 status='pending' 的那些具体记录
+-- 不影响 status='shipped' 的记录
+```
+
+### Gap Lock(间隙锁)
+
+锁住**索引记录之间的间隙**,不包含记录本身。目的是阻止其他事务在间隙中插入新记录。
+
+```sql
+-- 假设索引上有以下值: 10, 20, 30, 40, 50
+
+-- 锁定 (20, 30) 这个开区间
+SELECT * FROM t WHERE id = 25 FOR UPDATE;
+-- id=25 不存在 → 不锁记录,锁住包含 25 的间隙 (20, 30)
+-- 其他事务不能在 (20, 30) 区间内插入任何值
+```
+
+### Next-Key Lock = Record Lock + Gap Lock
+
+**左开右闭区间** `(prev_value, value]`,是 RR 模式下默认的锁算法。
+
+### 一图理解 Next-Key Lock
+
+假设索引上有值 **10, 20, 30, 40, 50**,执行 `SELECT ... WHERE id = 20 FOR UPDATE;`:
+
+```mermaid
+graph LR
+ subgraph Data["📊 数据分布"]
+ direction TB
+ V1["10"] --- G1["Gap: (-∞, 10)"]
+ V1 --> NK["(10, 20] ⬅️ 锁定区域"]
+ NK --> V2["20"]
+ V2 --> G2["Gap: (20, 30)"]
+ V2 --> V3["30"]
+ end
+
+ subgraph Test["🧪 INSERT 测试"]
+ direction TB
+ T1["INSERT id=15"] --> B1["❌ 阻塞
落入 (10, 20) 间隙"]
+ T2["INSERT id=20"] --> B2["❌ 阻塞
记录已被锁定"]
+ T3["INSERT id=25"] --> OK["✅ 允许
落在 (20, 30) 间隙外"]
+ end
+
+ V1 -.-> T1
+ V2 -.-> T2
+ V3 -.-> T3
+
+ style NK fill:#C44569,color:#fff,stroke-width:3px
+ style B1 fill:#EE5A24,color:#fff
+ style B2 fill:#EE5A24,color:#fff
+ style OK fill:#00D866,color:#fff
+```
+
+> [!TIP] 记忆口诀
+>
+> **「左开右闭」**:Next-Key Lock 锁定的是 `(前一个值, 当前值]`。
+> - 插入 **等于** 锁定值的记录 → ❌ 阻塞(Record Lock 生效)
+> - 插入 **落在开区间内** 的值 → ❌ 阻塞(Gap Lock 生效)
+> - 插入 **落在右侧间隙外** 的值 → ✅ 允许
+
+### RC vs RR:隔离级别对锁的影响
+
+| 特性 | RR(默认) | RC(Read Committed) |
+|------|-----------|---------------------|
+| Record Lock | ✅ 有 | ✅ 有 |
+| **Gap Lock** | ✅ 有 | ❌ **无** |
+| **Next-Key Lock** | ✅ 默认使用 | ❌ 退化为 Record Lock |
+| 幻读防护 | ✅ Gap Lock 阻止插入 | ❌ 可能发生幻读 |
+| 并发度 | 较低(锁范围大) | 较高(不锁间隙) |
+
+> [!IMPORTANT] 为什么 RC 能提升并发?
+>
+> Gap Lock 的存在意义是防止「幻读」——在同一个事务中两次 `SELECT` 读到不同行数。RC 模式下每次读取都生成快照(Snapshot Isolation),天然不需要 Gap Lock。代价是可能看到其他事务未提交的写入(不过 Read Committed 保证不会读到未提交的事务本身)。
+>
+> ```sql
+> -- 显式设置会话隔离级别
+> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
+> ```
+
+### Next-Key Lock 的特殊情形
+
+| 场景 | 加锁方式 | 原因 |
+|------|---------|------|
+| **唯一索引等值查询命中** | 退化为 Record Lock | 已知不存在间隙中的冲突记录 |
+| **唯一索引等值查询未命中** | Gap Lock | 锁住该值会落入的间隙(防止幻读) |
+| **非唯一索引等值查询** | Next-Key Lock | 多个相同值,需保护左侧间隙 |
+| **范围查询** | 每个匹配记录的 Next-Key Lock + 最大值的右边间隙 | 保护整个范围 |
+| **INSERT** | Gap Lock on gap | 只锁插入位置的间隙,防冲突 |
+| **主键等值更新且命中** | Record Lock | 等同于唯一索引精确查询 |
+
+```mermaid
+sequenceDiagram
+ participant T as 事务
+ participant DB as InnoDB
+
+ T->>DB: SELECT ... WHERE pk = 5 FOR UPDATE
+ Note over T,DB: 唯一索引、恰好命中
+ DB-->>T: 📌 Record Lock on pk=5
+ rect rgb(200, 255, 200)
+ Note right of DB: ✅ 不加 Gap Lock
因为唯一索引已排除
间隙冲突的可能
+ end
+
+ T->>DB: SELECT ... WHERE uk = 99 FOR UPDATE
+ Note over T,DB: 唯一索引、**未命中**
+ DB-->>T: 🔒 Gap Lock on (90, 100)
+ rect rgb(255, 230, 200)
+ Note right of DB: ⚠️ 加上 Gap Lock
防止别人在 (90,100) 之间
插入 uk=99 的记录
+ end
+```
+
+### ⚡ 当前读 vs 一致性读:谁触发了锁?
+
+理解这一组概念是掌握锁机制的最终一步——**并不是所有 SELECT 都会加锁**。
+
+| 读取方式 | SQL 示例 | 是否加锁 | 数据来源 |
+|---------|---------|---------|---------|
+| **一致性读(快照读)** | `SELECT * FROM t WHERE ...`(默认) | ❌ 不加锁 | Undo Log 历史版本(MVCC) |
+| **当前读** | `SELECT ... FOR UPDATE/SHARE MODE` | ✅ 加锁 | 磁盘/缓冲池,读取最新提交数据 |
+| **当前读** | `UPDATE / DELETE / INSERT` | ✅ 加锁 | 磁盘/缓冲池,读取并修改最新数据 |
+
+> [!IMPORTANT] 为什么区分两种读?
+>
+> 想象以下场景:
+> ```
+> 事务 A: BEGIN; SELECT count(*) FROM orders; -- 读取了 100 条
+> 事务 B: BEGIN; INSERT INTO orders ... COMMIT; -- 新增了 1 条
+> 事务 A: SELECT count(*) FROM orders; -- 还是 100 条!🤔
+> ```
+>
+> 第二次 `SELECT` 仍然看到 100 条,是因为一致性读使用的是事务启动时的快照。如果业务确实需要最新的计数,必须使用当前读:
+> ```sql
+> SELECT COUNT(*) FROM orders LOCK IN SHARE MODE;
+> ```
+>
+> > [!NOTE] 深入阅读
+> > 想进一步了解 MVCC 与锁的配合原理,参见 [[hhs/MySQL/25-MVCC 原理]] — 其中详细解释了 Read View 如何与锁协同工作。
+
+## 锁的场景矩阵
+
+面试和实际排障中,**「这条 SQL 到底加了什么锁?」**是最常见的问题。下面这张图按查询条件分类,帮你快速判断 InnoDB 的选择:
+
+```mermaid
+flowchart TD
+ Q{"查询方式"}
+
+ Q -->|"精确等值(unique index)"| R1["Record Lock
仅锁那条记录"]
+ Q -->|"精确等值(non-unique index)"| R2["Next-Key Lock
记录 + 左侧间隙"]
+ Q -->|"范围查询"
WHERE id > 10"> R3["每个匹配记录的
Next-Key Lock + 最大值右侧间隙"]
+ Q -->|"无索引条件"| R4["全表记录的 Next-Key Lock
退化为表锁效果 ⚠️"]
+ Q -->|"DELETE | UPDATE | INSERT"| R5["INSERT: Gap Lock on gap
DELETE/UPDATE: Record Lock"]
+
+ style R1 fill:#00B6BC,color:#fff
+ style R4 fill:#EE5A24,color:#fff
+ style R5 fill:#FF9F43,color:#000
+```
+
+> [!TIP] 实用记忆法
+>
+> 看到一条 SQL,依次回答三个问题就能判断加锁类型:
+> 1. **有索引吗?** → 无索引 = 全表锁(❌)
+> 2. **是等值查询吗?** → 是 → 再看是否有唯一索引
+> 3. **是范围查询吗?** → 是 → 每个命中记录都加 Next-Key Lock
+
+## 死锁与排查
+
+在生产环境中,死锁不是「会不会发生」的问题,而是「什么时候发生」。理解死锁的形成过程比避免死锁更重要。
+
+```sql
+-- 查看最近的死锁信息
+SHOW ENGINE INNODB STATUS\G
+-- 在 LATEST DETECTED DEADLOCK 部分查看详细过程
+
+-- 查看当前锁等待
+SELECT * FROM sys.innodb_lock_waits;
+
+-- MySQL 8.0+: 更直观的视图
+SELECT * FROM performance_schema.data_locks;
+SELECT * FROM performance_schema.data_lock_waits;
+```
+
+```mermaid
+sequenceDiagram
+ participant T1 as 事务 A
+ participant T2 as 事务 B
+ participant DB as InnoDB
+
+ T1->>DB: LOCK x (X)
+ DB-->>T1: ✅ 获得 x 锁
+ T2->>DB: LOCK y (X)
+ DB-->>T2: ✅ 获得 y 锁
+
+ T1->>DB: LOCK y (X)
+ DB-->>T1: ⏳ 等待 T2 释放 y
+ T2->>DB: LOCK x (X)
+ DB-->>T2: ⏳ 等待 T1 释放 x
+
+ Note over DB: 检测到环路!
+ DB->>T1: 💀 死锁! 回滚 T1
+ DB-->>T2: y 锁可用
+ T2->>DB: 获得 y 锁 ✅
+```
+
+> [!TIP] 避免死锁的策略
+> 1. **固定顺序访问资源**:所有事务按相同的顺序加锁(如先用户表再订单表)
+> 2. **一次性加所有锁**:减少持锁时间
+> 3. **短事务原则**:尽可能少地参与竞争
+> 4. **降低隔离级别**:RC 模式不使用 Gap Lock,大幅降低死锁概率
+
+## 关联笔记
+
+- [[hhs/MySQL/23-ACID 与原子性实现]] — Redo Log 和 Undo Log 的基础知识
+- [[hhs/MySQL/25-MVCC 原理]] — MVCC 与锁的配合(一致性读 vs 当前读)
+- [[hhs/MySQL/26-死锁与排查]] — 完整的死锁诊断流程
+- [[hhs/GORM/08-事务管理]] — GORM 事务中的锁获取时机
diff --git a/hhs/MySQL/27-死锁与排查.md b/hhs/MySQL/27-死锁与排查.md
new file mode 100644
index 0000000..12647d0
--- /dev/null
+++ b/hhs/MySQL/27-死锁与排查.md
@@ -0,0 +1,316 @@
+---
+tags: [MySQL, 死锁, Deadlock, 排查, 等待图]
+create time: 2026-05-16 00:00
+---
+
+# 死锁与排查
+
+## 概述
+
+死锁是两个或多个事务互相等待对方释放锁资源的环形依赖。MySQL InnoDB 内置了死锁检测机制,会自动选择其中一个事务回滚来解除死锁。理解死锁的成因和排查方法是后端工程师的必备技能。
+
+## 常见死锁场景
+
+> [!TIP] 死锁的本质:环形等待
+> 所有死锁都可以归结为同一个模式:**每个事务都持有对方需要的资源,同时又在等待对方持有的资源**。
+> 想象两个人从桥的两端相向而行,桥只能容一人通过——谁也不退让,就卡住了。数据库里的"退让"就是回滚一个事务。
+
+### 场景一:交叉顺序加锁
+
+**最经典、最容易复现的场景。** 核心原因是两个事务对相同资源的访问顺序不一致。
+
+```sql
+-- 事务 A -- 事务 B
+UPDATE accounts SET balance = balance - 100 WHERE user_id = 1; -- 加 X 锁 user_id=1
+UPDATE accounts SET balance = balance + 100 WHERE user_id = 2; -- 尝试加 X 锁 user_id=2 → 被 T2 阻塞
+
+UPDATE accounts SET balance = balance + 100 WHERE user_id = 1; -- 尝试加 X 锁 user_id=1 → 被 T1 阻塞
+UPDATE accounts SET balance = balance - 100 WHERE user_id = 2; -- 永远不会执行
+
+-- 结果:T1 等 T2 的 user_id=2,T2 等 T1 的 user_id=1 → 死锁
+```
+
+> [!QUESTION] ❓ 思考一下
+> 转账操作为什么容易出现这个问题?因为"扣款方"和"收款方"在两个事务中角色互换了:T_A 先转 1→2,T_B 先转 2→1。如果两笔转账都按 `MIN(user_id)` → `MAX(user_id)` 的顺序操作,就不可能出现循环等待。
+
+**💡 修复方案**:在所有代码路径中统一加锁顺序(比如总是按 user_id 升序):
+
+```sql
+-- 统一按照 user_id 从小到大锁定
+-- 无论转出还是转入,都是先 lock MIN(a,b),再 lock MAX(a,b)
+```
+
+### 场景二:范围查询 + 间隙锁
+
+> [!WARNING] RC vs RR 的关键差异
+> 在 **REPEATABLE READ(RR)** 隔离级别下,InnoDB 会启用 Gap Lock。这意味着范围查询"锁定了一段区间"——不仅锁了存在的记录,还锁了它们之间的"空隙"。
+> 如果你不需要可重复读保证,**考虑降级到 READ COMMITTED(RC)**,Gap Lock 将不复存在。
+
+```sql
+-- 假设表中有 id = 1, 5, 10 三条记录
+
+-- 事务 A -- 事务 B
+BEGIN; BEGIN;
+SELECT * FROM items INSERT INTO items (id, name)
+ WHERE id BETWEEN 2 AND 9 VALUES (5, 'item5');
+ FOR UPDATE; -- 拿到 X 锁(id=5) + Gap(1,5) + Gap(5,10)
+ -- Gap(5,10) 阻塞了下面的插入
+
+INSERT INTO items (id, name) INSERT INTO items (id, name)
+ VALUES (5, 'test'); VALUES (7, 'new_item');
+ -- 尝试插入 id=5 → -- 尝试插入 id=7 → 落入 Gap(5,10)
+ -- 已被 T_A 的 NX 锁 -- 被 T_A 的 Gap Lock 阻塞!
+ -- T_A 试图插入 id=7 → 被 T_B 阻塞
+ -- → 死锁!
+```
+
+> [!NOTE] 为什么间隙锁会导致死锁?
+> 很多人不理解:`SELECT ... FOR UPDATE` 只是查数据,为什么要锁"不存在的记录"?
+> InnoDB 的设计哲学是:**防止幻读**。如果 T_A 查到 2~9 之间没有 id=7 的记录,等它稍后想插入时却被告知"已有人占了",这就是幻读。所以它在 5 和 10 之间也加了一把"虚拟钥匙"。
+>
+> 这个机制在大多数 OLTP 业务中是过度保护——换成 RC 就能彻底消除这类死锁。
+
+### 场景三:批量操作的非确定性顺序
+
+> [!TIP] 经验法则
+> **任何涉及多条记录的批量操作,都应当在代码层面显式排序。** SQL 引擎的优化器可能会根据统计信息、成本模型或执行计划改变行锁定的实际顺序,这与你的预期并不一致。
+
+```sql
+-- 批量删除 ORDER BY 不确定导致锁顺序不一致
+DELETE FROM orders WHERE id IN (1001, 1002, 1003); -- 实际锁顺序可能是 1003, 1001, 1002
+
+DELETE FROM orders WHERE id IN (1003, 1002, 1001); -- 期望顺序 1003, 1002, 1001
+
+-- IN 列表的顺序不一定等于锁定的顺序,尤其当 Optimizer 重排时
+
+-- ✅ 推荐写法:子查询中显式 ORDER BY
+DELETE FROM orders WHERE id IN (
+ SELECT id FROM (
+ SELECT id FROM orders WHERE status = 'cancelled' ORDER BY id ASC
+ ) AS ordered
+);
+```
+
+## SHOW ENGINE INNODB STATUS 分析
+
+> [!IMPORTANT] 只保留最近一次
+> `SHOW ENGINE INNODB STATUS` **只显示最新的死锁记录**。如果线上频繁发生死锁,旧记录会被覆盖。
+> 这就是为什么必须提前开启 `innodb_print_all_deadlocks`,把全量死锁写入错误日志。
+
+这是排查死锁的第一入口:
+
+```sql
+SHOW ENGINE INNODB STATUS\G
+```
+
+切换到 `trx` 段(向下滚动),定位到 `LATEST DETECTED DEADLOCK` 区域:
+
+```
+------------------------
+LATEST DETECTED DEADLOCK
+------------------------
+2026-05-16 10:30:45.123
+*** (1) TRANSACTION:
+TRANSACTION 123456, ACTIVE 5 sec starting index read
+mysql tables in use 1, locked 1
+LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
+MySQL thread id 7890, OS thread handle 140123, query id id 12345
+
+*** (1) WAITING FOR THIS LOCK to be granted:
+RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
+trx id 123456 lock_mode X locks rec but not gap waiting
+Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
+
+*** (2) TRANSACTION:
+TRANSACTION 123457, ACTIVE 3 sec inserting
+mysql tables in use 1, locked 1
+
+*** (2) HOLDING THE LOCK(S):
+RECORD LOCKS space id 42 page no 12 n bits 72 index PRIMARY of table `app`.`orders`
+trx id 123457 lock mode S locks rec but not gap
+Record lock, heap no 5 PHYSICAL RECORD: p8: 0x55 length 6: 1001
+
+*** (2) WAITING FOR THIS LOCK to be granted:
+RECORD LOCKS space id 42 page no 13 n bits 72 index idx_status of table `app`.`orders`
+trx id 123457 lock_mode X locks rec but not gap waiting
+Record lock, heap no 3 PHYSICAL RECORD: p8: 0x44 length 6: 2001
+
+*** WE ROLL BACK TRANSACTION (2)
+```
+
+### 解读模板
+
+> [!CHECKLIST] 逐行拆解法
+> 按以下顺序阅读,避免被大量信息干扰:
+> 1. **看时间**:`2026-05-16 10:30:45.123` → 确认是不是当前线上问题
+> 2. **定位两个事务**:`*** (1)` 和 `*** (2)`
+> 3. **分别看各自主张**:"持有的锁" + "等待的锁"
+> 4. **画等价图**:T1 holds A → wants B, T2 holds B → wants A → 环形依赖成立
+> 5. **找根因**:哪个操作触发了什么锁?为什么需要那个锁?
+
+```
+Transaction 1: 持有的锁 → 等待的锁
+Transaction 2: 持有的锁 → 等待的锁(将被回滚的那个)
+
+关键信息提取:
+- 哪个索引触发了死锁?(index PRIMARY / idx_status)
+- 锁的类型?(X lock, S lock, gap lock)
+- 涉及哪条记录?(heap no 5, physical record = 1001)
+- 被回滚的是哪个事务?(WE ROLL BACK TRANSACTION 2)
+```
+
+## 进阶排查手段
+
+### 使用 performance_schema 实时观察
+
+当 `SHOW ENGINE INNODB STATUS` 不够用时,可以直接查询 InnoDB 的运行时锁视图:
+
+```sql
+-- 查看当前阻塞链(谁在等谁)
+SELECT
+ r.trx_id AS waiting_trx_id,
+ r.trx_mysql_thread_id AS waiting_thread,
+ r.req_query AS waiting_query,
+ b.trx_id AS blocking_trx_id,
+ b.trx_mysql_thread_id AS blocking_thread,
+ b.trx_query AS blocking_query
+FROM performance_schema.data_lock_waits AS w
+INNER JOIN information_schema.innodb_trx AS b ON w.requesting_engine_tx_id = b.trx_id
+INNER JOIN information_schema.innodb_trx AS r ON w.blocking_engine_tx_id = r.trx_id;
+```
+
+> [!NOTE] performance_schema vs SHOW ENGINE
+> - `SHOW ENGINE`:**事后分析**——死锁已经发生、已经被回滚后,只能看到历史记录
+> - `performance_schema`:**实时监控**——可以看到正在发生的锁等待链,帮你提前发现即将爆发的问题
+>
+> 生产环境建议两者配合:`innodb_print_all_deadlocks` 兜底 + 监控脚本轮询 `data_lock_waits`。
+
+### 乐观锁替代方案
+
+并非所有场景都需要排他锁。考虑使用乐观锁(Optimistic Locking)来从根本上消除冲突:
+
+```sql
+-- 悲观锁:先加锁再判断
+BEGIN;
+SELECT balance FROM accounts WHERE user_id = 1 FOR UPDATE; -- 直接锁住
+UPDATE accounts SET balance = 900 WHERE user_id = 1;
+COMMIT;
+
+-- ✅ 乐观锁:先改再校验(基于版本号)
+UPDATE accounts SET balance = 900, version = version + 1
+WHERE user_id = 1 AND version = 5; -- 只有版本仍为 5 时才更新
+-- affected_rows = 1 → 成功;affected_rows = 0 → 重试
+```
+
+> [!QUESTION] ❓ 什么时候该用乐观锁?
+> 记住一个原则:**读多写少用乐观锁,写多读少用悲观锁**。如果你的业务并发写冲突概率很低(比如 < 5%),乐观锁的性能远超悲观锁——因为它不需要数据库层面的锁开销。但一旦冲突变频繁,回滚和重试的成本会超过锁本身。
+
+## 排查工作流
+
+```mermaid
+flowchart TD
+ A["发现死锁告警"] --> B["SHOW ENGINE INNODB STATUS"]
+ B --> C["提取涉及的事务 SQL"]
+ C --> D["复现死锁场景"]
+ D --> E{"根因分析"}
+
+ E -->|"加锁顺序不一致"| F["统一加锁顺序"]
+ E -->|"范围查询间隙锁"| G["缩小 WHERE 范围
或改用 RC"]
+ E -->|"批量操作无序"| H["ORDER BY 主键后再操作"]
+ E -->|"大事务持锁时间长"| I["拆小事务
减少持锁范围"]
+
+ F --> J["压测验证"]
+ G --> J
+ H --> J
+ I --> J
+
+ J --> K{"死锁消失?"}
+ K -->|是| L["🎉 上线监控"]
+ K -->|否| D
+
+ style L fill:#00D866,color:#fff
+```
+
+## Go 中的死锁处理
+
+在 Go 应用层应对死锁的核心思路是:**捕获错误码 1213 → 回滚事务 → 重试**。
+
+```go
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/go-sql-driver/mysql"
+)
+
+func WithRetry(db *sql.DB, retryTimes int, fn func(tx *sql.Tx) error) error {
+ for i := 0; i < retryTimes; i++ {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+
+ if err := fn(tx); err != nil {
+ _ = tx.Rollback()
+
+ // MySQL 错误码 1213 = ER_LOCK_DEADLOCK
+ var mysqlErr *mysql.MySQLError
+ if errors.As(err, &mysqlErr) && mysqlErr.Number == 1213 {
+ // 指数退避:第一次等 50ms,第二次 100ms,避免集体雪崩
+ time.Sleep(time.Millisecond * time.Duration(50*(1< [!TIP] 实际生产建议
+> - 重试次数建议 3~5 次,过多说明架构有根本问题
+> - 加上 Prometheus/Grafana 监控死锁重试率,超过阈值应当告警而非无限重试
+> - 对于支付、库存等敏感业务,考虑引入分布式乐观锁或消息队列串行化,而非依赖重试掩盖问题
+
+## 预防策略汇总
+
+| 策略 | 效果 | 实施难度 |
+|------|------|---------|
+| **固定加锁顺序** | 🏆 最有效 | 低 |
+| **缩小事务范围** | 减少持锁窗口 | 低 |
+| **使用 RC 隔离级别** | 消除 Gap Lock | 中(需评估业务影响)|
+| **批量操作加 ORDER BY PK** | 确定性顺序 | 低 |
+| **索引优化** | 减少范围扫描 | 中 |
+| **死锁检测超时设置** | 缩短失败等待时间 | 低 |
+
+```ini
+# 相关配置
+innodb_deadlock_detect = ON # 开启死锁检测(默认 ON)
+innodb_lock_wait_timeout = 50 # 锁等待超时(秒)
+innodb_print_all_deadlocks = ON # 记录所有死锁到错误日志
+```
+
+> [!WARNING] innodb_print_all_deadlocks
+> 默认情况下,只有最新的死锁会在 `SHOW ENGINE INNODB STATUS` 中显示。开启此选项后,所有死锁都会记录到 MySQL 错误日志中——这对分析问题至关重要。
+
+## 关联笔记
+
+- [[hhs/MySQL/26-锁机制总览]] — 各种锁类型及其交互规则
+- [[hhs/MySQL/25-MVCC 原理]] — MVCC 如何通过一致性读避免不必要的锁
+- [[hhs/DEV/Go-Database]] — Go 中数据库事务的最佳实践
diff --git a/hhs/MySQL/28-一致性读与当前读.md b/hhs/MySQL/28-一致性读与当前读.md
new file mode 100644
index 0000000..692b8b1
--- /dev/null
+++ b/hhs/MySQL/28-一致性读与当前读.md
@@ -0,0 +1,334 @@
+---
+tags: [MySQL, 一致性读, 当前读, snapshot read, current read]
+create time: 2026-05-16 00:00
+---
+
+# 一致性读 vs 当前读
+
+## 概述
+
+> [!QUESTION] 思考题
+> 事务 A 在执行 `SELECT * FROM orders WHERE id = 1`,同时事务 B 正在 `UPDATE orders SET amount = 100 WHERE id = 1` 并尚未 COMMIT——此时 A 应该读到旧值还是新值?
+
+InnoDB 为此提供了两种读模式:**一致性读(Consistent Nonlocking Read)**和**当前读(Current Read)**。它们的核心区别在于是否使用 MVCC 快照、是否获取锁。这个区别直接影响你对并发行为的预期,也是理解 InnoDB 事务隔离的基石。
+
+## 两种读的对比
+
+| 特性 | 一致性读(快照读) | 当前读 |
+|------|-------------------|--------|
+| **语句** | `SELECT`(不加锁) | `SELECT ... FOR UPDATE/SHARE MODE` |
+| | | `UPDATE` |
+| | | `DELETE` |
+| | | `INSERT` |
+| **使用 MVCC?** | ✅ 是,读历史版本 | ❌ 否,读最新已提交数据 |
+| **加锁?** | ❌ 不加锁 | ✅ 加 X 锁或 S 锁 |
+| **看到的是?** | Read View 时刻的数据 | 实际的、最新的行记录 |
+| **隔离级别依赖?** | ✅ 是(RR vs RC 行为不同)| ❌ 否(总是读最新)|
+
+> [!TIP] 核心直觉
+> - **一致性读** = 读「快照」→ 不阻塞别人,别人也阻塞不了你 → 适合报表、数据分析
+> - **当前读** = 读「真实」→ 拿到最新一行并锁住 → 适合余额扣减、库存扣减等写前校验场景
+
+```mermaid
+sequenceDiagram
+ participant T1 as Transaction A
+ participant DB as InnoDB
+ participant T2 as Transaction B
+
+ T1->>DB: BEGIN;
+ T2->>DB: BEGIN;
+
+ T1->>DB: SELECT * FROM t WHERE id=1;
+ Note over T1,DB: 一致性读 → 创建 ReadView,
读到 mvcc_version_A
+
+ T2->>DB: UPDATE t SET col="v2" WHERE id=1;
+ T2->>DB: COMMIT;
+
+ T1->>DB: SELECT * FROM t WHERE id=1;
+ Note over T1,DB: 一致性读 → 复用同一 ReadView,
仍读到 mvcc_version_A ✅
+
+ T1->>DB: SELECT * FROM t WHERE id=1 FOR UPDATE;
+ Note over T1,DB: 当前读 → 绕过 ReadView,
读到 v2 + 获取 X 锁
+```
+
+## 底层原理:Undo Log + Version Chain
+
+一致性读之所以能读到「历史版本」,核心依赖 InnoDB 的 **undo log** 和行记录中的 **version chain**。
+
+### Row Record 的结构(MVCC 视角)
+
+每一行记录除了业务数据外,还隐藏了两个关键字段:
+
+| 隐藏字段 | 说明 |
+|----------|------|
+| `DB_TRX_ID` | 最后修改这行记录的事务 ID(6 字节) |
+| `DB_ROLL_PTR` | 回滚指针,指向 undo log 中这条记录的旧版本(20 字节) |
+
+### Undo Log 链(Version Chain)的形成
+
+```mermaid
+flowchart LR
+ A["新写入的行"] --> B["旧版本 (undo_log_1)"]
+ B --> C["更早版本 (undo_log_2)"]
+ C --> D["再早版本 (undo_log_3)"]
+
+ style A fill:#00D866,color:#fff
+ style B fill:#FF9F43,color:#000
+ style C fill:#FF9F43,color:#000
+ style D fill:#FF9F43,color:#000
+```
+
+每次 UPDATE / DELETE 操作时,InnoDB 不会直接覆盖原行,而是:
+
+1. **将旧行数据写入 undo log**(形成版本链上一个节点)
+2. **在新行上记录前一个版本的回滚指针** (`DB_ROLL_PTR`)
+3. 更新 `DB_TRX_ID` 为当前事务 ID
+
+> [!QUESTION] 为什么是链表而不是数组?
+> 因为版本数量在写入时无法预知。链表允许 O(1) 追加新版本,且通过 `DB_ROLL_PTR` 可以高效遍历——只沿着真正需要回溯的路径走。
+
+### Read View 如何从版本链中找到可见版本
+
+```mermaid
+flowchart TD
+ A["SELECT 执行"] --> B["构建 ReadView
m_ids, min_trx_id, max_trx_id"]
+ B --> C{"遍历 version chain"}
+ C --> D["检查当前版本的 DB_TRX_ID"]
+
+ D --> E{"trx_id 在 ReadView 中可见?"}
+
+ E -->|是| G["✅ 返回此版本"]
+ E -->|否| F["沿 DB_ROLL_PTR 到上一版本"]
+ F --> C
+
+ F --> H{"是否已无更多版本?"}
+ H -->|是| I["返回空 — 无可视数据"]
+
+ style B fill:#00B6BC,color:#fff
+ style G fill:#00D866,color:#fff
+ style I fill:#FF6B6B,color:#fff
+```
+
+> [!NOTE] 可见性判断规则
+> - 若 `trx_id == ReadView.m_ids` 中的某个值 → **当前事务自己改的,可见**
+> - 若 `trx_id < min_trx_id` → **提交于快照之前的,可见**
+> - 若 `trx_id >= max_trx_id` → **提交于快照之后的,不可见**
+> - 若在 `[min_trx_id, max_trx_id)` 之间且在 `m_ids` 列表中 → **可见(该事务在 ReadView 创建时未提交)**
+> - 若在 `[min_trx_id, max_trx_id)` 之间但**不在** `m_ids` 列表中 → **不可见(该事务已提交)**
+
+### 一致性读的完整流程
+
+```mermaid
+flowchart TD
+ A["普通 SELECT"] --> B{"目标行是否有锁?"}
+
+ B -->|无锁| C["直接读取当前行"]
+
+ B -->|有锁| D["跳过当前行"]
+ D --> E{"DB_ROLL_PTR 是否存在?"}
+
+ E -->|否| F["无可视版本, 返回空"]
+ E -->|是| G["跟随指针读取 undo log 旧版本"]
+ G --> H{"旧版本 trx_id 可见?"}
+
+ H -->|是| I["✅ 返回可见版本"]
+ H -->|否| J{"还有更早版本?"}
+
+ J -->|是| G
+ J -->|否| F
+
+ C --> K["返回结果"]
+ I --> K
+ F --> K
+
+ style B fill:#FF9F43,color:#000
+ style I fill:#00D866,color:#fff
+ style F fill:#FF6B6B,color:#fff
+```
+
+> [!TIP] 一致性读的性能优势
+> 由于不需要加锁、不需要等待锁释放,一致性读在大量只读场景下几乎不产生并发开销。这就是为什么报表查询、数据导出都应该用普通 `SELECT`——既不影响业务写入,也避免了自己被阻塞。
+
+## 深入理解一致性读
+
+> [!NOTE] 为什么叫「快照读」?
+> 因为它读的不是磁盘上的最新数据,而是某个时间点的数据「快照」。在 RR 模式下,这个快照在事务第一次 SELECT 时就冻结了;在 RC 模式下,每次 SELECT 都刷新快照。
+
+## 深入理解当前读
+
+```sql
+-- 所有当前读语句
+SELECT ... LOCK IN SHARE MODE; -- 加 S 锁(共享读锁)
+SELECT ... FOR UPDATE; -- 加 X 锁(排他写锁)
+UPDATE ... -- 隐式加 X 锁
+DELETE ... -- 隐式加 X 锁
+INSERT ... -- 隐式加 X 锁(对被插入的行)
+```
+
+### FOR UPDATE 的实际行为
+
+```sql
+-- 会话 A
+BEGIN;
+SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
+-- 加 X 锁到 user_id=100 对应的所有索引记录
+-- 包括聚簇索引记录和所有二级索引记录
+
+-- 会话 B(尝试读取)
+BEGIN;
+SELECT * FROM orders WHERE user_id = 100;
+-- ✅ 能读到!(一致性读,不走当前读路径)
+-- 读到的是 FOR UPDATE 之前的版本
+
+-- 会话 B(尝试修改)
+UPDATE orders SET amount = 50 WHERE user_id = 100;
+-- ⏳ 阻塞!需要 X 锁,被会话 A 持有
+-- 直到 A COMMIT 或 ROLLBACK
+```
+
+### FOR SHARE MODE 的行为
+
+```sql
+-- 会话 A
+BEGIN;
+SELECT * FROM orders WHERE user_id = 100 LOCK IN SHARE MODE;
+-- 加 S 锁(其他事务也可以加 S 锁,但都不能加 X 锁)
+
+-- 会话 B
+BEGIN;
+SELECT * FROM orders WHERE user_id = 100 LOCK IN SHARE MODE;
+-- ✅ 可以同时获得 S 锁(共享)
+
+UPDATE orders SET amount = 50 WHERE user_id = 100;
+-- ⏳ 阻塞!需要 X 锁,但有 S 锁在
+```
+
+## 二级索引的回表效应
+
+当前读在二级索引上有一个特殊行为:不仅锁二级索引记录,还要**回表锁住聚簇索引记录**。
+
+```mermaid
+sequenceDiagram
+ participant T as 事务
+ participant SI as 二级索引 idx_user_id
+ participant CI as 聚簇索引 PK
+
+ T->>SI: UPDATE orders SET amount=? WHERE user_id=100
+ SI->>SI: 找到所有 user_id=100 的记录,
得到 pk=[1,5,8]
+
+ loop 遍历每个 pk
+ SI->>CI: 回表加 X 锁
+ CI-->>SI: 已锁
+ end
+
+ SI-->>T: UPDATE 完成
+```
+
+> [!TIP] 这意味着什么?
+> 用二级索引做 UPDATE/DELETE/FOR UPDATE 时,锁的范围比看起来更大——它覆盖了二级索引记录 + 回表后的聚簇索引记录。如果二级索引区分度不高(比如 gender 列),会导致大量行被锁住。
+
+## 实战决策树
+
+```mermaid
+flowchart TD
+ Q["你需要读数据"] --> Decision{"要读最新提交的数据吗?"}
+
+ Decision -->|不需要 / 读旧数据也可接受| Snapshot["用普通 SELECT
一致性读 ✅"]
+ Decision -->|必须读最新数据| CurrentRead["用当前读"]
+
+ CurrentRead --> Purpose{"接下来要修改这些数据吗?"}
+ Purpose -->|是| FU["SELECT ... FOR UPDATE
拿 X 锁"]
+ Purpose -->|否,只是防别人改| FS["SELECT ... LOCK IN SHARE MODE
拿 S 锁"]
+
+ style Snapshot fill:#00D866,color:#fff
+ style FU fill:#00B6BC,color:#fff
+ style FS fill:#FF9F43,color:#000
+ style Decision fill:#FF9F43,color:#000
+```
+
+## 实战场景与避坑指南
+
+### ✅ 典型场景:扣减余额(防超卖)
+
+```sql
+-- 正确的做法:当前读 + FOR UPDATE
+BEGIN;
+SELECT balance FROM user_wallet WHERE user_id = ? FOR UPDATE;
+-- ⏱️ 读到的是最新余额,同时锁住行
+
+UPDATE user_wallet SET balance = balance - 50 WHERE user_id = ?;
+COMMIT;
+```
+
+> [!WARNING] 为什么不能用普通 SELECT?
+> 如果用了 `SELECT balance FROM user_wallet WHERE user_id = ?`(一致性读),两个并发事务可能都读到旧的 balance=200,然后各自减 50 → 最后余额变成 150 而不是 100。**数据丢失了**。这就是为什么写前校验必须用当前读。
+
+### ✅ 典型场景:报表查询
+
+```sql
+-- 正确的做法:纯 SELECT,不加锁
+SELECT product_name, SUM(quantity)
+FROM orders
+WHERE created_at >= '2026-01-01'
+GROUP BY product_name;
+```
+
+这种场景不需要读「此刻的最新数据」,只要保证自身的事务内一致性即可。使用普通 SELECT 不会影响其他事务的写入性能。
+
+### 🚫 常见误区:误以为 `FOR UPDATE` 会阻塞别人的 `SELECT`
+
+很多开发者以为加了 `FOR UPDATE`,其他人就查不了这行了——**这是错误的**。
+
+| 会话 A | 会话 B | 结果 |
+|--------|--------|------|
+| `SELECT ... FOR UPDATE` | `SELECT ...` | ✅ B 能读到(快照读)|
+| `SELECT ... FOR UPDATE` | `SELECT ... FOR UPDATE` | ⏳ B 被阻塞 |
+| `SELECT ... FOR UPDATE` | `UPDATE ...` | ⏳ B 被阻塞 |
+| `SELECT ... FOR UPDATE` | `DELETE ...` | ⏳ B 被阻塞 |
+
+**结论**:`FOR UPDATE` 只影响其他事务的**写操作**和**当前读**,不影响普通的 `SELECT`。
+
+### 🚫 常见误区:忽略二级索引的回表锁
+
+```sql
+-- 假设 orders 表上有二级索引 idx_user_id(user_id)
+BEGIN;
+SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
+```
+
+这里不仅锁定了 `idx_user_id = 100` 的所有索引记录,还通过回表对每个对应的**聚簇索引主键行**加了 X 锁。
+
+> [!QUESTION] 如果 user_id 上有 100 万条记录,这个 FOR UPDATE 会怎样?
+> 会锁住 100 万个聚簇索引记录!在 RR 模式下还会加间隙锁,导致整个索引范围都被锁定。此时任何其他基于 `user_id` 或主键的 INSERT / UPDATE 都会被阻塞。**这就是为什么大范围的 FOR UPDATE 是线上事故的高发原因。**
+
+### 🚫 陷阱:Undo Log 无限膨胀
+
+每次 UPDATE 操作都会将旧版本写入 undo log。如果一个热点行被高频更新,version chain 会越来越长:
+
+```
+新值 ← v_n ← v_{n-1} ← ... ← v_2 ← v_1 ← 初始值
+```
+
+当一致性读需要遍历这条长链时,性能会逐渐下降。在极端情况下可能导致:
+- **查询变慢**:遍历越长的 version chain 消耗越多 CPU
+- **undo log 文件膨胀**:占用大量磁盘空间
+
+**建议**:避免对同一行做高频的小幅度更新(例如计数器),考虑改为追加写入历史表。
+
+## RC vs RR 下的差异总结
+
+| 场景 | RR 下的行为 | RC 下的行为 |
+|------|-----------|-----------|
+| `SELECT`(普通) | 第一次 SELECT 创建 ReadView,之后复用 | 每次 SELECT 创建新 ReadView |
+| `SELECT ... FOR UPDATE` | 读最新 + 锁记录 | 读最新 + 锁记录(与 RR 相同)|
+| `UPDATE WHERE` 条件列 | 加 Next-Key Lock(含间隙)| 只加 Record Lock(不含间隙)|
+| 同一个事务内多次读 | 始终看到一致快照 | 每次可能看到新提交的数据 |
+
+> [!QUESTION] 什么时候 RC 和 RR 的行为会「打架」?
+> 当你在同一个连接中混合使用了 RC 和 RR(通过 `SET SESSION` 切换隔离级别)。例如你的框架默认用 RR,但某个关键接口临时切了 RC。这时同一个事务内的 SELECT 行为不一致——前半段用旧 ReadView,后半段用新 ReadView。建议在事务开始时明确设置隔离级别,并在事务结束后恢复。
+
+## 关联笔记
+
+- [[hhs/MySQL/25-MVCC 原理]] — ReadView 的构造和版本链查找
+- [[hhs/MySQL/26-锁机制总览]] — 当前读涉及的 S 锁和 X 锁
+- [[hhs/GORM/08-事务管理]] — GORM 中的 FirstForUpdate / SetLock 用法
diff --git a/hhs/MySQL/29-Binary Log.md b/hhs/MySQL/29-Binary Log.md
new file mode 100644
index 0000000..2691563
--- /dev/null
+++ b/hhs/MySQL/29-Binary Log.md
@@ -0,0 +1,279 @@
+---
+tags: [MySQL, Binary Log, binlog, WAL]
+create time: 2026-05-16 00:00
+---
+
+# Binary Log(Binlog)
+
+## 概述
+
+Binlog 是 MySQL Server 层生成的**逻辑日志**,记录所有修改数据的 SQL 语句(或行变更)。它是主从复制、时间点恢复和增量备份的基础。
+
+### 知识地图
+
+```mermaid
+flowchart LR
+ A["Binary Log"] --> B["主从复制
从库通过 relay log 重放 binlog"]
+ A --> C["时间点恢复 PITR
恢复到任意精确时刻"]
+ A --> D["增量备份
基线备份 + binlog 增量"]
+ A --> E["审计分析
追踪数据变更历史"]
+ A --> F["崩溃一致性
与 Redo Log 2PC 协作"]
+
+ style A fill:#6A0DAD,color:#fff
+```
+
+> [!TIP] 读这一页之前建议先了解
+> 对 Binlog 的理解建立在 InnoDB 存储引擎基础之上。**建议先看**: [[hhs/MySQL/21-InnoDB 架构]] → 本文档 → [[hhs/MySQL/30-主从复制]]
+
+## Binlog 与 Redo Log 的根本区别
+
+```mermaid
+graph LR
+ subgraph Redo_Log["Redo Log"]
+ R1["InnoDB Engine 专属"]
+ R2["物理日志
记录页级修改"]
+ R3["循环写入
固定大小"]
+ R4["保证崩溃恢复"]
+ end
+
+ subgraph Binlog["Binlog"]
+ B1["Server Layer 通用
所有引擎都写"]
+ B2["逻辑日志
记录 SQL 或行变更"]
+ B3["追加写入
可无限增长"]
+ B4["保证主从一致 + PITR"]
+ end
+
+ style R1 fill:#C44569,color:#fff
+ style B1 fill:#00B6BC,color:#fff
+```
+
+### 关键对比
+
+| 特性 | Redo Log | Binlog |
+|------|----------|--------|
+| **层级** | InnoDB 引擎层 | MySQL Server 层 |
+| **范围** | 只有 InnoDB 使用 | 所有引擎都可使用 |
+| **内容** | 物理:页偏移+字节修改 | 逻辑:SQL 或行变更 |
+| **写入方式** | 循环覆盖 | 追加 append-only |
+| **用途** | Crash Recovery | 主从复制、PITR、审计 |
+| **2PC 协调** | Phase 1: Redo prepare | Phase 2: Binlog fsync → redo commit |
+
+## Binlog Format 三种模式
+
+### Statement 模式
+
+每条修改数据的 SQL 作为一条 event 写入。主从复制时,从库直接执行这条 SQL:
+
+```
+# At 12345
+#260516 10:30:00 server id 1 end_log_pos 12378 CRC32 0xabc123
+UPDATE orders SET status = 'shipped' WHERE id = 100;
+```
+
+> [!QUESTION] Statement 模式简单直观,那为什么还要有其他模式?
+>
+> 关键问题:**NOW()、RAND()、UUID() 这些非确定性函数**。主库和从库的执行时间不同,结果就不同——这会导致主从数据不一致。例如:`UPDATE scores SET grade = NOW()` 在主库和执行时是 10:30:00,但从库可能在 10:30:01 执行,导致两个库的该记录值不同。
+
+| 优点 | 缺点 |
+|------|------|
+| 体积小,binlog 少 | 某些函数(NOW(), RAND())结果在主从不一致 |
+| 无需额外存储空间 | INSERT ... SELECT 产生大量 binlog |
+| | 复杂触发器行为可能不同 |
+
+### Row 模式
+
+只记录行的实际变化(增删改前后的数据),不关心 SQL 是什么。从库拿到的是「数据快照」,直接重放即可:
+
+```
+### @1=100 (BIGINT)
+### @2='pending' (VARCHAR(20))
+### @3=50.00 (DECIMAL(10,2))
+### @4='2026-05-16 10:30:00' (DATETIME)
+### UPDATE `app_db`.`orders`
+### WHERE @1=100
+### @2='pending'
+### @3=50.00
+### @4='2026-05-16 10:30:00'
+### SET
+### @1=100
+### @2='shipped'
+### @3=50.00
+### @4='2026-05-16 10:30:00'
+```
+
+> [!TIP] 理解 Row 模式的关键
+> 上面每行 `@n=值 (类型)` 表示第 n 个列的值和 MySQL 内部类型。WHERE 子句定位原行,SET 子句给出新值。虽然比 Statement 模式冗长很多,但**完全不受函数非确定性影响**——这就是为什么生产环境强烈推荐 ROW。
+
+| 优点 | 缺点 |
+|------|------|
+| 主从数据绝对一致 | 体积极大(尤其是大批量 UPDATE)|
+| 支持 DDL 复制 | DELETE 全表扫描时极度膨胀 |
+| 不依赖函数确定性 | |
+| | BINLOG 格式无法直接阅读 |
+
+### Mixed 模式
+
+Statement 为主,遇到不确定情况切 Row:
+
+| 触发条件 | 切换为 Row |
+|---------|-----------|
+| 表没有主键 | ✅ |
+| 使用了 UUID() / RAND() | ✅ |
+| INSERT DELAYED | ✅ |
+| UPSERT (REPLACE/ON DUPLICATE KEY) | ✅ |
+
+```ini
+binlog_format = STATEMENT # 传统默认(已废弃)
+binlog_format = ROW # 生产推荐 ✅
+binlog_format = MIXED # 折中方案
+```
+
+> [!WARNING] 强烈建议使用 ROW 模式
+> Oracle 官方在 5.7+ 已将默认值改为 ROW。Statement 模式下「主库正确但从库错误」是常见的线上事故原因。
+
+### 总结:如何选择?
+
+> [!QUESTION] 如果生产环境必须三选一,你会选哪个?
+>
+> 答案:**ROW**。理由很简单——数据一致性永远排在体积和可读性之前。虽然 ROW 模式的 binlog 体积更大,但它消除了所有不确定性,而节省空间带来的好处远不及一次主从不一致造成的损失。
+
+## Binlog 文件结构
+
+```
+/var/lib/mysql/
+├── mysql-bin.000001 ← 二进制日志文件
+├── mysql-bin.000002 ← 自动增长
+├── mysql-bin.000003
+├── mysql-bin.index ← 索引文件,列出所有 binlog
+└── binary.log ← 备用名称(某些配置)
+```
+
+### Binlog 文件组织
+
+```mermaid
+graph TD
+ A["mysql-bin.index"] -->|指向| B["mysql-bin.000001"]
+ A -->|指向| C["mysql-bin.000002"]
+ A -->|指向| D["mysql-bin.nnn"]
+ B --> E["Format Description Event"]
+ B --> F["Query Event"]
+ B --> G["Rows / Xid Events"]
+ C --> H["Format Description Event"]
+
+ style A fill:#FF8C00,color:#fff
+```
+
+> [!QUESTION] 为什么需要 index 文件?
+> Binlog 是 append-only 的,新的事务不断写入。index 文件记录了所有有效的 binlog 文件列表,这样 MySQL 启动时可以快速定位到最新的 binlog 位置,而不用扫描整个磁盘。
+
+### 四种核心 Event
+
+| Event 类型 | 作用 | 示例 |
+|-----------|------|------|
+| **Query Event** | 执行 DDL 或非事务型 DML | CREATE TABLE, ALTER TABLE |
+| **Rows Event** | 记录行变更(ROW 模式)| INSERT_ROWS, UPDATE_ROWS, DELETE_ROWS |
+| **Xid Event** | 事务提交标记 | COMMIT #1001 |
+| **Format Description Event** | 描述 binlog 版本和格式 | 每个文件第一条 |
+
+## Binlog 核心参数
+
+```ini
+[mysqld]
+server_id = 1 # 必须唯一,用于主从识别
+log_bin = /var/log/mysql/mysql-bin # 开启并指定路径
+binlog_format = ROW # 推荐 ROW
+binlog_row_image = FULL # 完整行(可选值:FULL / MINIMAL / NOBLOB)
+binlog_expire_logs_seconds = 604800 # 7 天自动清理
+max_binlog_size = 100M # 单文件最大大小
+sync_binlog = 1 # 每次 commit 都刷盘(安全)
+gtid_mode = ON # GTID 模式
+enforce_gtid_consistency = ON # 强制 GTID 兼容的事务
+binlog_checksum = CRC32 # 完整性校验
+```
+
+### binlog_row_image 参数详解
+
+> [!TIP] 为什么默认是 FULL?
+> `FULL` 记录修改前后的所有列值,最简单也最安全。当某些列是 BLOB 类型且非常大时,可以改用 `MINIMAL`(只记录被修改的列 + 主键)来节省空间和性能。
+
+| 值 | 行为 | 适用场景 |
+|----|------|---------|
+| **FULL** | 记录所有列的值 | 通用场景,默认值 ✅ |
+| **MINIMAL** | 仅记录被修改的列 + 必要索引列 | 大表 UPDATE,减少 binlog 体积 |
+| **NOBLOB** | 与 FULL 相同,但排除 BLOB/TEXT 列 | 包含大文本字段的表 |
+
+### sync_binlog 与安全性
+
+| 值 | 行为 | 性能损失 | 丢数据风险 |
+|----|------|---------|-----------|
+| **0** | OS 决定何时刷盘 | 最低 | 断电丢失整个缓冲区 |
+| **1** | 每次 commit 都刷盘 | 最高 | 零 ❌ |
+| **N** | 每 N 次 commit 刷一次 | 中等 | 最多丢 N 个事务 |
+
+> [!TIP] sync_binlog 与 innodb_flush_log_at_trx_commit
+> - 两者都设为 1 = 最强的 ACID 保证
+> - 两者配合保证了即使服务器宕机也不会丢失任何已提交事务
+> - 对 SSD 磁盘,sync_binlog=1 的性能损耗约 5%~10%,完全可接受
+
+### gtid_mode 的作用
+
+> [!TIP] 为什么推荐使用 GTID?
+> 传统基于 Position 的主从复制在故障转移时需要手动计算 position,容易出错。GTID 为每个事务分配全局唯一 ID,复制时只需要知道"哪些事务已经执行过"即可,大大简化了主从切换和恢复流程。
+
+### binlog 清理策略
+
+> [!WARNING] 千万不要直接删除 binlog 文件!
+>
+> 误删会导致 MySQL 无法识别索引与磁盘文件的对应关系,引发启动失败或数据不一致。
+
+| 方式 | 命令 | 说明 |
+|------|------|------|
+| **按时间自动清理** | `binlog_expire_logs_seconds = 604800` | 7 天过期自动回收 ✅推荐 |
+| **手动清理** | `PURGE BINARY LOGS BEFORE '2026-05-09 00:00:00';` | 保留指定日期之前的 binlog |
+| **清理到指定文件** | `PURGE BINARY LOGS TO 'mysql-bin.000003';` | 清除 000003 之前的所有 binlog |
+| **清空所有(谨慎)** | `RESET MASTER;` | ⚠️ 仅用于测试环境 |
+
+## 查看和解析 Binlog
+
+### 服务端查询
+
+```sql
+-- 当前正在写的 binlog 文件(主从架构中查看主库状态)
+SHOW MASTER STATUS\G
+
+-- 列出所有 binlog 文件及大小
+SHOW BINARY LOGS;
+
+-- 查看当前 binlog 位置(5.7+ 推荐用法)
+SHOW BINARY LOG STATUS\G
+```
+
+> [!TIP] `SHOW MASTER STATUS` vs `SHOW BINARY LOG STATUS`
+> MySQL 8.0.23+ 更推荐使用 `BINARY LOG STATUS`,因为从库也可以执行这个命令(MASTER 一词在复制语境下对从库不语义准确)。两者功能相同。
+
+### 命令行解析
+
+```bash
+# 命令行解析 binlog(-v 展开列名,DECODE-ROWS 以可读格式展示行变更)
+mysqlbinlog --base64-output=DECODE-ROWS -v mysql-bin.000001
+
+# 按时间范围解析(常用于精确时间点恢复)
+mysqlbinlog --start-datetime="2026-05-16 00:00:00" \
+ --stop-datetime="2026-05-16 12:00:00" \
+ mysql-bin.000001 > decoded.sql
+
+# 按 position 位置解析(精确到事务边界)
+mysqlbinlog --start-position=1234 --stop-position=5678 mysql-bin.000001
+```
+
+> [!QUESTION] 如何定位一个错误操作的具体 position?
+>
+> 1. 用 `mysqlbinlog --start-datetime="..." --stop-datetime="..." file | grep "DELETE FROM"` 找到可疑 SQL
+> 2. 查看 SQL 上方的 `# at XXXXX` 确定起始 position
+> 3. 再结合 `--stop-position` 可以只回滚特定事务区间
+
+## 关联笔记
+
+- [[hhs/MySQL/30-主从复制]] — Binlog 是主从复制的基石
+- [[hhs/MySQL/23-ACID 与原子性实现]] — Binlog 与 Redo Log 的两阶段提交协议
+- [[hhs/MySQL/32-备份与恢复]] — 基于 binlog 的 PITR 时间点恢复
diff --git a/hhs/MySQL/30-主从复制详解.md b/hhs/MySQL/30-主从复制详解.md
new file mode 100644
index 0000000..4cc7be5
--- /dev/null
+++ b/hhs/MySQL/30-主从复制详解.md
@@ -0,0 +1,356 @@
+---
+tags: [MySQL, 主从复制, Replication, Semi-Sync, GTID]
+create time: 2026-05-16 10:30
+---
+
+# 主从复制
+
+## 概述
+
+MySQL 主从复制是通过 Binlog 将主库的数据变更传播到从库的过程。它是高可用架构的基础组件,也是读写分离的前提。
+
+## 复制架构
+
+```mermaid
+flowchart LR
+ subgraph "Master"
+ M1["业务写入 → DML"] --> M2["Server 层生成 Binlog"]
+ M2 --> M3["Binlog Dump Thread"]
+ end
+
+ subgraph "Network"
+ M3 -.Binlog Stream.-> I1["IO Thread"]
+ end
+
+ subgraph "Slave"
+ I1 --> S1["Relay Log
中继日志"]
+ S1 --> S2["SQL Thread"]
+ S2 --> S3["数据文件更新"]
+ end
+
+ style M2 fill:#C44569,color:#fff
+ style S2 fill:#00B6BC,color:#fff
+```
+
+### 三线程模型
+
+| 线程 | 归属 | 职责 |
+|------|------|------|
+| **Binlog Dump Thread** | Master | 连接到一个 Slave 就创建一个,负责发送 binlog |
+| **IO Thread** | Slave | 连接 Master,拉取 binlog 写入本地 Relay Log |
+| **SQL Thread** | Slave | 读取 Relay Log 重放应用到本地数据库 |
+
+## 同步模式
+
+```mermaid
+flowchart TD
+ subgraph "异步复制 Async"
+ A1["Client 写入 Master"] --> A2["Master 刷盘返回 OK"]
+ A2 -->|"后续"| A3["异步发给 Slave"]
+ end
+
+ subgraph "半同步复制 Semi-Sync"
+ S1["Client 写入 Master"] --> S2["Master 刷盘"]
+ S2 --> S3["等待 ≥1 个 Slave ACK"]
+ S3 -->|"超时 10s"| S4["降级为异步 ⚠️"]
+ S3 -->|"收到 ACK"| S5["返回 OK"]
+ S5 --> S6["异步发给其他 Slave"]
+ end
+
+ subgraph "组复制 Group Replication"
+ G1["Client 写入"] --> G2["Paxos 共识"]
+ G2 --> G3["多数派确认"]
+ G3 --> G4["并行回放"]
+ end
+
+ style A3 fill:#FF9F43,color:#000
+ style S5 fill:#00D866,color:#fff
+ style G3 fill:#00B6BC,color:#fff
+```
+
+### 异步复制(默认)
+
+最简单的模式——Master 写完就不管了,异步发给 Slave。
+
+```sql
+-- MySQL 5.5 (已弃用)
+-- CHANGE MASTER TO
+-- MASTER_HOST='10.0.1.10', ...
+
+-- ✅ MySQL 8.0+ 标准语法
+CHANGE REPLICATION SOURCE TO
+ SOURCE_HOST='10.0.1.10',
+ SOURCE_USER='repl_user',
+ SOURCE_PASSWORD='password',
+ SOURCE_PORT=3306,
+ SOURCE_AUTO_POSITION = 1; -- GTID 模式自动定位
+
+START REPLICA;
+```
+
+> [!WARNING] 异步复制的风险
+> Master 宕机后,尚未传输到 Slave 的数据会丢失。对于金融系统这是不可接受的。
+
+### 半同步复制(Semi-Sync)
+
+至少一个从库确认收到 Binlog 后,Master 才返回写成功。
+
+```ini
+# Master 端配置
+plugin_load_add = semisync_master
+rpl_semi_sync_master_enabled = 1
+rpl_semi_sync_master_timeout = 10000 # 10 秒超时后降级为异步
+rpl_semi_sync_master_wait_point = AFTER_SYNC # 刷盘后再等待
+```
+
+```ini
+# Slave 端配置
+plugin_load_add = semisync_slave
+rpl_semi_sync_slave_enabled = 1
+```
+
+> [!NOTE] 半同步不是银弹
+> - 从库收到 Binlog ≠ 已从磁盘持久化
+> - 如果 Master 和从库同时断电,仍可能丢数据
+> - 网络分区时 Master 会超时降级为异步
+>
+> **最佳实践**:Semi-Sync + 定期备份 + PITR 恢复组合使用
+
+## GTID 复制
+
+GTID(Global Transaction Identifier)给每个事务分配全局唯一的 ID,替代传统的 position-based 复制。
+
+```
+GTID 格式: source_id:transaction_id
+例: 3E11FA47-71CA-11E1-9E33-C80AA9429562:23
+ ↑ UUID of server ↑ Sequence number
+```
+
+### GTID 复制流程
+
+```mermaid
+flowchart TD
+ A["Client 事务写入 Master"] --> B["Server 层分配 GTID
source_id:next_seq"]
+ B --> C["写入 Binlog 事件
带 GTID 标记"]
+ C --> D["Binlog Dump 线程发送
GTID + 事件到 Slave"]
+ D --> E["Slave IO Thread 写入
Relay Log (含 GTID)"]
+ E --> F{"GTID Set
是否已包含此 GTID?"}
+ F -->|是| G["跳过重复事件 ✅"]
+ F -->|否| H["SQL Worker 重放事务"]
+ H --> I["更新 Slave gtid_executed"]
+ G --> J["继续下一个事件"]
+ I --> J
+
+ style B fill:#C44569,color:#fff
+ style F fill:#FFD166,color:#000
+ style H fill:#00B6BC,color:#fff
+```
+
+> [!NOTE] GTID 的一致性约束
+> 启用 `enforce_gtid_consistency = ON` 后,以下操作被**禁止**:
+> - 对非事务引擎(如 MyISAM)的 DML
+> - 创建/删除临时表
+> - `CREATE TABLE ... SELECT`(混合语句级和语句级二进制日志)
+> - 对视图的 DML(无法追踪来源表)
+
+### GTID vs 传统 Position
+
+| 特性 | Position-based | GTID-based |
+|------|---------------|------------|
+| **定位方式** | 文件名 + position 号 | GTID set |
+| **跨库切换** | 需要手动查 position | 自动定位 |
+| **跳过事务** | 困难 | `SET gtid_next = '...'; BEGIN; COMMIT; SET gtid_next = AUTOMATIC;` |
+| **一致性检查** | 手动比对 | `pt-table-checksum` + GTID 验证 |
+
+```ini
+# 启用 GTID
+gtid_mode = ON
+enforce_gtid_consistency = ON
+master_info_repository = TABLE # 复制信息存在表中而非文件
+relay_log_info_repository = TABLE
+```
+
+## 并行复制(Multithreaded Slave, MTS)
+
+MySQL 5.6+ 引入从库并行回放,大幅提升复制延迟处理能力。
+
+```ini
+# MySQL 5.x(已弃用)
+# slave_parallel_type = LOGICAL_CLOCK
+# slave_parallel_workers = 8
+
+# ✅ MySQL 8.0+ 标准命名
+replica_parallel_type = LOGICAL_CLOCK # 基于 GTID 的并发回放
+replica_parallel_workers = 8 # 并发 worker 数量
+```
+
+### MTS 并行模式对比
+
+```mermaid
+flowchart LR
+ subgraph "DATABASE(默认)"
+ D1["Relay Log 事件"] --> D2{"按 database\n分桶"}
+ D2 -->|"db_a"| W1["Worker 1"]
+ D2 -->|"db_b"| W2["Worker 2"]
+ D2 -->|"db_c"| W3["Worker 3"]
+ W1 & W2 & W3 --> J1["写入数据"]
+ end
+
+ subgraph "GROUP_TRANSACTIONS"
+ G1["Relay Log 事件"] --> G2{"判断事务\n依赖关系"}
+ G2 -->|"无冲突"| A1["组 1 → 并行执行"]
+ G2 -->|"有冲突"| A2["组 2 → 串行等待"]
+ G1 -->|"无冲突"| A1
+ A1 --> J2["写入数据"]
+ A2 --> J2
+ end
+
+ style A1 fill:#00D866,color:#fff
+ style A2 fill:#FF9F43,color:#000
+```
+
+| 维度 | `DATABASE` | `GROUP_TRANSACTIONS` |
+|------|-----------|---------------------|
+| **分组依据** | database 名称 | 事务间的写集依赖关系 |
+| **并发度** | 受限于 database 数量 | 取决于并发事务比例,通常更高 |
+| **精确度** | 粗粒度——同一 DB 内串行 | 细粒度——无依赖即可并行 |
+| **适用场景** | schema 设计良好的系统 | 多库共享、跨库事务较多的场景 |
+| **引入版本** | MySQL 5.6 | MySQL 5.7+ |
+
+> [!TIP] 如何选择
+> - 如果业务按 database 做数据隔离(每个 tenant 一个 DB),选 `DATABASE`
+> - 如果多个业务共享少数几个 database,选 `GROUP_TRANSACTIONS`
+
+### LOGICAL_CLOCK 原理
+
+> [!INFO] `LOGICAL_CLOCK` = GTID 驱动的并行回放
+> 无论选择 `DATABASE` 还是 `GROUP_TRANSACTIONS`,底层引擎都是 LOGICAL_CLOCK。
+> 区别仅在于如何把连续事件流切分成可并行的逻辑组。
+
+```mermaid
+flowchart TD
+ A["Relay Log 事件流"] --> B{"按 database 分组"}
+ B --> C["DB1 的事件队列"]
+ B --> D["DB2 的事件队列"]
+ B --> E["DBn 的事件队列"]
+
+ C --> W1["Worker 1 执行 DB1 事件"]
+ D --> W2["Worker 2 执行 DB2 事件"]
+ E --> Wn["Worker n 执行 DBn 事件"]
+
+ style W1 fill:#00D866,color:#fff
+ style W2 fill:#00B6BC,color:#fff
+ style Wn fill:#FF9F43,color:#000
+```
+
+> [!TIP] Parallel Replication 的限制
+> - 基于 database 的并行要求同一 database 内没有冲突事务
+> - 跨 database 的操作无法并行
+> - innodb_table_locks=OFF 且 autocommit=1 时才能并行(避免表锁冲突)
+
+## 监控复制状态
+
+```sql
+-- MySQL 8.0+ 已弃用 SHOW SLAVE STATUS
+-- SHOW SLAVE STATUS\G
+
+-- ✅ 使用 performance_schema 视图
+SELECT * FROM performance_schema.replication_connection_status;
+SELECT * FROM performance_schema.replication_applier_status;
+
+-- ⚠️ 以上两表各返回一行,多从库场景下需要关联:
+-- connection: 一条代表 Master → Slave 的 IO 连接
+-- applier: 每行代表一个 SQL Worker 线程
+```
+
+```sql
+-- 实时延迟监控(MySQL 8.0+, GTID 模式)
+SELECT
+ MAX(latency) / 1000000000 AS max_lag_seconds
+FROM (
+ SELECT
+ MAX(clock_timestamp) - MIN(clock_start) AS latency
+ FROM performance_schema.replication_applier_status_by_worker
+) lag;
+```
+
+## 常见问题与排查
+
+### IO 线程断开(Slave_IO_Running: No)
+
+```mermaid
+flowchart TD
+ A["IO Thread 断连"] --> B{"原因定位"}
+ B -->|网络不通| C["检查防火墙 / 安全组端口"]
+ B -->|Binlog 已过期| D["purge binary logs / 调整 expire_logs_days"]
+ B -->|认证失败| E["核对 user 权限 & password"]
+ B -->|Server UUID 冲突| F["重置 slave server_uuid"]
+ C --> G["CHANGE REPLICATION SOURCE TO
SOURCE_LOG_FILE = '...',
SOURCE_LOG_POS = ..."]
+ D --> G
+ E --> G
+ F --> G
+ G --> H["START REPLICA"]
+ H --> I["确认 IO 恢复"]
+
+ style A fill:#FF6B6B,color:#fff
+ style I fill:#00D866,color:#fff
+```
+
+**常见排查步骤:**
+
+```sql
+-- 1. 查看具体错误信息
+SELECT LAST_ERROR_MESSAGE FROM performance_schema.replication_connection_status;
+
+-- 2. 检查 Master Binlog 是否已被清理
+SHOW MASTER LOGS;
+
+-- 3. 如果 Binlog 已过期,需要全量恢复(mysqldump + --single-transaction)
+-- 或使用 xtrabackup 做 In-place Slave 重建
+```
+
+### SQL 线程卡住(Slave_SQL_Running: No)
+
+这通常是由于数据不一致导致的——例如从库被手动修改过。
+
+> [!WARNING] 不要直接跳过!
+> 盲目 `sql_slave_skip_counter` 可能导致数据静默丢失。必须先确认事务内容。
+
+```sql
+-- ⚠️ MySQL 5.7 及以下(已弃用)
+-- SET GLOBAL sql_slave_skip_counter = 1;
+-- STOP SLAVE; START SLAVE;
+
+-- ✅ MySQL 8.0+ GTID 模式:跳过指定事务
+SET GTID_NEXT='3E11FA47-71CA-11E1-9E33-C80AA9429562:100';
+BEGIN; COMMIT;
+SET GTID_NEXT='AUTOMATIC';
+
+-- 重新加入复制组
+STOP REPLICA;
+SET GLOBAL gtid_purged = '3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100';
+START REPLICA;
+```
+
+### 延迟过大如何诊断
+
+```mermaid
+flowchart LR
+ A["Seconds_Behind_Master 大"] --> B{"IO 延迟还是 SQL 延迟?"}
+ B -->|"Relay_Log_Space 持续增长"| C["SQL Worker 回放太慢
→ 增加 replica_parallel_workers
→ 考虑 GROUP_TRANSACTIONS"]
+ B -->|"Read_Master_Log_Pos 跟丢"| D["网络带宽不足 / Master
写入量太大
→ 检查网络 / 限制 binlog 传输大小"]
+ B -->|"DDL 执行中"| E["大表 ALTER TABLE 阻塞
→ 使用 pt-online-schema-change"]
+
+ style C fill:#00B6BC,color:#fff
+ style D fill:#FF9F43,color:#000
+ style E fill:#C44569,color:#fff
+```
+
+---
+
+## 关联笔记
+
+- [[hhs/MySQL/29-Binary Log]] — Binlog 是主从复制的数据源
+- [[hhs/MySQL/31-MHA / Orchestrator]] — 自动故障切换工具
+- [[hhs/MySQL/33-Group Replication]] — Paxos 多主复制进阶方案
+- [[hhs/MySQL/32-读写分离与代理]] — 基于主从架构的流量分发
diff --git a/hhs/MySQL/31-MHA与Orchestrator.md b/hhs/MySQL/31-MHA与Orchestrator.md
new file mode 100644
index 0000000..3106b26
--- /dev/null
+++ b/hhs/MySQL/31-MHA与Orchestrator.md
@@ -0,0 +1,345 @@
+---
+tags: [MySQL, MHA, Orchestrator, 高可用, 故障切换, MySQL HA]
+create time: 2026-05-16 14:30
+---
+
+# MHA / Orchestrator 故障切换
+
+## 概述
+
+当主库宕机时,如何**快速且正确**地选出一个新的主库并完成流量切换?这就是高可用(HA)系统的核心问题——既要尽量减少停机时间,又要保证数据不丢失、不发生冲突。
+
+本节将对比两种主流的 MySQL 自动故障切换方案:
+- **MHA**:由日本 DeNA 开发的经典 Perl 方案,以数据一致性著称
+- **Orchestrator**:由 Spotify 开发的现代 Go 工具,侧重拓扑管理和运维便利
+
+> [!QUESTION] 核心思考
+> 在讨论具体工具之前,先问一个问题:**如果只有一个从库,它应该立刻提升为主库吗?**
+>
+> 答案并不简单。如果主库只是短暂的网络抖动,切换就是"过度响应";但如果主库真的挂了还不切,服务就不可用了。如何在「反应太快」和「反应太慢」之间找到平衡,是 HA 设计的永恒命题。
+
+## 故障场景分类
+
+```mermaid
+flowchart TD
+ A["主库异常"] --> Type{"异常类型"}
+
+ Type -->|"进程挂死"<| H1["检测快
切换快
可自动处理"]
+ Type -->|"磁盘损坏"<| H2["数据可能损坏
需人工介入"]
+ Type -->|"脑裂 Split-Brain"<| H3["最危险!
双主写入导致数据冲突"]
+ Type -->|"网络分区"<| H4["隔离误判
假阳性切换"]
+
+ style H3 fill:#EE5A24,color:#fff
+```
+
+> [!NOTE] 关键认知
+> HA 工具能自动处理的大多是**进程级故障**。一旦发生磁盘损坏或网络脑裂,就需要人工判断——这也是为什么理解底层原理比单纯部署工具更重要。
+
+## MHA(Master High Availability)
+
+由日本 DeNA 开发(2009年)的经典 MySQL HA 方案,以 Perl 编写。其核心设计思想是**「绝不丢失已提交的数据」**——在故障切换前,会尽力补齐最从库缺失的 binlog 事件。
+
+```mermaid
+sequenceDiagram
+ participant M as MHA Manager
+ participant DM as Dead Master
+ participant MS as Most Up-to-Date Slave
+ participant SS as Other Slaves
+
+ M->>DM: ping... no response
+ Note over M: 连续 3 次 ping 失败
+ M->>MS: SHOW MASTER STATUS
+ MS-->>M: relay log 最新的位置
+ M->>SS: SHOW RELAYLOG STATUS
+ SS-->>M: relay log 较旧的位置
+
+ Note over M: Step 1: 补齐最从库的 missing events
+ M->>MS: 应用其他 slave 的 relay log 中的缺失事件
+ Note over M: Step 2: 提升新主
+
+ M->>MS: CHANGE MASTER TO... (无 master)
+ Note over MS: 脱离子复制链,成为独立主库
+
+ Note over M: Step 3: 重新配置其他从库指向新主
+
+ M->>SS: CHANGE MASTER TO master=new-master-ip
+ Note over SS: 全部切换到新主
+```
+
+### MHA 的核心优势
+
+| 特性 | 说明 |
+|------|------|
+| **数据一致性保证** | 选择 relay log 最新的 slave 作为新主 |
+| **自动补齐** | 从其他 slave 获取 dead master 尚未传的 binlog |
+| **VIP 漂移** | 通过 Keepalived + VIP 实现无缝切换 |
+| **Phantom Manager** | Manager 节点本身也可以做数据管理 |
+
+> [!TIP] 为什么选「最从」而非「任意从」?
+>
+> 假设 Master → SlaveA → SlaveB 的级联复制结构:
+> - Master 挂了,SlaveA 可能比 SlaveB 更「新」
+> - 如果直接提升 SlaveB,需要等待它追完所有relay log,延时长且风险大
+> - MHA 的做法是:找到 relay log 最全的那个从库,确保数据丢失最小
+
+### MHA 组成
+
+MHA 采用 **Manager + Node** 的分布式架构:
+
+```
+Manager Node ← 运行 mha_manager,独立部署(不放在 MySQL 节点上)
+Master Node ← 当前主库(可挂,不影响 Node 组件)
+Slaves Nodes ← 多个从库(自动检测哪个最新)
+App Servers ← 应用端通过 VIP 访问
+```
+
+> [!NOTE] 部署建议
+> - Manager 应部署在**独立的第三方机器**上,不要和任何 MySQL 实例同机
+> - 所有节点之间必须配置 **SSH 互信**,Manager 需要通过 SSH 执行远程操作
+
+#### 安装与配置
+
+```bash
+# 安装 RPM 包(CentOS/RHEL 7+)
+yum install mha4mysql-manager mha4mysql-node -y
+```
+
+```bash
+# 配置文件 /etc/masterha/app1.cnf
+[server default]
+manager_workdir=/data/mha/app1
+manager_log=/data/mha/app1/manager.log
+user=mha_user # Manager 连接 MySQL 的管理账号
+password=mha_pass
+ssh_user=root # SSH 连接账号
+repl_user=repl_user # 主从复制账号
+repl_password=repl_pass
+
+[server1]
+hostname=10.0.1.10
+candidate_master=1 # 允许被提升为主库
+
+[server2]
+hostname=10.0.1.11
+candidate_master=1
+
+[server3]
+hostname=10.0.1.12
+no_master=1 # 永远不做主库(例如数据量特别大的从库)
+```
+
+> [!TIP] candidate_master 的作用
+>
+> 默认情况下,MHA 优先选择 relay log 最新的从库,而不看它离 Master 的距离。但如果一个从库是「级联」链路末端(Master → S1 → S2),即使 S2 的 relay log 最全,也不应该提升它——因为它一旦变主,其他从库要重新追很长的 binlog。
+>
+> `candidate_master=1` 可以告诉 MHA:**这个节点虽然可能不是 relay log 最完整的,但仍然有资格被提升**。配合 `report_script` 还可以加入更多判断逻辑。
+
+#### GTID 支持
+
+MHA 0.56+ 版本开始支持 GTID 模式下的故障切换:
+
+```ini
+# app1.cnf 中开启 GTID 模式
+[server default]
+master_binlog_dir=/var/lib/mysql
+remote_workdir=/tmp/mha
+use_gtid_current_pos=1 # 使用 GTID 定位复制位置
+```
+
+> [!NOTE] GTID vs Position 的选择
+>
+> | 方式 | 优点 | 缺点 |
+> |------|------|------|
+> | binlog position | 兼容所有 MySQL 版本 | 需要精确计算每个 slave 丢失的事件 |
+> | GTID current_pos | 简化了定位逻辑,切换更可靠 | 要求 MySQL 5.6+,且需提前开启 GTID |
+
+如果生产环境已启用 GTID,强烈建议使用 GTID 模式;否则保持默认的 binlog position 即可。
+
+### MHA 的局限
+
+> [!WARNING] MHA 的问题
+> 1. **Manager 单点**:虽然可以配 Phantom,但需要一个额外的管理入口
+> 2. **Perl 技术栈**:生态维护活跃度下降
+> 3. **不支持 GTID 之前**:GTID 出现前依赖 binlog position
+> 4. **脑裂风险**:如果 Master 只是网络闪断而非真宕机,可能出现双主
+>
+> **替代方案**:Orchestrator + Go-HAProxy 或 Patroni(PostgreSQL 方向)
+
+## Orchestrator
+
+由 Spotify 开源的纯 Go 编写的 MySQL 拓扑发现和运维工具。核心定位是**「MySQL 拓扑的管理员」**——它首先帮你搞清楚「现在整个集群是什么样的」,然后在此基础上提供故障切换、手动切换、修复落后从库等操作。
+
+```mermaid
+flowchart TB
+ subgraph "Orchestrator 架构"
+ OAPI["Orchestrator HTTP API
port 3000"]
+ ODB["SQLite/MySQL
存储拓扑信息"]
+ OSCAN["Discovery Scan
定期 SHOW SLAVE HOSTS"]
+
+ frontend["Web UI
拓扑可视化"] --> OAPI
+ dashboard["Dashboard"] --> OAPI
+
+ OSCAN --> ODB
+ OAPI --> ODB
+
+ HAProxy["Go-HAProxy /
MaxScale / ProxySQL"] -->|"重定向流量"| NEWMASTER["新主库"]
+ end
+
+ OAPI --> HAProxy
+```
+
+> [!NOTE] 设计理念差异
+>
+> | 维度 | MHA | Orchestrator |
+> |------|-----|-------------|
+> | 首要目标 | 自动故障切换(Failover) | 拓扑管理 + 运维辅助 |
+> | 侵入性 | 需要在每个节点安装 node 组件 | **零侵入**,只读查询 |
+> | 执行方式 | Manager 直接 SSH 操作各节点 | 通过 API 调用,外部脚本接管 |
+> | 技术栈 | Perl | Go(单二进制文件部署) |
+
+### Orchestrator 如何工作
+
+Orchestrator 的核心是一个**后台扫描循环**:
+
+```
+每 5~30 秒 ──┬──► SHOW SLAVE HOSTS / SHOW REPLICA STATUS
+ ├──► 读取各实例的 Binlog Position
+ ├──► 构建完整拓扑树(含级联复制关系)
+ └──► 写入内部数据库 → Web UI 刷新显示
+```
+
+这意味着 Orchestrator **不主动修改任何 MySQL 配置**,它只是一个观察者 + 操作台。所有的故障切换需要外部工具(如 Go-HAProxy)配合完成。
+
+### 自动故障切换(Auto-Failover)
+
+虽然 Orchestrator 以拓扑管理见长,但它同样支持全自动故障切换:
+
+```ini
+# orchestrator.conf.json 关键配置
+{
+ "DiscoverByShowSlaveHosts": true,
+ "DetectMasterFailoverWithSuperReadOnly": false,
+ "DetectorScript": "/usr/local/bin/orchestrator-detector.sh",
+
+ // 自动 Failover 的条件控制
+ "AutoFaildown": true, // Master 恢复后是否自动降级并重新纳入复制链
+ "AutoFailop": true, // Master 宕机时是否自动切换
+ "ExpireStatsSec": 600 // 统计信息过期时间
+
+ // 选择新主的策略
+ "PreferredCandidateDistances": [1, 2], // 优先选距离近的从库
+ "CheckSlaveLagThreadsCached": true // 自动检测 Slave 延迟
+}
+```
+
+> [!QUESTION] Orchestrator 为什么不直接改 VIP?
+>
+> MHA 内置了 VIP 漂移功能,但 Orchestrator 选择了更松耦合的设计。原因是:
+> 1. 不同环境使用不同的代理层(HAProxy / ProxySQL / MaxScale / 云厂商 LB)
+> 2. 让外部系统决定「怎么切流量」更加灵活
+> 3. Orchestrator 专注于做一件事:**准确知道拓扑结构并给出操作建议**
+>
+> 生产环境中常见的做法是写一个 wrapper 脚本,当 Orchestrator 检测到 Master 挂掉时自动调用它来切换 ProxySQL 或 HAProxy 的后端配置。
+
+### 常用 API 示例
+
+```bash
+# 1. 查看某个应用下的所有实例拓扑
+curl http://localhost:3000/api/topology/app/mydb
+
+# 2. 找到最适合晋升为主库的候选者
+curl http://localhost:3000/api/candidate-instances/app/mydb
+
+# 3. 强制提升某实例为主库(手动干预)
+curl -X POST http://localhost:3000/api/become-master/host/db/instance
+
+# 4. 自动修复脱离的从库(重新对接到新主)
+curl -X POST http://localhost:3000/api/recover/full/host/db/instance
+
+# 5. 获取故障切换分析(不实际执行,只看推荐方案)
+curl http://localhost:3000/api/fail-coordination/force/master-simulate/host/db/instance
+```
+
+> [!TIP] Simulate 模式
+>
+> `fail-coordination` 接口支持模拟切换流程而不真正执行。在生产环境中强烈建议先用这个命令**演练一次**,确认推荐的切换方案符合预期后再正式执行。
+
+## 脑裂的处理
+
+```mermaid
+flowchart TD
+ A["网络分区导致
Master 与 Slave 失联"] --> B{"HA 工具判断
Master 存活?"}
+
+ B -->|否| C["提升 Slave 为新主"]
+ C --> D{"原 Master 恢复后
是否还活着?"}
+ D -->|是| E["⚠️ 双主!两个库都在接受写请求"]
+ E --> F["用 binlog 比对
找出冲突数据"]
+ F --> G["回滚冲突数据
或将原 Master 降级为从库"]
+
+ D -->|否| H["原 Master 重启后
作为从库接入"]
+
+ B -->|是| I["不做切换,继续等待"]
+
+ style E fill:#EE5A24,color:#fff
+ style H fill:#00D866,color:#fff
+```
+
+### 脑裂后的数据恢复步骤
+
+当确认发生脑裂时,标准处理流程如下:
+
+```
+Step 1 ──► 停止新主的写入(防止更多冲突)
+Step 2 ──► 导出两个库的 GTID 集合或 binlog position 范围
+Step 3 ──► 使用 mysqlbinlog + diff 对比差异事件
+Step 4 ──► 保留「正确」分支的数据
+Step 5 ──► 将另一方的冲突数据标记为 rollback 或直接丢弃
+Step 6 ──► 重建复制链路
+```
+
+> [!WARNING] 预防脑裂的策略
+> 1. **STONITH(Shoot The Other Node In The Head)**:通过 IPMI / 远程管理卡强制关闭疑似脑裂节点——这是最彻底的方案
+> 2. **Quorum(多数派机制)**:3 节点集群中,至少 2 节点存活才允许选举,避免少数派擅自决策
+> 3. **带外心跳**:除了 TCP ping,再用 SSH 或其他独立通道探测,降低单通道误判概率
+> 4. **延迟备份**:保留一台物理隔离的冷备,脑裂时可从中恢复,相当于最后的保险丝
+> 5. **超时阈值调优**:不要为了减少误切把超时设得太长(如超过 30s),故障期间每一秒都很宝贵
+
+## 选型建议
+
+```mermaid
+flowchart LR
+ A["需要 MySQL HA
自动故障切换"] --> B{"技术栈偏好?"}
+
+ B -->|"传统运维体系
已有 Perl 团队"| MHA["MHA
经典稳定"]
+ B -->|"Go / 云原生
需要 Web UI"| ORC["Orchestrator
灵活现代"]
+ B -->|"想要官方支持"<| INN["InnoDB Cluster
MySQL Shell"]
+ B -->|"不想自己维护 HA"| CLOUD["云厂商 RDS
托管方案"]
+
+ style MHA fill:#4A90D9,color:#fff
+ style ORC fill:#00D866,color:#fff
+ style INN fill:#F5A623,color:#fff
+ style CLOUD fill:#EE5A24,color:#fff
+```
+
+### 关键对比维度
+
+| 维度 | MHA | Orchestrator | InnoDB Cluster |
+|------|-----|-------------|---------------|
+| **数据丢失** | 极小(自动补齐 binlog) | 较小(依赖 GTID) | 零(基于 Group Replication) |
+| **部署复杂度** | 中(需 SSH 互信 + RPM) | 低(单二进制文件) | 高(需 PXC/Galera) |
+| **故障恢复时间** | 通常 < 10s | 通常 < 15s | 秒级但检测较慢 |
+| **在线扩展** | 不支持 | 仅拓扑管理,不处理 DDL | 支持分布式 DDL |
+| **社区活跃度** | 较低(DeNA 维护) | 高(VK / 社区活跃) | 官方维护 |
+| **适用 MySQL 版本** | 5.6~5.7 | 5.6+(推荐 8.0) | 8.0+ |
+
+> [!TIP] 实际生产中的常见选择
+>
+> - **中小团队(< 10 台 MySQL 实例)**:Orchestrator 是性价比最高的选择——上手简单、维护成本低、Web UI 对 DBA 友好
+> - **大流量业务对数据一致性要求极高**:如果预算允许,上云厂商 RDS 的付费高可用版是最省心的
+> - **自建全量多主写入**:考虑 InnoDB Group Replication 或 ProxySQL + Orchestrator 组合
+
+## 关联笔记
+
+- [[hhs/MySQL/30-主从复制]] — 主从复制的配置和监控
+- [[hhs/MySQL/33-Group Replication]] — 内置多主复制,不需要外部 HA 工具
+- [[hhs/Redis/06-主从与哨兵]] — Redis Sentinel 与 MySQL HA 方案的对比
diff --git a/hhs/MySQL/32-Group Replication.md b/hhs/MySQL/32-Group Replication.md
new file mode 100644
index 0000000..fe58ffc
--- /dev/null
+++ b/hhs/MySQL/32-Group Replication.md
@@ -0,0 +1,275 @@
+---
+tags: [MySQL, Group Replication, Paxos, 多主复制, 集群]
+create time: 2026-05-16 00:00
+---
+
+# Group Replication(组复制)
+
+## 概述
+
+Group Replication(GR)是 Oracle 官方提供的 MySQL 高可用解决方案,基于 Paxos 共识算法实现多主复制,提供自动故障检测和成员管理。它代表了 MySQL 分布式能力的核心方向。
+
+## GR 与主从复制的对比
+
+```mermaid
+flowchart LR
+ subgraph "传统主从"
+ RM["单主模型
Master → Slave(s)"]
+ RS["异步/半同步复制"]
+ RF["故障切换需外部工具"]
+ end
+
+ subgraph "Group Replication"
+ RG["多主模型
任一节点接受写入"]
+ RP["Paxos 共识保证一致性"]
+ RA["自动故障检测 + 自动选举"]
+ end
+
+ RM -.">"| RA
+ RF -.">"| RA
+
+ style RG fill:#00B6BC,color:#fff
+ style RP fill:#C44569,color:#fff
+ style RA fill:#00D866,color:#fff
+```
+
+## 两种工作模式
+
+### 单主模式(Single-Primary)
+
+```mermaid
+flowchart TD
+ Primary["Primary\nRW + RO"] --> Paxos["Paxos 共识层"]
+ Paxos --> S1["Secondary 1\n只读"]
+ Paxos --> S2["Secondary 2\n只读"]
+ Paxos --> S3["Secondary N\n只读"]
+
+ style Primary fill:#00B6BC,color:#fff
+ style Paxos fill:#C44569,color:#fff
+ style S1 fill:#74b9ff,color:#000
+ style S2 fill:#74b9ff,color:#000
+ style S3 fill:#74b9ff,color:#000
+```
+
+- **特点**:类似主从,但故障切换全自动
+- **适用**:大多数读写分离场景
+- **冲突解决**:单主天然无冲突
+
+### 多主模式(Multi-Primary)
+
+```mermaid
+flowchart LR
+ N1["Node 1\nRW + RO"] <-- Paxos --> N2["Node 2\nRW + RO"]
+ N2 <-- Paxos --> N3["Node 3\nRW + RO"]
+
+ style N1 fill:#00B6BC,color:#fff
+ style N2 fill:#C44569,color:#fff
+ style N3 fill:#00D866,color:#fff
+```
+
+- **特点**:所有节点都可读写
+- **适用**:多数据中心容灾、低延迟写入需求
+- **冲突检测**:基于行级别的乐观锁,冲突时拒绝写入节点
+
+> [!WARNING] 多主模式的限制
+> - 有冲突检测但不自动解决——冲突的那条事务会被拒绝并返回错误码 3092
+> - 不适合相同数据在多个节点上同时修改的场景
+> - **生产环境推荐使用单主模式**
+
+## GR 的工作原理
+
+```mermaid
+flowchart TD
+ Client["客户端连接"] --> Node["任意节点"]
+ Node --> TXN["事务执行"]
+ TXN --> Cert["Certification: 检查与其他节点的冲突"]
+ Cert -->|"无冲突"| Order["Ordering: 获得全局顺序号"]
+ Cert -->|"冲突"| REJECT["❌ 事务被拒绝
error 3092"]
+
+ Order --> Broadcast["Broadcast: 向所有成员广播 committed order"]
+ Broadcast --> Apply["Apply: 按全局顺序提交"]
+
+ Apply --> Members{"所有成员 >= N/2+1?"}
+ Members -->|是| SUCCESS["✅ 事务提交成功"]
+ Members -->|否| PENDING["⏳ pending 状态
等待多数派确认"]
+
+ style SUCCESS fill:#00D866,color:#fff
+ style REJECT fill:#EE5A24,color:#fff
+ style PENDING fill:#FF9F43,color:#000
+```
+
+### Certification(认证)
+
+GR 在每个事务提交前进行冲突检测:
+
+```go
+// 伪代码:certification 算法
+func certTransaction(txn Transaction) error {
+ for _, pk := range txn.writeSet.primaryKeys {
+ // 检查全局 write set 中是否存在相同主键的并发事务
+ if conflict, exists := globalWriteSet.Find(pk); exists {
+ if !areCommutative(txn, conflict) {
+ return ErrorConflict("row-level conflict detected")
+ }
+ }
+ }
+ return nil
+}
+```
+
+**认证流程的核心思路:**
+
+1. **Collect**: 当事务在本地执行完毕后,收集所有被修改行的主键集合(write set)
+2. **Certify**: 将 write set 与组内其他成员已提交的事务进行比较
+3. **Resolve**: 若发现冲突且不可交换(commutative),则拒绝该事务(返回错误码 3092);若无冲突或可交换,则通过认证
+
+> [!TIP] 什么是可交换操作?
+> 两个事务如果修改的是不同的行,即使它们同时发生也不会冲突,这在数学上称为「可交换」(commutative)。例如:`UPDATE users SET age=age+1 WHERE id=1` 和 `UPDATE users SET name='Alice' WHERE id=2` 是可交换的,GR 允许两者都提交。
+
+### Ordering(全局排序)
+
+通过认证后,事务需要获得一个全局顺序号(view change ID + local order),确保所有节点以**完全相同的顺序**应用事务——这是保证一致性的关键。
+
+### Broadcast & Apply(广播与应用)
+
+获得全局序号的事务会通过 group communication layer 广播给所有组成员。每个成员收到后按全局顺序依次 apply,最终达成状态机的一致性。这就是 Paxos/Raft 式的确定性状态机复制。
+
+```mermaid
+flowchart TD
+ TA["事务 A: UPDATE users SET x=1 WHERE id=1"]
+ TB["事务 B: UPDATE users SET y=2 WHERE id=1"]
+
+ TA --> CA{Certification}
+ TB --> CB{Certification}
+
+ CA -->|passed| O1["已获得全局顺序 #100"]
+ CB -->|conflict! both write id=1| CE["Conflict Error 3092"]
+
+ style CE fill:#EE5A24,color:#fff
+```
+
+## 部署配置
+
+```ini
+# 每个节点 my.cnf 中需要配置
+
+# 基本复制配置
+server_id = 1
+gtid_mode = ON
+enforce_gtid_consistency = ON
+master_info_repository = TABLE
+relay_log_info_repository = TABLE
+binlog_checksum = NONE
+
+# GR 专用配置
+plugin_load_add = 'group_replication.so'
+group_replication_group_name = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+group_replication_start_on_boot = OFF
+group_replication_local_address = "10.0.1.10:33061"
+group_replication_group_seeds = "10.0.1.10:33061,10.0.1.11:33061,10.0.1.12:33061"
+
+# 单主模式
+group_replication_single_primary_mode = ON
+group_replication_enforce_update_everywhere_checks = OFF
+
+# 多主模式(取消注释以上两行,注释掉以下两行)
+# group_replication_single_primary_mode = OFF
+# group_replication_enforce_update_everywhere_checks = ON
+```
+
+### 初始化步骤
+
+```sql
+-- 在每个节点上执行
+
+-- 1. 创建复制用户
+CREATE USER rpl_user@'%' IDENTIFIED BY 'password';
+GRANT REPLICATION SLAVE ON *.* TO rpl_user@'%';
+FLUSH PRIVILEGES;
+
+-- 2. 配置集群地址
+SET GLOBAL group_replication_bootstrap_group = OFF;
+
+-- 3. 在第一个节点启动组(引导组)
+SET GLOBAL group_replication_bootstrap_group = ON;
+START GROUP_REPLICATION;
+SET GLOBAL group_replication_bootstrap_group = OFF;
+
+-- 4. 在其他节点加入组
+START GROUP_REPLICATION;
+
+-- 5. 检查组成员状态
+SELECT * FROM performance_schema.replication_group_members;
+```
+
+## 性能与注意事项
+
+### 性能特点
+
+GR 的性能瓶颈主要来自 Paxos 共识层,以下为生产环境的经验总结:
+
+| 维度 | 说明 |
+|------|------|
+| **写入延迟** | 单主模式下,提交需要等待多数派确认(majority commit),TPS 通常比异步主从低 20%~40% |
+| **并发能力** | 行级认证 + 乐观锁策略让不同行的写操作可以并行通过,高并发小事务场景表现较好 |
+| **网络要求** | 节点间通过端口 33061 通信,要求低延迟、高带宽局域网;跨 DC 部署延迟应控制在 5ms 以内 |
+| **SSD 必要** | binlog 和 relay log 频繁刷盘,强烈建议使用 SSD/NVMe |
+
+### 生产注意事项
+
+> [!IMPORTANT] 上线前必须检查的清单
+> 1. **表必须有主键**:没有主键的表无法被 GR 复制,启动时会报错并拒绝加入该表的数据
+> 2. **外键约束**:建议关闭 `foreign_key_checks`,GR 不保证跨节点外键一致性
+> 3. **存储引擎**:只支持 InnoDB
+> 4. **事务隔离级别**:推荐使用 `READ COMMITTED`(GR 默认),RR 在部分场景下可能增加冲突概率
+> 5. **自增主键冲突**:多主模式下多个节点同时产生自增值会冲突,需配置 `auto_increment_increment` 和 `auto_increment_offset` 错开编号区间
+> 6. **DDL 操作**:大表的 DDL(如 ADD INDEX)会在整个组阻塞,务必在维护窗口执行
+> 7. **GTID 不可变**:开启 GTID 后无法关闭,数据初始化时必须用 GTID 方式导出导入
+
+## 故障处理
+
+```mermaid
+stateDiagram-v2
+ [*] --> ONLINE: 节点正常加入组
+
+ ONLINE --> REMOVED: 节点宕机 / 网络断开
+ ONLINE --> OFFLINE: 手动 STOP GROUP_REPLICATION
+
+ REMOVED --> SELF_JOIN: 节点恢复并重新加入
+ REMOVED --> [*]: 永久退出
+
+ SELF_JOIN --> ONLINE: 数据同步完成
+
+ state REMOVED {
+ state_config ["Config Change Pending"]
+ state_error ["Error Recovering"]
+ }
+```
+
+> [!NOTE] 组的最小生存条件
+> - 单主模式:至少 2 个节点在线(1 primary + 1 secondary)
+> - 多主模式:至少 N/2+1 个节点在线
+> - 少于这个阈值,整个组进入 readonly 模式,拒绝所有写入
+
+## 监控命令
+
+```sql
+-- 组成员状态
+SELECT * FROM performance_schema.replication_group_members;
+-- MEMBER_ID, MEMBER_HOST, MEMBER_PORT, MEMBER_STATE (ONLINE/RECOVERING/ERROR/RO)
+
+-- 组内事务统计
+SELECT * FROM performance_schema.replication_group_member_stats;
+
+-- 正在进行的冲突事务
+SELECT * FROM performance_schema.replication_group_history;
+
+-- 连接数
+SELECT * FROM performance_schema.replication_group_connections;
+```
+
+## 关联笔记
+
+- [[hhs/MySQL/30-主从复制]] — GR 的主干是改进后的主从复制
+- [[hhs/MySQL/31-MHA与Orchestrator]] — 为什么 GR 不需要外部 HA 工具
+- [[hhs/MySQL/35-监控指标]] — GR 特有的监控指标
diff --git a/hhs/MySQL/33-InnoDB Cluster.md b/hhs/MySQL/33-InnoDB Cluster.md
new file mode 100644
index 0000000..19da248
--- /dev/null
+++ b/hhs/MySQL/33-InnoDB Cluster.md
@@ -0,0 +1,330 @@
+---
+tags: [MySQL, InnoDB Cluster, MySQL Shell, MySQL Router, Group Replication, 高可用, 官方集群, HA]
+create time: 2026-05-16 00:00
+status: draft
+---
+
+# InnoDB Cluster
+
+## 概述
+
+InnoDB Cluster 是 MySQL 官方的**高可用(HA)集群解决方案**,它将 Group Replication(GR)底层协议封装成一个开箱即用的平台。
+
+> [!TIP] 为什么需要 InnoDB Cluster?
+> Group Replication 虽然可靠,但手动配置极其繁琐:需要处理 SSL、参数调优、选举策略等。InnoDB Cluster 把 GR 的复杂性隐藏起来,提供一套标准化的 API(MySQL Shell)来完成部署、监控和运维,让开发者聚焦应用层而非基础设施。
+
+它由三个组件构成:
+
+| 组件 | 角色 | 运行时必需? |
+|------|------|:-:|
+| **MySQL Shell** | 管理工具,用于创建和维护集群 | ❌ |
+| **MySQL Router** | 客户端代理,智能路由读写请求 | ✅ |
+| **Cluster Monitor** | 内置监控器,嵌入在每个 MySQL 实例中 | ✅ |
+
+核心能力包括:**自动故障转移**、**多主/单写拓扑**、**数据一致性保证**以及**无缝连接**。
+
+## 架构总览
+
+```mermaid
+flowchart TB
+ subgraph "应用层"
+ App1["App 1"]
+ App2["App 2"]
+ end
+
+ Router["MySQL Router
端口 6446(RW) / 6447(RO)"]
+
+ subgraph "InnoDB Cluster"
+ ICM["InnoDB Cluster Monitor
MySQL Shell 守护进程"]
+
+ subgraph "Group Replication Set"
+ Node1["Instance 1
Primary (RW)"]
+ Node2["Instance 2
Secondary (RO)"]
+ Node3["Instance 3
Secondary (RO)"]
+ end
+ end
+
+ subgraph "Metadata Store"
+ Metadb["metadata_db
元数据存储实例"]
+ end
+
+ App1 --> Router
+ App2 --> Router
+ Router -->|"6446 RW"| Node1
+ Router -->|"6447 RO"| Node2
+ Router -->|"6447 RO"| Node3
+
+ Node1 -.Heartbeat.-> ICM
+ Node2 -.Heartbeat.-> ICM
+ Node3 -.Heartbeat.-> ICM
+
+ ICM --> Metadb
+```
+
+> [!QUESTION] 思考:为什么 Router 需要两个端口?
+> 写入必须到达唯一的 Primary 以保证数据一致性,读取则可以分发到任意 Secondary 节点提升并发能力。Router 通过两个端口天然实现了读写分离,应用层无需感知集群拓扑变化。
+
+## 快速搭建
+
+### 前置条件
+
+```bash
+# 准备 3 台服务器(或容器),每台安装 MySQL 8.0+
+# 确保 mysql-shell 和 mysql-router 也已安装
+
+# 最小配置(单机测试可共用一台机器不同端口)
+for port in 33061 33062 33063; do
+ mysqld --defaults-file=/etc/mysql/my-${port}.cnf &
+done
+```
+
+### 使用 MySQL Shell 一键部署
+
+```javascript
+// 连接到第一个实例
+dba.connect('root@localhost:33061')
+
+// 检查实例是否符合集群要求
+dba.checkInstanceConfiguration('root@localhost:33061')
+
+// 配置实例(如果需要)
+dba.configureInstance('root@localhost:33061')
+
+// 创建集群
+cluster = dba.createCluster('myCluster')
+
+// 添加第二、第三个实例
+cluster.addInstance('root@localhost:33062')
+cluster.addInstance('root@localhost:33063')
+
+// 查看集群状态
+cluster.status()
+```
+
+输出示例:
+
+```json
+{
+ "clusterName": "myCluster",
+ "defaultReplicaSet": {
+ "name": "default",
+ "primary": "localhost:33061",
+ "status": "OK",
+ "statusText": "Cluster is ONLINE and can tolerate up to ONE failure.",
+ "topology": {
+ "localhost:33061": {
+ "role": "PRIMARY",
+ "mode": "R/W",
+ "status": "ONLINE"
+ },
+ "localhost:33062": {
+ "role": "SECONDARY",
+ "mode": "R/O",
+ "status": "ONLINE"
+ },
+ "localhost:33063": {
+ "role": "SECONDARY",
+ "mode": "R/O",
+ "status": "ONLINE"
+ }
+ }
+ }
+}
+```
+
+### 配置 MySQL Router
+
+```bash
+# 自动生成路由配置
+mysqlrouter --bootstrap root@localhost:33061 --directory ./router --force
+
+# 启动 Router
+cd ./router && ./start.sh
+
+# Router 自动暴露:
+# - localhost:6446 → 写操作 → 转发到 Primary
+# - localhost:6447 → 读操作 → 轮询到所有 Secondary
+```
+
+## 高可用与故障转移
+
+InnoDB Cluster 的自动故障转移基于 **Group Replication 的 Paxos 式选举机制**。当当前 Primary 不可达时:
+
+```mermaid
+sequenceDiagram
+ participant App as 应用
+ participant Router as MySQL Router
+ participant P as Primary (A)
+ participant S1 as Secondary (B)
+ participant S2 as Secondary (C)
+
+ App->>Router: SELECT / INSERT
+ Router->>P: 转发写请求
+ P-->>S1: binlog / GTID 复制
+ P-->>S2: binlog / GTID 复制
+
+ Note over P: Primary 宕机
+ P--x Router: 连接断开
+ Router--x App: 返回 Error
+
+ S1->>S2: 心跳超时检测
+ S2->>S1: 无法联系 Primary
+ S1->>S1: 触发选举(GCS view change)
+ S1->>S2: 投票选举新 Primary
+ S2->>S1: 确认(B 票数最高)
+ B->>B: 成为新的 Primary
+
+ Note over Router: 监控器检测到拓扑变化
+ Router->>S1: 更新路由表
+ App->>Router: 重试写入
+ Router->>S1: 转发到新 Primary
+```
+
+> [!NOTE] 故障转移关键参数
+> - `group_repification_member_weight`(默认 50):影响选举权重,范围 0~100,值越高越优先成为 Primary
+> - `group_repification_single_primary_mode`:开启单写模式后,同一时刻只有一个可写节点
+> - **RPO = 0**:在同步模式下,已提交的事务不会丢失;异步模式下可能存在少量数据损失
+
+### 容忍节点数
+
+| 节点总数 | 可容忍故障数 | 需在线最低数 |
+|----------|:-:|------------|
+| 3 节点 | 1 | 2 |
+| 5 节点 | 2 | 3 |
+| 7 节点 | 3 | 4 |
+
+> [!WARNING] 脑裂风险
+> 当网络分区导致集群分裂成两组时,每组都认为自己是合法的。InnoDB Cluster 通过 **"多数派原则"(Quorum)** 解决——只有拥有多数节点的分区才能继续提供服务,其余进入只读模式。这就是为什么推荐部署奇数个节点:避免平分局面。
+
+## 日常运维操作
+
+以下操作均在 MySQL Shell 中执行(JavaScript 模式)。
+
+### 查看集群状态
+
+```javascript
+// 连接到集群中的任意实例即可
+dba.connect('root@localhost:33061')
+var cluster = dba.getCluster()
+cluster.status() // 输出完整的拓扑和每个节点的健康信息
+```
+
+### 添加 / 移除节点
+
+```javascript
+const cluster = dba.getCluster()
+
+// 添加新节点
+cluster.addInstance('root@localhost:33064')
+
+// 安全移除节点(会先同步数据)
+cluster.removeInstance('root@localhost:33062', {'safeClone': true})
+
+// 注意:如果节点已宕机且无法恢复,使用 force 模式:
+// cluster.removeInstance('root@localhost:33062', {'force': true})
+```
+
+### 切换主节点
+
+```javascript
+// 手动将某个 Secondary 提升为 Primary
+cluster.switchToMultiPrimaryMode() // 切换到多主模式
+cluster.switchToSinglePrimaryMode() // 切回单主模式
+
+// 在单主模式下指定哪个节点作为 Primary
+cluster.setPrimaryInstance('root@localhost:33062')
+```
+
+### 升级集群
+
+```javascript
+// 滚动升级(逐个节点升级,不停服)
+cluster.checkInstanceUpgrade() // 检查各节点是否满足升级条件
+cluster.checkStackUpgrade() // 整体栈升级评估
+```
+
+## 生产注意事项
+
+### 必备前置步骤
+
+```bash
+# 每个 MySQL 实例必须配置的关键参数(my.cnf)
+[mysqld]
+# Group Replication 基础配置
+server-id=1 # 全局唯一
+gtid_mode=ON # 必须开启 GTID
+enforce_gtid_consistency=ON # 必须启用
+binlog_checksum=CRC32 # 增强数据完整性
+transaction_write_set_extraction=XXHASH64 # Router 发现读写集用
+loose-group_replication_bootstrap_group=OFF
+```
+
+> [!TIP] 单机多实例测试技巧
+> 开发阶段可以用一台机器跑三个实例,只需:
+> 1. 每个实例独立的数据目录、端口、socket 文件
+> 2. 独立的 `.cnf` 配置文件
+> 3. `group_replication_ip_whitelist="127.0.0.1"` 允许本地通信
+
+### 常见陷阱
+
+| 问题 | 原因 | 解决方案 |
+|------|------|---------|
+| 节点加入后变为 `ERROR` | server_uuid 冲突或 UUID 未生成 | 删除 `data/auto.cnf` 重启实例 |
+| Router 报 `No connection` | Cluster Monitor 未运行 | 确保实例正常运行,Monitor 是嵌入式的 |
+| 写入变慢 | 单写模式下游记事务等待组内确认 | 增加 `transaction_alloc_block_size` 或使用多主模式 |
+| 脑裂后无法恢复 | 少数派分区无法 elect 新 primary | 手动引导:`dba.startCluster()` 在多数派分区执行 |
+
+## 应用接入
+
+### Go 接入
+
+> [!TIP] 连接池配置建议
+> Go 的 `sql.DB` 需要合理设置连接池参数,避免在故障切换瞬间全部连接失效。
+
+```go
+dsn := "app_user:password@tcp(localhost:6446)/mydb?charset=utf8mb4&parseTime=True"
+db, err := sql.Open("mysql", dsn)
+if err != nil { log.Fatal(err) }
+
+// 故障切换时的关键配置
+db.SetMaxIdleConns(10) // 保持足够空闲连接快速恢复
+db.SetConnMaxLifetime(5 * time.Minute) // 定期丢弃旧连接,避免持有死连接
+db.SetMaxOpenConns(50)
+```
+
+**要点解释:**
+- 应用只需连接 Router 的端口,无需知道后端有多少节点
+- `SetConnMaxLifetime` 确保经过几次切换后,旧连接自然被回收,新连接会自动指向新 Primary
+- 建议在应用层配合重试逻辑(如指数退避),因为故障切换期间 Router 可能有几秒的延迟
+
+### Java 接入
+
+```java
+String url = "jdbc:mysql://localhost:6446/mydb";
+Properties props = new Properties();
+props.setProperty("user", "app_user");
+props.setProperty("password", "password");
+props.setProperty("autoReconnect", "true"); // 启用自动重连
+props.setProperty("maxReconnects", "5"); // 故障后最多重试 5 次
+
+try (Connection conn = DriverManager.getConnection(url, props)) {
+ // 写操作走 6446,读操作走 6447
+}
+```
+
+## InnoDB Cluster vs 自建 GR
+
+| 特性 | InnoDB Cluster | 自建 GR |
+|------|---------------|---------|
+| **部署复杂度** | 低(Shell 自动化)| 高(手动配置每个节点)|
+| **Router 集成** | 内置 MySQL Router | 需自行搭配 ProxySQL 等 |
+| **监控界面** | `shell cluster.status()` | 需查 performance_schema |
+| **升级维护** | `dba.patchInstance()` | 手动逐个升级 |
+| **灵活性** | 较低(Oracle 锁定)| 高(自定义参数)|
+| **适用场景** | 快速落地、中小型团队 | 深度定制、大规模生产 |
+
+## 关联笔记
+
+- [[hhs/MySQL/32-Group Replication]] — InnoDB Cluster 底层就是 GR
+- [[hhs/MySQL/31-MHA与Orchestrator]] — 不同 HA 方案的对比
+- [[hhs/GORM/13-多数据库支持]] — GORM 在集群环境中的使用注意事项
diff --git a/hhs/MySQL/34-备份与恢复.md b/hhs/MySQL/34-备份与恢复.md
new file mode 100644
index 0000000..071b155
--- /dev/null
+++ b/hhs/MySQL/34-备份与恢复.md
@@ -0,0 +1,278 @@
+---
+tags: [MySQL, mysqldump, XtraBackup, PITR, 备份恢复]
+create time: 2026-05-16 00:00
+---
+
+# 备份与恢复
+
+## 概述
+
+备份是数据安全最后一道防线。本章覆盖逻辑备份(mysqldump)、物理备份(Percona XtraBackup)、以及基于 binlog 的时间点恢复(PITR)三大核心方案。
+
+## 备份策略全景
+
+```mermaid
+flowchart LR
+ subgraph "逻辑备份"
+ LD["mysqldump
导出 SQL"]
+ LF["全量 + 增量
依赖 binlog"]
+ end
+
+ subgraph "物理备份"
+ PD["Percona XtraBackup
热拷贝ibd文件"]
+ PF["压缩 + 加密
支持部分备份"]
+ end
+
+ subgraph "选择指南"
+ LS["数据量 < 50GB
停机窗口可接受"]
+ PS["数据量 > 50GB
需要 0 停机"]
+ end
+
+ LD --> LS
+ PD --> PS
+
+ style LS fill:#00B6BC,color:#fff
+ style PS fill:#00D866,color:#fff
+```
+
+## mysqldump 逻辑备份
+
+### 全量备份
+
+```bash
+# 最基础的全量备份
+mysqldump -u root -p --all-databases > all_dbs_$(date +%F).sql
+
+# 推荐的参数组合(保持一致性和完整性)
+mysqldump \
+ -u root -p \
+ --single-transaction # MVCC 快照,不锁表
+ --flush-logs # 切换 binlog,便于后续恢复
+ --master-data=2 # 记录 binlog position(注释形式)
+ --routines # 包含存储过程和函数
+ --triggers # 包含触发器
+ --events # 包含事件调度器
+ --databases app_db report_db > backup_$(date +%F).sql
+```
+
+### 还原
+
+```bash
+# 全量还原
+mysql -u root -p < backup_2026-05-16.sql
+
+# 只还原某个表(先导出再导入)
+mysqldump -u root -p app_db --table users > users.sql
+mysql -u root -p app_db < users.sql
+```
+
+### mysqldump 的优缺点
+
+| 优点 | 缺点 |
+|------|------|
+| 简单可靠,几乎不会出错 | 大库慢(导入/导出)|
+| 文本格式,可读可编辑 | 导出的数据和原始可能有差异(如字符集转换)|
+| 自带权限和表结构定义 | 不支持增量备份 |
+| 跨版本迁移方便 | 备份期间虽然有 `--single-transaction`,但 `--flush-logs` 仍需短暂锁表 |
+
+## Percona XtraBackup(物理备份)
+
+### 全量备份
+
+```bash
+# 全量备份(热备,不停机)
+xtrabackup --backup \
+ --target-dir=/data/backups/full/ \
+ --user=root --password=xxx \
+ --parallel=4 # 多线程并行拷贝
+
+# 备份后的准备阶段(使备份可用于恢复)
+xtrabackup --prepare --target-dir=/data/backups/full/
+
+# 增量备份(基于上一次备份)
+xtrabackup --backup \
+ --target-dir=/data/backups/inc1/ \
+ --incremental-basedir=/data/backups/full/ \
+ --user=root --password=xxx
+
+# 合并增量到全量(减少恢复时间)
+# 先对全量做 prepare,只应用已提交的事务,不回滚未提交的
+xtrabackup --prepare --target-dir=/data/backups/full/ --apply-only
+# 再将增量备份合并进来
+xtrabackup --prepare --target-dir=/data/backups/full/ --incremental-dir=/data/backups/inc1/
+
+# 如果有多个增量层,依次合并
+xtrabackup --prepare --target-dir=/data/backups/full/ --incremental-dir=/data/backups/inc2/
+```
+
+### 恢复
+
+```bash
+# 1. 停止 MySQL
+systemctl stop mysqld
+
+# 2. 清空数据目录(或用新目录)
+rm -rf /var/lib/mysql/*
+
+# 3. 拷贝备份数据
+cp -a /data/backups/full/. /var/lib/mysql/
+
+# 4. 设置权限
+chown -R mysql:mysql /var/lib/mysql
+
+# 5. 启动 MySQL
+systemctl start mysqld
+```
+
+### XtraBackup 的优缺点
+
+| 优点 | 缺点 |
+|------|------|
+| **热备份**:备份期间业务不受影响 | 学习曲线较陡 |
+| 速度快:直接拷贝 ibd 文件 | 需要安装额外软件 |
+| 支持增量备份 | 不能跨 major version(8.0→8.0)|
+| 恢复速度远快于 mysqldump | 占用较多磁盘空间(物理拷贝)|
+
+## PITR — 时间点恢复
+
+结合全量备份 + binlog,可以将数据库恢复到任意精确时刻。
+
+> [!QUESTION] 思考:如果凌晨 3 点的备份完成时 binlog position 是 `mysql-bin.000005:1234`,而 10 点误操作发生在 `mysql-bin.000006` 中,该怎么办?
+> 答案是:binlog 链不能断——从 `mysql-bin.000005:1234` 开始到当前为止的所有 binlog 文件都必须保留。任何缺失都会导致 PITR 失败。
+
+```mermaid
+timeline
+ title PITR 恢复流程
+ 00:00 : 凌晨全量备份 (XtraBackup)
+ 06:00 : 应用正常运行
+ 10:00 : ⚠️ 误删表!DELETE FROM users;
+ 10:01 : 检测到异常,开始恢复
+ 恢复过程 : 1. 恢复全量备份到 00:00
+ : 2. 重放 binlog 到 09:59:59
+ : 3. 跳过误操作语句
+ : 4. 恢复到 10:00 之前的状态
+```
+
+```bash
+# 第一步:恢复全量备份
+# (前面已讲过 XtraBackup 恢复步骤)
+
+# 第二步:找到误操作的准确位置
+# 方法 A:用时间定位(推荐,最直观)
+mysqlbinlog --stop-datetime='2026-05-16 09:59:59' /var/lib/mysql/mysql-bin.000006 > safe_events.sql
+
+# 方法 B:搜索误操作语句,获取 position
+mysqlbinlog /var/lib/mysql/mysql-bin.000006 | grep -B5 "DROP DATABASE\|DELETE FROM users"
+# 找到对应的 DELETE 所在事务的 stop_position,在此之前停止
+
+# 第三步:重放 binlog 到误操作之前(基于 position)
+mysqlbinlog \
+ --start-position=1234 \
+ --stop-position=4567 \
+ /var/lib/mysql/mysql-bin.000005 > before_incident.sql
+
+# 第四步:跳过误操作行,手动编辑 SQL 后重放其余部分
+# 方法一:导出到文件后用 sed/grep 过滤掉误操作的 DDL/DML
+mysqlbinlog /var/lib/mysql/mysql-bin.000005 > all_events.sql
+# 用编辑器打开 all_events.sql,定位到误操作的 BEGIN/COMMIT 区间并删除
+
+# 方法二:精确用 position 范围分段重放
+mysqlbinlog \
+ --stop-position=4567 \
+ /var/lib/mysql/mysql-bin.000005 | mysql -u root -p # 重放到误操作前
+
+mysqlbinlog \
+ --start-position=5000 \
+ /var/lib/mysql/mysql-bin.000005 | mysql -u root -p # 从安全点继续重放
+
+# MySQL 8.0+ GTID 模式(推荐,更简洁)
+# 包含 uuid 下 1~100 的 GTID,排除第 101 个事务(即误操作的那个)
+mysqlbinlog \
+ --include-gtids='uuid:1-100' \
+ --exclude-gtids='uuid:101' \
+ mysql-bin.000005 | mysql -u root -p
+```
+
+> [!WARNING] PITR 的关键约束
+> - **必须有完整的 binlog 链**:全量备份时的 binlog position 之后所有的 binlog 都不能丢
+> - **`binlog_format` 必须是 ROW 或 MIXED**:STATEMENT 模式下 PITR 不可靠(相同的 SQL 在不同上下文中可能产生不同结果)
+> - **GTID 需要 `gtid_executed` 一致**:使用 `--include-gtids` / `--exclude-gtids` 要求服务端启用了 `enforce_gtid_consistency=ON`
+> - **恢复到某个时间点会覆盖该时间点之后的所有数据**:做好评估,建议先在测试库验证恢复流程
+
+## 备份验证与自动化
+
+> [!QUESTION] 思考:如果一周后发生灾难,你发现昨晚的备份根本打不开——那时候还来得及补救吗?
+> **未经测试的备份等于没有备份**。定期验证是备份策略中最重要的环节。
+
+### 验证方法
+
+```bash
+# mysqldump SQL 文件验证:在测试库导入一次
+mysql -u root -p test_db < backup_latest.sql
+# 检查关键表的数据量和行数是否符合预期
+
+# XtraBackup 验证:prepare 阶段就是第一次验证
+xtrabackup --prepare --target-dir=/data/backups/latest/
+# prepare 成功 = 备份文件完整性合格
+# 更进一步:恢复到从库或测试机做一次完整演练(至少每季度一次)
+```
+
+### 自动化方案
+
+```bash
+#!/bin/bash
+# /opt/scripts/mysql-backup.sh — 每日备份脚本
+set -euo pipefail
+
+BACKUP_DIR="/data/backups"
+DATE=$(date +%F)
+RETENTION_DAYS=7
+
+# 1. XtraBackup 全量备份
+xtrabackup --backup \
+ --target-dir="${BACKUP_DIR}/${DATE}/" \
+ --user=backup_user --password=$(cat /etc/backup_pass) \
+ --parallel=4
+
+# 2. 压缩备份目录(节省磁盘空间)
+tar czf "${BACKUP_DIR}/${DATE}.tar.gz" -C "${BACKUP_DIR}" "${DATE}"
+rm -rf "${BACKUP_DIR}/${DATE}"
+
+# 3. 清理超过保留期的旧备份
+find "${BACKUP_DIR}" -name "*.tar.gz" -mtime +${RETENTION_DAYS} -delete
+
+# 4. 备份元数据记录(方便追踪和恢复时定位)
+echo "$(date +%F %T) | full | ${DATE} | OK" >> "${BACKUP_DIR}/backup.log"
+```
+
+```cron
+# crontab -e — 每天凌晨 2 点执行
+0 2 * * * /opt/scripts/mysql-backup.sh >> /var/log/mysql-backup-cron.log 2>&1
+```
+
+### 快速恢复清单
+
+> [!CHECKLIST] PITR 快速恢复步骤
+> 1. [ ] 确认误操作的准确时间
+> 2. [ ] 找到最新可用的全量备份及其 binlog position
+> 3. [ ] 停止应用写操作(防止二次损害)
+> 4. [ ] 恢复全量备份到干净目录
+> 5. [ ] 用 `--stop-datetime` 重放 binlog 到误操作前
+> 6. [ ] 启动 MySQL 并验证关键表数据
+> 7. [ ] 切换流量到新实例
+> 8. [ ] 通知相关人员,更新 incident 记录
+
+## 备份频率建议
+
+| 数据类型 | 全量备份频率 | 增量备份频率 | binlog 留存 |
+|---------|-----------|-----------|-----------|
+| 核心交易数据 | 每天一次 | 每小时 | ≥ 7 天 |
+| 用户数据 | 每天一次 | 每天 | ≥ 7 天 |
+| 日志/归档数据 | 每周一次 | 按需 | 按需 |
+| 测试环境 | 按需 | — | 按需 |
+
+## 关联笔记
+
+- [[hhs/MySQL/29-Binary Log]] — Binlog 是 PITR 的基础
+- [[hhs/MySQL/30-主从复制]] — 从库可以作为只读备份节点
+- [[hhs/DEV/Go-Database]] — Go 应用中处理数据库恢复错误的最佳实践
diff --git a/hhs/MySQL/35-Go 连接池配置.md b/hhs/MySQL/35-Go 连接池配置.md
new file mode 100644
index 0000000..53a19ec
--- /dev/null
+++ b/hhs/MySQL/35-Go 连接池配置.md
@@ -0,0 +1,361 @@
+---
+tags: [MySQL, Go, sql.DB, 连接池, 性能调优]
+create time: 2026-05-16 00:00
+---
+
+# Go 连接 MySQL 的工程实践
+
+## 概述
+
+Go 的 `database/sql` 提供了数据库无关的连接管理抽象。但与 Redis 不同——Go 标准库的 driver 是**连接池**而非直连,理解它的行为模型对避免生产事故至关重要。
+
+## sql.DB 的本质
+
+```mermaid
+flowchart TB
+ App["应用进程"] --> Pool["sql.DB 连接池"]
+
+ Pool -->|"动态创建"| C1["Connection 1
到 MySQL Server"]
+ Pool --> C2["Connection 2"]
+ Pool --> C3["Connection N..."]
+
+ Pool -.MaxOpenConns 限制.| MaxLimit["最大连接数"]
+
+ C1 --> MySQL["MySQL Server Process"]
+ C2 --> MySQL
+ C3 --> MySQL
+
+ style Pool fill:#00B6BC,color:#fff
+ style MaxLimit fill:#EE5A24,color:#fff
+```
+
+> [!WARNING] 常见的误解
+> `sql.DB` **不是单个连接**,而是一个**连接池**。它代表的是逻辑连接——物理连接是按需创建、空闲回收的。这意味着:
+> - 多个 goroutine 可以同时使用同一个 `db` 对象
+> - `db` 是 goroutine-safe 的
+> - 不要将 `db` 传给每个请求单独维护(会导致连接膨胀)
+
+## 核心参数调优
+
+```go
+import "database/sql"
+
+db, err := sql.Open("mysql", dsn)
+if err != nil {
+ log.Fatal(err)
+}
+
+// 四大核心参数
+db.SetMaxOpenConns(50) // 池中最多的打开连接数
+db.SetMaxIdleConns(10) // 池中最多保持的空闲连接数
+db.SetConnMaxLifetime(time.Minute * 5) // 连接存活时间
+db.SetConnMaxIdleTime(time.Minute * 2) // 空闲连接被回收的时间(Go 1.15+)
+```
+
+### 参数关系图
+
+```mermaid
+flowchart LR
+ A["SetMaxOpenConns = 50
全局上限"] --> B{"当前活跃连接数 < 50?"}
+ B -->|是| C["创建新连接"]
+ B -->|否| D["排队等待可用连接 ⏳"]
+
+ E["SetMaxIdleConns = 10
最少保留 10 个空闲"] --> F["关闭多余的空闲连接"]
+ G["SetConnMaxLifetime = 5m
超过则关闭重建"] --> H["防止 MySQL 8.0 默认 8h wait_timeout 冲突"]
+ I["SetConnMaxIdleTime = 2m
空闲超 2 分钟即关闭"] --> J["减少资源浪费"]
+
+ style D fill:#EE5A24,color:#fff
+ style C fill:#00D866,color:#fff
+```
+
+### 参数的依赖关系
+
+```go
+// 重要约束
+// 如果 SetMaxOpenConns <= 0 → 无限制(不推荐!)
+// 如果 SetMaxIdleConns > SetMaxOpenConns → 自动调整为等于 MaxOpenConns
+
+// 合理配置示例
+db.SetMaxOpenConns(100) // 根据压测确定
+db.SetMaxIdleConns(20) // 至少 1/5 的 MaxOpenConns
+db.SetConnMaxLifetime(5 * time.Minute) // 短于 MySQL wait_timeout (默认 8h)
+db.SetConnMaxIdleTime(2 * time.Minute) // 及时释放长期空闲连接
+```
+
+### 如何确定 MaxOpenConns 的值
+
+```mermaid
+flowchart TD
+ A["压测:单实例 QPS + 平均延迟"] --> B["计算:
MaxOpenConns = QPS × AvgLatency(s)"]
+ B --> C{"是否接近 MySQL max_connections?"}
+ C -->|"是,不够"| D["考虑读写分离
或分库"]
+ C -->|"否,有余量"| E["设为计算值的 80%
留出安全余量"]
+ E --> F["观察 P99 延迟
和连接池等待数"]
+
+ style B fill:#4FC08D,color:#fff
+ style E fill:#EAB308,color:#fff
+ style F fill:#A0AEC0,color:#fff
+```
+
+> [!TIP] 经验公式与验证方法
+> ```
+> 理论值 = 并发请求数 × 单次查询平均耗时
+> ```
+> - **步骤一**:用 `ab` 或 `wrk` 压测,拿到 QPS 和平均响应时间
+> - **步骤二**:`MaxOpenConns ≈ QPS × AvgLatency(s)`,例如 500 QPS、20ms → `500 × 0.02 = 10`
+> - **步骤三**:在此基础上乘以 1.5~2 倍的安全系数(考虑突发流量、慢查询兜底)
+> - **步骤四**:上线后通过 Prometheus 监控 `go_sql_driver_connections` 实际使用量调优
+>
+> > [!WARNING] 常见误区
+> > - **MaxOpenConns 设越大越好** → 连接越多,单连接 CPU 竞争越严重,延迟反而上升
+> > - **忽略 SetMaxIdleConns** → 默认值为 2,突发流量到来时频繁创建连接导致延迟抖动
+> > - **MaxIdleConns 应 ≥ 1**(生产建议 CPU 核数或 10~20),确保有预热好的连接可用
+
+## DSN 格式详解
+
+```go
+// user:password@tcp(host:port)/dbname?param1=value1¶m2=value2
+dsn := "app_user:app_password@tcp(10.0.1.10:3306)/app_db" +
+ "?charset=utf8mb4" +
+ "&parseTime=True" + // 自动将 MySQL DATETIME 解析为 Go time.Time
+ "&loc=Local" + // 时区设置
+ "&timeout=10s" + // dial 超时
+ "&readTimeout=30s" + // 读取超时
+ "&writeTimeout=30s" + // 写入超时
+ "&allowNativePasswords=true" + // MySQL 8.0 认证插件
+ "&tls=false" // TLS 连接
+```
+
+> [!TIP] parseTime = true 的价值
+> ```go
+> // ❌ parseTime=False (默认): time 字段变成 string
+> var createdAt string
+> db.QueryRow("SELECT created_at FROM users WHERE id = ?", id).Scan(&createdAt)
+> // createdAt = "2026-05-16 10:30:00" — 需要手动解析
+>
+> // ✅ parseTime=True: time 字段自动转为 time.Time
+> var createdAt time.Time
+> db.QueryRow("SELECT created_at FROM users WHERE id = ?", id).Scan(&createdAt)
+> // createdAt = 2026-05-16 10:30:00 +0800 CST
+> ```
+
+## go-sql-driver/mysql 特有参数
+
+`github.com/go-sql-driver/mysql` 除了标准 DSN 参数外,还提供了一些有用的驱动级配置:
+
+| 参数 | 推荐值 | 说明 |
+|------|--------|------|
+| `interpolateParams` | `true` | 将占位符替换为实际值后拼接 SQL,减少往返次数 ⚡ |
+| `loc=Local` | `Local` | 时区设为本地时区(Asia/Shanghai) |
+| `parseTime` | `true` | MySQL DATETIME/TIMESTAMP 自动转为 Go `time.Time` |
+| `charset=utf8mb4` | `utf8mb4` | 支持 Emoji 和所有 Unicode 字符 |
+| `allowNativePasswords` | `true` | MySQL 8.0 `caching_sha2_password` 认证插件 |
+| `allowAllFiles` | `false` | 禁止 `LOAD DATA LOCAL INFILE` 读取任意文件 🔒 |
+| `clientFoundRows` | `false` | UPDATE 返回"匹配行数"而非"影响行数" |
+| `tls=false` / `tls=skip-verify` / `tls=preferred` | 按需选择 | TLS 加密连接(生产环境建议启用) |
+
+```go
+dsn := "app_user:app_password@tcp(10.0.1.10:3306)/app_db" +
+ "?charset=utf8mb4&parseTime=True&loc=Local" +
+ "&timeout=10s&readTimeout=30s&writeTimeout=30s" +
+ "&interpolateParams=true" + // 性能优化:减少网络往返
+ "&allowAllFiles=false" + // 安全加固:防止 LOCAL INFILE 注入
+ "&maxAllowedPacket=67108864" // 最大包大小 64MB(大文本/BLOB 必须设)
+```
+
+> [!WARNING] interpolateParams 注意事项
+> - **仅适用于不重复使用的查询**——如果复用 `*sql.Stmt`,会导致 SQL 注入风险
+> - **对批量插入无效**——prepare + exec 在大批量场景下依然更快
+> - 适合的场景:单条查询、CRUD 接口中的简单参数绑定
+
+## 事务最佳实践
+
+### 模式一:defer tx.Rollback(最常用)
+
+```go
+tx, err := db.Begin()
+if err != nil {
+ return err
+}
+defer func() {
+ if p := recover(); p != nil {
+ tx.Rollback() // panic 时确保回滚,防止连接泄漏
+ panic(p) // 重新抛出
+ }
+}()
+
+// 执行业务操作
+_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
+if err != nil {
+ return err // defer 触发 Rollback()
+}
+_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
+if err != nil {
+ return err
+}
+
+return tx.Commit() // defer 不会触发 Rollback(err == nil)
+```
+
+> [!TIP] 核心原则
+> `defer tx.Rollback()` 在 Commit **之后**调用时是安全的——提交过的事务没有需要回滚的内容。因此可以简化为:
+> ```go
+> tx, _ := db.Begin()
+> defer tx.Rollback() // 简单粗暴,适用于绝大多数场景
+>
+> // ... 业务逻辑
+> return tx.Commit()
+> ```
+
+### 模式二:函数式封装(推荐复用)
+
+```go
+// 事务包装器:失败自动回滚,成功返回 Commit 的 err
+func transaction(db *sql.DB, fn func(*sql.Tx) error) error {
+ tx, err := db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if p := recover(); p != nil {
+ tx.Rollback()
+ panic(p)
+ }
+ }()
+
+ if err := fn(tx); err != nil {
+ _ = tx.Rollback() // 忽略 rollback 错误,返回原始业务错误
+ return err
+ }
+ return tx.Commit()
+}
+
+// 使用方式
+err := transaction(db, func(tx *sql.Tx) error {
+ _, err := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
+ if err != nil {
+ return err
+ }
+ _, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
+ return err
+})
+```
+
+> [!NOTE] 为什么不建议过度复杂的 panic recover?
+> | 写法 | 适用场景 | 复杂度 |
+> |------|---------|--------|
+> | `defer tx.Rollback()` | 日常开发 ✅ | 低 |
+> | 函数式封装 | 团队规范、多模块复用 | 中 |
+> | 手动 Recover+Rollback | 极少数有全局 panic 处理的框架 | 高 |
+>
+> 大多数应用不需要担心 commit 后 rollback 的问题——`database/sql` 内部会正确处理,所以一句 `defer tx.Rollback()` 就足够了。
+
+## 查询模式速查
+
+```go
+// 单行查询
+var id int
+var name string
+err := db.QueryRow("SELECT id, name FROM users WHERE email = ?", email).
+ Scan(&id, &name)
+
+// 多行查询
+rows, err := db.Query("SELECT id, name FROM users WHERE status = ?", 1)
+if err != nil {
+ return nil, err
+}
+defer rows.Close() // 一定要 Close!
+
+users := make([]User, 0)
+for rows.Next() {
+ var u User
+ if err := rows.Scan(&u.ID, &u.Name); err != nil {
+ return nil, err
+ }
+ users = append(users, u)
+}
+
+// 批量插入
+tx, _ := db.Begin()
+stmt, _ := tx.Prepare("INSERT INTO orders (user_id, amount) VALUES (?, ?)")
+for _, o := range orders {
+ stmt.Exec(o.UserID, o.Amount)
+}
+stmt.Close()
+tx.Commit()
+
+// 预处理语句复用(高性能场景)
+stmt, _ := db.Prepare("SELECT * FROM users WHERE id = ?")
+defer stmt.Close()
+// 多次执行时复用 stmt,避免重复解析 SQL
+```
+
+## 常见问题排查
+
+### 连接泄漏
+
+```go
+// 症状:应用连接数持续增长,最终达到 MaxOpenConns 上限
+// 原因:没有 Close() rows 或没有 Commit/Rollback tx
+
+// 检查方法
+rows, err := db.Query("SELECT COUNT(*) FROM information_schema.processlist WHERE command != 'Sleep'")
+// 查看活跃连接数是否异常
+```
+
+### 连接风暴
+
+```go
+// 症状:短时间内创建大量连接,MySQL 报错 Too many connections
+// 原因:每次请求都新建 sql.DB 而非复用
+
+// ✅ 正确做法
+var db *sql.DB // 全局变量或在初始化函数中创建一次
+
+// ❌ 错误做法:每个 handler 都 Open
+func handler(w http.ResponseWriter, r *http.Request) {
+ db, _ := sql.Open("mysql", dsn) // 每个请求都新建池!
+}
+```
+
+### 与 MySQL wait_timeout 的配合
+
+```ini
+# MySQL 端配置(my.cnf)
+wait_timeout = 600 # 10 分钟非交互式超时
+interactive_timeout = 600
+```
+
+```go
+// Go 端配置必须短于 MySQL 的 wait_timeout
+db.SetConnMaxLifetime(5 * time.Minute) // 5 分钟 < 10 分钟
+// 这样 Go 会在连接被 MySQL 强制关闭前主动重建
+```
+
+## 思考与拓展
+
+> [!QUESTION] 为什么 `sql.Open` 不真正建立连接?
+> `sql.Open` 只验证 DSN 格式并初始化连接池对象,**不会发起真实的 TCP 连接**。第一次实际查询时才会创建物理连接。如果需要启动时即验证连通性:
+> ```go
+> err := db.Ping() // 立即发起一次真实连接
+> if err != nil { log.Fatal(err) }
+> ```
+
+> [!QUESTION] 生产环境 SetMaxIdleConns 默认值是 2——这对突发流量有什么影响?
+> 当空闲连接只有 2 个时,如果 QPS 从 100 突然飙升到 1000,8 个新连接需要逐个建立 TCP 握手(三次握手 + TLS),每次新增连接的延迟可能高达数毫秒。设一个合理的 MaxIdleConns(如 CPU 核数或 10~20)可以维持"热连接池",让突发流量立即命中已有连接。
+
+> [!QUESTION] go-redis 和 sql.DB 的连接池设计有何异同?
+> | 维度 | `*sql.DB` | `go-redis.Client` |
+> |------|-----------|-------------------|
+> | 最大连接 | `SetMaxOpenConns(n)` | `MaxActive(n)` |
+> | 空闲连接 | `SetMaxIdleConns(n)` | `MaxIdle(n)` |
+> | 空闲回收 | `SetConnMaxIdleTime(d)` | `IdleTimeout(d)` |
+> | 等待超时 | 内部阻塞(无直接 API) | `PoolTimeout(d)` |
+> | 保活机制 | 需手动设置 Lifetime | `PingPeriod` PING/PONG |
+>
+> 共同点:**都是 goroutine-safe 的共享池**,不应每请求新建实例。
+
+## 关联笔记
+
+- [[hhs/GORM/01-安装与初始化]] — GORM 底层调用 `*sql.DB` 进行连接池调优
+- [[hhs/Redis/11-运维与性能调优]] — go-redis 连接池参数对比(MaxActive/MaxIdle vs SetMaxOpenConns/SetMaxIdleConns)
diff --git a/hhs/MySQL/36-Schema 迁移管理.md b/hhs/MySQL/36-Schema 迁移管理.md
new file mode 100644
index 0000000..dfc71e9
--- /dev/null
+++ b/hhs/MySQL/36-Schema 迁移管理.md
@@ -0,0 +1,490 @@
+---
+tags: [MySQL, Schema Migration, flyway, goose, migrate]
+create time: 2026-05-16 00:00
+---
+
+# Schema 迁移管理
+
+## 概述
+
+生产环境中,数据库表结构不会一蹴而就。随着业务发展,频繁的结构变更需要通过版本化的迁移脚本来管理,而不是直接在数据库中手工 ALTER TABLE。
+
+> [!QUESTION] 思考一下
+> 如果你在凌晨三点接到电话——"生产库挂了,因为一个没有回滚脚本的迁移执行了一半就崩溃了",你会怎么做?
+>
+> 这个问题的答案涵盖了本章要讨论的全部主题:**版本化**、**可回滚**、**幂等性**以及 **dirty state 恢复**。
+
+本文档将覆盖从工具选型到生产级实战的完整流程:
+
+| 阶段 | 核心问题 | 对应章节 |
+|------|---------|---------|
+| 选型 | 该选哪个迁移工具? | 主流迁移工具对比 |
+| 上手 | 怎么写迁移文件?怎么跑? | golang-migrate/migrate 实战 |
+| 设计 | 怎么写安全的 DDL? | 迁移设计原则 |
+| 进阶 | 千万级大表怎么改不锁表? | 生产级大表在线 DDL |
+| 工程化 | 如何在 CI/CD 中安全验证? | CI/CD 集成与测试 |
+
+## 为什么需要迁移工具?
+
+```mermaid
+flowchart TD
+ V1["v1: 初始建表
CREATE TABLE users"] --> V2["v2: 加字段
ALTER TABLE users ADD COLUMN phone"]
+ V2 --> V3["v3: 改列类型
ALTER TABLE users MODIFY COLUMN email VARCHAR(255)"]
+ V3 --> V4["v4: 建新表
CREATE TABLE orders"]
+
+ Dev["开发环境"] -->|"手动执行"| OK1["✅ 没问题"]
+ Staging["测试环境"] -->|"不知道要跑哪些脚本"| FAIL1["❌ 遗漏"]
+ Prod["生产环境"] -->|"怕出错不敢动"| FAIL2["❌ 停滞"]
+
+ Migrate["迁移工具自动化"] --> Auto1["Dev → Staging → Prod 一致性 ✅"]
+ Auto1 --> Auto2["可回滚 ✅"]
+ Auto2 --> Auto3["审计追踪 ✅"]
+
+ style FAIL1 fill:#EE5A24,color:#fff
+ style FAIL2 fill:#EE5A24,color:#fff
+ style Auto1 fill:#00D866,color:#fff
+```
+
+## 主流迁移工具对比
+
+| 工具 | 语言 | 核心特性 | 安装方式 | 适用场景 |
+|------|------|---------|---------|---------|
+| **golang-migrate/migrate** | Go | 简洁 API、支持多种 database、按序执行 | `go get -u github.com/golang-migrate/migrate/v4/cmd/migrate@latest` | Go 项目首选 |
+| **pressly/goose** | Go | CLI + API、`embed.FS` 内嵌迁移文件、自包含二进制 | `go install github.com/pressly/goose/v3/cmd/goose@latest` | 需要将迁移嵌入二进制的团队 |
+| **Flyway** | Java | 企业级、支持多语言、CI 集成、Schema History Table | Homebrew / Maven / Docker | Java/跨语言团队 |
+| **Laravel Migrator** | PHP | 框架内置、自动跟踪版本 | 内置于 Laravel 框架 | Laravel 项目 |
+| **Alembic** | Python | SQLAlchemy 生态、自动生成迁移脚手架 | `pip install alembic` | Python/数据科学 |
+| **SQLAlchemy 2.0+** | Python | `alembic` 是事实标准 | 同上 | Python Web 服务 |
+
+> [!TIP] 选型建议
+> - **Go 项目内部用** → `golang-migrate`(稳定、社区大)或 `goose`(可以 embed 进 binary,部署更方便)
+> - **团队混合语言(Java/Python/Go)** → Flyway(协议统一)
+> - **单体项目起步** → 选你们最熟悉的语言的库即可,迁移工具的差异不如"坚持使用它"重要
+
+## golang-migrate/migrate 实战
+
+### 项目结构
+
+```
+migrations/
+├── 000001_create_users_table.up.sql
+├── 000001_create_users_table.down.sql
+├── 000002_add_phone_to_users.up.sql
+├── 000002_add_phone_to_users.down.sql
+└── 000003_create_orders_table.up.sql
+```
+
+### 迁移文件示例
+
+```sql
+-- 000001_create_users_table.up.sql
+CREATE TABLE users (
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+ username VARCHAR(50) NOT NULL UNIQUE,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ password_hash VARCHAR(64) NOT NULL,
+ status TINYINT NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ INDEX idx_status_created (status, created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 000001_create_users_table.down.sql
+DROP TABLE IF EXISTS users;
+```
+
+```sql
+-- 000002_add_phone_to_users.up.sql
+-- safe: ADD COLUMN 默认值为 NOT NULL 时不会锁全表(MySQL 8.0.12+)
+ALTER TABLE users ADD COLUMN phone VARCHAR(20) AFTER email;
+
+-- 000002_add_phone_to_users.down.sql
+ALTER TABLE users DROP COLUMN phone;
+```
+
+### 命令行操作
+
+```bash
+# 创建新的迁移文件
+migrate create -ext sql -dir migrations -seq create_products_table
+
+# 执行所有未运行的迁移
+migrate -path migrations -database "mysql://user:pass@tcp(host:3306)/db" up
+
+# 向前 migration 指定步数
+migrate -path migrations -database "mysql://..." up 2
+
+# 回退指定步数
+migrate -path migrations -database "mysql://..." down 1
+
+# 查看当前状态
+migrate -path migrations -database "mysql://..." version
+
+# 强制设置版本号(极端情况下的修复手段)
+migrate -path migrations -database "mysql://..." force 3
+```
+
+### Go API 方式
+
+```go
+package main
+
+import (
+ "log"
+ "github.com/golang-migrate/migrate/v4"
+ _ "github.com/golang-migrate/migrate/v4/database/mysql"
+ _ "github.com/golang-migrate/migrate/v4/source/file"
+)
+
+func main() {
+ m, err := migrate.New(
+ "file://migrations", // 迁移文件目录
+ "mysql://user:pass@tcp(localhost:3306)/app_db", // 数据库连接
+ )
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // 运行所有未执行的迁移
+ if err := m.Up(); err != nil && err != migrate.ErrNoChange {
+ log.Fatal(err)
+ }
+
+ // 查看当前版本
+ version, dirty, err := m.Version()
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Printf("Current migration version: %d (dirty=%v)", version, dirty)
+}
+```
+
+> [!WARNING] Dirty State
+> 如果迁移过程中途崩溃(如网络中断),migration table 会被标记为 `dirty=true`。下次运行迁移时会拒绝执行直到你手动修复:
+> ```bash
+> # 确认迁移确实完成了,清除 dirty 标志
+> migrate -path migrations -database "mysql://..." force 3
+> ```
+>
+> > [!CAUTION] 为什么不能盲目清 dirty?
+> > `dirty=true` 意味着上一个迁移可能只执行了一半——表可能被删了但索引还没建完。强制清脏前务必确认:
+> > 1. 当前 schema 状态与预期版本是否一致
+> > 2. 如果有不一致,需要手工补跑缺失的 DDL 后再 `force`
+>
+> **预防优于修复**:生产环境大表 DDL 建议在低峰期执行,并在预发环境充分验证。
+
+### goose 的 Embed FS 方式
+
+相比 `golang-migrate` 需要外部挂载 migrations 目录,goose 支持将 SQL 文件内嵌到二进制中:
+
+```go
+import (
+ "embed"
+ "github.com/pressly/goose/v3"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+
+func init() {
+ goose.SetBaseDir("./") // go.work 根目录
+ goose.SetDialect("mysql")
+}
+
+func RunMigrate(ctx context.Context) error {
+ return goose.ContextualMigrateUp(ctx, migrations)
+}
+```
+
+好处:**不需要在目标服务器维护 migrations 目录**,二进制即一切,适合容器化部署。
+
+## 常见陷阱与最佳实践
+
+### 迁移命名规范
+
+| 约定 | 示例 | 说明 |
+|------|------|------|
+| 序号_描述.up.sql | `000003_create_orders_table.up.sql` | 5 位数字确保排序正确 |
+| 序号_描述.down.sql | `000003_create_orders_table.down.sql` | 每个 up 配 down |
+| 动词开头 | `add_phone_to_users`, `create_orders` | 可读性强 |
+| 禁止空格 | ✅ `create_users_table` ❌ `create users table` | 避免 shell 转义问题 |
+
+### 回滚文件不是必须写的
+
+> [!EXAMPLE] 什么时候可以省略 .down.sql?
+> - **DROP TABLE / DROP COLUMN** → down 里怎么写?你不知道之前的 schema 长什么样。此时 down 写 "无法自动回滚" 即可。
+> - **创建只读表** → down 中可以 DROP,但这种场景本身就应该尽量避免。
+>
+> **结论**:down 脚本的价值在于"安全撤销"。如果你不确定怎么回滚,宁可留空并记录原因,也不要写一个会丢失数据的回滚脚本。
+
+### 禁止在迁移中使用 DML 混合事务
+
+```sql
+-- ❌ 危险:DDL + DML 混在同一个迁移里
+ALTER TABLE users ADD COLUMN bio TEXT;
+UPDATE users SET bio = 'default' WHERE status = 1;
+
+-- ✅ 拆成两个迁移
+-- 000004_add_bio_column.up.sql
+ALTER TABLE users ADD COLUMN bio TEXT;
+
+-- 000005_fill_default_bio.up.sql
+UPDATE users SET bio = 'default' WHERE status = 1 AND bio IS NULL;
+```
+
+原因:某些迁移工具对 DDL 和 DML 的事务语义处理不同,混在一起可能导致 **DDL 提交了但 DML 失败**,数据处于半填充状态。
+
+### 索引迁移的额外关注
+
+> [!NOTE] 索引创建的锁行为
+> MySQL 8.0+ InnoDB 支持 Online DDL,但加索引操作依然会:
+> - 扫描全表构建 B+Tree(读取量 ≈ 表大小 × 列数)
+> - 期间新写入的数据会被记录在 change buffer 或 redo log 中
+> - 如果表正在被高频写入,可能会触发多次 rebuild
+>
+> **建议**:1000 万行以上的大表加索引,使用 `pt-online-schema-change` 或 `gh-ost`(见下方"生产级在线 DDL")。
+
+## 迁移设计原则
+
+### 幂等性
+
+每个迁移应该是**幂等的**——多次执行不会产生副作用。
+
+```sql
+-- ❌ 非幂等:第二次执行会报错 "Column already exists"
+ALTER TABLE users ADD COLUMN bio TEXT;
+
+-- ✅ 幂等:IF NOT EXISTS 防止重复创建
+CREATE TABLE IF NOT EXISTS products (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(200)
+);
+```
+
+> [!QUESTION] ALTER TABLE ADD COLUMN 如何做到幂等?
+> MySQL 本身不支持 `ADD COLUMN IF NOT EXISTS`,但有一种间接做法:利用视图或存储过程先检查列是否存在。不过实践中更推荐的做法是——
+> **让迁移工具保证不重复执行**。golang-migrate/goose/flyway 都会维护一个 schema version table,已执行过的版本不会再跑。所以你只需要在单个文件内避免幂等问题即可。
+
+### 原子性
+
+同一事务中多个 DDL 语句不是原子的(MySQL InnoDB 对 DDL 使用隐式提交)。因此 **把互不相关的变更分拆到不同迁移文件**,既是安全策略,也是工程最佳实践。
+
+```sql
+-- ❌ 危险:一条 SQL 包含多种变更类型
+ALTER TABLE orders
+ ADD INDEX idx_status (status),
+ ADD COLUMN source VARCHAR(50),
+ CHANGE COLUMN description detail TEXT;
+
+-- ✅ 安全:每一步独立迁移文件,单步失败不会污染全局状态
+-- migration/001_add_source_column.up.sql
+ALTER TABLE orders ADD COLUMN source VARCHAR(50);
+
+-- migration/002_add_status_index.up.sql
+ALTER TABLE orders ADD INDEX idx_status (status);
+
+-- migration/003_rename_description_to_detail.up.sql
+ALTER TABLE orders CHANGE COLUMN description detail TEXT;
+```
+
+> [!TIP] 分步的额外好处:每步可以单独评估锁时间
+> - ADD COLUMN → 通常毫秒级(MySQL 8.0.12+ instantDDL)
+> - ADD INDEX → 可能几分钟到几小时(需要全表扫描排序)
+> - CHANGE/COLUMN TYPE → 可能需要 rebuild 表,最长
+>
+> 分开后你可以决定哪些用普通 `up`、哪些需要在低峰期手动执行。
+
+### 向前兼容(Three-Phase Deployment)
+
+> [!WARNING] 最常见的线上事故场景
+> 代码直接部署了"只读新列"的新逻辑 + 同时执行了迁移,结果新旧实例混跑期间旧实例读到 NULL 值导致业务异常。
+>
+> 记住黄金法则:**先部署兼容代码,再改 schema,最后清理旧逻辑**。
+
+```mermaid
+sequenceDiagram
+ participant C as Code Deploy
+ participant M as Migration
+ participant D as Database
+
+ Note over C,D: Phase 1: 部署向后兼容代码(最关键的一步)
+ C->>+D: 支持读写新列
空值时回退读旧列
+ C->>D: 同时写入新旧两列
+ D-->>-C: OK — 新旧schema都工作
+
+ Note over C,D: Phase 2: 执行迁移补数据
+ M->>D: ALTER TABLE ADD COLUMN new_col DEFAULT ...
+ Note over M,D: 此时已有代码兜底,即使回滚也安全
+ D-->>M: migration done
+
+ Note over C,D: Phase 3: 清理旧逻辑(可选延后)
+ C->>D: 部署精简代码
只依赖新列
+ D-->>C: OK — 旧列可以 DROP 了
+```
+
+> [!NOTE] 渐进式淘汰时间线
+> | 阶段 | 时间窗口 | 说明 |
+> |------|---------|------|
+> | 双写期 | T ~ T+2h | 同时写新旧列,用于灰度验证 |
+> | 数据填充期 | T+2h ~ T+24h | 通过后台任务把旧数据刷到新列 |
+> | 切换期 | T+24h~48h | 流量逐渐切到只读新列 |
+> | 清理期 | 确认无误后 | DROP 旧列 |
+
+## 生产级大表在线 DDL
+
+当表超过千万行时,普通的 `ALTER TABLE` 即便支持 Online DDL 也会带来可观的复制延迟和资源消耗。这时需要借助专门的工具来实现**零锁变更**。
+
+### 方案对比
+
+```mermaid
+flowchart LR
+ subgraph Native["MySQL 原生"]
+ N1["Online DDL\nMySQL 5.6+"] --> S1["INPLACE 算法
仍有短暂 LOCK"]
+ N2["INSTANT DDL\nMySQL 8.0.12+"] --> S2["几乎不锁表
但仅限特定操作"]
+ end
+
+ subgraph ThirdParty["第三方工具"]
+ T1["pt-online-schema-change"] --> P1["触发器 + 影子表
Percona Toolkit"]
+ T2["gh-ost"] --> P2["binlog 解析 + 别名切换
GitHub"]
+ end
+
+ S1 -.→|"百万行以下"| OK
+ S2 -.→|"500万行内 ADD/DROP COLUMN"| OK
+ P1 -.→|"超大表 复杂DDL"| BIG
+ P2 -.→|"超大表 要求极低延迟"| BIG
+
+ style S2 fill:#00D866,color:#fff
+ style P2 fill:#FFA500,color:#fff
+```
+
+### pt-online-schema-change(OSC)
+
+Percona Toolkit 提供的经典工具,原理是**创建影子表 → 触发器同步 → 原子替换**。
+
+```bash
+# 基本用法:给 orders 表添加索引
+pt-online-schema-change \
+ --alter="ADD INDEX idx_status (status)" \
+ D=app_db,t=orders \
+ --execute
+
+# 生产环境推荐参数
+pt-online-schema-change \
+ --alter="ADD INDEX idx_status (status)" \
+ D=app_db,t=orders \
+ --chunk-time=0.5 # 每批处理 0.5s,控制单批影响
+ --max-lag=1s # 主从延迟超过 1s 自动暂停
+ --check-interval=500 # 每 500ms 检查一次延迟
+ --recursion-method=processlist # 检测连接源的方式
+ --parallel-copy=4 # 多副本并发拷贝(MySQL 8.0+)
+ --no-drop-old-table # 不立即删除旧表,先确认无误后再手工 DROP
+```
+
+> [!CAUTION] OSC 的三大限制
+> 1. **不支持外键** — 有外键约束的表无法使用
+> 2. **触发器开销** — 每个插入/更新/删除都要额外执行触发器
+> 3. **内存占用** — 影子表和原表在同一实例上,磁盘空间要预留至少 1x 表大小
+
+### gh-ost(推荐)
+
+GitHub 开源的工具,通过解析 binlog 来同步增量数据,最后用**重命名表**实现原子切换。
+
+```bash
+# 基本用法
+./gh-ost \
+ --user="migrator" \
+ --password="pass" \
+ --host="127.0.0.1" \
+ --port=3306 \
+ --database="app_db" \
+ --table="orders" \
+ --alter="ADD INDEX idx_status (status)" \
+ --test-on-replica # 先在副本上测试(强烈建议)
+ --allow-on-master # 最后在主库执行时去掉 --test-on-replica
+
+# 关键参数
+--cut-over=atomic # 原子切换(默认),也有 graceful 模式
+--max-lag-millis=1000 # 容忍的主从延迟阈值
+--throttle-control-replicas="replica1,replica2" # 多个副本监控点
+--initially-drop-old-table # 先删旧影子表,避免冲突
+--initially-drop-ghost-table
+```
+
+> [!TIP] 选型决策树
+> ```
+> 你的表有多大?
+> ├─ < 100 万行 → MySQL 原生 Online DDL 就够了
+> ├─ 100 万 ~ 1000 万 → INSTANT DDL(加/删列用 native,索引用 OSC)
+> └─ > 1000 万 → gh-ost(首选)或 OSC
+> ↓
+> 有没有外键约束?
+> ├─ 有 → OSC 不适用,只能用 gh-ost
+> └─ 无 → gh-ost(资源更友好)优于 OSC
+> ```
+
+## CI/CD 集成与测试
+
+迁移脚本如果只在开发环境跑过就上了生产,等于开盲盒。CI 管道必须包含迁移验证环节。
+
+### Docker 化验证
+
+```yaml
+# .github/workflows/migrate-check.yml
+name: Migration Validation
+on: pull_request
+jobs:
+ test-migration:
+ runs-on: ubuntu-latest
+ services:
+ mysql:
+ image: mysql:8.0
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd "mysqladmin ping -h localhost"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - uses: actions/checkout@v4
+ - name: Run migrations up
+ run: |
+ migrate -path migrations -database "mysql://root:root@tcp(localhost:3306)/test_db" up
+ - name: Verify schema
+ run: |
+ mysql -h localhost -u root -proot test_db -e "SHOW TABLES;"
+ - name: Rollback and re-apply
+ run: |
+ migrate -path migrations -database "mysql://root:root:root@tcp(localhost:3306)/test_db" down $(migrate -path migrations -database "mysql://..." version)
+ migrate -path migrations -database "mysql://root:root@tcp(localhost:3306)/test_db" up
+```
+
+> [!IMPORTANT] 为什么需要回滚再重跑?
+> 这一步验证了两件事:
+> 1. **down 脚本可用** — 不是所有迁移都有 down 脚本
+> 2. **幂等性** — 先 rollback 再 apply 等价于干净状态从头跑,暴露出依赖顺序问题
+
+### Schema Diff 作为 PR Check
+
+```bash
+# 在 PR 中自动比对开发环境 schema 与迁移文件的差异
+# 安装 schemacatch 或用 flyway validate
+flyway -url=jdbc:mysql://localhost:3306/app_dev validate
+```
+
+输出示例:
+```
+VALID: Migrations checksum mismatch is not allowed
+-> Currently on version: 15/15
+```
+
+> [!QUOTE] 经验之谈
+> "99% 的生产事故不是因为迁移逻辑错了,而是因为本地跑的版本和 CI 检验的版本不一致。"
+> — 保持 CI、staging、prod 的迁移文件完全一致,任何手动改库的行为都应该被禁止。
+
+## 关联笔记
+
+- [[hhs/GORM/17-迁移工具]] — GORM 自带的 AutoMigrate vs 手动迁移工具的权衡
+- [[hhs/GORM/01-安装与初始化]] — GORM 的表结构创建机制
diff --git a/hhs/MySQL/37-分库分表.md b/hhs/MySQL/37-分库分表.md
new file mode 100644
index 0000000..93f7357
--- /dev/null
+++ b/hhs/MySQL/37-分库分表.md
@@ -0,0 +1,455 @@
+---
+tags: [MySQL, 分库分表, Sharding, Vitess, ShardingSphere, Snowflake]
+create time: 2026-05-16 00:00
+---
+
+# 分库分表
+
+## 概述
+
+当单库单表的百万级数据无法满足性能需求时,分库分表(Sharding)成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题。
+
+> [!QUESTION] 你真的需要分库分表吗?
+>
+> InnoDB 在单表千万级数据下依然表现优秀。一条合适的联合索引 + 读写分离就能解决 80% 的性能瓶颈。分片引入的复杂度远超收益——跨分片 JOIN、分布式事务、数据倾斜、在线迁移……每一个都是生产环境的深夜警报。**建议仅在以下情况才考虑分片:**
+>
+> 1. 单表已突破 **5000 万行**且增长趋势明显
+> 2. QPS 已接近单机 MySQL 的物理极限(约 1~2 万写入/秒)
+> 3. SSD 存储成本显著上升,无法继续横向扩展
+
+## 分片策略总览
+
+```mermaid
+flowchart TD
+ subgraph "水平分片 Horizontal"
+ HS["按用户哈希
user_id % N"]
+ HR["按范围 Range
created_at 月份"]
+ HL["按列表 List
country IN ('CN','US')"]
+ end
+
+ subgraph "垂直分片 Vertical"
+ V1["按业务域拆分
订单库 / 用户库 / 商品库"]
+ V2["冷热数据分离
热表 SSD / 冷表 HDD"]
+ end
+
+ style HS fill:#00D866,color:#fff
+ style V1 fill:#00B6BC,color:#fff
+```
+
+| 维度 | 适用场景 | 优点 | 缺点 |
+|------|---------|------|------|
+| **Hash 分片** | 均匀写入分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
+| **Range 分片** | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(双 11) |
+| **List 分片** | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需预规划 |
+| **垂直拆分** | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
+
+> [!TIP] 最佳实践:先垂直再水平
+> 如果不同业务的表耦合在同一库里,先做**垂直拆分**(按业务域拆成多个库),再对单个大表做**水平拆分**。两步都做能最大化收益。
+
+## 水平分表实战
+
+### Hash 分片
+
+```sql
+-- 假设拆分 orders 表为 4 张子表:orders_0 ~ orders_3
+-- 路由规则:order_id % 4 = 表后缀
+-- order_id=1001 → orders_1
+-- order_id=1004 → orders_0
+```
+
+```go
+// 通过 order_id 计算目标分片
+func GetShardTableName(orderID int64, shardCount int) string {
+ return fmt.Sprintf("orders_%d", orderID%int64(shardCount))
+}
+```
+
+> ⚠️ Hash 分片的坑:`order_id` 本身由数据库自增生成——如果你还没上分布式 ID,`order_id` 不能用作分片键(因为它是单库内递增的)。此时应改用 `user_id`(雪花算法生成的分布式 ID)。
+
+### Range 分片
+
+```sql
+-- 按月分表(适合审计日志、交易流水等时间序列数据)
+-- logs_202601, logs_202602, ..., logs_202612
+-- 优势:天然支持范围查询 WHERE created_at BETWEEN ...
+-- 劣势:最近月份写入压力大,历史月份只有读取
+```
+
+```sql
+-- 利用 range 分片的特性做数据归档
+-- 每月 1 号自动将上月数据迁移到 archive 库
+ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
+CREATE TABLE logs_202602 LIKE logs_202601;
+```
+
+### List 分片
+
+```sql
+-- 按 tenant_id 列表拆分(SaaS 多租户场景)
+-- tenant_A 的所有表在一个库,tenant_B 在另一个库
+-- 优势:租户级别的数据隔离,方便合规审查
+-- tenant_A: db_tenant_0.tenant_A_users, db_tenant_0.tenant_A_orders
+-- tenant_B: db_tenant_1.tenant_B_users, db_tenant_1.tenant_B_orders
+```
+
+### 联合分片
+
+```sql
+-- 双维度分片:shard_id = (user_id % 16) * 4 + (month % 4)
+-- 第一层 user_id 哈希:分散写入压力,同一用户的记录集中
+-- 第二层 month 取模:便于按时间范围和归档
+-- 共 16 × 4 = 64 张子表
+```
+
+## 分片键选型指南
+
+分片键是选择最关键的决策——**一旦选定,后期几乎无法更换**。
+
+```mermaid
+flowchart TD
+ Start["选定分片键"] --> Q1{"查询是否
总是包含此字段?"}
+
+ Q1 -->|是| Q2{"数据量是否
分布均匀?"}
+ Q1 -->|否| FAIL1["❌ 范围查询需扫描全部分片"]
+
+ Q2 -->|是| RECOMMEND["✅ 推荐作为分片键"]
+ Q2 -->|否| REBALANCE["⚠️ 考虑 List 或 Range 分片"]
+
+ RECOMMEND --> Q3{"是否有业务
级联删除?"}
+ Q3 -->|无| FINAL["🏆 user_id 是最常见的分片键"]
+ Q3 -->|有| WARNING["⚠️ 级联操作会触发多分片写"]
+
+ FAIL1 --> Q4{"是否按时间查询为主?"}
+ Q4 -->|是| TIME["✅ Range 按时间分片"]
+ Q4 -->|否| TRYDIFF["🔄 换一个能覆盖主查询的字段"]
+```
+
+> [!EXAMPLE] 经典案例:电商订单的分片键选择
+> - **错误选择**:`order_id` —— 自增 ID,数据集中在最后一个分片
+> - **正确选择**:`user_id` —— 分布式 ID,天然分散;且 90% 的订单查询都带 `WHERE user_id = ?`
+> - **折中方案**:`(user_id, order_id)` 联合主键,`user_id` 作分片键,`order_id` 作二级索引
+
+### 验证分片键的三个问题
+
+在决定分片键之前,用这三个问题自检:
+
+| # | 问题 | 如果答案是否定的 |
+|---|------|-----------------|
+| 1 | **核心查询路径是否都能带上这个字段?** | 会出现大量跨分片扫描 |
+| 2 | **数据分布是否足够随机化?** | 需要加盐(salt)打散 |
+| 3 | **未来 1~2 年的业务增长是否会改变查询模式?** | 预留可调整空间 |
+
+> [!NOTE] 数据倾斜检测
+> ```sql
+> -- 监控每个分片的数据量差异
+> SELECT table_name, table_rows
+> FROM information_schema.tables
+> WHERE table_schema = 'mydb' AND table_name LIKE 'orders_%'
+> ORDER BY table_rows DESC;
+> -- 如果最大分片行数 > 最小分片的 3 倍 → 存在倾斜
+> ```
+
+## 分布式 ID 与分片的关系
+
+分库分表后,**全局唯一 ID** 是第一道门槛。每个分片的自增 ID 只在本地有意义,合并时必然冲突。
+
+> [!IMPORTANT] 主键策略必须适配分片
+>
+> 如果你的主键是 AUTO_INCREMENT,每个分库各自从 1 开始计数——一旦需要合并数据或做跨库查询,ID 冲突不可避免。这就是为什么**要做分片就必须先上分布式 ID**。
+>
+> 详细的 ID 生成方案请参考:[[hhs/MySQL/09-主键策略对比]](Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。
+
+快速选型参考:
+
+| 场景 | 推荐方案 | 理由 |
+|------|---------|------|
+| 已有 Snowflake Worker | 直接用现有 WorkerID | 保持一致性,无需额外组件 |
+| 已有独立 ID 服务 | 调用 gRPC/HTTP ID 接口 | 集中管控,可回拨告警 |
+| 轻量级项目 | MySQL sequence 表 | 简单可靠,但高并发下有瓶颈 |
+
+## 跨分片查询
+
+这是分库分表最大的痛点。
+
+### 不可行方案
+
+```sql
+-- ❌ JOIN 跨分片:MySQL 原生不支持分布式 JOIN
+-- 一个表在 sharded_db.orders_0..3,另一个在 user_db.users
+
+-- ❌ UNION ALL 拼全部分片(除非你知道具体分片号)
+SELECT * FROM orders_0 WHERE user_id = 100
+UNION ALL SELECT * FROM orders_1 WHERE user_id = 100
+UNION ALL SELECT * FROM orders_2 WHERE user_id = 100
+UNION ALL SELECT * FROM orders_3 WHERE user_id = 100;
+```
+
+### 可行方案:应用层 JOIN
+
+```go
+// Step 1: 获取用户信息
+userInfo, _ := userService.GetUser(userID)
+
+// Step 2: 计算订单所在分片
+shards := []string{fmt.Sprintf("orders_%d", userID%4)}
+
+// Step 3: 并发查询各分片
+type OrderResult struct {
+ Orders []Order
+ Err error
+}
+
+ch := make(chan OrderResult, len(shards))
+for _, shard := range shards {
+ go func(s string) {
+ orders := queryOrders(s, userID)
+ ch <- OrderResult{Orders: orders}
+ }(shard)
+}
+
+var allOrders []Order
+for range shards {
+ result := <-ch
+ allOrders = append(allOrders, result.Orders...)
+}
+sort.Slice(allOrders, func(i, j int) bool {
+ return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
+})
+```
+
+```mermaid
+flowchart LR
+ Client["客户端请求"] --> GW["应用网关
计算目标分片"]
+ GW -->|"concurrent"| S1["Query orders_0"]
+ GW -->|"concurrent"| S2["Query orders_1"]
+ GW -->|"concurrent"| S3["Query orders_2"]
+ GW -->|"concurrent"| S4["Query orders_3"]
+ S1 & S2 & S3 & S4 --> Merge["合并排序
应用层聚合"]
+ Merge --> Resp["返回最终结果"]
+ style Merge fill:#FF9F43,color:#000
+```
+
+### 跨分片分页(难点中的难点)
+
+```sql
+-- ❌ LIMIT 深分页在各分片各自生效,合并后分页结果不对
+-- 分片 0: SELECT * FROM orders_0 ORDER BY id LIMIT 0, 20 → 拿到前 20
+-- 分片 1: SELECT * FROM orders_1 ORDER BY id LIMIT 0, 20 → 拿到前 20
+-- 合并取前 20?→ 错了!实际全局可能有 80 条比这些更早的记录
+
+-- ✅ 正确做法:游标分页 + 应用层归并
+-- 每个分片取更大窗口:ORDER BY created_at LIMIT 0, (N+depth)
+-- 应用层全量合并后再截取
+```
+
+```go
+// 游标分页 + 多路归并排序
+// 类似 Git rebase 的多分支合并逻辑
+
+type PageRequest struct {
+ UserID int64
+ Limit int // 期望返回数量
+ AfterTs int64 // 游标:上次最后一条的时间戳
+}
+
+func QueryShardedOrders(req PageRequest) ([]Order, error) {
+ shards := []string{fmt.Sprintf("orders_%d", req.UserID%4)}
+
+ // Step 1: 各分片拉取 (Limit + Depth) 条数据
+ type RawPage struct {
+ Shard string
+ Orders []Order
+ }
+
+ var allPages []RawPage
+ for _, shard := range shards {
+ q := fmt.Sprintf(
+ "SELECT * FROM %s WHERE user_id = %d AND created_at < %d ORDER BY created_at DESC LIMIT %d",
+ shard, req.UserID, req.AfterTs, req.Limit*2, // 扩大 2 倍缓冲
+ )
+ rows, _ := db.Query(shardDBConn(shard), q)
+ orders := scanOrders(rows)
+ allPages = append(allPages, RawPage{shard, orders})
+ }
+
+ // Step 2: 多路归并(k-way merge)
+ merged := mergeSort(allPages, req.Limit)
+ return merged, nil
+}
+
+// k-way merge:类似归并排序的 merge 步骤
+func mergeSort(pages []RawPage, limit int) []Order {
+ all := make([]Order, 0, len(pages)*limit)
+ for _, p := range pages {
+ all = append(all, p.Orders...)
+ }
+ sort.Slice(all, func(i, j int) bool {
+ return all[i].CreatedAt.After(all[j].CreatedAt)
+ })
+ if len(all) > limit {
+ return all[:limit]
+ }
+ return all
+}
+```
+
+> [!QUESTION] 为什么不在数据库层做多分片聚合?
+> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。想象你有 4 个桶,每个桶倒出前 20 颗珠子,合起来 80 颗里取前 20——但如果你要的是「全局第 1000~1020 颗」,每个桶自己取前 20 完全不够。**解决方案是加大每片采集窗口,然后在应用层做全局排序截断。** 代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
+
+### 跨分片聚合(COUNT / SUM / AVG)
+
+```sql
+-- ❌ COUNT(*) 在单个分片运行,汇总时直接相加即可
+-- ✅ 聚合类查询:SUM(xxx) → 各分片算完再 SUM
+
+-- 但复杂聚合(GROUP BY + JOIN)会很贵
+-- 方案:预计算 + 定时汇总表
+-- cron 每小时执行一次:
+INSERT INTO summary_daily (date, total_orders, total_amount)
+SELECT DATE(created_at), COUNT(*), SUM(amount)
+FROM orders_0 GROUP BY DATE(created_at)
+UNION ALL
+SELECT DATE(created_at), COUNT(*), SUM(amount)
+FROM orders_1 GROUP BY DATE(created_at);
+```
+
+## 中间件方案
+
+| 中间件 | 开发者 | 部署模式 | 核心特点 |
+|--------|-------|---------|---------|
+| **Vitess** | YouTube / Google | Proxy | 透明分片、在线 Re-sharding、K8s 原生 |
+| **ShardingSphere** | Apache | Proxy / JDBC | 功能最全、生态广、Java 生态为主 |
+| **MyCat** | 开源社区 | Proxy | 轻量简单、上手快 |
+| **Cobar** | Alibaba | Proxy | 已停止维护,被 ShardingSphere 取代 |
+
+### Vitess 架构
+
+```mermaid
+flowchart TB
+ subgraph App["应用层"]
+ GO["Go / Java App"]
+ end
+
+ subgraph VT["Vitess Layer"]
+ VTGate["vtgate
SQL 路由 + 连接池"]
+ VSchemas["vschema
分片策略定义"]
+ end
+
+ subgraph Shards["MySQL Instances"]
+ S1["Shard-0
mysqld-3000 ~ 3002"]
+ S2["Shard-1
mysqld-3003 ~ 3005"]
+ S3["Shard-2
mysqld-3006 ~ 3008"]
+ end
+
+ GO --> VTGate
+ VTGate --> VSchemas
+ VSchemas -->|"keyspace/table routing"| S1
+ VSchemas -->|"keyspace/table routing"| S2
+ VSchemas -->|"keyspace/table routing"| S3
+
+ style VTGate fill:#00B6BC,color:#fff
+ style VSchemas fill:#C44569,color:#fff
+```
+
+> [!NOTE] Vitess 的核心优势
+> 1. **透明路由**:应用感知不到分片,SQL 无需改动
+> 2. **在线 Re-sharding**:数据迁移过程不影响线上服务
+> 3. **Global Search**:通过 secondary index 支持全分片搜索
+> 4. **Kubernetes Native**:原生支持 K8s 部署与弹性伸缩
+
+## 数据迁移策略
+
+**从零停机迁移角度,这是生产环境最关键的一环。**
+
+### 双写迁移法(推荐)
+
+```mermaid
+flowchart TD
+ A["旧库 single_db"] --> B["新集群 sharded_db_0..N"]
+
+ subgraph "Phase 1: 存量数据搬迁"
+ P1["离线导出旧表
mysqldump"] --> P2["分批导入新集群
每批 ~100 万行"]
+ end
+
+ subgraph "Phase 2: 双写过渡"
+ APP["应用代码"] -->|"读旧+写旧"| P1
+ APP -->|"写新(异步)"| B
+ NOTE1["新旧数据一致
允许短暂不一致"]
+ end
+
+ subgraph "Phase 3: 切读"
+ P3["数据校验工具
row count / checksum"] --> P4["切换读路由到新集群"]
+ end
+
+ subgraph "Phase 4: 清理"
+ P5["停止旧库写入"] --> P6["观察 1~2 周无异常
下线旧库"]
+ end
+
+ P1 -.->|阶段顺序| P2 -.->|阶段顺序| P3 -.->|阶段顺序| P4 -.->|阶段顺序| P5 -.->|阶段顺序| P6
+```
+
+```go
+// 双写伪代码:写入新集群时走异步通道
+func CreateOrder(order *Order) error {
+ // 同步写旧库(保证兼容)
+ if err := writeOldDB(order); err != nil {
+ return err
+ }
+
+ // 异步写新分片(不阻塞主流程)
+ go func() {
+ shard := GetShardTableName(order.ID, 4)
+ writeNewDB(shard, order)
+ }()
+
+ return nil
+}
+```
+
+### 渐进式迁移(适合超大表)
+
+```
+┌──────────────────────────────────────────────────┐
+│ 渐进式迁移:按分片逐步切换 │
+│ │
+│ 初始: 全部流量 → 老库 │
+│ Phase 1: hash(user_id) % 2 == 0 → 新分片 0,1 │
+│ hash(user_id) % 2 == 1 → 老库 │
+│ Phase 2: hash(user_id) % 4 = 0,1 → 新分片 0,1 │
+│ hash(user_id) % 4 = 2,3 → 新分片 2,3 │
+│ Final: 全部流量 → 新集群 │
+│ │
+│ 优势:每次切换只影响部分用户,风险可控 │
+│ 劣势:需要在应用层维护两套路由规则 │
+└──────────────────────────────────────────────────┘
+```
+
+> [!WARNING] 迁移 checklist
+>
+> - [ ] DDL 变更兼容性:旧版代码能否读取新表结构?(永远先加字段、后删字段)
+> - [ ] 数据校验:用 `pt-table-checksum` 比对新旧数据一致性
+> - [ ] 回滚预案:切到新集群后发现问题,能在 5 分钟内切回
+> - [ ] 索引重建:新分片是否需要不同的索引策略?
+> - [ ] 监控接入:分片后的慢查询监控面板是否就绪?
+
+## 分片的挑战与反模式
+
+| 问题 | 描述 | 应对策略 |
+|------|------|---------|
+| **数据倾斜** | 某些分片数据量远大于其他 | 检查 hash 分布,必要时换分片键或加 salt |
+| **跨分片事务** | InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿 |
+| **Rebalance** | 新增节点后数据如何迁移 | 渐进式迁移,Vitess 内置 Scatter-Gather |
+| **复杂聚合** | COUNT / SUM 跨分片很贵 | 预计算 + 定时汇总到汇总表 |
+| **分页困难** | LIMIT 深分页在各分片各自生效 | 游标分页 + 应用层合并(见上方详解) |
+| **全局唯一约束** | UNIQUE KEY 在多分片下失效 | 改为业务层面去重,或用 Redis SET |
+
+> [!WARNING] 别过早分片
+>
+> 再次强调:**90% 的项目永远不需要分库分表**。InnoDB 单表千万级性能依然优秀。只有在满足前述三个条件时才考虑分片。如果团队规模小、迭代快,花两周优化 SQL 和索引的收益远高于花一个月做分片架构。
+
+## 关联笔记
+
+- [[hhs/MySQL/09-主键策略对比]] — 分布式 ID 生成方案(Snowflake / ULID / UUID)
+- [[hhs/GORM/13-多数据库支持]] — GORM 对多库连接的支持
+- [[hhs/MySQL/35-Go 连接池配置]] — 多分片场景下的连接池隔离
+- [[hhs/MySQL/38-监控指标]] — 分片集群的监控指标体系
diff --git a/hhs/MySQL/38-监控指标.md b/hhs/MySQL/38-监控指标.md
new file mode 100644
index 0000000..3abac8b
--- /dev/null
+++ b/hhs/MySQL/38-监控指标.md
@@ -0,0 +1,262 @@
+---
+tags: [MySQL, 监控指标, QPS, TPS, Buffer Pool Hit Rate, Prometheus, Grafana, InnoDB]
+create time: 2026-05-16 00:00
+---
+
+# 监控指标
+
+## 概述
+
+有效的监控体系能让你在问题爆发前发现问题。MySQL 的监控涵盖 **性能**、**可用性**、**容量** 和 **安全** 四个维度。
+
+> [!TIP] 核心思想
+> 监控的目的不是「收集更多数据」,而是 **「用最少的关键指标发现最多的问题」**。初学者常犯的错误是把所有 SHOW STATUS 变量都记录下来——但真正需要告警的可能不到 20 个。先定义你的 SLA/SLO,再决定监控什么。
+
+一个典型的 MySQL 监控栈自下而上:
+
+```mermaid
+graph TD
+ A["MySQL Server"] -->|"SHOW GLOBAL STATUS / performance_schema"| B["mysqld_exporter"]
+ B -->|"HTTP scrape"| C["Prometheus"]
+ C -->|"查询 + 告警规则"| D["Grafana"]
+ C -->|"Alertmanager"| E["告警通道\n钉钉/飞书/PagerDuty"]
+ D -->|"Dashboard"| F["运维 & 开发"]
+
+ style A fill:#f9d,stroke:#333
+ style C fill:#bee,stroke:#333
+ style D fill:#ddf,stroke:#333
+ style E fill:#fdb,stroke:#333
+```
+
+理解了这个架构,你就能定位任何一个环节出问题时该检查哪里。
+
+## 核心指标速查
+
+### 吞吐量类
+
+| 指标 | SHOW STATUS 名称 | 健康范围 | 说明 |
+|------|-----------------|---------|------|
+| **QPS** | `Queries` | 视硬件而定 | 包含内部重写(如 INSERT → multiple row),非客户端请求数 |
+| **TPS** | `Com_commit` + `Com_rollback` | 视硬件而定 | 只统计 InnoDB 事务型操作 |
+| **Threads Connected** | `Threads_connected` | < MaxConnections × 0.8 | 当前已建立的连接总数 |
+| **Threads Running** | `Threads_running` | < CPU 核心数 | 正在执行查询的线程数(瞬时高峰可短暂超限) |
+
+> [!WARNING] QPS ≠ 客户端请求数
+> `Queries` 计数的是服务器内部处理的查询数,而非客户端发送的请求数。一条 `INSERT INTO t VALUES(1),(2),(3)` 会被计数为 1 次 Client Query,但可能被优化器展开后计为多次 Internal Queries。如果需要精确的客户端请求量,应看 `Com_select` + `Com_insert` + `Com_update` + `Com_delete` 的总和。
+
+```sql
+-- 计算实时 QPS 和 TPS
+SHOW GLOBAL STATUS LIKE 'Queries'; -- 累计查询数
+SHOW GLOBAL STATUS LIKE 'Com_commit'; -- 累计 commit 数
+SHOW GLOBAL STATUS LIKE 'Com_rollback'; -- 累计 rollback 数
+
+-- QPS = Queries / Uptime
+-- TPS = (Com_commit + Com_rollback) / Uptime
+```
+
+```go
+// Go 中采集指标的推荐方式:直接查询并返回 map
+func collectStatus(db *sql.DB) (map[string]string, error) {
+ rows, err := db.Query("SHOW GLOBAL STATUS")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ metrics := make(map[string]string)
+ for rows.Next() {
+ var name, value string
+ rows.Scan(&name, &value) // value 可能是数字或字符串,统一读取为 string
+ metrics[name] = value
+ }
+ return metrics, rows.Err()
+}
+```
+
+这段代码的核心思路是 **一次查询拿全部指标**,避免 N+1 次网络往返。实际生产环境中,你会在每个指标之间做差值运算得到速率,详见下方公式推导。
+
+**速率计算公式:**
+
+```
+ΔQPS = (Queries_now - Queries_prev) / (Timestamp_now - Timestamp_prev)
+ΔTPS = ((Com_commit_now + Com_rollback_now) - (Com_commit_prev + Com_rollback_prev)) / ΔSeconds
+```
+
+Prometheus 的 `mysqld_exporter` 会自动帮你完成这个差值计算,如果用原生 SQL 采样则需自行实现。
+
+### InnoDB 引擎指标
+
+InnoDB 是绝大多数 MySQL 业务场景使用的存储引擎,其内部指标决定了数据库的整体健康状况。
+
+| 指标 | SHOW STATUS 名称 | 健康阈值 | 说明 |
+|------|-----------------|---------|------|
+| **Buffer Pool 读请求** | `Innodb_buffer_pool_read_requests` | — | 从 Buffer Pool 中读取的逻辑请求总数 |
+| **Buffer Pool 磁盘读取** | `Innodb_buffer_pool_reads` | 命中率 ≥ 99% | 需回磁盘读取的次数(未命中) |
+| **Redo Log 写入量** | `Innodb_data_written` | 突增意味着写入密集 | 累计写入 Redo Log 的数据字节数 |
+| **Redo Log flush 等待** | `Innodb_log_waits` | 应接近 0 | Redo Log 缓冲区满导致刷盘等待 |
+| **死锁次数** | `Innodb_deadlocks` | 不应持续增加 | 每次死锁回滚一个事务 |
+| **平均行锁等待** | `Innodb_row_lock_time_avg` | < 10ms | 行级锁平均等待时间 |
+| **行锁总等待时间** | `Innodb_row_lock_time` | 监控趋势 | 所有行锁等待累积耗时(ms) |
+| **DML 计数** | `Innodb_rows_deleted/inserted/updated` | 监控比例变化 | 各类 DML 操作的累计次数 |
+
+> [!NOTE] 如何判断 Buffer Pool 大小是否合理?
+> 只看命中率是不够的。更可靠的方法是观察 `Innodb_buffer_pool_reads` 的增长斜率:如果它几乎是一条水平线(单位时间内增长极少),说明当前 Buffer Pool 够用;如果斜率持续走高,就是增大 `innodb_buffer_pool_size` 的信号。一般建议设为物理内存的 **50%~70%**。
+
+```sql
+-- Buffer Pool 命中率(推荐写法)
+SELECT
+ (1 - A.value / B.value) * 100 AS hit_rate_pct
+FROM (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') A,
+ (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') B;
+
+-- 为什么不用 INFORMATION_SCHEMA.INNODB_METRICS?
+-- 该表需要额外启用,且部分版本中字段名不一致,推荐使用 global_status 视图
+```
+
+> [!QUESTION] 思考
+> 假设你的 Buffer Pool 命中率为 99.5%,但这个值是从服务启动至今累计计算的。如果最近一小时内出现了缓存穿透,累计比率会显示出来吗?
+> **答案**:不会——累计值会稀释短期异常。正确的做法是用定时采集的 **差值** 来计算窗口内的实时命中率。
+
+### 复制延迟指标
+
+主从复制是 MySQL 高可用的基石。延迟超过容忍度,读写分离就会带来数据一致性问题。
+
+| 指标 | SHOW REPLICA / SLAVE STATUS 字段 | 告警阈值 | 说明 |
+|------|--------------------------------|---------|------|
+| **Seconds_Behind_Master** | `Seconds_Behind_Master` | > 5s 警告,> 30s 严重 | 从库落后主库的秒数(NULL 表示连接断开) |
+| **IO Thread** | `Slave_IO_Running` (5.7) / `Replica_IO_Running` (8.0) | 必须 = Yes | IO 线程负责拉取 binlog |
+| **SQL Thread** | `Slave_SQL_Running` (5.7) / `Replica_SQL_Running` (8.0) | 必须 = Yes | SQL 线程负责重放事件 |
+| **Relay Log 空间** | `Relay_Log_Space` | 突增预警 | 中继日志积压通常意味着大事务 |
+
+```sql
+-- 标准查看方式(MySQL 8.0.22+ 使用 REPLICA 关键字)
+SHOW REPLICA STATUS\G
+
+-- MySQL 8.0+: 基于 GTID 的更精确延迟检测
+SELECT
+ PROCESSLIST_TIME AS delay_seconds,
+ INFO
+FROM performance_schema.threads t
+JOIN performance_schema.events_stages_current ess
+ ON t.THREAD_ID = ess.THREAD_ID
+WHERE NAME = 'stage/replication_sql_waiting_for_master_to_send_event';
+```
+
+> [!CAUTION] Seconds_Behind_Master 的陷阱
+> 当从库停止时,这个值会变成 NULL 而非一个巨大的数字。因此告警逻辑应同时检查 `Slave_IO_Running = No`、`Slave_SQL_Running = No` 和 `Seconds_Behind_Master IS NULL` 三种情况。
+
+## 慢查询趋势
+
+慢查询日志不只是排障工具,更是 **长期性能趋势** 的风向标。每天慢查询数量的突然增加,往往预示着即将发生的系统性问题。
+
+```sql
+-- 每日慢查询统计
+SELECT DATE_FORMAT(event_time, '%Y-%m-%d') AS day,
+ COUNT(*) AS slow_count,
+ ROUND(AVG(query_time), 3) AS avg_time,
+ ROUND(MAX(query_time), 3) AS max_time
+FROM mysql.slow_log
+GROUP BY day
+ORDER BY day DESC
+LIMIT 30;
+```
+
+> [!TIP] 实操建议
+> 在生产环境不建议直接查 `mysql.slow_log`(它是表格式存储,数据量大时性能差)。推荐使用 Percona 的 **[pt-query-digest](https://www.percona.com/doc/percona-toolkit/LATEST/pt-query-digest.html)**,它能聚合相似查询并按类型统计,还能输出 HTML 报告。
+
+一个实用的告警规则设计:
+
+```mermaid
+flowchart LR
+ A["Slow Query 阈值\nquery_time > 1s"] --> B{"单条超时 > 10s?"}
+ B -->|是| C["立即告警\nP1 级别"]
+ B -->|否| D{"当日累计 > 昨日 2x?"}
+ D -->|是| E["趋势告警\nP2 级别"]
+ D -->|否| F["正常记录"]
+
+ style C fill:#f99,stroke:#c00
+ style E fill:#fd9,stroke:#ca0
+ style F fill:#9f9,stroke:#0a0
+```
+
+这种分级告警能帮你区分「偶发异常」和「系统性恶化」,避免告警疲劳。
+
+## 告警分级策略
+
+好的监控应该告诉你 **现在该做什么**,而不是让你从一堆告警里自己判断。
+
+| 级别 | 触发条件示例 | 响应时效 | 通知渠道 |
+|------|-------------|---------|---------|
+| **P0 致命** | Slave_SQL_Running = No、磁盘空间 < 5%、Innodb_deadlocks > 0/min | 5 分钟内 | 电话 + 短信 + IM |
+| **P1 严重** | 延迟 > 30s、Threads_running > CPU×2、QPS 骤降 50%+ | 15 分钟内 | 短信 + IM |
+| **P2 警告** | 延迟 > 5s、命中率 < 95%、慢查询激增 | 1 小时内 | IM 群 |
+| **P3 提示** | Buffer Pool 利用率持续下降、表空间增长过快 | 当天处理 | 日报/周报 |
+
+> [!QUESTION] 你的 SLA 是什么?
+> 不同的业务对可用性的要求差异巨大:金融系统可能要求 99.999%,而内部工具 99.9% 就足够了。**先明确业务目标,再反过来设计告警阈值**。不要盲目套用别人的配置。
+
+## 监控工具选型
+
+| 工具 | 定位 | 优点 | 缺点 |
+|------|------|------|------|
+| **Prometheus + mysqld_exporter** | 指标采集 | 云原生标准、告警丰富 | 需自建 Grafana Dashboard |
+| **PMM (Percona)** | 完整监控平台 | 开箱即用、包含 QAN 分析 | 较重,占用资源 |
+| **Zabbix** | 传统监控 | 成熟稳定、报警灵活 | 缺少查询级洞察 |
+| **Datadog/NewRelic** | SaaS 监控 | 零运维、可视化优秀 | 付费、数据出网 |
+| **阿里云 RDS 监控** | 云服务内置 | 免费、深度集成 | 锁定云厂商 |
+
+### Prometheus + mysqld_exporter 快速搭建
+
+```yaml
+# prometheus.yml 追加
+scrape_configs:
+ - job_name: 'mysql'
+ static_configs:
+ - targets: ['localhost:9104'] # mysqld_exporter 端口
+```
+
+```bash
+# 启动 exporter(MySQL 5.7)
+mysqld_exporter \
+ --config.my-cnf=/etc/mysql/debian.cnf \
+ --collect.global_status \
+ --collect.engine_innodb_status \
+ --collect.perf_schema.tableiowaits
+
+# MySQL 8.0 推荐额外开启 perf_schema 指标
+mysqld_exporter \
+ --config.my-cnf=/etc/mysql/debian.cnf \
+ --collect.global_status \
+ --collect.info_schema.innodb_metrics \
+ --collect.perf_schema.eventswaitssummary
+```
+
+> [!NOTE] mysqld_exporter 权限
+> exporter 需要专门的只读账号,至少授予 `REPLICATION CLIENT`, `PROCESS`, `SUPER` 权限即可。切勿用 root 账号运行。
+
+## Grafana 关键 Dashboard
+
+以下面板组合作为基线监控,可根据自身需求增减:
+
+```
+1. Overview: QPS, TPS, Connections, Slow Queries
+2. InnoDB Buffer Pool: Hit Rate, Dirty Pages, Free Pages
+3. Replication: Seconds Behind Master, IO/SQL Thread Lag
+4. Disk I/O: Read/Write Latency, Throughput
+5. Network: Bytes Sent/Received per Second
+6. Memory: Used vs Available
+```
+
+社区推荐的 Dashboard ID(导入到 Grafana 即可):
+
+| 主题 | Dashboard ID | 作者 |
+|------|-------------|------|
+| MySQL Overview | [3131](https://grafana.com/grafana/dashboards/3131-mysql-database/) | Google |
+| Percona MySQL | [9967](https://grafana.com/grafana/dashboards/9967-percona-server-mysql-dashboard/) | Percona |
+| mysqld_exporter | [12234](https://grafana.com/grafana/dashboards/12234-mysqld-exporter-overview/) | Prometheus Community |
+
+## 关联笔记
+
+- [[hhs/MySQL/20-慢查询日志分析]] — pt-query-digest 配合监控系统
+- [[hhs/Redis/09-高级特性]] — Redis 监控指标的对比
+- [[hhs/GORM/16-日志与调试]] — GORM 级别的慢查询追踪
diff --git a/hhs/MySQL/39-安全加固.md b/hhs/MySQL/39-安全加固.md
new file mode 100644
index 0000000..241aca5
--- /dev/null
+++ b/hhs/MySQL/39-安全加固.md
@@ -0,0 +1,362 @@
+---
+tags: [MySQL, 安全加固, SSL/TLS, SQL Injection, ACL]
+create time: 2026-05-16 00:00
+---
+
+# 安全加固
+
+## 概述
+
+数据库安全涉及访问控制、传输加密、数据保护和防御攻击等多个层面。MySQL 内置了丰富的安全机制,但默认配置往往不够严格。本节梳理生产环境必须做的安全加固措施。
+
+## 最小权限原则
+
+> [!QUESTION] 思考
+> 为什么应用账号不应该拥有 `DROP` 或 `ALTER` 权限?如果业务确实需要建表,应该怎么设计?
+
+```sql
+-- ❌ 最差实践:应用直接用 root 连接(常见于开发环境)
+GRANT ALL PRIVILEGES ON *.* TO 'app_user'@'%';
+
+-- ✅ 正确做法:最小权限 + 限制来源 IP
+CREATE USER 'app_user'@'10.0.1.%' IDENTIFIED BY 'strong_password_here';
+GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'10.0.1.%';
+-- ⚠️ 不给 CREATE、DROP、ALTER、FILE、PROCESS、SUPER 等管理权限
+FLUSH PRIVILEGES;
+```
+
+### MySQL 角色系统(8.0+)
+
+> [!TIP] 角色 vs 直接授权
+> 当用户数量增多时,逐个给用户赋权难以维护。角色可以理解为"权限模板",给角色授权后再把角色分配给用户。
+
+```sql
+-- 创建角色(权限模板)
+CREATE ROLE 'app_reader', 'app_writer', 'app_admin';
+
+-- 按角色分配权限
+GRANT SELECT ON app_db.* TO 'app_reader';
+GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_writer';
+GRANT ALL ON app_db.* TO 'app_admin';
+
+-- 给用户分配角色
+GRANT 'app_reader', 'app_writer' TO 'app_user'@'%';
+SET DEFAULT ROLE ALL TO 'app_user'@'%'; -- 登录后自动激活这些角色
+```
+
+> [!NOTE] 高危权限清单(绝对不可滥用)
+> - **FILE**: 可以读取服务器上的任意文件(`LOAD DATA INFILE`)→ 可读取 `/etc/shadow`
+> - **PROCESS**: 可以看到所有用户的 running query → 泄露业务逻辑和敏感数据
+> - **SUPER**: 可以修改全局配置(包括关闭 SSL 要求)→ 可能绕过多层安全防御
+> - **SHUTDOWN**: 可以直接关闭数据库 → 造成停机
+> - ** grant option**: 可以将自己拥有的权限授予他人 → 权限扩散
+>
+> 应用账号绝对不应该拥有以上任何权限。
+
+## 账户初始化与清理
+
+> [!QUESTION] 你以为干净的数据库,真的干净吗?
+> MySQL 初始化后会默认创建一些不安全的账户和数据库,很多团队忽略了这一步。
+
+```sql
+-- 初始化后必须执行的操作:
+DROP DATABASE IF EXISTS test; -- 默认的 test 库任何人都可以访问
+DROP USER ''@'localhost'; -- 删除匿名账户(无密码即可登录)
+DROP USER 'root'@'%' ; -- 删除 root 的远程访问权限
+
+-- 检查残留账户
+SELECT user, host, plugin FROM mysql.user WHERE user = '';
+
+-- 确保 root 只能从本地登录
+RENAME USER 'root'@'%' TO 'root'@'localhost';
+```
+
+## 密码安全
+
+> [!QUESTION] 你的密码策略能挡住暴力破解吗?
+> `123456`、`password`、`admin123` 这类弱口令是黑客的首选攻击目标。
+
+```sql
+-- 启用密码强度验证插件
+INSTALL COMPONENT 'file://component_validate_password';
+
+-- 配置密码策略
+SET GLOBAL validate_password.policy = MEDIUM; -- LOW/MEDIUM/STRONG
+SET GLOBAL validate_password.length = 12; -- 最小长度建议 ≥ 16
+SET GLOBAL validate_password.mixed_case_count = 1; -- 大小写混合
+SET GLOBAL validate_password.number_count = 1; -- 数字要求
+SET GLOBAL validate_password.special_char_count = 1; -- 特殊字符
+
+-- 强制现有用户使用强密码并定期轮换
+ALTER USER 'app_user'@'%' PASSWORD EXPIRE INTERVAL 90 DAY;
+-- 90 天强制更换密码
+
+-- 服务账号可以关闭过期(避免定时任务因密码过期而失败)
+ALTER USER 'app_user'@'%' PASSWORD EXPIRE NEVER;
+```
+
+## SQL 注入防护
+
+> [!WARNING] 核心原则
+> SQL 注入的本质是**将用户输入当作 SQL 代码来执行**。参数化查询通过驱动层发送参数,服务端将其视为纯数据而非可执行语句。
+
+```mermaid
+flowchart LR
+ A["恶意输入: admin' OR '1'='1"] --> B{是否参数化?}
+ B -->|❌ 字符串拼接| C["最终 SQL: SELECT * FROM users WHERE name = 'admin' OR '1'='1'"]
+ C --> D["⚠️ 返回所有记录"]
+ B -->|✅ 占位符 ?| E["driver 发送参数作为数据"]
+ E --> F["最终 SQL: SELECT * FROM users WHERE name = ? \n(参数值: admin' OR '1'='1")"]
+ F --> G["✅ 找不到匹配,返回空"]
+
+ style D fill:#FF6B6B,color:#fff
+ style G fill:#00D866,color:#fff
+```
+
+### 参数化查询(唯一有效的手段)
+
+```go
+// ❌ 危险拼接(SQL 注入漏洞)
+query := fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", userInput)
+db.Query(query)
+
+// ✅ 正确:使用占位符(Driver 层处理转义)
+db.Query("SELECT * FROM users WHERE username = ?", userInput)
+
+// ✅ 正确:GORM Prepared Statement(预编译,性能更好)
+db.Where("username = ?", userInput).First(&user)
+```
+
+```java
+// Java PreparedStatement(预编译,同一语句可多次执行不同参数)
+PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
+ps.setInt(1, userId);
+ResultSet rs = ps.executeQuery();
+```
+
+### 不能参数化的场景及替代方案
+
+> [!IMPORTANT] 参数化只能处理**数据值**,不能处理标识符(表名、列名、排序方向)。
+
+```sql
+-- 场景:动态表名(LIKE 'prefix_%')
+-- ❌ 不能用参数化,因为参数不能用于标识符
+SELECT * FROM ? WHERE id = 1; -- 语法错误
+
+-- ✅ 白名单校验 + 反引号包裹
+allowedTables := map[string]bool{
+ "users": true, "orders": true, "products": true,
+}
+if !allowedTables[tableName] {
+ return errors.New("invalid table name")
+}
+query := fmt.Sprintf("SELECT * FROM `%s` WHERE id = ?", tableName)
+db.Query(query, id)
+
+-- ✅ 动态 ORDER BY 使用白名单
+allowedColumns := map[string]bool{"created_at": true, "name": true, "id": true}
+col := r.URL.Query().Get("order_by")
+if !allowedColumns[col] {
+ col = "created_at" // 默认排序
+}
+// 再配合 ASC/DESC 白名单
+direction := "ASC"
+if dir := r.URL.Query().Get("direction"); dir == "DESC" {
+ direction = dir
+}
+query := fmt.Sprintf("SELECT * FROM users ORDER BY `%s` %s", col, direction)
+```
+
+## 传输加密
+
+### SSL/TLS 配置
+
+> [!TIP] 为什么需要 TLS?
+> 内网通信不等于安全。同一台交换机下可以用 tcpdump 抓包,中间人攻击(MITM)在容器化环境中也完全可行。
+
+```bash
+# 生成 CA + 服务端证书
+mysql_ssl_rsa_setup --datadir=/var/lib/mysql/mysql-ssl
+
+# my.cnf 配置服务端 SSL
+[mysqld]
+require_secure_transport = ON # 强制所有连接使用 SSL(MySQL 8.0.12+)
+ssl-ca = /var/lib/mysql/mysql-ssl/ca.pem
+ssl-cert = /var/lib/mysql/mysql-ssl/server-cert.pem
+ssl-key = /var/lib/mysql/mysql-ssl/server-key.pem
+
+# 可选:禁止不安全的 LOCAL_INFILE(防止通过 LOAD DATA LOCAL 读客户端文件)
+local-infile = 0
+```
+
+```go
+// Go 驱动 SSL 配置(生产环境必须验证证书)
+import (
+ "crypto/tls"
+ _ "github.com/go-sql-driver/mysql"
+)
+
+tlsConfig := &tls.Config{
+ MinVersion: tls.VersionTLS12, // 不支持 TLS 1.0/1.1
+ InsecureSkipVerify: false, // ⚠️ 测试环境可用 true,生产必须 false
+ ClientAuth: tls.RequireAndVerifyClientCert, // mTLS:双向认证(可选)
+}
+mysql.RegisterTLSConfig("custom", tlsConfig)
+
+dsn := "user:pass@tcp(host:3306)/db?tls=custom&parseTime=True"
+db, _ := sql.Open("mysql", dsn)
+```
+
+> [!CHECK] 验证 TLS 是否生效
+> ```sql
+> SHOW SESSION STATUS LIKE 'Ssl_cipher';
+> -- 如果返回空,说明当前连接未使用加密
+>
+> SHOW VARIABLES LIKE '%ssl%';
+> -- Require_SSL 应为 YES
+> ```
+
+### 身份认证插件
+
+> [!NOTE] caching_sha2_password vs mysql_native_password
+> MySQL 8.0 默认使用 `caching_sha2_password`,它支持 SHA-256 哈希和密码缓存机制(性能更好)。但部分老旧驱动(如 Python MySQLdb < 2.1.0)只支持 `mysql_native_password`。
+
+```sql
+-- MySQL 8.0 默认使用 caching_sha2_password(更安全)
+-- legacy 客户端可能需要切换回 mysql_native_password
+ALTER USER 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'password';
+
+-- 检查当前使用的认证插件
+SELECT user, host, plugin, authentication_string
+FROM mysql.user;
+
+-- 统一切换为更安全的方式(推荐)
+ALTER USER 'app_user'@'%' IDENTIFIED WITH caching_sha2_password BY 'new_strong_password';
+```
+
+## 信息与版本泄露
+
+> [!QUESTION] 你在帮敌人认识你?
+> 数据库版本号让攻击者知道该用哪些 CVE 漏洞;主机名可能暴露部署架构。
+
+```sql
+-- ❌ 默认暴露了大量信息
+SELECT VERSION(), @@hostname, @@basedir;
+
+-- 方法1: 使用反向代理隐藏真实端口
+-- nginx 配置:将 /db 路径转发到 MySQL,应用通过 HTTP 走 ProxyProtocol
+
+-- 方法2: 自定义 error_log 输出格式
+[mysqld]
+log_error_verbosity = 2 # 减少详细错误信息输出
+```
+
+## 审计日志
+
+> [!IMPORTANT] 通用日志 vs 专用审计
+> `general_log` 记录一切(包括普通查询),性能开销巨大。**仅用于临时排查**,不适合长期开启。生产审计推荐使用专用审计插件。
+
+```sql
+-- MySQL Enterprise Audit Plugin(商业版)
+-- 社区版可以用 general_log 作为短期替代方案
+
+-- ⚠️ 开启通用日志(性能开销较大,仅用于临时审计)
+SET GLOBAL general_log = 'ON';
+SET GLOBAL general_log_file = '/var/log/mysql/general.log';
+SET GLOBAL general_log = 'OFF'; -- 查完后立即关闭
+
+-- slow_query_log 长期开启(记录慢查询,便于发现异常高频查询)
+SET GLOBAL slow_query_log = 'ON';
+SET GLOBAL long_query_time = 2; -- 超过 2 秒的记录
+SET GLOBAL log_queries_not_using_indexes = 'ON';
+```
+
+### 社区审计方案(DDL/DML 追踪)
+
+```sql
+-- 记录所有 DDL 操作到自定义审计表
+CREATE TABLE ddl_audit (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+ user_host VARCHAR(255),
+ ddl_statement TEXT
+);
+
+-- DML 行级变更追踪(触发器方式)
+DELIMITER //
+CREATE TRIGGER audit_users_insert_after
+AFTER INSERT ON users
+FOR EACH ROW
+BEGIN
+ INSERT INTO dml_audit (action, table_name, row_id, changed_at)
+ VALUES ('INSERT', 'users', NEW.id, NOW());
+END//
+
+CREATE TRIGGER audit_users_update_after
+AFTER UPDATE ON users
+FOR EACH ROW
+BEGIN
+ IF OLD.status != NEW.status THEN
+ INSERT INTO dml_audit (action, table_name, row_id, old_val, new_val, changed_at)
+ VALUES ('UPDATE', 'users', NEW.id, OLD.status, NEW.status, NOW());
+ END IF;
+END//
+DELIMITER ;
+
+-- 使用 EVENT TRIGGER(MySQL 8.0.16+)记录 DDL
+CREATE EVENT TRIGGER trg_ddl_audit
+ON OBJECT SCALAR
+EVENT DDL
+EXECUTE AS OWNER
+WHEN (CURRENT_USER() NOT IN ('root'))
+DO LOG 'DDL detected by ' || CURRENT_USER();
+```
+
+> [!TIP] 开源审计替代方案
+> - **MariaDB Audit Plugin**: 免费,支持事件类型过滤
+> - **Percona Audit Log Plugin**: Percona Server 内置
+> - **MySQL Router + ProxySQL**: 通过网络代理层拦截和记录所有查询
+
+## 安全加固 Checklist
+
+```mermaid
+flowchart TD
+ A["🔒 MySQL 安全加固"] --> B["1. 访问控制"]
+ B --> B1["最小权限原则"]
+ B --> B2["限制来源 IP/CIDR"]
+ B --> B3["禁用匿名账户"]
+
+ A --> C["2. 密码安全"]
+ C --> C1["validate_password 插件"]
+ C --> C2["定期轮换密码"]
+ C --> C3["禁用空密码账户"]
+
+ A --> D["3. 传输加密"]
+ D --> D1["SSL/TLS 强制"]
+ D --> D2["TLS 1.2+"]
+ D --> D3["禁用 LOAD DATA LOCAL"]
+
+ A --> E["4. 审计日志"]
+ E --> E1["slow_query_log 常开"]
+ E --> E2["general_log 按需开关"]
+ E --> E3["binlog 备份保护"]
+
+ A --> F["5. 信息管控"]
+ F --> F1["隐藏版本/主机名"]
+ F --> F2["custom error messages"]
+ F --> F3["关闭 performance_schema 敏感暴露"]
+
+ A --> G["6. OS & 网络"]
+ G --> G1["mysql 目录 chmod 700"]
+ G --> G2["删除 test 库 + 示例数据"]
+ G --> G3["防火墙限制 3306 端口"]
+
+ style B1 fill:#00D866,color:#fff
+ style D1 fill:#00B6BC,color:#fff
+ style F1 fill:#FFB800,color:#fff
+```
+
+## 关联笔记
+
+- [[hhs/GORM/01-安装与初始化]] — GORM 连接参数中的安全配置
+- [[hhs/DEV/Go-Database]] — Go 驱动的安全连接配置
diff --git a/hhs/MySQL/40-常见踩坑.md b/hhs/MySQL/40-常见踩坑.md
new file mode 100644
index 0000000..a9e77ba
--- /dev/null
+++ b/hhs/MySQL/40-常见踩坑.md
@@ -0,0 +1,321 @@
+---
+tags: [MySQL, 踩坑, FileSort, 隐式转换, TIMESTAMP, DATETIME, AUTO_INCREMENT, FULLTEXT, 幻读, LEFT JOIN]
+create time: 2026-05-16 00:30
+---
+
+# 常见踩坑
+
+## 概述
+
+MySQL 的设计有许多「反直觉」的行为。本章汇总最常见的陷阱和误区,帮助你在编码阶段就规避这些问题。
+
+## 1. ORDER BY 产生 FileSort
+
+```sql
+-- ❌ 看似有索引,但实际上触发了 FileSort
+CREATE INDEX idx_status ON orders(status);
+
+EXPLAIN SELECT * FROM orders
+WHERE status = 'pending'
+ORDER BY created_at DESC;
+-- Extra: Using where; Using filesort
+
+-- 为什么?idx_status 只按 status 排序,created_at 是无序的
+-- 虽然 WHERE 能用索引过滤,但 ORDER BY 无法利用该索引
+
+-- ✅ 解决方法:创建联合索引
+ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
+
+-- 现在的 EXPLAIN:
+-- key: idx_status_created
+-- Extra: Using where ← No filesort! 因为索引里已经是有序的
+```
+
+```mermaid
+flowchart LR
+ IDX["联合索引 idx(status, created_at)"]
+
+ IDX -->|"WHERE status=? ORDER BY created_at"| PERF["✅ 完全利用索引顺序"]
+ IDX -->|"WHERE status=? LIMIT 100"| PERF
+
+ single_idx["单列索引 idx(status)"]
+ single_idx -->|"WHERE status=? ORDER BY created_at"| SORT["❌ Filesort
内存排序"]
+
+ style PERF fill:#00D866,color:#fff
+ style SORT fill:#EE5A24,color:#fff
+```
+
+> [!TIP] 教学要点
+> **问题**:为什么已有索引还不够?
+> **回答**:B+树索引只能保证第一列有序。当 WHERE 固定了第一列的值后,剩余行的第二列仍然保持插入顺序,而非目标字段的排序顺序。
+
+## 2. COUNT(*) vs COUNT(1) vs COUNT(column)
+
+```sql
+-- 三者语义不同,但在 InnoDB 中 COUNT(*) 最快
+SELECT COUNT(*) FROM users; -- 统计行数(忽略 NULL)
+SELECT COUNT(1) FROM users; -- 同 COUNT(*)(InnoDB 优化相同)
+SELECT COUNT(email) FROM users; -- 统计 email 非 NULL 的行数
+SELECT COUNT(DISTINCT city) FROM users; -- 去重计数
+
+-- InnoDB 优化细节
+-- COUNT(*) 直接扫描聚簇索引的最左叶子页开始计数(O(N))
+-- COUNT(column) 除了扫描还需要检查是否为 NULL → 稍慢
+-- COUNT(DISTINCT) 需要额外排序/去重 → 最慢
+```
+
+```mermaid
+graph TB
+ subgraph "性能对比 (由快到慢)"
+ A["COUNT(*)
只数行数"] -->|快 10\%~30\%| B["COUNT(1)
优化器等价"]
+ B -->|稍慢| C["COUNT(col)
需判 NULL"]
+ C -->|最慢| D["COUNT(DISTINCT col)
需排序去重"]
+ end
+
+ style A fill:#00D866,color:#fff
+ style B fill:#7BED9F,color:#000
+ style C fill:#FFA500,color:#fff
+ style D fill:#EE5A24,color:#fff
+```
+
+> [!TIP] 结论
+> - 如果你只是想统计行数 → 用 `COUNT(*)`
+> - `COUNT(1)` 和 `COUNT(*)` 在 InnoDB 中是完全等价的(优化器视为同一计划)
+> - 不要在 COUNT 中放列名除非你真的要排除 NULL 值
+>
+> **问题思考**:为什么 `COUNT(*)` 不需要逐行判断字段值?因为 InnoDB 内部维护了行计数器,直接从聚簇索引叶子节点遍历即可。
+
+## 3. TIMESTAMP 自动更新陷阱
+
+```sql
+CREATE TABLE events (
+ id BIGINT PRIMARY KEY,
+ name VARCHAR(100),
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ ON UPDATE CURRENT_TIMESTAMP -- ← 这里!
+);
+
+INSERT INTO events (name) VALUES ('Meeting');
+-- created_at = 2026-05-16 10:00:00
+-- updated_at = 2026-05-16 10:00:00
+
+-- 即使只修改 name,updated_at 也会自动更新
+UPDATE events SET name = 'New Meeting' WHERE id = 1;
+-- updated_at = 2026-05-16 10:05:00 ← 自动更新了!
+
+-- 但如果修改的是 created_at 呢?
+UPDATE events SET created_at = '2026-01-01 00:00:00' WHERE id = 1;
+-- updated_at 同样会自动更新为当前时间!
+-- 这就是陷阱——修改任何字段都会触发 ON UPDATE
+```
+
+> [!WARNING] 独立创建时间和更新时间
+> ```sql
+> -- 推荐模式:created_at 不使用 ON UPDATE,updated_at 用 ON UPDATE
+> CREATE TABLE good_events (
+> id BIGINT PRIMARY KEY,
+> name VARCHAR(100),
+> created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 不受 ON UPDATE 影响
+> updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+> );
+> ```
+
+> [!TIP] 为什么修改 created_at 也会触发 ON UPDATE?
+> `ON UPDATE CURRENT_TIMESTAMP` 作用于**整行**而非特定列。任何字段的更新都会让 `updated_at` 刷新为当前时间。这不是 bug,是 MySQL 的设计语义——"此行被修改了"。
+
+## 4. VARCHAR 长度计算
+
+```sql
+-- utf8mb4 下,VARCHAR(n) 的最大字节数 = n × 4
+-- MySQL 一行最大 65535 字节
+
+-- ❌ 看起来合理,实际超限
+CREATE TABLE bad_table (
+ col1 VARCHAR(16383), -- 16383 × 4 = 65532 bytes + 2 bytes length prefix = 65534 ✓
+ col2 CHAR(1) -- 1 × 4 = 4 bytes + overhead = ❌ 超出 65535 行限制
+);
+-- ERROR 1118: Row size too large
+
+-- ✅ 解决方案
+CREATE TABLE good_table (
+ col1 VARCHAR(16382), -- 留余量
+ col2 LONGTEXT -- 大文本走外存
+);
+```
+
+> [!WARNING] 关于 65535 的常见误区
+> - 65535 是**行级别**的硬限制(不含 TEXT/BLOB 等外部存储字段)
+> - `VARCHAR(n)` 需要 **1~2 字节**的长度前缀来记录实际数据长度
+> - `innodb_large_prefix=ON` 时可突破到约 8KB(配合 DYNAMIC 页格式),但跨引擎兼容性差,不建议依赖
+>
+> **问题思考**:为什么 TEXT/BLOB 不计入行大小限制?因为它们的数据存储在溢出页(overflow pages)中,行内只保留一个 20 字节的指针。
+
+## 5. LEFT JOIN 条件位置
+
+```sql
+-- ❌ 把 LEFT JOIN 的过滤条件放在 WHERE,等效于 INNER JOIN
+SELECT u.name, o.amount
+FROM users u LEFT JOIN orders o ON u.id = o.user_id
+WHERE o.status = 'paid';
+-- WHERE o.status 会把 LEFT JOIN 的结果过滤掉不匹配的行 → 变成 INNER JOIN
+
+-- ✅ 正确:把条件放在 ON 子句
+SELECT u.name, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.amount ELSE 0 END), 0)
+FROM users u
+LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';
+
+-- ✅ 或者:过滤右表应该在 WHERE 中显式检查 NULL
+SELECT u.name, o.amount
+FROM users u LEFT JOIN orders o ON u.id = o.user_id
+WHERE o.status = 'paid' OR o.id IS NULL;
+```
+
+> [!TIP] LEFT JOIN vs INNER JOIN 的快速判断
+> **口诀**:左表的过滤条件放 WHERE(不影响左表完整性),右表的过滤条件放 ON(保留左表全量)。
+>
+> **问题思考**:如果 WHERE 中的列来自两张表(`WHERE u.city = 'Beijing' AND o.status = 'paid'`)会怎样?
+> **回答**:此时 WHERE 先执行 JOIN,再对结果集做二次过滤。这仍然是 LEFT JOIN,只是部分数据被 WHERE 筛掉了——它不会退化为 INNER JOIN。
+
+## 6. 隐式类型转换导致索引失效
+
+```sql
+-- phone 是 VARCHAR(20)
+ALTER TABLE users ADD INDEX idx_phone (phone);
+
+-- ❌ 传入了 INT,MySQL 自动把 phone 转成 INT 再比较
+SELECT * FROM users WHERE phone = 13800138000;
+-- EXPLAIN: type=ALL, Rows=1000000 → 索引失效
+
+-- ✅ 始终传入字符串
+SELECT * FROM users WHERE phone = '13800138000';
+-- EXPLAIN: type=ref, key=idx_phone → 走索引
+
+-- 类似陷阱
+SELECT * FROM users WHERE id = '12345';
+-- varchar 列传 int 同理,MySQL 会做隐式转换
+```
+
+> [!WARNING] 隐式转换的核心规则
+> - MySQL 的转换优先级:**INT > VARCHAR**。当列类型为 VARCHAR、值为 INT 时,MySQL 会把**每一行的 VARCHAR 值转为 INT**再比较 → 索引失效。
+> - 反之(列是 INT、传入 VARCHAR),MySQL 会将传入值转为 INT → **索引依然生效**。
+> - **结论**:永远让参数类型与列类型严格一致。这是后端 ORM 框架最容易忽略的点。
+
+## 7. datetime 的精度与范围
+
+```sql
+-- MySQL 8.0 默认 datetime(0) — 不带小数秒
+CREATE TABLE t1 (dt DATETIME);
+INSERT INTO t1 VALUES ('2026-05-16 10:30:45.123456');
+SELECT dt FROM t1;
+-- 输出: 2026-05-16 10:30:45 ← 微秒丢失!
+
+-- ✅ 如果需要保留精度
+CREATE TABLE t2 (dt DATETIME(6));
+INSERT INTO t2 VALUES ('2026-05-16 10:30:45.123456');
+SELECT dt FROM t2;
+-- 输出: 2026-05-16 10:30:45.123456
+```
+
+```mermaid
+graph LR
+ subgraph "DATETIME vs TIMESTAMP"
+ A["DATETIME
存储: 8 bytes
范围: 1000~9999
不受时区影响"] --> B["适合业务时间
如订单创建时间"]
+ C["TIMESTAMP
存储: 4 bytes
范围: 1970~2038
受 server 时区影响"] --> D["适合审计字段
如最后登录时间"]
+ end
+
+ style B fill:#00D866,color:#fff
+ style D fill:#FFA500,color:#fff
+```
+
+> [!WARNING] TIMESTAMP 有上限问题
+> TIMESTAMP 的最大值是 `2038-01-19 03:14:07`。如果你的系统需要在 2038 年之后使用,务必改用 `DATETIME`,否则会遇到无法插入数据的诡异 bug。这也是为什么很多团队统一只用 DATETIME。
+
+## 8. AUTO_INCREMENT 删除后的断号问题
+
+```sql
+CREATE TABLE users (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(50)
+);
+
+INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Charlie'), ('Dave');
+DELETE FROM users WHERE id IN (2, 3);
+-- 当前 id: 1, 4
+INSERT INTO users (name) VALUES ('Eve');
+-- Eve 的 id = 5(不会复用 2 或 3)
+```
+
+> [!WARNING] 自动递增号的特性
+> - AUTO_INCREMENT **不会回收已删除的值**。这是设计使然——保证全局唯一且有序追加写友好。
+> - 如果业务需要**紧凑编号**(如内部工号),必须手动处理(如重建表或使用序列表),但**不建议在生产系统这样做**。
+> - InnoDB 的 AUTO_INCREMENT 锁在内存中(mysql.auto_increment_helper 表),即使删除所有行也不会归零(除非 TRUNCATE)。
+
+## 9. LIKE 前缀通配符导致全表扫描
+
+```sql
+ALTER TABLE products ADD INDEX idx_name (name);
+
+-- ❌ 前缀通配符 → 索引失效
+SELECT * FROM products WHERE name LIKE '%手机%';
+-- EXPLAIN: type=ALL → 全表扫描!
+
+-- ✅ 前缀固定 → 可以利用索引(最左前缀匹配)
+SELECT * FROM products WHERE name LIKE '手机%';
+-- EXPLAIN: type=range → 走索引范围扫描
+
+-- ✅ 替代方案:使用全文索引
+ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
+SELECT * FROM products WHERE MATCH(name) AGAINST('手机');
+```
+
+> [!TIP] LIKE 索引利用规则速查
+> | 模式 | 是否走索引 | 原因 |
+> |------|-----------|------|
+> | `'abc%'` | ✅ | 可定位起始位置 |
+> | `'%abc'` | ❌ | 未知起始位置 |
+> | `'%abc%'` | ❌ | 同上 |
+>
+> **建议**:对中文搜索需求优先使用 Elasticsearch 等专业搜索引擎,不要依赖 LIKE。
+
+## 10. 可重复读(RR)下的幻读陷阱
+
+```sql
+-- Session A -- Session B
+BEGIN; BEGIN;
+ INSERT INTO orders ...; ← 成功!
+COMMIT; COMMIT;
+SELECT COUNT(*) FROM orders; -- 结果包含了 B 插入的行
+```
+
+```mermaid
+sequenceDiagram
+ participant A as Session A
+ participant DB as InnoDB
+ participant B as Session B
+
+ A->>A: BEGIN
+ A->>DB: SELECT COUNT(*)
+ Note over A,DB: 读到 10 行
+
+ B->>B: BEGIN
+ B->>DB: INSERT row_11
+ B->>DB: COMMIT
+
+ A->>DB: SELECT COUNT(*)
+ Note over A,DB: RR 级别下仍读到 11 行
(Read View 未刷新)
+
+ A->>DB: UPDATE ... WHERE id > 100
+ Note over A,DB: 间隙锁发现新行
→ 阻塞等待 B 的事务释放
+```
+
+> [!WARNING] RR 隔离级别的幻读是「有条件」的
+> - **普通 SELECT** 不会看到其他事务的新数据吗?**不一定**。InnoDB 的快照读只读第一次创建 Read View 时的状态,但 DML(UPDATE/DELETE)会刷新视图并感知新行。
+> - `SELECT ... FOR SHARE / FOR UPDATE` 会使用 next-key lock 阻止幻读——这才是真正的「不可幻读」。
+> - MySQL 的 RR ≠ SQL 标准定义的「完全隔离幻读」。这是最容易产生误解的地方之一。
+
+## 11. 关联笔记
+
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 EXPLAIN 识别上述问题
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引失效的典型原因
+- [[hhs/MySQL/21-查询改写技巧]] — 各种问题的改写方案
diff --git a/hhs/MySQL/README.md b/hhs/MySQL/README.md
new file mode 100644
index 0000000..1bfb0d2
--- /dev/null
+++ b/hhs/MySQL/README.md
@@ -0,0 +1,228 @@
+---
+tags: [MySQL, Database, SQL, RDBMS]
+create time: 2026-05-16 00:00
+---
+
+# MySQL 知识库
+
+## 概述
+
+本文件夹系统整理 MySQL 的核心知识点,从底层架构到生产实践,覆盖日常开发和高阶场景。MySQL 是世界上最流行的开源关系型数据库,掌握它不仅是后端开发的必备技能,更是理解"数据如何持久化"这一根本问题的钥匙。
+
+> [!QUESTION] 为什么选 MySQL?
+> - **生态成熟**:社区活跃、文档完善、云厂商全覆盖(RDS、PolarDB、TencentDB)
+> - **存储引擎可插拔**:InnoDB 默认提供事务和行锁,MyISAM 适合只读分析,Memory 用于缓存
+> - **协议兼容性好**:标准 SQL + 丰富的方言扩展,驱动支持几乎所有主流语言
+> - **性能可预期**:B+ Tree 索引 + Buffer Pool + Redo Log 三层架构,让写入和查询都有章可循
+
+## 知识体系
+
+### 一、入门基础
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 1.1 | [MySQL 架构与进程模型](./01-MySQL 架构与进程模型.md) | Client-Server 模型、连接层 / SQL 层 / 引擎层 / 存储层;Server 线程 vs Worker 线程;`mysqld` 启动流程 |
+| 1.2 | [安装与初始化](./02-安装与初始化.md) | Community Edition 安装、Docker Compose、`mysql_install_db`、配置文件层级 (`my.cnf`) |
+| 1.3 | [客户端工具](./03-客户端工具.md) | `mysql CLI`、`mysqlsh`、Workbench、HeidiSQL、DBeaver;常用 `\G`、`\h` 等快捷命令 |
+| 1.4 | [数据类型全景](./04-数据类型全景.md) | 整型(TINYINT~BIGINT)、浮点(FLOAT / DOUBLE / DECIMAL)、字符串(CHAR / VARCHAR / TEXT / BLOB)、日期时间(DATE / DATETIME / TIMESTAMP / TIME)、JSON |
+| 1.5 | [字符集与排序规则](./05-字符集与排序规则.md) | utf8mb4 为什么比 utf8 重要、Collation 的 Binlog / Case 差异、`COLLATE` 关键字 |
+
+> [!TIP] 选择数据类型时,遵循「够用就好」原则
+> 能用 TINYINT 就别用 INT,能用 DATETIME 就别用 STRING 存日期——这直接影响索引效率和存储空间。
+
+### 二、存储引擎与表设计
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 2.1 | [InnoDB 深度解析](./06-InnoDB 深度解析.md) | Clustered Index、Change Buffer、Insert Buffer、Adaptive Hash Index |
+| 2.2 | [其他存储引擎概览](./07-其他存储引擎概览.md) | MyISAM / Memory / Archive 各自适用场景、为什么生产环境几乎只用 InnoDB |
+| 2.3 | [表结构设计三范式](./08-表结构设计三范式.md) | 1NF ~ 3NF、反范式取舍、冗余字段的设计哲学 |
+| 2.4 | [主键策略对比](./09-主键策略对比.md) | Auto-increment / UUID / Snowflake / ULID —— 各方案对索引碎片化的影响 |
+
+```mermaid
+graph TB
+ subgraph "SQL Layer"
+ A["Parser"] --> B["Preprocessor"]
+ B --> C["Optimizer"]
+ C --> D["Executor"]
+ end
+
+ subgraph "Storage Engine
InnoDB"
+ D --> E["Buffer Pool"]
+ E --> F["Redo Log"]
+ E --> G["Undo Log"]
+ E --> H["Data File"]
+ D --> I["Change Buffer"]
+ end
+
+ subgraph "OS Layer"
+ H --> J["Page Cache"]
+ F --> K["I/O Subsystem"]
+ G --> K
+ end
+
+ style A fill:#4FC08D,color:#fff
+ style C fill:#FF9F43,color:#000
+ style E fill:#00B6BC,color:#fff
+ style F fill:#EE5A24,color:#fff
+ style G fill:#C44569,color:#fff
+ style H fill:#A0AEC0,color:#fff
+```
+
+> [!NOTE] InnoDB 的核心缓冲机制
+> - **Buffer Pool**:热数据页的内存缓存,命中率应在 99%+
+> - **Redo Log**:保证 durability,循环写入,崩溃恢复时使用
+> - **Undo Log**:支持 MVCC 和多版本读,rollback 也用它
+
+### 三、SQL 精解
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 3.1 | [DDL — 建表与结构变更](./10-DDL 建表与结构变更.md) | CREATE TABLE / ALTER TABLE、`ADD COLUMN` 在线变更、`ALGORITHM=INPLACE` |
+| 3.2 | [DML — 增删改](./11-DML 增删改.md) | INSERT ... ON DUPLICATE KEY UPDATE、REPLACE INTO、DELETE vs TRUNCATE |
+| 3.3 | [DQL — SELECT 全解析](./12-DQL SELECT 全解析.md) | SELECT 执行顺序、DISTINCT、GROUP BY 优化、LIMIT 深分页问题 |
+| 3.4 | [JOIN 原理与优化](./13-JOIN 原理与优化.md) | Inner / Left / Right / Cross JOIN、Index Merge、Nested Loop Join、Block Nested Loop |
+| 3.5 | [子查询与派生表](./14-子查询与派生表.md) | WHERE 子查询 vs FROM 子查询、EXISTS / IN / ANY 语义差异、Derived Table 物化 |
+| 3.6 | [UNION 与集合运算](./15-UNION 与集合运算.md) | 集合运算、去重 vs 保留重复、合并字段类型推断 |
+
+### 四、索引与查询优化
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 4.1 | [B+ Tree 索引原理](./16-B+Tree 索引原理.md) | 为什么不用 B 树 / Hash / 红黑树?非叶子节点只存键、叶子节点链表串联 |
+| 4.2 | [聚簇索引与二级索引](./17-聚簇索引与二级索引.md) | Secondary Index 回表、Covering Index(覆盖索引)、Index Only Scan |
+| 4.3 | [联合索引与最左前缀](./18-联合索引与最左前缀.md) | (a,b,c) 的使用模式、为什么 b 单独查不了、隐式类型转换导致索引失效 |
+| 4.4 | [EXPLAIN 完全指南](./19-EXPLAIN 完全指南.md) | type 字段(ALL/index/ref/range/eq_ref/system)、Extra(Using where/index/filesort/temporary)|
+| 4.5 | [慢查询日志分析](./20-慢查询日志分析.md) | `slow_query_log` 配置、`pt-query-digest`、`mysqldumpslow` |
+| 4.6 | [查询改写技巧](./21-查询改写技巧.md) | JOIN → EXISTS 转换、UNION ALL 拆分 OR、避免函数作用于索引列 |
+| 4.7 | [深分页优化](./22-深分页优化.md) | `LIMIT 1000000, 20` 的陷阱、延迟关联(Deferred Join)、游标分页 |
+
+```mermaid
+flowchart LR
+ A["SELECT 语句"] --> B["Parse Tree"]
+ B --> C["Query Optimizer"]
+ C --> D["Execution Plan"]
+ D --> E{type}
+ E -->|"system"<| F["最优:只有1行"]
+ E -->|"const"<| G["常量访问"]
+ E -->|"eq_ref"<| H["唯一索引,每行1次"]
+ E -->|"ref"<| I["非唯一索引,多行匹配"]
+ E -->|"range"<| J["索引范围扫描"]
+ E -->|"index"<| K["全索引扫描"]
+ E -->|"ALL"<| L["全表扫描 ⚠️"]
+ style L fill:#EE5A24,color:#fff
+ style F fill:#00B6BC,color:#fff
+```
+
+> [!QUESTION] 什么是最左前缀法则?
+> 假设联合索引 `(city, age, sex)`,以下情况哪些能用上索引?
+> - `WHERE city = 'Shanghai'` ✅ 第一列命中
+> - `WHERE city = 'Shanghai' AND age = 20` ✅ 前两列连续命中
+> - `WHERE age = 20 AND sex = 'M'` ❌ 跳过了第一列 city
+> - `WHERE city = 'Shanghai' AND sex = 'M'` ⚠️ 用到 city 部分,sex 需额外过滤
+
+### 五、事务与并发控制
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 5.1 | [ACID 与原子性实现](./23-ACID 与原子性实现.md) | Redo Log(物理 WAL)+ Undo Log(逻辑回滚)的组合拳 |
+| 5.2 | [隔离级别与可见性](./24-隔离级别与可见性.md) | Read Uncommitted / Read Committed / Repeatable Read / Serializable |
+| 5.3 | [MVCC 原理](./25-MVCC 原理.md) | Read View + Undo Log version chain、RC 下每次 SELECT 新建 Read View、RR 下第一次 SELECT 创建 |
+| 5.4 | [锁机制总览](./26-锁机制总览.md) | 全局锁、表级锁(MDL)、行级锁(Record Lock / Next-Key Lock / Gap Lock)|
+| 5.5 | [死锁与排查](./27-死锁与排查.md) | `SHOW ENGINE INNODB STATUS`、等待图、减少锁竞争的策略 |
+| 5.6 | [一致性读 vs 当前读](./28-一致性读与当前读.md) | `SELECT` 一致性读、`LOCK IN SHARE MODE / FOR UPDATE` 当前读 |
+
+```mermaid
+sequenceDiagram
+ participant T1 as Transaction A
+ participant DB as InnoDB Engine
+ participant T2 as Transaction B
+
+ T1->>DB: BEGIN;
+ T2->>DB: BEGIN;
+ T1->>DB: SELECT balance FROM accounts WHERE id=1;
+ Note over T1,DB: 一致性读 → 返回历史版本(快照)
+ T2->>DB: UPDATE accounts SET balance=100 WHERE id=1;
+ T2->>DB: COMMIT;
+ T1->>DB: SELECT balance FROM accounts WHERE id=1 FOR UPDATE;
+ Note over T1,DB: 当前读 → 读取最新已提交记录 + X锁
+ T1->>DB: COMMIT;
+```
+
+> [!NOTE] RC vs RR 的关键区别
+> - **RC (Read Committed)**:每次 SELECT 都新建 Read View → 能看到其他事务已提交的修改(不可重复读)
+> - **RR (Repeatable Read)**:同一个事务内第一次 SELECT 创建 Read View → 整个事务看到一致快照(可重复读),配合 Next-Key Lock 解决幻影读
+
+### 六、高可用与分布式
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 6.1 | [Binary Log(Binlog)](./29-Binary Log.md) | Row / Statement / Mixed 格式、binlog_cache、`mysqlbinlog` 工具 |
+| 6.2 | [主从复制详解](./30-主从复制详解.md) | Semi-Sync 半同步、GTID 复制、并行复制(MTS)、延迟监控 |
+| 6.3 | [MHA 与 Orchestrator](./31-MHA与Orchestrator.md) | 自动故障检测与切换、脑裂处理 |
+| 6.4 | [Group Replication](./32-Group Replication.md) | Paxos 共识、单主/多主模式、冲突检测 |
+| 6.5 | [InnoDB Cluster](./33-InnoDB Cluster.md) | MySQL Shell + Router + Monitor,官方一键部署方案 |
+| 6.6 | [备份与恢复](./34-备份与恢复.md) | mysqldump(逻辑)、Percona XtraBackup(物理)、PITR 时间点恢复 |
+
+```mermaid
+flowchart LR
+ subgraph "Master"
+ W["Write / DML"] --> WL["Binlog Writer"]
+ end
+
+ subgraph "Network"
+ BL["Binlog Stream"] --> IO["IO Thread"]
+ end
+
+ subgraph "Slave"
+ IO --> SL["SQL Thread"]
+ SL --> SF["Slave Status Variables"]
+ end
+
+ WL -.->|异步/半同步| BL
+ SF -.->|Seconds_Behind_Master| Monitor["Monitoring"]
+```
+
+> [!TIP] 生产环境的主从架构建议
+> - 至少 **一主两从**,使用 GTID + Semi-Sync 保证数据不丢
+> - 读写分离时注意 **弱一致性风险**:刚写的数据可能读不到
+> - 定期做 **灾备演练**,验证切换时间和数据完整性
+
+### 七、工程实践
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 7.1 | [Go 连接池配置](./35-Go 连接池配置.md) | `sql.DB` 参数调优:MaxOpenConns、MaxIdleConns、ConnMaxLifetime、ConnMaxIdleTime |
+| 7.2 | [Schema 迁移管理](./36-Schema 迁移管理.md) | flyway / goose / migrate、版本化迁移脚本、幂等性设计 |
+| 7.3 | [分库分表](./37-分库分表.md) | ShardingSphere、Vitess、水平切分(Hash / Range / List)、跨分片查询 |
+| 7.4 | [监控指标](./38-监控指标.md) | QPS/TPS、Slow Query Count、Innodb Buffer Pool Hit Rate、Threads Connected、Replication Lag |
+| 7.5 | [安全加固](./39-安全加固.md) | 最小权限原则、SSL/TLS 加密传输、审计日志、防止 SQL 注入 |
+| 7.6 | [常见踩坑](./40-常见踩坑.md) | `ORDER BY` 文件排序、`COUNT(*)` vs `COUNT(1)`、`timestamp` 自动更新 |
+
+[[hhs/DEV/Go-Database]] — Go 中 sql.DB 的连接池使用模式
+
+## 学习路径建议
+
+```mermaid
+flowchart TD
+ BASE["一、入门基础
架构 + 数据类型"] --> CORE["二、存储引擎与表设计"]
+ CORE --> SQL["三、SQL 精解
DDL/DML/DQL"]
+ SQL --> INDEX["四、索引与查询优化"]
+ SQL --> TXN["五、事务与并发控制"]
+ INDEX --> HA["六、高可用与分布式"]
+ TXN --> HA
+ HA --> ENG["七、工程实践
分库分表 + 监控 + 安全"]
+
+ style BASE fill:#00B6BC,color:#fff
+ style INDEX fill:#FF9F43,color:#000
+ style TXN fill:#C44569,color:#fff
+ style HA fill:#4FC08D,color:#fff
+ style ENG fill:#EE5A24,color:#fff
+```
+
+## 关联笔记
+
+- [[hhs/GORM/02-模型定义]] — GORM Model 如何映射到 MySQL 表和字段类型
+- [[hhs/Redis/02-核心数据类型]] — MySQL 与 Redis 数据类型的对应关系和设计选型
+- [[hhs/DEV/Go-Database]] — Go 连接 MySQL 的工程实践
+- [[hhs/EXAM/Week05]] — Docker Compose 中的 MySQL 服务管理
diff --git a/hhs/Redis/01-安装与部署.md b/hhs/Redis/01-安装与部署.md
new file mode 100644
index 0000000..4562d68
--- /dev/null
+++ b/hhs/Redis/01-安装与部署.md
@@ -0,0 +1,257 @@
+---
+tags: [Redis, 缓存, 运维, 部署]
+create time: 2026-05-15 18:10
+---
+
+# Redis 安装与部署
+
+## 概述
+
+本节介绍 Redis 的安装方式、关键配置和安全加固。无论使用官方二进制还是容器化方案,核心目标一致:**安全启动、合理分配内存、设置持久化策略**。
+
+> [!QUESTION] 为什么安装步骤简单,生产部署却容易出问题?
+>
+> Redis 本质是一个单进程内存服务,安装只需几行命令。但一旦上线,内存超限导致 OOM Killer 介入、未设密码被外网扫描器写入大量脏数据、或 AOF/RDB 同时触发 fork 导致内存翻倍——这些才是真正需要关注的问题。
+
+## 选择安装方式
+
+先根据场景确定合适的部署路径:
+
+```mermaid
+flowchart LR
+ A[开始:选择安装方式] --> B{运行环境?}
+ B -->|macOS 本地开发| C[brew install redis]
+ B -->|Ubuntu/Debian 服务器| D[apt install redis-server]
+ B -->|需要指定特定版本| E[源码编译]
+ B -->|本地开发 / 微服务| F[Docker Compose 推荐]
+ B -->|高可用集群| G[[hhs/Redis/07-集群方案]]
+
+ C --> Z[进入配置阶段 →]
+ D --> Z
+ E --> Z
+ F --> Z
+```
+
+### macOS — Homebrew
+
+```bash
+brew install redis # 安装最新稳定版
+brew services start redis # 注册为后台服务
+redis-cli ping # 验证:应返回 PONG
+```
+
+> [!TIP] 安装后配置文件位于 `/opt/homebrew/etc/redis.conf`(Apple Silicon)或 `/usr/local/etc/redis.conf`(Intel)。
+
+### Linux — 包管理器
+
+```bash
+sudo apt update && sudo apt install redis-server
+sudo systemctl enable --now redis-server
+```
+
+发行版默认安装的 Redis 版本可能落后于上游最新版。如果需要跟进特性或安全补丁,考虑源码编译或 Docker。
+
+### 源码编译(指定版本)
+
+```bash
+wget https://download.redis.io/releases/redis-7.2.4.tar.gz
+tar xzf redis-7.2.4.tar.gz
+cd redis-7.2.4
+make -j$(nproc) # 利用多核并行编译
+sudo make install # 安装到 /usr/local/bin
+redis-server --version # 确认版本
+```
+
+> [!NOTE] Redis 基于 C 开发,编译时无外部依赖(新版内置 jemalloc)。如果你的系统缺少 gcc/make,需提前安装构建工具链。
+
+### Docker(推荐本地开发)
+
+```yaml
+version: "3.9"
+services:
+ redis:
+ image: redis:7-alpine # Alpine 镜像仅 ~30MB,适合开发环境
+ container_name: redis-dev
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis-data:/data
+ command: >
+ redis-server
+ --requirepass ${REDIS_PASSWORD:-changeme}
+ --appendonly yes
+ --maxmemory 512mb
+ --maxmemory-policy allkeys-lru
+ restart: unless-stopped
+
+volumes:
+ redis-data:
+```
+
+> [!TIP]
+> - 通过 `${REDIS_PASSWORD:-changeme}` 引用环境变量,避免在仓库中硬编码真实密码。
+> - 开发环境建议限制 `maxmemory`,防止占满本机内存。
+> - 生产环境不要使用 `redis:latest` 标签,锁定具体版本号以避免未知变更。
+
+> [!EXAMPLE] 如何快速连接带密码的 Redis?
+> ```bash
+> export REDIS_PASSWORD="your-strong-pass"
+> redis-cli -a "$REDIS_PASSWORD" ping
+> # 输出: PONG
+> ```
+
+## 关键配置项
+
+编辑 `redis.conf`(Docker 用命令行参数覆盖),以下是生产环境必看的核心选项:
+
+| 配置项 | 推荐值 | 说明 |
+|--------|--------|------|
+| `bind 127.0.0.1` | 内网 IP 或注释 | 限制监听地址;生产环境需配合防火墙规则 |
+| `protected-mode yes` | 保持默认 | 无密码时自动拒绝外部连接 |
+| `port 6379` | — | 自定义端口,但不要指望这算安全措施 |
+| `requirepass your-strong-pass` | 强密码 | **必须设置**,尤其对外暴露时 |
+| `maxmemory 2gb` | 物理内存的 60%~70% | 防止 OOM Killer 接管,给 OS 和子进程留余地 |
+| `maxmemory-policy allkeys-lru` | — | 内存满时的淘汰策略,见下方说明 |
+| `tcp-backlog 511` | 511~1024 | 三次握手已完成但未被 accept 的队列深度 |
+| `timeout 300` | 300 | 客户端空闲超时断开(秒),释放无效连接 |
+| `loglevel notice` | — | debug 级别会产生海量日志,影响性能 |
+| `databases 16` | — | 逻辑数据库数量,通常不改 |
+
+> [!IMPORTANT] maxmemory-policy 的选择直接影响业务语义
+>
+> - `allkeys-lru`:对所有键做 LRU 淘汰。**通用场景首选**。
+> - `volatile-lru`:仅对设置了过期时间的键做 LRU 淘汰。适合缓存+热数据混合的场景。
+> - `noeviction`:不淘汰,空间不足时直接报错。适合纯缓存,应用层自行控制生命周期。
+>
+> **选错策略的代价是什么?** 如果用 `noeviction` 而忘了设置过期时间,Redis 会在写操作时报 `OOM` 错误,而非优雅降级。
+
+启动并验证配置:
+
+```bash
+# 以自定义配置启动
+redis-server /etc/redis/redis.conf
+
+# 逐项检查是否生效
+redis-cli CONFIG GET maxmemory
+redis-cli CONFIG GET maxmemory-policy
+redis-cli CONFIG GET requirepass
+```
+
+> [!NOTE] 线上修改无需重启:大部分配置可以通过 `CONFIG SET key value` 动态调整,但退出后失效。持久化需在配置文件中修改。
+
+## Systemd 服务管理(Linux)
+
+使用 systemd 管理能确保开机自启、崩溃自动恢复:
+
+```ini
+[Unit]
+Description=Redis In-Memory Data Store
+After=network.target # 网络就绪后再启动 Redis
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/redis-server /etc/redis/redis.conf
+ExecReload=/bin/kill -USR2 $MAINPID # USR2 触发配置重载(Redis 6.2+)
+Restart=on-failure # 崩溃后自动拉起
+RestartSec=5 # 间隔 5 秒重试,避免频繁闪退
+LimitNOFILE=65535 # 文件描述符上限,支持大量并发连接
+
+[Install]
+WantedBy=multi-user.target # 多用户模式下的标准服务
+```
+
+```bash
+sudo systemctl daemon-reload # 读取新增/修改的服务文件
+sudo systemctl enable --now redis # 启动 + 开机自启
+sudo journalctl -u redis -f # 实时查看日志
+```
+
+> [!QUESTION] 为什么 `Restart=on-failure` 还不够?
+>
+> 如果 Redis 因为配置错误(如 bind 地址不可达)反复崩溃,`on-failure` 会无限重试。配合日志告警或健康检查才能真正发现问题。
+
+## 内核级调优
+
+Redis 是 IO 密集型单进程服务,操作系统层面的参数会直接影响吞吐量上限:
+
+```bash
+echo "vm.overcommit_memory = 1" >> /etc/sysctl.conf
+echo "net.core.somaxconn = 1024" >> /etc/sysctl.conf
+echo "vm.max_map_count = 262144" >> /etc/sysctl.conf
+sysctl -p # 立即生效
+```
+
+```mermaid
+flowchart LR
+ A[客户端连接请求] -->|SYN| B[TCP 半连接队列\nsomaxconn]
+ B -->|三次握手完成| C[TCP 全连接队列\ntcp-backlog]
+ C --> D[Redis accept()]
+ D --> E[分叉持久化\novercommit_memory]
+
+ style A fill:#e1f5fe
+ style C fill:#fff3e0
+ style E fill:#f3e5f5
+```
+
+| sysctl 参数 | 作用 | 为什么需要 |
+|-------------|------|-----------|
+| `vm.overcommit_memory = 1` | 允许 fork() 时内存过度分配 | RDB 和 AOF rewrite 都通过 fork 创建子进程,父子进程共享物理页;若无此设置,大内存实例 fork 可能被内核拒绝 |
+| `net.core.somaxconn = 1024` | TCP 全连接队列长度 | 高并发下队列满了会导致客户端 connect 超时,表现为"Redis 连不上" |
+| `vm.max_map_count = 262144` | mmap 区域上限 | Redis 在开启 LFU 等特性时会使用 mmap,默认值过小会被限制 |
+
+## 部署架构演进
+
+单机 → Sentinel → Cluster,不同阶段的架构差异:
+
+```mermaid
+flowchart TB
+ subgraph S1[阶段一:单机]
+ C1[Client] --> M1[(Master)]
+ end
+
+ subgraph S2[阶段二:主从 + Sentinel]
+ C2[Client] --> M2[(Master)]
+ C2 -.-> S2a[Sentinel]
+ M2 --> S2b[(Slave 1)]
+ M2 --> S2c[(Slave 2)]
+ end
+
+ subgraph S3[阶段三:Cluster]
+ C3[Client] --> P[Hash Slot 路由]
+ P --> N1[Node 1: Slots 0-5460]
+ P --> N2[Node 2: Slots 5461-10922]
+ P --> N3[Node 3: Slots 10923-16383]
+ end
+
+ S1 ===>|数据量增长,需要高可用| S2
+ S2 ===>|单机内存瓶颈,需要水平扩展| S3
+
+ style S1 fill:#e8f5e9
+ style S2 fill:#fff3e0
+ style S3 fill:#fce4ec
+```
+
+> [!INFO] 本系列后续文档将深入每个阶段:
+> - [[hhs/Redis/04-RDB持久化]] — RDB 快照机制
+> - [[hhs/Redis/05-AOF持久化]] — 追加日志机制
+> - [[hhs/Redis/07-集群方案]] — Cluster 分片原理与部署
+
+## 快速检查清单
+
+> [!CHECKLIST] 部署前核对
+> - [ ] 已设置 `requirepass`(非默认或测试密码)
+> - [ ] `maxmemory` 不超过物理内存的 70%,预留 OS 和 fork 空间
+> - [ ] 已启用持久化(至少 AOF `appendonly yes`)
+> - [ ] `bind` 指向内网地址,或通过防火墙限制访问来源 IP
+> - [ ] `vm.overcommit_memory` 已设为 1
+> - [ ] 已配置合理的 `timeout` 和 `tcp-keepalive`,清理僵尸连接
+> - [ ] 确认 `maxmemory-policy` 符合业务语义(推荐 `allkeys-lru`)
+> - [ ] Docker 部署时密码通过环境变量注入,未硬编码在 compose 文件中
+
+## 关联笔记
+
+- [[hhs/Redis/02-基础数据结构]] — String / Hash / List / Set / ZSet 的使用场景
+- [[hhs/Redis/04-RDB持久化]] — RDB 快照原理与配置
+- [[hhs/Redis/05-AOF持久化]] — AOF 重写机制与 fsync 策略
+- [[hhs/Redis/07-集群方案]] — Cluster 部署与迁移策略
+- [[hhs/Redis/08-常见问题排查]] — OOM、延迟 spike、连接数爆满的诊断思路
\ No newline at end of file
diff --git a/hhs/Redis/02-核心数据类型.md b/hhs/Redis/02-核心数据类型.md
new file mode 100644
index 0000000..55d3d27
--- /dev/null
+++ b/hhs/Redis/02-核心数据类型.md
@@ -0,0 +1,323 @@
+---
+tags: [Redis, 缓存, 数据结构]
+create time: 2026-05-15 18:11
+---
+
+# Redis 核心数据类型与底层编码
+
+## 概述
+
+> [!QUESTION] 同一个 `SET` 命令,有时占内存几字节,有时几百字节——是 Bug 吗?
+> 不是。Redis 会根据值的特征**自动选择最省内存的内部表示**。这就是你要理解的"编码"概念。
+
+Redis 有五种基础数据类型:**String、Hash、List、Set、ZSet**。但每种类型在不同场景下会切换不同的**内部编码**——理解这一点比单纯记忆命令更重要,因为它直接决定了操作的时间复杂度和内存消耗。
+
+---
+
+```mermaid
+flowchart TD
+ subgraph "String"
+ S1["int — 整数"]
+ S2["embstr (≤44B) — 短字符串"]
+ S3["raw — 长字符串"]
+ end
+
+ subgraph "Hash"
+ H1["ziplist — 元素少且小"]
+ H2["hashtable — 超出阈值后切换"]
+ end
+
+ subgraph "List"
+ L1["quicklist (ziplist 节点 + 指针)"]
+ end
+
+ subgraph "Set"
+ ST1["intset — 全是整数"]
+ ST2["hashtable — 出现非整数"]
+ end
+
+ subgraph "ZSet"
+ Z1["ziplist — 元素少且差距小"]
+ Z2["skiplist + hashtable — 大规模数据"]
+ end
+```
+
+## 一、String(字符串)
+
+### 三种内部编码
+
+| 编码 | 触发条件 | 典型场景 |
+|------|---------|---------|
+| `int` | 值可表示为长整型 | 计数器 (`INCR`) |
+| `embstr` | ≤ 44 字节(Redis 7 之前是 39B) | 短文本存储 |
+| `raw` | > 44 字节 | 大对象 JSON / Base64 |
+
+> [!QUESTION] embstr 和 raw 的区别?
+> - `embstr`:一块连续内存分配,创建/销毁只需一次 malloc/free,效率高
+> - `raw`:分开分配 SDS 结构体和 redisObject 头,支持扩容
+> - 超过 44B 时 embstr 不可变,只能切 raw
+
+### 代码示例(Go go-redis)
+
+```go
+// 设置过期时间(原子操作)
+rdb.Set(ctx, "token:abc", jwtPayload, 24*time.Hour)
+
+// 原子递增计数器
+rdb.Incr(ctx, "counter:api")
+
+// MGET 批量获取
+vals, _ := rdb.MGet(ctx, "user:1", "user:2", "user:3").Result()
+```
+
+> [!INSIGHT] 编码切换是透明的
+> 你不需要自己管理编码——`REDISOBJECT.encoding` 字段由 Redis 自动维护。用 `MEMORY USAGE key` 可以看到实际占用字节数,这是验证编码效果最直接的方式。
+
+## 二、Hash(哈希表)
+
+### 内部编码切换
+
+```mermaid
+flowchart LR
+ A["ziplist"] -->|"超出阈值: 节点数>5 或值≥64B"| B["hashtable"]
+
+ style A fill:#e1f5fe
+ style B fill:#fff3e0
+```
+
+阈值由两个配置控制:
+- `hash-max-ziplist-size 5`:节点数 > 5 或某个字段长度 ≥ 64B → 切 hashtable
+- `hash-max-ziplist-value 64`:单个值 > 64B → 切 hashtable
+
+### 适用场景
+
+| 场景 | 命令 | 说明 |
+|------|------|------|
+| 用户信息缓存 | `HSET user:1 name alice age 25` | 只更新需要改的字段 |
+| 表单数据存储 | `HMSET` / `HGETALL` | 替代 String 存整个 JSON |
+
+### String vs Hash 选型
+
+> [!QUESTION] 为什么更新一个字段要读写整个 JSON?
+> String 存的是完整序列化字符串,改任何内容都要重新序列化写入。Hash 是结构化的键值对,只修改目标字段即可。
+
+```mermaid
+quadrantChart
+ title "String vs Hash — 场景选择矩阵"
+ x-axis "简单操作" --> "复杂操作"
+ y-axis "内存紧凑" --> "灵活存取"
+ "String (JSON)": [0.3, 0.75]
+ "Hash (字段级)": [0.7, 0.85]
+```
+
+| 维度 | **方案 A** — String(JSON) | **方案 B** — Hash(字段级) |
+|------|-----------------------------|----------------------------|
+| 读取 | `GET user:1` ✅ 一行搞定 | `HGETALL user:1` 需合并 |
+| 局部更新 | ❌ 读 → 改 → 写整个 JSON | ✅ `HSET user:1 email b@x.com` |
+| 内存占用 | 较高(重复 key、序列化开销) | 较低(ziplist 紧凑存储) |
+| 适用规模 | 小数据、读多写少 | 中等大小、频繁部分更新 |
+
+> [!TIP] Hash 在 ziplist 编码下内存占用比单独 String 省 ~40%,适合字段数 < 100 的对象缓存。
+
+#### 方案 A — String(JSON 整体存取)
+
+```
+GET user:1 → {"name":"alice","age":25,"email":"a@x.com"}
+✅ 读写简单 ✅ 序列化一致
+❌ 更新一个字段要读写整个 JSON
+```
+
+#### 方案 B — Hash(字段级操作)
+
+```
+HSET user:1 email b@x.com
+✅ 局部更新,内存更紧凑
+❌ HGETALL 不适合超大 hash
+```
+
+> [!CAUTION] 警惕大 Hash
+> `HGETALL` 在 hashtable 编码下是 O(N),hash 很大时会阻塞 Redis。如果字段数超过几千,考虑拆成多个 key 或改回 String(JSON)。
+
+## 三、List(列表)
+
+### quicklist 结构
+
+> [!QUESTION] 为什么不用双向链表 + SDS,而要用 quicklist?
+> 普通双向链表每个节点都有指针开销(16B)。quicklist 把多个元素打包进一个 ziplist 紧凑块中,大幅减少指针浪费。
+
+```mermaid
+flowchart LR
+ H["head"] <--> Z1["ziplist 节点 #1\n(≤ 4096 项)"]
+ Z1 <--> Z2["ziplist 节点 #2\n(≤ 4096 项)"]
+ Z2 <--> Z3["ziplist 节点 #N\n(≤ 4096 项)"]
+ Z3 <--> T["tail"]
+
+ classDef linkPoint fill:#e8f5e9,stroke:#4caf50
+ classDef znode fill:#fff3e0,stroke:#ff9800
+ class H,T linkPoint
+ class Z1,Z2,Z3 znode
+```
+
+List 统一使用 **quicklist**(压缩链表),每个节点是一个 ziplist:
+- `list-max-ziplist-size 5`:默认每个节点最多 4096 个元素
+- `list-compress-depth 0`:默认不压缩首尾节点(方便头部操作)
+
+### 典型场景
+
+| 场景 | 命令 | 说明 |
+|------|------|------|
+| 消息队列 | `LPUSH` + `BRPOP` | 单消费者阻塞式消费 |
+| 最新 N 条记录 | `LRANGE 0 -1` | 配合 `LTRIM` 固定长度 |
+| 延迟队列 | ZSet score = timestamp | 比 List 灵活 |
+
+```go
+// 发布新消息
+rdb.LPush(ctx, "news-feed", jsonMessage)
+// 修剪到最近 1000 条
+rdb.LTrim(ctx, "news-feed", 0, 999)
+// 取出最近 10 条
+items, _ := rdb.LRange(ctx, "news-feed", 0, 9).Result()
+```
+
+## 四、Set(集合)
+
+### intset → hashtable 切换
+
+```mermaid
+flowchart LR
+ E["Set 添加元素"] --> Q{"全部是\n整数?"}
+ Q -->|"是"| I["intset\n有序数组 · 内存紧凑"]
+ Q -->|"否 — 出现字符串"| H["hashtable\nO(1) 查找 · 集合运算"]
+
+ classDef encoding fill:#e1f5fe,stroke:#2196f3
+ classDef encoding2 fill:#fff3e0,stroke:#ff9800
+ class I encoding
+ class H encoding2
+```
+
+### 集合操作(唯一能力)
+
+```bash
+SADD tags:post1 go golang redis
+SADD tags:post2 redis docker golang
+
+SDIFF tags:post1 tags:post2 # go — post1 独有
+SINTER tags:post1 tags:post2 # golang redis — 共有的
+SUNION tags:post1 tags:post2 # go golang redis docker
+SCARD tags:post1 # 3 — 元素个数
+SISMEMBER tags:post1 go # 1 — 判断成员
+```
+
+### 应用场景
+
+- 点赞/收藏列表:`SADD like:post:42 user:100`
+- 共同好友:`SINTER user:A:friends user:B:friends`
+- 随机抽取:`SRANDMEMBER lottery 5`(不删除)/ `SPOP lottery 5`(删除)
+
+> [!TIP] Set 的 SSCAN 命令
+> `SSCAN` 可以增量迭代大集合,避免单次命令阻塞 Redis——和 Hash 的 `HSCAN` 同理。
+
+## 五、ZSet / Sorted Set(有序集合)
+
+### skiplist + hashtable 双结构
+
+> [!QUESTION] 为什么需要两套数据结构?
+> skiplist 天然有序、支持高效范围查询,但按 member 查找要遍历;hashtable 查找 O(1),但无序。两者互补,代价是每个元素存了两份索引。
+
+```mermaid
+flowchart LR
+ Key["key: leaderboard"] --> SL["skiplist\n按 score 排序 · 范围查询 O(log N)"]
+ Key --> HT["hashtable\n按 member 查找 · O(1) 定位"]
+
+ classDef primary fill:#e8f5e9,stroke:#4caf50
+ classDef secondary fill:#fff3e0,stroke:#ff9800
+ class SL secondary
+ class HT secondary
+```
+
+这是 Redis 中**最复杂**的数据结构,代价也是最高的——每个元素存在两份索引。
+
+### 关键命令
+
+```bash
+# 添加(member → score)
+ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
+
+# 正序排名 1~5
+ZRANGE leaderboard 0 4 WITHSCORES
+
+# 倒序 Top 10
+ZREVRANGE leaderboard 0 9 WITHSCORES
+
+# 获取分数
+ZSCORE leaderboard "player2" # 200
+
+# 获取排名(从 0 开始)
+ZRANK leaderboard "player3" # 1
+ZREVRANK leaderboard "player3" # 2
+
+# 分数范围查询
+ZRANGEBYSCORE leaderboard 0 150
+
+# 移除指定范围
+ZREMRANGEBYRANK leaderboard 0 2
+```
+
+### Go 示例
+
+```go
+// 排行榜加分(原子操作)
+rdb.ZIncr(ctx, "leaderboard", redis.Z{
+ Score: 10.5,
+ Member: "player42",
+})
+
+// 获取 Top 10
+members, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()
+for _, m := range members {
+ fmt.Printf("%s: %.1f\n", m.Member, m.Score)
+}
+```
+
+### ZSet vs List 做消息队列对比
+
+| 维度 | List (BRPOP) | ZSet (BZPOPMIN) |
+|------|-------------|-----------------|
+| FIFO 顺序 | ✅ 保证 | ⚠️ 依赖 score |
+| 延迟执行 | ❌ 不支持 | ✅ score = 执行时间戳 |
+| 重排优先级 | ❌ 需重建 | ✅ 改 score 即可 |
+| 复杂度 | O(log(N/M)) | O(log N) |
+
+> [!WARNING] ZSet 性能陷阱
+> 不要往同一个 ZSet 里塞超过百万级别的元素——skiplist 虽然 O(log N),但实际维护成本不小。百万级可以考虑按分片拆成多个 ZSet。
+
+## 快速对照表
+
+| 数据类型 | 编码 | 最佳场景 | 时间复杂度 |
+|---------|------|---------|-----------|
+| String | int / embstr / raw | 计数、缓存、分布式锁 | O(1) |
+| Hash | ziplist / hashtable | 对象缓存(部分更新) | O(1)~O(N) |
+| List | quicklist | FIFO 队列、feed流 | O(1)~O(N) |
+| Set | intset / hashtable | 标签、去重、关系运算 | O(1) |
+| ZSet | ziplist / skiplist+ht | 排行榜、延迟任务、权重 | O(log N) |
+
+## 核心思想:内存与性能的权衡
+
+Redis 的数据类型本质上是一套**编码策略系统**。理解这个系统的关键在于三个原则:
+
+1. **小数据用紧凑编码** — ziplist / intset / embstr 牺牲灵活性换取极致内存效率
+2. **大数据切高性能结构** — hashtable / skiplist / raw 以空间换时间,操作复杂度可控
+3. **切换是自动且平滑的** — Redis 在后台根据阈值决定何时升级编码,开发者只需关注业务语义
+
+> [!SUMMARY] 实战检查清单
+> - 缓存对象 → Hash(ziplist)或 String(JSON),字段多且常部分更新选 Hash
+> - 计数 / 锁 / Token → String(int 编码最省)
+> - Feed 流 / 队列 → List(quicklist)
+> - 标签 / 去重 / 集合运算 → Set(intset)
+> - 排行榜 / 延时任务 / 带权随机 → ZSet(skiplist)
+
+## 关联笔记
+
+- [[hhs/Redis/08-SortedSet精解]] — ZSet 的高级玩法
+- [[hhs/Redis/03-基本命令速查]] — 常用命令速查表
+- [[hhs/Redis/07-集群方案]] — 集群环境下的大 Key 风险
diff --git a/hhs/Redis/03-基本命令速查.md b/hhs/Redis/03-基本命令速查.md
new file mode 100644
index 0000000..a3142bd
--- /dev/null
+++ b/hhs/Redis/03-基本命令速查.md
@@ -0,0 +1,304 @@
+---
+tags: [Redis, 缓存, 命令]
+create time: 2026-05-15 18:12
+---
+
+# Redis 基本命令速查
+
+## 概述
+
+按使用频率排序的常用命令,标注时间复杂度、典型场景和避坑点。完整命令参考官方文档。
+
+### 数据类型选型速查
+
+遇到业务需求时,按以下流程选择合适的 Redis 数据结构:
+
+```mermaid
+flowchart TD
+ A["需要存什么?"] --> B{"单值 / 键值对?"}
+ B -- 是 --> C["String
缓存, 计数器, 分布式锁"]
+ B -- 否 --> D{"多个字段 / 对象?"}
+ D -- 是 --> E["Hash
用户信息, 配置, 表单数据"]
+ D -- 否 --> F{"需要排序 / 排名?"}
+ F -- 是 --> G["ZSet
排行榜, 延迟队列, 范围查询"]
+ F -- 否 --> H{"需要去重 / 集合运算?"}
+ H -- 是 --> I["Set
标签, 共同关注, UV 统计"]
+ H -- 否 --> J{"需要队列 / 栈?"}
+ J -- 是 --> K["List
消息队列, 最新列表, 栈"]
+ J -- 否 --> L["Pub/Sub
广播, 实时通知"]
+```
+
+## String — 字符串操作
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `SET key value [EX s] [PX ms] [NX\|XX]` | OK / nil | O(1) | **万能设置**,EX/PX 设过期,NX 仅不存在时写,XX 仅存在时写 |
+| `GET key` | value / nil | O(1) | 获取值 |
+| `MGET key [key...]` | values array | O(N) | 批量获取 |
+| `SETRANGE key offset value` | length | O(1)* | 覆盖指定偏移量字节(*近似) |
+| `APPEND key value` | new-length | O(1)* | 追加到末尾 |
+| `INCR key` | incremented val | O(1) | 原子 +1 |
+| `INCRBY key increment` | incremented val | O(1) | 原子 +N |
+| `DECRBY key decrement` | decremented val | O(1) | 原子 -N |
+| `STRLEN key` | length | O(1) | 获取字符串长度 |
+| `GETSET key value` | old value | O(1) | **已废弃**,用 `SET key value NX` 替代 |
+| `GETRANGE key start end` | substring | O(N) | 截取子串(end 为 -1 表示末尾) |
+| `SETEX key seconds value` | OK | O(1) | **已废弃**,用 `SET key value EX seconds` 替代 |
+| `PSETEX key milliseconds value` | OK | O(1) | **已废弃**,用 `SET key value PX milliseconds` 替代 |
+| `MSET key value [key value ...]` | OK | O(N) | 原子设置多个键 |
+
+```go
+// SET 条件写入——分布式锁的核心
+rdb.Set(ctx, "lock:order:"+orderId, "1", 3*time.Second, redis.KeepTTL|redis.OnlyIfAbsent)
+
+// INCR 做限流计数器
+rdb.IncrBy(ctx, "rate:user:"+userID, 1)
+```
+
+> [!WARNING] MGET ≠ 事务保证
+> MGET 是并发执行 N 次 GET,不保证原子性。需要原子读多个字段请用 `MULTI` 或 Lua 脚本。
+
+## Hash — 哈希操作
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `HSET key field value [field value ...]` | added / changed count | O(N) | 设置一个或多个字段 |
+| `HGET key field` | value / nil | O(1) | 获取单个字段 |
+| `HMGET key field [field ...]` | values array | O(N) | 批量获取多个字段 |
+| `HGETALL key` | all fields | O(N) | 获取全部键值对 ⚠️ 大 hash 慎用 |
+| `HDEL key field [field ...]` | deleted count | O(N) | 删除字段 |
+| `HEXISTS key field` | 1 or 0 | O(1) | 判断字段是否存在 |
+| `HINCRBY key field increment` | new value | O(1) | 原子递增字段值 |
+| `HSCAN key cursor MATCH pattern COUNT n` | cursor, entries | O(N) | 安全遍历,不用 HKEYS/HVALS 全量扫 |
+
+```go
+// 用户信息缓存——部分更新
+pipe := rdb.Pipeline()
+pipe.HSet(ctx, "user:42", "email", "new@email.com")
+pipe.HSet(ctx, "user:42", "phone", "1380000")
+pipe.Exec(ctx) // 管道内原子执行
+```
+
+> [!TIP] HGETALL vs HSCAN
+> `HGETALL` 一步到位获取全部字段,但大 hash(>10K 字段)会阻塞主线程。生产环境推荐 `HSCAN` 分片读取。
+
+## List — 列表操作
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `LPUSH key elem [elem ...]` | list length | O(N) | 从左侧推入 |
+| `RPUSH key elem [elem ...]` | list length | O(N) | 从右侧推入 |
+| `LPOP key` | element / nil | O(1) | 弹出左侧元素 |
+| `RPOP key` | element / nil | O(1) | 弹出右侧元素 |
+| `LRANGE key start stop` | subarray | O(S+N) | 范围查询 S=start, N=stop-start |
+| `LLEN key` | length | O(1) | 列表长度 |
+| `LINDEX key index` | element | O(N) | 按索引获取 |
+| `LTRIM key start stop` | OK | O(N) | 只保留指定范围(固定队列长度必备) |
+| `BLPOP key [key ...] timeout` | element / nil | O(timeout) | **阻塞式** LPOP |
+| `BRPOP key [key ...] timeout` | element / nil | O(timeout) | **阻塞式** RPOP |
+| `RPOPLPUSH src dst` | element | O(1) | **已废弃**,用 LMOVE 替代 |
+| `LMOVE key where1 where2` | element | O(1) | 弹出一端、push 到另一端(支持 LEFT/RIGHT) |
+
+```go
+// BRPOP 消费者模型
+val, err := rdb.BRPop(ctx, 5*time.Second, "task_queue").Result()
+if err == redis.Nil {
+ return nil // 超时,无消息
+}
+```
+
+> [!QUESTION] LPUSH + RPOP 还是 RPUSH + LPOP?
+> 两者性能相同(都是两端操作)。选择取决于语义——"先到的先处理"用 LPUSH+RPOP(FIFO),反之则相反。
+
+## Set — 集合操作
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `SADD key member [member ...]` | added count | O(N) | 添加成员 |
+| `SREM key member [member ...]` | removed count | O(N) | 移除成员 |
+| `SMEMBERS key` | all members | O(N) | 获取全部 ⚠️ |
+| `SISMEMBER key member` | 1 or 0 | O(1) | 判断成员 |
+| `SCARD key` | cardinality | O(1) | 元素个数 |
+| `SRANDMEMBER key [count]` | member(s) | O(N) | 随机取(不删除) |
+| `SPOP key [count]` | member(s) | O(N) | 随机取并删除 |
+| `SINTER key [key ...]` | intersection | O(N*M) | 交集 ⚠️ 跨节点成本高 |
+| `SUNION key [key ...]` | union | O(N*M) | 并集 |
+| `SDIFF key [key ...]` | difference | O(N*M) | 差集 |
+| `SINTERSTORE dst key [key ...]` | result count | O(N*M) | 交集结果存入新 key |
+
+```go
+// 共同关注——用户 A 和用户 B 都关注的人
+common := rdb.SInter(ctx, "follow:userA", "follow:userB").Val()
+
+// 抽奖系统——随机取 N 个不重复中奖者
+winners := rdb.SPopN(ctx, "entry:lottery", 5).Val()
+```
+
+> [!TIP] Set 的典型应用场景
+> - **去重**:UV 统计(`SADD` + `SCARD`)
+> - **交集/并集**:共同好友、标签聚合
+> - **随机抽样**:`SPOP` / `SRANDMEMBER` 做抽奖或推荐
+
+## ZSet —— 有序集合
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `ZADD key score member [score member ...]` | added/changed | O(N log N) | 添加/更新 |
+| `ZSCORE key member` | score / nil | O(1) | 获取分数 |
+| `ZINCRBY key increment member` | new score | O(log N) | 增加分数 |
+| `ZRANGE key start stop [WITHSCORES]` | members | O(log N+M) | 正序范围 |
+| `ZREVRANGE key start stop [WITHSCORES]` | members | O(log N+M) | 倒序范围 |
+| `ZRANGEBYSCORE key min max [WITHSCORES]` | members | O(log N+M) | 分数区间 |
+| `ZRANK key member` | rank / nil | O(log N) | 升序排名 |
+| `ZREVRANK key member` | reverse rank / nil | O(log N) | 降序排名 |
+| `ZCARD key` | count | O(1) | 元素数 |
+| `ZCOUNT key min max` | count | O(log N) | 分数范围内元素数 |
+| `ZREM key member [member ...]` | removed | O(M log N) | 移除 |
+| `ZREMRANGEBYRANK key start stop` | removed | O(log N+M) | 按排名删 |
+| `ZREMRANGEBYSCORE key min max` | removed | O(log N+M) | 按分数删 |
+| `ZMSCORE key member [member ...]` | scores | O(N) | 批量获取分数(7.0+) |
+| `ZPOPMAX key [count]` | top members | O(log N+M) | 弹出最高分 |
+| `ZPOPMIN key [count]` | bottom members | O(log N+M) | 弹出最低分 |
+| `ZINTERSTORE dst numkeys key [numkeys...]` | cardinality | O(N×K+K log K) | 多 ZSet 求交集存新 key |
+| `ZUNIONSTORE dst numkeys key [numkeys...]` | cardinality | O(N×K+K log K) | 多 ZSet 求并集存新 key |
+| `ZRANDMEMBER key [count] [WITHSCORES]` | members | O(N) | 随机取(7.0+) |
+| `ZRANGEBYLEX key min max [LIMIT offset count]` | members | O(log N+M) | 同分数按 member 字典序排 |
+| `ZSCAN key cursor MATCH pattern COUNT n` | cursor, entries | O(N) | 安全遍历 |
+
+```go
+// 排行榜——获取 TOP 10
+top10 := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Val()
+for _, z := range top10 {
+ fmt.Printf("%s: %.1f\n", z.Member, z.Score)
+}
+
+// 分数区间查询——80~100 分的学生
+students := rdb.ZRangeByScore(ctx, "score:classA", redis.ZRangeBy{
+ Min: "80", Max: "100",
+}).Val()
+```
+
+> [!TIP] 分数边界写法
+> `[` 包含、`(` 不包含。例如 `"[80"` 表示 ≥80,`"(100"` 表示 <100。`-inf` 和 `+inf` 表示无穷大。
+
+## 通用命令
+
+| 命令 | 返回值 | 复杂度 | 说明 |
+|------|--------|--------|------|
+| `DEL key [key ...]` | deleted count | O(N) | N = key 的平均大小,大 key 会阻塞 |
+| `EXPIRE key seconds` | 1 or 0 | O(1) | 设过期时间(秒) |
+| `PEXPIRE key milliseconds` | 1 or 0 | O(1) | 毫秒级过期 |
+| `TTL key` | seconds / -1 / -2 | O(1) | 剩余存活秒数 |
+| `PTTL key` | milliseconds | O(1) | 剩余存活毫秒 |
+| `EXPIREAT key timestamp` | 1 or 0 | O(1) | 按 Unix 时间戳过期 |
+| `KEYS pattern` | matching keys | **O(N)** | ⛔ 全库扫描,生产禁用! |
+| `SCAN cursor MATCH pattern COUNT n` | cursor, keys | **增量迭代 O(1)** | ✅ 安全的模糊查找 |
+| `DBSIZE` | count | O(1) | 当前数据库 key 总数 |
+| `SELECT index` | OK | O(1) | 切换数据库(0~15) |
+| `FLUSHDB` | OK | O(N) | 清空当前库 |
+| `FLUSHALL` | OK | O(N) | 清空所有库 |
+| `TYPE key` | string/hash/list... | O(1) | 查看数据类型 |
+| `MOVE key db` | 1 or 0 | O(1) | 移动到其他数据库(集群不支持) |
+| `RENAME key newkey` | OK | O(1) | 重命名(不管类型) |
+| `RANDOMKEY` | key / nil | O(1) | 随机返回一个 key(集群不支持) |
+
+```go
+// 优雅删除大 key——避免 DEL 阻塞主线程
+keys := []string{"large:key1", "large:key2"}
+rdb.Del(ctx, keys...).Err() // 小批量 DEL
+
+// 或者用 SSCAN/HSCAN 分批删除字段
+iter := rdb.HScan(ctx, "big:hash", 0, "", 100).Iterator()
+for iter.Next(ctx) {
+ rdb.HDel(ctx, "big:hash", iter.Val()...)
+}
+```
+
+> [!WARNING] TTL 的陷阱
+> - `TTL` 返回 `-1` 表示 key 存在但无过期时间,`-2` 表示 key 不存在
+> - Redis 的过期键删除是**惰性删除 + 定期删除**混合策略,不能保证设置 EXPIRE 后立即清理
+> - 集群模式下 `MOVE`、`RANDOMKEY`、`FLUSHALL` **不可用**
+
+## SCAN vs KEYS 深度对比
+
+```bash
+# ⛔ KEYS — 阻塞整个服务
+KEYS user:*
+# 100 万 key 中匹配 1000 个 → 主线程阻塞数百毫秒甚至秒级
+
+# ✅ SCAN — 增量迭代,每次 O(1)
+SCAN 0 MATCH user:* COUNT 100
+# 返回游标 + 一小批匹配 key → 用游标继续,直到游标回到 0
+```
+
+```go
+// Go 中的 SCAN 用法
+iter := rdb.Scan(ctx, 0, "user:prefix:*", 100).Iterator()
+for iter.Next(ctx) {
+ fmt.Println(iter.Val())
+}
+// 最后检查 iter.Err()
+```
+
+> [!WARNING] SCAN 的不确定性
+> - 某个 key 可能返回多次(中间被修改或删除)
+> - 某些匹配的 key 可能不返回
+> - 因此 **不要用 SCAN 做统计计数**,只能用于清理或迁移等容错场景
+
+## 管道 Pipeline vs 事务 Transaction
+
+Redis 协议本身就是 request/response,Pipeline 把多个命令打包一次发送,大幅减少网络往返;而事务通过 `MULTI` / `EXEC` 保证一组命令的**原子执行**。
+
+### Pipeline —— 批量发送,减少 RTT
+
+```go
+pipe := rdb.Pipeline()
+pipe.Set(ctx, "k1", "v1", 0)
+pipe.Set(ctx, "k2", "v2", 0)
+pipe.Set(ctx, "k3", "v3", 0)
+results, err := pipe.Exec(ctx) // 一次性发送,一次性接收
+```
+
+```bash
+# CLI 模式
+redis-cli --pipeline <<< $'SET k1 v1\nSET k2 v2\nSET k3 v3'
+```
+
+> [!TIP] Pipeline 不是银弹
+> 单次 batch 控制在 50~200 条为佳。过大反而会增加 RTT 等待时间,且占用服务端协程。
+
+### MULTI / EXEC —— 原子化执行
+
+```go
+pipe, cancel := rdb.TxPipeline()
+defer cancel()
+
+pipe.Set(ctx, "acc:A", "900", 0)
+pipe.Set(ctx, "acc:B", "1100", 0)
+_, err := pipe.Exec(ctx) // 全部命令依次执行(不被其他命令打断)
+```
+
+```bash
+# Redis CLI 中的事务
+MULTI
+> SET acc:A 900
+> SET acc:B 1100
+EXEC # 原子提交
+```
+
+> [!NOTE] Pipeline vs Transaction 对比
+> | 维度 | Pipeline | Transaction (MULTI/EXEC) |
+> |------|----------|------------------------|
+> | **目的** | 减少网络往返(性能) | 保证原子性(一致性) |
+> | **命令失败处理** | 单个失败不影响其他命令 | `EXEC` 前语法错误会取消整个事务 |
+> | **WATCH 支持** | 不支持 | 支持乐观锁 (`WATCH key`) |
+> | **适用场景** | 批量写入、批量查询 | 转账、库存扣减等需要原子性的操作 |
+
+> [!WARNING] Redis 事务不是"数据库事务"
+> Redis 事务只保证**不中断**(没有回滚),中间执行的命令如果出错,后续命令仍会继续。如果需要"出错就回滚"的行为,请使用 Lua 脚本。
+
+## 关联笔记
+
+- [[hhs/Redis/02-核心数据类型]] — 每种类型的底层编码原理
+- [[hhs/Redis/05-AOF持久化]] — 持久化策略对性能的影响
+- [[hhs/Redis/09-高级特性]] — Pipeline / Lua / 事务详解
diff --git a/hhs/Redis/04-RDB持久化.md b/hhs/Redis/04-RDB持久化.md
new file mode 100644
index 0000000..fe7a078
--- /dev/null
+++ b/hhs/Redis/04-RDB持久化.md
@@ -0,0 +1,228 @@
+---
+tags: [Redis, 缓存, 持久化, RDB]
+create time: 2026-05-15 18:12
+---
+
+# RDB 持久化
+
+## 概述
+
+> [!SUMMARY] RDB 是什么?
+> RDB(Redis Database)是 Redis 最早的持久化机制,通过在特定时间点将内存中的数据集写入磁盘形成**快照文件(.rdb)**,实现数据持久化。
+
+### 核心特点
+
+| 特性 | 说明 |
+|------|------|
+| ⚡ 恢复极快 | 二进制文件直接加载,避免命令回放 |
+| 📦 文件紧凑 | 压缩存储,远小于等量 AOF 日志 |
+| 🔧 架构简单 | 无额外复杂逻辑,适合备份和迁移 |
+| ⚠️ 数据窗口丢失 | 两次快照之间的写操作不可恢复 |
+
+### 何时使用
+
+- **大数据集冷备**:定期全量备份到 S3 / OSS
+- **灾难恢复**:配合 CI/CD 快速重建实例
+- **大规模数据场景**:恢复速度优先于毫秒级数据一致性
+
+> [!QUESTION] 为什么 RDB 恢复比 AOF 快?
+> RDB 是直接加载二进制快照到内存,AOF 则需要逐条回放命令——数据量越大差距越明显。
+
+## 触发机制
+
+### 手动触发
+
+```bash
+# 当前线程执行,阻塞主线程 ❌
+SAVE
+
+# 主进程 fork 子进程在后台完成(推荐)
+BGSAVE
+
+# 查看上次 BGSAVE 状态
+LASTSAVE
+```
+
+| 命令 | 行为 | 影响 |
+|------|------|------|
+| `SAVE` | 同步保存,主线程阻塞 | **生产环境禁用!** |
+| `BGSAVE` | fork 子进程异步保存 | 主线程继续服务,但触发 fork 瞬间短暂停顿 |
+| `BGREWRITEAOF` | AOF rewrite | 与 BGSAVE 可同时运行(不同子进程) |
+
+### 自动触发——条件持久化
+
+通过 `redis.conf` 配置:
+
+```conf
+save 900 1 # 900 秒内至少有 1 个 key 被修改 → 触发快照
+save 300 10 # 300 秒内至少有 10 个 key 被修改
+save 60 10000 # 60 秒内至少有 10000 个 key 被修改
+```
+
+> [!QUESTION] 为什么要三档条件?
+> - **低活跃度**:900s/1key 保证最终一致
+> - **中活跃度**:300s/10key 平衡频率和数据量
+> - **高活跃度**:60s/10000key 高频大量变更时快速落盘
+
+```bash
+# 运行时动态修改
+CONFIG SET save "900 1\n300 10\n60 10000"
+
+# 临时关闭自动快照(谨慎!)
+CONFIG SET save ""
+```
+
+> [!WARNING] CONFIG SET 不持久
+> `CONFIG SET` 仅在内存中生效,重启后失效。修改 `redis.conf` 并 reload 才是持久化的方式。
+
+### 其他触发场景
+
+| 场景 | 说明 |
+|------|------|
+| **shutdown 保存** | `SHUTDOWN` / `SHUTDOWN NOSAVE` 会在关闭前执行一次完整 RDB 保存(除非显式传 NOSAVE) |
+| **AOF rewrite** | `BGREWRITEAOF` 重写 AOF 时同样会 fork 子进程生成 RDB preamble(开启混合持久化时) |
+| **主从同步** | 新从节点首次全量同步时,主节点会自动触发 BGSAVE 并将快照传给从节点 |
+
+> [!TIP] 避免频繁触发
+> 多个触发条件同时满足时,Redis 会去重,仅执行一次 BGSAVE。若已有 BGSAVE 在执行,后续的 SAVE 请求将被忽略。
+
+## RDB 文件结构
+
+Redis 7.0+ 引入了新格式,更紧凑且支持增量复制:
+
+```mermaid
+flowchart TD
+ H1["Header"] --> H2["Magic: REDIS"]
+ H1 --> H3["RDB Version"]
+ H1 --> H4["Reserved / CRC64"]
+
+ D1["DB Number
1 byte"] --> D2["Key Type
1 byte"]
+ D2 --> D3["Encoded Key + Value"]
+
+ E1["EOF Marker"] --> E2["CRC64 Checksum
8 bytes"]
+
+ H1 & H2 & H3 & H4 ==> "Dataset Section"
+ D1 & D2 & D3 ==> "Dataset Section"
+ D3 -.-> "... N keys ..."
+ E1 & E2 ==> "Footer"
+```
+
+## Fork 过程详解
+
+```mermaid
+sequenceDiagram
+ participant Server as Redis Server
+ participant Parent as 主进程
+ child ForkGroup as Forked Child
+ participant Child as 写时复制子进程
+ participant Disk as 磁盘 (.rdb)
+ end
+
+ Server->>Parent: BGSAVE 指令
+ Parent->>Server: OK(立即返回)
+ Parent->>Child: fork() 创建子进程
+ Note over Child: 子进程独享父进程
内存副本(写时复制 COW)
+ Child->>Disk: 遍历所有 key → 序列化
+ Note over Child: 主进程仍可读写
COW 机制按需拷贝脏页
+ Child->>Parent: 生成临时 .rdb.tmp
+ Parent->>Disk: RENAME 原子替换
+ Parent->>Server: BGSAVE 完成通知
+```
+
+> [!WARNING] COW 内存消耗
+> fork 后主进程若持续写入大值,会导致 COW 副本膨胀。例如你有一个 1GB 的值被频繁修改,fork 后每次写都可能多拷贝一页(4KB)。高峰期建议关闭自动 RDB,仅依赖 AOF。
+
+### TTL / 过期键处理
+
+> [!QUESTION] RDB 快照会保存已过期的 key 吗?
+> **不会。** Redis 在 fork 子进程前会先执行一次主动过期扫描,清理掉所有已超时的 key。
+
+具体流程:
+1. `BGSAVE` 触发时,主线程先将当前所有已过期 key 从内存中删除
+2. 子进程 fork 完成后遍历剩余 key → 序列化到 .rdb 文件
+3. 因此:**RDB 文件中不会出现已过期键**
+
+```mermaid
+flowchart LR
+ A["BGSAVE 触发"] --> B["主线程: 主动过期
清理超时 Key"]
+ B --> C["Fork 子进程"]
+ C --> D["子进程: 遍历剩余数据
序列化到 .rdb"]
+ D --> E[".rdb 无过期键"]
+
+ style E fill:"#c4e8b0"
+```
+
+> [!NOTE] 过期策略差异
+> 虽然 RDB 不保存过期键,但 AOF 可能包含过期命令(如之前的 SET k v EX 100)。AOF 重写时会主动过滤过期键,恢复时也只会加载有效数据。两者优先保证启动时的数据一致性。
+
+## 恢复流程
+
+```bash
+# 1. 停止 Redis
+redis-cli SHUTDOWN NOSAVE
+
+# 2. 把 .rdb 放到 redis_dir(由 dir 配置决定)
+cp dump.rdb /var/lib/redis/
+
+# 3. 启动 Redis
+redis-server --daemonize yes
+
+# 4. 验证数据恢复
+redis-cli DBSIZE
+redis-cli KEYS "*"
+```
+
+## RDB vs AOF 对比
+
+| 维度 | RDB | AOF |
+|------|-----|-----|
+| 数据安全性 | 两次快照间数据丢失 | 每秒/每次可配置,接近零丢失 |
+| 恢复速度 | ⚡ 极快(直接加载二进制文件) | 🐢 较慢(需要回放日志) |
+| 文件大小 | ✅ 小(紧凑压缩) | ❌ 大(文本命令日志) |
+| CPU 开销 | 低(偶尔 fork) | 高(持续追加写入) |
+| 适用场景 | 备份、灾难恢复、大数据集冷备 | 对可用性要求高的在线服务 |
+| 重启耗时 | 秒级 | 取决于日志长度(可 pre-fork) |
+
+## Redis 7 混合持久化
+
+结合 RDB + AOF 的优点:
+
+```conf
+aof-use-rdb-preamble yes
+```
+
+开启后,AOF rewrite 不再只追加命令日志,而是先写入一份 RDB 快照,再追加后续命令:
+
+```mermaid
+flowchart LR
+ T1["传统 AOF 重建
逐条命令回放"] -->|"O(N) 慢"| R1["重建耗时: 长"]
+
+ H1["RDB 二进制快照
全量快速还原"] -->|"秒级加载"| R2["重建耗时: 短"]
+ H2["AOF 增量日志
仅保留变更后操作"] -->|"精确到毫秒"| R2
+
+ style R1 fill:"#f9d0c4"
+ style R2 fill:"#c4e8b0"
+```
+
+> [!TIP] 混合持久化几乎是无脑开启的配置——启动快 + 不丢数据。
+
+## 运维要点
+
+```bash
+# 监控 RDB 相关指标
+INFO persistence
+# rdb_last_bgsave_status: OK
+# rdb_last_save_time: 1715000000 ← 最后成功时间戳
+
+# 检查文件大小
+ls -lh /var/lib/redis/dump.rdb
+
+# 压缩备份(已内置 LZ4 压缩,通常不需要额外压缩)
+tar czf redis-backup.tar.gz dump.rdb
+```
+
+## 关联笔记
+
+- [[hhs/Redis/05-AOF持久化]] — AOF 持久化机制
+- [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障转移流程
+- [[hhs/Redis/09-运维调优]] — 备份策略与大 Key 治理
diff --git a/hhs/Redis/05-AOF持久化.md b/hhs/Redis/05-AOF持久化.md
new file mode 100644
index 0000000..a4386a1
--- /dev/null
+++ b/hhs/Redis/05-AOF持久化.md
@@ -0,0 +1,218 @@
+---
+tags: [Redis, 缓存, 持久化, AOF]
+create time: 2026-05-15 18:13
+---
+
+# AOF 持久化
+
+## 概述
+
+AOF(Append Only File)以**追加写日志**的方式记录每个写操作。相比 RDB 的快照式恢复,AOF 提供更高的数据安全性——你可以根据策略做到**不丢数据、丢 1 秒、或丢 N 条**。代价是文件更大、恢复更慢。
+
+## 核心机制
+
+### 命令追加过程
+
+```mermaid
+flowchart LR
+ Client["客户端写入"] --> Server["Redis 主线程
执行命令 + 返回结果"]
+ Server --> Buffer["aof_buffer
内核/用户态缓冲区"]
+ Buffer --> FSyncPolicy{"刷新策略?"}
+ FSyncPolicy -->|everysec| KernelBuf["操作系统页缓存"]
+ KernelBuf --> Disk["磁盘 aof 文件"]
+ FSyncPolicy -->|always| Disk
+ FSyncPolicy -->|no| OSPage["交给 OS 自己 flush"]
+
+ style Server fill:#f9d,stroke:#96c
+ style Disk fill:#dfd,stroke:#6c6
+```
+
+> [!TIP] 一条命令是怎么落到磁盘的?
+> 1. 客户端发送命令 → 2. Redis 在主线程执行并返回结果 → 3. 同时将命令追加到 `aof_buffer` → 4. 根据 `appendfsync` 策略最终刷入磁盘。整个过程对主线程几乎是异步的。
+
+### 刷新策略配置
+
+```conf
+# appendfsync always # 每次写入都 fsync -> 最安全,性能差
+appendfsync everysec # 每秒 fsync <- 推荐(最佳平衡)
+# appendfsync no # 完全依赖 OS -> 性能最好,风险最高
+```
+
+| 策略 | 丢失风险 | 吞吐量 (ops/s) | 适用场景 |
+|------|---------|---------------|---------|
+| `always` | 零丢失 | ~900 | 金融级要求(极少用) |
+| `everysec` | 最多丢 1 秒 | ~74k | **生产标准配置** |
+| `no` | OS 决定,可能丢大量 | ~93k | 可接受全量丢失的场景 |
+
+> [!TIP] everysec 的性能真相
+> `everysec` 不会阻塞主线程——它把 fsync 放到单独的后台线程。即使某个 fsync 花了 2s(磁盘卡顿),也只是这一秒的写入被推迟到下一周期,不会影响主线程。
+
+## AOF 重写(Rewrite)
+
+AOF 文件会不断增长,需要定期重写来去除无效命令(如 key 被 DEL 后又 SET)。
+
+### 重写触发条件
+
+```conf
+auto-aof-rewrite-percentage 100 # AOF 比上次 rewrite 后增大的百分比
+auto-aof-rewrite-min-size 64mb # AOF 最小绝对大小(避免刚启动就 rewrite)
+```
+
+示例:当前 AOF = 128MB,上次 rewrite 后大小 = 64MB
+- 增量 = 128 - 64 = 64MB
+- 增量占比 = 64 / 64 = 100% -> 触发 rewrite
+- 但如果当前 AOF < 64MB -> 不触发
+
+> [!QUESTION] 如果设置 auto-aof-rewrite-min-size 太大或太小会发生什么?
+> - **太大**:AOF 膨胀了很久才触发重写,磁盘空间浪费严重
+> - **太小**:频繁重写,增加不必要的 CPU 和 IO 开销
+>
+> 这本质上是一个「文件瘦身频率」的工程权衡题。
+
+### 重写过程(类似 RDB fork)
+
+```mermaid
+sequenceDiagram
+ participant S as Redis Server
+ participant P as 主进程
+ child CG as Rewriter Child
+ participant C as 重写子进程
+ participant Log as appendonly.aof
+ participant NewLog as appendonly.aof_rewrite_tmp
+ end
+
+ S->>P: BGREWRITEAOF
+ P->>S: OK
+ P->>C: fork() 子进程
+ C->>NewLog: fork 时刻起遍历内存 -> 重写为精简命令
+ Note over P,C: Main process continues serving requests
+ loop 每个新写命令
+ P->>Log: 追加原始命令
+ P->>C: 同时写入 aof_rewrite_buf
(重写期间的新操作)
+ end
+ C->>NewLog: 追加 aof_rewrite_buf 中的内容
+ C->>P: 完成
+ P->>Log: rename(原 -> 旧.bak, 新 -> 当前)
+```
+
+> [!WARNING] 重写的内存开销
+> fork 后重写子进程也使用 COW 机制。如果你的 AOF 正在持续增长且写入量大,可能需要评估可用内存是否足够支撑 fork 的 COW 副本。在 100GB+ 的 Redis 实例上,一次 fork 可能导致额外的几 GB 内存占用。
+
+## AOF 开启方式
+
+```conf
+appendonly yes # 开启 AOF
+appendfilename "appendonly.aof" # AOF 文件名
+dir /var/lib/redis # AOF/RDB 文件存放目录
+```
+
+```bash
+# Redis 7.x 目录结构(新版本使用多文件目录而非单文件)
+ls /var/lib/redis/appendonlydir/
+total 256M
+drwxr-x--- 2 redis redis 4.0K May 15 18:00 .
+drwxr-x--- 2 redis redis 4.0K May 15 18:00 ..
+-rw-r----- 1 redis redis 16K May 15 18:00 manifest.aof
+-rw-r----- 1 redis redis 128M May 15 18:00 base.rdb
+-rw-r----- 1 redis redis 128M May 15 18:01 incr-0000000000000003.aof
+```
+
+### 思考:为什么 AOF 会持续增长?
+
+想象以下操作序列:`SET x 1 -> SET x 2 -> SET x 3 -> DEL x`。如果不做处理,AOF 中会记录这 4 条命令,但 key `x` 已经不存在了——这些写入了磁盘却没有任何价值。这就是为什么需要**重写**。
+
+## 混合持久化(Hybrid Persistence)
+
+混合持久化是 Redis 4.0 引入、Redis 7 默认开启的特性——在 AOF 重写时,先用 RDB 快照全量备份当前内存状态,再用 AOF 追加增量变化。
+
+```conf
+# redis.conf
+aof-use-rdb-preamble yes # 开启混合持久化(默认值)
+```
+
+```mermaid
+flowchart LR
+ BG["BGREWRITEAOF"] --> Fork["fork 子进程"]
+ Fork --> RDBPart["RDB 全量部分
二进制快照,写入极快"]
+ Fork --> AOFPart["AOF 增量部分
仅含 fork 后的写操作"]
+ RDBPart --> Merge["合并为临时文件"]
+ AOFPart --> Merge
+ Merge --> Rename["原子 rename 替换"]
+
+ style RDBPart fill:#bbf,stroke:#66c
+ style AOFPart fill:#dfd,stroke:#090
+ style Merge fill:#ffd,stroke:#cc0
+```
+
+**好处有两个:**
+1. **恢复速度快**:启动时先加载 RDB 全量快照(二进制格式比解析 AOF 命令快 10~100 倍),再回放少量 AOF 增量命令
+2. **文件体积小**:相比纯 AOF 记录每条操作指令,混合模式下文件通常缩小 70%~90%
+
+> [!WARNING] 恢复流程的变化
+> 混合模式下,Redis 启动时先读取 `base.rdb` 还原全部数据,再逐条执行增量 `.aof` 段中的命令。如果只保留 AOF 段而删除 `base.rdb`,恢复将退化为纯 AOF 模式,速度大幅下降。
+
+## AOF vs RDB 对比
+
+| 维度 | AOF (`everysec`) | RDB(典型配置) |
+|------|-----------------|----------------|
+| 数据安全性 | ⭐⭐⭐ 最多丢 1s | ⭐ 可能丢数分钟 ~ 小时 |
+| 恢复速度 | ⭐⭐ 较慢(需逐条 replay) | ⭐⭐⭐ 快(二进制直接加载) |
+| CPU 开销 | ⭐⭐ 每秒一次 fsync | ⭐ 仅在 fork 时短暂增长 |
+| 磁盘 IO | ⭐⭐ 持续写入日志 | ⭐ 仅在快照间隔触发 |
+| 文件大小 | ⭐ 较大(逐条记录) | ⭐⭐⭐ 较小(二进制压缩) |
+| 数据丢失窗口 | <= 1 秒 | = save 间隔时间 |
+
+> [!QUESTION] 什么时候该用 RDB 而不是 AOF?
+> 如果你的业务场景是**缓存而非数据库**——数据可以重新构建或允许短暂丢失,那 RDB 就够了。只有当数据丢失会造成长期损失(如订单、余额)时,才需要上 AOF。
+
+## AOF 故障恢复
+
+```bash
+# 如果 AOF 损坏(断电等原因导致截断)
+redis-server --repair appendonly.aof
+# 或 Redis 7 下修复整个目录
+redis-server --fix appendonlydir/aof-manifest
+```
+
+### fsync 失败时的行为
+
+```
+fsync -> EIO(磁盘错误)
+ ↓
+ERROR "I/O error writing to APPEND ONLY FILE..."
+ ↓
+立即 SHUTDOWN NOSAVE(停止服务防止数据进一步损坏)
+```
+
+> [!NOTE] Redis 为什么会主动 shutdown?
+> 这是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。宁可停机也不能让用户在「不确定」的数据上继续业务。
+
+> [!WARNING] AOF 不是万能的
+> - AOF 修复工具只能恢复大部分,极端情况下会有部分命令丢失
+> - 定期做冷备份(RDB 迁移到 OSS/S3)仍然是必须的兜底手段
+
+## AOF vs RDB 选型决策树
+
+```mermaid
+flowchart TD
+ Start{"你的首要需求是?"} -->|"数据安全第一"| AOF["AOF everysec"]
+ Start -->|"恢复速度第一"| RDB["RDB 仅"]
+ Start -->|"两者兼顾"| HYBRID["混合持久化"]
+
+ HYBRID --> Check{"数据重要性极高?"}
+ Check -->|"是"| ALWAYS["AOF always
+ 每天冷备"]
+ Check -->|"否"| EVERYSEC["AOF everysec
+ 混合持久化"]
+
+ style HYBRID fill:#dfd,stroke:#090
+ style ALWAYS fill:#fdd,stroke:#900
+ style EVERYSEC fill:#dfd,stroke:#090
+```
+
+> [!TIP] 生产环境黄金组合
+> `AOF everysec + 混合持久化 + 定时 rdb 冷备到对象存储`——几乎覆盖所有常见场景。
+
+## 关联笔记
+
+- [[hhs/Redis/04-RDB持久化]] — RDB 快照机制
+- [[hhs/Redis/06-主从与哨兵]] — 哨兵监控中 AOF 状态检查
+- [[hhs/Redis/09-运维调优]] — AOF 文件膨胀治理
diff --git a/hhs/Redis/06-主从与哨兵.md b/hhs/Redis/06-主从与哨兵.md
new file mode 100644
index 0000000..b502bf5
--- /dev/null
+++ b/hhs/Redis/06-主从与哨兵.md
@@ -0,0 +1,411 @@
+---
+tags: [Redis, 缓存, 高可用, 主从, Sentinel]
+create time: 2026-05-15 18:13
+---
+
+# Redis 主从复制与 Sentinel
+
+## 概述
+
+在生产环境中,单节点 Redis 存在两大问题:**写入能力有上限**、**宕机即停服**。本节介绍两个构建基础高可用的核心机制——
+
+| 机制 | 解决什么问题 | 一句话说明 |
+|------|------------|----------|
+| **主从复制(Replication)** | 数据冗余 + 读扩展 | Master 处理写操作,Replica 自动同步数据并承担读请求 |
+| **Sentinel(哨兵)** | 人工恢复慢 | 自动检测 Master 故障,提升最佳 Replica 为新 Master |
+
+> [!SUMMARY] 知识定位
+> - ✅ 本方案适合 **读多写少** 的场景(8:2 ~ 9:1)
+> - ⚠️ 写入仍然集中在单点,若需水平写入 → [[hhs/Redis/07-集群方案]]
+> - 🔧 这是学习 Redis Cluster 的前置知识
+
+```mermaid
+flowchart TD
+ Master["Master
写操作入口"]
+ R1["Replica-1"]
+ R2["Replica-2"]
+ R3["Replica-3"]
+
+ Master -->|"全量/增量同步"| R1
+ Master -->|"全量/增量同步"| R2
+ Master -->|"全量/增量同步"| R3
+
+ ClientRW["写请求"] --> Master
+ ClientR["读请求"] --> R1
+ ClientR --> R2
+ ClientR --> R3
+```
+
+## 一、主从复制原理
+
+### 异步 vs 半同步
+
+> [!QUESTION] 为什么 Redis 选择异步复制?
+> 如果等 Replica 确认后 Master 才返回客户端,那网络延迟 = 写入延迟。对于追求低延迟的场景(微秒级),这是不可接受的。
+
+Redis 默认**异步复制**——Master 写完即返回客户端,不等待 Replica 确认。这保证了极致低延迟,代价是极端情况下可能丢失数据:
+
+```text
+场景: 写 A=1 → Master 返回 OK → Master 宕机(还没传到 Replica)→ 重启后 A 不存在
+```
+
+若业务需要 **至少一份副本落盘** 的强一致性保证,可配合 Lua + `wait` 命令实现弱半同步:
+
+```go
+// Go: 确保至少 N 个副本成功写入
+numReplicas := 1 // 至少 1 个 replica 收到
+timeout := 100 // 超时 100ms
+result := client.Do(ctx, "WAIT", numReplicas, timeout).Int()
+// result == 1 → 至少有 1 个副本已同步
+```
+
+> [!TIP] WAIT 的代价
+> 阻塞当前线程直到满足条件或超时。高频写场景慎用,建议仅在关键事务中使用。
+
+### 全量同步 vs 增量同步
+
+> [!NOTE] PSync:一次连接,多次复用
+> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。
+
+```mermaid
+sequenceDiagram
+ participant R as Replica
+ participant M as Master
+
+ Note over R,M: ── 阶段 1: 全量同步(首次或断线过长)──
+ R->>M: PSYNC ? -1 (首次连接)
+ M->>M: BGSAVE → dump.rdb.tmp
+ M-->>R: +CONTENTS dump.rdb
+ R->>R: 重写本地数据集
+
+ loop 同步期间的新命令
+ M->>M: 追加到 repl-backlog-buffer
+ end
+
+ M-->>R: +CONTENTS buf (缓冲命令流)
+ R->>R: 重放缓冲命令
+
+ Note over R,M: ── 阶段 2: 增量同步(后续心跳)──
+ loop 通常 1 秒一次
+ R->>M: PSync
+ M->>R: 发送 offset 之后的命令
+ end
+```
+
+#### 什么情况下触发全量同步?
+
+| 触发条件 | 说明 |
+|---------|------|
+| 首次连接 | Replica 为空,必须下载完整 RDB |
+| Master 重启 | Run ID 改变,无法增量恢复 |
+| Replica 手动 SLAVEOF | 主动请求全量同步 |
+| Backlog 过小 | 断线时间超出 repl-backlog-size 缓存容量 |
+| config-resetstat 执行 | 元数据重置导致无法增量 |
+
+### 复制背调(Replication Backlog)
+
+每个 Master 内部维护一个固定大小的环形缓冲区(默认 1MB):
+
+```mermaid
+flowchart LR
+ WriteHead["写入指针"] -->|"新命令流入"| Buffer["repl-backlog-buffer
环形缓冲区(默认1MB)"]
+ ReadHead["读取指针"] -->|"命令回放给 Replica"--> Buffer
+ Buffer -->|"溢出"`覆盖最旧数据`"
+```
+
+```conf
+# 相关配置
+repl-backlog-size 256mb # 缓冲区大小,增大允许更长断线恢复
+repl-backlog-ttl 3600 # 无人订阅时多久自动释放(秒)
+```
+
+**经验估算公式**:假设业务 QPS = 10,000,每条命令平均 50 字节,则每秒消耗 ~500KB 缓冲区。
+- backlog = 1MB → 约支持 **2 秒** 断线恢复
+- backlog = 256MB → 约支持 **8 分钟** 断线恢复
+
+### 大键分裂问题(Big Key Split)
+
+当某个 key 体积很大时(如几 MB 的 Hash/Set),一次 `SET` 会阻塞整个 Redis 进程数毫秒甚至更久。对 Replica 来说,这个命令在网络上传输也会成为瓶颈。
+
+> [!SUMMARY] 如何发现大 Key?
+> ```bash
+> redis-cli --bigkeys # 在线扫描(谨慎!高 QPS 环境有性能影响)
+> scan 0 MATCH * COUNT 100000 # 逐页扫描,用 type/hashlist/set 分别评估
+> ```
+
+解决方案:
+- 写入侧:**分拆大 key** 为多个小 key(如按用户 ID 哈希分散)
+- 同步侧:增大 `repl-diskless-sync-delay` 让多个 Replica 同时接收,避免重复传输
+
+## 二、级联复制(拓扑优化)
+
+当 Replica 数量增多时,每个都连 Master 会造成带宽压力:
+
+```mermaid
+flowchart LR
+ Master["Master"]
+ R1["Replica-1
(也接受从连接)"]
+ R2["Replica-2"]
+ R3["Replica-3"]
+ R4["Replica-4"]
+
+ Master --> R1
+ Master --> R2
+ R1 --> R3
+ R1 --> R4
+```
+
+> [!TIP] 最佳实践
+> 只有 1~2 个 Replica 直接连 Master,其余通过级联获取数据。降低 Master 的网络和 CPU 负担。
+
+## 三、只读副本注意事项
+
+```conf
+replica-read-only yes # 默认值
+```
+
+> [!WARNING] 禁止在 Replica 上写操作
+> 即使关闭 `read-only`,Replica 上的写入在主从重新同步时会被清掉。而且 `DEL` 一个不存在的 key 也会触发额外命令发送给 Master。
+
+### Replica 的其他关键配置
+
+```conf
+# --- 副本如何发现 Master ---
+replica-announce-ip 192.168.1.20 # 对外宣告的 IP(Docker/NAT 环境下必设)
+replica-announce-port 6379 # 对外宣告的端口
+
+# --- Replica 是否可被查询 ---
+replica-lazy-flush no # FULLRESYNC 后清空旧数据策略:no=立即 flush
+replica-serve-stale-data yes # Master 不可达时是否继续服务请求(默认是)
+replica-read-only yes # 只读保护
+
+# --- 副本同步相关 ---
+repl-diskless-sync no # 无盘复制开关
+repl-diskless-sync-delay 5 # 等待其他副本同时连接的延迟(秒)
+repl-timeout 60 # 同步超时阈值
+```
+
+## 四、核心参数调优参考
+
+> [!NOTE] 根据业务特点选择合适的参数组合
+
+### 方案 A:极致性能优先
+
+```conf
+# 适用于读 QPS > 50k,对一致性要求不高
+repl-backlog-size 256mb # 大容量 backlog 减少全量同步
+repl-diskless-sync yes # 无盘复制加速 RDB 传输
+replica-lazy-flush no # 确保同步前快速清理旧数据
+```
+
+### 方案 B:一致性优先
+
+```conf
+# 适用于金融、账务等场景
+WAIT 1 100 # 写入时确认至少 1 个副本成功
+repl-backlog-size 512mb # 更大缓冲区容纳更多增量命令
+```
+
+### 常用调试命令
+
+```bash
+# 查看当前复制状态
+redis-cli INFO replication
+
+# 输出示例:
+# role:master ← 或 replica
+# connected_slaves:3
+# master_repl_offset:12345678
+# repl_backlog_active:1
+# repl_backlog_size:268435456
+```
+
+## 五、Sentinel 哨兵机制
+
+### 架构组成
+
+```mermaid
+flowchart TD
+ S1["Sentinel-1"]
+ S2["Sentinel-2"]
+ S3["Sentinel-3"]
+
+ MasterNode["Master"]
+ RepNode["Replica"]
+
+ S1 <-->|心跳 PING/PONG| S2
+ S1 <-->|心跳 PING/PONG| S3
+ S2 <-->|心跳 PING/PONG| S3
+
+ S1 <--> MasterNode
+ S2 <--> MasterNode
+ S3 <--> MasterNode
+
+ MasterNode <--> RepNode
+```
+
+> [!QUESTION] 为什么需要奇数个 Sentinel?
+> Sentinel 选举采用多数派原则(Quorum)。3 个节点中 2 个达成共识即可;5 个中需要 3 个。偶数增加 1 个 Sentinel 不会带来额外投票优势,反而浪费资源。
+
+### 核心功能
+
+#### 1. 监控(Monitoring)
+
+```bash
+# Sentinel 定期向 Master/Replica 发 PING
+# Master 必须响应:PING → PONG 或 INFO
+# Replica 同理
+
+# 三种下线判断
+1. SDOWN (Subjectively Down) — 某个 Sentinel 认为不可达
+2. ODOWN (Objectively Down) — Quorum 数量的 Sentinel 都认为不可达
+3. Failover in progress — 进入故障转移流程
+```
+
+#### 2. 选主(Leader Election)
+
+```mermaid
+sequenceDiagram
+ participant S1 as Sentinel-1
+ participant S2 as Sentinel-2
+ participant S3 as Sentinel-3
+
+ S1->>S2: 提议自己为 leader (SEND ME YOUR VOTE)
+ S2->>S1: OK (投票给 S1)
+ S3->>S1: OK (投票给 S1)
+ S1->>S2: 我当选 leader
+ Note over S1,S3: ⚡ 任意 Sentinel 都可以主动发起选举
+```
+
+### 故障转移步骤
+
+当 Master 被判定 ODOWN 后,Sentinel Leader 执行以下流程:
+
+```mermaid
+flowchart TD
+ A["1. 选出最佳 Replica"] --> B["评估标准:
复制偏移量 > 优先级 > RunID"]
+ B --> C["SLAVEOF NO ONE
提升为新 Master"]
+ C --> D["其他 Replica → SLAVEOF new-master"]
+ D --> E["更新集群元数据"]
+ E --> F["通知客户端新地址"]
+```
+
+**最佳 Replica 选择标准**(按优先级排序):
+1. **复制偏移量最大** — 离最新数据的 Replica 优先
+2. **`replica-priority` 最小** — 值越小越优先当选(0 = 不可当选)
+3. **Run ID 字典序最小** — 作为最终 tie-breaker
+
+### Sentinel 配置文件
+
+```conf
+sentinel monitor mymaster 192.168.1.10 6379 2
+# ↑ name ↑ host port quorum(达成SDOWN共识的个数)
+
+sentinel auth-pass mymaster your-password
+
+# 多久无响应即标记 SDOWN(毫秒)
+sentinel down-after-milliseconds mymaster 30000
+
+# 故障转移超时(毫秒),超过则失败重试
+sentinel failover-timeout mymaster 180000
+
+# 最多多少 Replica 同时与新 Master 同步
+sentinel parallel-syncs mymaster 1
+```
+
+> [!WARNING] Sentinel 配置陷阱
+> - `down-after-milliseconds` 不要设太低(如 1s),网络抖动会导致误判;建议 **15~30 秒**
+> - `failover-timeout` 不宜太短,防止频繁重试加重系统负载
+> - `parallel-syncs` 设为 1 可避免转移期间所有 Replica 同时不可用
+> - **生产环境建议手动维护 Sentinel 配置**,避免自动感知的不确定性
+> - Redis 6+ 启用 ACL 后,需配置 `sentinel user/myuser password/mypass` 而非 `auth-pass`
+
+### Sentinel 下的客户端连接
+
+Go 和 Python 生态都提供了原生支持——客户端内部自动发现 Master/Replica,故障时重新连接。
+
+```go
+// go-redis: 原生 Sentinel 模式
+rdb, err := redis.NewFailoverClient(&redis.FailoverOptions{
+ MasterName: "mymaster",
+ SentinelAddrs: []string{"sento1:26379", "sento2:26379", "sento3:26379"},
+ Password: "your-password",
+ MaxRetries: 3,
+ RetryDelay: 500 * time.Millisecond,
+})
+// 读请求走 Replica,写请求走 Master
+_ = rdb.Get(ctx, "key")
+```
+
+```python
+# redis-py Sentinel
+from redis.sentinel import Sentinel
+
+sentinel = Sentinel([('sento1', 26379), ('sento2', 26379), ('sento3', 26379)])
+master = sentinel.master_for('mymaster', password='your-password') # 写
+slave = sentinel.slave_for('mymaster', password='your-password') # 读
+```
+
+## 五、生产实践 checklist
+
+### 部署前
+
+- [ ] **Sentinel 节点数 ≥ 3**,奇数个,部署在不同可用区或机器上
+- [ ] **Master + Replica 数量建议 1:2 ~ 1:3**,过多会影响同步延迟
+- [ ] **`replica-priority` 设值**:不可作为 Master 的设为 `0`
+- [ ] **持久化策略一致**:Master 和 Replica 的 RDB/AOF 策略应相同
+
+### 监控项
+
+```bash
+# 关键指标(每秒采样)
+INFO replication # connected_slaves, master_repl_offset
+INFO memory # used_memory_human (同步期间会突增)
+INFO stats # instantaneous_ops_per_sec
+```
+
+### 故障排查流程
+
+```mermaid
+flowchart TD
+ A["发现 Master 异常"] --> B{"Sentinel 是否已 ODOWN?"}
+ B -->|"是"| C["自动 Failover 中..."]
+ B -->|"否"| D["检查网络 / CPU / 大 Key 阻塞"]
+ C --> E["Failover 成功?"]
+ E -->|"是"| F["客户端已自动切换 ✓"]
+ E -->|"否"| G["手动干预:
SENTINEL FAILOVER mymaster"]
+ D --> H["问题解决后
重新加入复制拓扑"]
+```
+
+### 常见坑点汇总
+
+| 坑 | 现象 | 解决 |
+|---|------|------|
+| Docker 环境下 IP 错乱 | Sentinel 记录了容器的内部 IP | 设置 `replica-announce-ip` |
+| 脑裂(Split Brain) | 网络分区后 Master 仍处理写请求 | 用 `min-replicas-to-write 1` 保护 |
+| OOM during BGSAVE | fork 子进程导致内存翻倍 | 关闭 THP,增大 swap,限制 maxmemory |
+
+> [!NOTE] 防脑裂配置
+> ```conf
+> # Master 端配置:至少 N 个 Replica 在线才接受写入
+> min-replicas-to-write 1
+> # 对应的最大允许延迟(秒),超时视为掉线
+> min-replicas-max-lag 10
+> ```
+
+## 六、局限性与替代方案
+
+| 问题 | Sentinel 无法解决 |
+|------|----------------|
+| 单 Master 写入瓶颈 | ❌ 只有一个 Master |
+| 数据量超限单机容量 | ❌ |
+| 跨机房容灾 | ❌(Sentinel 跨网段延迟太高) |
+
+> [!TIP] 需要水平扩展?看 Cluster 方案 → [[hhs/Redis/07-集群方案]]
+
+## 关联笔记
+
+- [[hhs/Redis/07-集群方案]] — Cluster 架构(水平扩展)
+- [[hhs/Redis/09-运维调优]] — 监控与告警配置
+- [[hhs/Redis/04-AOF持久化]] — AOF 与 RDB 结合的高可用策略
+- [[hhs/EXAM/Week05]] — Docker Compose 中的部署示例
diff --git a/hhs/Redis/07-集群方案.md b/hhs/Redis/07-集群方案.md
new file mode 100644
index 0000000..8272d68
--- /dev/null
+++ b/hhs/Redis/07-集群方案.md
@@ -0,0 +1,404 @@
+---
+tags: [Redis, 缓存, 高可用, Cluster, 分布式]
+create time: 2026-05-15 18:14
+---
+
+# Redis Cluster 集群方案
+
+## 概述
+
+Redis Cluster 是 Redis 官方的**分布式**解决方案,通过数据分片(sharding)将数据分布在多个节点上,支持横向扩展和自动故障转移。核心设计:**哈希槽(Hash Slots)**而非一致性哈希。
+
+```mermaid
+flowchart TD
+ C1["Client"] --> N1["Node A: slot 0~5460"]
+ C1 --> N2["Node B: slot 5461~10922"]
+ C1 --> N3["Node C: slot 10923~16383"]
+
+ N1 -->|"复制"| NA["Node A Replica"]
+ N2 -->|"复制"| NB["Node B Replica"]
+ N3 -->|"复制"| NC["Node C Replica"]
+```
+
+## 哈希槽机制
+
+### 为什么不用一致性哈希?
+
+| 对比项 | 一致性哈希 | 哈希槽 |
+|--------|-----------|---------|
+| 增节点迁移量 | O(N/K × log N) | **O(1/N × total_slots)** = 每次 ~1300 slots |
+| 客户端透明性 | ✅ 通常透明 | ⚠️ 需要 MOVED/ASK 重定向 |
+| 数据倾斜 | 依赖虚拟节点 | ❌ slots 分配不均会倾斜 |
+| 复杂度 | 简单 | 略复杂(Gossip + 槽位管理) |
+
+> [!QUESTION] 思考
+> Redis 选择了 16384 个槽而不是其他数字,你觉得为什么是这个数?太少了会怎样?太多了又会怎样?
+
+### 槽位计算
+
+Redis 使用 **CRC16 算法**对 key 取模,结果映射到 0~16383 共 16384 个槽:
+
+```bash
+# CRC16(key) % 16384 = slot 编号
+# 例:CRC16("foo") % 16384 = 5798 → 属于 Node B (5461~10922)
+```
+
+```go
+// Go redis 库内部自动计算,开发者无需关心
+rdb.Set(ctx, "foo", "bar", 0) // 自动路由到对应节点
+```
+
+> [!TIP] 多 key 在同一节点的技巧——用 `{}` 包裹 tag:
+> ```
+> SET {user:100}:name alice → slot X
+> SET {user:100}:age 25 → 同 slot X
+> SADD {user:100}:friends user:200
+> ```
+> 这样 `user:100` 的所有操作都在同一个节点上,支持 MULTI/LUA 事务。
+
+> [!NOTE] hash tag 的边界
+> - `{user:100}:name` 中只有 `{user:100}` 部分参与 hash 计算,`{}` 后面的 `:name` 是实际 key 的一部分。
+> - 如果 key 中没有 `{}`,则整个 key 参与 hash 计算。
+> - 务必确保 tag 逻辑正确,否则同一用户的不同属性可能分散在不同节点,导致事务失败。
+
+## 通信协议与 Gossip 协议
+
+### 节点间通信端口
+
+```
+节点通信端口 = client_port + 10000
+例如: 6379 → 16379 (cluster bus)
+```
+
+> [!WARNING] 防火墙配置
+> 生产环境中必须开放 `port` 和 `port+10000` 两个端口。很多云服务器安全组默认只开一个端口,会导致集群组建失败或节点状态异常。
+
+### Gossip 协议运作方式
+
+每个节点每秒向其他节点发送 PING/PONG,维持集群状态的一致性:
+
+```mermaid
+sequenceDiagram
+ participant A as Node A
+ participant B as Node B
+
+ loop 每秒钟
+ A->>B: PING (包含自身状态 + 随机选 3 个其他节点)
+ B->>A: PONG (含自身 + 选中节点的状态)
+ end
+
+ Note over A: Gossip 收敛原理:
每次随机选 3 个节点交换信息,
N 个节点 ~ log₃(N) 轮即可全同步
+```
+
+**Gossip 传播的三类信息:**
+1. **PING/PONG** — 心跳,携带发送者的状态摘要
+2. **MEET** — 手动加入集群,向指定节点发送 MEET 指令
+3. **FAIL** — 某节点不可达,广播给其他节点
+
+> [!QUESTION] 思考
+> 为什么 Gossip 不直接发给所有节点,而是随机选 3 个?当集群扩展到 1000 台节点时,这样做的好处是什么?
+
+### PING/PONG 报文详解
+
+```
+PING 报文体包含:
+├── 发送节点的 nodeId
+├── 槽位分配表(哪些节点负责哪些 slot)
+├── 节点状态(master/slave/fail)
+├── 最后通信时间戳
+└── 当前集群 epoch(用于版本控制)
+```
+
+## 数据迁移流程
+
+### 移动一个 slot(跨节点)
+
+```bash
+# 在源节点执行(准备阶段)
+CLUSTER SETSLOT MIGRATING
+
+# 在目标节点执行(确认阶段)
+CLUSTER SETSLOT IMPORTING
+
+# 逐 key 迁移(客户端负责搬 data)
+for key in $(redis-cli -h source-cluster-scan $slot --count 100); do
+ redis-cli -h source MIGRATE target "" $key 0 5000
+done
+
+# 完成迁移
+CLUSTER SETSLOT NODE
+```
+
+### 自动化迁移 —— redis-cli cluster reshard
+
+```bash
+redis-cli --cluster reshard node-host:6379 \
+ --cluster-from \
+ --cluster-to \
+ --cluster-slots 5461 \
+ --cluster-yes
+```
+
+> [!WARNING] reshard 期间服务不中断
+> 但涉及迁移的 slot 在高负载下可能有额外延迟,低峰期操作更稳妥。
+
+## 创建集群
+
+### 手动创建(3 Master + 3 Replica)
+
+```bash
+# 1. 启动 6 个实例(不同端口)
+for port in $(seq 7001 7006); do
+ mkdir -p /tmp/$port && redis-server \
+ --port $port --cluster-enabled yes \
+ --appendonly yes --cluster-config-file nodes.conf \
+ --cluster-node-timeout 5000 \
+ --loglevel warning > /tmp/$port/redis.log 2>&1 &
+done
+
+# 2. 一行命令组建集群
+redis-cli --cluster create \
+ 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \
+ 127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \
+ --cluster-replicas 1
+```
+
+### Docker Compose(开发环境)
+
+```yaml
+version: "3.9"
+services:
+ redis-1:
+ image: redis:7-alpine
+ command: redis-server --cluster-enabled yes --appendonly yes
+ ports: ["7001:7001"]
+ volumes: ["redis-data-1:/data"]
+ # ... 重复 redis-2 ~ redis-6
+volumes:
+ redis-data-1:
+ # ...
+```
+
+### 连接集群客户端
+
+```go
+import "github.com/redis/go-redis/v9"
+
+rdb := redis.NewClusterClient(&redis.ClusterOptions{
+ Addrs: []string{":7001", ":7002", ":7003", ":7004", ":7005", ":7006"},
+ Password: "your-password",
+ MaxRetries: 3,
+})
+defer rdb.Close()
+
+// SET/GET 自动路由到正确的节点
+rdb.Set(ctx, "key", "value", 0)
+val, _ := rdb.Get(ctx, "key").Result()
+```
+
+## Cluster 的局限性
+
+| 限制 | 说明 | 替代方案 |
+|------|------|---------|
+| 不支持多数据库 | 固定只有 db 0 | 应用层模拟 db(key 前缀) |
+| 命令子集受限 | `KEYS`, `MIGRATE`, `SORT` 等不可用 | 用 `SCAN` 替代 `KEYS` |
+| Lua 脚本有限 | 不支持非确定性的函数(time/random) | 使用 `EVALSHA` 减少网络传输 |
+| Pipeline 受限 | 同一 pipeline 中所有 key 必须在同一节点 | 用 hash tag `{user:100}:x` 对齐 |
+| MULTI/EXEC 受限 | 同上,跨 key 事务不支持 | 用 Lua 脚本实现原子操作 |
+
+> [!TIP] 解决 Pipeline 跨节点问题
+> ```go
+> // ❌ 错误:key 分布在不同节点
+> pipe.Pipeline().
+> Get(ctx, "user:1:name").
+> Get(ctx, "user:2:name")
+>
+> // ✅ 正确:用 hash tag 对齐到同一节点
+> pipe.Pipeline().
+> Get(ctx, "{user:1}:name").
+> Get(ctx, "{user:1}:age")
+> ```
+
+## 运维命令速查
+
+```bash
+# 集群状态
+redis-cli --cluster check 127.0.0.1:7001
+
+# 查看节点信息
+redis-cli -p 7001 CLUSTER NODES
+# 输出格式: nodeId ip:port@busPort flags master/slot-range self
+
+# 示例输出解析
+e3a1b2c3... 192.168.1.10:7001@17001 master - 0 1715000000000 1 connected 0-5460
+# ↑ node-id ↑ addr ↑ bus-port ↑ role ↑ epoch ↑ slots
+
+# 统计各节点 key 数量
+redis-cli -p 7001 DBSIZE
+redis-cli -c -p 7001 --cluster call DBSIZE # 在所有节点执行
+
+# 强制某个 slot 下线(紧急情况)
+redis-cli --cluster fix 127.0.0.1:7001
+```
+
+## 扩容指南
+
+```mermaid
+flowchart LR
+ Before["3 Master
各 5461 slots"] -->|添加 3 新节点| Middle["6 Master
slot 全空"]
+ Middle -->|reshard 迁 slot| After["6 Master
各 ~2730 slots"]
+ After -->|加 replica| Final["6 Master + 6 Replica"]
+```
+
+> [!TIP] 扩容节奏建议
+> 1. 先加 Master 并分 slot
+> 2. 再给每个 Master 配 Replica
+> 3. 分批迁移(每次 1~2 台),避免流量剧变
+
+## 故障转移机制(Failover)
+
+> [!QUESTION] 思考
+> 假设一个 Master 宕机了,谁来决定哪个 Replica 接任?如果让客户端来判断,会有什么问题?
+
+### 故障检测三阶段
+
+```
+┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+│ PFAIL 潜在失效 │ → │ FAIL 确认失效 │ → │ 选举 Leader │ → Failover
+│ (单个节点判定) │ │ (多数节点同意) │ │ (Replica 竞选) │
+└─────────────┘ └─────────────┘ └─────────────┘
+ ↓ 超时 ↓ 多数派 ↓ Raft 式投票
+ cluster-node-timeout 超过半数标记 最高优先级获胜利
+```
+
+**详细流程:**
+
+| 阶段 | 谁触发 | 条件 | 持续时间 |
+|------|--------|------|---------|
+| **PFAIL** | 单个节点 | 超过 `cluster-node-timeout` 未收到 PONG | 瞬时 |
+| **FAIL** | PFAIL 节点广播 | 大多数 Master 同意该节点不可达 | 秒级 |
+| **选举** | 宕机 Master 下唯一存活 Replica | 发起集群纪元递增 + Raft 投票 | 秒级 |
+| **Failover** | 当选 Leader | 提升为 Master,接管原 Master 的 slots | 亚秒级 |
+
+### 选举规则
+
+1. **只有 Replica 能参与选举**,Master 不会主动抢占
+2. **选举时机**:Replica 发现自己复制的 Master 进入 FAIL 状态
+3. **Raft 式投票**:每个 Master 一票,先到先得
+4. **获胜条件**:获得多数 Master 的选票
+5. **升级步骤**:
+ - 将自己提升为 Master
+ - 接管所有原 Master 管理的 slots
+ - 向所有节点广播新的配置
+
+> [!NOTE] 脑裂保护
+> 如果网络分区导致两个节点的 Master 都认为对方宕机,各自选举出新的 Master,Redis 会通过 **epoch 版本号**解决冲突——后升级的那个会被回退,这是为了避免「脑裂」写丢失。
+
+### 客户端容错处理
+
+集群模式下客户端会收到两种重定向响应:
+
+```go
+// MOVED: 永久重定向(槽位已迁移到新节点)
+// 客户端应更新拓扑,后续请求直接发到目标节点
+MOVED 3999 192.168.1.20:7002
+
+// ASK: 临时重定向(正在迁移中)
+// 单次请求发送到目标节点,后续仍发原节点
+ASK 3999 192.168.1.20:7002
+```
+
+> [!TIP] 重试策略要点
+> - MOVED 后更新本地路由表,**不再重试旧节点**
+> - ASK 只重试**这一次**,之后恢复原来的路由
+> - 如果连续收到 MOVED/ASK,说明集群正在剧烈变更,适当退避
+
+## 软客户端 vs 硬客户端
+
+> [!QUESTION] 思考
+> 客户端拿到第一个 MOVED 响应后才去查询新拓扑,还是从一开始就维护完整的集群地图?哪种方式在扩容时更稳定?
+
+### 两种模式对比
+
+| 特性 | 软客户端(Lazy Mode) | 硬客户端(Eager Mode) |
+|------|---------------------|----------------------|
+| 初始化行为 | 连接任一节点获取初始拓扑 | 预取所有节点完整拓扑 |
+| 拓扑更新 | 遇到 MOVED/ASK 时按需刷新 | 后台定时拉取,提前感知变化 |
+| 扩容时体验 | 迁移过渡期频繁 MOVED,请求偶发延迟 | 平滑无感,提前知道新 slot 在哪 |
+| 内存开销 | 仅存储已知节点 | 存储全部节点 + slot→node 映射表 |
+| 代表库 | lettuce(默认 lazy) | go-redis, jedis(内置拓扑缓存) |
+
+```go
+// go-redis 示例:硬客户端,自动维护拓扑
+rdb := redis.NewClusterClient(&redis.ClusterOptions{
+ Addrs: []string{":7001", ":7002", ":7003"},
+ PoolSize: 20, // 每个节点独立连接池
+ MinIdleConns: 5, // 保持最小空闲连接
+ ReadTimeout: 3 * time.Second, // 读超时
+ WriteTimeout: 3 * time.Second, // 写超时
+ IdleTimeout: 5 * time.Minute, // 连接空闲回收
+})
+
+// 连接池隔离:每个节点有独立的连接池,互不影响
+// GET 请求 go-redis 会自动判断 key 所在的 slot 并路由到正确节点
+val, err := rdb.Get(ctx, "user:100:name").Result()
+```
+
+```go
+// lettuce 示例:软客户端,按需刷新拓扑(Java/Spring)
+let cluster = RedisCluster.connect(url)
+// 遇到 MOVED 时自动重新发现拓扑
+let val = await cluster.get("key")
+```
+
+> [!TIP] 推荐配置
+> 生产环境建议采用硬客户端 + 合理连接池大小。每个节点独立连接池可以避免热点 key 占满单个节点的连接资源。
+
+## 生产部署关键注意点
+
+### 网络与安全
+
+| 项目 | 建议值/做法 | 原因 |
+|------|------------|------|
+| 防火墙 | 开放 `port` 和 `port+10000` | 集群总线通信依赖双端口 |
+| TLS | Redis 7.0+ 支持 TLS(需编译开启) | 加密集群总线通信,防止拓扑泄露 |
+| ACL | Redis 6+ 启用 ACL 认证 | 替代传统 AUTH,更细粒度权限控制 |
+| bind | 绑定内网 IP,不暴露公网 | 集群总线不鉴权,外网可控制整个集群 |
+
+### 参数调优
+
+| 参数 | 推荐值 | 说明 |
+|------|--------|------|
+| `cluster-node-timeout` | 15000 ms | 不要太短,避免网络抖动误判;不要长于业务容忍度 |
+| `maxmemory-policy` | `allkeys-lru` | 集群场景统一设置 LRU 淘汰,避免部分节点 OOM |
+| `appendonly` | `yes` | 集群推荐 AOF + 每秒刷盘,兼顾性能与安全 |
+| `cluster-replica-validity-factor` | 10 | 乘以 timeout 作为复制链路容忍阈值,0 表示不拒绝主从链接 |
+
+### 架构最佳实践
+
+```
+┌─────────────────────────────────────────────┐
+│ 你的应用服务 │
+└──┬──────────┬──────────┬──────────┬──────────┘
+ │ │ │ │
+┌──▼────┐ ┌──▼────┐ ┌──▼────┐ ┌──▼────┐
+│Service A│ │Service B│ │Service C│ │Service D│
+│ :7001-7 │ │ :7004-7 │ │ :7010-7 │ │ :7016-7 │
+└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
+ │ │ │ │
+ Cluster 1 Cluster 2 Cluster 3 Cluster 4
+ (独立集群) (独立集群) (独立集群) (独立集群)
+```
+
+> [!IMPORTANT] 原则
+> 1. **一服一群**:不同业务线使用独立集群,避免互相干扰
+> 2. **奇数 Master**:选举需要多数派,偶数 Master 不如少一个 Master 多一个 Slave
+> 3. **至少 3 Master + 3 Replica**:单 Master 没有高可用意义
+> 4. **数据隔离**:不要用 `FLUSHALL`,集群里会影响所有节点
+> 5. **大 key 慎用**:集群下大 key 无法拆分,会导致单个节点内存/带宽瓶颈
+
+## 关联笔记
+
+- [[hhs/Redis/06-主从与哨兵]] — Sentinel 单主高可用方案
+- [[hhs/Redis/09-运维调优]] — 监控指标与告警
+- [[hhs/Redis/README]] — 知识索引总览
diff --git a/hhs/Redis/08-SortedSet精解.md b/hhs/Redis/08-SortedSet精解.md
new file mode 100644
index 0000000..077bf4f
--- /dev/null
+++ b/hhs/Redis/08-SortedSet精解.md
@@ -0,0 +1,351 @@
+---
+tags: [Redis, 缓存, 数据结构, SortedSet]
+create time: 2026-05-15 18:14
+---
+
+# Sorted Set 高级玩法
+
+## 概述
+
+Sorted Set(有序集合)是 Redis 中**功能最丰富、场景最广泛**的数据结构。它的底层由 skiplist + hashtable 组成,天然支持按分数排序和 O(1) 的成员查找。本节聚焦实战中常用的高级模式。
+
+## 排行榜系统
+
+### 基础实现
+
+```bash
+# 添加用户分数
+ZADD leaderboard 3500 "user:101"
+ZADD leaderboard 4200 "user:102"
+ZADD leaderboard 3800 "user:103"
+
+# 获取 Top 5
+ZREVRANGE leaderboard 0 4 WITHSCORES
+# 返回:user:102(4200), user:103(3800), user:101(3500)
+
+# 查询排名(从大到小)
+ZREVRANK leaderboard "user:101" # 2
+
+# 获取指定分数范围的用户
+ZRANGEBYSCORE leaderboard 3000 4000
+```
+
+### Go 代码示例
+
+```go
+// 更新用户分数(原子加分)
+rdb.ZIncr(ctx, "leaderboard", redis.Z{
+ Score: 50.5,
+ Member: "player:" + userID,
+})
+
+// 获取排名 + 分数区间
+rank, _ := rdb.ZRevRank(ctx, "leaderboard", "player:"+userID).Result()
+members, _ := rdb.ZRangeWithScores(ctx, "leaderboard", rank-4, rank).Result()
+// 展示上下各 2 名的分数差距
+```
+
+```mermaid
+flowchart TD
+ S["新得分到达"] --> A{"用户是否已有排名?"}
+ A -->|否| B["ZADD leaderboard score member"]
+ A -->|是| C["ZINCRBY leaderboard delta member"]
+ C --> D["计算前后排名差"]
+ D --> E["推送排名变化通知"]
+```
+
+> [!TIP] ZRANK vs ZREVRANK
+> - `ZRANK`:升序排名,最小值排第 0 位(适合延迟队列)
+> - `ZREVRANK`:降序排名,最大值排第 0 位(适合排行榜)
+
+### 多维度排行榜技巧
+
+同一份数据需要按不同维度排名时,不要用多个 ZSet——用 **score = base + fraction**:
+
+```
+总分 = 胜场 × 10000 + 击杀数
+例: 5胜3杀 → score = 50003
+→ 先比胜率,再比击杀数,完美映射为单浮点数
+```
+
+## 延迟队列
+
+### 原理
+
+利用 ZSet 的 score 字段存储执行时间戳,通过 `BZPOPMIN`(阻塞弹最低分)定时拉取到期的任务:
+
+```bash
+# 提交延迟任务(5秒后执行)
+ZADD delayed_tasks 1715000005 "task:sms:user:42"
+
+# 消费者循环拉取
+BZPOPMIN delayed_tasks 0 # 超时为 0,一直等待
+# 返回: ["delayed_tasks", "task:sms:user:42", "1715000005"]
+```
+
+```go
+// Go 伪代码——定时轮询模型
+for {
+ results, err := rdb.BZPopMin(ctx, time.Second, "tasks").Result()
+ if err == redis.Nil {
+ continue // 无任务,继续等
+ }
+ executeTask(results.Value.(string))
+}
+```
+
+> [!QUESTION] 为什么不用 Timer 而用 ZSet?
+> - Timer/chan 只能单机内使用,分布式场景下多节点无法共享
+> - ZSet 可以跨节点消费同一个延迟队列
+> - 配合 Leader Election 可以实现可靠的多消费者竞争
+
+### 精度与漂移
+
+```bash
+# 毫秒级精度(Unix timestamp in ms)
+ZADD delayed_tasks $(($(date +%s%3N) + 5000)) "task:id:123"
+
+# 检查是否有到期任务(不阻塞)
+ZRANGEBYSCORE delayed_tasks -inf $(date +%s%3N)
+# 然后把结果移到处理队列
+ZREM delayed_tasks
+LPUSH processing_queue
+```
+
+> [!WARNING] 延迟队列不是消息队列
+> 对于大量并发任务,ZSet 的 O(log N) 插入和维护成本会累积。考虑专用 MQ(Kafka/RabbitMQ)更合适。Redis ZSet 适合**规模适中(万级以内)**的延迟任务场景。
+
+## 滑动窗口去重计数
+
+ZSet 天然适合做**固定时间窗口内的去重计数**(如每分钟独立访客):
+
+```bash
+# key = page:home:uv,score = unix timestamp ms,member = visitor_id
+ZREMRANGEBYSCORE page:home:uv $now_ms 60000 # 清除 60s 前的数据
+ZADD page:home:uv $now_ms visitor:a visitor:b visitor:c
+SCARD page:home:uv # 当前窗口内独立访客数
+```
+
+```go
+// Go —— 每请求一次做一次清理 + 添加
+rdb.ZRemRangeByScore(ctx, "page:home:uv", "0", now.Add(-time.Minute).UnixMilli())
+rdb.ZAdd(ctx, "page:home:uv", redis.Z{Score: float64(now.UnixMilli()), Member: visitorID})
+count, _ := rdb.ZCard(ctx, "page:home:uv").Result()
+```
+
+> [!TIP] ZSet vs Bitmap vs HyperLogLog — 计数器选型
+> | 方案 | 精度 | 内存(百万UV) | 支持查询明细 |
+> |------|------|---------------|-------------|
+> | ZSet | ✅ 精确 | ~60 MB | ✅ member = 用户 ID |
+> | Bitmap | ✅ 精确 | ~125 KB | ❌ 不可逆 |
+> | HyperLogLog | ⚠️ ±0.1% | 12 KB | ❌ 不可逆 |
+>
+> **选法**: 需要查"谁来过"或用 score 做时间分析 → ZSet;纯计数、极大规模 → Bitmap / HLL。
+
+## 滑动窗口限流器
+
+用 `ZREMRANGEBYSCORE` + `ZCARD` 实现经典的**固定窗口 / 滑动窗口限流**:
+
+```bash
+# === 固定窗口限流 ===
+# key = limit:user:1001, score = unix timestamp(每秒一个请求)
+ZREMRANGEBYSCORE limit:user:1001 $now_ts $(($now_ts - 1)) # 清除 1s 前数据
+ZADD limit:user:1001 $now_ts req:$RANDOM # 记录本次
+ZCARD limit:user:1001 # 当前窗口 QPS
+
+# === 滑动窗口限流(更精准)===
+WINDOW_MS=1000
+ZREMRANGEBYSCORE ratelimit:user:1001 0 $(($(date +%s%3N) - WINDOW_MS))
+ZADD ratelimit:user:1001 $(date +%s%3N) $(date +%s%3N):$$
+ZCARD ratelimit:user:1001 # ≤ 100 则放行,否则拒绝
+```
+
+```go
+const maxReqPerSec = 100
+
+func SlidingWindowLimit(rdb *redis.Client, ctx context.Context, userID string) bool {
+ now := time.Now().UnixMilli()
+ windowStart := now - 1000 // 1 秒窗口
+
+ pipe := rdb.Pipeline()
+ pipe.ZRemRangeByScore(ctx, "rl:"+userID, "0", fmt.Sprint(windowStart))
+ pipe.ZAdd(ctx, "rl:"+userID, redis.Z{Score: float64(now), Member: fmt.Sprint(now)})
+ pipe.ZCard(ctx, "rl:"+userID)
+
+ results, err := pipe.Exec(ctx)
+ if err != nil {
+ return false // 系统异常时保守拒绝
+ }
+ count := results[2].(*redis.IntCmd).Val()
+ return count <= maxReqPerSec
+}
+```
+
+> [!NOTE] Redisson 的滑动窗口限流器
+> Redisson 提供了开箱即用的 `RRateLimiter`,内部正是基于 ZSet + Lua 脚本实现的 **multi-window** 模型——把 1 秒拆成多个子窗口来平滑过渡,减少边界突刺。生产环境建议优先用成熟客户端库。
+
+```mermaid
+flowchart LR
+ subgraph W["滑动窗口限流流程"]
+ A["请求到达"] --> B["删除窗口外过期成员"]
+ B --> C["写入当前请求时间戳"]
+ C --> D{"数量 ≤ 上限?"}
+ D -->|是| E["✅ 放行"]
+ D -->|否| F["❌ 限流拒绝"]
+ end
+```
+
+## Top-N 实时聚合
+
+业务需要合并多个排行榜取**全局 Top-K** 时,用 `ZUNIONSTORE` / `ZINTERSTORE`:
+
+```bash
+# 已有两个分区的排行榜
+ZADD rank:cn 1000 "vip_user:A" 500 "user:B"
+ZADD rank:us 800 "vip_user:A" 900 "user:C"
+
+# 合并求和(默认分数相加)
+ZUNIONSTORE global:top 2 rank:cn rank:us AGGREGATE SUM
+
+# 取全局 Top 10
+ZRANGE global:top 0 9 WITHSCORES REVERSE
+# → vip_user:A(1800), user:C(900), user:B(500)...
+```
+
+```go
+keys := []string{"rank:cn", "rank:us", "rank:jp"}
+// 合并三个区域排行榜,取分数最高的 20 名
+rdb.ZUnionStore(ctx, "global:top20", keys)
+top20, _ := rdb.ZRevRangeWithScores(ctx, "global:top20", 0, 19).Result()
+```
+
+> [!WARNING] ZUNIONSTORE / ZINTERSTORE 阻塞风险
+> 当参与合并的集合都很大时,这两个命令是 O(N+M) 且在主线程执行。**最佳实践**:将结果写入另一个 ZSet,定时(如每分钟)异步刷新。
+
+## Geo — 地理位置
+
+### 基本操作
+
+```bash
+# 添加位置
+GEOADD cities 116.4074 "beijing" 121.4737 "shanghai" 108.9690 "nanning"
+
+# 计算两点距离(米)
+GEOPOS cities beijing shanghai # 获取经纬度坐标
+GEODIST cities beijing nanning km # 距离(km/m/fi 单位)
+
+# 附近的人(半径 50km 内)
+GEORADIUS cities 116.4074 39.9042 50 km WITHDIST WITHCOORD
+
+# 新版本 API(推荐)
+GEORADIUSBYMEMBER cities shanghai 100 km DESC COUNT 5
+# DESC: 从远到近(默认从近到远)
+```
+
+### 常见场景
+
+| 场景 | 命令 | 注意 |
+|------|------|------|
+| 附近门店 | GEORADIUSBYMEMBER | 限流 + 分页 |
+| 打车接单范围 | GEOADD + GEORADIUS | 结合 spatial index |
+| 围栏检测 | GEOPOS + 数学计算 | 超出范围触发告警 |
+
+> [!TIP] Geo 底层就是 Sorted Set
+> `GEOPOS` 本质是 `ZSCORE`(存的是 encode 后的 lat+lng),所以也可以用 ZREVRANGE 做通用查询。Geo 只是一层语法糖。
+
+## 社交关系 —— 共同好友 / 标签匹配
+
+### 基础交集
+
+用 **Set** 存储用户的兴趣标签,用 **ZSet** 做加权推荐:
+
+```bash
+# Set 存储原始标签(轻量,适合求交集)
+SADD interests:user:1 go redis docker k8s
+SADD interests:user:2 go python flask k8s
+
+# 共同兴趣
+SINTER interests:user:1 interests:user:2
+# → go, k8s
+```
+
+### 加权推荐(ZSet 方案)
+
+当每个标签有**权重分数**时,把标签转为 ZSet,利用 `ZUNIONSTORE` 计算匹配度:
+
+```bash
+# 用户兴趣 + 权重分数
+ZADD rec:user:1 "go" 1.0 "redis" 0.9 "docker" 0.8 "k8s" 0.7
+ZADD rec:user:2 "go" 0.95 "python" 1.0 "flask" 0.6 "k8s" 0.9
+
+# 合并两个用户的兴趣,分数相加 = 共同权重
+ZUNIONSTORE temp:rec 2 rec:user:1 rec:user:2 AGGREGATE SUM
+
+# 分数越高 = 共同兴趣越多越强
+ZRANGEBYSCORE temp:rec 1.5 2.0 REVERSE WITHSCORES
+# → go(1.95), k8s(1.6)
+```
+
+> [!WARNING] 大集合运算警告
+> `SINTER` / `SUNION` / `ZUNIONSTORE` 的时间复杂度是 O(N × M),在大型集合上可能阻塞主线程。建议预计算 + 定时刷新,或在低峰期异步完成。
+
+## 优先级队列
+
+### 用 ZSet 做优先级任务队列
+
+```bash
+# score = priority (数值越大优先级越高)
+# member = task JSON
+
+ZADD priority-queue 10 '{"id":"t1","action":"email"}'
+ZADD priority-queue 8 '{"id":"t2","action":"sms"}'
+ZADD priority-queue 15 '{"id":"t3","action":"push"}'
+
+# 取出最高优先级任务
+ZPOPMAX priority-queue
+# → {"id":"t3","action":"push"}
+
+# 或阻塞式(无任务时等待)
+BZPOPMAX priority-queue 5
+```
+
+## Sorted Set 性能边界
+
+| 指标 | 数据量 | 说明 |
+|------|--------|------|
+| 单个 ZSet 推荐上限 | 200 万成员 | 超过后写入延迟上升 |
+| skiplist 高度期望值 | O(log₂N) ≈ 20 | 平均跳板层数 |
+| 内存占用估算 | ~500 bytes/entry | 含 ziplist/skiplist + hashtable 开销 |
+
+### 百万级 ZSet 优化策略
+
+```mermaid
+mindmap
+ root((百万级 ZSet
优化策略))
+ 按业务维度拆分
+ 全国排行→按省份
+ rank:gd 广东
+ rank:js 江苏
+ rank:sz 四川
+ 各端独立
+ feed:recommend:app1
+ feed:recommend:app2
+ 预计算 + 缓存
+ ZUNIONSTORE→定时刷新
+ 低峰期异步合并
+ 游标分页
+ ZRANGEBYSCORE + min/max
+ 避免 OFFSET 深翻页
+ 编码降级
+ <64元素 <64B→ziplist
+ ≥128元素→skiplist+hashtable
+```
+
+> [!TIP] 编码转换的触发条件
+> `maxziplist` entries(默认 128)和 `maxziplistvalue`(默认 64 bytes)控制着 ziplist ↔ skiplist 的转变。在数据量较大时建议调大 `zset-max-ziplist-entries` 以利用 ziplist 的内存紧凑性,但要权衡查询性能。
+
+## 关联笔记
+
+- [[hhs/Redis/02-核心数据类型]] — ZSet 底层编码(ziplist → skiplist)
+- [[hhs/Redis/03-基本命令速查]] — ZSet 命令表
+- [[hhs/Redis/README]] — 知识索引总览
diff --git a/hhs/Redis/09-高级特性.md b/hhs/Redis/09-高级特性.md
new file mode 100644
index 0000000..5f7b89a
--- /dev/null
+++ b/hhs/Redis/09-高级特性.md
@@ -0,0 +1,406 @@
+---
+tags: [Redis, 缓存, 高级特性]
+create time: 2026-05-15 18:15
+---
+
+# Redis 高级特性
+
+## 概述
+
+掌握事务、Lua 脚本和 Pipeline,是 Redis 从「基础缓存」迈向「生产级系统」的关键。本节逐一剖析其原理、场景和陷阱。
+
+## 一、事务(MULTI / EXEC)
+
+### 基本用法
+
+```bash
+MULTI # 开启事务(不阻塞,之后命令只是排队)
+INCR counter # QUEUED
+INCR other_counter # QUEUED
+EXEC # 一次性执行所有排队的命令
+```
+
+```go
+pipe := rdb.TxPipeline() // TxPipeline ≠ MULTI,见下方说明
+pipe.Incr(ctx, "counter")
+pipe.Set(ctx, "key", "value", 0)
+results, _ := pipe.Exec(ctx)
+```
+
+> [!TIP] Go 的 TxPipeline vs MULTI
+> `rdb.TxPipeline()` 在 go-redis 中**并不发送 MULTI/EXEC**——它只是普通 pipeline。这是为了兼容 Cluster 环境(集群不支持事务)。两者区别:
+
+| 维度 | MULTI/EXEC | Pipeline |
+|------|-----------|----------|
+| 原子性 | ✅ 整体执行,中途不插队 | ❌ 命令独立发送执行 |
+| 错误处理 | 编译期错误全部拒绝;运行时错误逐个记录 | 每条返回各自结果 |
+| Cluster 支持 | ❌ | ✅(但需同 key tag) |
+| 性能 | 同 Pipeline | 批量发送减少 RTT |
+
+### 事务的错误处理
+
+```bash
+MULTI
+SET k v NX
+GET KEYY xxx # ⚠️ 语法错误!
+EXEC
+```
+
+| 错误类型 | 发生时 | 行为 |
+|---------|--------|------|
+| **编译时错误**(语法、参数) | EXEC 之前 | EXEC 拒绝执行整个事务 |
+| **运行时错误**(类型不匹配) | EXEC 期间 | 当前命令报错,其余继续执行 |
+
+```bash
+MULTI
+SET k 123 # OK, queued
+INCR k # OK, queued (先 SET 再 INCR 是合法的)
+EXEC
+# → [OK, 124] — 两个都成功了
+```
+
+> [!WARNING] Redis 事务 ≠ 数据库事务
+> - 没有 ROLLBACK / ACID 保证
+> - 不能捕获异常后回滚
+> - 如果需要强一致性,请用 **Lua 脚本**
+
+## 二、Lua 脚本
+
+### 为什么需要 Lua?
+
+核心目的:**原子性操作多个命令**。MULTI 只能保证"不插队",Lua 能保证"逻辑原子性"(读-判断-写一气呵成)。
+
+```bash
+EVAL "return redis.call('GET', KEYS[1])" 1 mykey
+# ↑ script ↑ commands ↑ key count ↑ keys...
+```
+
+### 经典场景 1:分布式锁
+
+```lua
+-- acquire_lock.lua
+-- KEYS[1] = lock key
+-- ARGV[1] = expire seconds (TTL)
+-- ARGV[2] = unique token (client ID)
+
+if redis.call('set', KEYS[1], ARGV[2], 'NX', 'EX', ARGV[1]) == 'OK' then
+ return 1 -- 成功获取
+end
+
+if redis.call('get', KEYS[1]) == ARGV[2] then
+ -- 已持有且未过期,原子性续期
+ redis.call('expire', KEYS[1], ARGV[1])
+ return 1
+end
+
+return 0 -- 被其他客户端持有
+```
+
+```go
+// Go 调用
+script := redis.NewScript(`
+ if redis.call("get", KEYS[1]) == false then
+ -- setex(key, seconds, value): ARGV[1]=过期秒数, ARGV[2]=唯一令牌
+ redis.call("setex", KEYS[1], ARGV[1], ARGV[2])
+ return 1
+ end
+ return 0
+`)
+
+ok, err := script.Run(ctx, c, []string{"lock:order:" + orderId},
+ lockTTL, clientToken).Int()
+if ok == 1 {
+ defer unlock(clientToken)
+ doBiz() // 临界区代码
+}
+```
+
+### 经典场景 2:限流器(令牌桶简化版)
+
+```lua
+-- rate_limit.lua
+local limit = tonumber(ARGV[1]) -- 每分钟上限
+local window = tonumber(ARGV[2]) -- 窗口秒数
+
+local current = tonumber(redis.call('get', KEYS[1]) or '0')
+
+if current >= limit then
+ return 0 -- 超限,拒绝
+end
+
+local pipe = redis.pipeline()
+pipe.incr(KEYS[1])
+pipe.expire(KEYS[1], window)
+pipe.exec()
+
+return 1 -- 通过
+```
+
+### 经典场景 3:订单状态机
+
+```lua
+-- order_status.lua
+-- KEYS[1] = order key
+-- ARGV[1] = from_status
+-- ARGV[2] = to_status
+
+local current = redis.call('hget', KEYS[1], 'status')
+if current ~= ARGV[1] then
+ return 0 -- 状态不匹配(第一个返回值:失败)
+end
+
+redis.call('hset', KEYS[1], 'status', ARGV[2])
+return 1 -- 成功(第一个返回值),后续通过 GET 读取新状态
+```
+
+### EVAL vs EVALSHA
+
+```bash
+EVAL "script..." numkeys args... # 每次传完整脚本
+EVALSHA numkeys args... # 只传 SHA1,省带宽
+```
+
+> [!TIP] 使用 EVALSHA 的最佳实践
+> ```go
+> script := redis.NewScript(...)
+> // 首次调用失败可能是 NOSCRIPT(缓存未命中),会自动重试 EVAL
+> result := script.Run(ctx, ...).Int()
+> ```
+> go-redis 内部已处理了 NOSCRIPT → EVAL fallback 的逻辑。
+
+### Lua 沙箱限制
+
+```lua
+-- 不可用:非确定性函数(传统 Redis)
+math.random() -- ❌ 无法预测结果
+os.date() -- ❌ 依赖系统时间
+time.time() -- ❌ Lua 标准库不存在
+
+-- ✅ 推荐做法:在客户端生成,通过 ARGV 传入
+-- local token = client.generateUUID()
+-- redis.call('set', KEYS[1], ARGV[1]) -- 带 token 写入
+
+> [!NOTE] Redis 7.2+ 更新
+> Redis 7.2 起开放了部分确定性数学函数:`math.random`(需自行 set seed)、`math.max`、`math.min`、`math.log`。但不建议在状态机类脚本中使用随机性。
+```
+
+> [!QUESTION] 为什么 Lua 脚本不能有随机函数?
+> Lua 脚本在主线程中同步执行,如果引入随机性或时间依赖,会导致两个严重问题:
+> 1. **主从不一致**:主节点执行返回 42,从节点重放却得到 87,数据分裂。
+> 2. **AOF 重放不可靠**:重启后从 appendonly.aof 重放脚本,结果与上次不同,状态机紊乱。
+> 因此 Redis 在 Lua 环境中屏蔽了所有非确定性 API,保证「同样的输入一定得到同样的输出」。
+
+## 三、Pipeline
+
+### 为什么需要 Pipeline?
+
+Redis 协议本质是 request/response,每个命令一次网络往返(RTT)。假设客户端到服务器 RTT 为 1ms,执行 1000 条命令:
+
+| 方式 | RTT 次数 | 总耗时估算 |
+|------|---------|-----------|
+| 单次发送 | 1000 | ~1s |
+| Pipeline (batch=200) | 5 次 | ~5ms |
+
+### Pipeline vs TxPipeline
+
+在 go-redis 中,有两种管道模式可用:
+
+| 方式 | 适用场景 | 注意事项 |
+|------|---------|---------|
+| `Pipeline` | 独立命令批量 | 不保证原子性 |
+| `TxPipeline` (go-redis) | 需要原子的批量 | 对应 MULTI/EXEC |
+
+```go
+// 方式一:普通 Pipeline(性能优化首选)
+pipe := rdb.Pipeline()
+pipe.Set(ctx, "user:1:name", "alice", 0)
+pipe.Set(ctx, "user:1:age", "25", 0)
+pipe.HSet(ctx, "user:1:profile", "email", "a@x.com")
+results, _ := pipe.Exec(ctx) // 返回 []redis.Result
+_ = results // 实际使用中检查 error
+
+// 方式二:Watch CAS(乐观锁 —— 读-改-写原子的核心模式)
+var balance int64
+err := rdb.Watch(ctx, func(tx *redis.Tx) error {
+ acc, err := tx.Get(ctx, "account:alice").Result()
+ if err != nil { return err }
+ balance, _ = strconv.ParseInt(acc, 10, 64)
+ return nil // 释放 Watch 锁
+}, "account:alice")
+if err != nil { /* NOSCRIPT / watch abort */ return }
+
+if balance < 100 {
+ log.Fatal("余额不足")
+}
+
+_, err = rdb.ExecFunc(ctx, func(tx *redis.Tx) error {
+ tx.DecrBy(ctx, "account:alice", 100)
+ tx.IncrBy(ctx, "account:bob", 100)
+ return nil
+}).Result()
+```
+
+> [!WARNING] Pipeline 的注意事项
+> - 单个 pipeline 内的命令可能跨多个节点(Cluster 模式下会分发执行)
+> - 避免在 pipeline 中混用 WATCH/MULTI(Cluster 不支持)
+> - 批量越大越好?**不是**——单 batch 建议 50~200 条,过大反而降低吞吐且增加超时风险
+
+### Pipeline 实战——数据迁移
+
+```go
+func BatchMigrate(ctx context.Context, rdb *redis.Client, srcKeys []string) error {
+ const batchSize = 100
+ pipe := rdb.Pipeline()
+
+ for i := 0; i < len(srcKeys); i += batchSize {
+ batch := srcKeys[i : min(i+batchSize, len(srcKeys))]
+
+ for _, key := range batch {
+ val, _ := rdb.Get(ctx, key).Result()
+ newKey := strings.TrimPrefix(key, "old:")
+ pipe.Set(ctx, newKey, val, 0)
+ }
+
+ _, err := pipe.Exec(ctx)
+ if err != nil {
+ return err
+ }
+ pipe.Close()
+ pipe = rdb.Pipeline() // 重建 pipeline 避免内存泄漏
+ }
+ return nil
+}
+```
+
+## 四、Pub/Sub —— 发布订阅
+
+### 基础用法
+
+```bash
+# 消费者 A
+SUBSCRIBE channel:newposts
+
+# 消费者 B(也可以 subscribe 同一频道)
+SUBSCRIBE channel:newposts
+
+# 发布者
+PUBLISH channel:newposts '{"id":42,"title":"Redis Tips"}'
+→ 消费者 A 和 B 同时收到消息
+```
+
+```go
+// go-redis 订阅者
+pubsub := rdb.Subscribe(ctx, "channel:newposts")
+defer pubsub.Close()
+
+ch := pubsub.Channel() // chan redis.Message,阻塞接收
+for msg := range ch {
+ fmt.Printf("收到: %s\n", msg.Payload)
+}
+
+// go-redis 发布者 — 无返回值,发出去就不管了
+rdb.Publish(ctx, "channel:newposts", `{"id":42,"title":"Redis Tips"}`)
+```
+
+> [!WARNING] Pub/Sub 是无状态的
+> - **连接级绑定**:每个 SUBSCRIBE 会独占一个 Redis 连接——线上建议限流并发 subscriber 数量
+> - **消息不持久化**:订阅者下线就丢失,重连不会收到离线期间的消息
+> - **适合场景**:WebSocket 广播、实时通知、配置变更推送
+
+### Stream —— 可靠的替代方案
+
+Stream 是 Redis 5.0+ 引入的消息队列数据结构,弥补了 Pub/Sub 的可靠性缺陷。核心设计围绕 **Consumer Group(消费者组)**:
+
+```bash
+# 生产者写入
+XADD mystream * user_id 123 action login ip 192.168.1.1
+
+# 创建消费者组
+XGROUP CREATE mystream group1 $ # $ = 从最新消息开始消费
+# XGROUP CREATE mystream group1 0 # 0 = 从头开始(生产环境慎用)
+
+# 消费者读取(阻塞模式,无消息时等待指定毫秒数)
+XREADGROUP GROUP group1 consumer1 COUNT 1 STREAMS mystream >
+
+# 确认消费完成(必须调用,否则该条消息会一直留在 pending 列表)
+XACK mystream group1
+```
+
+> [!TIP] `$` vs `>` —— 初学者最容易踩的坑
+> | 标识符 | 含义 | 使用场景 |
+> |--------|------|---------|
+> | `$` | "最后一条已写入的消息" | 新 consumer 加入时,只接收**将来**的消息 |
+> | `0` | "历史第一条" | 数据迁移/重放(谨慎用于大 stream) |
+> | `>` | "我只取未分配给我的新消息" | **正常消费循环中始终用 `>`**,不取到 pending 消息 |
+
+> [!QUESTION] 如果消费者挂了怎么办?
+> 好消息会被移到 **pending 列表**。用 `XPENDING mystream group1` 查看,用 `XCLAIM` 将 pending 消息转移给其他 consumer 重新处理。这就是 Stream 比 Pub/Sub 可靠的地方:**有状态、可追责**。
+
+```mermaid
+flowchart LR
+ P["Producer"] -->|"XADD"| S["Stream (持久化)"]
+ S -->|"读取并标记 pending"| G1["Consumer Group 1
consumer-a"]
+ S -->|"读取并标记 pending"| G2["Consumer Group 2
consumer-b"]
+ G1 -->|"XACK"| S
+
+ style S fill:#dfd,stroke:#090
+ style G1 fill:#ddf,stroke:#669
+ style G2 fill:#ddf,stroke:#669
+```
+
+> [!TIP] Pub/Sub vs Stream 选型
+> | 场景 | 推荐 |
+> |------|------|
+> | WebSocket 实时推送 | Pub/Sub |
+> | 任务队列、订单事件 | Stream |
+> | 海量消息、高吞吐 | Kafka/RabbitMQ |
+
+## 五、Keyspace Notifications —— 键事件监听
+
+### 原理与配置
+
+Redis 会在每个 key 发生变更时发送通知,订阅者通过 Pub/Sub 协议接收。开启方式:
+
+```conf
+# redis.conf(或运行时 CONFIG SET)
+notify-keyspace-events KEA
+# K = Keyspace events → 发布到 __keyspace@__ prefix
+# E = Keyevent events → 发布到 __keyevent@__ prefix
+# A = All events 简写 → 覆盖 g/l/s/h/z/x/e(不包含 K/E/d/t/n)
+# g = DEL/EXPIRE/RENAMExxx 等通用命令
+# l = list, s = set, h = hash, z = sorted-set
+# x = expired, e = evicted (maxmemory 淘汰)
+```
+
+```bash
+# 订阅过期事件(Keyevent 模式:更精确)
+PSUBSCRIBE __keyevent@0__:expired
+
+# 另一个连接设置过期 key
+EXPIRE test:key 2
+→ 收到: __keyevent@0__:expired "test:key"
+```
+
+```go
+// go-redis 监听器
+psub := rdb.PSubscribe(ctx, "__keyevent@0__:expired")
+defer psub.Close()
+
+ch := psub.Channel()
+for msg := range ch {
+ fmt.Printf("Key %s expired\n", msg.Payload)
+ // 可以触发清理逻辑、计数器归零等
+}
+```
+
+> [!WARNING] 生产环境注意事项
+> - **性能开销**:每条写命令都会生成通知,高 QPS + 全量监听 (`KAE`) 会显著增加 CPU/内存
+> - **异步不保证送达**:本质是 Pub/Sub,网络抖动时通知可能丢失
+> - **从节点不会推送**:默认只在写入节点生效,哨兵/集群切换后需重新订阅
+> - **建议**:只订阅你需要的事件类型(如 `KElx`),而非 `KExa` 通配一切
+
+## 关联笔记
+
+- [[hhs/Redis/06-主从与哨兵]] — Sentinel 故障检测(也是基于 PING/PONG 心跳)
+- [[hhs/Redis/07-集群方案]] — Cluster 下 Pipeline/Lua 的限制
+- [[hhs/Redis/README]] — 知识索引总览
diff --git a/hhs/Redis/10-缓存架构模式.md b/hhs/Redis/10-缓存架构模式.md
new file mode 100644
index 0000000..c9f4bbf
--- /dev/null
+++ b/hhs/Redis/10-缓存架构模式.md
@@ -0,0 +1,443 @@
+---
+tags: [Redis, 缓存, 架构]
+create time: 2026-05-15 18:15
+---
+
+# Redis 缓存架构模式
+
+## 概述
+
+在实际生产系统中,Redis 很少直接裸用——它需要与业务数据库、应用层紧密配合。本节介绍最常见的**缓存设计模式**以及经典的三大问题(穿透/击穿/雪崩)的解决方案。
+
+> [!QUESTION] 为什么有了 DB 还需要缓存?
+> - **性能**:内存读写 ≈ 1μs,磁盘 IO ≈ 1ms,差距三个数量级
+> - **容量**:DB 扛不住瞬时峰值,缓存可以做"削峰填谷"
+> - **成本**:一台 Redis 集群的成本远低于扩容 MySQL
+
+**核心矛盾**:缓存带来了性能提升,也引入了**一致性**和**可用性**的新风险。下文所有模式都是围绕这个矛盾在做取舍。
+
+## 一、基础缓存策略
+
+### Cache-Aside Pattern(旁路缓存)—— 最常用
+
+```mermaid
+flowchart TD
+ Req["请求到达"] --> Hit{"读缓存命中?"}
+ Hit -->|是| Data["返回缓存数据"]
+ Hit -->|否| DB["查询数据库"]
+ DB --> WriteCache["写入缓存
set key val EX 3600"]
+ WriteCache --> Data
+
+ UpReq["写请求到达"] --> DelCache["先更新 DB"]
+ DelCache --> DelKV["再删除缓存 Key"]
+ DelKV --> Done["结束"]
+
+ style WriteCache fill:#dfd,stroke:#090
+ style DelKV fill:#fdd,stroke:#900
+```
+
+> [!QUESTION] 写操作为什么"删缓存"而不是"更新缓存"?
+> - **删缓存**:简单可靠,下次读时自然回源 DB 拿到最新数据
+> - **更新缓存**:需要构造完整值(可能跨表 JOIN),复杂且容易不一致
+> - **关键原则**:读时保证一致性,写后不保证实时性(延迟满足)
+
+#### Go 示例
+
+```go
+// 读操作
+func GetUser(ctx context.Context, id int64) (*User, error) {
+ data, err := rdb.Get(ctx, "user:"+strconv.FormatInt(id, 10)).Bytes()
+ if err == redis.Nil {
+ // 缓存未命中 → 查 DB
+ u, err := db.GetUser(id)
+ if err != nil {
+ return nil, err
+ }
+ // 回填缓存
+ ttl := 30*time.Minute + time.Duration(rand.Intn(300))*time.Second
+ rdb.Set(ctx, "user:"+strconv.FormatInt(id, 10), toJSON(u), ttl)
+ return u, nil
+ }
+
+ var u User
+ json.Unmarshal(data, &u)
+ return &u, nil
+}
+
+// 写操作 —— 先更 DB,再删缓存
+func UpdateUser(ctx context.Context, u *User) error {
+ if err := db.UpdateUser(u); err != nil {
+ return err
+ }
+ rdb.Del(ctx, "user:"+strconv.FormatInt(u.ID, 10))
+ return nil
+}
+```
+
+> [!WARNING] Cache-Aside 的最终一致性
+> 写操作后立刻读,可能读到旧值(因为删缓存比下一个读请求先执行)。对于强一致场景,可以:
+> - 读操作加一把短锁(避免并发刷缓存覆盖新值)
+> - 或者直接用 DB 作为唯一真相源(放弃缓存一致性依赖)
+
+### Read/Write Through(读写穿透)
+
+通过中间件层自动处理缓存读写,业务代码只跟中间件交互:
+
+```mermaid
+flowchart LR
+ App["App Code
业务层"] --> Cache["Cache Layer
中间件透明封装"]
+ Cache --> DB["Database
持久层"]
+
+ style App fill:#efe,stroke:#090
+ style Cache fill:#fda,stroke:#da0
+ style DB fill:#ddf,stroke:#66c
+```
+
+> [!NOTE] 实际中较少纯原生实现
+> Redisson(Java)、Spring Cache 提供此抽象;Go 生态没有标准库,通常自行封装。核心思路是 **用 AOP / Middleware 把缓存逻辑从业务代码中剥离**。
+
+## 二、三大经典问题
+
+### 缓存穿透(Penetration)—— 不存在的数据一直打到 DB
+
+> [!QUESTION] 为什么是"不存在的数据"而不是"过期数据"?
+> - **过期数据**:过段时间就自然恢复了,是 TTL 管理的范畴
+> - **不存在的数据**:DB 和缓存都没有,请求每次都打到 DB,属于**恶意攻击或 Bug**
+
+```mermaid
+sequenceDiagram
+ participant C as 客户端
+ participant R as Redis
+ participant D as MySQL
+
+ C->>R: GET user:999999999
+ R-->>C: nil (未命中)
+ C->>D: SELECT * FROM users WHERE id=999999999
+ D-->>C: empty result
+ Note over C,D: 每次非法请求都走完整链路
QPS = N × 非法流量
+```
+
+#### 方案 1:布隆过滤器(Bloom Filter)
+
+```mermaid
+flowchart LR
+ Req["请求"] --> BF{"布隆过滤器"}
+ BF -->|不存在
直接拒绝| Reject["返回空 / 错误"]
+ BF -->|可能存在| Cache["查 Redis"]
+ Cache -->|"nil"| DB["查 DB"]
+ Cache -->|"命中"| Return["返回缓存值"]
+ DB -->|"有数据"| StoreCache["写入缓存 + 回刷 BF"]
+ DB -->|"无数据"| EmptyCache["空值缓存"]
+
+ style Reject fill:#fcc,stroke:#c00
+ style Return fill:#cfc,stroke:#090
+ style StoreCache fill:#dfd,stroke:#090
+```
+
+```go
+// Package main — 布隆过滤器预加载示例
+import "github.com/bits-and-blooms/bloom/v3"
+
+bf := bloom.NewWithEstimates(1_000_000, 0.01) // 百万级元素,错误率 1%
+bf.Add([]byte("user:1"))
+bf.Add([]byte("user:2"))
+
+if !bf.Test([]byte("user:999999")) {
+ return nil, fmt.Errorf("key does not exist") // 直接拒绝,不查 DB
+}
+```
+
+> [!TIP] 布隆过滤器的误判
+> `false positive` 会发生(判断存在但实际不存在),但不会 `false negative`。所以把拦截器放在 DB 前面是安全的。
+
+#### 方案 2:空值缓存
+
+```go
+// 缓存一个短过期时间的空值(如 30s ~ 5min)
+rdb.Set(ctx, "user:999999", "", 5*time.Minute)
+// TTL 不宜过长,否则业务数据新增后仍然拦截
+```
+
+> [!QUESTION] 两种方案怎么选?
+> - 全量已知数据集(如商品 SKU ID 列表)→ **布隆过滤器**,精确高效
+> - 动态未知数据集(如任意用户 ID)→ **空值缓存**,实现简单
+
+### 缓存击穿(Breakdown)—— 热点 key 过期瞬间 QPS 洪峰
+
+```mermaid
+sequenceDiagram
+ participant K as Key: hot_product:42
+ participant R as Redis
+ participant D as DB
+
+ time t1
+ K->>R: GET key
+ R-->>K: ✅ 命中,正常返回
+
+ time t2 (TTL 到期)
+ K->>R: GET key → nil!
+
+ time t3 — "击穿瞬间"
+ rect rgba(255, 0, 0, 0.1)
+ loop 1000 个并发请求
+ R-->>Req: nil (未命中)
+ Req->>D: SELECT ... (全部打到 DB!)
+ end
+ end
+
+ Note over D: ⚠️ DB QPS 瞬时暴增 → 可能崩溃
+```
+
+#### 方案 1:互斥锁(Mutex Lock)
+
+```go
+func GetProduct(ctx context.Context, id int64) (*Product, error) {
+ data, _ := rdb.Get(ctx, "product:"+strconv.FormatInt(id, 10)).Bytes()
+ if data != nil {
+ var p Product
+ json.Unmarshal(data, &p)
+ return &p, nil
+ }
+
+ // 拿分布式锁,只有一个 goroutine 去查 DB + 重建缓存
+ lockKey := "lock:product:" + strconv.FormatInt(id, 10)
+ locked, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
+ if err != nil || !locked {
+ time.Sleep(50 * time.Millisecond) // 等别人重建完
+ return GetProduct(ctx, id) // 递归重试(有最大深度保护)
+ }
+ defer rdb.Del(ctx, lockKey)
+
+ // 双重检查——防止自己等太久,另一个已经重建了
+ data, _ = rdb.Get(ctx, "product:"+strconv.FormatInt(id, 10)).Bytes()
+ if data != nil {
+ var p Product
+ json.Unmarshal(data, &p)
+ return &p, nil
+ }
+
+ p, _ := db.GetProduct(id)
+ rdb.Set(ctx, "product:"+strconv.FormatInt(id, 10), toJSON(p), 30*time.Minute)
+ return p, nil
+}
+```
+
+#### 方案 2:逻辑过期(Logical Expiration)
+
+```mermaid
+flowchart LR
+ Read["读请求"] --> Check{"检查 expire_at"}
+ Check -->|"未过期"| Return["直接返回旧值 ✅"]
+ Check -->|"已过期"| Async["启动 goroutine 重建缓存"]
+ Async --> ReturnOld["返回旧值(不阻塞)"]
+
+ subgraph "后台重建"
+ Rebuild["查 DB 最新数据"] --> SetNew["覆盖 Redis value"]
+ end
+
+ style Return fill:#cfc,stroke:#090
+ style ReturnOld fill:#ffd,stroke:#da0
+```
+
+> [!TIP] 两种方案的本质区别
+> - **Mutex Lock**:保证返回的一定是**最新数据**,但会短暂阻塞
+> - **Logical Expiration**:保证**永远不阻塞用户**,但可能返回略旧的数据
+
+```go
+// 读取时不检查物理 TTL,只检查逻辑过期时间
+v := rdb.Get(ctx, key).Val()
+var item struct {
+ Content string
+ ExpireAt int64
+}
+json.Unmarshal([]byte(v), &item)
+
+if time.Now().Unix() < item.ExpireAt {
+ return item.Content // 未过期,直接返回
+}
+
+// 异步重建缓存(不阻塞当前请求)
+go rebuildCacheAsync(key, item.ExpireAt)
+return item.Content // 返回旧值,后台静默刷新
+```
+
+### 缓存雪崩(Avalanche)—— 大量 key 同时过期
+
+> [!QUESTION] 穿透/击穿/雪崩 的区别是什么?
+> - **穿透**:数据**永远不存在**,请求直达 DB → 关注"过滤非法请求"
+> - **击穿**:单个**热点 key** 过期,瞬时洪峰 → 关注"防并发重建"
+> - **雪崩**:大批量 / **全部 key** 同时过期 → 关注"错峰 + 兜底"
+
+```mermaid
+flowchart LR
+ subgraph "❌ 无抖动"
+ T["TTL=30min"] -->|"t=30:00"| AllExpire["100% keys expire simultaneously"]
+ AllExpire --> Crash["DB overload → crash"]
+ style Crash fill:#fcc,stroke:#c00
+ end
+
+ subgraph "✅ 加随机抖动"
+ T2["TTL=30min ±5min"] -->|"t=30:00"| P1["20% expire"]
+ T2 -->|"t=31:00"| P2["20% expire"]
+ T2 -->|"t=32:00"| P3["20% expire"]
+ T2 -->|"t=33:00"| P4["20% expire"]
+ T2 -->|"t=34:00"| P5["20% expire"]
+ P1 -->|"DB 平稳承受"| Safe
+ P2 --> Safe
+ P3 --> Safe
+ P4 --> Safe
+ P5 --> Safe
+ style Safe fill:#cfc,stroke:#090
+ end
+```
+
+#### 解决方案
+
+**① TTL 加随机偏移(最常用)**
+
+```go
+// ✅ 给 TTL 加随机偏移(抖动)
+baseTTL := 30 * time.Minute
+jitter := time.Duration(rand.Intn(300)) * time.Second // 0~5min 随机
+rdb.Set(ctx, key, value, baseTTL+jitter)
+```
+
+> [!NOTE] 抖动范围怎么定?
+> 建议设为基础 TTL 的 **10% ~ 20%**。太小效果不明显,太大则可能导致冷数据过早失效。
+
+**② 多级缓存**
+
+```go
+// L1 本地缓存(进程内)→ L2 Redis(分布式)→ DB
+func GetValue(key string) ([]byte, error) {
+ if v := localCache.Get(key); v != nil { // ~1μs,L1 hit
+ return v, nil
+ }
+
+ rdbKey := "cache:" + key
+ if v, err := rdb.Get(ctx, rdbKey).Bytes(); err == nil { // ~1ms,L2 hit
+ localCache.Set(key, v) // L1 回填
+ return v, nil
+ }
+
+ // L2 miss → 查 DB → 回填两级缓存
+ data := queryDB(key)
+ l1TTL, l2TTL := 5*time.Minute, 30*time.Minute
+ rdb.Set(ctx, rdbKey, data, l2TTL)
+ localCache.Set(key, data)
+ return data, nil
+}
+```
+
+**③ 限流降级(兜底策略)**
+
+```go
+// 当缓存命中率骤降时,触发熔断
+if cacheMissRate > 0.5 {
+ // 返回默认响应 / 排队等待 / 直接限流
+ return fallbackResponse()
+}
+```
+
+> [!WARNING] 多级缓存的一致性挑战
+> 本地缓存和 Redis 之间天然存在延迟不一致。多节点部署下,A 节点的本地缓存和 B 节点的本地缓存可能看到不同值。**适合读多写少、允许短暂不一致的场景**。
+
+```mermaid
+flowchart LR
+ subgraph "应用进程内"
+ L1["L1 本地缓存
sync.Map / bigcache
⚡ ~1μs"]
+ end
+
+ subgraph "分布式层"
+ L2["L2 Redis Cluster
⚡ ~1ms"]
+ end
+
+ L1 -->|"miss"| L2
+ L2 -->|"miss"| DB["MySQL / 外部 API
⚡ ~5~50ms"]
+ DB -->|"回填"| L2
+ L2 -->|"广播失效"| L1
+```
+
+## 三、缓存设计原则总结
+
+| # | 原则 | 说明 |
+|---|------|------|
+| 1 | 永远不信任单一数据源 | 缓存和 DB 保持最终一致 |
+| 2 | 设 TTL 是必须的 | 即使内存够也要设,防止永久占用 |
+| 3 | TTL 加随机抖动 | 避免大规模同时过期 |
+| 4 | 大 Key 拆小 | 单个 Key ≤ 5KB,防止网络阻塞 |
+| 5 | Pipeline 批量操作 | 减少 RTT,提升吞吐 |
+| 6 | 监控命中率 | 低于 80% 考虑调整策略 |
+| 7 | 不要缓存不可变数据 | 配置类可存本地内存 |
+| 8 | 敏感数据不入库 | 密码、手机号要脱敏或加密 |
+
+## 四、常见架构模式速览
+
+```mermaid
+flowchart TD
+ Client["客户端请求"]
+
+ Client --> LocalCache{"本地缓存?"}
+ LocalCache -->|hit| Return["返回"]
+ LocalCache -->|miss| Redis["Redis"]
+
+ Redis -->|hit| Return
+ Redis -->|miss| DB["数据库"]
+
+ DB --> Redis
+ Redis --> LocalCache
+
+ Writer["写操作"] -->|"先更 DB"| DB
+ DB -->|"异步/同步刷缓存"| Redis
+
+ style Redis fill:#fda,stroke:#da0
+ style LocalCache fill:#dfd,stroke:#090
+ style DB fill:#ddf,stroke:#66c
+```
+
+## 五、缓存选型与演进路径
+
+> [!QUESTION] 项目初期应该从哪一层开始做缓存?
+> **答案:先做 L2(Redis),稳定后再加 L1(本地缓存)。** 原因如下:
+
+### 决策树
+
+```mermaid
+flowchart TD
+ Start["有新需求要加缓存"] --> Q1{"数据是否频繁变化?"}
+
+ Q1 -->|"否 — 配置/字典"| Config["直接放应用内存
不经过 Redis"]
+ Q1 -->|"是"| Q2{"QPS > 5000?"}
+
+ Q2 -->|"否 — 低频场景"| Simple["L2 only: Cache-Aside
+ Redis"]
+ Q2 -->|"是 — 中频场景"| Medium["L2 + ReadThrough
引入布隆过滤器防穿透"]
+ Q2 -->|"是 — 高频热点"| HotKey["L1 + L2 多级缓存
+ 逻辑过期防击穿"]
+
+ Config --> End["完成 ✅"]
+ Simple --> End
+ Medium --> End
+ HotKey --> End
+
+ style Config fill:#ffd,stroke:#da0
+ style Simple fill:#dfd,stroke:#090
+ style Medium fill:#fdd,stroke:#900
+ style HotKey fill:#fcc,stroke:#c00
+```
+
+### 不同阶段的关键关注点
+
+| 阶段 | 核心目标 | 推荐模式 | 避坑指南 |
+|------|---------|---------|---------|
+| **V1 起步期** | 快速上线,能跑就行 | Cache-Aside + Redis | 不要过度设计,先解决有无问题 |
+| **V2 增长期** | 扛住流量高峰 | 布隆过滤器 + 互斥锁 | 关注监控指标,建立告警机制 |
+| **V3 大规模** | 极致性能和稳定性 | 多级缓存 + 逻辑过期 + 降级策略 | 一致性 vs 可用性的平衡取舍 |
+
+> [!TIP] 一句话总结
+> **好的缓存架构不是设计出来的,是演进出來的**。先保证正确性,再优化性能,最后打磨可用性。
+
+## 关联笔记
+
+- [[hhs/Redis/09-高级特性]] — Lua 脚本 + 分布式锁
+- [[hhs/Redis/07-集群方案]] — Cluster 下的一致性挑战
+- [[hhs/Redis/README]] — 知识索引总览
+- [[hhs/GORM/09-钩子函数]] — GORM 生命周期中同步缓存的模式
diff --git a/hhs/Redis/11-运维与性能调优.md b/hhs/Redis/11-运维与性能调优.md
new file mode 100644
index 0000000..5cc1c40
--- /dev/null
+++ b/hhs/Redis/11-运维与性能调优.md
@@ -0,0 +1,391 @@
+---
+tags: [Redis, 缓存, 运维, 性能调优]
+create time: 2026-05-15 18:16
+---
+
+# Redis 运维与性能调优
+
+## 概述
+
+生产环境的 Redis 不是一装就跑——你需要持续监控、定期清理大 Key、合理配置连接池、分析慢查询。本节覆盖日常运维中最常见的问题和调优方法。
+
+## 一、监控指标体系
+
+### INFO 命令分级
+
+```bash
+# ──── 核心指标 ────
+INFO server # 版本、进程 ID、启动时间、运行天数
+INFO clients # 连接数、阻塞客户端数、写入缓冲区
+INFO memory # 内存使用量、峰值、碎片率 ← **必看**
+INFO stats # 命中率、命令数、fork 耗时 ← **必看**
+INFO persistence # RDB/AOF 状态 ← **必看**
+INFO replication # 主从关系、延迟 ← **必看**
+INFO commandstats # 各命令调用量和耗时排名
+
+# ──── 辅助指标 ────
+INFO keyspace # 每个 DB 的 key 数量和 TTL 统计
+INFO cpu # CPU 消耗
+INFO errorstats # 错误统计
+INFO cluster # 集群拓扑(仅 Cluster)
+INFO sentinel # Sentinel 信息(仅 Sentinel)
+```
+
+### 关键指标解读
+
+```bash
+# 从 INFO stats 提取的核心指标
+keyspace_hits: 987654 # 缓存命中次数
+keyspace_misses: 12346 # 缓存未命中次数
+# → 命中率 = 987654 / (987654 + 12346) = 98.76%
+
+used_memory_human: 1.23G # 已用内存
+used_memory_rss_human: 1.35G # OS 感知内存
+mem_fragmentation_ratio: 1.09 # RSS / used = 碎片率
+
+# ──── 健康标准 ────
+| 指标 | 正常范围 | 警告阈值 |
+|------|---------|---------|
+| 命中率 | > 90% | < 80% 需排查 |
+| mem_fragmentation_ratio | 1.0 ~ 1.5 | > 1.5 内存浪费,< 1.0 swap 风险 |
+| rejected_connections | 0 | > 0 说明 maxclients 不够 |
+| total_commands_processed/s | 平稳增长 | 断崖式下跌说明异常 |
+| connected_slaves | ≥ 预期 | 0 = 全部分离 |
+```
+
+### 副本延迟监控
+
+```bash
+# 从节点查看主从状态
+INFO replication
+# role:slave / master_host / master_port / master_link_status(up/down)
+# master_last_io_seconds_ago, master_sync_in_progress
+
+# 关键指标:master_repl_offset - slave_repl_offset
+# → 差值即为延迟字节数,超过 1MB 需关注
+```
+
+> [!WARNING] 副本延迟常见原因
+> - **单线程写放大**:主库高并发写入时,从库网络 IO 成为瓶颈
+> - **大 Key 持久化**:BGSAVE/BGREWRITEAOF 期间从库全量同步会短暂中断服务
+> - **解决方案**:配置多个从库分担读取流量 + 使用 `replica-read-only yes`(Redis 5.0+)禁止从库写入
+
+### 实时监控脚本
+
+```bash
+#!/bin/bash
+HOST=${1:-127.0.0.1}
+PORT=${2:-6379}
+
+echo "=== Memory ==="
+redis-cli -h $HOST -p $PORT INFO memory \
+ | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
+
+echo "=== Stats ==="
+redis-cli -h $HOST -p $PORT INFO stats \
+ | grep -E "keyspace_hits|keyspace_misses|instantaneous_ops_per_sec"
+
+echo "=== Clients ==="
+redis-cli -h $HOST -p $PORT INFO clients \
+ | grep -E "connected_clients|blocked_clients"
+
+echo "=== Persistence ==="
+redis-cli -h $HOST -p $PORT INFO persistence \
+ | grep -E "aof_enabled|rdb_last_bgsave_status"
+```
+
+## 二、慢查询日志
+
+### 配置与分析
+
+```bash
+# 默认配置
+CONFIG GET slowlog-log-slower-than # 默认 10000 μs (10ms)
+CONFIG GET slowlog-max-len # 默认 128 条
+
+# 调整为 5ms 阈值
+CONFIG SET slowlog-log-slower-than 5000
+
+# 扩大存储容量
+CONFIG SET slowlog-max-len 2048
+
+# 查看慢查询
+SLOWLOG GET 10
+
+# 重置
+SLOWLOG RESET
+```
+
+### 典型慢查询根因
+
+| 慢查询 | 根因 | 修复方案 |
+|--------|------|---------|
+| `KEYS *` | 全库扫描 O(N) | 改用 SCAN |
+| `HGETALL large_hash` | 大 Hash 全量返回 | HSCAN 分页 |
+| `SMEMBERS huge_set` | 大集合全量遍历 | SSCAN 分页 |
+| `LRANGE 0 -1` (百万条 List) | 全列表拉取 | LRANGE 指定范围 |
+| 无索引的 DB 查询(在 Lua 中) | DB 层问题 | 加 DB 索引 |
+
+```go
+// go-redis 中的 SlowLog 接口
+entries := rdb.SlowLogGet(ctx, 10).Val()
+for _, e := range entries {
+ fmt.Printf("ID: %d, Time: %s, Cmd: %s, Duration: %v\n",
+ e.ID, e.Time, e.Cmd, e.Duration)
+}
+```
+
+## 三、大 Key 治理
+
+### 什么是大 Key?
+
+通常指超过 **5KB** 的单个 Key(字符串除外,字符串可达数百 MB)。
+
+> [!WARNING] 大 Key 的危害
+> - **网络阻塞**:SET/GET 一个 1MB 的值占满一条 TCP 连接
+> - **过期事件堆积**:大量大 key 同时过期时一次性触发删除,阻塞主线程
+> - **分片迁移卡顿**:Cluster 迁移 slot 时需要复制整个值
+> - **RDB/AOF 膨胀**:持久化文件急剧增大
+
+### 检测工具
+
+```bash
+# 方式 1: redis-cli --bigkeys(采样扫描)
+redis-cli --bigkeys -n 1000
+
+# 输出示例:
+# --- SUMMARY ---
+# Size Count Type Keys
+# 1.23MB 1 string user:avatar:big_image
+# 456KB 3 hash order:items:*
+# ...
+
+# 方式 2: redis-rdb-tools (RDB 离线分析)
+rdb -c memory dump.rdb | sort -k2 -rn | head -20
+
+# 方式 3: RedisInsight / AnotherRedisDesktopManager GUI
+```
+
+### 拆分策略
+
+```
+原始大 Key:
+order:12345 -> hash{ item1, item2, ..., item5000 } ← 5000 个订单项
+
+拆分后:
+order:12345:id -> order_main_12345 ← 订单元数据
+order:12345:items:page:1 -> list[ item1~item100 ] ← 按页拆分
+order:12345:items:page:2 -> list[ item101~item200 ]
+
+读取时拼接 pages 即可
+```
+
+### DEL 大 Key 的风险
+
+```bash
+# ⛔ 直接 DEL 大 key 会阻塞主线程!
+DEL big_key
+
+# ✅ 渐进式删除(SCAN + UNLINK)
+UNLINK big_key # 异步删除(Redis 4.0+),立即返回
+ # 后台线程回收内存
+```
+
+> [!TIP] UNLINK vs DEL
+> - `DEL`:同步删除,大 key 可能阻塞数十毫秒甚至秒级
+> - `UNLINK`:异步删除,立即返回,后台回收(类似 GC)
+> - 生产环境优先使用 `UNLINK`
+
+## 四、连接池调优
+
+### Go go-redis 连接池配置
+
+```go
+rdb := redis.NewClient(&redis.Options{
+ Addr: "localhost:6379",
+ Password: "",
+ DB: 0,
+
+ // ──── 连接池参数 ────
+ MaxIdle: 10, // 空闲连接上限
+ MaxActive: 100, // 最大活跃连接数
+ IdleTimeout: 5 * time.Minute, // 空闲超时关闭
+
+ // ──── 超时参数 ────
+ DialTimeout: 5 * time.Second, // 建立 TCP 连接超时
+ ReadTimeout: 3 * time.Second, // 读超时
+ WriteTimeout: 3 * time.Second, // 写超时
+ PoolTimeout: 1 * time.Second, // 获取连接的等待超时
+
+ // ──── 心跳保活 ────
+ PingPeriod: 60 * time.Second, // PING/PONG 保活周期,防止连接被防火墙断开
+ MaxRetries: 3,
+ RetryDelay: 200 * time.Millisecond,
+})
+```
+
+### 连接池选型决策树
+
+```mermaid
+graph TD
+ A["预估 QPS"] -->|"< 500"| S["MaxActive: 20
MaxIdle: 5"]
+ A -->|"500 ~ 5000"| M["MaxActive: 50
MaxIdle: 20"]
+ A -->|"> 5000"| L["MaxActive: 100~200
MaxIdle: 30~50"]
+
+ B{"有无 Pipeline?"}
+ B -->|"是"| C["↑ 增加 30% 连接数"]
+ B -->|"否"| D["保持上述配置"]
+
+ style L fill:#fdd,stroke:#900
+```
+
+> [!TIP] MaxActive 计算公式
+> ```
+> MaxActive = N × (1 + CPU等待时间/CPU计算时间)
+> ```
+> 其中 N 为 Goroutine 数量。对于大多数 Web 应用,**50~100** 已经足够——如果频繁出现 PoolTimeout,优先考虑排查慢查询或大 Key 问题。
+
+> [!WARNING] 连接池常见问题
+> - **MaxActive 太小**:`context deadline exceeded`(PoolTimeout)频繁出现
+> - **MaxActive 太大**:创建过多 TCP 连接,文件描述符耗尽
+> - **ReadTimeout 太短**:大数据量操作(如 HGETALL 大 hash)被误杀
+> - **不设 DialTimeout**:Redis 宕机时请求永远挂起
+
+## 五、内存管理与淘汰策略
+
+### maxmemory 设置
+
+```conf
+maxmemory 2gb
+maxmemory-policy allkeys-lru
+```
+
+### 淘汰策略详解
+
+> [!QUESTION] 没有永不超限的策略?
+> 如果你不想数据丢失,应该设 `noeviction`——但这意味着写操作会失败。更好的做法是:**合理设置 maxmemory + 选择合适的淘汰策略**,而不是等到满了再处理。
+
+| 策略 | 作用范围 | 淘汰算法 | 适用场景 |
+|------|---------|---------|---------|
+| **allkeys-lru** | 所有 key | LRU(近似) | 通用缓存 ✅ 最常用 |
+| allkeys-lfu | 所有 key | LFU(频次统计) | Redis 6.0+,热点数据语义更强的场景 |
+| volatile-lru | 仅设 TTL 的 key | LRU(近似) | 混合存储:永不过期的配置 + 可淘汰的缓存 |
+| volatile-ttl | 仅设 TTL 的 key | TTL 最短优先 | 让数据自然死亡的场景 |
+| noeviction | — | — | 数据不可丢失,超限返回错误(如配置中心) |
+
+> [!NOTE] LRU vs LFU
+> - **LRU**:基于时间,维护一个候选池近似实现,内存开销小
+> - **LFU**:基于访问频率,每 key 额外消耗 2 bit 计数器,更精准但略贵
+> - 实际选型:不确定时先用 `allkeys-lru`;如果存在明显"二八定律"(20% 数据占 80% 访问),考虑 `allkeys-lfu`
+
+### 内存碎片治理
+
+```bash
+# 查看碎片率
+memfragmentationratio = used_memory_rss / used_memory
+# 正常范围: 1.0 ~ 1.5
+
+# 配置激活碎片整理(Redis 4.0+,仅在 Linux 上有效)
+activedefrag yes
+active-defrag-threshold-lower 10 # 碎片率 > 10% 时启动
+active-defrag-threshold-upper 50 # 碎片率 > 50% 时全力清理
+active-defrag-cycle-min 5 # 每次最多占 CPU 5%
+active-defrag-cycle-max 25 # 最高占 CPU 25%
+```
+
+> [!QUESTION] 为什么 fragmentation ratio < 1.0?
+> 表示 RSS < used_memory——这意味着发生了 **swap**。操作系统把 Redis 的物理内存换出到磁盘,灾难性后果——延迟飙升到秒级。
+>
+> ```bash
+> # 检查是否开启了 swap
+> free -m
+> # 如果 swapped > 0 → 立即处理!
+> ```
+
+## 六、备份与灾难恢复
+
+### 定时备份方案
+
+```bash
+#!/bin/bash
+# backup.sh — 每天凌晨 2 点执行
+BACKUP_DIR="/backup/redis"
+DATE=$(date +%Y%m%d_%H%M%S)
+
+# 触发 BGSAVE
+redis-cli BGSAVE
+
+# 等待完成(轮询 LASTSAVE)
+sleep 5
+cp /var/lib/redis/dump.rdb "$BACKUP_DIR/dump_$DATE.rdb"
+gzip "$BACKUP_DIR/dump_$DATE.rdb"
+
+# 保留最近 30 天的备份
+find $BACKUP_DIR -mtime +30 -delete
+
+# 上传到对象存储
+aws s3 cp "$BACKUP_DIR/dump_$DATE.rdb.gz" "s3://your-bucket/redis-backup/"
+```
+
+Crontab:
+```
+0 2 * * * /path/to/backup.sh >> /var/log/redis-backup.log 2>&1
+```
+
+### 灾难恢复流程
+
+```mermaid
+flowchart TD
+ A["⚠️ 数据异常或丢失"] --> B{"可用最近的备份?"}
+ B -->|"是"| C["停止写入,防止覆盖"]
+ C --> D["恢复 RDB/AOF 备份"]
+ D --> E["检查数据完整性"]
+ E --> F{"数据是否完整?"}
+ F -->|"是"| G["恢复写入,观察指标"]
+ F -->|"否"| H{"有增量日志?"}
+ H -->|"AOF"| I["附加修复 AOF 文件
redis-check-aof --fix"]
+ H -->|"无"| J["从主库全量同步"]
+ I --> E
+ J --> E
+ B -->|"否"| K["从主库重新同步"]
+ K --> E
+
+ style A fill:#fdd,stroke:#900
+ style G fill:#dfd,stroke:#090
+```
+
+### 云厂商迁移工具
+
+| 来源 | 目标 | 工具 |
+|------|------|------|
+| 自建 Redis | AWS ElastiCache | `elasticache-migration-tool` |
+| 自建 Redis | Aliyun ApsaraDB | `redis-shake` |
+| Redis Cluster | 云 Redis | 云控制台导入功能 |
+| Redis to Redis | 任意 | `redis-shake` |
+
+## 七、安全加固清单
+
+> [!CHECKLIST] 生产环境安全核对
+
+- [ ] `requirepass` 已设置强密码
+- [ ] `rename-command FLUSHALL ""` — 重命名危险命令(或设为空禁用)
+- [ ] `rename-command DEBUG ""` — 禁用 debug 命令
+- [ ] 绑定内网 IP 而非 `0.0.0.0`
+- [ ] TLS/SSL 加密传输(Redis 6.0+ 支持)
+- [ ] 定期更新版本号,修复已知 CVE
+- [ ] ACL 用户权限控制(Redis 6.0+ 支持多用户角色)
+
+```bash
+# ACL 示例(Redis 6.0+)
+ACL SETUSER readonly on ">readOnlyPass123" ~cached:* get mget scan
+ACL SETUSER writer on ">writePass123" ~*:set* ~*:add* ~*:incr* set add incr
+ACL LIST # 查看所有用户
+ACL WHOAMI # 当前用户
+```
+
+## 关联笔记
+
+- [[hhs/Redis/04-RDB持久化]] — RDB 快照与备份
+- [[hhs/Redis/05-AOF持久化]] — AOF 文件大小管理
+- [[hhs/Redis/07-集群方案]] — 集群运维注意事项
+- [[hhs/Redis/README]] — 知识索引总览
diff --git a/hhs/Redis/README.md b/hhs/Redis/README.md
new file mode 100644
index 0000000..0c20963
--- /dev/null
+++ b/hhs/Redis/README.md
@@ -0,0 +1,134 @@
+---
+tags: [Redis, 缓存, NoSQL, 索引]
+create time: 2026-05-15 18:10
+---
+
+# Redis 知识索引
+
+## 概述
+
+本文件夹汇总 Redis 相关的核心知识点,从数据结构到生产实践,覆盖日常开发和高阶场景。Redis 不只是「一个快」的键值存储——它的五种基础数据类型、高级数据结构(Sorted Set / Bitmap / HyperLogLog / Bloom Filter)、持久化机制、集群方案以及常见架构模式,构成了后端服务中不可或缺的基础设施层。
+
+> [!QUESTION] 为什么用 Redis?
+> - 纯内存读写,延迟在微秒级
+> - 单线程模型避免了锁竞争和上下文切换
+> - 丰富的数据结构可以直接表达业务语义(如排行榜用 Sorted Set)
+> - 发布订阅、Lua 脚本、Stream 等提供了类消息队列的能力
+
+## 知识体系
+
+### 一、入门基础
+
+| # | 主题 | 链接 |
+|---|------|------|
+| 1.1 | 安装与部署 | [[hhs/Redis/01-安装与部署]] — `redis-server`、`redis-cli`、配置文件、systemd 管理 |
+| 1.2 | 核心数据类型与编码 | [[hhs/Redis/02-核心数据类型]] — String/Hash/List/Set/ZSet 底层编码切换机制 |
+| 1.3 | 基本命令速查 | [[hhs/Redis/03-基本命令速查]] — 按类型分类的命令表 + Pipeline 用法 |
+
+> [!TIP] 理解编码胜过记忆命令
+> 同一个数据类型在不同容量下会切换不同编码(如 Hash 在元素少时用 ziplist,多了切 hash table)。理解这一点才能写出真正高效的 Redis 代码。
+
+### 二、持久化与高可用
+
+| # | 主题 | 链接 |
+|---|------|------|
+| 2.1 | RDB 快照 | [[hhs/Redis/04-RDB持久化]] — fork 原理、COW、触发时机 |
+| 2.2 | AOF 追加日志 | [[hhs/Redis/05-AOF持久化]] — everysec 策略、重写流程 |
+| 2.3 | 混合持久化 | Redis 7 特性:RDB 快照 + AOF 增量 = 快 + 准 |
+| 2.4 ~ 2.5 | 主从复制与 Sentinel | [[hhs/Redis/06-主从与哨兵]] — 全量/增量同步、故障转移流程 |
+| 2.6 | Cluster 集群 | [[hhs/Redis/07-集群方案]] — 哈希槽、Gossip、扩容迁移 |
+
+```mermaid
+flowchart LR
+ subgraph "Cluster 写入流程"
+ A["Client"] -->|"key → CRC16 → slot"| B["Slot 所在 Master"]
+ B -->|"重定向 MOVED/ASK"| C["目标 Master"]
+ C --> D["异步复制给 Replica"]
+ end
+```
+
+> [!QUESTION] RDB 还是 AOF?怎么选?
+> - 对**可用性要求极高**:AOF everysec,最多丢 1 秒数据
+> - 对**数据安全要求极高**:AOF always(牺牲性能换安全)
+> - **混合持久化**是最佳平衡点:重启时加载 RDB 块(快),回放 AOF 日志补齐(准)
+
+### 三、数据结构进阶
+
+| # | 主题 | 链接 |
+|---|------|------|
+| 3.1 | Sorted Set 高级玩法 | [[hhs/Redis/08-SortedSet精解]] — 排行榜、延迟队列、Geo、优先级队列 |
+| 3.2 | Bitmap | 签到、DAU 统计——每位一天,极致省内存 |
+| 3.3 | HyperLogLog | 亿级 UV 去重统计,误差 < 0.1%,仅占 12KB |
+| 3.4 | Bloom Filter | 防缓存穿透、URL 去重——false positive 可接受 |
+| 3.5 | Stream | 可靠消息队列,Consumer Group 消费组模型 |
+| 3.6 | GEO | 附近的人、距离计算、围栏检测——底层就是 ZSet |
+
+```mermaid
+flowchart TD
+ S["业务数据变更"] --> A["MySQL 写入"]
+ A --> B{"是否需要同步缓存?"}
+ B -->|是| C["删除缓存 Key
Cache-Aside"]
+ B -->|否| D["结束"]
+ C --> E["下次读取未命中"]
+ E --> F["回源 DB"]
+ F --> G["回填缓存"]
+ G --> D
+```
+
+### 四、缓存架构模式
+
+| # | 主题 | 链接 |
+|---|------|------|
+| 4.1 | Cache-Aside | 先更 DB 再删缓存;读时懒加载回填 |
+| 4.2 | Read/Write Through | 中间件自动管理一致性 |
+| 4.3 | 缓存穿透 | 布隆过滤器 / 空值缓存 |
+| 4.4 | 缓存击穿 | 互斥锁重建 / 逻辑过期异步刷新 |
+| 4.5 | 缓存雪崩 | TTL 随机抖动 + 多级缓存 + 限流降级 |
+| 4.6 | 一致性哈希 | 虚拟节点解决扩容抖动 |
+
+[[hhs/Redis/10-缓存架构模式]] — 完整实战文档包含 Go 示例代码和架构图
+
+### 五、高级特性
+
+| # | 主题 | 链接 |
+|---|------|------|
+| 5.1 | 事务 | MULTI/EXEC 不支持回滚 |
+| 5.2 | Lua 脚本 | 原子执行多命令——分布式锁、限流器、状态机 |
+| 5.3 | Pub/Sub & Stream | 实时通知 vs 可靠消息投递 |
+| 5.4 | Pipeline | 批量发送减少 RTT,50~200 条/batch 最优 |
+| 5.5 | Keyspace Notification | 过期事件订阅 |
+| 5.6 | Memory Policy | allkeys-lru / lfu / noeviction 等 |
+
+[[hhs/Redis/09-高级特性]] — 完整实战文档包含 Lua 脚本模板和 Pipeline 用法
+
+### 六、运维与性能调优
+
+| # | 主题 | 链接 |
+|---|------|------|
+| 6.1 | 慢查询分析 | `SLOWLOG GET`,阈值可调 |
+| 6.2 | 内存分析 | INFO memory、碎片率治理、Swap 排查 |
+| 6.3 | 大 Key 治理 | SCAN 替代 KEYS、UNLINK 异步删除、拆分策略 |
+| 6.4 | 连接池配置 | go-redis PoolSize、Timeout、Retry 参数调优 |
+| 6.5 | 监控指标 | QPS、命中率、CPU、客户端数、延迟 |
+| 6.6 | 备份与迁移 | BGSAVE、定时备份脚本、redis-shake、云服务迁移 |
+| 6.7 | 安全加固 | requirepass、ACL、rename-command、TLS |
+
+[[hhs/Redis/11-运维与性能调优]] — 完整运维手册含监控脚本和安全清单
+
+## 七、生态集成
+
+| 技术栈 | 集成方式 | 相关笔记 |
+|--------|---------|----------|
+| Go | go-redis / redigo | — |
+| Java | lettuce / jedis | — |
+| GORM | 钩子函数清理/更新缓存 | [[hhs/GORM/09-钩子函数]] |
+| Docker Compose | depends_on 启动顺序 | [[hhs/EXAM/Week05]] |
+| Gin Session | gin-contrib/sessions/redis | [[hhs/EXAM/Week05]] |
+
+## 关联笔记
+
+- [[hhs/GORM/09-钩子函数]] — GORM 生命周期中与 Redis 缓存同步的模式
+- [[hhs/GORM/02-模型定义/UUID主键策略]] — Snowflake WorkerID 依赖 Redis 协调
+- [[hhs/EXAM/Week05]] — Docker 中 Redis 容器管理与 Session 共享
+- [[hhs/EXAM/Week03]] — Session 存储选型对比(Redis vs 内存 vs DB)
+- [[一文吃透Redis]] — 整体知识图谱入口
diff --git a/hhs/Redis/一文吃透/README.md b/hhs/Redis/一文吃透Redis.md
similarity index 100%
rename from hhs/Redis/一文吃透/README.md
rename to hhs/Redis/一文吃透Redis.md