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/06-DDL 建表与结构变更.md b/hhs/MySQL/06-DDL 建表与结构变更.md
new file mode 100644
index 0000000..3700a96
--- /dev/null
+++ b/hhs/MySQL/06-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/07-DML 增删改.md b/hhs/MySQL/07-DML 增删改.md
new file mode 100644
index 0000000..63584ba
--- /dev/null
+++ b/hhs/MySQL/07-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/08-DQL SELECT 全解析.md b/hhs/MySQL/08-DQL SELECT 全解析.md
new file mode 100644
index 0000000..cf49d1a
--- /dev/null
+++ b/hhs/MySQL/08-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/09-JOIN 原理与优化.md b/hhs/MySQL/09-JOIN 原理与优化.md
new file mode 100644
index 0000000..ed74c52
--- /dev/null
+++ b/hhs/MySQL/09-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/10-子查询与派生表.md b/hhs/MySQL/10-子查询与派生表.md
new file mode 100644
index 0000000..a08f045
--- /dev/null
+++ b/hhs/MySQL/10-子查询与派生表.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/11-UNION 与集合运算.md b/hhs/MySQL/11-UNION 与集合运算.md
new file mode 100644
index 0000000..169f5f1
--- /dev/null
+++ b/hhs/MySQL/11-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/12-B+Tree 索引原理.md b/hhs/MySQL/12-B+Tree 索引原理.md
new file mode 100644
index 0000000..1d84661
--- /dev/null
+++ b/hhs/MySQL/12-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/13-聚簇索引与二级索引.md b/hhs/MySQL/13-聚簇索引与二级索引.md
new file mode 100644
index 0000000..b72078c
--- /dev/null
+++ b/hhs/MySQL/13-聚簇索引与二级索引.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/14-联合索引与最左前缀.md b/hhs/MySQL/14-联合索引与最左前缀.md
new file mode 100644
index 0000000..5ef4215
--- /dev/null
+++ b/hhs/MySQL/14-联合索引与最左前缀.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/15-EXPLAIN 完全指南.md b/hhs/MySQL/15-EXPLAIN 完全指南.md
new file mode 100644
index 0000000..ce8e8b7
--- /dev/null
+++ b/hhs/MySQL/15-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/16-慢查询日志分析.md b/hhs/MySQL/16-慢查询日志分析.md
new file mode 100644
index 0000000..2af9508
--- /dev/null
+++ b/hhs/MySQL/16-慢查询日志分析.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/17-查询改写技巧.md b/hhs/MySQL/17-查询改写技巧.md
new file mode 100644
index 0000000..5625b1f
--- /dev/null
+++ b/hhs/MySQL/17-查询改写技巧.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/18-深分页优化.md b/hhs/MySQL/18-深分页优化.md
new file mode 100644
index 0000000..d27fe3a
--- /dev/null
+++ b/hhs/MySQL/18-深分页优化.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/19-InnoDB 深度解析.md b/hhs/MySQL/19-InnoDB 深度解析.md
new file mode 100644
index 0000000..b35f231
--- /dev/null
+++ b/hhs/MySQL/19-InnoDB 深度解析.md
@@ -0,0 +1,479 @@
+---
+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
+```
+
+## 写入路径全景
+
+了解完顶层架构后,让我们跟一次完整的 **写入请求** 走一遍流程。这能帮你理解各个组件如何在同一笔事务中协同工作。
+
+```mermaid
+flowchart TD
+ S["SQL: INSERT/UPDATE/DELETE"] --> C{页在 Buffer Pool?}
+ C -->|命中| D["直接修改内存页"]
+ C -->|未命中| E["从磁盘加载页到 Buffer Pool
Page Cache Miss"]
+ E --> D
+
+ D --> F["标记页为 Dirty"]
+ F --> G["写入 Redo Log Buffer"]
+ G --> H{是否提交 COMMIT?}
+ H -->|否| P["Undo Log 记录回滚信息"]
+ H -->|是| I["两阶段提交 2PC"]
+
+ I --> J["先写 Redo Log → fsync 落盘"]
+ J --> K["再写 Binlog → fsync 落盘"]
+ K --> L["事务完成 ✅"]
+
+ P --> M["后台线程: Page Cleaner
脏页刷入磁盘 .ibd"]
+ L --> N["后台线程: Checkpoint
推进 Watermark 允许覆盖 Redo Log"]
+
+ O["Change Buffer
二级索引变更缓存"] -.-> M
+
+ style D fill:#00D866,color:#fff
+ style G fill:#FF9F43,color:#000
+ style J fill:#FFD166,color:#000
+ style K fill:#FFD166,color:#000
+ style L fill:#00B6BC,color:#fff
+```
+
+### 分步拆解
+
+| 步骤 | 操作 | 涉及组件 | 同步/异步 |
+|------|------|----------|-----------|
+| ① | 检查 Buffer Pool 中是否有目标页 | Buffer Pool | 同步 |
+| ② | 若无,从磁盘加载(随机读 I/O) | Buffer Pool + 磁盘 | 同步 |
+| ③ | 修改内存页 | Buffer Pool | 同步 |
+| ④ | 记录 Redo Log(相同页面偏移的修改字节) | Redo Log | 同步 |
+| ⑤ | 若涉及二级索引且页面不在 Buffer Pool 中 | Change Buffer | 同步(只改内存) |
+| ⑥ | `COMMIT` → 写 Redo Log Buffer → flush | Redo Log | 同步 |
+| ⑦ | `COMMIT` → 写 Binlog → flush | Binary Log | 同步 |
+| ⑧ | 后台 Clean Thread 将脏页刷入 .ibd | Buffer Pool + 磁盘 | 异步 |
+
+> [!WARNING] 真正的磁盘 I/O 只有两步
+>
+> - **步骤 ⑥**: Redo Log fsync(保证 crash safe)
+> - **步骤 ⑧**: 脏页刷入 .ibd(后台异步,与事务无关)
+>
+> 其余步骤全部在内存中完成。**这就是为什么 InnoDB 性能远优于 MyISAM**——MyISAM 每次写都要立即落盘,而 InnoDB 可以把大部分写操作延迟到后台执行。
+
+> [!TIP] 顺序写的秘密
+>
+> 注意到 **Redo Log 本身是顺序追加写**的!固定大小循环复用,永远往尾部写。这意味着即使磁盘是机械硬盘,Redo Log 的写入也保持极高的吞吐——因为没有任何随机 seek。
+>
+> 对比之下,数据文件 `.ibd` 的写入是随机的(根据 B+Tree 结构分布),这是 InnoDB 最慢的部分,也是 Buffer Pool 和 Change Buffer 要极力减少它的根本原因。
+
+---
+
+## 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
+
+ note["注意: 叶子节点之间通过双向链表串联,维持主键有序"]
+ note ~~~ F
+
+ style F fill:#00B6BC,color:#fff
+ style I fill:#00B6BC,color:#fff
+```
+
+> [!TIP] 为什么一定要用自增主键?
+> 如果选随机值(如 UUID)作为主键,每次插入都可能在索引树的中间位置分叉,导致:
+> - **页分裂**:16KB 页面满了要拆成两个,触发大量 I/O
+> - **写放大**:相邻页变得不连续,顺序写变成随机写
+> - **空间浪费**:页填充率下降(从 100% 降到 ~70%)
+>
+> 使用自增 INT/BIGINT 时,新记录总是在索引末尾追加——完美的顺序写模式。
+
+## Secondary Index(二级索引)
+
+所有非主键索引都是二级索引。关键点:**二级索引的叶子节点存储的是主键值**,而不是行数据。
+
+```mermaid
+flowchart 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
+
+ note["注意: 虚线表示二级索引查找后需通过主键回表获取完整行数据"]
+ note ~~~ SI1
+ note ~~~ CI1
+```
+
+### 覆盖索引(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 崩溃时会丢数据。
+
+### 两阶段提交(Two-Phase Commit)
+
+为什么 InnoDB 需要协调 Redo Log 和 Binlog?先想一个问题:
+
+> [!QUESTION] 如果只写一种日志,会出现什么后果?
+>
+> - **只写 Redo Log**:crash 恢复没问题,但无法做主从复制(Binlog 是复制的唯一数据源)
+> - **只写 Binlog**:crash 后 Binlog 可能包含了一个实际没有落盘的数据页的事务 → 主从不一致
+>
+> 答案:**两种情况都会导致 crash 恢复后,Redo Log 和 Binlog 之间的数据不一致。**
+
+```mermaid
+flowchart LR
+ subgraph "正常事务流程"
+ A1["步骤1: 修改 Buffer Pool
标记页为 Dirty"] --> A2["步骤2: 写 redo log prepare"]
+ A2 --> A3["步骤3: fsync redo log"]
+ A3 --> A4["步骤4: 写 binlog"]
+ A4 --> A5["步骤5: fsync binlog"]
+ A5 --> A6["步骤6: 写 redo log commit"]
+ A6 --> A7["步骤7: fsync redo log"]
+ A7 --> T1["事务成功 ✅"]
+ end
+
+ style A3 fill:#FFD166,color:#000
+ style A5 fill:#FF9F43,color:#000
+ style A7 fill:#FFD166,color:#000
+```
+
+> [!TIP] 核心思想:prepare = "我准备好了但不确定",commit = "确认执行"
+>
+> | Crash 时间点 | Redo Log 状态 | Binlog 状态 | 恢复行为 |
+> |---|---|---|---|
+> | A2 ~ A3 之间 | prepare(未刷盘) | 未写 | Redo Log 回滚 → **Binlog 也不会有这条记录** ✅ |
+> | A3 ~ A4 之间 | prepare(已刷盘) | 未写 | Redo Log 回滚到 prepare 之前 → **事务被丢弃** ✅ |
+> | A5 之后 | commit(已刷盘) | 已写 | Redo Log 完整重放 → **事务完整恢复** ✅ |
+>
+> **关键结论**:两阶段提交的本质是让 Redo Log 充当 "投票机制"——只有当事务真正安全地存在于两种日志中时才算完成。这保证了主从数据的一致性。
+
+---
+
+## 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%)
+
+## Checkpoint & Page Clean / Flush
+
+有了写入路径全景后,我们来看最后一个关键问题:**内存里的脏页什么时候被写回磁盘?**
+
+### LRU List ↔ Flush List 的双链表协作
+
+Buffer Pool 维护着两条核心链表,它们各自有不同的职责:
+
+```mermaid
+flowchart LR
+ subgraph "LRU List, 访问顺序"
+ Old["Old Sub-list (最近经常访问)"] --> New["New Sub-list (新加载的页)"]
+ end
+
+ subgraph "Flush List, 脏污程度"
+ FL1["Clean Pages (已刷盘)"] --> FL2["Dirty Pages (未刷盘)"]
+ end
+
+ DirtyInLRU["LRU 中的脏页
同时存在于 Flush List"] --> FL2
+ CleanedInLRU["已被刷盘的页
仍在 LRU 中等待淘汰"] --> FL1
+
+ style DirtyInLRU fill:#FF6B6B,color:#fff
+ style CleanedInLRU fill:#00D866,color:#fff
+```
+
+| 链表 | 排序依据 | 主要用途 |
+|------|----------|----------|
+| **LRU List** | 访问时间(从旧到新) | 决定哪些页该被淘汰出 Buffer Pool |
+| **Flush List** | 是否脏污 + LSN 递增 | 决定哪些页需要刷入磁盘 |
+
+> [!QUESTION] 为什么需要两条独立的链表?
+>
+> 想象一个场景:Buffer Pool 满了,需要回收一页空间。
+> - **只查 LRU**:可能取到 dirty page → 必须先刷盘再回收 → 慢
+> - **只查 Flush**:可能取到 clean page 但很久没访问过 → 浪费了干净页的空间
+>
+> 实际实现中,InnoDB 会从 LRU 的 Old 端找一个 dirty page 刷盘;如果一时找不到,也会刷干净页来救急。**这两条链表的配合是 Buffer Pool 高效运转的关键。**
+
+### Checkpoint: Redo Log 循环复用的水坝
+
+既然 Redo Log 是固定大小、循环重写的,那么问题来了:**旧日志什么时候能被安全覆盖?**
+
+```mermaid
+flowchart LR
+ A["Redo Log 写满尾部"] --> B{"能从头覆盖吗?"}
+
+ B -->|"否"| C["Page Cleaner: 刷脏页到磁盘"]
+ C --> D["Flush List 中的脏页减少"]
+
+ D --> E["Checkpoint Thread: 推进 Checkpoint LSN"]
+ E --> F["Watermark 前进 → 旧日志可覆盖 ✅"]
+
+ F --> G["头部开始循环写入"]
+ B -->|"是"| G
+
+ style F fill:#00D866,color:#fff
+ style G fill:#FFD166,color:#000
+```
+
+| 概念 | 含义 | 关键参数 |
+|------|------|----------|
+| **LSN (Log Sequence Number)** | 单调递增的日志序号,每个页也有当前 LSN | — |
+| **Checkpoint LSN** | 到此位置之前的所有页保证已落盘 | — |
+| **Redo Log 可覆盖分界** | `Checkpoint LSN < X < Current LSN` 之间的日志不能删 | — |
+| **innodb_max_dirty_pages_pct** | 脏页占 Buffer Pool 的上限百分比 | 默认 75% |
+
+> [!WARNING] 脏页过多的危险信号
+>
+> 当脏页比例超过 `innodb_max_dirty_pages_pct` 时,InnoDB 会触发 **流量控制(Throttling)**:
+> 1. 停止接受新的写请求
+> 2. 优先执行 Page Clean → 刷脏页
+> 3. 等比例回落后才恢复写操作
+>
+> 这会导致 **突发性写入延迟飙升**。监控 `Innodb_dblwr_pages_written` 和 `Innodb_buffer_pool_dirty_pages` 可以提前发现这个问题。
+>
+> **常见原因**: Buffer Pool 太小、大量批量更新、事务提交过于密集导致 redo flush 跟不上。
+
+---
+
+## 关联笔记
+
+### 索引相关
+- [[hhs/MySQL/16-B+Tree 索引原理]] — B+Tree 数据结构基础
+- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
+- [[hhs/MySQL/18-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
+- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
+
+### 事务与并发控制
+- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
+- [[hhs/MySQL/24-隔离级别与可见性]] — 四种隔离级别的实际区别
+- [[hhs/MySQL/25-MVCC 原理]] — Read View + Undo Version Chain 的完整实现
+- [[hhs/MySQL/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
+- [[hhs/MySQL/28-一致性读与当前读]] — 二次读与快照读的触发条件
+
+### 日志与恢复
+- [[hhs/MySQL/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
+- [[hhs/MySQL/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
+
+### 性能调优
+- [[hhs/MySQL/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
+- [[hhs/MySQL/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
+
+### 整体架构
+- [[hhs/MySQL/01-MySQL 架构与进程模型]] — Server 层与 InnoDB 引擎的分层协作
+- [[hhs/MySQL/07-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比
diff --git a/hhs/MySQL/20-其他存储引擎概览.md b/hhs/MySQL/20-其他存储引擎概览.md
new file mode 100644
index 0000000..b1212fa
--- /dev/null
+++ b/hhs/MySQL/20-其他存储引擎概览.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/21-表结构设计三范式.md b/hhs/MySQL/21-表结构设计三范式.md
new file mode 100644
index 0000000..66ec0bb
--- /dev/null
+++ b/hhs/MySQL/21-表结构设计三范式.md
@@ -0,0 +1,347 @@
+---
+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 字段内(如 ["Go","Python"])
+);
+
+-- ❌ 违反 1NF:同一列存多个值(用逗号分隔)
+CREATE TABLE bad_employees (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ name VARCHAR(100),
+ skills VARCHAR(255) -- 'Go,Python,Docker' — 不行!无法单独查询某个技能
+);
+```
+
+> [!TIP] 现代变通方案
+>
+> MySQL 5.7+ 支持 JSON 类型。虽然从严格范式角度看 JSON 列内部仍有复合结构,但数据库引擎提供了高效的索引和查询能力(如 `JSON_EXTRACT`),所以在工程实践中被广泛接受。**核心原则不变:不要把非结构化数据当字符串拼接处理。**
+
+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)
+);
+```
+
+```sql
+-- ✅ 修正为 BCNF:拆成两张表,让每个决定因素都是超键
+CREATE TABLE courses_teachers (
+ course_id BIGINT PRIMARY KEY,
+ teacher_id BIGINT NOT NULL,
+ UNIQUE KEY uk_course (course_id) -- 一门课只有一位老师
+);
+
+CREATE TABLE student_grades (
+ student_id BIGINT,
+ course_id BIGINT,
+ grade DECIMAL(5, 2),
+ PRIMARY KEY (student_id, course_id),
+ FOREIGN KEY (course_id) REFERENCES courses_teachers(course_id)
+);
+```
+
+> [!QUESTION] 3NF 已经够了,为什么还需要 BCNF?
+>
+> 大多数工程场景 3NF 完全够用。BCNF 解决的问题通常涉及**多重候选键重叠**的复杂建模。如果你遇到 "一门课只能由一位老师教" 这样的约束,且该约束与你的自然主键冲突,才需要考虑 BCNF。实践中建议先做到 3NF,遇到更新异常再往上走。
+
+> [!WARNING] 关于 MySQL 自增主键的陷阱
+> 给所有表加上 `id BIGINT AUTO_INCREMENT` 后,从理论上讲每个表的函数依赖都变成了「所有列直接依赖主键」——**自增 ID 会掩盖范式设计缺陷**。
+>
+> 但这恰恰是现代工程实践的真相:我们用自增代理主键保证查询性能,用外键语义保证设计合理。**范式检查应该在业务层(逻辑模型)上做,而不是在物理表结构上硬抠。**
+
+## 三范式速查总结
+
+读完上面的详细内容,回头用这张表做最后的对比和记忆强化:
+
+| 范式 | 核心问题 | 违规症状 | 一句话修复 |
+|------|---------|---------|-----------|
+| **1NF** | 列是不是原子值? | 一格里塞了多个值 | 拆成多行或多列 |
+| **2NF** | 这列依赖主键的**全部**吗?(仅复合 PK 场景) | 某些列只依赖主键的一部分 | 把只依赖部分的列拆到新表 |
+| **3NF** | 这列有没有**绕道**经过其他非主键列? | B 列的值由 C 列决定,而不是直接由主键决定 | 把 C 列及它决定的所有列拆出去 |
+| **BCNF** | 每个决定因素都是超键吗? | 候选键之间有重叠,约束冲突 | 拆到没有交叉候选键为止 |
+
+---
+
+## 表设计实操流程
+
+知道范式定义是一回事,拿到需求画出一张合理的 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/22-主键策略对比.md b/hhs/MySQL/22-主键策略对比.md
new file mode 100644
index 0000000..21af068
--- /dev/null
+++ b/hhs/MySQL/22-主键策略对比.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/README.md b/hhs/MySQL/README.md
index f1c12d4..d04f996 100644
--- a/hhs/MySQL/README.md
+++ b/hhs/MySQL/README.md
@@ -43,27 +43,80 @@ create time: 2026-05-16 00:00
## 知识体系
-### 一、入门基础
+### 一、入门基础(了解 MySQL 是什么)
+
+先装起来、连上去,知道能存什么类型的数据。
| # | 主题 | 说明 |
|---|------|------|
-| 1.1 | [MySQL 架构概览](./01-MySQL 架构与进程模型.md) | MySQL 分几层(客户端 → SQL → 引擎 → 存储);`mysqld` 是怎么启动的 |
-| 1.2 | [安装与初始化](./02-安装与初始化.md) | 本地安装、Docker Compose 快速启动、基本配置文件 |
-| 1.3 | [客户端工具](./03-客户端工具.md) | 命令行 `mysql`、DBeaver、Workbench,选一个顺手的就行 |
-| 1.4 | [数据类型全景](./04-数据类型全景.md) | 整型、浮点、字符串、日期时间、JSON——每种类型适合存什么 |
-| 1.5 | [字符集与排序规则](./05-字符集与排序规则.md) | 为什么一定要用 `utf8mb4`、中文搜索不出来的坑 |
+| 1 | [MySQL 架构与入门](./01-MySQL 架构与入门.md) | MySQL 分几层(客户端 → SQL → 引擎 → 存储);`mysqld` 是怎么启动的 |
+| 2 | [安装与初始化](./02-安装与初始化.md) | 本地安装、Docker Compose 快速启动、基本配置文件 |
+| 3 | [客户端工具](./03-客户端工具.md) | 命令行 `mysql`、DBeaver、Workbench,选一个顺手的就行 |
+| 4 | [数据类型全景](./04-数据类型全景.md) | 整型、浮点、字符串、日期时间、JSON——每种类型适合存什么 |
+| 5 | [字符集与排序规则](./05-字符集与排序规则.md) | 为什么一定要用 `utf8mb4`、中文搜索不出来的坑 |
> [!TIP] 选择数据类型时,遵循「够用就好」原则
> 能用 TINYINT 就别用 INT,能用 DATETIME 就别用 STRING 存日期——这直接影响索引效率和存储空间。
-### 二、存储引擎与表设计
+### 二、SQL 核心(学会写最常用的语句)
+
+建表、插数据、查数据——这是日常开发 90% 的时间在干的事。
| # | 主题 | 说明 |
|---|------|------|
-| 2.1 | [InnoDB 深度解析](./06-InnoDB 深度解析.md) | InnoDB 是怎么存数据的、聚簇索引的工作原理、缓冲机制概览 |
-| 2.2 | [其他存储引擎概览](./07-其他存储引擎概览.md) | MyISAM / Memory / Archive 的适用场景,为什么生产几乎只用 InnoDB |
-| 2.3 | [表结构设计三范式](./08-表结构设计三范式.md) | 什么是 1NF / 2NF / 3NF、什么时候该故意违反范式 |
-| 2.4 | [主键策略对比](./09-主键策略对比.md) | 自增 ID、UUID、雪花算法——各自对性能的影响 |
+| 6 | [DDL — 建表与结构变更](./06-DDL 建表与结构变更.md) | 怎么创建和修改表结构、大表加字段会不会锁表 |
+| 7 | [DML — 增删改](./07-DML 增删改.md) | INSERT / UPDATE / DELETE 的常用技巧和坑 |
+| 8 | [DQL — SELECT 全解析](./08-DQL SELECT 全解析.md) | SELECT 的执行顺序、去重、分组、分页——查对数据是日常开发的核心能力 |
+| 9 | [JOIN 原理与优化](./09-JOIN 原理与优化.md) | 多张表怎么连起来查、Nested Loop 是怎么工作的 |
+| 10 | [子查询与派生表](./10-子查询与派生表.md) | WHERE / FROM 里的子查询什么时候用、EXISTS 和 IN 有什么区别 |
+| 11 | [UNION 与集合运算](./11-UNION 与集合运算.md) | 把多个查询结果合并在一起,去重 vs 保留重复怎么处理 |
+
+### 三、索引与查询优化(让 SQL 跑得更快)
+
+学会写 SQL 之后,下一步是让 SQL 跑得快的——这就是索引的意义。
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 12 | [B+Tree 索引原理](./12-B+Tree 索引原理.md) | MySQL 为什么选 B+ Tree、它比 Hash / 红黑树好在哪里 |
+| 13 | [聚簇索引与二级索引](./13-聚簇索引与二级索引.md) | 数据存在哪棵树上、查二级索引为什么要"回表" |
+| 14 | [联合索引与最左前缀](./14-联合索引与最左前缀.md) | 多个字段一起建索引怎么用、为什么跳列就用不上了 |
+| 15 | [EXPLAIN 完全指南](./15-EXPLAIN 完全指南.md) | 怎么看 SQL 的执行计划、type 从优到差排哪些 |
+| 16 | [慢查询日志分析](./16-慢查询日志分析.md) | 抓出执行慢的 SQL、用工具分析根因 |
+| 17 | [查询改写技巧](./17-查询改写技巧.md) | OR → UNION ALL、JOIN → EXISTS,同样的结果写法影响性能 |
+| 18 | [深分页优化](./18-深分页优化.md) | `LIMIT 1000000, 20` 为什么慢、怎么优化 |
+
+```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 需额外过滤
+
+### 四、存储引擎(InnoDB 到底是怎么存的?)
+
+学会了怎么用,来看看底层是怎么存的。这部分不是必须立刻搞懂,但理解了会受益很多。
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 19 | [InnoDB 深度解析](./19-InnoDB 深度解析.md) | InnoDB 是怎么存数据的、聚簇索引的工作原理、缓冲机制概览 |
+| 20 | [其他存储引擎概览](./20-其他存储引擎概览.md) | MyISAM / Memory / Archive 的适用场景,为什么生产几乎只用 InnoDB |
```mermaid
graph TB
@@ -100,63 +153,27 @@ graph TB
> - **Redo Log**:操作日志,数据库崩溃了靠它恢复数据
> - **Undo Log**:撤销日志,回滚和版本控制靠它
-### 三、SQL 精解
+### 五、表设计(怎么设计一张好表)
+
+设计先行,写好的表结构能省去后面大量的麻烦。
| # | 主题 | 说明 |
|---|------|------|
-| 3.1 | [DDL — 建表与结构变更](./10-DDL 建表与结构变更.md) | 怎么创建和修改表结构、大表加字段会不会锁表 |
-| 3.2 | [DML — 增删改](./11-DML 增删改.md) | INSERT / UPDATE / DELETE 的常用技巧和坑 |
-| 3.3 | [DQL — SELECT 全解析](./12-DQL SELECT 全解析.md) | SELECT 的执行顺序、去重、分组、分页——查对数据是日常开发的核心能力 |
-| 3.4 | [JOIN 原理与优化](./13-JOIN 原理与优化.md) | 多张表怎么连起来查、Nested Loop 是怎么工作的 |
-| 3.5 | [子查询与派生表](./14-子查询与派生表.md) | WHERE / FROM 里的子查询什么时候用、EXISTS 和 IN 有什么区别 |
-| 3.6 | [UNION 与集合运算](./15-UNION 与集合运算.md) | 把多个查询结果合并在一起,去重 vs 保留重复怎么处理 |
+| 21 | [表结构设计三范式](./21-表结构设计三范式.md) | 什么是 1NF / 2NF / 3NF、什么时候该故意违反范式 |
+| 22 | [主键策略对比](./22-主键策略对比.md) | 自增 ID、UUID、雪花算法——各自对性能的影响 |
-### 四、索引与查询优化
+### 六、事务与并发控制(保证数据不出错)
+
+当多个用户同时操作数据时,如何保证数据不乱?这是进阶的必经之路。
| # | 主题 | 说明 |
|---|------|------|
-| 4.1 | [B+ Tree 索引原理](./16-B+Tree 索引原理.md) | MySQL 为什么选 B+ Tree、它比 Hash / 红黑树好在哪里 |
-| 4.2 | [聚簇索引与二级索引](./17-聚簇索引与二级索引.md) | 数据存在哪棵树上、查二级索引为什么要"回表" |
-| 4.3 | [联合索引与最左前缀](./18-联合索引与最左前缀.md) | 多个字段一起建索引怎么用、为什么跳列就用不上了 |
-| 4.4 | [EXPLAIN 完全指南](./19-EXPLAIN 完全指南.md) | 怎么看 SQL 的执行计划、type 从优到差排哪些 |
-| 4.5 | [慢查询日志分析](./20-慢查询日志分析.md) | 抓出执行慢的 SQL、用工具分析根因 |
-| 4.6 | [查询改写技巧](./21-查询改写技巧.md) | OR → UNION ALL、JOIN → EXISTS,同样的结果写法影响性能 |
-| 4.7 | [深分页优化](./22-深分页优化.md) | `LIMIT 1000000, 20` 为什么慢、怎么优化 |
-
-```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) | 什么是 ACID、Redo Log + Undo Log 怎么保证不出错 |
-| 5.2 | [隔离级别与可见性](./24-隔离级别与可见性.md) | 四个隔离级别各是什么意思,MySQL 默认的是哪个 |
-| 5.3 | [MVCC 原理](./25-MVCC 原理.md) | 不加锁也能并发读的秘密——多版本并发控制 |
-| 5.4 | [锁机制总览](./26-锁机制总览.md) | 从全局锁到行级锁,MySQL 在不同场景下锁什么 |
-| 5.5 | [死锁与排查](./27-死锁与排查.md) | 什么时候会发生死锁、怎么定位和避免 |
-| 5.6 | [一致性读 vs 当前读](./28-一致性读与当前读.md) | 普通 SELECT 读到什么、加 FOR UPDATE 又读到什么 |
+| 23 | [ACID 与原子性实现](./23-ACID 与原子性实现.md) | 什么是 ACID、Redo Log + Undo Log 怎么保证不出错 |
+| 24 | [隔离级别与可见性](./24-隔离级别与可见性.md) | 四个隔离级别各是什么意思,MySQL 默认的是哪个 |
+| 25 | [MVCC 原理](./25-MVCC 原理.md) | 不加锁也能并发读的秘密——多版本并发控制 |
+| 26 | [锁机制总览](./26-锁机制总览.md) | 从全局锁到行级锁,MySQL 在不同场景下锁什么 |
+| 27 | [死锁与排查](./27-死锁与排查.md) | 什么时候会发生死锁、怎么定位和避免 |
+| 28 | [一致性读 vs 当前读](./28-一致性读与当前读.md) | 普通 SELECT 读到什么、加 FOR UPDATE 又读到什么 |
```mermaid
sequenceDiagram
@@ -179,16 +196,18 @@ sequenceDiagram
> - **RC (Read Committed)**:每次查都能看到别的事务刚提交的数据 → 可能出现"不可重复读"
> - **RR (Repeatable Read)**:整个事务内看到的是同一个快照 → 数据一致,这也是 MySQL 的默认设置
-### 六、高可用与分布式
+### 七、高可用与分布式(让数据库永不宕机)
+
+单台 MySQL 扛不住了怎么办?这就涉及到集群和复制。
| # | 主题 | 说明 |
|---|------|------|
-| 6.1 | [Binary Log(Binlog)](./29-Binary Log.md) | Binlog 记录了所有写操作,主从复制和恢复都靠它 |
-| 6.2 | [主从复制详解](./30-主从复制详解.md) | 数据怎么从主库同步到从库、半同步 vs 异步的区别 |
-| 6.3 | [MHA 与 Orchestrator](./31-MHA与Orchestrator.md) | 主库挂了怎么自动切换到从库 |
-| 6.4 | [Group Replication](./32-Group Replication.md) | 多主同时写入、冲突怎么处理 |
-| 6.5 | [InnoDB Cluster](./33-InnoDB Cluster.md) | MySQL 官方的高可用集群方案 |
-| 6.6 | [备份与恢复](./34-备份与恢复.md) | 全量备份和增量备份、恢复到任意时间点 |
+| 29 | [Binary Log(Binlog)](./29-Binary Log.md) | Binlog 记录了所有写操作,主从复制和恢复都靠它 |
+| 30 | [主从复制详解](./30-主从复制详解.md) | 数据怎么从主库同步到从库、半同步 vs 异步的区别 |
+| 31 | [MHA 与 Orchestrator](./31-MHA与Orchestrator.md) | 主库挂了怎么自动切换到从库 |
+| 32 | [Group Replication](./32-Group Replication.md) | 多主同时写入、冲突怎么处理 |
+| 33 | [InnoDB Cluster](./33-InnoDB Cluster.md) | MySQL 官方的高可用集群方案 |
+| 34 | [备份与恢复](./34-备份与恢复.md) | 全量备份和增量备份、恢复到任意时间点 |
```mermaid
flowchart LR
@@ -213,16 +232,18 @@ flowchart LR
> - 至少 **一主两从**,读写分离时注意刚写的数据可能读不到(有延迟)
> - 定期做 **灾备演练**,别等挂了才知道切换不成功
-### 七、工程实践
+### 八、工程实践(生产环境中怎么管)
+
+部署上线后,连接池怎么配、数据大了怎么拆、出了问题怎么排查。
| # | 主题 | 说明 |
|---|------|------|
-| 7.1 | [Go 连接池配置](./35-Go 连接池配置.md) | `sql.DB` 几个关键参数的含义和调优思路 |
-| 7.2 | [Schema 迁移管理](./36-Schema 迁移管理.md) | 版本化的建表脚本、谁在用、怎么保证可回滚 |
-| 7.3 | [分库分表](./37-分库分表.md) | 数据量太大时怎么拆分、跨分片查询怎么办 |
-| 7.4 | [监控指标](./38-监控指标.md) | QPS/TPS、慢查询数、连接数——哪些数据决定数据库健康 |
-| 7.5 | [安全加固](./39-安全加固.md) | 权限管理、加密传输、SQL 注入防护 |
-| 7.6 | [常见踩坑](./40-常见踩坑.md) | ORDER BY 文件排序、COUNT 的区别、timestamp 自动更新 |
+| 35 | [Go 连接池配置](./35-Go 连接池配置.md) | `sql.DB` 几个关键参数的含义和调优思路 |
+| 36 | [Schema 迁移管理](./36-Schema 迁移管理.md) | 版本化的建表脚本、谁在用、怎么保证可回滚 |
+| 37 | [分库分表](./37-分库分表.md) | 数据量太大时怎么拆分、跨分片查询怎么办 |
+| 38 | [监控指标](./38-监控指标.md) | QPS/TPS、慢查询数、连接数——哪些数据决定数据库健康 |
+| 39 | [安全加固](./39-安全加固.md) | 权限管理、加密传输、SQL 注入防护 |
+| 40 | [常见踩坑](./40-常见踩坑.md) | ORDER BY 文件排序、COUNT 的区别、timestamp 自动更新 |
[[hhs/DEV/Go-Database]] — Go 中 sql.DB 的连接池使用模式
@@ -230,21 +251,33 @@ flowchart LR
```mermaid
flowchart TD
- BASE["一、入门基础
架构 + 数据类型"] --> CORE["二、存储引擎与表设计"]
- CORE --> SQL["三、SQL 精解
DDL/DML/DQL"]
- SQL --> INDEX["四、索引与查询优化"]
- SQL --> TXN["五、事务与并发控制"]
- INDEX --> HA["六、高可用与分布式"]
+ BASE["一、入门基础
架构 + 数据类型"] --> SQL["二、SQL 核心
DDL/DML/DQL"]
+ SQL --> INDEX["三、索引与优化
B+Tree/EXPLAIN/改写"]
+ INDEX --> ENGINE["四、存储引擎
InnoDB底层"]
+ INDEX --> DESIGN["五、表设计
范式/主键"]
+ SQL --> TXN["六、事务与并发
ACID/MVCC/锁"]
+ INDEX --> HA["七、高可用与分布式
复制/集群/备份"]
TXN --> HA
- HA --> ENG["七、工程实践
分库分表 + 监控 + 安全"]
+ ENGINE --> PROD["八、工程实践
连接池/监控/分库分表"]
+ DESIGN --> PROD
+ HA --> PROD
style BASE fill:#00B6BC,color:#fff
+ style SQL fill:#4FC08D,color:#000
style INDEX fill:#FF9F43,color:#000
- style TXN fill:#C44569,color:#fff
- style HA fill:#4FC08D,color:#fff
- style ENG fill:#EE5A24,color:#fff
+ style ENGINE fill:#C44569,color:#fff
+ style DESIGN fill:#A0AEC0,color:#000
+ style TXN fill:#EE5A24,color:#fff
+ style HA fill:#0097E6,color:#fff
+ style PROD fill:#4A4A4A,color:#fff
```
+**推荐学习顺序**:`一 → 二 → 三 → 六 → 四 → 五 → 七 → 八`
+
+> - **新手**:一 → 二 → (够用)
+> - **进阶**:一 → 二 → 三 → 六 → 四 → 五
+> - **生产环境**:全学,重点在 三、六、七、八
+
## 关联笔记
- [[hhs/GORM/02-模型定义]] — GORM Model 如何映射到 MySQL 表和字段类型