From 2e84683d9cf8034da5bc9b203e5f3f597d7632e4 Mon Sep 17 00:00:00 2001
From: hhs <386998068@qq.com>
Date: Thu, 21 May 2026 12:20:51 +0800
Subject: [PATCH] vault backup: 2026-05-21 12:20:51
---
hhs/MySQL/01-MySQL 架构与进程模型.md | 268 ----------
.../{ => 01-入门基础}/01-MySQL 架构与入门.md | 0
.../{ => 01-入门基础}/02-安装与初始化.md | 0
hhs/MySQL/{ => 01-入门基础}/03-客户端工具.md | 10 +-
.../{ => 01-入门基础}/04-数据类型全景.md | 0
.../{ => 01-入门基础}/05-字符集与排序规则.md | 6 +-
hhs/MySQL/01-入门基础/README.md | 24 +
hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更.md | 477 +++++++++++++++++
hhs/MySQL/{ => 02-SQL核心}/07-DML 增删改.md | 0
.../{ => 02-SQL核心}/08-DQL SELECT 全解析.md | 214 ++++++--
.../{ => 02-SQL核心}/09-JOIN 原理与优化.md | 281 +++++++++-
.../{ => 02-SQL核心}/10-子查询与派生表.md | 233 ++++++++-
hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md | 364 +++++++++++++
hhs/MySQL/02-SQL核心/README.md | 25 +
.../12-B+Tree 索引原理.md | 72 ++-
.../13-聚簇索引与二级索引.md | 161 ++++--
.../14-联合索引与最左前缀.md | 8 +-
.../15-EXPLAIN 完全指南.md | 6 +-
.../16-慢查询日志分析.md | 20 +-
.../17-查询改写技巧.md | 10 +-
.../{ => 03-索引与查询优化}/18-深分页优化.md | 4 +-
hhs/MySQL/03-索引与查询优化/README.md | 26 +
.../{ => 04-存储引擎}/19-InnoDB 深度解析.md | 30 +-
.../{ => 04-存储引擎}/20-其他存储引擎概览.md | 2 +-
hhs/MySQL/04-存储引擎/README.md | 21 +
.../{ => 05-表设计}/21-表结构设计三范式.md | 0
hhs/MySQL/{ => 05-表设计}/22-主键策略对比.md | 0
hhs/MySQL/05-表设计/README.md | 21 +
hhs/MySQL/06-DDL 建表与结构变更.md | 251 ---------
hhs/MySQL/06-InnoDB 深度解析.md | 479 ------------------
.../23-ACID 与原子性实现.md | 4 +-
.../24-隔离级别与可见性.md | 8 +-
.../{ => 06-事务与并发控制}/25-MVCC 原理.md | 8 +-
.../{ => 06-事务与并发控制}/26-锁机制总览.md | 8 +-
.../{ => 06-事务与并发控制}/27-死锁与排查.md | 4 +-
.../28-一致性读与当前读.md | 4 +-
hhs/MySQL/06-事务与并发控制/README.md | 25 +
hhs/MySQL/07-其他存储引擎概览.md | 221 --------
.../{ => 07-高可用与分布式}/29-Binary Log.md | 8 +-
.../30-主从复制详解.md | 7 +-
.../31-MHA与Orchestrator.md | 4 +-
.../32-Group Replication.md | 6 +-
.../33-InnoDB Cluster.md | 4 +-
.../{ => 07-高可用与分布式}/34-备份与恢复.md | 4 +-
hhs/MySQL/07-高可用与分布式/README.md | 25 +
.../{ => 08-工程实践}/35-Go 连接池配置.md | 0
.../{ => 08-工程实践}/36-Schema 迁移管理.md | 0
hhs/MySQL/{ => 08-工程实践}/37-分库分表.md | 8 +-
hhs/MySQL/{ => 08-工程实践}/38-监控指标.md | 2 +-
hhs/MySQL/{ => 08-工程实践}/39-安全加固.md | 0
hhs/MySQL/{ => 08-工程实践}/40-常见踩坑.md | 6 +-
hhs/MySQL/08-工程实践/README.md | 25 +
hhs/MySQL/08-表结构设计三范式.md | 347 -------------
hhs/MySQL/09-主键策略对比.md | 390 --------------
hhs/MySQL/10-DDL 建表与结构变更.md | 251 ---------
hhs/MySQL/11-DML 增删改.md | 251 ---------
hhs/MySQL/11-UNION 与集合运算.md | 238 ---------
hhs/MySQL/12-DQL SELECT 全解析.md | 312 ------------
hhs/MySQL/13-JOIN 原理与优化.md | 295 -----------
hhs/MySQL/14-子查询与派生表.md | 265 ----------
hhs/MySQL/15-UNION 与集合运算.md | 238 ---------
hhs/MySQL/16-B+Tree 索引原理.md | 379 --------------
hhs/MySQL/17-聚簇索引与二级索引.md | 200 --------
hhs/MySQL/18-联合索引与最左前缀.md | 197 -------
hhs/MySQL/19-EXPLAIN 完全指南.md | 328 ------------
hhs/MySQL/20-慢查询日志分析.md | 366 -------------
hhs/MySQL/21-查询改写技巧.md | 335 ------------
hhs/MySQL/22-深分页优化.md | 281 ----------
hhs/MySQL/README.md | 96 ++--
69 files changed, 2008 insertions(+), 6155 deletions(-)
delete mode 100644 hhs/MySQL/01-MySQL 架构与进程模型.md
rename hhs/MySQL/{ => 01-入门基础}/01-MySQL 架构与入门.md (100%)
rename hhs/MySQL/{ => 01-入门基础}/02-安装与初始化.md (100%)
rename hhs/MySQL/{ => 01-入门基础}/03-客户端工具.md (96%)
rename hhs/MySQL/{ => 01-入门基础}/04-数据类型全景.md (100%)
rename hhs/MySQL/{ => 01-入门基础}/05-字符集与排序规则.md (97%)
create mode 100644 hhs/MySQL/01-入门基础/README.md
create mode 100644 hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更.md
rename hhs/MySQL/{ => 02-SQL核心}/07-DML 增删改.md (100%)
rename hhs/MySQL/{ => 02-SQL核心}/08-DQL SELECT 全解析.md (54%)
rename hhs/MySQL/{ => 02-SQL核心}/09-JOIN 原理与优化.md (50%)
rename hhs/MySQL/{ => 02-SQL核心}/10-子查询与派生表.md (51%)
create mode 100644 hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
create mode 100644 hhs/MySQL/02-SQL核心/README.md
rename hhs/MySQL/{ => 03-索引与查询优化}/12-B+Tree 索引原理.md (82%)
rename hhs/MySQL/{ => 03-索引与查询优化}/13-聚簇索引与二级索引.md (51%)
rename hhs/MySQL/{ => 03-索引与查询优化}/14-联合索引与最左前缀.md (93%)
rename hhs/MySQL/{ => 03-索引与查询优化}/15-EXPLAIN 完全指南.md (97%)
rename hhs/MySQL/{ => 03-索引与查询优化}/16-慢查询日志分析.md (93%)
rename hhs/MySQL/{ => 03-索引与查询优化}/17-查询改写技巧.md (95%)
rename hhs/MySQL/{ => 03-索引与查询优化}/18-深分页优化.md (97%)
create mode 100644 hhs/MySQL/03-索引与查询优化/README.md
rename hhs/MySQL/{ => 04-存储引擎}/19-InnoDB 深度解析.md (91%)
rename hhs/MySQL/{ => 04-存储引擎}/20-其他存储引擎概览.md (98%)
create mode 100644 hhs/MySQL/04-存储引擎/README.md
rename hhs/MySQL/{ => 05-表设计}/21-表结构设计三范式.md (100%)
rename hhs/MySQL/{ => 05-表设计}/22-主键策略对比.md (100%)
create mode 100644 hhs/MySQL/05-表设计/README.md
delete mode 100644 hhs/MySQL/06-DDL 建表与结构变更.md
delete mode 100644 hhs/MySQL/06-InnoDB 深度解析.md
rename hhs/MySQL/{ => 06-事务与并发控制}/23-ACID 与原子性实现.md (98%)
rename hhs/MySQL/{ => 06-事务与并发控制}/24-隔离级别与可见性.md (95%)
rename hhs/MySQL/{ => 06-事务与并发控制}/25-MVCC 原理.md (95%)
rename hhs/MySQL/{ => 06-事务与并发控制}/26-锁机制总览.md (97%)
rename hhs/MySQL/{ => 06-事务与并发控制}/27-死锁与排查.md (98%)
rename hhs/MySQL/{ => 06-事务与并发控制}/28-一致性读与当前读.md (98%)
create mode 100644 hhs/MySQL/06-事务与并发控制/README.md
delete mode 100644 hhs/MySQL/07-其他存储引擎概览.md
rename hhs/MySQL/{ => 07-高可用与分布式}/29-Binary Log.md (95%)
rename hhs/MySQL/{ => 07-高可用与分布式}/30-主从复制详解.md (97%)
rename hhs/MySQL/{ => 07-高可用与分布式}/31-MHA与Orchestrator.md (98%)
rename hhs/MySQL/{ => 07-高可用与分布式}/32-Group Replication.md (96%)
rename hhs/MySQL/{ => 07-高可用与分布式}/33-InnoDB Cluster.md (98%)
rename hhs/MySQL/{ => 07-高可用与分布式}/34-备份与恢复.md (98%)
create mode 100644 hhs/MySQL/07-高可用与分布式/README.md
rename hhs/MySQL/{ => 08-工程实践}/35-Go 连接池配置.md (100%)
rename hhs/MySQL/{ => 08-工程实践}/36-Schema 迁移管理.md (100%)
rename hhs/MySQL/{ => 08-工程实践}/37-分库分表.md (97%)
rename hhs/MySQL/{ => 08-工程实践}/38-监控指标.md (99%)
rename hhs/MySQL/{ => 08-工程实践}/39-安全加固.md (100%)
rename hhs/MySQL/{ => 08-工程实践}/40-常见踩坑.md (97%)
create mode 100644 hhs/MySQL/08-工程实践/README.md
delete mode 100644 hhs/MySQL/08-表结构设计三范式.md
delete mode 100644 hhs/MySQL/09-主键策略对比.md
delete mode 100644 hhs/MySQL/10-DDL 建表与结构变更.md
delete mode 100644 hhs/MySQL/11-DML 增删改.md
delete mode 100644 hhs/MySQL/11-UNION 与集合运算.md
delete mode 100644 hhs/MySQL/12-DQL SELECT 全解析.md
delete mode 100644 hhs/MySQL/13-JOIN 原理与优化.md
delete mode 100644 hhs/MySQL/14-子查询与派生表.md
delete mode 100644 hhs/MySQL/15-UNION 与集合运算.md
delete mode 100644 hhs/MySQL/16-B+Tree 索引原理.md
delete mode 100644 hhs/MySQL/17-聚簇索引与二级索引.md
delete mode 100644 hhs/MySQL/18-联合索引与最左前缀.md
delete mode 100644 hhs/MySQL/19-EXPLAIN 完全指南.md
delete mode 100644 hhs/MySQL/20-慢查询日志分析.md
delete mode 100644 hhs/MySQL/21-查询改写技巧.md
delete mode 100644 hhs/MySQL/22-深分页优化.md
diff --git a/hhs/MySQL/01-MySQL 架构与进程模型.md b/hhs/MySQL/01-MySQL 架构与进程模型.md
deleted file mode 100644
index 7c9dcd9..0000000
--- a/hhs/MySQL/01-MySQL 架构与进程模型.md
+++ /dev/null
@@ -1,268 +0,0 @@
----
-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/01-MySQL 架构与入门.md b/hhs/MySQL/01-入门基础/01-MySQL 架构与入门.md
similarity index 100%
rename from hhs/MySQL/01-MySQL 架构与入门.md
rename to hhs/MySQL/01-入门基础/01-MySQL 架构与入门.md
diff --git a/hhs/MySQL/02-安装与初始化.md b/hhs/MySQL/01-入门基础/02-安装与初始化.md
similarity index 100%
rename from hhs/MySQL/02-安装与初始化.md
rename to hhs/MySQL/01-入门基础/02-安装与初始化.md
diff --git a/hhs/MySQL/03-客户端工具.md b/hhs/MySQL/01-入门基础/03-客户端工具.md
similarity index 96%
rename from hhs/MySQL/03-客户端工具.md
rename to hhs/MySQL/01-入门基础/03-客户端工具.md
index 8f859af..d9f7efb 100644
--- a/hhs/MySQL/03-客户端工具.md
+++ b/hhs/MySQL/01-入门基础/03-客户端工具.md
@@ -338,8 +338,8 @@ mysqlbinlog --start-position=154 --stop-position=892 mysql-bin.000001 | mysql -u
### MySQL 系列
- [[hhs/MySQL/README]] — MySQL 知识库总目录
-- [[hhs/MySQL/02-安装与初始化]] — 安装方法与初始化配置
-- [[hhs/MySQL/34-备份与恢复]] — mysqldump、xtrabackup 完整指南
-- [[hhs/MySQL/29-Binary Log]] — binlog 原理与 mysqlbinlog 深入
-- [[hhs/MySQL/39-安全加固]] — 账号权限、SSL、审计最佳实践
-- [[hhs/MySQL/01-MySQL 架构与进程模型]] — 理解客户端与服务端通信基础
+- [[hhs/MySQL/01-入门基础/02-安装与初始化]] — 安装方法与初始化配置
+- [[hhs/MySQL/07-高可用与分布式/34-备份与恢复]] — mysqldump、xtrabackup 完整指南
+- [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] — binlog 原理与 mysqlbinlog 深入
+- [[hhs/MySQL/08-工程实践/39-安全加固]] — 账号权限、SSL、审计最佳实践
+- [[hhs/MySQL/01-入门基础/01-MySQL 架构与入门]] — 理解客户端与服务端通信基础
diff --git a/hhs/MySQL/04-数据类型全景.md b/hhs/MySQL/01-入门基础/04-数据类型全景.md
similarity index 100%
rename from hhs/MySQL/04-数据类型全景.md
rename to hhs/MySQL/01-入门基础/04-数据类型全景.md
diff --git a/hhs/MySQL/05-字符集与排序规则.md b/hhs/MySQL/01-入门基础/05-字符集与排序规则.md
similarity index 97%
rename from hhs/MySQL/05-字符集与排序规则.md
rename to hhs/MySQL/01-入门基础/05-字符集与排序规则.md
index 2ac8c09..5518fdd 100644
--- a/hhs/MySQL/05-字符集与排序规则.md
+++ b/hhs/MySQL/01-入门基础/05-字符集与排序规则.md
@@ -267,6 +267,6 @@ ALTER DATABASE app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
- [[hhs/Redis/02-核心数据类型]] — Redis 的 STRING 类型对多字节字符的处理
- [[hhs/GORM/02-模型定义]] — GORM 建模时的字符串长度设置要点
-- [[hhs/MySQL/10-DDL 建表与结构变更]] — 大表 DDL 的在线变更策略
-- [[hhs/MySQL/40-常见踩坑]] — 隐式转换等更多常见问题
-- [[hhs/MySQL/29-Binary Log]] — Binlog Format(ROW vs STATEMENT)对字符集的影响
+- [[hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更]] — 大表 DDL 的在线变更策略
+- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — 隐式转换等更多常见问题
+- [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] — Binlog Format(ROW vs STATEMENT)对字符集的影响
diff --git a/hhs/MySQL/01-入门基础/README.md b/hhs/MySQL/01-入门基础/README.md
new file mode 100644
index 0000000..7e234a0
--- /dev/null
+++ b/hhs/MySQL/01-入门基础/README.md
@@ -0,0 +1,24 @@
+---
+tags: [MySQL, Database, 入门]
+create time: 2026-05-20 23:55
+---
+
+# 一、入门基础
+
+## 概述
+
+本章从零开始介绍 MySQL:它的整体架构长什么样、怎么装起来、用什么工具连接、以及数据类型和字符集这些最基础但最容易踩坑的知识点。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 1 | [[hhs/MySQL/01-入门基础/01-MySQL 架构与入门]] | MySQL 分几层(客户端 → SQL → 引擎 → 存储);`mysqld` 是怎么启动的 |
+| 2 | [[hhs/MySQL/01-入门基础/02-安装与初始化]] | 本地安装、Docker Compose 快速启动、基本配置文件 |
+| 3 | [[hhs/MySQL/01-入门基础/03-客户端工具]] | 命令行 `mysql`、DBeaver、Workbench,选一个顺手的就行 |
+| 4 | [[hhs/MySQL/01-入门基础/04-数据类型全景]] | 整型、浮点、字符串、日期时间、JSON——每种类型适合存什么 |
+| 5 | [[hhs/MySQL/01-入门基础/05-字符集与排序规则]] | 为什么一定要用 `utf8mb4`、中文搜索不出来的坑 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更.md b/hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更.md
new file mode 100644
index 0000000..4b4fd1c
--- /dev/null
+++ b/hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更.md
@@ -0,0 +1,477 @@
+---
+tags: [MySQL, DDL, ALTER TABLE, CREATE TABLE, DATATYPE, NULL, ONLINE DDL, INDEX, CHARACTER SET, SNOWFLAKE, PRIMARY KEY]
+create time: 2026-05-16 00:00
+update time: 2026-05-20 12:00
+---
+
+# DDL — 建表与结构变更
+
+## 概述
+
+DDL(Data Definition Language)用于定义和修改数据库对象的结构。本章从最基础的 `CREATE TABLE` 语法出发,覆盖数据类型选型、主键策略、索引设计原则与常见反模式,然后深入 `ALTER TABLE` 操作与在线 DDL 机制,最后给出生产环境的完整 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["选择尺寸"]
+ B --> C["TINYINT
-128 ~ 127"]
+ B --> D["SMALLINT
-3.2 万 ~ 3.2 万"]
+ B --> E["MEDIUMINT
-838 万 ~ 838 万"]
+ B --> F["INT
-21 亿 ~ 21 亿"]
+ B --> G["BIGINT
±922 亿亿"]
+
+ A -->|"否"| H["字符串?"]
+ H --> I["变长 < 255"]
+ I --> J["VARCHAR(N)"]
+ H --> K["定长 (密码/签名/IP)"]
+ K --> L["CHAR(N)"]
+ H --> M["超长 (文章/JSON)"]
+ M --> N["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 内部以二进制紧凑存储,性能接近整数类型。
+
+---
+
+## 主键设计选型
+
+InnoDB 是 **索引组织表(IOT)**——数据和聚簇索引存于一体。主键的选择直接影响磁盘布局、二级索引大小和分库分表的可行性。
+
+| 方案 | 长度 | 有序性 | 可预测性 | 适用场景 |
+|------|------|--------|---------|---------|
+| `BIGINT AUTO_INCREMENT` | 8 字节 | ✅ 天然有序 | ❌ 单调递增暴露业务量 | 单库单表、中小型项目 |
+| `BIGINT UNSIGNED` + 外部生成器 | 8 字节 | ❌ 随机 | ✅ 不可预测 | 高并发分布式场景 |
+| `CHAR(36) UUID()` | 36 字节 | ❌ 完全随机 | ✅ 不可预测 | 需要全局唯一 ID 的场景 |
+| `CHAR(26) ULID / Base62 Snowflake` | 26 字节 | ✅ 时间有序 | ✅ 不可预测 | **推荐**:分布式 + 有序 + 紧凑 |
+
+```sql
+-- ❌ 不推荐:直接用 UUID() 作主键(8.0.13 之前)
+id CHAR(36) PRIMARY KEY DEFAULT (UUID())
+-- 问题:① 36 字节膨胀二级索引;② 随机写入导致页分裂严重;
+-- ③ B+ 树高度增加,缓冲池命中率下降
+
+-- ✅ 推荐:Snowflake 风格 64 位整数
+-- 1 bit 符号位 + 41 bit 毫秒时间戳 + 10 bit 机器标识 + 12 bit 序列号
+-- 最大 ID = 2^63 - 1 ≈ 922 亿亿,可支撑到公元 2262 年
+id BIGINT UNSIGNED NOT NULL PRIMARY KEY
+```
+
+> [!QUESTION] 为什么 Snowflake 比 UUID 更受青睐?
+> 核心在于 **B+ 树的插入局部性**。Snowflake ID 大致有序,InnoDB 可以追加写入最后一页,开销极小。而 UUID v4 完全随机,每次插入都可能触发页面分裂和页迁移,在高并发写入时性能差距可达 **5~10 倍**。
+
+> [!TIP] 自增主键的安全隐患
+> 连续递增的 ID 会泄露业务量级信息(竞争对手可通过 API 返回的 ID 推测日活)。若需隐藏业务数据,可在应用层用 AES 加密 ID([Pseudo Random ID](https://github.com/yookoala/pagination-adapter#prandomid)),或在数据库层添加 `INSERT` 前 `RAND()` 扰序。
+
+---
+
+## 索引设计与反模式
+
+索引是 DDL 中最值得投入精力、也最容易被滥用的部分。一个好的索引可以让查询从全表扫描降到 O(log N)。
+
+### 基本原则
+
+```sql
+-- ✅ 最左前缀原则:联合索引 (a, b, c)
+INDEX idx_abc (a, b, c)
+
+-- 以下查询都能命中索引
+WHERE a = 1 -- ✓ (a,)
+WHERE a = 1 AND b = 2 -- ✓ (a,b)
+WHERE a = 1 AND b = 2 AND c = 3 -- ✓ (a,b,c)
+WHERE a = 1 AND c = 3 -- ✓ (a,) + filescan 查 c
+
+-- 以下查询只能命中部分索引
+WHERE b = 2 AND c = 3 -- ✗ 只用了 (a,b,c) 中的 c,b 无法利用
+WHERE a > 1 AND b = 2 -- ✓ 只有 b 能用到索引,c 不能(> 后断链)
+```
+
+> [!TIP] 最左前缀匹配口诀
+> **「从左往右,碰到范围就停」**——一旦遇到 `>`、`<`、`BETWEEN`、`LIKE 'abc%'` 这类范围条件,后面的列就不再参与索引查找。这也是为什么把等值列放在联合索引前面的原因。
+
+### 覆盖索引 & 回表
+
+```sql
+-- 假设表有 PRIMARY KEY (id), INDEX idx_status (status)
+
+-- ❌ 需要回表:查出不在索引中的列
+SELECT id, username, email FROM users WHERE status = 1;
+-- → 先查 idx_status 找到 id,再按 id 回聚簇索引拿 username/email
+
+-- ✅ 覆盖索引:所有所需列都在索引中,无需回表
+SELECT id FROM users WHERE status = 1;
+-- → 直接从 idx_status 取 id,零回表
+```
+
+> [!NOTE] 覆盖索引(Covering Index)是性能优化的杀手锏。当查询只需的列全部包含在某个二级索引中时,InnoDB 可以直接从索引树提取数据,无需再访问聚簇索引,大幅减少 I/O。
+
+### 常见反模式
+
+```sql
+-- ❌ 函数包裹导致索引失效
+SELECT * FROM users WHERE YEAR(created_at) = 2025;
+-- ✅ 改写为范围查询,利用索引
+SELECT * FROM users
+WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01';
+
+-- ❌ 隐式类型转换
+CREATE TABLE products (sku VARCHAR(20) NOT NULL);
+SELECT * FROM products WHERE sku = 12345; -- 字符串被转成数字
+-- ✅ 保持类型一致
+SELECT * FROM products WHERE sku = '12345';
+
+-- ❌ LIKE 通配符在前
+SELECT * FROM users WHERE username LIKE '%admin%';
+-- ✅ 改写为全文搜索或调整匹配策略
+SELECT * FROM users WHERE username LIKE 'admin%'; -- 可以用索引
+```
+
+> [!WARNING] 隐式类型转换是最隐蔽的性能杀手
+> 当列定义为 `VARCHAR` 但传入的是数值(反之亦然),MySQL 会在每一行上做隐式转换,**导致索引直接失效**。执行 `EXPLAIN` 看到 `type=ALL` 且无 `Using where` 优化时,第一反应就是检查类型是否匹配。
+
+---
+
+## 字符集与排序规则
+
+字符集决定了数据如何编码存储,排序规则决定了字符串比较的顺序。选错会导致乱码、排序错误甚至查询走不了索引。
+
+| 排序规则 | 特点 | 适用场景 |
+|----------|------|---------|
+| `utf8mb4_general_ci` | 速度快但不精确,已废弃 | 旧系统兼容 |
+| `utf8mb4_bin` | 精确的二进制比较,区分大小写 | 密码、Token 等敏感字段 |
+| `utf8mb4_0900_ai_ci` | 8.0 默认,Unicode 9.0 准确排序 | **通用推荐** |
+| `utf8mb4_zh_pinyin_ci` | 按拼音排序中文 | 中文名排序场景 |
+
+> [!TIP] utf8 不是真正的 UTF-8!
+> MySQL 中的 `utf8` 只是一个缩写,最大只支持 3 字节的字符(缺少 Emoji 等 4 字节 Unicode)。**始终使用 `utf8mb4`**,哪怕你确定没有 Emoji。
+
+> [!QUESTION] 排序规则影响索引吗?
+> 是的。如果表 A 用 `utf8mb4_0900_ai_ci` 而表 B 用 `utf8mb4_bin`,两个表做 JOIN 时 MySQL 会在运行时做临时转换,可能导致无法使用索引。关联表的字符集和排序规则应保持一致。
+
+---
+
+## MySQL 8.0 新增能力
+
+### 不可见索引(Invisible Indexes)
+
+上线前验证索引有效性的利器,无需真正删除索引即可测试其影响:
+
+```sql
+-- 将索引设为不可见(优化器不再使用它)
+ALTER TABLE orders ALTER INDEX idx_status INVISIBLE;
+
+-- 验证性能影响后,确认无用再删除
+DROP INDEX idx_status ON orders;
+
+-- 恢复可见
+ALTER TABLE orders ALTER INDEX idx_status VISIBLE;
+```
+
+> [!TIP] 渐进式下线索引流程
+> 1. 设置 `INVISIBLE` → 观察慢查询和错误率 1~7 天
+> 2. 确认无负面影响 → `DROP INDEX`
+> 3. 此方法比直接 `DROP` 更安全,相当于一次「灰度发布」
+
+### 表达式索引(Functional Indexes)
+
+8.0.13+ 支持对函数结果建立索引,无需冗余列:
+
+```sql
+-- 对 JSON 字段内的嵌套属性建立索引
+ALTER TABLE events ADD INDEX json_idx ((JSON_UNQUOTE(JSON_EXTRACT(data, '$.type'))));
+
+-- 对大小写不敏感的搜索建索引
+ALTER TABLE users ADD INDEX lower_email_idx ((LOWER(email)));
+```
+
+### 窗口函数与 CTE
+
+DDL 本身不涉及这些,但它们改变了我们设计表结构的方式——有了分析函数后,某些聚合维度表可以简化:
+
+```sql
+-- 传统做法:额外建一张月粒度汇总表
+CREATE TABLE sales_monthly (
+ month DATE NOT NULL,
+ total DECIMAL(12,2),
+ PRIMARY KEY (month)
+);
+
+-- 8.0 有了窗口函数后,很多场景可以直接在原表上计算
+SELECT sales_date, amount,
+ SUM(amount) OVER (ORDER BY sales_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7d
+FROM daily_sales;
+```
+
+## 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
+ Start["ALTER TABLE 开始"] --> Alg{"ALGORITHM"}
+
+ Alg -->|INPLACE| Inplace["原地重建索引"]
+ Alg -->|COPY| Copy["创建临时表拷贝数据"]
+ Alg -->|DEFAULT(8.0)| Inplace
+
+ Inplace --> Lock{"LOCK"}
+ Copy --> Lock
+
+ Lock|NONE| Online["在线
读写不阻塞"]
+ Lock|SHARED| ReadConc["并发读
写入等待"]
+ Lock|EXCLUSIVE| BlockAll["排他锁
速度最快"]
+
+ style Online fill:#00D866,color:#fff
+ style ReadConc fill:#FF9F43,color:#000
+ style BlockAll 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
+
+### 生产环境 DDL Checklist
+
+在执行任何 `ALTER TABLE` 之前,逐项核对这份清单。养成习惯可以避免 90% 的线上事故。
+
+```text
+□ 1. 确认操作有对应的应用代码上线计划
+ └─ DDL 与应用逻辑必须同步上线,否则可能出现字段不存在但代码已读写该字段的竞态
+
+□ 2. 预估耗时(先在预发/从库验证)
+ └─ SELECT COUNT(*) FROM big_table; → 根据行数估算 COPY 耗时
+ └─ 或使用 --dry-run 模式(pt-osc / gh-ost 均支持)
+
+□ 3. 选择 ALGORITHM=INPLACE + LOCK=NONE
+ └─ 只有在无法在线完成时才考虑 LOCK=EXCLUSIVE + 低峰期执行
+
+□ 4. 检查磁盘空间 ≥ 当前表大小 × 2
+ └─ INPLACE 通常只需少量额外空间;COPY 需要整份表的临时空间
+
+□ 5. 确认主从复制延迟可控
+ └─ DDL 在主库完成前从库会一直阻塞,DDL 完成后可能瞬间追赶大量事件
+ └─ 建议在监控中关注 Seconds_Behind_Master
+
+□ 6. 评估二级索引数量上限
+ └─ 每张表建议不超过 5~7 个索引(含主键)
+ └─ 每个索引都会拖慢 INSERT/UPDATE/DELETE
+ └─ 使用不可见索引先下线无用索引,再删除
+
+□ 7. 准备回滚方案
+ └─ 记录 ALTER 前的 CREATE TABLE 语句(SHOW CREATE TABLE)
+ └─ 如果新结构有问题,可以用原语句重建表
+
+□ 8. 更新文档和迁移脚本
+ └─ 关联笔记中的 GORM model、migration 文件需要同步修改
+```
+
+> [!WARNING] 最危险的操作顺序
+> 1. **先发代码改逻辑读旧列名** → 此时新列还不存在,查询报 `Unknown column`
+> 2. **再执行 ALTER TABLE ADD COLUMN** → 解决第一步的问题,但已有脏数据
+> 3. **最后清理旧列** → 第三次发布才能安全 DROP
+>
+> **最佳做法**:先用 `ADD COLUMN` + 双写(新旧并存),然后发代码切换到新字段,最后再 `DROP OLD_COLUMN`——三步走,每次只做一个动作。
+
+### NULL vs NOT NULL — 选型指南
+
+这是 DDL 中最常见的争论之一。核心原则:**能用 NOT NULL 就不用 NULL**。
+
+```mermaid
+graph TD
+ Q1["是否允许未知状态?"] -->|否| NN["NOT NULL + DEFAULT"]
+ Q1 -->|是| Biz{"业务语义?"}
+
+ Biz -->|逻辑删除/软删| SoftDel["TINYINT DEFAULT 0
0=正常 1=已删除"]
+ Biz -->| truly optional | AllowNull["允许 NULL
但加注释说明含义"]
+
+ style NN fill:#00D866,color:#fff
+ style SoftDel fill:#FF9F43,color:#000
+ style AllowNull fill:#EE5A24,color:#fff
+```
+
+| 维度 | 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/02-SQL核心/07-DML 增删改.md
similarity index 100%
rename from hhs/MySQL/07-DML 增删改.md
rename to hhs/MySQL/02-SQL核心/07-DML 增删改.md
diff --git a/hhs/MySQL/08-DQL SELECT 全解析.md b/hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析.md
similarity index 54%
rename from hhs/MySQL/08-DQL SELECT 全解析.md
rename to hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析.md
index cf49d1a..f81365e 100644
--- a/hhs/MySQL/08-DQL SELECT 全解析.md
+++ b/hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析.md
@@ -7,7 +7,19 @@ create time: 2026-05-16 00:00
## 概述
-SELECT 是 MySQL 中使用频率最高的语句,也是最容易被误解的语句。理解其内部执行顺序是写出高性能查询的第一步。
+SELECT 是 MySQL 中使用频率最高的语句,也是最容易被误解的语句。理解其内部执行顺序是写出**正确且高效**查询的第一步。
+
+本文覆盖 SELECT 从基础语法到进阶优化的所有核心场景:
+
+| 模块 | 内容 | 关键词 |
+|------|------|--------|
+| 执行顺序 | 书写顺序 vs 执行顺序、完整示例 | `WHERE` / `GROUP BY` / `HAVING` / `ORDER BY` |
+| 关键字详解 | DISTINCT、GROUP BY 优化、条件过滤 | 索引利用、聚合 |
+| 条件表达式 | CASE 分支、IF 三目运算 | 数据变形、行转列 |
+| 窗口函数 | 排名、前后行访问、累计计算、帧子句 | `PARTITION BY` / `ROWS BETWEEN` |
+| 子查询与 CTE | 标量子查询、EXISTS、WITH、递归 CTE | 可读性、复用性 |
+| 深分页优化 | 延迟关联、游标分页、性能对比 | LIMIT 陷阱 |
+| 性能贴士 | 常见反模式与修复方案 | Covering Index、索引失效 |
## SQL 书写顺序 vs 执行顺序
@@ -105,6 +117,74 @@ HAVING hire_date >= '2024-01-01' -- 错!HAVING 不能用非聚合
AND AVG(salary) > 15000;
```
+### 条件表达式 — CASE / IF / IFNULL
+
+在 SELECT 中插入"逻辑判断",是做数据变形(Pivot、区间分组)的核心技能。
+
+#### CASE 表达式
+
+SQL 中的 switch-case——标准 SQL 可移植性最好的分支语法:
+
+```sql
+-- ✅ CASE WHEN:多路分支
+SELECT name, salary,
+ CASE
+ WHEN salary >= 20000 THEN 'L5+'
+ WHEN salary >= 15000 THEN 'L4'
+ WHEN salary >= 10000 THEN 'L3'
+ ELSE 'L2-'
+ END AS level
+FROM employees;
+
+-- ✅ CASE WHEN:实现行转列(Pivot)
+-- 统计各部门各职级的员工数
+SELECT department,
+ SUM(CASE WHEN level = 'P' THEN 1 ELSE 0 END) AS individual_count,
+ SUM(CASE WHEN level = 'M' THEN 1 ELSE 0 END) AS manager_count,
+ SUM(CASE WHEN level = 'D' THEN 1 ELSE 0 END) AS director_count
+FROM employees
+GROUP BY department;
+```
+
+> [!TIP] CASE 位置决定影响范围
+> - **WHERE/CASE** → 逐行过滤,走普通索引
+> - **HAVING/CASE** → 需要先聚合再过滤,效率较低
+> - 能用 WHERE 解决的,不要推到 HAVING
+> ```sql
+> -- ❌ 把能写在 WHERE 的判断放到 HAVING
+> SELECT status, COUNT(*) FROM orders GROUP BY status HAVING status IN ('paid', 'shipped');
+> -- ✅ WHERE 先缩小范围,再聚合
+> SELECT status, COUNT(*) FROM orders WHERE status IN ('paid', 'shipped') GROUP BY status;
+> ```
+
+#### IF / IFNULL / COALESCE
+
+MySQL 专属的快捷函数,适合简单场景:
+
+```sql
+-- IF(condition, true_value, false_value) — 三目运算
+SELECT name,
+ IF(status = 'active', '在职', '离职') AS label,
+ IF(salary IS NULL, 0, salary) AS pay
+FROM employees;
+
+-- IFNULL(val, default) — 空值替换
+SELECT order_id, IFNULL(comments, '暂无评价') AS review
+FROM orders;
+
+-- COALESCE(v1, v2, ..., vn) — 返回第一个非 NULL 值
+-- MySQL 8.0.19+ 支持多个参数(之前只支持 2 个)
+SELECT name, COALESCE(alias, nickname, name) AS display_name;
+-- 优先级:别名 > 昵称 > 真实姓名
+```
+
+> [!NOTE] CASE vs IF 的选择
+> | 场景 | 推荐 | 原因 |
+> |------|------|------|
+> | 三路以上分支 | `CASE WHEN` | 可读性好,易扩展 |
+> | 二选一简单判断 | `IF()` | 简洁,但仅限 MySQL |
+> | 空值兜底 | `COALESCE()` | 标准 SQL,比多个 `IFNULL` 嵌套更优雅 |
+
## 窗口函数 (WINDOW FUNCTIONS)
窗口函数是 MySQL 8.0+ 引入的分析利器——它能在**不减少行数**的前提下进行聚合计算。
@@ -146,7 +226,7 @@ SELECT order_date, amount,
FROM daily_sales;
```
-### 累计计算
+### 累计计算 — Running Total / Percentage
```sql
-- 累计求和 (Running Total)
@@ -160,7 +240,23 @@ SELECT department, name, salary,
FROM employees;
```
-> [!NOTE] 窗口函数执行时机
+### 窗口函数执行流程
+
+```mermaid
+flowchart TD
+ A["FROM / JOIN
确定数据源"] --> B["WHERE
逐行过滤"]
+ B --> C["GROUP BY
分组聚合"]
+ C --> D["HAVING
分组后过滤"]
+ D --> E["SELECT 列计算
包括窗口函数"]
+ E --> F["ORDER BY
排序结果"]
+ F --> G["LIMIT / OFFSET
截断输出"]
+
+ style E fill:#00B6BC,color:#fff
+
+ linkStyle 4 stroke-width:3px,fill:none,stroke:#00B6BC
+```
+
+> [!NOTE] 窗口函数的"隐形"特性
> - 执行顺序在 WHERE、GROUP BY、HAVING **之后**,ORDER BY **之前**
> - 因此不能用 WHERE 直接过滤窗口函数的结果——需要套一层子查询:
> ```sql
@@ -170,6 +266,30 @@ FROM employees;
> ) ranked WHERE rn = 1;
> -- 作用:每个用户的最新一条订单记录
> ```
+>
+> 更详细的帧子句说明见下方「### 帧子句 — ROWS BETWEEN」小节。
+
+### 帧子句 — ROWS BETWEEN
+
+默认帧范围是 `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`。用 `ROWS BETWEEN` 可以更精确控制:
+
+```sql
+-- 近 7 天滑动窗口均值
+SELECT order_date, amount,
+ ROUND(AVG(amount) OVER(
+ ORDER BY order_date
+ ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
+ ), 2) AS avg_7day
+FROM daily_sales;
+```
+
+> [!NOTE] 帧子句速查
+> | 语法 | 含义 |
+> |------|------|
+> | `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` | 从分区起点到当前行(**默认**) |
+> | `ROWS BETWEEN 6 PRECEDING AND CURRENT ROW` | 当前行及前 6 行,共 7 行 |
+> | `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING` | 整个分区 |
+> | `ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING` | 前后各 2 行的滑动窗口 |
## 子查询与 CTE
@@ -233,61 +353,40 @@ SELECT * FROM org_chart ORDER BY level, name;
> - **性能**:MySQL 会将非递归 CTE 优化为临时表或内联展开——多数情况下两者性能一致
> - **复用**:同一个 CTE 可在一个语句中多次引用(派生表不行)
----
+## 深分页陷阱
-## 深分页问题
-
-这是 MySQL 最著名的性能陷阱之一。
+`LIMIT offset, size` 在 offset 很大时性能急剧下降——MySQL 仍需扫描并跳过前面所有行。
```sql
--- ❌ 灾难级写法:扫描 100 万行后丢弃前 999,990 行
+-- ❌ 灾难级:扫描 100 万行后丢弃前 999,990 行
SELECT * FROM orders LIMIT 999990, 10;
--- MySQL 需要先定位到第 999990 行,才返回接下来的 10 行
--- ✅ 方案一:延迟关联(Deferred Join)
+-- ✅ 方案一:延迟关联(扫主键索引,只回表 10 次)
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
+-- ✅✅ 方案二:游标分页(推荐,O(log N) 恒定性能)
SELECT * FROM orders
-WHERE id > 999985
+WHERE id > 999985 -- 上一页最后一条的 ID
ORDER BY id ASC
LIMIT 10;
```
-> [!TIP] 为什么游标分页更优?
-> - `WHERE id > ?` 走索引范围扫描,复杂度 O(log N) 而非 O(N)
-> - 无论在第几页,查询时间恒定
-> - 需要前端传「上一页最后一个 ID」作为下一页的游标
+> [!TIP] 分页方案选型
+> | 方案 | 复杂度 | 支持跳页 | 适用场景 |
+> |------|--------|---------|---------|
+> | 传统 `LIMIT n, m` | O(N) | ✅ | 小数据量 (< 1 万行) |
+> | 延迟关联 | O(log N + m) | ✅ | 大数据量、需要精确页码 |
+> | 游标分页 | O(log N) | ❌ | 瀑布流、"加载更多" |
>
-> **局限性**:不支持跳页(不能直接跳到第 100 页),但这对瀑布流场景足够。
+> **更完整的分析与调优策略**请见 → [[hhs/MySQL/03-索引与查询优化/18-深分页优化]]
## 性能小贴士
+### SELECT * 的反面教材
+
```sql
-- ❌ 避免 SELECT *
SELECT * FROM users WHERE status = 1;
@@ -295,18 +394,45 @@ SELECT * FROM users WHERE status = 1;
-- ✅ 只查需要的列
SELECT id, username, email FROM users WHERE status = 1;
-- 好处:减少网络传输、提高 Buffer Pool 命中率、可能触发 Covering Index
+```
--- ❌ 函数包裹索引列
+### 函数包裹索引列
+
+```sql
+-- ❌ 函数包裹导致索引失效
SELECT * FROM users WHERE YEAR(created_at) = 2026;
--- ✅ 用范围替代函数
+-- ✅ 用范围替代函数(走索引范围扫描)
SELECT * FROM users
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01';
```
+### ORDER BY 与 Using filesort
+
+```sql
+-- ✅ 排序列有索引 → 直接按索引顺序输出,无需额外排序
+SELECT * FROM orders ORDER BY user_id;
+-- EXPLAIN Extra: NULL (无 filesort)
+
+-- ⚠️ 混合 ASC/DESC → 无法利用普通 B+Tree 索引排序
+SELECT * FROM orders ORDER BY user_id ASC, created_at DESC;
+-- EXPLAIN Extra: Using filesort — 需要额外的内存/磁盘排序
+```
+
+> [!TIP] 覆盖索引 (Covering Index)
+> 当 SELECT 的列全部包含在某个索引中时,InnoDB 可以直接从索引树返回结果,**无需回表**。
+> ```sql
+> -- 创建覆盖索引:id + status + updated_at 都在 idx 中
+> CREATE INDEX idx_cover ON users(status, updated_at);
+> -- 下面这个查询完全走索引扫描,不回表
+> SELECT status, updated_at FROM users WHERE status = 1;
+> ```
+>
+> **验证方法**:看 EXPLAIN 的 `Extra` 列是否出现 `Using index`。
+
## 关联笔记
-- [[hhs/MySQL/12-JOIN 原理与优化]] — 深入理解 JOIN 的内部执行机制
-- [[hhs/MySQL/13-子查询与派生表]] — 与 SELECT 密切相关的子查询技术
-- [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与原生 SQL 的差异
+- [[hhs/MySQL/02-SQL核心/09-JOIN 原理与优化]] — JOIN 的内部执行机制与驱动表选择
+- [[hhs/MySQL/02-SQL核心/10-子查询与派生表]] — EXISTS / IN 子查询与 CTE 的性能对比
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 通过执行计划识别查询瓶颈
diff --git a/hhs/MySQL/09-JOIN 原理与优化.md b/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md
similarity index 50%
rename from hhs/MySQL/09-JOIN 原理与优化.md
rename to hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md
index ed74c52..fad9499 100644
--- a/hhs/MySQL/09-JOIN 原理与优化.md
+++ b/hhs/MySQL/02-SQL核心/09-JOIN 原理与优化.md
@@ -9,6 +9,114 @@ create time: 2026-05-16 00:00
JOIN 是关系型数据库的核心能力,也是性能问题的主要来源。理解 MySQL 的 JOIN 执行算法才能写出高效的关联查询。
+## 什么是 JOIN
+
+在关系型数据库中,数据通常分散在多张表中——用户信息一张表,订单记录另一张表。**JOIN 就是把这些分散的表按某种规则"拼"在一起,形成一张完整的视图。**
+
+> [!QUESTION] 💡 思考一下
+> 想象你在 Excel 里有两张表:左边是学生名单(含学号),右边是成绩表(也有学号)。你想看到"每个学生对应的成绩"——你会怎么操作?
+> **答**:你会通过"学号"这一列,把两行的数据对应起来。SQL 中的 JOIN 就是这个操作的自动化版本。
+
+### 一个具体的例子
+
+假设有两张表:
+
+| users 表 | | orders 表 | |
+|-----------|---|------------|---|
+| **id** | **name** | **order_id** | **user_id** | **amount** |
+| 1 | Alice | 101 | 1 | ¥200 |
+| 2 | Bob | 103 | 2 | ¥80 |
+| | | 102 | 3 | ¥150 |
+
+我们想查 **"每个订单对应用户的名字"** ——单张表做不到,必须同时看 users 和 orders:
+
+```sql
+SELECT u.name, o.order_id, o.amount
+FROM users u JOIN orders o ON u.id = o.user_id;
+```
+
+结果:
+
+| name | order_id | amount |
+|------|----------|--------|
+| Alice | 101 | ¥200 |
+| Bob | 103 | ¥80 |
+
+> ⚠️ 注意:order_id=102(user_id=3)没有出现在结果中,因为 users 表里没有 id=3 的记录。这就是 JOIN 的匹配逻辑——**只有两边都能对上的行才会被选中**。
+
+```mermaid
+flowchart LR
+ subgraph users 表
+ A1["id=1 / Alice"]
+ A2["id=2 / Bob"]
+ A3["id=3(不存在)"]
+ end
+
+ subgraph orders 表
+ B1["order_id=101
user_id=1 ¥200"]
+ B2["order_id=102
user_id=3 ¥150"]
+ B3["order_id=103
user_id=2 ¥80"]
+ end
+
+ A1 -->|"ON id = user_id"| B1
+ A2 -->|"ON id = user_id"| B3
+ A3 -.->|"users 表中无 id=3"| B2
+
+ style A3 fill:#EE5A24,color:#fff
+ style B2 fill:#EE5A24,color:#fff
+```
+
+> [!NOTE] 为什么要拆成多张表?
+> 你可能会问:为什么不把所有数据存在一张大表里?这涉及数据库设计的核心原则——**避免冗余**。
+> - 如果订单表直接存用户名,用户改名时要更新几万条订单记录
+> - 分表后只改 users 表一条记录,订单表通过 user_id 引用即可
+> - 这就是「范式」(Normal Form)的思想——详见 [[hhs/MySQL/05-表设计/21-表结构设计三范式]]
+
+### 一句话理解 JOIN 的本质
+
+> **JOIN 就是"按条件逐行匹配两张表的数据"**。有索引时能跳过大部分不匹配的行(快),没索引时只能一行一行比对(慢)——后续所有优化都是围绕这一点展开的。
+
+## 驱动表与被驱动表
+
+> [!TIP] 为什么先讲这个?
+> 在深入 JOIN 的执行算法之前,必须先理解**驱动表**和**被驱动表**的概念——它们是所有 JOIN 优化决策的基础。
+
+当一个 JOIN 语句被执行时,MySQL 会将两张表区分角色:
+
+| 角色 | 职责 | 通俗理解 |
+|------|------|---------|
+| **驱动表(Driving Table)** | 先读取数据,提供"查找的关键词" | 手持"点名册"的人 |
+| **被驱动表(Driven Table)** | 根据驱动表提供的每行数据去匹配 | 拿着点名册逐一核对的人 |
+
+上面的例子中:
+
+```sql
+-- users 是驱动表,orders 是被驱动表
+SELECT u.name, o.order_id
+FROM users u -- 👈 驱动表:先读它
+JOIN orders o ON u.id = o.user_id; -- 👆 被驱动表:每次拿 u.id 来匹配
+```
+
+### 谁当驱动表重要吗?
+
+**非常重要。** 核心原则:**用小表驱动大表**——驱动表行数越少,被驱动表被扫描的次数就越少。
+
+```mermaid
+flowchart TD
+ S["小表:100 行"] -->|"驱动"| L["大表:100 万行
匹配 100 次 ✅"]
+
+ L2["大表:100 万行"] -->|"驱动"| S2["小表:100 行
匹配 100 万次 ❌"]
+
+ style L fill:#00D866,color:#fff
+ style S2 fill:#EE5A24,color:#fff
+```
+
+MySQL 的 Optimizer(优化器)会**尝试自动选择最优顺序**——它会统计每张表的行数,评估成本后决定谁做驱动表。但当统计信息不准确或数据分布特殊时,优化器可能选错,这时就需要手动干预。
+
+> [!TIP] 黄金法则
+> **驱动表可以全表扫描,但被驱动表必须走索引。**
+> 任何 JOIN 优化的目标都是确保被驱动表的访问方式足够高效。我们会在后面的「驱动表选择」深入学习如何判断和优化。
+
## JOIN 类型速览
```sql
@@ -77,13 +185,25 @@ flowchart TD
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 表
+> [!QUESTION] 💡 思考
+> 如果被驱动表有百万行数据,每读一行都要跟驱动表的所有行做比较——这比数据库慢的原因更接近"人脑"的逻辑。你觉得 MySQL 在这种极端情况下会怎么优化?
+> **答**:不要逐行带进来比较!先把驱动表的 N 行拼成一大块放到内存缓冲区里,然后对被驱动表只扫一遍就完事。这就是 **Block** Nested-Loop Join 的核心思想。
-总成本 = ceil(rows_users / buffer_rows) × rows_orders
+```mermaid
+flowchart LR
+ S["从驱动表读 N 行"] --> B["写入 Join Buffer
默认 256KB"]
+ B --> P{"Buffer 满了?"}
+ P -->|否| R["扫描被驱动表
与 Buffer 中每行比较"]
+ P -->|是| O["输出已匹配结果"]
+ O --> C["清空 Buffer"]
+ C --> R
+ R --> S
+```
+
+```sql
+-- BNLJ 成本估算公式
+-- 总操作数 ≈ ceil(驱动表行数 / 每Buffer能存行数) × 被驱动表行数
+-- 每Buffer行数 = floor(join_buffer_size / 单行字节数)
```
> [!NOTE] Join Buffer 大小
@@ -94,7 +214,8 @@ Step 4: 重复直到读完 users 表
> -- 注意:它不参与排序也不去重,纯粹做行数据缓存
> ```
-**BNLJ 的致命弱点**:被驱动表无论多大都必须全扫一次。如果两张都是百万级大表,代价呈爆炸式增长。
+> [!WARNING] BNLJ 是最后的手段
+> 被驱动表无论多大都必须全扫一次。两张百万级大表无索引 JOIN 的成本可达 **百亿级行比较**。
### 2. Index Nested-Loop Join(INLJ,索引嵌套循环)
@@ -125,8 +246,9 @@ sequenceDiagram
> [!NOTE] INLJ 的成本拆解
> - 单次查找代价 = log₂(索引页数),通常 ≈ 3~4 次磁盘随机读
-> - 若驱动表 1000 行、被驱动表 100 万行:**1000 × log₂(1M) ≈ 1000 × 20 = 20000 次索引查找**
-> - 对比 BNLJ:若走 BNLJ 则需 **1000 / buffer_rows × 1M** 次行比较 —— 差距巨大
+> - 若驱动表 1000 行、被驱动表 100 万行:**1000 × log₂(1M) ≈ 20,000 次索引查找**
+> - 对比 BNLJ(Buffer 每批存 50 行):ceil(1000/50) × 1,000,000 = **20,000,000 次行比较**
+> - 差距达 **三个数量级**
**INLJ 的两个子分类**:
@@ -135,6 +257,30 @@ sequenceDiagram
| Unique NLJ | 主键 / 唯一索引 | 恰好 0 或 1 行 | `Using index condition` |
| Non-Unique NLJ | 普通二级索引 | 可能 0…N 行 | `Using index condition` |
+### 成本对比:加一个索引能带来什么?
+
+假设场景:**驱动表 1,000 行,被驱动表 100 万行**
+
+```mermaid
+quadrantChart
+ title JOIN 算法性能分布
+ x-axis "低效 ← → 高效"
+ y-axis "高成本 ← → 低成本"
+ "Block NLJ (无索引)": [0.08, 0.1]
+ "Non-Unique NLJ": [0.25, 0.75]
+ "Unique NLJ (主键)": [0.95, 0.95]
+```
+
+```sql
+-- 量化对比 (操作次数)
+-- Unique NLJ (主键): 1000 × log2(1000000) ≈ 20,000 次查找
+-- Non-Unique NLJ (二级): 1000 × log2(1M) × 10 ≈ 200,000 次查找 (假设每 key 平均 10 匹配)
+-- Block NLJ (无索引): ceil(1000/50) × 1,000,000 = 20,000,000 次行比较
+```
+
+> [!SUMMARY] 💡 一句话总结
+> **同一个 JOIN,有索引和无索引相差三个数量级——这就是 MySQL 优化的核心杠杆。**
+
## 驱动表选择
MySQL 在解析 SQL 时,**默认从左到右**确定驱动表——左边第一张表就是驱动表。但 Optimizer 会在评估成本后决定是否交换表的顺序(以最小化被驱动表的扫描行数)。
@@ -238,7 +384,40 @@ JOIN products p ON o.product_id = p.id;
### Multi-Join 处理策略(3+ 表)
-生产中最常见的是 3 表以上 JOIN。优化思路升级:
+生产中最常见的是 3 表以上 JOIN。MySQL 采用 **链式驱动**——前一张表的输出结果作为下一张被驱动表的输入。
+
+```mermaid
+sequenceDiagram
+ participant T1 as 表A (驱动)
+ participant T2 as 表B (被驱动)
+ participant T3 as 表C (被驱动)
+ participant T4 as 表D (被驱动)
+
+ Note over T1: 第一步:全扫 A
得到 N1 行中间结果
+
+ loop N1 行
+ T1->>T2: B ON A.x = B.x (走索引)
+ T2-->>T1: M1 行匹配
+ end
+ Note over T1,T2: 第二步:得到 M1 行中间结果
+
+ loop M1 行
+ T1->>T3: C ON B.y = C.y (走索引)
+ T3-->>T1: M2 行匹配
+ end
+ Note over T1,T3: 第三步:得到 M2 行中间结果
+
+ loop M2 行
+ T1->>T4: D ON C.z = D.z (走索引)
+ T4-->>T1: 最终结果
+ end
+```
+
+> [!QUESTION] 💡 思考
+> 如果有 4 张表按顺序 JOIN,每张表都有合适的索引——那中间结果的行数会逐级递减还是递增?为什么?
+> **答**:取决于 JOIN 条件的选择性。WHERE 过滤和精确匹配会让行数逐层减少;而一对多关联则可能逐层膨胀。**关键是要确保每一层的被驱动表都走索引。**
+
+优化思路升级:
1. **确保第 2 张及之后的每张表都有索引支撑 JOIN 条件**——只有第 1 张表可以全表扫描
2. **用小表驱动大表**:EXPLAIN 输出从上到下依次是被驱动表,上面的驱动下面的
@@ -274,9 +453,83 @@ SELECT * FROM users u JOIN orders o ON u.user_id = o.user_id;
| **隐式转换** | `WHERE varchar_col = 123` | 显式字符串比较 |
| **缺少复合索引** | 多条件 JOIN 只用单列索引 | 创建覆盖联合索引 |
+## 进阶优化技巧
+
+### 1. Index Condition Pushdown(ICP,索引条件下推)
+
+传统扫描方式:二级索引找到 ROWID → 回表取完整行 → WHERE 过滤。ICP 将部分 WHERE 条件的过滤下沉到存储引擎层,在二级索引上就直接完成,减少回表次数。
+
+```sql
+-- 假设 (category, status) 上有联合索引
+SELECT * FROM products WHERE category = 'electronics' AND status = 'active';
+
+-- ICP 开启时 (SHOW STATUS LIKE 'Last_query_cost...'):
+-- 普通模式: 二级索引扫描所有 category='electronics' 的 ROWID → 全部回表 → 再过滤 status
+-- ICP 模式: 二级索引上同时匹配 category + status → 只回表符合两条件的行
+```
+
+> [!TIP] 如何确认 ICP 生效?
+> EXPLAIN 的 Extra 列出现 `Using index condition`——这表示 MySQL 5.6+ 的 ICP 已启用。
+
+### 2. Loose Scan(松散扫描)
+
+当聚合函数(COUNT/DISTINCT/MAX/MIN)配合有序索引使用时,Optimizer 可以跳过中间重复值,直接"跳跃"到每组第一个记录。
+
+```sql
+-- 假设 (category, subcategory) 上有联合索引
+-- 传统 GROUP BY: 扫描所有 10 万行 → 分组 → 每组选第一条
+SELECT category, COUNT(DISTINCT subcategory) FROM products GROUP BY category;
+
+-- Loose Scan: 直接从二级索引树中抽取每组的唯一子键
+-- 代价从全表扫描降到仅遍历索引的第一层 ≈ O(unique_categories)
+```
+
+> [!NOTE] 触发条件
+> - 聚合函数必须是 `MIN()/MAX()` 或 `COUNT(DISTINCT)`
+> - GROUP BY 的列必须紧跟联合索引的最左前缀
+> - 查询结果中不能有其他需要额外处理的列
+
+### 3. Semi Join(半连接)
+
+将子查询转化为 JOIN 的一种优化策略。MySQL 会对 IN / EXISTS 子查询自动做半连接转换。
+
+```sql
+-- 原始子查询
+SELECT * FROM orders WHERE user_id IN (SELECT id FROM users WHERE level = 'vip');
+
+-- 等价于半连接:orders 只需要知道"是否存在匹配",不需要返回 users 的所有匹配行
+-- MySQL 内部可能选择以下策略之一:
+-- DuplicateWeedout: 先做 JOIN 再去重
+-- FirstMatch: 被驱动表找到第一行匹配就停止
+-- Loosescan: 对被驱动表做 Loose Scan
+```
+
+> [!WARNING] 注意去重开销
+> 半连接需要额外的去重步骤(DuplicateWeedout)。如果驱动表本身 SELECT DISTINCT,可以考虑先对驱动表去重再做 JOIN。
+
+### 4. Derived Table Materialization(派生表物化)
+
+复杂子查询作为 FROM 子句时,MySQL 会将其物化为临时表。过度物化会带来额外开销。
+
+```sql
+-- 派生表会被物化为临时表
+SELECT * FROM (SELECT user_id, SUM(amount) FROM orders GROUP BY user_id) t
+JOIN users u ON t.user_id = u.id;
+
+-- 如果 optimizer_switch='derived_merge=on'(MySQL 5.7+ 默认),
+-- 优化器会尝试将子查询合并到外层查询中,避免物化开销
+```
+
+> [!TIP] 调优建议
+> ```sql
+> -- 查看当前优化器开关状态
+> SHOW VARIABLES LIKE 'optimizer_switch';
+> -- derived_merge=on 是推荐的默认配置,能让 MySQL 自动展开简单派生表
+> ```
+
## 性能对比速查表
-| 算法 | 最优场景 | 最坏场景 | 典型代价公式 |
+| **算法** | **最优场景** | **最坏场景** | **典型代价公式** |
|------|---------|---------|-------------|
| **Unique NLJ** | 被驱动表是主键 / 唯一索引 | 驱动表大量行无匹配(返回 NULL) | 驱动表行数 × log(被驱动表) |
| **Non-Unique NLJ** | 被驱动表有普通二级索引 | 索引选择性差,大量重复值 | 驱动表行数 × log(被驱动表) × 平均匹配数 |
@@ -290,6 +543,6 @@ SELECT * FROM users u JOIN orders o ON u.user_id = o.user_id;
## 关联笔记
-- [[hhs/MySQL/14-子查询与派生表]] — EXISTS / IN 子查询与 JOIN 的替代关系
-- [[hhs/MySQL/16-B+Tree 索引原理]] — JOIN 如何利用二级索引加速
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 type 字段识别 JOIN 质量问题
+- [[hhs/MySQL/02-SQL核心/10-子查询与派生表]] — EXISTS / IN 子查询与 JOIN 的替代关系
+- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — JOIN 如何利用二级索引加速
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 通过 type 字段识别 JOIN 质量问题
diff --git a/hhs/MySQL/10-子查询与派生表.md b/hhs/MySQL/02-SQL核心/10-子查询与派生表.md
similarity index 51%
rename from hhs/MySQL/10-子查询与派生表.md
rename to hhs/MySQL/02-SQL核心/10-子查询与派生表.md
index a08f045..1451e91 100644
--- a/hhs/MySQL/10-子查询与派生表.md
+++ b/hhs/MySQL/02-SQL核心/10-子查询与派生表.md
@@ -1,5 +1,5 @@
---
-tags: [MySQL, 子查询, EXISTS, IN, 派生表]
+tags: [MySQL, 子查询, EXISTS, IN, 派生表, CTE]
create time: 2026-05-16 00:00
---
@@ -32,6 +32,8 @@ graph BT
## 标量子查询
+### 基本用法
+
```sql
-- 用法 1:在 SELECT 中调用
SELECT
@@ -48,10 +50,39 @@ INSERT INTO reports (month, order_total)
VALUES ('2026-05', (SELECT SUM(amount) FROM orders WHERE MONTH(created_at) = 5));
```
+### 相关 vs 无关子查询
+
+这是理解子查询性能的基石:**子查询是否引用了外层查询的列?**
+
+```mermaid
+flowchart LR
+ UC["子查询"] --> UCX{"是否引用外层表的列?"}
+ UCX -->|否:无关子查询
Uncorrelated| UCN["先执行一次
结果供外层复用 ✅"]
+ UCX -->|是:相关子查询
Correlated| UCR["对每一行外层记录
都要重新执行 ⚠️"]
+
+ style UCN fill:#00B6BC,color:#fff
+ style UCR fill:#FF9F43,color:#000
+```
+
+> [!EXAMPLE] 对比示例
+> ```sql
+> -- 🟢 无关子查询:只执行一次
+> -- 子查询完全不依赖 users 表
+> SELECT * FROM products
+> WHERE price > (SELECT AVG(price) FROM products);
+>
+> -- 🔴 相关子查询:执行 N 次(N = users 行数)
+> -- 子查询引用了外层 u.id
+> SELECT u.username,
+> (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
+> FROM users u;
+> -- 10 万用户 → 子查询执行 10 万次
+> ```
+
> [!WARNING] 标量子查询的性能隐患
> MySQL 8.0.21 之前,标量子查询**无法被物化**,会导致每次执行都重新计算(N+1 问题)。
> ```sql
-> -- ❌ 慢:每个用户都要查一次 orders 表
+> -- ❌ 慢:每个用户都要查一次 orders 表(相关子查询)
> SELECT u.username,
> (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
> FROM users u;
@@ -193,19 +224,30 @@ 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
+对比两种情况的执行计划:
+
+```mermaid
+graph LR
+ subgraph "❌ 物化型派生表"
+ T1["select_type: DERIVED"] -->|orders | T2["type: ALL
Extra: Using temporary, Using filesort"]
+ P1["PRIMARY → "] -->|全表扫描| P2["type: ALL"]
+ P3["PRIMARY → u"] -->|全表扫描| P4["Extra: Using where"]
+ end
+
+ subgraph "✅ 可展开型派生表"
+ F1["select_type: SIMPLE"] -->|orders | F2["type: ALL
Extra: NULL(无额外开销)"]
+ F3["SIMPLE → u"] -->|索引扫描| F4["type: index
Extra: Using index"]
+ end
+
+ style T2 fill:#FF9F43,color:#000
+ style P2 fill:#EE5A24,color:#fff
+ style F2 fill:#00D866,color:#fff
+ style F4 fill:#00D866,color:#fff
```
> [!TIP] 性能优化技巧
@@ -213,6 +255,52 @@ FROM users u JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
> - `optimizer_switch='derived_merge=on'`(默认开启)控制是否允许 Flattening
> - 对于超大结果集物化,注意 `tmp_table_size` / `max_heap_table_size` 限制
+### 常见陷阱与最佳实践
+
+| 坑 | 症状 | 解法 |
+|----|------|------|
+| **缺少别名** | `Every derived table must have its own alias` | 派生表必须起别名:`FROM (...) AS t` |
+| **自引用冲突** | `Table 'xxx' is specified twice` | UPDATE/DELETE 中的派生表需用嵌套包裹 |
+| **NULL 传播** | NOT IN 返回空集 | 改用 `NOT EXISTS` 或加 `IS NOT NULL` 过滤 |
+| **隐式类型转换** | 索引失效,全表扫描 | 确保比较列类型一致(如 `VARCHAR` vs `INT`)|
+| **大结果集物化** | `Using temporary; Using filesort` | 用 JOIN 重写或拆分为多次查询 |
+
+## 实战:性能排查 Checklist
+
+遇到慢的子查询,按以下顺序逐项检查:
+
+```mermaid
+flowchart TD
+ S["慢查询:含子查询"] --> C1{"EXPLAIN 看了吗?"}
+ C1 -->|没看| STOP["🛑 先跑 EXPLAIN
别盲猜,数据说话"]
+ C1 -->|看了| C2{"Extra 有 Using temporary 吗?"}
+
+ C2 -->|"是:有临时表"| D1["派生表物化了 → 考虑改 JOIN 或提升为 CTE"]
+ C2 -->|"否"| C3{"有 Using filesort 吗?"}
+
+ C3 -->|"是"| D2["排序开销大 → 检查是否有可用索引"]
+ C3 -->|"否"| C4{"子查询是否在 SELECT 列表中?"}
+
+ C4 -->|"是:标量子查询"| D3["N+1 问题 → 改写为 LEFT JOIN + GROUP BY"]
+ C4 -->|"否"| C5{"用了 NOT IN 吗?"}
+
+ C5 -->|"是"| D4["NULL 陷阱 → 换 NOT EXISTS"]
+ C5 -->|"否"| C6{"关联列类型一致吗?"}
+
+ C6 -->|"不一致"| D5["隐式类型转换导致索引失效 → 统一类型"]
+ C6 -->|"一致"| DONE["✅ 执行计划合理,可能是业务逻辑本身复杂"]
+
+ style STOP fill:#EE5A24,color:#fff
+ style D1 fill:#FF9F43,color:#000
+ style D2 fill:#FF9F43,color:#000
+ style D3 fill:#FF9F43,color:#000
+ style D4 fill:#EE5A24,color:#fff
+ style D5 fill:#FF9F43,color:#000
+ style DONE fill:#00D866,color:#fff
+```
+
+> 核心原则:**先看 EXPLAIN,再动手改**。很多情况下不是子查询的问题,而是缺了索引或类型不匹配。
+
## ANY / ALL / SOME
```sql
@@ -245,15 +333,134 @@ graph TB
style R2 fill:#C44569,color:#fff
```
+## UPDATE / DELETE 中的子查询
+
+子查询不仅限于 SELECT,UPDATE 和 DELETE 同样可以使用。
+
+```sql
+-- UPDATE:用子查询结果更新列
+UPDATE employees e
+SET salary = (
+ SELECT avg_salary FROM (
+ SELECT AVG(salary) AS avg_salary
+ FROM employees WHERE department = e.department
+ ) tmp
+)
+WHERE dept_level = 'manager';
+-- ⚠️ MySQL 要求嵌套一层:不能直接在 UPDATE 中引用被更新的表
+-- 所以要用派生表包裹一下(tmp)来绕过这个限制
+
+-- DELETE:条件过滤删除记录
+DELETE FROM sessions
+WHERE user_id IN (
+ SELECT id FROM users WHERE status = 'banned'
+);
+
+-- 关联删除:保留每个分组中最新的一条
+DELETE t1 FROM logs t1
+INNER JOIN logs t2
+ ON t1.category = t2.category
+ AND t1.created_at < t2.created_at;
+-- 虽然这不是子查询写法,但效果等价于上面的子查询逻辑
+-- 实际场景中更推荐 JOIN 写法,性能更好
+```
+
+## CTE:子查询的现代写法(MySQL 8.0+)
+
+`WITH` 子句(Common Table Expression)是 MySQL 8.0 引入的新语法,本质上是给派生表起了一个「有名字的、可复用的」外壳。
+
+```sql
+-- 传统派生表写法
+SELECT dept, avg_salary
+FROM (
+ SELECT department AS dept, AVG(salary) AS avg_salary
+ FROM employees WHERE status = 'active'
+ GROUP BY department
+) AS dt
+WHERE avg_salary > 15000;
+
+-- ✅ 等价 CTE 写法
+WITH dept_stats AS (
+ SELECT department AS dept, AVG(salary) AS avg_salary
+ FROM employees WHERE status = 'active'
+ GROUP BY department
+)
+SELECT dept, avg_salary
+FROM dept_stats
+WHERE avg_salary > 15000;
+```
+
+```mermaid
+flowchart LR
+ subgraph "传统派生表"
+ D1["FROM (...)"] -->|可读性差| D2["重复写的逻辑难以复用"]
+ D3["嵌套深时层级混乱 😵"]
+ end
+
+ subgraph "CTE 写法"
+ C1["WITH name AS (...)"] -->|语义清晰| C2["可在主查询中多次引用"]
+ C3["链式 CTE 层层递进 🧩"]
+ end
+
+ D1 -.->|"功能等价"| C1
+ D2 -.->|"可读性提升"| C2
+ D3 -.-> "|简化复杂查询|" C3
+
+ style C1 fill:#00D866,color:#fff
+ style C2 fill:#00B6BC,color:#fff
+ style C3 fill:#00B6BC,color:#fff
+```
+
+### 递归 CTE
+
+这是子查询体系无法做到的能力——自我引用的 CTE 可以遍历树形结构。
+
+```sql
+-- 递归 CTE:生成从 1 到 10 的序列
+WITH RECURSIVE nums AS (
+ SELECT 1 AS n -- 锚点成员(递归起点)
+ UNION ALL
+ SELECT n + 1 FROM nums WHERE n < 10 -- 递归成员
+)
+SELECT * FROM nums;
+
+-- 实战:查询组织的完整汇报线
+WITH RECURSIVE org_chain AS (
+ SELECT id, name, manager_id, 1 AS level
+ FROM employees WHERE manager_id IS NULL -- 根节点:CEO
+ UNION ALL
+ SELECT e.id, e.name, e.manager_id, oc.level + 1
+ FROM employees e
+ INNER JOIN org_chain oc ON e.manager_id = oc.id -- 递归:逐级下钻
+)
+SELECT * FROM org_chain ORDER BY level, name;
+```
+
+> [!WARNING] 递归 CTE 的注意事项
+> - MySQL 默认递归深度上限为 **1000**(`cte_max_recursion_depth`),超出会报错
+> - 务必设置合理的终止条件,否则会无限递归直到达到上限
+> - 递归部分不能直接用 `LIMIT` 来截断结果
+
+> [!TIP] 何时用 CTE vs 派生表?
+> | 场景 | 推荐 |
+> |------|------|
+> | 只需引用一次,逻辑简单 | 派生表(更轻量) |
+> | 需要多次引用同一段逻辑 | CTE(DRY 原则) |
+> | 需要嵌套多层 | CTE(可读性碾压) |
+> | 需要递归查询(树形结构) | 只能 CTE |
+> | 兼容 MySQL 5.7 | 只能派生表 |
+
## 总结:核心要点回顾
| 主题 | 一句话 |
|------|--------|
| 标量子查询 | MySQL 8.0.21 之前不可物化,百万行数据必踩 N+1 陷阱,优先改写为 JOIN |
+| 相关 vs 无关 | 相关子查询引用了外层列,每行都要重新执行——性能杀手 |
| IN vs EXISTS | 语义上 IN 看值、EXISTS 看存在性;不确定时选 EXISTS,更安全的默认选项 |
| NOT IN 陷阱 | 子查询出现 NULL 即返回空集,生产中几乎永远该用 `NOT EXISTS` 替代 |
| 派生表物化 | 有 GROUP BY / LIMIT / UNION 等操作的子查询无法被展开,必然产生临时表开销 |
| ANY / ALL | 对应 SQL 的 OR / AND 累加,ALL 在空子查询结果时恒返回 TRUE(反直觉,需注意) |
+| CTE | MySQL 8.0+ 现代写法,可读性和可维护性优于匿名派生表,唯一支持递归的子查询形式 |
> [!TIP] 核心心法
> **子查询不是万能的——它首先是为了表达清晰,其次才是性能。**
@@ -261,5 +468,5 @@ graph TB
## 关联笔记
-- [[hhs/MySQL/12-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系
-- [[hhs/MySQL/13-JOIN 原理与优化]] — EXISTS 子查询常可重写为 JOIN
+- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系
+- [[hhs/MySQL/02-SQL核心/09-JOIN 原理与优化]] — EXISTS 子查询常可重写为 JOIN
diff --git a/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md b/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
new file mode 100644
index 0000000..17edd82
--- /dev/null
+++ b/hhs/MySQL/02-SQL核心/11-UNION 与集合运算.md
@@ -0,0 +1,364 @@
+---
+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
+-- ❌ 错误写法:WHERE / ORDER BY / LIMIT 只作用于最后一个 SELECT
+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' -- ⚠️ 只过滤 logs_202605!
+ORDER BY created_at DESC
+LIMIT 50;
+
+-- ✅ 正确写法:如果需要对每个分表单独过滤,用子查询包裹
+SELECT * FROM
+ (SELECT * FROM logs_202601 WHERE status = 'error') AS t1
+UNION ALL
+SELECT * FROM
+ (SELECT * FROM logs_202602 WHERE status = 'error') AS t2
+UNION ALL
+SELECT * FROM
+ (SELECT * FROM logs_202603 WHERE status = 'error') AS t3
+UNION ALL
+SELECT * FROM
+ (SELECT * FROM logs_202604 WHERE status = 'error') AS t4
+UNION ALL
+SELECT * FROM
+ (SELECT * FROM logs_202605 WHERE status = 'error') AS t5
+ORDER BY created_at DESC
+LIMIT 50;
+```
+
+> [!NOTE] 分表合并的注意事项
+> - `WHERE / ORDER BY / LIMIT` **仅作用于 UNION 中最后一个 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,
+ 3 AS rank_score -- 标题命中,权重最高
+FROM products
+WHERE title LIKE '%runners%'
+UNION ALL
+SELECT id, title, description,
+ 2 AS rank_score -- 描述命中,权重次之
+FROM products
+WHERE description LIKE '%runners%'
+ AND title NOT LIKE '%runners%'; -- 排除已在上面出现的
+ORDER BY rank_score DESC, id;
+```
+
+> [!NOTE] 多条件搜索的 UNION ALL 策略
+> - 通过不同 SELECT 赋予不同权重(`rank_score`),合并后统一 `ORDER BY` 即可实现"加权排序"
+> - **注意去重边界**:第二个 SELECT 加 `NOT LIKE` 过滤可避免重复输出同一商品。如果无法在前端精确排重,可以考虑外层套一层 `DISTINCT`(代价是会退化为 UNION)或改用 `GROUP BY id`
+
+```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
+```
+
+## 执行计划特征(EXPLAIN)
+
+用 `EXPLAIN` 观察 UNION 的执行特征,能直观看到 Optimizer 的处理策略:
+
+```sql
+CREATE TABLE users_a (id INT PRIMARY KEY, name VARCHAR(64), city_id INT);
+CREATE TABLE users_b (id INT PRIMARY KEY, name VARCHAR(64), city_id INT);
+
+EXPLAIN SELECT * FROM users_a WHERE city_id = 10
+UNION ALL
+SELECT * FROM users_b WHERE city_id = 10;
+```
+
+期望的 EXPLAIN 输出:
+
+```
++----+--------------+------------+------+---------------+------+---------+-------+-------+------+
+| id | select_type | table | type | key | extra| rows | ... | ref | |
++----+--------------+------------+------+---------------+------+---------+-------+-------+------+
+| 1 | PRIMARY | users_a | ref | idx_city_id | NULL | 100 | ... | const | |
+| 2 | UNION | users_b | ref | idx_city_id | NULL | 100 | ... | const | |
++----+--------------+------------+------+---------------+------+---------+-------+-------+------+
+```
+
+关键解读:
+
+| 字段 | 含义 | UNION 中的表现 |
+|------|------|--------------|
+| **select_type** | 查询类型 | `PRIMARY`(第一个 SELECT) + 每个后续 SELECT 标记为 `UNION` |
+| **table** | 涉及的表 | 每个 UNION 分支独立显示一行 |
+| **Extra** | 附加信息 | UNION ALL 无额外信息;使用去重版 UNION 时 Extra 会出现 `Using temporary; Using filesort` |
+
+> [!WARNING] UNION 去重的隐藏代价
+>
+> ```sql
+> -- 对比两个版本的 Extra
+> EXPLAIN SELECT * FROM users_a WHERE city_id = 10
+> UNION ALL -- Extra: (空)
+> SELECT * FROM users_b WHERE city_id = 10;
+>
+> EXPLAIN SELECT * FROM users_a WHERE city_id = 10
+> UNION -- Extra: Using temporary; Using filesort
+> SELECT * FROM users_b WHERE city_id = 10;
+> ```
+>
+> MySQL 内部会将所有 UNION 分支的结果收集到一个**临时表**中,然后对这个临时表做**全表扫描 + 文件排序**来完成去重。当结果集很大时,这就是性能瓶颈所在。
+>
+> 生产排查建议:如果 UNION 查询慢,先用 `EXPLAIN` 确认 Extra 是否包含 `Using temporary`;再用 `EXPLAIN ANALYZE`(MySQL 8.0.16+)查看各分支的实际行数和耗时。
+
+---
+
+## 与 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
+```
+
+## 常见陷阱与排查
+
+| 陷阱 | 症状 | 解法 |
+|------|------|------|
+| **列数不匹配** | `The used SELECT statements have a different number of columns` | 确保每个 SELECT 的列数完全相同;用 `NULL` 补齐 |
+| **类型隐式转换** | UNION 中对应列类型不一致导致全表扫描或结果错误 | 手动 CAST 到一致类型(如 `CAST(0 AS CHAR)`) |
+| **ORDER BY 位置错误** | MySQL 忽略分支内的 ORDER BY 或报错 1221 | ORDER BY / LIMIT 放整个 UNION 的最后;需要保护内部排序请用派生表包装 |
+| **UNION ALL 产生重复行** | 业务期望唯一结果但实际有重复 | 检查是否真的有去重需求 — 有的话用 UNION;无法避免时用 DISTINCT/GROUP BY |
+| **分表 WHERE 漏写** | WHERE 只过滤最后一个分表,前面全部数据入库 | 每个分表子查询独立加 WHERE,或用视图封装 |
+| **分页错位** | OFFSET 导致合并后第一页显示第二页数据 | 先合并再整体 OFFSET,不能用局部 LIMIT + OFFSET 代替全局分页 |
+
+### 实战排查 Checklist
+
+遇到 UNION 相关慢查询或异常时,按以下顺序逐项检查:
+
+```mermaid
+flowchart TD
+ S["UNION 查询异常/慢"] --> C1{"EXPLAIN 看了吗?"}
+ C1 -->|没看| STOP["🛑 先跑 EXPLAIN
别盲猜,看 select_type 确认分支数"]
+ C1 -->|看了| C2{"Extra 有 Using temporary 吗?"}
+
+ C2 -->|"是:UNION 去重"| D1["换 UNION ALL + 业务侧保证不重复"]
+ C2 -->|"否"| C3{"列数/列类型一致吗?"}
+
+ C3 -->|"不一致"| D2["CAST 统一类型
否则可能隐式转换导致索引失效"]
+ C3 -->|"一致"| C4{"ORDER BY 在最终位置吗?"}
+
+ C4 -->|"否,放在中间"| D3["移动到最后
或在每个分支外加子查询包裹"]
+ C4 -->|"是"| C5{"WHERE 对所有分支都生效吗?"}
+
+ C5 -->|"否"| D4["每个分子查询独立加条件"]
+ C5 -->|"是"| DONE["✅ SQL 结构正确,
继续排查索引和数据分布"]
+
+ style STOP fill:#EE5A24,color:#fff
+ style D1 fill:#FF9F43,color:#000
+ style D2 fill:#FF9F43,color:#000
+ style D3 fill:#FF9F43,color:#000
+ style D4 fill:#EE5A24,color:#fff
+ style DONE fill:#00D866,color:#fff
+```
+
+## 核心要点回顾
+
+| 主题 | 一句话 |
+|------|--------|
+| UNION ALL vs UNION | 能用 ALL 就绝不用 UNION — 去重的临时表+文件排序代价远超想象 |
+| ORDER BY/LIMIT 作用域 | 只在 UNION 最后一个 SELECT 生效;跨表排序必须外层包装子查询 |
+| 字段匹配规则 | 列数必须相同,类型应尽量兼容 — 列名取自第一个 SELECT |
+| Feed 流/多源合并 | UNION ALL 的经典场景,一次 DB 往返搞定多数据源混合 |
+| 分表汇总 | 同构分表最理想的聚合方式,但注意 WHERE 只对最后一个分支生效 |
+| 执行计划标识 | PRIMARY + UNION 双行 — Extra 中出现 `Using temporary` 就是去重开销的信号 |
+
+> [!TIP] 核心心法
+> **UNION ALL 是你最好的朋友,UNION 是你的最后手段。**
+> 写 UNION 查询前问自己三个问题:① 结果集真的需要去重吗?② 能不能用 CASE WHEN 单表扫描代替?③ 如果拆成 N 次简单查询,应用层合并是不是更清晰?大部分时候答案会让你回到 UNION ALL。
+
+## 关联笔记
+
+- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — SELECT 基础语法与 UNION 的结合使用
+- [[hhs/MySQL/02-SQL核心/07-DML 增删改]] — DML 中的批量操作与 UNION 的互补关系
diff --git a/hhs/MySQL/02-SQL核心/README.md b/hhs/MySQL/02-SQL核心/README.md
new file mode 100644
index 0000000..f7a4af1
--- /dev/null
+++ b/hhs/MySQL/02-SQL核心/README.md
@@ -0,0 +1,25 @@
+---
+tags: [MySQL, SQL, DDL, DML, DQL]
+create time: 2026-05-20 23:55
+---
+
+# 二、SQL 核心
+
+## 概述
+
+本章覆盖日常开发中最常用的 SQL 操作:建表改表(DDL)、增删改(DML)、查询(DQL),以及多表关联(JOIN)、子查询、集合运算等进阶写法。掌握这些就能完成 90% 的日常数据库操作。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 6 | [[hhs/MySQL/02-SQL核心/06-DDL 建表与结构变更]] | 怎么创建和修改表结构、大表加字段会不会锁表 |
+| 7 | [[hhs/MySQL/02-SQL核心/07-DML 增删改]] | INSERT / UPDATE / DELETE 的常用技巧和坑 |
+| 8 | [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] | SELECT 的执行顺序、去重、分组、分页 |
+| 9 | [[hhs/MySQL/02-SQL核心/09-JOIN 原理与优化]] | 多张表怎么连起来查、Nested Loop 是怎么工作的 |
+| 10 | [[hhs/MySQL/02-SQL核心/10-子查询与派生表]] | WHERE / FROM 里的子查询什么时候用、EXISTS 和 IN 有什么区别 |
+| 11 | [[hhs/MySQL/02-SQL核心/11-UNION 与集合运算]] | 把多个查询结果合并在一起,去重 vs 保留重复怎么处理 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/12-B+Tree 索引原理.md b/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
similarity index 82%
rename from hhs/MySQL/12-B+Tree 索引原理.md
rename to hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
index 1d84661..d611ca4 100644
--- a/hhs/MySQL/12-B+Tree 索引原理.md
+++ b/hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理.md
@@ -73,11 +73,11 @@ flowchart TD
## 为什么树这么矮?
-InnoDB 一页 16KB,假设:
+InnoDB 一页 16KB(可配置),扣除页头 / 页尾等元数据开销后,**实际可用空间约 14~15 KB**。假设:
- 主键 BIGINT = 8 bytes
-- 指针 = 6 bytes
-- 每个内部节点 Entry ≈ 14 bytes
-- 一页可存 ≈ 16KB / 14 bytes ≈ **1170 个子节点**
+- 指针(Page Pointer)= 6 bytes
+- 每个内部节点 Entry(Key + Pointer + 冗余信息)≈ **16 bytes**
+- 一页可存 ≈ 14.5 KB / 16 bytes ≈ **900 ~ 1170 个子节点**(取保守值 1170)
```mermaid
flowchart LR
@@ -99,25 +99,27 @@ flowchart LR
> ——退化成一棵二叉树(红黑树的高度)。所以 B+ Tree 的**阶数越高,树越矮,IO 越少**。
>
> [!TIP] 直观感受
-> 用 SHOW INDEXES 查看某个表的索引信息,关注 Cardinality(基数)——
+> 通过 `information_schema.statistics` 查看索引基数,关注 CARDINALITY(基数)——
> 基数接近总行数说明区分度高,索引效果好:
> ```sql
-> -- 查看表索引及基数估计值
-> SHOW INDEXES FROM users;
+> -- 查看表的索引基数统计
+> SELECT INDEX_NAME, NON_UNIQUE, CARDINALITY
+> FROM information_schema.statistics
+> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users';
> ```
## 索引查找过程
```mermaid
sequenceDiagram
- participant Q as "查询id等于42"
- participant R as "根节点IO1"
- participant I as "内部节点IO2"
- participant L as "叶子节点IO3"
+ participant Q as "查询"
+ participant R as "根节点"
+ participant I as "内部节点"
+ participant L as "叶子节点"
participant D as "数据行"
- Q->>R: "id大于30走向右分支"
- R->>I: "指向第二层右子节点"
+ Q->>R: "id > 30 → 右分支"
+ R->>I: "定位到第二层右子节点"
I->>L: "命中对应叶子节点"
L->>D: "读取完整行数据"
@@ -155,7 +157,7 @@ SELECT email FROM users WHERE email = 'test@example.com';
> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
> - Extra 中**没有** `Using index` → 触发了回表
>
-> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/17-聚簇索引与二级索引]]
+> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]]
## 聚簇索引 vs 二级索引
@@ -216,6 +218,32 @@ flowchart TB
联合索引 `(a, b, c)` 的本质是:**先按 a 排序,a 相同时按 b 排序,a、b 都相同时按 c 排序**。
+```mermaid
+graph TD
+ subgraph "B+ Tree 叶子节点中数据的实际存储顺序"
+ R1["(10, 'pending')"]
+ R2["(10, 'paid')"]
+ R3["(10, 'shipped')"]
+ R4["(20, 'pending')"]
+ R5["(20, 'paid')"]
+ R6["(30, 'pending')"]
+ end
+
+ Q1["WHERE user_id = 10
✅ 精确匹配一层"] --> S1["命中: R1, R2, R3"]
+ Q2["WHERE user_id = 10 AND status = 'paid'
✅ 精确匹配两层"] --> S2["命中: R2"]
+ Q3["WHERE status = 'paid'
❌ 缺少最左列 user_id"] --> S3["全表扫描"]
+ Q4["WHERE user_id = 10 AND created_at > ...
⚠️ 只能用到 user_id"] --> S4["R1,R2,R3 + 逐行过滤"]
+
+ style S1 fill:#00D866,color:#fff
+ style S2 fill:#00D866,color:#fff
+ style S3 fill:#EE5A24,color:#fff
+ style S4 fill:#FF9F43,color:#000
+```
+
+> [!NOTE] 核心直觉
+> - 联合索引的 B+ Tree **不是按列独立排序**的,而是把所有列拼成一条记录整体排序。
+> - 跳过最左列 → 相当于跳过了目录的第一层,直接翻到第二层找,找不到就退化全表扫描。
+
```sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
@@ -310,10 +338,10 @@ SELECT * FROM users WHERE phone = '13800138000';
```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 + 二级索引数量
+-- ① 聚簇索引 idx__PRIMARY → 1 次 IO(顺序插入在末尾)
+-- ② 二级索引 idx_email → 1 次 IO(定位 + 可能的页分裂)
+-- ③ 每多一个二级索引 → 各加 1 次 IO
+-- 总写入成本 = 1(聚簇)+ N(N 个二级索引)
```
> [!NOTE] 直观理解
@@ -340,7 +368,7 @@ INSERT INTO users (id, email, name, age) VALUES (1, 'a@x.com', 'Tom', 25);
| **INSERT** | 叶子节点末尾追加(顺序 IO) | 需定位正确位置 + 可能的页分裂(随机 IO) |
| **UPDATE** | 若主键不变则无影响;变化则删除+重建 | 所有涉及列变化的索引都需要更新 |
| **DELETE** | 标记删除或合并页 | 同上,且可能有页合并开销 |
-| **页分裂** | 高水位上升时发生 | 频率更高(二级索引更密集) |
+| **页分裂** | 顺序写满一页后可能触发 | 频率更高(写入定位分散 + 热点行密集) |
> [!TIP] 经验法则
> - 写密集型系统(如日志、订单创建),索引数量控制在 **3 个以内**
@@ -374,6 +402,6 @@ INSERT INTO users (id, email, name, age) VALUES (1, 'a@x.com', 'Tom', 25);
## 关联笔记
- [[hhs/GORM/02-模型定义]] — GORM 创建索引的 struct tag 映射到 B+ Tree
-- [[hhs/MySQL/17-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的设计原则与实践
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读
+- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
+- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 联合索引的设计原则与实践
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读
diff --git a/hhs/MySQL/13-聚簇索引与二级索引.md b/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
similarity index 51%
rename from hhs/MySQL/13-聚簇索引与二级索引.md
rename to hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
index b72078c..15bdbb9 100644
--- a/hhs/MySQL/13-聚簇索引与二级索引.md
+++ b/hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引.md
@@ -1,14 +1,32 @@
---
tags: [MySQL, 聚簇索引, 二级索引, Covering Index, 回表]
-create time: 2026-05-16 00:00
+create time: 2026-05-20 14:00
---
-# 聚簇索引 vs 二级索引
+# 13-聚簇索引与二级索引
## 概述
InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。
+为了方便说明,本文档将始终使用以下 `users` 表作为示例:
+
+```sql
+CREATE TABLE users (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(64) NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ status TINYINT NOT NULL DEFAULT 1,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ INDEX idx_email (email),
+ INDEX idx_status_created (status, created_at)
+) ENGINE=InnoDB;
+```
+
+这张表中:
+- **聚簇索引**:`id`(PRIMARY KEY)
+- **二级索引**:`idx_email`(email)、`idx_status_created`(status, created_at)
+
> [!QUESTION] 思考
> 假设一张用户表按 `id` 排序存储在磁盘上——
> 现在要查 `WHERE email = 'alice@test.com'`,数据库需要做什么?
@@ -16,6 +34,39 @@ InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什
带着这个问题往下看。
+## 一张图看懂索引全貌
+
+在深入细节之前,先看这张示意图——它展示了同一个表上**聚簇索引**和**二级索引**如何共存:
+
+```mermaid
+block-beta
+ columns 2
+
+ block:ClusteredIndex["聚簇索引 — idx_id (PK = id)"]
+ columns 1
+ CI_L1["枝节点: id=5 | id=18"]
+ CI_L2["枝节点: id=12 | id=25"]
+ CI_L3["叶子节点: 整行数据(id=5, name=Bob, email=bob@x.com, …)"]
+ end
+
+ block:SecondaryIndex["二级索引 — idx_email (email)"]
+ columns 1
+ SI_L1["枝节点: email < 'k'"]
+ SI_L2["枝节点: email >= 'k'"]
+ SI_L3["叶子节点: (email='alice@x.com' | PK=12)"]
+ SI_L4["叶子节点: (email='bob@x.com' | PK=5)"]
+ end
+
+ style CI_L3 fill:#00B6BC,color:#fff
+ style SI_L3 fill:#E8DFF5,color:#333
+ style SI_L4 fill:#E8DFF5,color:#333
+```
+
+> [!HIGHLIGHT] 核心区别一目了然
+> - **聚簇索引叶子层**存的是**完整行数据** → 查到就结束
+> - **二级索引叶子层**存的是**「索引列 + 主键」** → 拿到主键后还要去聚簇索引里再查一次(回表)
+> - 所有索引共享同一份数据(存储在主键聚簇索引中),二级索引只是"额外的查找路径"
+
## 聚簇索引(Clustered Index)
聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。
@@ -52,6 +103,8 @@ flowchart TD
style G fill:#00B6BC,color:#fff
```
+理解了聚簇索引为什么快,我们反过来想——如果不是按主键查,而是通过其他字段(比如 `email`)查询,又会发生什么?这就是二级索引的故事。
+
## 二级索引(Secondary Index)
除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
@@ -97,60 +150,106 @@ sequenceDiagram
Note over SI,DB: 至少 2 次 IO:1次二级索引 + 1次回表
```
-### 减少回表的策略
+### 减少回表的策略(Covering Index)
+
+要减少回表,最直接的方法是让查询**完全在二级索引中完成**——这就是 Covering Index。
```sql
-- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
SELECT name, email FROM users WHERE email = 'alice@test.com';
-- idx_email 能匹配 WHERE,但 name 不在索引里 → 需要回表
+EXPLAIN SELECT name, email FROM users WHERE email = 'alice@test.com'\G
+*************************** 1. row ***************************
+ id: 1
+ select_type: SIMPLE
+ table: users
+ type: ref ← 通过 idx_email 找到匹配行
+possible_keys: idx_email
+ key: idx_email
+ key_len: 769
+ ref: const
+ rows: 1
+ filtered: 100.00
+ Extra: Using where ← 注意:无 "Using index",说明走了回表
-- ✅ 好:覆盖索引,无需回表
ALTER TABLE users ADD INDEX idx_email_name (email, name);
-SELECT name, email FROM users WHERE email = 'alice@test.com';
--- EXPLAIN Extra: Using index ← 完美!
+EXPLAIN SELECT name, email FROM users WHERE email = 'alice@test.com'\G
+*************************** 1. row ***************************
+ id: 1
+ select_type: SIMPLE
+ table: users
+ type: ref
+possible_keys: idx_email,idx_email_name
+ key: idx_email_name ← 优化器选择了更优的联合索引
+ key_len: 769
+ ref: const
+ rows: 1
+ filtered: 100.00
+ Extra: Using index ← 完美!Index Only Scan,无需回表
```
> [!SUCCESS] Covering Index 的黄金法则
> **把 SELECT 中的列 + WHERE 中的列放到同一个联合索引中**,就能实现 Index Only Scan。
> - 适合:高频查询、固定列选择
-> - 不适合:SELECT *(永远无法覆盖)、列变化频繁的查询
+> - 不适合:`SELECT *`(永远无法覆盖)、列变化频繁的查询
-## 回表 vs 索引下推(ICP)
+### 当 Covering Index 不够用时:索引下推(ICP)
-MySQL 5.6 引入的 Index Condition Pushdown 优化了部分回表场景。
+并非所有查询都能被覆盖索引解决。比如 `SELECT *` 必须回表,或者部分过滤条件无法放入索引中。这时 MySQL 5.6 引入的 **索引下推**(Index Condition Pushdown)能进一步减少无效回表。
+
+假设 `users` 表有联合索引 `idx_status_created (status, created_at)`:
```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
+-- 查询:WHERE status = 1 AND created_at > '2024-01-01'
+SELECT id, name FROM users WHERE status = 1 AND created_at > '2024-01-01';
```
```mermaid
flowchart TD
- N["无 ICP"] --> A["查到 1000 条 '张%' 的记录"]
- A --> B["1000 次回表检查 status"]
- B --> C["最终只有 10 条符合"]
+ N["无 ICP"] --> A["在 idx_status_created
查到 1000 条 status=1 的记录"]
+ A --> B["1000 次回表检查 created_at"]
+ B --> C["最终只有 50 条满足时间条件"]
- Y["有 ICP"] --> D["在索引中预检 status"]
- D --> E["1000 条中筛出 10 条"]
- E --> F["仅 10 次回表"]
+ Y["有 ICP"] --> D["在二级索引中直接判断
created_at > '2024-01-01'"]
+ D --> E["1000 条中筛出 50 条"]
+ E --> F["仅 50 次回表"]
style B fill:#EE5A24,color:#fff
style F fill:#00D866,color:#fff
```
+> [!HIGHLIGHT] ICP 的本质
+> 把 **Server 层**的过滤条件(如 `created_at > ...`)**下推到 Handler 层**,在读取二级索引页时就完成判断,命中了才回表。
+>
+> **适用条件:**
+> - 必须是二级索引(聚簇索引不走 ICP)
+> - 过滤条件中能利用到的部分必须在索引列范围内
+> - MySQL 5.6+ 默认开启,无需额外配置
+
+```sql
+-- 验证 ICP 是否生效
+EXPLAIN SELECT * FROM users WHERE status = 1 AND created_at > '2024-01-01'\G
+*************************** 1. row ***************************
+ id: 1
+ select_type: SIMPLE
+ table: users
+ type: range
+possible_keys: idx_status_created
+ key: idx_status_created
+ key_len: 5 -- TINYINT(1) + DATETIME(5)
+ ref: NULL
+ rows: 1000
+ filtered: 10.00
+ Extra: Using where ← 注意这里
+ -- 如果开启了 ICP,实际执行时会先判断 created_at,减少回表次数
+
+-- 可以通过开关观察差异
+SET optimizer_switch = 'index_condition_pushdown=off';
+-- EXPLAIN Extra 仍为 "Using where",但执行计划变差(更多回表)
+SET optimizer_switch = 'index_condition_pushdown=on';
+```
+
## 两种索引的空间对比
```mermaid
@@ -195,6 +294,6 @@ graph TB
## 关联笔记
-- [[hhs/MySQL/16-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响
+- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理
+- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响
- [[hhs/GORM/15-性能优化]] — GORM 场景下的 Covering Index 实践
diff --git a/hhs/MySQL/14-联合索引与最左前缀.md b/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
similarity index 93%
rename from hhs/MySQL/14-联合索引与最左前缀.md
rename to hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
index 5ef4215..f1d9c91 100644
--- a/hhs/MySQL/14-联合索引与最左前缀.md
+++ b/hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀.md
@@ -191,7 +191,7 @@ GROUP BY type;
## 关联笔记
-- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效
-- [[hhs/MySQL/20-慢查询日志分析]] — 如何从 slow log 中识别索引未命中
-- [[hhs/MySQL/21-查询改写技巧]] — 将失效的查询改写为可利用索引的形式
+- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效
+- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何从 slow log 中识别索引未命中
+- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 将失效的查询改写为可利用索引的形式
diff --git a/hhs/MySQL/15-EXPLAIN 完全指南.md b/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
similarity index 97%
rename from hhs/MySQL/15-EXPLAIN 完全指南.md
rename to hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
index ce8e8b7..bb06d17 100644
--- a/hhs/MySQL/15-EXPLAIN 完全指南.md
+++ b/hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南.md
@@ -323,6 +323,6 @@ 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 问题
+- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
+- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
+- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
diff --git a/hhs/MySQL/16-慢查询日志分析.md b/hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md
similarity index 93%
rename from hhs/MySQL/16-慢查询日志分析.md
rename to hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md
index 2af9508..136892b 100644
--- a/hhs/MySQL/16-慢查询日志分析.md
+++ b/hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md
@@ -307,7 +307,7 @@ ORDER BY created_at DESC LIMIT 10;
- `table->rows`(预估扫描行数)
- `query_block->filesort` / `temporary_table` 是否存在
-更详细的 EXPLAIN 解读见 [[hhs/MySQL/19-EXPLAIN 完全指南]]。
+更详细的 EXPLAIN 解读见 [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]]。
#### Step 4: 针对性优化
@@ -335,9 +335,9 @@ flowchart LR
```
详见:
-- [[hhs/MySQL/21-查询改写技巧]] — 常见 SQL 改写方案
-- [[hhs/MySQL/22-深分页优化]] — OFFSET 深分页的替代方案
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引设计原则
+- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 常见 SQL 改写方案
+- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — OFFSET 深分页的替代方案
+- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引设计原则
#### Step 5: 回归验证 & 监控接入
@@ -358,9 +358,9 @@ flowchart LR
## 关联笔记
-- [[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 日常使用中的典型陷阱
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — EXPLAIN 各字段的详细解读与执行计划分析
+- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — SQL 反模式改写方案
+- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — OFFSET 深分页的替代方案
+- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — 索引底层结构,理解为何某些写法会让索引失效
+- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 行锁/表锁/间隙锁,排查 Lock_time 偏高问题
+- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — MySQL 日常使用中的典型陷阱
diff --git a/hhs/MySQL/17-查询改写技巧.md b/hhs/MySQL/03-索引与查询优化/17-查询改写技巧.md
similarity index 95%
rename from hhs/MySQL/17-查询改写技巧.md
rename to hhs/MySQL/03-索引与查询优化/17-查询改写技巧.md
index 5625b1f..0be1e61 100644
--- a/hhs/MySQL/17-查询改写技巧.md
+++ b/hhs/MySQL/03-索引与查询优化/17-查询改写技巧.md
@@ -327,9 +327,9 @@ mindmap
## 关联笔记
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
-- [[hhs/MySQL/20-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
-- [[hhs/MySQL/22-深分页优化]] — 游标分页的专项深入
-- [[hhs/MySQL/16-B+Tree 索引原理]] — 理解索引走与否的根本原因
-- [[hhs/MySQL/40-常见踩坑]] — 生产环境中的典型错误写法合集
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
+- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
+- [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] — 游标分页的专项深入
+- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — 理解索引走与否的根本原因
+- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — 生产环境中的典型错误写法合集
- [[hhs/GORM/15-性能优化]] — GORM 层的批量操作优化
diff --git a/hhs/MySQL/18-深分页优化.md b/hhs/MySQL/03-索引与查询优化/18-深分页优化.md
similarity index 97%
rename from hhs/MySQL/18-深分页优化.md
rename to hhs/MySQL/03-索引与查询优化/18-深分页优化.md
index d27fe3a..494fb01 100644
--- a/hhs/MySQL/18-深分页优化.md
+++ b/hhs/MySQL/03-索引与查询优化/18-深分页优化.md
@@ -276,6 +276,6 @@ flowchart TD
## 关联笔记
-- [[hhs/MySQL/12-DQL SELECT 全解析]] — LIMIT 基础语法与 OFFSET 的定义
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 分页查询的 EXPLAIN 分析
+- [[hhs/MySQL/02-SQL核心/08-DQL SELECT 全解析]] — LIMIT 基础语法与 OFFSET 的定义
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 分页查询的 EXPLAIN 分析
- [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与各方案的 Go 实现
diff --git a/hhs/MySQL/03-索引与查询优化/README.md b/hhs/MySQL/03-索引与查询优化/README.md
new file mode 100644
index 0000000..b8cf681
--- /dev/null
+++ b/hhs/MySQL/03-索引与查询优化/README.md
@@ -0,0 +1,26 @@
+---
+tags: [MySQL, 索引, B+Tree, EXPLAIN, 查询优化]
+create time: 2026-05-20 23:55
+---
+
+# 三、索引与查询优化
+
+## 概述
+
+学会写 SQL 之后,下一步是让 SQL 跑得快。本章从 B+ Tree 底层原理讲起,覆盖聚簇索引、联合索引、EXPLAIN 执行计划分析、慢查询排查、查询改写和深分页优化——这些是解决线上性能问题的核心技能。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 12 | [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] | MySQL 为什么选 B+ Tree、它比 Hash / 红黑树好在哪里 |
+| 13 | [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] | 数据存在哪棵树上、查二级索引为什么要"回表" |
+| 14 | [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] | 多个字段一起建索引怎么用、为什么跳列就用不上了 |
+| 15 | [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] | 怎么看 SQL 的执行计划、type 从优到差排哪些 |
+| 16 | [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] | 抓出执行慢的 SQL、用工具分析根因 |
+| 17 | [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] | OR → UNION ALL、JOIN → EXISTS,同样的结果写法影响性能 |
+| 18 | [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] | `LIMIT 1000000, 20` 为什么慢、怎么优化 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/19-InnoDB 深度解析.md b/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md
similarity index 91%
rename from hhs/MySQL/19-InnoDB 深度解析.md
rename to hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md
index b35f231..5a69486 100644
--- a/hhs/MySQL/19-InnoDB 深度解析.md
+++ b/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md
@@ -454,26 +454,26 @@ flowchart LR
## 关联笔记
### 索引相关
-- [[hhs/MySQL/16-B+Tree 索引原理]] — B+Tree 数据结构基础
-- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证是否命中覆盖索引(Using index)
+- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] — B+Tree 数据结构基础
+- [[hhs/MySQL/03-索引与查询优化/13-聚簇索引与二级索引]] — 聚簇索引与二级索引的深度对比
+- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 覆盖索引与最左前缀原则的关系
+- [[hhs/MySQL/03-索引与查询优化/15-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/06-事务与并发控制/23-ACID 与原子性实现]] — Undo Log 保证原子性的核心机制
+- [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — 四种隔离级别的实际区别
+- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — Read View + Undo Version Chain 的完整实现
+- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — InnoDB 行锁与 Buffer Pool 的协同
+- [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] — 二次读与快照读的触发条件
### 日志与恢复
-- [[hhs/MySQL/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
-- [[hhs/MySQL/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
+- [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] — Binlog 与 Redo Log 的两阶段提交(2PC)
+- [[hhs/MySQL/07-高可用与分布式/34-备份与恢复]] — 全量备份 + Redo Log 恢复策略
### 性能调优
-- [[hhs/MySQL/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
-- [[hhs/MySQL/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
+- [[hhs/MySQL/08-工程实践/38-监控指标]] — 生产环境关键监控项(Buffer Pool命中率、脏页比例等)
+- [[hhs/MySQL/08-工程实践/40-常见踩坑]] — InnoDB 常见性能陷阱及排查方法
### 整体架构
-- [[hhs/MySQL/01-MySQL 架构与进程模型]] — Server 层与 InnoDB 引擎的分层协作
-- [[hhs/MySQL/07-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比
+- [[hhs/MySQL/01-入门基础/01-MySQL 架构与入门]] — Server 层与 InnoDB 引擎的分层协作
+- [[hhs/MySQL/04-存储引擎/20-其他存储引擎概览]] — MyISAM / Memory / Archive 的特点对比
diff --git a/hhs/MySQL/20-其他存储引擎概览.md b/hhs/MySQL/04-存储引擎/20-其他存储引擎概览.md
similarity index 98%
rename from hhs/MySQL/20-其他存储引擎概览.md
rename to hhs/MySQL/04-存储引擎/20-其他存储引擎概览.md
index b1212fa..4fce951 100644
--- a/hhs/MySQL/20-其他存储引擎概览.md
+++ b/hhs/MySQL/04-存储引擎/20-其他存储引擎概览.md
@@ -217,5 +217,5 @@ flowchart TD
## 关联笔记
-- [[hhs/MySQL/06-InnoDB 深度解析]] — InnoDB 五大核心组件完整文档
+- [[hhs/MySQL/04-存储引擎/19-InnoDB 深度解析]] — InnoDB 五大核心组件完整文档
- [[hhs/GORM/13-多数据库支持]] — GORM 在不同数据库间的移植注意事项
diff --git a/hhs/MySQL/04-存储引擎/README.md b/hhs/MySQL/04-存储引擎/README.md
new file mode 100644
index 0000000..cbcfaf8
--- /dev/null
+++ b/hhs/MySQL/04-存储引擎/README.md
@@ -0,0 +1,21 @@
+---
+tags: [MySQL, InnoDB, 存储引擎]
+create time: 2026-05-20 23:55
+---
+
+# 四、存储引擎
+
+## 概述
+
+学会了怎么用,来看看底层是怎么存的。本章深入解析 InnoDB 的存储机制(聚簇索引、Buffer Pool、Redo/Undo Log)以及 MyISAM、Memory 等其他引擎的适用场景。理解这部分会让你在排查性能问题时更有底气。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 19 | [[hhs/MySQL/04-存储引擎/19-InnoDB 深度解析]] | InnoDB 是怎么存数据的、聚簇索引的工作原理、缓冲机制概览 |
+| 20 | [[hhs/MySQL/04-存储引擎/20-其他存储引擎概览]] | MyISAM / Memory / Archive 的适用场景,为什么生产几乎只用 InnoDB |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/21-表结构设计三范式.md b/hhs/MySQL/05-表设计/21-表结构设计三范式.md
similarity index 100%
rename from hhs/MySQL/21-表结构设计三范式.md
rename to hhs/MySQL/05-表设计/21-表结构设计三范式.md
diff --git a/hhs/MySQL/22-主键策略对比.md b/hhs/MySQL/05-表设计/22-主键策略对比.md
similarity index 100%
rename from hhs/MySQL/22-主键策略对比.md
rename to hhs/MySQL/05-表设计/22-主键策略对比.md
diff --git a/hhs/MySQL/05-表设计/README.md b/hhs/MySQL/05-表设计/README.md
new file mode 100644
index 0000000..179651a
--- /dev/null
+++ b/hhs/MySQL/05-表设计/README.md
@@ -0,0 +1,21 @@
+---
+tags: [MySQL, 表设计, 范式, 主键]
+create time: 2026-05-20 23:55
+---
+
+# 五、表设计
+
+## 概述
+
+设计先行,写好的表结构能省去后面大量的麻烦。本章介绍数据库设计的三范式(以及什么时候该故意违反范式),以及不同主键策略(自增 ID、UUID、雪花算法)对性能的影响。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 21 | [[hhs/MySQL/05-表设计/21-表结构设计三范式]] | 什么是 1NF / 2NF / 3NF、什么时候该故意违反范式 |
+| 22 | [[hhs/MySQL/05-表设计/22-主键策略对比]] | 自增 ID、UUID、雪花算法——各自对性能的影响 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/06-DDL 建表与结构变更.md b/hhs/MySQL/06-DDL 建表与结构变更.md
deleted file mode 100644
index 3700a96..0000000
--- a/hhs/MySQL/06-DDL 建表与结构变更.md
+++ /dev/null
@@ -1,251 +0,0 @@
----
-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/06-InnoDB 深度解析.md b/hhs/MySQL/06-InnoDB 深度解析.md
deleted file mode 100644
index b35f231..0000000
--- a/hhs/MySQL/06-InnoDB 深度解析.md
+++ /dev/null
@@ -1,479 +0,0 @@
----
-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/23-ACID 与原子性实现.md b/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md
similarity index 98%
rename from hhs/MySQL/23-ACID 与原子性实现.md
rename to hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md
index 49006d8..7194149 100644
--- a/hhs/MySQL/23-ACID 与原子性实现.md
+++ b/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md
@@ -291,6 +291,6 @@ sequenceDiagram
## 关联笔记
-- [[hhs/MySQL/24-隔离级别与可见性]] — ACID 中 Isolation 的具体实现
-- [[hhs/MySQL/25-MVCC 原理]] — Undo Log 如何支撑多版本并发控制
+- [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — ACID 中 Isolation 的具体实现
+- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — Undo Log 如何支撑多版本并发控制
- [[hhs/GORM/08-事务管理]] — GORM 的事务 API 与 MySQL 引擎层的对应关系
diff --git a/hhs/MySQL/24-隔离级别与可见性.md b/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md
similarity index 95%
rename from hhs/MySQL/24-隔离级别与可见性.md
rename to hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md
index 3e34f68..80e1eaa 100644
--- a/hhs/MySQL/24-隔离级别与可见性.md
+++ b/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md
@@ -112,7 +112,7 @@ SELECT COUNT(*) FROM orders WHERE amount > 100;
```
> [!QUESTION] 为什么 InnoDB 能在 RR 下挡住幻影?
-> RC 每次 SELECT 都创建新的 Read View,所以能看到后来提交的新行。而 RR 下第一个 SELECT 就创建了 Read View,整个事务期间都用这个视图——后来的 INSERT 在 Read View 中不可见。再加上 Next-Key Lock 锁住插入间隙,INSERT 也会被阻塞。详见 [[hhs/MySQL/25-MVCC 原理]]。
+> RC 每次 SELECT 都创建新的 Read View,所以能看到后来提交的新行。而 RR 下第一个 SELECT 就创建了 Read View,整个事务期间都用这个视图——后来的 INSERT 在 Read View 中不可见。再加上 Next-Key Lock 锁住插入间隙,INSERT 也会被阻塞。详见 [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]]。
## 各隔离级别下的 Read View 行为
@@ -213,6 +213,6 @@ Next-Key Lock 并非万能,有几种场景 RR 仍无法阻止幻影读:
## 关联笔记
-- [[hhs/MySQL/23-ACID 与原子性实现]] — ACID 的基础概念和 Redo/Undo Log
-- [[hhs/MySQL/25-MVCC 原理]] — Read View 的详细构造算法
-- [[hhs/MySQL/26-锁机制总览]] — 隔离级别与锁类型的对应关系
+- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — ACID 的基础概念和 Redo/Undo Log
+- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — Read View 的详细构造算法
+- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 隔离级别与锁类型的对应关系
diff --git a/hhs/MySQL/25-MVCC 原理.md b/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md
similarity index 95%
rename from hhs/MySQL/25-MVCC 原理.md
rename to hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md
index 88a8644..94b3439 100644
--- a/hhs/MySQL/25-MVCC 原理.md
+++ b/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md
@@ -281,7 +281,7 @@ SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
## 关联笔记
-- [[hhs/MySQL/24-隔离级别与可见性]] — 隔离级别的定义及 RC vs RR 的选择
-- [[hhs/MySQL/23-ACID 与原子性实现]] — Undo Log 的结构和生命周期
-- [[hhs/MySQL/26-锁机制总览]] — MVCC 与锁的配合(一致性读 vs 当前读)
-- [[hhs/MySQL/28-一致性读与当前读]] — 什么情况下走 MVCC、什么情况下需要加锁
+- [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] — 隔离级别的定义及 RC vs RR 的选择
+- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Undo Log 的结构和生命周期
+- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — MVCC 与锁的配合(一致性读 vs 当前读)
+- [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] — 什么情况下走 MVCC、什么情况下需要加锁
diff --git a/hhs/MySQL/26-锁机制总览.md b/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md
similarity index 97%
rename from hhs/MySQL/26-锁机制总览.md
rename to hhs/MySQL/06-事务与并发控制/26-锁机制总览.md
index d09649a..39c9b91 100644
--- a/hhs/MySQL/26-锁机制总览.md
+++ b/hhs/MySQL/06-事务与并发控制/26-锁机制总览.md
@@ -311,7 +311,7 @@ sequenceDiagram
> ```
>
> > [!NOTE] 深入阅读
-> > 想进一步了解 MVCC 与锁的配合原理,参见 [[hhs/MySQL/25-MVCC 原理]] — 其中详细解释了 Read View 如何与锁协同工作。
+> > 想进一步了解 MVCC 与锁的配合原理,参见 [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — 其中详细解释了 Read View 如何与锁协同工作。
## 锁的场景矩阵
@@ -386,7 +386,7 @@ sequenceDiagram
## 关联笔记
-- [[hhs/MySQL/23-ACID 与原子性实现]] — Redo Log 和 Undo Log 的基础知识
-- [[hhs/MySQL/25-MVCC 原理]] — MVCC 与锁的配合(一致性读 vs 当前读)
-- [[hhs/MySQL/26-死锁与排查]] — 完整的死锁诊断流程
+- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Redo Log 和 Undo Log 的基础知识
+- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — MVCC 与锁的配合(一致性读 vs 当前读)
+- [[hhs/MySQL/06-事务与并发控制/27-死锁与排查]] — 完整的死锁诊断流程
- [[hhs/GORM/08-事务管理]] — GORM 事务中的锁获取时机
diff --git a/hhs/MySQL/27-死锁与排查.md b/hhs/MySQL/06-事务与并发控制/27-死锁与排查.md
similarity index 98%
rename from hhs/MySQL/27-死锁与排查.md
rename to hhs/MySQL/06-事务与并发控制/27-死锁与排查.md
index 12647d0..b1e1c96 100644
--- a/hhs/MySQL/27-死锁与排查.md
+++ b/hhs/MySQL/06-事务与并发控制/27-死锁与排查.md
@@ -311,6 +311,6 @@ innodb_print_all_deadlocks = ON # 记录所有死锁到错误日志
## 关联笔记
-- [[hhs/MySQL/26-锁机制总览]] — 各种锁类型及其交互规则
-- [[hhs/MySQL/25-MVCC 原理]] — MVCC 如何通过一致性读避免不必要的锁
+- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 各种锁类型及其交互规则
+- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — MVCC 如何通过一致性读避免不必要的锁
- [[hhs/DEV/Go-Database]] — Go 中数据库事务的最佳实践
diff --git a/hhs/MySQL/28-一致性读与当前读.md b/hhs/MySQL/06-事务与并发控制/28-一致性读与当前读.md
similarity index 98%
rename from hhs/MySQL/28-一致性读与当前读.md
rename to hhs/MySQL/06-事务与并发控制/28-一致性读与当前读.md
index 692b8b1..fc4eb81 100644
--- a/hhs/MySQL/28-一致性读与当前读.md
+++ b/hhs/MySQL/06-事务与并发控制/28-一致性读与当前读.md
@@ -329,6 +329,6 @@ SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
## 关联笔记
-- [[hhs/MySQL/25-MVCC 原理]] — ReadView 的构造和版本链查找
-- [[hhs/MySQL/26-锁机制总览]] — 当前读涉及的 S 锁和 X 锁
+- [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] — ReadView 的构造和版本链查找
+- [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] — 当前读涉及的 S 锁和 X 锁
- [[hhs/GORM/08-事务管理]] — GORM 中的 FirstForUpdate / SetLock 用法
diff --git a/hhs/MySQL/06-事务与并发控制/README.md b/hhs/MySQL/06-事务与并发控制/README.md
new file mode 100644
index 0000000..a245ba3
--- /dev/null
+++ b/hhs/MySQL/06-事务与并发控制/README.md
@@ -0,0 +1,25 @@
+---
+tags: [MySQL, 事务, ACID, MVCC, 锁, 并发]
+create time: 2026-05-20 23:55
+---
+
+# 六、事务与并发控制
+
+## 概述
+
+当多个用户同时操作数据时,如何保证数据不出错?本章从 ACID 原理出发,逐步深入隔离级别、MVCC 多版本并发控制、锁机制和死锁排查——这是理解 MySQL 并发行为的核心章节,也是面试的高频考点。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 23 | [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] | 什么是 ACID、Redo Log + Undo Log 怎么保证不出错 |
+| 24 | [[hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性]] | 四个隔离级别各是什么意思,MySQL 默认的是哪个 |
+| 25 | [[hhs/MySQL/06-事务与并发控制/25-MVCC 原理]] | 不加锁也能并发读的秘密——多版本并发控制 |
+| 26 | [[hhs/MySQL/06-事务与并发控制/26-锁机制总览]] | 从全局锁到行级锁,MySQL 在不同场景下锁什么 |
+| 27 | [[hhs/MySQL/06-事务与并发控制/27-死锁与排查]] | 什么时候会发生死锁、怎么定位和避免 |
+| 28 | [[hhs/MySQL/06-事务与并发控制/28-一致性读与当前读]] | 普通 SELECT 读到什么、加 FOR UPDATE 又读到什么 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/07-其他存储引擎概览.md b/hhs/MySQL/07-其他存储引擎概览.md
deleted file mode 100644
index b1212fa..0000000
--- a/hhs/MySQL/07-其他存储引擎概览.md
+++ /dev/null
@@ -1,221 +0,0 @@
----
-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/29-Binary Log.md b/hhs/MySQL/07-高可用与分布式/29-Binary Log.md
similarity index 95%
rename from hhs/MySQL/29-Binary Log.md
rename to hhs/MySQL/07-高可用与分布式/29-Binary Log.md
index 3df2b3a..065113b 100644
--- a/hhs/MySQL/29-Binary Log.md
+++ b/hhs/MySQL/07-高可用与分布式/29-Binary Log.md
@@ -23,7 +23,7 @@ flowchart LR
```
> [!TIP] 读这一页之前建议先了解
-> 对 Binlog 的理解建立在 InnoDB 存储引擎基础之上。**建议先看**: [[hhs/MySQL/21-InnoDB 架构]] → 本文档 → [[hhs/MySQL/30-主从复制]]
+> 对 Binlog 的理解建立在 InnoDB 存储引擎基础之上。**建议先看**: [[hhs/MySQL/04-存储引擎/19-InnoDB 深度解析]] → 本文档 → [[hhs/MySQL/07-高可用与分布式/30-主从复制详解]]
## Binlog 与 Redo Log 的根本区别
@@ -286,6 +286,6 @@ mysqlbinlog --start-position=1234 --stop-position=5678 mysql-bin.000001
## 关联笔记
-- [[hhs/MySQL/30-主从复制]] — Binlog 是主从复制的基石
-- [[hhs/MySQL/23-ACID 与原子性实现]] — Binlog 与 Redo Log 的两阶段提交协议
-- [[hhs/MySQL/32-备份与恢复]] — 基于 binlog 的 PITR 时间点恢复
+- [[hhs/MySQL/07-高可用与分布式/30-主从复制详解]] — Binlog 是主从复制的基石
+- [[hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现]] — Binlog 与 Redo Log 的两阶段提交协议
+- [[hhs/MySQL/07-高可用与分布式/34-备份与恢复]] — 基于 binlog 的 PITR 时间点恢复
diff --git a/hhs/MySQL/30-主从复制详解.md b/hhs/MySQL/07-高可用与分布式/30-主从复制详解.md
similarity index 97%
rename from hhs/MySQL/30-主从复制详解.md
rename to hhs/MySQL/07-高可用与分布式/30-主从复制详解.md
index 4cc7be5..c07cb7c 100644
--- a/hhs/MySQL/30-主从复制详解.md
+++ b/hhs/MySQL/07-高可用与分布式/30-主从复制详解.md
@@ -350,7 +350,6 @@ flowchart LR
## 关联笔记
-- [[hhs/MySQL/29-Binary Log]] — Binlog 是主从复制的数据源
-- [[hhs/MySQL/31-MHA / Orchestrator]] — 自动故障切换工具
-- [[hhs/MySQL/33-Group Replication]] — Paxos 多主复制进阶方案
-- [[hhs/MySQL/32-读写分离与代理]] — 基于主从架构的流量分发
+- [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] — Binlog 是主从复制的数据源
+- [[hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator]] — 自动故障切换工具
+- [[hhs/MySQL/07-高可用与分布式/32-Group Replication]] — Paxos 多主复制进阶方案
diff --git a/hhs/MySQL/31-MHA与Orchestrator.md b/hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator.md
similarity index 98%
rename from hhs/MySQL/31-MHA与Orchestrator.md
rename to hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator.md
index 3106b26..e794285 100644
--- a/hhs/MySQL/31-MHA与Orchestrator.md
+++ b/hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator.md
@@ -340,6 +340,6 @@ flowchart LR
## 关联笔记
-- [[hhs/MySQL/30-主从复制]] — 主从复制的配置和监控
-- [[hhs/MySQL/33-Group Replication]] — 内置多主复制,不需要外部 HA 工具
+- [[hhs/MySQL/07-高可用与分布式/30-主从复制详解]] — 主从复制的配置和监控
+- [[hhs/MySQL/07-高可用与分布式/32-Group Replication]] — 内置多主复制,不需要外部 HA 工具
- [[hhs/Redis/06-主从与哨兵]] — Redis Sentinel 与 MySQL HA 方案的对比
diff --git a/hhs/MySQL/32-Group Replication.md b/hhs/MySQL/07-高可用与分布式/32-Group Replication.md
similarity index 96%
rename from hhs/MySQL/32-Group Replication.md
rename to hhs/MySQL/07-高可用与分布式/32-Group Replication.md
index fe58ffc..9b90aaf 100644
--- a/hhs/MySQL/32-Group Replication.md
+++ b/hhs/MySQL/07-高可用与分布式/32-Group Replication.md
@@ -270,6 +270,6 @@ SELECT * FROM performance_schema.replication_group_connections;
## 关联笔记
-- [[hhs/MySQL/30-主从复制]] — GR 的主干是改进后的主从复制
-- [[hhs/MySQL/31-MHA与Orchestrator]] — 为什么 GR 不需要外部 HA 工具
-- [[hhs/MySQL/35-监控指标]] — GR 特有的监控指标
+- [[hhs/MySQL/07-高可用与分布式/30-主从复制详解]] — GR 的主干是改进后的主从复制
+- [[hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator]] — 为什么 GR 不需要外部 HA 工具
+- [[hhs/MySQL/08-工程实践/38-监控指标]] — GR 特有的监控指标
diff --git a/hhs/MySQL/33-InnoDB Cluster.md b/hhs/MySQL/07-高可用与分布式/33-InnoDB Cluster.md
similarity index 98%
rename from hhs/MySQL/33-InnoDB Cluster.md
rename to hhs/MySQL/07-高可用与分布式/33-InnoDB Cluster.md
index 19da248..a212573 100644
--- a/hhs/MySQL/33-InnoDB Cluster.md
+++ b/hhs/MySQL/07-高可用与分布式/33-InnoDB Cluster.md
@@ -325,6 +325,6 @@ try (Connection conn = DriverManager.getConnection(url, props)) {
## 关联笔记
-- [[hhs/MySQL/32-Group Replication]] — InnoDB Cluster 底层就是 GR
-- [[hhs/MySQL/31-MHA与Orchestrator]] — 不同 HA 方案的对比
+- [[hhs/MySQL/07-高可用与分布式/32-Group Replication]] — InnoDB Cluster 底层就是 GR
+- [[hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator]] — 不同 HA 方案的对比
- [[hhs/GORM/13-多数据库支持]] — GORM 在集群环境中的使用注意事项
diff --git a/hhs/MySQL/34-备份与恢复.md b/hhs/MySQL/07-高可用与分布式/34-备份与恢复.md
similarity index 98%
rename from hhs/MySQL/34-备份与恢复.md
rename to hhs/MySQL/07-高可用与分布式/34-备份与恢复.md
index 071b155..f574a39 100644
--- a/hhs/MySQL/34-备份与恢复.md
+++ b/hhs/MySQL/07-高可用与分布式/34-备份与恢复.md
@@ -273,6 +273,6 @@ echo "$(date +%F %T) | full | ${DATE} | OK" >> "${BACKUP_DIR}/backup.log"
## 关联笔记
-- [[hhs/MySQL/29-Binary Log]] — Binlog 是 PITR 的基础
-- [[hhs/MySQL/30-主从复制]] — 从库可以作为只读备份节点
+- [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] — Binlog 是 PITR 的基础
+- [[hhs/MySQL/07-高可用与分布式/30-主从复制详解]] — 从库可以作为只读备份节点
- [[hhs/DEV/Go-Database]] — Go 应用中处理数据库恢复错误的最佳实践
diff --git a/hhs/MySQL/07-高可用与分布式/README.md b/hhs/MySQL/07-高可用与分布式/README.md
new file mode 100644
index 0000000..4a9cd41
--- /dev/null
+++ b/hhs/MySQL/07-高可用与分布式/README.md
@@ -0,0 +1,25 @@
+---
+tags: [MySQL, 主从复制, 高可用, Binlog, 集群]
+create time: 2026-05-20 23:55
+---
+
+# 七、高可用与分布式
+
+## 概述
+
+单台 MySQL 扛不住了怎么办?本章从 Binlog 原理讲起,覆盖主从复制、自动故障切换(MHA / Orchestrator)、Group Replication、InnoDB Cluster 以及备份恢复策略——这些是保障生产环境数据库可靠性的必备知识。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 29 | [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] | Binlog 记录了所有写操作,主从复制和恢复都靠它 |
+| 30 | [[hhs/MySQL/07-高可用与分布式/30-主从复制详解]] | 数据怎么从主库同步到从库、半同步 vs 异步的区别 |
+| 31 | [[hhs/MySQL/07-高可用与分布式/31-MHA与Orchestrator]] | 主库挂了怎么自动切换到从库 |
+| 32 | [[hhs/MySQL/07-高可用与分布式/32-Group Replication]] | 多主同时写入、冲突怎么处理 |
+| 33 | [[hhs/MySQL/07-高可用与分布式/33-InnoDB Cluster]] | MySQL 官方的高可用集群方案 |
+| 34 | [[hhs/MySQL/07-高可用与分布式/34-备份与恢复]] | 全量备份和增量备份、恢复到任意时间点 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/35-Go 连接池配置.md b/hhs/MySQL/08-工程实践/35-Go 连接池配置.md
similarity index 100%
rename from hhs/MySQL/35-Go 连接池配置.md
rename to hhs/MySQL/08-工程实践/35-Go 连接池配置.md
diff --git a/hhs/MySQL/36-Schema 迁移管理.md b/hhs/MySQL/08-工程实践/36-Schema 迁移管理.md
similarity index 100%
rename from hhs/MySQL/36-Schema 迁移管理.md
rename to hhs/MySQL/08-工程实践/36-Schema 迁移管理.md
diff --git a/hhs/MySQL/37-分库分表.md b/hhs/MySQL/08-工程实践/37-分库分表.md
similarity index 97%
rename from hhs/MySQL/37-分库分表.md
rename to hhs/MySQL/08-工程实践/37-分库分表.md
index 93f7357..2aaf467 100644
--- a/hhs/MySQL/37-分库分表.md
+++ b/hhs/MySQL/08-工程实践/37-分库分表.md
@@ -157,7 +157,7 @@ flowchart TD
>
> 如果你的主键是 AUTO_INCREMENT,每个分库各自从 1 开始计数——一旦需要合并数据或做跨库查询,ID 冲突不可避免。这就是为什么**要做分片就必须先上分布式 ID**。
>
-> 详细的 ID 生成方案请参考:[[hhs/MySQL/09-主键策略对比]](Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。
+> 详细的 ID 生成方案请参考:[[hhs/MySQL/05-表设计/22-主键策略对比]](Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。
快速选型参考:
@@ -449,7 +449,7 @@ func CreateOrder(order *Order) error {
## 关联笔记
-- [[hhs/MySQL/09-主键策略对比]] — 分布式 ID 生成方案(Snowflake / ULID / UUID)
+- [[hhs/MySQL/05-表设计/22-主键策略对比]] — 分布式 ID 生成方案(Snowflake / ULID / UUID)
- [[hhs/GORM/13-多数据库支持]] — GORM 对多库连接的支持
-- [[hhs/MySQL/35-Go 连接池配置]] — 多分片场景下的连接池隔离
-- [[hhs/MySQL/38-监控指标]] — 分片集群的监控指标体系
+- [[hhs/MySQL/08-工程实践/35-Go 连接池配置]] — 多分片场景下的连接池隔离
+- [[hhs/MySQL/08-工程实践/38-监控指标]] — 分片集群的监控指标体系
diff --git a/hhs/MySQL/38-监控指标.md b/hhs/MySQL/08-工程实践/38-监控指标.md
similarity index 99%
rename from hhs/MySQL/38-监控指标.md
rename to hhs/MySQL/08-工程实践/38-监控指标.md
index 3abac8b..727ed52 100644
--- a/hhs/MySQL/38-监控指标.md
+++ b/hhs/MySQL/08-工程实践/38-监控指标.md
@@ -257,6 +257,6 @@ mysqld_exporter \
## 关联笔记
-- [[hhs/MySQL/20-慢查询日志分析]] — pt-query-digest 配合监控系统
+- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — pt-query-digest 配合监控系统
- [[hhs/Redis/09-高级特性]] — Redis 监控指标的对比
- [[hhs/GORM/16-日志与调试]] — GORM 级别的慢查询追踪
diff --git a/hhs/MySQL/39-安全加固.md b/hhs/MySQL/08-工程实践/39-安全加固.md
similarity index 100%
rename from hhs/MySQL/39-安全加固.md
rename to hhs/MySQL/08-工程实践/39-安全加固.md
diff --git a/hhs/MySQL/40-常见踩坑.md b/hhs/MySQL/08-工程实践/40-常见踩坑.md
similarity index 97%
rename from hhs/MySQL/40-常见踩坑.md
rename to hhs/MySQL/08-工程实践/40-常见踩坑.md
index a9e77ba..5d10c1a 100644
--- a/hhs/MySQL/40-常见踩坑.md
+++ b/hhs/MySQL/08-工程实践/40-常见踩坑.md
@@ -316,6 +316,6 @@ sequenceDiagram
## 11. 关联笔记
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 EXPLAIN 识别上述问题
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引失效的典型原因
-- [[hhs/MySQL/21-查询改写技巧]] — 各种问题的改写方案
+- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 通过 EXPLAIN 识别上述问题
+- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引失效的典型原因
+- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 各种问题的改写方案
diff --git a/hhs/MySQL/08-工程实践/README.md b/hhs/MySQL/08-工程实践/README.md
new file mode 100644
index 0000000..51308eb
--- /dev/null
+++ b/hhs/MySQL/08-工程实践/README.md
@@ -0,0 +1,25 @@
+---
+tags: [MySQL, 工程实践, 连接池, 监控, 分库分表]
+create time: 2026-05-20 23:55
+---
+
+# 八、工程实践
+
+## 概述
+
+部署上线后,连接池怎么配、Schema 怎么迁移、数据量大了怎么拆分、出了问题怎么排查。本章聚焦生产环境中 MySQL 的运维和工程化管理,是"从会用到能管好"的最后一公里。
+
+## 本章文档
+
+| # | 主题 | 说明 |
+|---|------|------|
+| 35 | [[hhs/MySQL/08-工程实践/35-Go 连接池配置]] | `sql.DB` 几个关键参数的含义和调优思路 |
+| 36 | [[hhs/MySQL/08-工程实践/36-Schema 迁移管理]] | 版本化的建表脚本、谁在用、怎么保证可回滚 |
+| 37 | [[hhs/MySQL/08-工程实践/37-分库分表]] | 数据量太大时怎么拆分、跨分片查询怎么办 |
+| 38 | [[hhs/MySQL/08-工程实践/38-监控指标]] | QPS/TPS、慢查询数、连接数——哪些数据决定数据库健康 |
+| 39 | [[hhs/MySQL/08-工程实践/39-安全加固]] | 权限管理、加密传输、SQL 注入防护 |
+| 40 | [[hhs/MySQL/08-工程实践/40-常见踩坑]] | ORDER BY 文件排序、COUNT 的区别、timestamp 自动更新 |
+
+## 关联笔记
+
+- [[hhs/MySQL/README]] — MySQL 知识库总目录
diff --git a/hhs/MySQL/08-表结构设计三范式.md b/hhs/MySQL/08-表结构设计三范式.md
deleted file mode 100644
index 66ec0bb..0000000
--- a/hhs/MySQL/08-表结构设计三范式.md
+++ /dev/null
@@ -1,347 +0,0 @@
----
-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/09-主键策略对比.md b/hhs/MySQL/09-主键策略对比.md
deleted file mode 100644
index 21af068..0000000
--- a/hhs/MySQL/09-主键策略对比.md
+++ /dev/null
@@ -1,390 +0,0 @@
----
-tags: [MySQL, 主键, Auto Increment, UUID, Snowflake]
-create time: 2026-05-16 00:00
----
-
-# 主键策略对比
-
-## 概述
-
-主键是聚簇索引的核心——决定了数据在磁盘上的物理排列方式。不同的主键策略直接影响写入性能、索引碎片化程度以及分布式扩展能力。
-
-> [!QUESTION] 一个有趣的问题
-> 假设你的日活用户是 100 万,每年增长约 3600 万。你会用 INT(最大 42 亿)还是 BIGINT(最大 1800 亿)?
->
-> 直觉上 BIGINT 更保险。但每个字节在主键上的代价都在放大——因为 InnoDB 的**所有二级索引都包含主键列**。一条 BIGINT 比 INT 多 4 字节,每张二级索引表每条记录就多 4 字节的开销。如果你的系统有 5 张外键关联这张表的二级索引,那每行就白白多了 20 字节。**选大一级不犯错是有代价的。**
-
-## 策略全景图
-
-```mermaid
-flowchart TD
- Start["选择主键策略"] --> Type{"自增还是分散?"}
-
- Type -->|自增| AUTO["Auto Increment"]
- Type -->|分散| RAND["分布式生成"]
-
- AUTO --> A1["INT / BIGINT AUTO_INCREMENT"]
-
- RAND --> R1{"有序还是随机?"}
- R1 -->|随机| RIA["UUID / GUID"]
- R1 -->|大致有序| RID["Snowflake / ULID"]
-
- RIA --> U1["索引严重碎片化"]
- RIA --> U2["写放大 5~10x"]
-
- RID --> V1["近似递增 · 紧凑"]
- RID --> V2["分布式友好"]
-
- style AUTO fill:#00D866,color:#fff
- style RID fill:#00B6BC,color:#fff
- style RIA fill:#EE5A24,color:#fff
-```
-
-## AUTO_INCREMENT 自增主键
-
-最简单也最常用的方案。
-
-```sql
-CREATE TABLE users (
- id BIGINT AUTO_INCREMENT PRIMARY KEY,
- username VARCHAR(50) UNIQUE,
- email VARCHAR(255)
-);
-
--- InnoDB 自动管理自增值
--- 每张 InnoDB 表有一个隐藏的 auto_increment_counter
--- 默认从 1 开始,按步长递增
-```
-
-### 步长与偏移
-
-```sql
--- 全局设置
-SET GLOBAL auto_increment_increment = 2; -- 每次 +2
-SET GLOBAL auto_increment_offset = 1; -- 从 1 开始
-
--- 这样服务器 A 得到 1,3,5,...;服务器 B offset=2 得到 2,4,6,...
--- 可以用于简单的双主复制去重
-
--- 查看当前自增值
-SHOW TABLE STATUS LIKE 'users'\G
--- Auto_increment: 12345
-
--- 手动重置
-ALTER TABLE users AUTO_INCREMENT = 10000;
-```
-
-### 优点与挑战
-
-| 优点 | 挑战 |
-|------|------|
-| 顺序写入,无碎片 | 集中式增长,单机上限受 BIGINT 限制 |
-| 索引紧凑,空间利用率高 | 泄露总数(公开 API 暴露数量趋势)|
-| 查询极快(数字比较) | 跨库合并 ID 时需人工规划 |
-
-> [!WARNING] 并发下的自增值跳跃
-> 在高并发下,AUTO_INCREMENT 可能跳过一些数值。比如并发 INSERT 时,InnoDB 可能会分配 1, 3, 5 而不是 1, 2, 3。这是因为 InnoDB 为每个 INSERT 分配一批值以避免锁竞争。如果业务要求连续编号(如流水号),需要用其他方式实现。
-
-### 性能调优与监控
-
-> [!TIP] INT 还是 BIGINT?
-> 这是一个常见的选型陷阱。BIGINT 占 8 字节,INT 只占 4 字节(上限约 21 亿)。如果你的表不超过 21 亿行,用 INT 可以让索引更紧凑——二级索引、JOIN 条件都会更省空间。什么时候该升级到 BIGINT?当你的分库方案预估总量超过 21 亿时。
-
-```sql
--- 查看表的自增列类型占用
-SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH
-FROM INFORMATION_SCHEMA.COLUMNS
-WHERE TABLE_SCHEMA = 'mydb' AND TABLE_NAME = 'users';
-
--- 监控自增计数器使用率(%)
-SELECT
- table_name,
- auto_increment,
- CASE data_type
- WHEN 'tinyint' THEN 255
- WHEN 'smallint' THEN 65535
- WHEN 'mediumint' THEN 16777215
- WHEN 'int' THEN 4294967295
- WHEN 'bigint' THEN 18446744073709551615
- END AS max_val,
- ROUND(auto_increment /
- CASE data_type
- WHEN 'tinyint' THEN 255
- WHEN 'smallint' THEN 65535
- WHEN 'mediumint' THEN 16777215
- WHEN 'int' THEN 4294967295
- WHEN 'bigint' THEN 18446744073709551615
- END * 100, 2) AS usage_pct
-FROM INFORMATION_SCHEMA.TABLES t
-JOIN INFORMATION_SCHEMA.COLUMNS c USING (table_schema, table_name)
-WHERE c.extra LIKE '%auto_increment%'
-ORDER BY usage_pct DESC;
-```
-
-**优化建议:**
-
-| 场景 | 做法 |
-|------|------|
-| 预分配 ID 范围 | 用独立 `id_generator` 表,批量领取一段 ID,减少 DB 压力 |
-| 防止 ID 泄露 | API 返回时做偏移或加盐(例如 `id + 100000`,再存回库时用 `id - 100000`)|
-| 跨库合并去重 | 按机器 ID 规划偏移量(类似 MySQL 双主模式),或用 Snowflake 替代 |
-
-## UUID / GUID
-
-```sql
--- MySQL 内置函数生成 UUID
-SELECT UUID();
--- '550e8400-e29b-41d4-a716-446655440000' (36 字符含横杠)
-
-SELECT UUID_SHORT();
--- 无横杠的 64-bit 整数,基于 server_id + 计数器
--- ⚠️ 重启后会重置计数器,可能导致重复
-```
-
-### UUID 对聚簇索引的伤害
-
-```mermaid
-flowchart LR
- subgraph BPlus["InnoDB 聚簇索引 = B+ Tree"]
- Root["根节点"] --> BranchA["内部节点 A"]
- Root --> BranchB["内部节点 B"]
- Root --> BranchC["内部节点 C"]
- BranchA --> LeafA1["叶子页 P5"]
- BranchA --> LeafA2["叶子页 P2"]
- BranchB --> LeafB1["叶子页 P8"]
- BranchC --> LeafC1["叶子页 P1"]
- LeafA1 -.->|"双向链表"| LeafA2
- LeafA2 -.->|"双向链表"| LeafB1
- LeafB1 -.->|"双向链表"| LeafC1
- LeafC1 -.->|"双向链表"| LeafA1
- end
-
- UUID1["UUID: a3f1..."] -->|"插入"| LeafC1
- UUID2["UUID: b2c4..."] -->|"插入"| LeafA2
- UUID3["UUID: c7d8..."] -->|"插入"| LeafB1
- UUID4["UUID: d1e2..."] -->|"插入"| LeafA1
- UUID5["UUID: e9f3..."] -->|"插入"| LeafC1
-
- style BPlus fill:#F8F8F8,color:#333
- style UUID1 fill:#EE5A24,color:#fff
- style UUID2 fill:#EE5A24,color:#fff
- style UUID3 fill:#EE5A24,color:#fff
- style UUID4 fill:#EE5A24,color:#fff
- style UUID5 fill:#EE5A24,color:#fff
-```
-
-UUID 的随机性导致每次插入都可能落在 B+ Tree 的不同叶子页,与自增主键形成鲜明对比:
-- **页分裂频率飙升**:16KB 页面快速填满 → 分裂
-- **索引碎片化**:数据在磁盘上分散存放
-- **缓存命中率下降**:热点区域变冷
-- **存储空间膨胀**:36 字符 × 4 byte = ~144 bytes/条索引额外开销
-
-### 解决思路:UUID 转二进制
-
-```sql
--- ❌ 差:VARCHAR(36) 存字符串 UUID
-CREATE TABLE bad_uuid_users (
- id VARCHAR(36) PRIMARY KEY,
- name VARCHAR(100)
-);
-
--- ✅ 好:BINARY(16) 存原始 UUID 字节
-CREATE TABLE good_uuid_users (
- id BINARY(16) PRIMARY KEY,
- name VARCHAR(100)
-);
-
-INSERT INTO good_uuid_users VALUES (UUID_TO_BIN(UUID()), 'Alice');
--- UUID_TO_BIN() 把 UUID 打乱重组,让 B+ Tree 的分布更均匀
-```
-
-> [!TIP] MySQL 8.0 的 uuid_to_bin / bin_to_uuid 系列函数
-> ```sql
-> -- uuid_to_bin(uuid, swap_flag)
-> -- swap_flag = 0: 直接转换(仍随机)
-> -- swap_flag = 1: 重组字节序(近似递增)← 推荐
->
-> INSERT INTO t (id) VALUES (UUID_TO_BIN(UUID(), TRUE));
-> SELECT BIN_TO_UUID(id, TRUE) FROM t; -- 还原为可读 UUID
-> ```
-
-## Snowflake 雪花算法
-
-Twitter 开源的分布式 ID 生成方案。
-
-```
-64-bit 结构:
-│ 1bit │ 41 bits │ 10 bits │ 12 bits │
-│ sign │ timestamp(ms) │ worker_id │ sequence │
-│ 0 │ │ (机器标识) │ (序列号) │
-
-范围:41ms → 约 69 年
-worker_id:最多 1024 台机器
-sequence:每台机器每毫秒最多 4096 个 ID
-```
-
-### Go 实现要点
-
-```go
-package snowflake
-
-import (
- "sync"
- "time"
-)
-
-// Worker 生成分布式 ID
-// 位布局: [41bit timestamp][10bit workerID][12bit sequence]
-type Worker struct {
- mu sync.Mutex
- lastTime int64
- sequence uint16 // 每毫秒从 0 开始,溢出时回退等待
- workerID int64
- epoch int64 // 起始时间戳(Epoch),避免负数
-}
-
-func NewWorker(workerID int64) (*Worker, error) {
- return &Worker{
- workerID: workerID,
- epoch: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(),
- }, nil
-}
-
-func (w *Worker) NextID() int64 {
- w.mu.Lock()
- defer w.mu.Unlock()
-
- now := time.Now().UnixMilli()
- if now < w.epoch {
- panic("epoch not reached")
- }
- if now == w.lastTime {
- w.sequence++
- } else {
- w.sequence = 0
- }
- w.lastTime = now
-
- // 位拼接: (elapsed << 22) | (workerID << 12) | sequence
- return (now-w.epoch)<<22 | w.workerID<<12 | int64(w.sequence)
-}
-```
-
-### Snowflake 的优点
-
-| 特点 | 说明 |
-|------|------|
-| **有序递增** | 时间戳在前,天然递增,对聚簇索引友好 |
-| **分布式** | 无中心节点,每台机器独立生成 |
-| **高密度** | 64-bit INT64,占 8 字节,比 UUID 省一半 |
-| **可解析** | 从 ID 中可以还原出时间戳 |
-
-### Snowflake 的挑战
-
-> [!NOTE] 时钟回拨问题
-> 如果服务器时间回退(NTP 同步、虚拟机卡顿),同一个时间戳可能生成两次相同 ID。解决方案:
-> - **阻塞等待**:时间回拨时暂停生成直到时间追上
-> - **抛出异常**:由上层重试
-> - **预留比特位**:预留少量 bit 给错误码/回拨标志
-
-## ULID
-
-Universal Unique Lexicographically Sortable Identifier —— 比 UUID 更适合数据库的方案。
-
-```
-26 字符 Base32 编码:
-│ 4 byte (32 bit) │ 8 byte (64 bit) │ 10 byte (80 bit random) │
-│ milliseconds │ entropy │ randomness │
-│ Epoch ms since | 随机熵源 │ │
-│ 1970-01-01 │ │ │
-```
-
-### Go 实现
-
-```go
-import (
- "crypto/rand"
- "time"
-
- "github.com/oklog/ulid/v2"
-)
-
-// 生成 ULID
-func NewULID() string {
- t := uint64(time.Now().UnixMilli())
- entropy := ulid.MonotonicEntropy(rand.Reader, 5)
- id, _ := ulid.New(t, entropy)
- return id.String() // 如: "01JKQX5H3PMTB9R1KZAS7BMFZC"
-}
-```
-
-> [!TIP] ULID vs Snowflake:怎么选?
-> - **需要人类可读的 ID**(打印到日志、放在 URL 里)→ ULID,Base32 只含兼容字符,无大小写混淆
-> - **追求极致紧凑** → Snowflake,8 字节纯数字,INT64 直接可用
-> - **不想维护 worker_id** → ULID 不需要节点标识,天然去中心化
-
-- 16 字节存储,与 Snowflake 同级别
-- 字符串形式可直接用于 URL、HTTP header
-- 自然按字典序排序(时间戳在前)
-- Go 标准库已有 `oklog/ulid` 包
-
-## 性能实测数据
-
-> [!QUESTION] 思考:为什么同样是 16 字节,UUID Binary 和 ULID 的索引效率差距那么大?
-> 答案是**有序性**。B+ Tree 最怕随机插入——它会导致大量的页分裂(page split)和碎片。即使存储长度相同,写入模式决定了最终性能。
-
-以下数据基于单机 MySQL 8.0 (InnoDB),100 万条 INSERT benchmark:
-
-| 指标 | BIGINT AI | UUID(BIN) | Snowflake | ULID |
-|------|-----------|-----------|-----------|------|
-| **写入耗时** | ~3s | ~25s | ~4s | ~4s |
-| **索引大小** | 24 MB | 48 MB | 24 MB | 48 MB |
-| **页面填充率** | ~70% | ~35% | ~68% | ~67% |
-| **SELECT 平均延迟** | 0.15 ms | 0.25 ms | 0.16 ms | 0.16 ms |
-| **磁盘空间比** | 1x | 2.1x | 1x | 1.4x |
-
-> [!NOTE] 数据来源说明
-> 以上为典型场景下的参考值。实际性能取决于数据分布、并发量、硬件配置。关键结论是一致的:**有序 ID 在 InnoDB 上的写入效率约为随机 ID 的 5~8 倍**。如果看到 UUID 写入了更快的场景,大概率是测试方法有误(例如只测了单次插入而忽略了页分裂累积效应)。
-
-## 反模式警示
-
-> [!DANGER] 常见陷阱
->
-> **1. 用 UUID 做外键关联**
-> 每条二级索引都要存一份外键引用。一张有 5 个外键的表,`VARCHAR(36)` 版本的 UUID 会让索引膨胀到原来的 **5 倍以上**。永远优先使用 `BINARY(16)`。
->
-> **2. 把 Snowflake 序列号存在 MySQL 里**
-> Snowflake ID 本身已经包含时间戳,再额外加一个 `created_at` 字段属于典型的重复存储。除非你有特殊的审计需求,否则用一个字段就够了。
->
-> **3. 用自增 ID 做多分片部署**
-> 两个独立的 MySQL 实例各自从 1 开始自增,一旦需要合并就 ID 冲突。这是早期很多创业团队踩过的坑——等发现问题时业务量已经很大,迁移代价极高。**从一开始就用分布式 ID**。
->
-> **4. 依赖 UUID_SHORT() 做去重**
-> 该函数依赖 server_id + 内存计数器,重启后计数器归零。如果你的服务偶尔重启(OOM kill、滚动发布),可能产生重复 ID。不要把它用在消息队列或事件溯源这种对幂等性敏感的场景。
-
-## 对比总结
-
-| 策略 | 长度 | 有序性 | 分布式 | 存储空间 | 索引效率 | 适用场景 |
-|------|------|--------|--------|---------|---------|---------|
-| **BIGINT AUTO_INCREMENT** | 8 byte | ✅ 严格递增 | ❌ 需分片规划 | 最小 | 🏆 最优 | 单库 / 小分布式 |
-| **UUID (Binary)** | 16 byte | ❌ 随机 | ✅ | 中等 | 较差 | 跨库合并 |
-| **UUID TO_BIN(swapped)** | 16 byte | ⚠️ 近似递增 | ✅ | 中等 | 改善 | MySQL 8.0+ |
-| **Snowflake** | 8 byte | ✅ 近似递增 | ✅ | 最小 | 🏆 最优 | 大规模分布式 |
-| **ULID** | 16 byte | ✅ 严格递增 | ✅ | 中等 | 良好 | 微服务 / 事件溯源 |
-
-> [!TIP] 选型决策树
-> ```
-> 单库? → AUTO_INCREMENT (简单可靠)
-> ↓
-> 分布式且 < 100 万 QPS? → Snowflake / ULID
-> ↓
-> 需要与外部系统对接 UUID? → UUID_TO_BIN(uuid, TRUE)
-> ↓
-> 需要人类可读? → ULID(Base32 字符串)
-> ```
-
-## 关联笔记
-
-- [[hhs/GORM/02-模型定义]] — GORM 中不同主键类型的 struct tag 设置
-- [[hhs/GORM/08-事务管理]] — 分布式事务中的 ID 一致性
-- [[hhs/Redis/08-SortedSet精解]] — Snowflake WorkerID 可用 Redis 做协调
diff --git a/hhs/MySQL/10-DDL 建表与结构变更.md b/hhs/MySQL/10-DDL 建表与结构变更.md
deleted file mode 100644
index 3700a96..0000000
--- a/hhs/MySQL/10-DDL 建表与结构变更.md
+++ /dev/null
@@ -1,251 +0,0 @@
----
-tags: [MySQL, DDL, ALTER TABLE, CREATE TABLE, DATATYPE, NULL, ONLINE DDL]
-create time: 2026-05-16 00:00
----
-
-# DDL — 建表与结构变更
-
-## 概述
-
-DDL(Data Definition Language)用于定义和修改数据库对象的结构。本章聚焦最常用的 `CREATE TABLE`、`ALTER TABLE`,以及 MySQL 8.0 引入的在线 DDL 特性。
-
-## CREATE TABLE
-
-```sql
-CREATE TABLE users (
- id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
- username VARCHAR(50) NOT NULL COMMENT '用户名',
- email VARCHAR(255) NOT NULL COMMENT '邮箱唯一',
- password_hash VARCHAR(64) NOT NULL COMMENT 'bcrypt hash',
- status TINYINT NOT NULL DEFAULT 1 COMMENT '1=active 0=disabled',
- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
- updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-
- UNIQUE KEY uk_email (email),
- INDEX idx_username (username),
- INDEX idx_status_created (status, created_at)
-) ENGINE=InnoDB
- DEFAULT CHARSET=utf8mb4
- COLLATE=utf8mb4_0900_ai_ci
- COMMENT='用户表';
-```
-
-### 关键语法解析
-
-| 子句 | 作用 |
-|------|------|
-| `ENGINE=InnoDB` | 显式指定存储引擎(8.0 默认值)|
-| `DEFAULT CHARSET` | 数据库级字符集覆盖 |
-| `ON UPDATE CURRENT_TIMESTAMP` | 自动维护更新时间戳 |
-| `UNIQUE KEY` | 唯一索引,同时约束数据唯一性 |
-| `INDEX idx_name (col)` | 普通索引,辅助查询 |
-| `COMMENT` | 表和字段注释,可通过 `SHOW FULL COLUMNS` 查看 |
-
-> [!TIP] 命名规范约定
-> - **索引名**:`idx_表名_列名`(普通索引)、`uk_表名_列名`(唯一索引)——避免超过 64 字符
-> - **时间字段**:统一用 `DATETIME` 而非 `TIMESTAMP`。后者范围仅限 `1970~2038`,遇到闰秒时会报错
-> - **自增主键**:务必用 `UNSIGNED`,正数区间翻倍(0 ~ 42.9 亿),且能略微节省空间
-
-> [!QUESTION] 思考:为什么 `password_hash` 要用 `VARCHAR(64)` 而不是固定长度?
-> 答案取决于你使用的哈希算法。bcrypt 输出为 60 字符(如 `$2b$12$...`),预留 64 留有扩展余量。如果改用 SHA-256 则恰好 64 字节,此时 `CHAR(64)` 反而更高效——因为固定长度无需额外存储长度前缀。
-
----
-
-### 数据类型选择指南
-
-建表时数据类型直接决定磁盘占用和查询性能。以下是常见场景的经验推荐:
-
-```mermaid
-graph TD
- A["需要整数?" -->|"是"| B{"需要 UNSIGNED?"}
- B -->|"否"| C["INT — 覆盖 -21 万 ~ 21 万"]
- B -->|"是"| D["BIGINT UNSIGNED — 最大 1844 亿"]
-
- A -->|"否"| E["字符串?"]
- E -->|"变长 < 255"| F["VARCHAR(N)"]
- E -->|"定长 (密码/签名)"| G["CHAR(N)"]
- E -->|"超长 (文章/JSON)"| H["TEXT / LONGTEXT"]
-```
-
-| 类型家族 | 适用场景 | 避坑提示 |
-|----------|---------|---------|
-| `TINYINT` | 状态标记、布尔值 | 建议加 `UNSIGNED`,0~255 足够 |
-| `INT` | 计数器、外键引用 | 数字型 ID 优先 `INT UNSIGNED`,超 42 亿再用 `BIGINT` |
-| `VARCHAR(N)` | 用户名、邮箱、地址 | N 按实际 +20% 余量;不要盲目设 255 |
-| `DATETIME` | 所有时间戳 | MySQL 8.0 推荐;避免 `TIMESTAMP` 的 2038 年瓶颈 |
-| `DECIMAL(M,D)` | 金额 | **绝对禁止**用 `FLOAT/DOUBLE` 存储财务数据 |
-
-#### 一个常见的反例
-
-```sql
--- ❌ 错误:用 FLOAT 存价格,会出现精度丢失
-price FLOAT DEFAULT 0.00,
-
--- ✅ 正确:DECIMAL 精确表示货币,M 总位数 D 小数位
-price DECIMAL(10, 2) DEFAULT 0.00 COMMENT '单位:元',
-```
-
-> [!NOTE] DECIMAL(10,2) 表示最多 10 位数字,其中 2 位在小数点后,即最大值为 `99999999.99`。在 InnoDB 内部以二进制紧凑存储,性能接近整数类型。
-
-## ALTER TABLE — 常见操作
-
-### 增删改列
-
-`CHANGE` 与 `MODIFY` 的区别在于:**CHANGE 必须同时写出列名(可改名)**,而 MODIFY 只改属性不更名。这是初学最容易混淆的地方。
-
-```sql
--- 添加列
-ALTER TABLE users ADD COLUMN phone VARCHAR(20) AFTER email;
-ALTER TABLE users ADD COLUMN last_login_ip VARCHAR(45); -- IPv4/IPv6 通用
-
--- 修改列类型(不改名)
-ALTER TABLE users MODIFY COLUMN status TINYINT UNSIGNED DEFAULT 0;
-
--- 重命名列(改名 + 可选改类型)
-ALTER TABLE users CHANGE COLUMN phone phone_number VARCHAR(20);
-
--- 删除列
-ALTER TABLE users DROP COLUMN last_login_ip;
-
--- 删除索引
-ALTER TABLE users DROP INDEX idx_username;
-```
-
-> [!WARNING] ALTER TABLE DROP COLUMN 不可逆
-> 列一旦删除,其中的数据和统计信息全部消失。**生产环境执行 DDL 前务必确认已有备份**。现代运维流程中应通过迁移工具(见关联笔记)做版本化管理,而非手动执行 ALTER。
-
-### 在线 DDL(ALGORITHM / LOCK)
-
-MySQL 5.6+ 支持 Online DDL,允许在结构变更期间持续处理读写请求。
-
-```sql
--- 三种算法对比
-ALTER TABLE users ADD COLUMN bio TEXT, ALGORITHM=INPLACE;
-ALTER TABLE users ADD COLUMN bio TEXT, ALGORITHM=COPY;
-ALTER TABLE users ADD COLUMN bio TEXT; -- 8.0 默认 INPLACE
-
--- 三种锁策略对比
-ALTER TABLE ... LOCK=NONE; -- 不阻塞任何读/写(最安全)
-ALTER TABLE ... LOCK=SHARED; -- 允许并发读,阻塞写
-ALTER TABLE ... LOCK=EXCLUSIVE; -- 阻塞所有其他操作(最快)
-```
-
-> [!NOTE] INPLACE vs COPY 的本质区别
-> - **INPLACE**:直接在原表上重建索引或新增索引,不需要全表拷贝数据。支持的场景包括:增加/删除索引、改变列默认值、新增列等。
-> - **COPY**:创建临时表 → 逐行拷贝数据 → 原子替换。适用于改变列定义导致格式不兼容的场景,比如 `VARCHAR` 改 `INT`。
->
-> **经验法则**:8.0 默认的 `INPLACE` 对大多数操作都够用。若不确定,先跑 `pt-online-schema-change --dry-run` 看预估耗时。
-
-```mermaid
-flowchart LR
- A["ALTER TABLE 开始"] --> B{"ALGORITHM"}
- B -->|"INPLACE"| C["直接修改数据字典
原地更新索引"]
- B -->|"COPY"| D["创建临时表拷贝数据后替换"]
-
- C --> E{"LOCK"}
- D --> E
-
- E -->|"NONE"| F["在线执行
读写不阻塞"]
- E -->|"SHARED"| G["并发读
写入等待"]
- E -->|"EXCLUSIVE"| H["阻塞全部操作
速度最快"]
-
- style F fill:#00D866,color:#fff
- style G fill:#FF9F43,color:#000
- style H fill:#EE5A24,color:#fff
-```
-
-### 大表 DDL 实战技巧
-
-对百万行以上的表执行 `ALTER TABLE` 时:
-
-```bash
-# ❌ 危险:直接在线上执行
-ALTER TABLE big_table ADD INDEX idx_col (col);
-
-# ✅ 方案一:pt-online-schema-change(Percona Toolkit)
-pt-online-schema-change \
- --user=root --password=xxx \
- --alter "ADD INDEX idx_col (col)" \
- D=mydb,t=big_table \
- --execute
-
-# ✅ 方案二:gh-ost(GitHub 开源)
-gh-ost \
- --user=root --password=xxx \
- --host=127.0.0.1 --port=3306 \
- --database=mydb \
- --table=big_table \
- --alter "ADD INDEX idx_col (col)" \
- --allow-on-master \
- --execute
-```
-
-> [!TIP] pt-osc 原理三步走
-> 1. **创建新表**:与原表结构一致 + 目标变更
-> 2. **触发器同步**:创建 INSERT/UPDATE/DELETE 触发器,将原表的变更实时同步到新表
-> 3. **原子替换**:加排他锁极短时间,swap 两张表名后删除触发器和旧表
-
-> [!QUESTION] gh-ost 和 pt-osc 怎么选?
-> - **pt-osc** 依赖触发器,对高并发写密集型表有较大开销
-> - **gh-ost** 基于 binlog 解析,无需触发器,对线上影响更小
-> - **结论**:新项目优先 gh-ost;老项目已有 pt-toolkit 积累也可以用 pt-osc
-
-### NULL vs NOT NULL — 选型指南
-
-这是 DDL 中最常见的争论之一。核心原则:**能用 NOT NULL 就不用 NULL**。
-
-```mermaid
-graph TD
- A["是否允许未知状态" -->|"否"| B["NOT NULL + DEFAULT"]
- A -->|"是"| C{"业务语义?"}
- C -->|"逻辑删除/软删"| D["TINYINT DEFAULT 0
0=正常 1=已删除"]
- C -->|" truly optional "| E["允许 NULL
但加注释说明含义"]
-```
-
-| 维度 | NOT NULL + DEFAULT | 允许 NULL |
-|------|-------------------|----------|
-| **索引效率** | InnoDB 二级索引不存储纯 NULL 值,用默认值占位可减少索引碎片 | 包含 NULL 标记的索引需要额外 1 bit |
-| **查询安全** | `WHERE col = ?` 不会遗漏任何行 | `WHERE col = 'value'` **排除了 NULL 行**(需用 `IS NULL`) |
-| **聚合函数** | SUM/COUNT 直接可用 | COUNT(col) 忽略 NULL 行,容易误判总行数 |
-| **可读性** | `status = 0` 一目了然 | `status IS NULL` 语义模糊——到底是"还没填"还是"被清除了"? |
-
-> [!WARNING] NULL 的三个常见误区
-> 1. **"NULL 占空间更小"**:InnoDB 中 NULL 也需要在行格式中标记,固定为每 8 个字节 1 bit 的 null bitmap。比存一个空字符串或零值并不节省什么。
-> 2. **"NULL = NULL"**:在 SQL 三值逻辑中,`NULL = NULL` 结果为 UNKNOWN,永远不等于 TRUE。判断必须用 `IS NULL` / `IS NOT NULL`。
-> 3. **"外键可以为 NULL"**:技术上可以,但会导致孤儿记录难以追踪。建议用显式的 `deleted_at` 时间戳代替外键 NULL 做软删除。
-
-## 常用 DDL 查询
-
-这些 SQL 命令帮助你查看当前数据库的「结构全貌」,在排查索引失效、表空间膨胀时尤其有用。
-
-```sql
--- 查看表完整创建语句(含所有索引、注释、引擎配置)
-SHOW CREATE TABLE users\G
-
--- 查看所有索引(含索引类型、列顺序、唯一性)
-SHOW INDEX FROM users\G
-
--- 查看表统计信息(Rows 为估算值,非精确计数)
-SHOW TABLE STATUS LIKE 'users'\G
--- 重点关注 Rows(估算行数)、Data_length、Index_length
-
--- 查看分区情况
-SELECT PARTITION_NAME, PARTITION_EXPRESSION, PARTITION_DESCRIPTION
-FROM INFORMATION_SCHEMA.PARTITIONS WHERE TABLE_NAME = 'logs';
-```
-
-> [!TIP] 快速定位慢查询相关的结构问题
-> ```sql
-> -- 检查表是否存在大量碎片的索引(数据删除后未回收的空间)
-> SELECT TABLE_NAME, INDEX_NAME, CARDINALITY
-> FROM INFORMATION_SCHEMA.STATISTICS
-> WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'users'
-> ORDER BY CARDINALITY ASC;
->
-> -- 低基数(CARDINALITY 接近 0)的索引通常效果不佳,考虑移除
-> ```
-
-## 关联笔记
-
-- [[hhs/GORM/02-模型定义]] — GORM AutoMigrate 等价于 DDL 的自动化版本
-- [[hhs/GORM/17-迁移工具]] — Schema 迁移管理最佳实践
diff --git a/hhs/MySQL/11-DML 增删改.md b/hhs/MySQL/11-DML 增删改.md
deleted file mode 100644
index 63584ba..0000000
--- a/hhs/MySQL/11-DML 增删改.md
+++ /dev/null
@@ -1,251 +0,0 @@
----
-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/11-UNION 与集合运算.md b/hhs/MySQL/11-UNION 与集合运算.md
deleted file mode 100644
index 169f5f1..0000000
--- a/hhs/MySQL/11-UNION 与集合运算.md
+++ /dev/null
@@ -1,238 +0,0 @@
----
-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-DQL SELECT 全解析.md b/hhs/MySQL/12-DQL SELECT 全解析.md
deleted file mode 100644
index cf49d1a..0000000
--- a/hhs/MySQL/12-DQL SELECT 全解析.md
+++ /dev/null
@@ -1,312 +0,0 @@
----
-tags: [MySQL, SELECT, 查询执行顺序, GROUP BY, 分页, 窗口函数, CTE]
-create time: 2026-05-16 00:00
----
-
-# DQL — SELECT 全解析
-
-## 概述
-
-SELECT 是 MySQL 中使用频率最高的语句,也是最容易被误解的语句。理解其内部执行顺序是写出高性能查询的第一步。
-
-## SQL 书写顺序 vs 执行顺序
-
-```mermaid
-flowchart LR
- W["⑥ SELECT"] --> A["① FROM"]
- A --> B["② JOIN"]
- B --> C["③ ON"]
- C --> D["④ WHERE"]
- D --> E["⑤ GROUP BY"]
- E --> F["⑦ HAVING"]
- F --> G["⑧ DISTINCT"]
- G --> H["⑨ ORDER BY"]
- H --> I["⑩ LIMIT / OFFSET"]
-
- style W fill:#C44569,color:#fff
- style A fill:#00B6BC,color:#fff
- style I fill:#4FC08D,color:#fff
-```
-
-> [!QUESTION] 为什么理解这个很重要?
-> 因为 SQL 不是按照你写的顺序执行的——而是按数字顺序!这意味着:
-> - `WHERE` 在 `SELECT` 之前执行 → 不能在 WHERE 中使用 SELECT 定义的别名
-> - `GROUP BY` 在 `HAVING` 之前 → WHERE 过滤行,HAVING 过滤分组
-> - `ORDER BY` 在 `LIMIT` 之前 → 先排序再截断
-
-### 一个完整的例子
-
-```sql
-SELECT
- DATE(created_at) AS order_date, -- ⑥ 计算列
- COUNT(*) AS cnt, -- 聚合函数
- SUM(amount) AS total -- 聚合函数
-FROM orders -- ① 确定数据源
-WHERE status = 'paid' -- ④ 先过滤行
- AND created_at >= '2026-01-01' -- 过滤条件
-GROUP BY DATE(created_at) -- ⑤ 按日期分组
-HAVING COUNT(*) > 5 -- ⑦ 过滤分组
-ORDER BY total DESC -- ⑨ 排序
-LIMIT 10; -- ⑩ 取前 10 页
-```
-
-## SELECT 关键字详解
-
-### DISTINCT
-
-```sql
--- 去重查询
-SELECT DISTINCT department FROM employees;
-
--- DISTINCT 作用于所有选中的列组合
-SELECT DISTINCT country, city FROM customers;
--- 返回的是 (country, city) 的唯一组合
-```
-
-> [!QUESTION] DISTINCT 一定快吗?
-> 不一定。`DISTINCT` 本质上是 `GROUP BY` 的简化版——MySQL 内部可能通过 temporary table + filesort 去重。当数据量大时,它的代价不亚于一次普通的分组聚合。
->
-> **替代方案**:如果去重目的是为下拉框提供选项,可以用 `SELECT DISTINCT department FROM employees LIMIT 50;` 限制返回量;更优的做法是在应用层缓存选项列表。
-
-### GROUP BY 优化
-
-```sql
--- ✅ 好:GROUP BY 走索引
--- idx_status_dept = (status, department),可以直接按 department 分组
-SELECT department, COUNT(*)
-FROM employees
-WHERE status = 'active'
-GROUP BY department;
-
--- ❌ 差:GROUP BY 无法利用索引(LIKE 左模糊导致索引失效)
-SELECT department, COUNT(*)
-FROM employees
-WHERE name LIKE '%chen%' -- 左模糊导致索引失效
-GROUP BY department;
--- EXPLAIN: Using where; Using temporary
-```
-
-### HAVING 与 WHERE 的选择
-
-```sql
--- ✅ WHERE 过滤行(早过滤,减少数据量)
-SELECT department, AVG(salary)
-FROM employees
-WHERE hire_date >= '2024-01-01' -- 先筛选最近入职的人
-GROUP BY department
-HAVING AVG(salary) > 15000; -- 再过滤平均工资
-
--- ❌ 把能放 WHERE 的条件放到 HAVING 里
--- 虽然结果一样,但效率更低
-SELECT department, AVG(salary)
-FROM employees
-GROUP BY department
-HAVING hire_date >= '2024-01-01' -- 错!HAVING 不能用非聚合列
- AND AVG(salary) > 15000;
-```
-
-## 窗口函数 (WINDOW FUNCTIONS)
-
-窗口函数是 MySQL 8.0+ 引入的分析利器——它能在**不减少行数**的前提下进行聚合计算。
-
-### 排名函数
-
-```sql
--- ROW_NUMBER() / RANK() / DENSE_RANK()
--- 按部门内工资排名(处理并列名次的三种方式)
-SELECT name, department, salary,
- ROW_NUMBER() OVER(PARTITION BY department ORDER BY salary DESC) AS rn,
- RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS rnk,
- DENSE_RANK() OVER(PARTITION BY department ORDER BY salary DESC) AS drnk
-FROM employees;
-
--- 结果示例:
--- | name | dept | salary | rn | rnk | drnk |
--- | Alice | sales | 20000 | 1 | 1 | 1 |
--- | Bob | sales | 20000 | 2 | 1 | 1 |
--- | Carol | sales | 18000 | 3 | 3 | 2 |
-```
-
-> [!TIP] RANK vs DENSE_RANK 的区别
-> - `RANK(20000, 20000, 18000)` → **1, 1, 3**(跳过第二名)
-> - `DENSE_RANK(20000, 20000, 18000)` → **1, 1, 2**(不跳号)
-> - `ROW_NUMBER` → **1, 2, 3**(永远无并列)
-
-### 前后行访问 — LAG / LEAD
-
-```sql
-SELECT order_date, amount,
- LAG(amount, 1) OVER(ORDER BY order_date) AS prev_amount, -- 前一天的金额
- LEAD(amount, 1) OVER(ORDER BY order_date) AS next_amount -- 后一天的金额
-FROM daily_sales;
-
--- 计算日环比增长率
-SELECT order_date, amount,
- ROUND((amount - LAG(amount) OVER(ORDER BY order_date)) / LAG(amount) OVER(ORDER BY order_date) * 100, 2) AS growth_pct
-FROM daily_sales;
-```
-
-### 累计计算
-
-```sql
--- 累计求和 (Running Total)
-SELECT order_date, amount,
- SUM(amount) OVER(ORDER BY order_date) AS running_total
-FROM daily_sales;
-
--- 当前分区内占比
-SELECT department, name, salary,
- ROUND(salary * 100.0 / SUM(salary) OVER(PARTITION BY department), 2) AS dept_pct
-FROM employees;
-```
-
-> [!NOTE] 窗口函数执行时机
-> - 执行顺序在 WHERE、GROUP BY、HAVING **之后**,ORDER BY **之前**
-> - 因此不能用 WHERE 直接过滤窗口函数的结果——需要套一层子查询:
-> ```sql
-> SELECT * FROM (
-> SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) AS rn
-> FROM orders
-> ) ranked WHERE rn = 1;
-> -- 作用:每个用户的最新一条订单记录
-> ```
-
-## 子查询与 CTE
-
-### 标量子查询 (Scalar Subquery)
-
-返回单一值,可以像普通列一样使用:
-
-```sql
--- WHERE 中的标量子查询
-SELECT name, salary
-FROM employees
-WHERE salary > (SELECT AVG(salary) FROM employees); -- 工资高于公司平均的人
-```
-
-### 行子查询 (Row Subquery / IN)
-
-```sql
--- IN 子查询
-SELECT name, department
-FROM employees
-WHERE department IN (SELECT id FROM departments WHERE region = 'APAC');
-
--- EXISTS:关注"是否存在"而非具体数据,比 IN 更高效
-SELECT d.name
-FROM departments d
-WHERE EXISTS (SELECT 1 FROM employees e WHERE e.dept_id = d.id);
-```
-
-### CTE (Common Table Expression) — WITH 语法
-
-MySQL 8.0+ 推荐用法,比嵌套子查询可读性强很多:
-
-```sql
--- 简单 CTE
-WITH dept_stats AS (
- SELECT department, COUNT(*) AS emp_count, AVG(salary) AS avg_salary
- FROM employees
- GROUP BY department
-)
-SELECT * FROM dept_stats WHERE avg_salary > 15000;
-
--- 递归 CTE:处理层级数据(组织架构、分类树等)
-WITH RECURSIVE org_chart AS (
- -- 锚点成员:根节点
- SELECT id, name, manager_id, 1 AS level
- FROM employees
- WHERE manager_id IS NULL
-
- UNION ALL
-
- -- 递归成员:逐层展开
- SELECT e.id, e.name, e.manager_id, oc.level + 1
- FROM employees e
- INNER JOIN org_chart oc ON e.manager_id = oc.id
-)
-SELECT * FROM org_chart ORDER BY level, name;
-```
-
-> [!QUESTION] CTE vs 派生表?
-> - **可读性**:CTE 命名清晰,逻辑分层;派生表层层嵌套,括号匹配困难
-> - **性能**:MySQL 会将非递归 CTE 优化为临时表或内联展开——多数情况下两者性能一致
-> - **复用**:同一个 CTE 可在一个语句中多次引用(派生表不行)
-
----
-
-## 深分页问题
-
-这是 MySQL 最著名的性能陷阱之一。
-
-```sql
--- ❌ 灾难级写法:扫描 100 万行后丢弃前 999,990 行
-SELECT * FROM orders LIMIT 999990, 10;
--- MySQL 需要先定位到第 999990 行,才返回接下来的 10 行
-
--- ✅ 方案一:延迟关联(Deferred Join)
-SELECT o.* FROM orders o
-INNER JOIN (
- SELECT id FROM orders ORDER BY id LIMIT 999990, 10
-) AS tmp ON o.id = tmp.id;
--- 子查询只扫主键索引(极紧凑),外层再 JOIN 拿完整数据
-```
-
-```mermaid
-flowchart LR
- subgraph "传统 LIMIT 999990,10"
- A1["扫描聚簇索引
跳过 999990 行"] --> A2["取出 10 行数据"]
- end
-
- subgraph "延迟关联方案"
- B1["扫描聚簇索引
跳过 999990 行"] --> B2["仅提取 10 个主键"]
- B2 --> B3["JOIN 回聚簇索引
精确查找 10 个主键"]
- B3 --> B4["返回结果"]
- end
-
- A1 --> A2
- style B1 fill:#00B6BC,color:#fff
- style B2 fill:#00D866,color:#fff
-```
-
-### 游标分页(推荐)
-
-```sql
--- 上一页最后一条记录的 id = 999985
-SELECT * FROM orders
-WHERE id > 999985
-ORDER BY id ASC
-LIMIT 10;
-```
-
-> [!TIP] 为什么游标分页更优?
-> - `WHERE id > ?` 走索引范围扫描,复杂度 O(log N) 而非 O(N)
-> - 无论在第几页,查询时间恒定
-> - 需要前端传「上一页最后一个 ID」作为下一页的游标
->
-> **局限性**:不支持跳页(不能直接跳到第 100 页),但这对瀑布流场景足够。
-
-## 性能小贴士
-
-```sql
--- ❌ 避免 SELECT *
-SELECT * FROM users WHERE status = 1;
-
--- ✅ 只查需要的列
-SELECT id, username, email FROM users WHERE status = 1;
--- 好处:减少网络传输、提高 Buffer Pool 命中率、可能触发 Covering Index
-
--- ❌ 函数包裹索引列
-SELECT * FROM users WHERE YEAR(created_at) = 2026;
-
--- ✅ 用范围替代函数
-SELECT * FROM users
-WHERE created_at >= '2026-01-01'
- AND created_at < '2027-01-01';
-```
-
-## 关联笔记
-
-- [[hhs/MySQL/12-JOIN 原理与优化]] — 深入理解 JOIN 的内部执行机制
-- [[hhs/MySQL/13-子查询与派生表]] — 与 SELECT 密切相关的子查询技术
-- [[hhs/GORM/06-排序与分页]] — GORM 分页 API 与原生 SQL 的差异
diff --git a/hhs/MySQL/13-JOIN 原理与优化.md b/hhs/MySQL/13-JOIN 原理与优化.md
deleted file mode 100644
index ed74c52..0000000
--- a/hhs/MySQL/13-JOIN 原理与优化.md
+++ /dev/null
@@ -1,295 +0,0 @@
----
-tags: [MySQL, JOIN, Nested Loop, 索引优化]
-create time: 2026-05-16 00:00
----
-
-# JOIN 原理与优化
-
-## 概述
-
-JOIN 是关系型数据库的核心能力,也是性能问题的主要来源。理解 MySQL 的 JOIN 执行算法才能写出高效的关联查询。
-
-## JOIN 类型速览
-
-```sql
--- Inner JOIN:只返回两边都匹配的行(最常用)
-SELECT * FROM orders o INNER JOIN users u ON o.user_id = u.id;
-
--- LEFT OUTER JOIN:左表全保留,右表不匹配则为 NULL
-SELECT o.id, u.username
-FROM orders o LEFT JOIN users u ON o.user_id = u.id
-WHERE u.id IS NULL; -- 找孤儿订单(用户已被删除)
-
--- RIGHT OUTER JOIN:等价于交换左右表的 LEFT JOIN
--- 实践中几乎不用 RIGHT JOIN,改成 LEFT JOIN 更易读
-
--- CROSS JOIN:笛卡尔积(慎用!)
-SELECT a.name, b.name FROM table_a CROSS JOIN table_b;
--- 结果 = |a| × |b| 行
-```
-
-## JOIN 执行算法
-
-MySQL InnoDB 在执行 JOIN 时,**本质上只有两种核心策略**:被驱动表有索引时用 **INLJ**(Index Nested-Loop Join),没有索引时退化到 **BNLJ**(Block Nested-Loop Join)。而 INLJ 内部又根据索引是否为唯一键进一步细分。
-
-| 算法缩写 | 全称 | 触发条件 | 关键特征 |
-|---------|------|---------|---------|
-| **INLJ** | Index Nested-Loop Join | 被驱动表 JOIN 列有索引 | 每行做一次索引查找,O(log m) |
-| **BNLJ** | Block Nested-Loop Join | 被驱动表无可用索引 | 多行拼成块写入 Join Buffer,逐表扫描 |
-
-> [!NOTE] 为什么没有"Simple NLJ"?
-> Simple NLJ 是学术上的概念——外层每行内层逐行比较。MySQL **从未使用**这种实现:有索引就走 INLJ(直接 SEEK),没索引就直接上 BNLJ(分块缓冲)。下文不再讨论 Simple NLJ。
-
-### 决策对照表
-
-当你写了一个 JOIN 后,Optimizer 的选择逻辑如下:
-
-```mermaid
-flowchart TD
- J["Optimizer 开始评估"] --> D{"被驱动表 JOIN 列
是否有索引?"}
-
- D -->|有| INLJ["Index Nested-Loop Join"]
- D -->|无| BNLJ["Block Nested-Loop Join"]
-
- INLJ --> I{"该索引是否为
唯一索引 / 主键?"}
- I -->|是| UNIJ["Unique NLJ
一次查找即返回"]
- I -->|否| INDEXJ["Non-Unique NLJ
一次查找可能多行"]
-
- BNLJ --> BUFF["读取 N 行 → Join Buffer
被驱动表全扫 1 次"]
-
- style UNIJ fill:#00D866,color:#fff
- style INDEXJ fill:#FF9F43,color:#000
- style BUFF fill:#EE5A24,color:#fff
-```
-
-> [!TIP] 一眼判断好坏
-> - 走 **Unique NLJ** (eq_ref) → ✅ 最优
-> - 走 **INLJ** (ref) → ✅ 良好
-> - 走 **BNLJ** (ALL) → ⚠️ 需要加索引
-
-### 1. Block Nested-Loop Join(BNL,块嵌套循环)
-
-当被驱动表**没有可用索引**时启用。MySQL 会将多行驱动表缓存到 Join Buffer 中,一次性与被驱动表比较。
-
-```sql
--- 假设 user.tag 和 order.tag 上都没有索引
--- Optimizer 会选择 BNLJ
-SELECT * FROM users u JOIN orders o ON u.tag = o.tag;
-```
-
-```
-Step 1: 从 users 表读 N 行 → Join Buffer (默认 256KB)
-Step 2: 扫描 orders 表,对每一行与 Buffer 中的所有行比较
-Step 3: Buffer 满了就输出匹配结果,清空再加载
-Step 4: 重复直到读完 users 表
-
-总成本 = ceil(rows_users / buffer_rows) × rows_orders
-```
-
-> [!NOTE] Join Buffer 大小
-> ```sql
-> SHOW VARIABLES LIKE 'join_buffer_size';
-> -- 默认 256KB,可调至最大 4MB
-> -- 每个连接独立分配,退出时释放
-> -- 注意:它不参与排序也不去重,纯粹做行数据缓存
-> ```
-
-**BNLJ 的致命弱点**:被驱动表无论多大都必须全扫一次。如果两张都是百万级大表,代价呈爆炸式增长。
-
-### 2. Index Nested-Loop Join(INLJ,索引嵌套循环)
-
-最理想的 JOIN 方式——被驱动表可以通过索引快速定位。
-
-```sql
--- orders.user_id 上有索引 → Optimizer 选 INLJ
-SELECT * FROM users u JOIN orders o ON u.id = o.user_id;
-
--- 提示优化器使用特定 JOIN 顺序(非强制)
-SELECT * FROM users u USE INDEX FOR JOIN (idx_user_id)
-JOIN orders o ON u.id = o.user_id;
-```
-
-```mermaid
-sequenceDiagram
- participant Driver as 驱动表(users)
- participant IDX as 二级索引(idx_user_id)
- participant Target as 被驱动表(orders)
-
- loop 每行驱动数据
- Driver->>IDX: Seek key=user_id
- IDX-->>Driver: 找到匹配的 ROWID
- Driver->>Target: Read by ROWID (回表取完整行)
- Target-->>Driver: 返回完整行
- end
-```
-
-> [!NOTE] INLJ 的成本拆解
-> - 单次查找代价 = log₂(索引页数),通常 ≈ 3~4 次磁盘随机读
-> - 若驱动表 1000 行、被驱动表 100 万行:**1000 × log₂(1M) ≈ 1000 × 20 = 20000 次索引查找**
-> - 对比 BNLJ:若走 BNLJ 则需 **1000 / buffer_rows × 1M** 次行比较 —— 差距巨大
-
-**INLJ 的两个子分类**:
-
-| 子类 | 索引类型 | 每次查找返回 | Extra 标识 |
-|------|---------|------------|-----------|
-| Unique NLJ | 主键 / 唯一索引 | 恰好 0 或 1 行 | `Using index condition` |
-| Non-Unique NLJ | 普通二级索引 | 可能 0…N 行 | `Using index condition` |
-
-## 驱动表选择
-
-MySQL 在解析 SQL 时,**默认从左到右**确定驱动表——左边第一张表就是驱动表。但 Optimizer 会在评估成本后决定是否交换表的顺序(以最小化被驱动表的扫描行数)。
-
-```sql
--- 经验法则:用小表驱动大表
--- Optimizer 通常会根据统计信息自动决定最优顺序
-SELECT * FROM small_table t1 JOIN large_table t2 ON t1.id = t2.small_id;
-```
-
-> [!TIP] 黄金法则
-> **驱动表可以是全表扫描,但被驱动表必须走索引。**
-> 任何 JOIN 优化都要回到这个原则:检查 EXPLAIN 中被驱动表的 type 是否为 ref / eq_ref / range,如果是 ALL 就说明优化失败了。
-
-### STRAIGHT_JOIN:强制驱动表顺序
-
-当 Optimizer 因统计信息过期或数据分布不均而选错顺序时,可以用 `STRAIGHT_JOIN` 强制指定:
-
-```sql
--- 告诉 MySQL:不要用你的优化器,按我写的顺序执行
-SELECT * FROM large_table t1 STRAIGHT_JOIN small_table t2
-ON t1.id = t2.small_id;
-```
-
-**适用场景**:
-1. EXPLAIN 显示被驱动表走了全表扫描(type = ALL)
-2. 大表作为驱动表且小表能走索引(`small_id` 有索引)时,比反过来的成本低得多
-3. 临时排查问题——固定顺序便于复现和优化
-
-> [!WARNING] 谨慎使用 STRAIGHT_JOIN
-> 它绕过了 Optimizer 的成本模型,仅在确认优化器做出错误选择时才用。数据分布变化后可能反而变慢。优先选择修复统计信息(`ANALYZE TABLE`)或调整索引。
-
-### 驱动表 vs 被驱动表的判断标准
-
-| 角色 | 访问方式 | 理想 type | 可接受 type |
-|------|---------|----------|-----------|
-| **驱动表** | 全表扫描 / 索引扫描 | `ALL`, `index` | — |
-| **被驱动表** | 每行索引查找 | `eq_ref`, `ref` | `range`, `fulltext` |
-| **两者都差** | ⚠️ 性能灾难 | — | `ALL` × 2 |
-
-## JOIN 优化 Checklist
-
-### 流程概览
-
-```mermaid
-flowchart TD
- A["写好 JOIN 查询"] --> B{"EXPLAIN 分析"}
- B --> C{"被驱动表 type"}
-
- C -->|"eq_ref / ref"<| OK["✅ 走索引,优秀"]
- C -->|"range"<| WARN["⚠️ 范围扫描,可接受"]
- C -->|"ALL / index"<| BAD["❌ 全表/全索引扫描"]
-
- BAD --> D{"Checklist 逐项排查"}
- D --> E["被驱动表的 JOIN 条件列有索引吗?"]
- D --> F["JOIN 条件的数据类型一致吗?
VARCHAR vs INT 会导致索引失效"]
- D --> G["有没有函数包裹 JOIN 列?"]
- D --> H["能不能把 JOIN 拆成多次单表查询?"]
-
- style OK fill:#00D866,color:#fff
- style BAD fill:#EE5A24,color:#fff
-```
-
-### 实战:EXPLAIN 输出解读
-
-看一个具体的例子:
-
-```sql
-EXPLAIN SELECT * FROM users u
-JOIN orders o ON u.id = o.user_id
-JOIN products p ON o.product_id = p.id;
-```
-
-期望的 EXPLAIN 输出:
-
-```
-+----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
-| id | select_type | table | type | key | extra | rows | filtered | ref | |
-+----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
-| 1 | SIMPLE | u | ALL | NULL | | 1000 | 100.00 | NULL | |
-| 1 | SIMPLE | o | ref | idx_user_id | | 50 | 100.00 | u.id | |
-| 1 | SIMPLE | p | eq_ref | PRIMARY | | 1 | 100.00 | o.product_id | |
-+----+-------------+-------+--------+---------------+---------+---------+-------------------+------+-------+
-```
-
-逐字段说明:
-
-| 字段 | 含义 | 本例中的解读 |
-|------|------|------------|
-| **table** | 当前行涉及的表 | 三表 JOIN 有三行输出 |
-| **type** | 访问类型(关键指标) | `ALL` → 驱动表全扫(正常);`ref` → 被驱动表走普通索引;`eq_ref` → 被驱动表走主键/唯一索引 |
-| **key** | 实际使用的索引 | `idx_user_id` 和 `PRIMARY` 均命中 |
-| **rows** | 预估扫描行数 | 1000 × 50 × 1 = 50000 次索引查找,可接受 |
-| **filtered** | WHERE 过滤后的比例 | 100% 表示 WHERE 还没起作用(无额外过滤条件) |
-| **Extra** | 额外信息 | 无 `Using filesort` / `Using temporary`,说明执行计划健康 |
-
-> [!NOTE] Extra 中需要警惕的关键字
-> - `Using filesort` → 需要额外排序,考虑加联合索引
-> - `Using temporary` → 用了临时表,常出现在 DISTINCT / GROUP BY / UNION 中
-> - `Using index condition` → 下推索引条件,部分过滤在存储引擎层完成,是好事
-
-### Multi-Join 处理策略(3+ 表)
-
-生产中最常见的是 3 表以上 JOIN。优化思路升级:
-
-1. **确保第 2 张及之后的每张表都有索引支撑 JOIN 条件**——只有第 1 张表可以全表扫描
-2. **用小表驱动大表**:EXPLAIN 输出从上到下依次是被驱动表,上面的驱动下面的
-3. **利用覆盖索引减少回表**:如果 SELECT 的列都在索引里,InnoDB 可以直接从索引树返回结果
-4. **拆分解耦**:超复杂的多表 JOIN 可以考虑拆成两步——先拿 ID 集合再批量查详情
-
-```sql
--- 覆盖索引示例:索引已包含所有需要的列,无需回表
-CREATE INDEX idx_order_cover ON orders(user_id, product_id, amount);
--- 此时下面这个查询可以直接走覆盖索引扫描
-SELECT user_id, product_id, amount FROM orders WHERE user_id = 42;
-```
-
-### USING vs ON
-
-```sql
--- 推荐:用 USING 更简洁(要求两列同名)
-SELECT * FROM users u JOIN orders o USING (user_id);
-
--- 等价的 ON 写法
-SELECT * FROM users u JOIN orders o ON u.user_id = o.user_id;
-
--- 进阶技巧:USING 的结果集中 user_id 只出现一列,避免列名冲突
-```
-
-### 常见 JOIN 陷阱
-
-| 陷阱 | 示例 | 修复 |
-|------|------|------|
-| **类型不匹配** | `u.code VARCHAR` JOIN `o.code INT` | 统一类型 |
-| **函数包裹** | `ON YEAR(u.created) = YEAR(o.created)` | 改用范围比较 |
-| **NULL 值** | `ON t1.col = t2.col` 中一列为 NULL 不匹配 | 检查业务逻辑 |
-| **隐式转换** | `WHERE varchar_col = 123` | 显式字符串比较 |
-| **缺少复合索引** | 多条件 JOIN 只用单列索引 | 创建覆盖联合索引 |
-
-## 性能对比速查表
-
-| 算法 | 最优场景 | 最坏场景 | 典型代价公式 |
-|------|---------|---------|-------------|
-| **Unique NLJ** | 被驱动表是主键 / 唯一索引 | 驱动表大量行无匹配(返回 NULL) | 驱动表行数 × log(被驱动表) |
-| **Non-Unique NLJ** | 被驱动表有普通二级索引 | 索引选择性差,大量重复值 | 驱动表行数 × log(被驱动表) × 平均匹配数 |
-| **Block NLJ** | 被驱动表无索引 + 驱动表较小 | 大表 × 大表无索引 | ceil(驱动表行数 / Buffer行数) × 被驱动表行数 |
-
-> [!WARNING] 数量级示意
-> 假设驱动表 1000 行、被驱动表 100 万行:
-> - Unique NLJ:≈ 1000 × 20 = **2 万次**索引查找
-> - Block NLJ(Buffer 存 50 行):≈ ceil(1000/50) × 1,000,000 = **2000 万次**行比较
-> - 差距达 **三个数量级**,这就是为什么被驱动表索引如此重要。
-
-## 关联笔记
-
-- [[hhs/MySQL/14-子查询与派生表]] — EXISTS / IN 子查询与 JOIN 的替代关系
-- [[hhs/MySQL/16-B+Tree 索引原理]] — JOIN 如何利用二级索引加速
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 通过 type 字段识别 JOIN 质量问题
diff --git a/hhs/MySQL/14-子查询与派生表.md b/hhs/MySQL/14-子查询与派生表.md
deleted file mode 100644
index a08f045..0000000
--- a/hhs/MySQL/14-子查询与派生表.md
+++ /dev/null
@@ -1,265 +0,0 @@
----
-tags: [MySQL, 子查询, EXISTS, IN, 派生表]
-create time: 2026-05-16 00:00
----
-
-# 子查询与派生表
-
-## 概述
-
-子查询是嵌套在另一个查询中的 SELECT 语句。它可以在 WHERE、FROM、SELECT 等多个位置出现,每种位置的语义和执行方式不同。
-
-## 分类
-
-```mermaid
-graph BT
- subgraph "标量子查询"
- S1["返回一行一列
用在 SELECT / WHERE"]
- end
- subgraph "行子查询"
- S2["返回一行多列
用在 ROW() 比较"]
- end
- subgraph "列子查询"
- S3["返回多行一列
用在 IN / ANY / ALL"]
- end
- subgraph "表子查询(派生表)"
- S4["返回多行多列
用在 FROM 子句"]
- end
- subgraph "EXISTS 子查询"
- S5["返回布尔值
用于 EXISTS / NOT EXISTS"]
- end
-```
-
-## 标量子查询
-
-```sql
--- 用法 1:在 SELECT 中调用
-SELECT
- username,
- (SELECT COUNT(*) FROM orders WHERE user_id = users.id) AS order_count
-FROM users;
-
--- 用法 2:在 WHERE 中等价于常量
-SELECT * FROM products
-WHERE price > (SELECT AVG(price) FROM products);
-
--- 用法 3:在 INSERT 中赋值
-INSERT INTO reports (month, order_total)
-VALUES ('2026-05', (SELECT SUM(amount) FROM orders WHERE MONTH(created_at) = 5));
-```
-
-> [!WARNING] 标量子查询的性能隐患
-> MySQL 8.0.21 之前,标量子查询**无法被物化**,会导致每次执行都重新计算(N+1 问题)。
-> ```sql
-> -- ❌ 慢:每个用户都要查一次 orders 表
-> SELECT u.username,
-> (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
-> FROM users u;
-> -- 如果有 10 万用户,就要查 10 万次 orders 表
->
-> -- ✅ 换成 JOIN 或窗口函数
-> SELECT u.username, COALESCE(SUB.cnt, 0) AS cnt
-> FROM users u
-> LEFT JOIN (
-> SELECT user_id, COUNT(*) AS cnt
-> FROM orders GROUP BY user_id
-> ) SUB ON u.id = SUB.user_id;
-> ```
-
-## IN vs EXISTS
-
-这是面试经典题,也是实际中最纠结的选择。
-
-```sql
--- IN 子查询
-SELECT * FROM users
-WHERE id IN (SELECT user_id FROM orders);
-
--- EXISTS 子查询
-SELECT * FROM users u
-WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-```
-
-```mermaid
-flowchart TD
- A["Optimizer 选择策略"] --> B{"哪张表更小?"}
-
- B -->|"子查询结果小"<| C["转换 IN → EXISTS
先跑子查询,外层仅匹配结果"]
- B -->|"子查询结果大"<| D["转换 EXISTS → IN
外层先行过滤,减少子查询次数"]
-
- C --> E["NOT IN 永远转不成 EXISTS!
NULL 值会导致语义错误"]
- D --> F["NOT EXISTS 是最安全的反查写法"]
-
- style E fill:#EE5A24,color:#fff
- style F fill:#00D866,color:#fff
-```
-
-> [!QUESTION] NOT IN 和 NOT EXISTS 有什么区别?
-> ```sql
-> -- ⚠️ 致命陷阱
-> SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
->
-> -- 如果子查询返回任何 NULL 值,整个 NOT IN 结果为 UNKNOWN
-> -- 最终返回空结果集!
->
-> -- ✅ 正确写法
-> SELECT * FROM users
-> WHERE id NOT IN (SELECT user_id FROM orders WHERE user_id IS NOT NULL);
->
-> -- 或者直接用 NOT EXISTS(更安全)
-> SELECT * FROM users u
-> WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-> ```
-
-### 选型决策:IN vs EXISTS vs JOIN
-
-实际开发中三者往往能写出等价逻辑,选法参考下表:
-
-| 场景 | 推荐写法 | 理由 |
-|------|----------|------|
-| 外大内小(外层百万、子查询几百) | `EXISTS` | 外层每行只需匹配一条即可停止,短路效应明显 |
-| 外小内大 | `IN` | Optimizer 会先物化子查询结果再匹配,效率高 |
-| 子查询含 `NULL` 值可能 | `EXISTS` / `NOT EXISTS` | `NOT IN` 遇到 NULL 直接返回空集(见上文陷阱) |
-| 需要返回外键表的完整列 | `JOIN` + `DISTINCT` | EXISTS 只能判断存在性,无法获取关联表字段 |
-| 只是判断"有没有" | `EXISTS` | 语义最清晰,性能也最优 |
-
-> [!TIP] 经验法则
-> **不确定时优先用 EXISTS**——它的语义是"是否存在"而非"值是否匹配",即使 MySQL Optimizer 最终把 IN 和 EXISTS 优化成执行计划也一样。用 EXISTS 能让读代码的人一眼看懂意图,同时给 Optimizer 留下优化空间。
-
-## 派生表(Derived Table / Subquery in FROM)
-
-```sql
--- 派生表:子查询作为一个虚拟表出现在 FROM 位置
-SELECT dept, avg_salary
-FROM (
- SELECT department AS dept, AVG(salary) AS avg_salary
- FROM employees
- WHERE status = 'active'
- GROUP BY department
-) AS dept_stats
-WHERE avg_salary > 15000
-ORDER BY avg_salary DESC;
-```
-
-### 物化(Materialization)
-
-MySQL 是否将子查询物化为临时表,取决于查询复杂度:
-
-```mermaid
-flowchart LR
- A["子查询"] --> B{"能否被推入外层?"}
- B -->|能| C["Flattening
展开为 JOIN,零额外开销 ✅"]
- B -->|不能| D{"是否需要聚合?"}
- D -->|是| E["Materialization
物化为临时表 ⚠️"]
- D -->|否| F["Semi-join 优化
半连接优化 ✅"]
-
- style C fill:#00D866,color:#fff
- style F fill:#00B6BC,color:#fff
- style E fill:#FF9F43,color:#000
-```
-
-```sql
--- ✅ 可以被 Flattening 优化(无额外开销)
-SELECT u.id, o.amount
-FROM users u
-JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
--- Optimizer 将其转换为普通的 JOIN
-
--- ❌ 必须 Materialization(有临时表开销)
-SELECT u.id, SUB.total
-FROM users u
-JOIN (
- SELECT user_id, SUM(amount) AS total
- FROM orders GROUP BY user_id
-) AS SUB ON u.id = SUB.user_id;
--- 子查询因为有 GROUP BY,必须先物化成临时表
-```
-
-> [!NOTE] 如何判断是否物化?
-> 使用 `EXPLAIN FORMAT=JSON`:
-> ```json
-> {
-> "select_type": "DERIVED",
-> "materialized_from_subquery": { ... }
-> }
-> ```
-> 如果出现 `"using_temporary_table": true`,说明使用了临时表。
-
-实战中更常用的方式是 `EXPLAIN` + 观察 type 和 Extra:
-
-```sql
--- ❌ 物化型派生表 → Extra 出现 "Using temporary"
-EXPLAIN SELECT u.id, SUB.total
-FROM users u
-JOIN (SELECT user_id, SUM(amount) AS total FROM orders GROUP BY user_id) AS SUB ON u.id = SUB.user_id;
-
--- id | select_type | table | type | Extra
--- ---|----------------|------------|-------|----------------------------
--- 1 | PRIMARY | | ALL | NULL
--- 1 | PRIMARY | u | ALL | Using where
--- 2 | DERIVED | orders | ALL | Using temporary; Using filesort
-
--- ✅ 可 Flattening → 无临时表,Extra 干净
-EXPLAIN SELECT u.id, o.amount
-FROM users u JOIN (SELECT user_id, amount FROM orders) AS o ON u.id = o.user_id;
-
--- id | select_type | table | type | Extra
--- 1 | SIMPLE | orders| ALL | NULL
--- 1 | SIMPLE | u | index | Primary key
-```
-
-> [!TIP] 性能优化技巧
-> - 当发现派生表产生临时表时,考虑**手动提升为 CTE**(MySQL 8.0+),有时能改变 Optimizer 行为
-> - `optimizer_switch='derived_merge=on'`(默认开启)控制是否允许 Flattening
-> - 对于超大结果集物化,注意 `tmp_table_size` / `max_heap_table_size` 限制
-
-## ANY / ALL / SOME
-
-```sql
--- ANY:子查询返回的值中,任意一个满足即可
-SELECT product_name, price
-FROM products
-WHERE price > ANY (SELECT price FROM products WHERE category = 'premium');
--- 只要比任意一件 premium 产品便宜就行
-
--- ALL:必须大于子查询返回的所有值
-SELECT product_name, price
-FROM products
-WHERE price > ALL (SELECT price FROM products WHERE category = 'premium');
--- 必须比所有 premium 产品都贵(即最大值之上)
-
--- SOME 等价于 ANY(同义词)
-```
-
-```mermaid
-graph TB
- P["Products: 10, 50, 100, 200"]
-
- ANY -->|"price > ANY(...)"<| A1["> 10 OR > 50 OR > 100 OR > 200"]
- A1 --> R1["结果: 所有 > 10 的商品"]
-
- ALL -->|"price > ALL(...)"<| A2["> 10 AND > 50 AND > 100 AND > 200"]
- A2 --> R2["结果: 只有 > 200 的商品"]
-
- style R1 fill:#00B6BC,color:#fff
- style R2 fill:#C44569,color:#fff
-```
-
-## 总结:核心要点回顾
-
-| 主题 | 一句话 |
-|------|--------|
-| 标量子查询 | MySQL 8.0.21 之前不可物化,百万行数据必踩 N+1 陷阱,优先改写为 JOIN |
-| IN vs EXISTS | 语义上 IN 看值、EXISTS 看存在性;不确定时选 EXISTS,更安全的默认选项 |
-| NOT IN 陷阱 | 子查询出现 NULL 即返回空集,生产中几乎永远该用 `NOT EXISTS` 替代 |
-| 派生表物化 | 有 GROUP BY / LIMIT / UNION 等操作的子查询无法被展开,必然产生临时表开销 |
-| ANY / ALL | 对应 SQL 的 OR / AND 累加,ALL 在空子查询结果时恒返回 TRUE(反直觉,需注意) |
-
-> [!TIP] 核心心法
-> **子查询不是万能的——它首先是为了表达清晰,其次才是性能。**
-> 写完后务必跑 `EXPLAIN`,确认没有意料之外的临时表或多表扫描。当数据量上去后,能改写成 JOIN 的子查询就尽量改写,因为 JOIN 的执行路径对 Optimizer 更加透明。
-
-## 关联笔记
-
-- [[hhs/MySQL/12-DQL SELECT 全解析]] — SELECT 执行顺序与子查询的关系
-- [[hhs/MySQL/13-JOIN 原理与优化]] — EXISTS 子查询常可重写为 JOIN
diff --git a/hhs/MySQL/15-UNION 与集合运算.md b/hhs/MySQL/15-UNION 与集合运算.md
deleted file mode 100644
index 169f5f1..0000000
--- a/hhs/MySQL/15-UNION 与集合运算.md
+++ /dev/null
@@ -1,238 +0,0 @@
----
-tags: [MySQL, UNION, UNION ALL, 集合运算]
-create time: 2026-05-16 00:00
----
-
-# UNION / UNION ALL
-
-## 概述
-
-UNION 是 SQL 标准中的集合运算,用于将多个 SELECT 的结果纵向合并为一个结果集。核心应用场景包括:**分表数据汇总**、**多源 Feed 流合并**、以及**需要排序去重的跨查询聚合**。
-
-## 基本语法
-
-```sql
--- 两种形式
-SELECT col1, col2 FROM table_a
-UNION -- 去重(内部排序 + 去重)
-SELECT col1, col2 FROM table_b;
-
-SELECT col1, col2 FROM table_a
-UNION ALL -- 不去重(直接拼接,性能更高)
-SELECT col1, col2 FROM table_b;
-```
-
-## UNION vs UNION ALL
-
-| 特性 | UNION | UNION ALL |
-|------|-------|-----------|
-| **去重** | ✅ 内部去重 | ❌ 保留所有行 |
-| **性能** | 低(需排序去重) | 高(直接追加) |
-| **ORDER BY 位置** | 只能放在最后一个 SELECT | 同上 |
-| **适用场景** | 需要唯一结果的合并 | 已知不重复的合并 |
-
-```mermaid
-flowchart LR
- A1["数据源 A"] --> U
- B1["数据源 B"] --> U
-
- U{"UNION"} --> R1["全部数据去重后输出"]
-
- U2{"UNION ALL"} --> R2["全部数据直接拼接"]
-
- style R1 fill:#FF9F43,color:#000
- style R2 fill:#00D866,color:#fff
-```
-
-> [!TIP] 首选 UNION ALL
-> 如果你能通过业务逻辑保证各部分结果不重复(比如按日期区间划分),**一律用 UNION ALL**。UNION 的去重操作需要 Sort/Dedup 阶段,在大结果集上是昂贵操作。
-
-## 实战场景
-
-### 场景一:多表同构合并
-
-```sql
--- 日志系统按月分表:logs_202601, logs_202602, ..., logs_202605
-SELECT * FROM logs_202601
-UNION ALL
-SELECT * FROM logs_202602
-UNION ALL
-SELECT * FROM logs_202603
-UNION ALL
-SELECT * FROM logs_202604
-UNION ALL
-SELECT * FROM logs_202605
-WHERE status = 'error'
-ORDER BY created_at DESC
-LIMIT 50;
-```
-
-> [!NOTE] 分表合并的注意事项
-> - 上述写法中,`WHERE / ORDER BY / LIMIT` 仅作用于**最后一个 SELECT**。前面的分表查询不做过滤,全部返回后再合并排序。
-> - 如果每个分表数据量很大(百万级),建议**用子查询保护每个表的局部 ORDER BY + LIMIT**,先各取 Top-N 再全局排。这大幅减少中间结果集大小。
-> - UNION ALL 不保证顺序,最终 ORDER BY 必不可少。
-
-### 场景二:多表分页合并(Feed 流)
-
-```sql
--- 需求: 用户的动态 Feed 由关注的人和发布的文章混合组成, 按时间排序分页
--- ❌ 应用层先查再排: 至少两次 DB 往返 + 内存归并排序
--- ✅ UNION ALL 一次搞定
-
-SELECT user_id AS source_id, content, 'follow' AS source_type, created_at
-FROM follow_feed WHERE user_id = 42
-UNION ALL
-SELECT article_id AS source_id, summary AS content, 'article' AS source_type, published_at
-FROM articles WHERE author_id = 42
-ORDER BY created_at DESC
-LIMIT 20 OFFSET 0;
-```
-
-> [!TIP] 何时用 UNION vs CASE WHEN?
-> - **数据来源是不同表或不同结构**: UNION ALL 是不二之选
-> - **同表不同条件的聚合计数**: `SUM(CASE WHEN ...)` 单次扫描更高效
-> - **经验法则**: UNION 的 SQL 可读性显著优于多个 OR 条件叠加时, 优先选 UNION
-
-### 场景三:分页合并多个条件
-
-```sql
--- 搜索商品:标题含关键词 或 描述含关键词
-SELECT id, title, description, score_title
-FROM products
-WHERE title LIKE '%runners%'
-UNION ALL
-SELECT id, title, description, score_desc
-FROM products
-WHERE description LIKE '%runners%';
-```
-
-> [!WARNING] UNION 的字段匹配规则
-> - 各 SELECT 的**列数必须相同**
-> - 各 SELECT 对应列的**类型应尽量兼容**(MySQL 会自动转换)
-> - ORDER BY 和 LIMIT 通常放在**整个 UNION 的最后**
-> - 列名取第一个 SELECT 的别名
-> - **注意**: MySQL 不推荐在 UNION 的各个独立 SELECT 中使用 ORDER BY(结果可能被丢弃)
-
-```sql
--- ✅ 正确
-SELECT id, name FROM products WHERE price > 100
-UNION ALL
-SELECT id, name FROM products WHERE category = 'sale';
-
--- ❌ 错误:列数不一致
-SELECT id, name FROM products
-UNION ALL
-SELECT id FROM discounts;
-
--- ❌ 错误:ORDER BY 放在中间(MySQL 可能忽略或报错)
-SELECT id FROM table_a
-ORDER BY id
-UNION ALL
-SELECT id FROM table_b;
-```
-
-### UNION 进阶:ORDER BY 与 LIMIT 的行为
-
-```sql
--- ⚠️ 问题:每个 SELECT 的 ORDER BY 在合并后无效
-SELECT id FROM orders WHERE status = 'pending' ORDER BY created_at DESC
-UNION ALL
-SELECT id FROM orders WHERE status = 'shipped' ORDER BY created_at DESC;
--- 上面的 ORDER BY 基本被 MySQL 忽略
-
--- ✅ 正确解法:用子包装保护每个查询的排序
-SELECT * FROM
-(
- SELECT id, created_at FROM orders WHERE status = 'pending'
- ORDER BY created_at DESC LIMIT 50
-) AS a
-UNION ALL
-SELECT * FROM
-(
- SELECT id, created_at FROM orders WHERE status = 'shipped'
- ORDER BY created_at DESC LIMIT 50
-) AS b
-ORDER BY created_at DESC
-LIMIT 20;
-```
-
-> [!QUESTION] 为什么子查询能保护 ORDER BY?
-> MySQL 优化器发现 UNION 外还有 ORDER BY + LIMIT 时, 会认为内部排序有用,从而保留它。
-> 但官方文档并不保证这种行为——这是基于执行计划的经验结论, 生产环境务必确认。
-
----
-
-## UNION 的执行流程
-
-```mermaid
-flowchart TD
- A["查询 1"] --> R1["结果集 1"]
- B["查询 2"] --> R2["结果集 2"]
- C["查询 N"] --> RN["结果集 N"]
-
- R1 --> Temp["临时表 + 唯一索引
UNION 有, UNION ALL 无"]
- R2 --> Temp
- RN --> Temp
-
- Temp --> Dedup{"需要去重?"}
- Dedup -->|是| Sort["排序 + 去重"]
- Dedup -->|否| Direct["直接输出"]
-
- Sort --> Output["最终结果集"]
- Direct --> Output
-
- style Dedup fill:#FF9F43,color:#000
- style Sort fill:#EE5A24,color:#fff
-```
-
-## 与 JOIN 的选择
-
-```sql
--- 场景:获取每个部门的员工总数 + 总监姓名
--- JOIN 方案(交叉维度,适合取不同列)
-SELECT d.name, COUNT(e.id) AS emp_count, mgr.name AS manager
-FROM departments d
-LEFT JOIN employees e ON d.id = e.dept_id
-LEFT JOIN employees mgr ON d.manager_id = mgr.id
-GROUP BY d.id;
-
--- UNION 方案(平行维度,适合合并同类数据)
-SELECT dept_id, 'total' AS metric, COUNT(*) AS value FROM employees GROUP BY dept_id
-UNION ALL
-SELECT dept_id, 'managers' AS metric, COUNT(*) AS value
-FROM employees WHERE is_manager = 1 GROUP BY dept_id;
-```
-
-> [!QUESTION] UNION 还是多个查询?
-> 很多场景中 UNION 看起来方便,但背后可能有更好的解法:
-> - **应用层合并**:在 Go/Java 中发两次查询然后合并数组(零 DB 压力)
-> - **STORED PROCEDURE**:存储过程中多次查询 + 临时表
-> - **视图**:封装 UNION 逻辑供多次复用
->
-> 核心原则:**能不在数据库做的就不做**。UNION 的代价是排序、去重、临时表。
-
-## 性能对比:UNION vs 替代方案
-
-| 方案 | DB 往返次数 | 临时表开销 | 排序开销 | 适用规模 |
-|------|------------|-----------|---------|---------|
-| **UNION ALL** | 1 次 | 有(结果集缓冲) | 仅外层的 ORDER BY | 百万行级 |
-| **UNION(去重)** | 1 次 | 有(唯一索引) | Sort + Dedup | 十万行以内 |
-| **应用层 N 次查询** | N 次 | 无 | 内存归并 | 任意,受网络影响 |
-| **SUM(CASE WHEN)** | 1 次 | 无 | 无 | 单表聚合计数 |
-
-```mermaid
-flowchart LR
- A["数据源数量 > 1"] --> B{"能否用单次扫描解决?"}
- B -->|是: 单表多条件| C["SUM CASE WHEN
最优"]
- B -->|否| D{"结果集是否已知不重复?"}
- D -->|是| E["UNION ALL
推荐"]
- D -->|否| F["UNION
去重"]
- style C fill:#00D866,color:#fff
- style E fill:#74C0FC,color:#000
- style F fill:#FF9F43,color:#000
-```
-
-## 关联笔记
-
-- [[hhs/MySQL/12-DQL SELECT 全解析]] — SELECT 基础语法与 UNION 的结合使用
-- [[hhs/MySQL/03-CRUD 操作]] — DML 中的批量操作与 UNION 的互补关系
diff --git a/hhs/MySQL/16-B+Tree 索引原理.md b/hhs/MySQL/16-B+Tree 索引原理.md
deleted file mode 100644
index 1d84661..0000000
--- a/hhs/MySQL/16-B+Tree 索引原理.md
+++ /dev/null
@@ -1,379 +0,0 @@
----
-tags: [MySQL, B+ Tree, 索引原理, Clustered Index]
-create time: 2026-05-16 00:00
----
-
-# B+ Tree 索引原理
-
-## 概述
-
-MySQL InnoDB 的索引默认使用 B+ Tree(Balance Plus Tree)。理解它的工作方式是掌握所有索引优化技巧的前提。
-
-## 为什么用 B+ Tree?
-
-```mermaid
-flowchart LR
- BT["B+ Tree
多路平衡树"] --> W1["胜出的原因"]
- BT2["B Tree
传统平衡树"] --> W2["缺点"]
- HT["Hash
哈希表"] --> W3["缺陷"]
- BST["RBTree
二叉平衡树"] --> W4["不适合磁盘"]
-
- W1 -.->|"范围扫描 + 磁盘友好 + 叶子链表串联"| WINNER["✅ B+ Tree 胜出"]
- W2 -.->|"非叶子也存数据 → IO 更多"| REASON
- W3 -.->|"无范围查询能力"| REASON
- W4 -.->|"树太高 IO 频繁"| REASON
-
- style WINNER fill:#00D866,color:#fff
- style BT fill:#00D866,color:#fff
-```
-
-### 关键差异点
-
-| 特性 | B+ Tree | B Tree | Hash | 红黑树 |
-|------|---------|--------|------|--------|
-| **范围查询** | ✅ 叶子节点链表遍历 | ❌ 需回溯祖先 | ❌ | ❌ |
-| **全表扫描** | ✅ 顺序扫描叶子 | ❌ 需要层序遍历 | — | ❌ |
-| **磁盘 IO 次数** | 低(阶数高,树矮) | 高 | O(1) 但仅等值 | 极高 |
-| **插入删除稳定性** | ✅ 分裂/合并均衡 | ✅ | ❌ 缩容重哈希 | ✅ |
-| **查询性能可预测** | ✅ 始终 O(logₘn) | ✅ | ⚠️ 冲突时退化 | ✅ |
-
-## B+ Tree 结构
-
-```mermaid
-flowchart TD
- ROOT["根节点
键: 15, 30, 45"] --> N1["内部节点
键: 5, 10"]
- ROOT --> N2["内部节点
键: 20, 25"]
- ROOT --> N3["内部节点
键: 35, 40"]
- ROOT --> N4["内部节点
键: 50, 55"]
-
- N1 --> LEAF1["[1][3][5][8][10]
叶子 · 存数据 + 双向链表"]
- N2 --> LEAF2["[12][15][20][22][25]"]
- N3 --> LEAF3["[28][32][35][40][45]"]
- N4 --> LEAF4["[48][50][52][55][60]"]
-
- LEAF1 -.-> LEAF2 -.-> LEAF3 -.-> LEAF4
-
- style ROOT fill:#4FC08D,color:#fff
- style N1 fill:#A0AEC0,color:#fff
- style N2 fill:#A0AEC0,color:#fff
- style N3 fill:#A0AEC0,color:#fff
- style N4 fill:#A0AEC0,color:#fff
- style LEAF1 fill:#00B6BC,color:#fff
- style LEAF2 fill:#00B6BC,color:#fff
- style LEAF3 fill:#00B6BC,color:#fff
- style LEAF4 fill:#00B6BC,color:#fff
-```
-
-### 核心特征
-
-1. **非叶子节点只存储键(Key)和指针**,不存储完整数据行 —— 一个页可以放更多键,树更矮
-2. **所有数据存储在叶子节点**,形成有序链表 —— 支持范围扫描
-3. **叶子节点之间通过双向链表连接** —— 相邻页面不用回溯父节点
-4. **所有叶子节点在同一深度** —— 查询性能稳定
-
-## 为什么树这么矮?
-
-InnoDB 一页 16KB,假设:
-- 主键 BIGINT = 8 bytes
-- 指针 = 6 bytes
-- 每个内部节点 Entry ≈ 14 bytes
-- 一页可存 ≈ 16KB / 14 bytes ≈ **1170 个子节点**
-
-```mermaid
-flowchart LR
- D1["1层
1,170 行"] --> D2["2层
约 137 万行"]
- D2 --> D3["3层
约 16 亿行"]
- D3 --> D4["4层
约 1900 亿行"]
-
- style D1 fill:#A0AEC0,color:#fff
- style D2 fill:#FF9F43,color:#000
- style D3 fill:#00D866,color:#fff
- style D4 fill:#EE5A24,color:#fff
-```
-
-> [!TIP] 这意味着什么?
-> 即使是一张有 1 亿行的表,查找任意一条记录也只需要 **3~4 次磁盘 IO**(每次读取一个页)。这就是 B+ Tree 在磁盘介质上无可替代的原因。
-
-> [!QUESTION] 思考一下
-> 如果每个内部节点只存一个键,那这棵树会变成什么样子?
-> ——退化成一棵二叉树(红黑树的高度)。所以 B+ Tree 的**阶数越高,树越矮,IO 越少**。
->
-> [!TIP] 直观感受
-> 用 SHOW INDEXES 查看某个表的索引信息,关注 Cardinality(基数)——
-> 基数接近总行数说明区分度高,索引效果好:
-> ```sql
-> -- 查看表索引及基数估计值
-> SHOW INDEXES FROM users;
-> ```
-
-## 索引查找过程
-
-```mermaid
-sequenceDiagram
- participant Q as "查询id等于42"
- participant R as "根节点IO1"
- participant I as "内部节点IO2"
- participant L as "叶子节点IO3"
- participant D as "数据行"
-
- Q->>R: "id大于30走向右分支"
- R->>I: "指向第二层右子节点"
- I->>L: "命中对应叶子节点"
- L->>D: "读取完整行数据"
-
- Note over Q,D:"总共 3 次随机 IO"
-```
-
-> [!QUESTION] 思考一下
-> 如果二级索引也存整行数据,还需要回表吗?
-> ——不需要了。这就是**覆盖索引(Covering Index)**的核心思想。
-
-### 覆盖索引(Covering Index)
-
-当查询需要的数据全部存在于某个索引的 B+ Tree 中时,就不需要再走聚簇索引了——这叫 **Index Only Scan**,在 `EXPLAIN` 中显示为 `Using index`。
-
-```sql
-CREATE TABLE users (
- id BIGINT PRIMARY KEY,
- email VARCHAR(255),
- name VARCHAR(100),
- age INT,
- INDEX idx_email(email)
-);
-
--- ❌ 必须回表:email 在索引里,但 name 不在
-SELECT name, email FROM users WHERE email = 'test@example.com';
--- ① 走 idx_email 找到 id → ② 回聚簇索引拿 name
-
--- ✅ 覆盖索引:不需要回表!
-SELECT email FROM users WHERE email = 'test@example.com';
--- 只需从 idx_email 叶子节点直接拿到 email
-```
-
-> [!NOTE] Covering Index 的判断方法
-> - `Extra = Using index`(无 `Using where`)→ 完整覆盖,零回表
-> - `Extra = Using index condition` → 索引下推(ICP),部分过滤在索引层面完成
-> - Extra 中**没有** `Using index` → 触发了回表
->
-> 💡 更多细节(回表代价、ICP 原理、空间对比)详见 [[hhs/MySQL/17-聚簇索引与二级索引]]
-
-## 聚簇索引 vs 二级索引
-
-这是本节最重要的概念延伸,也是理解后续所有索引优化的基础。
-
-```mermaid
-flowchart TB
- subgraph "聚簇索引 = 数据本身"
- C1["id=1 · 整行数据"]
- C2["id=2 · 整行数据"]
- C3["id=3 · 整行数据"]
- end
-
- subgraph "二级索引 idx_email"
- S1["email='a' → id=1"]
- S2["email='b' → id=2"]
- S3["email='c' → id=3"]
- end
-
- S1 -.回表.-> C1
- S2 -.回表.-> C2
- S3 -.回表.-> C3
-
- style C1 fill:#00D866,color:#fff
- style C2 fill:#00D866,color:#fff
- style C3 fill:#00D866,color:#fff
- style S1 fill:#FF9F43,color:#000
- style S2 fill:#FF9F43,color:#000
- style S3 fill:#FF9F43,color:#000
-```
-
-> [!QUESTION] 什么是回表?
-> 当用二级索引 `idx_email` 查 `WHERE email = 'a'` 时:
-> 1. 先在二级索引中找到 `email='a'` → 得到主键 `id=1`
-> 2. 再用 `id=1` 去聚簇索引中查找完整行数据
-> 这个两步过程叫**回表(Table Lookup)**。
->
-> **优化方向**:让查询条件直接走聚簇索引,或者用 Covering Index 避免回表。
-
-## MySQL 索引类型总览
-
-在 B+ Tree 的基础上,InnoDB 支持多种索引类型。理解它们的选择时机也是面试和实战的常考点。
-
-| 索引类型 | 底层结构 | 适用场景 | 等值查找 | 范围查找 | 是否有序 |
-|----------|----------|----------|----------|----------|----------|
-| **聚簇索引 (Clustered)** | B+ Tree 叶子存整行数据 | 主键查询(默认自带) | ✅ O(log n) | ✅ 顺序扫描叶子 | ✅ |
-| **二级索引 (Secondary)** | B+ Tree 叶子存 `索引列 + 主键` | 非主键字段查询 | ✅ O(log n) | ✅ | ✅ |
-| **联合索引 (Composite)** | 单个 B+ Tree,多列组合排序 | 前缀匹配的多字段过滤 | ✅ | ✅ | ✅ |
-| **唯一索引 (Unique)** | 基于 B+ Tree 加约束 | 保证列值唯一(如手机号) | ✅ | ✅ | ✅ |
-| **前缀索引 (Prefix)** | B+ Tree 只取字符前 N 位 | 长字符串字段(如 URL) | ✅ | ⚠️ 精度降低 | ✅ |
-| **全文索引 (Fulltext)** | InnoDB 使用倒排索引 | 文本搜索 (`MATCH...AGAINST`) | — | — | ❌ |
-| **空间索引 (Spatial)** | R-Tree | GIS 地理空间数据 | — | — | ❌ |
-
-> [!NOTE] 重点记忆
-> 除 **全文索引** 和 **空间索引** 外,其余全部依赖 B+ Tree。所以本文的核心内容覆盖了 InnoDB 90% 以上的日常使用场景。
-
-### 联合索引的最左前缀原则
-
-联合索引 `(a, b, c)` 的本质是:**先按 a 排序,a 相同时按 b 排序,a、b 都相同时按 c 排序**。
-
-```sql
-CREATE TABLE orders (
- id BIGINT PRIMARY KEY,
- user_id INT,
- status VARCHAR(20),
- created_at DATETIME,
- INDEX idx_user_status(user_id, status) -- 联合索引
-);
-
--- 以下可以命中索引
-SELECT * FROM orders WHERE user_id = 1; -- ✅ (user_id)
-SELECT * FROM orders WHERE user_id = 1 AND status = 'paid'; -- ✅ (user_id, status)
-
--- 以下无法完全命中联合索引
-SELECT * FROM orders WHERE status = 'paid'; -- ❌ 缺少最左列 user_id
-SELECT * FROM orders WHERE user_id = 1 AND created_at > ...; -- ⚠️ 只能用到 user_id
-```
-
-> [!TIP] 设计联合索引的黄金法则
-> 1. **把区分度最高的列放在最左边**——能最大程度缩小搜索范围
-> 2. **等值匹配的列排在范围查询之前**
-> 3. **覆盖最常出现的查询模式**,而不是把所有可能的列堆在一起
-
-## 实战常见陷阱:索引为什么不生效?
-
-B+ Tree 再优秀,也用不好 SQL。以下是最常见的索引失效场景:
-
-### 1. 函数 / 运算包裹了索引列
-
-```sql
--- ❌ 索引失效 —— 每行都要计算 DATE(created_at)
-SELECT * FROM orders WHERE DATE(created_at) = '2025-05-01';
-
--- ✅ 改用范围查询,利用 B+ Tree 的范围扫描能力
-SELECT * FROM orders
-WHERE created_at >= '2025-05-01 00:00:00'
- AND created_at < '2025-05-02 00:00:00';
-```
-
-> [!NOTE] 原理
-> B+ Tree 按原始值排序,对 `created_at` 建了索引但查的是 `DATE(created_at)`,相当于换了个"钥匙"去找"锁",树根目录里根本找不到这个新钥匙。
-
-### 2. LIKE 以通配符开头
-
-```sql
--- ❌ 走全表扫描
-SELECT * FROM users WHERE name LIKE '%abc%';
-
--- ✅ 前缀匹配仍然走索引
-SELECT * FROM users WHERE name LIKE 'abc%';
-```
-
-### 3. OR 条件中有一列无索引
-
-```sql
--- ❌ 即使 id 有索引、email 也有索引,但一旦 email 没索引,整个 OR 就可能不走索引
-SELECT * FROM users WHERE id = 1 OR email = 'test@x.com';
-```
-
-### 4. 隐式类型转换
-
-```sql
--- 假设 phone 是 VARCHAR 类型并建有索引
--- ❌ 传入的是数字类型,MySQL 要对每行做 CAST(phone AS SIGNED)
-SELECT * FROM users WHERE phone = 13800138000;
-
--- ✅ 保持类型一致
-SELECT * FROM users WHERE phone = '13800138000';
-```
-
-### 5. 回表太多导致 Optimizer 选择全表扫描
-
-```sql
--- 当二级索引需要回表的行数占总行数很大比例时(通常 > 20%~30%),
--- Optimizer 会认为走索引反而更慢(频繁随机 IO),主动选择全表扫描。
--- 这是正常行为,强行用 FORCE INDEX 往往适得其反。
-```
-
-> [!TIP] 怎么判断要不要加索引?
-> - 先用 `EXPLAIN` 看执行计划
-> - `type` 从 `ALL → index → range → ref → const` 依次越来越优
-> - 如果已经是 `ref` 或 `range` 且 Extra 没有异常提示,说明索引已经在工作
-
-## 索引的设计权衡
-
-好索引带来快查询,但也带来慢写入。设计时需要权衡以下代价:
-
-### 写入放大(Write Amplification)
-
-聚簇索引只需维护一个 B+ Tree,但**每个二级索引都是独立的 B+ Tree**。每插入一行数据:
-
-```sql
-INSERT INTO users (id, email, name, age) VALUES (1, 'a@x.com', 'Tom', 25);
--- InnoDB 需要同时更新:
--- ① 聚簇索引 idx__PRIMARY → 1 次 IO
--- ② 二级索引 idx_email → 1 次 IO
--- ③ 如果有更多二级索引... → N 次 IO
--- 总写入成本 = 1 + 二级索引数量
-```
-
-> [!NOTE] 直观理解
-> 假设有 5 个二级索引,插入一条记录就要写 **6 棵 B+ Tree**。
-> 删除、更新同理——所有相关索引都要同步修改。这就是为什么**索引越多,写越慢**。
-
-### 空间占用
-
-```sql
--- 一张表 100 GB,建了 4 个二级索引:
--- 聚簇索引 ≈ 100 GB(就是数据本身)
--- 二级索引 × 4 ≈ 30~50 GB(取决于索引列的宽度)
--- 总磁盘用量 ≈ 130~150 GB
-```
-
-> [!QUESTION] 思考一下
-> 如果你有一张日增百万行的日志表,你会给它建几个二级索引?
-> ——答案通常是:**少而精**。先分析最慢的几个查询,针对性加索引,而不是"以防万一全加上"。
-
-### 维护开销
-
-| 操作 | 聚簇索引影响 | 二级索引影响 |
-|------|-------------|-------------|
-| **INSERT** | 叶子节点末尾追加(顺序 IO) | 需定位正确位置 + 可能的页分裂(随机 IO) |
-| **UPDATE** | 若主键不变则无影响;变化则删除+重建 | 所有涉及列变化的索引都需要更新 |
-| **DELETE** | 标记删除或合并页 | 同上,且可能有页合并开销 |
-| **页分裂** | 高水位上升时发生 | 频率更高(二级索引更密集) |
-
-> [!TIP] 经验法则
-> - 写密集型系统(如日志、订单创建),索引数量控制在 **3 个以内**
-> - 读密集型系统(如报表查询),可以放宽到 **5 个左右**
-> - 超过 10 个索引几乎必然影响写入性能,应审慎评估
-
-## 最佳实践清单
-
-结合上述原理与实战经验,整理一份日常工作中可以直接使用的检查清单:
-
-> [!CHECKLIST] 索引设计与审查清单
->
-> **[建表阶段]**
-> - [ ] 主键选择合理(自增 BIGINT / UUID 替代方案考虑过?)
-> - [ ] 高频查询字段已建索引
-> - [ ] 联合索引区分度最高的列放在最左
-> - [ ] 避免过度索引(写多读少的表控制在 3 个以内)
->
-> **[上线前]**
-> - [ ] 所有慢查询 `EXPLAIN` 后 type 不是 `ALL`
-> - [ ] 覆盖索引场景用 `Using index` 确认不走回表
-> - [ ] 范围查询列不要和其他等值列反序排列
-> - [ ] VARCHAR 长字符串考虑使用前缀索引
->
-> **[线上巡检]**
-> - [ ] `SHOW INDEXES` 检查 Cardinality 接近行数
-> - [ ] 无用索引定期清理(使用 pt-duplicate-key-checker 等工具)
-> - [ ] 大表 DDL 使用 `ALGORITHM=INPLACE, LOCK=NONE` 避免锁表
-> - [ ] 监控慢查询日志,按需调整索引策略
-
-## 关联笔记
-
-- [[hhs/GORM/02-模型定义]] — GORM 创建索引的 struct tag 映射到 B+ Tree
-- [[hhs/MySQL/17-聚簇索引与二级索引]] — 深入解析聚簇索引的物理存储结构与二级索引的回表优化
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的设计原则与实践
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — type=index / range 的物理含义与索引执行计划解读
diff --git a/hhs/MySQL/17-聚簇索引与二级索引.md b/hhs/MySQL/17-聚簇索引与二级索引.md
deleted file mode 100644
index b72078c..0000000
--- a/hhs/MySQL/17-聚簇索引与二级索引.md
+++ /dev/null
@@ -1,200 +0,0 @@
----
-tags: [MySQL, 聚簇索引, 二级索引, Covering Index, 回表]
-create time: 2026-05-16 00:00
----
-
-# 聚簇索引 vs 二级索引
-
-## 概述
-
-InnoDB 使用 B+ Tree 组织所有索引,但根据「索引叶子节点存什么」的不同,分为两类:**聚簇索引**和**二级索引**。理解这两者的差异,是你设计高效查询和执行计划的起点。
-
-> [!QUESTION] 思考
-> 假设一张用户表按 `id` 排序存储在磁盘上——
-> 现在要查 `WHERE email = 'alice@test.com'`,数据库需要做什么?
-> (提示:email 不是存储排序的依据。)
-
-带着这个问题往下看。
-
-## 聚簇索引(Clustered Index)
-
-聚簇索引就是数据本身。InnoDB 表中**只能有一个聚簇索引**。
-
-### 聚簇索引的形成规则
-
-| 优先级 | 条件 | 说明 |
-|--------|------|------|
-| ① | **PRIMARY KEY** | 如果有显式 PK,它自动成为聚簇索引 |
-| ② | **UNIQUE + NOT NULL** | 没有 PK 但有唯一非空列,用它 |
-| ③ | **隐藏 row_id** | 都没有时,InnoDB 自动生成隐藏的 6-byte row_id |
-
-> [!WARNING] 隐藏的 row_id 是个坑
-> 如果你的表既没 PK 也没有 UNIQUE NOT NULL 列,InnoDB 会自动生成隐藏主键。这时:
-> - 你自定义的任何索引都会变成二级索引
-> - 二级索引回表时需要额外跳转 → 回表代价更高
-> - 外键引用不可靠(ID 对用户透明)
->
-> **结论:每张表必须有显式 PRIMARY KEY。**
-
-### 聚簇索引的特性
-
-```mermaid
-flowchart TD
- A["按主键排序存储数据"] --> B["数据页紧凑排列"]
- B --> C["连续主键 = 顺序写入"]
- C --> D["最小化页分裂"]
- D --> E["最佳写入性能"]
-
- A --> F["范围查询极快
WHERE id BETWEEN 100 AND 200
只需扫描一段连续的叶子页"]
- F --> G["顺序 I/O 而非随机 I/O"]
-
- style E fill:#00D866,color:#fff
- style G fill:#00B6BC,color:#fff
-```
-
-## 二级索引(Secondary Index)
-
-除了聚簇索引外的所有索引都是二级索引。叶子节点存储的是:**索引列的值 + 主键值**。
-
-```mermaid
-flowchart TD
- subgraph "二级索引页
idx_status_created = (status, created_at)"
- direction LR
- R1["status=1 \| created_at=01 → PK=5"]
- R2["status=1 \| created_at=02 → PK=12"]
- R3["status=1 \| created_at=03 → PK=18"]
- R4["status=0 \| created_at=01 → PK=3"]
- R5["status=0 \| created_at=04 → PK=25"]
- end
-
- style R1 fill:#E8DFF5,color:#333
- style R3 fill:#00B6BC,color:#fff
-```
-
-> [!NOTE] 关键观察
-> - 每行都包含 **索引列值**(用于匹配查询条件)和 **主键值**(用于回表定位完整行数据)
-> - 二级索引本身是 B+ Tree,按索引列排序;叶子层每一行指向聚簇索引中的对应数据
-> - 如果只需要索引中的列(如 `SELECT status WHERE status=1`),就 **无需回表**——这就是 Covering Index
-
-## 回表的代价
-
-```mermaid
-sequenceDiagram
- participant App as 应用层
- participant SI as 二级索引(idx_email)
- participant CI as 聚簇索引(PK=id)
- participant DB as 数据盘
-
- App->>SI: 查找 email='alice@test.com'
- SI-->>App: 找到 → id=12345
-
- App->>CI: 用 id=12345 查找
- CI->>DB: 定位叶子页 (随机 IO #1)
- DB-->>CI: 返回完整行
-
- CI-->>App: 返回完整行
-
- Note over SI,DB: 至少 2 次 IO:1次二级索引 + 1次回表
-```
-
-### 减少回表的策略
-
-```sql
--- ❌ 差:Covering Index 未命中,需要回表拿 name 字段
-SELECT name, email FROM users WHERE email = 'alice@test.com';
--- idx_email 能匹配 WHERE,但 name 不在索引里 → 需要回表
-
--- ✅ 好:覆盖索引,无需回表
-ALTER TABLE users ADD INDEX idx_email_name (email, name);
-SELECT name, email FROM users WHERE email = 'alice@test.com';
--- EXPLAIN Extra: Using index ← 完美!
-```
-
-> [!SUCCESS] Covering Index 的黄金法则
-> **把 SELECT 中的列 + WHERE 中的列放到同一个联合索引中**,就能实现 Index Only Scan。
-> - 适合:高频查询、固定列选择
-> - 不适合:SELECT *(永远无法覆盖)、列变化频繁的查询
-
-## 回表 vs 索引下推(ICP)
-
-MySQL 5.6 引入的 Index Condition Pushdown 优化了部分回表场景。
-
-```sql
--- 假设联合索引 idx_name_status = (name, status)
--- 查询:WHERE name LIKE '张%' AND status = 1
-
--- ❌ 无 ICP:所有匹配的 name 都要回表查 status
-SELECT * FROM users WHERE name LIKE '张%' AND status = 1;
--- 步骤:1) 找到所有 '张%' 的行 2) 逐条回表查 status 3) 过滤
-
--- ✅ 有 ICP:在二级索引中就先过滤 status
--- 引擎层直接读二级索引页,提取 name 和 status,先判断 status=1
--- 只有满足条件的才回表
--- 减少了大量无效回表
-
--- EXPLAIN 验证
-EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND status = 1\G
--- Extra 显示: Using index condition
-```
-
-```mermaid
-flowchart TD
- N["无 ICP"] --> A["查到 1000 条 '张%' 的记录"]
- A --> B["1000 次回表检查 status"]
- B --> C["最终只有 10 条符合"]
-
- Y["有 ICP"] --> D["在索引中预检 status"]
- D --> E["1000 条中筛出 10 条"]
- E --> F["仅 10 次回表"]
-
- style B fill:#EE5A24,color:#fff
- style F fill:#00D866,color:#fff
-```
-
-## 两种索引的空间对比
-
-```mermaid
-graph TB
- subgraph Clustered["聚簇索引 — 100 万行"]
- CI["每页约 200 行 | 16KB / 80 bytes"]
- CI2["总页数 ≈ 5000 页"]
- end
-
- subgraph Secondary["二级索引 — idx_email VARCHAR(255)"]
- SI["每页约 80 行 | 16KB / 200 bytes"]
- SI2["总页数 ≈ 12500 页"]
- end
-
- CI --> CI2
- SI --> SI2
-
- style CI2 fill:#00B6BC,color:#fff
- style SI2 fill:#C44569,color:#fff
-```
-
-> [!NOTE] 二级索引通常比聚簇索引大得多
-> 因为二级索引存的是「索引列 + 主键」,而聚簇索引存的是「整行」。如果索引列很长(如 VARCHAR(255)),二级索引会膨胀得很厉害。这也是为什么长字符串列做索引时要限制长度:`(name(50))`。
-
-## 两种索引的对比总结
-
-| 维度 | 聚簇索引 (Clustered) | 二级索引 (Secondary) |
-|------|---------------------|---------------------|
-| **数量** | 每张表仅一个 | 可以有多个 |
-| **叶子节点存储** | 整行数据 | 索引列值 + 主键值 |
-| **形成依据** | PK / 唯一非空列 / 隐藏 row_id | `CREATE INDEX` 或 `UNIQUE KEY` |
-| **范围查询** | 极快(顺序扫描连续页) | 需要回表,代价高 |
-| **覆盖索引** | 天然覆盖(本身就是数据) | 仅当所需列都在索引中时生效 |
-| **空间占用** | 基准大小 | 通常更大(多一列主键 + 膨胀风险) |
-| **写入代价** | 插入可能触发页分裂 | 更新索引列需改索引 + 回表改数据 |
-
-> [!SUMMARY] 核心记忆点
-> 1. 聚簇索引 = 数据本身,**一张表只能有一个**
-> 2. 二级索引 = 「索引列 + 主键」,查数据要**回表**
-> 3. 能用 Covering Index 的场景永远优于回表
-> 4. ICP 是 MySQL 5.6 对回表的温和优化——能省则省
-
-## 关联笔记
-
-- [[hhs/MySQL/16-B+Tree 索引原理]] — B+ Tree 作为底层数据结构的工作原理
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 联合索引的搜索模式对回表的影响
-- [[hhs/GORM/15-性能优化]] — GORM 场景下的 Covering Index 实践
diff --git a/hhs/MySQL/18-联合索引与最左前缀.md b/hhs/MySQL/18-联合索引与最左前缀.md
deleted file mode 100644
index 5ef4215..0000000
--- a/hhs/MySQL/18-联合索引与最左前缀.md
+++ /dev/null
@@ -1,197 +0,0 @@
----
-tags: [MySQL, 联合索引, 最左前缀, 索引失效]
-create time: 2026-05-16 00:00
----
-
-# 联合索引与最左前缀
-
-## 概述
-
-联合索引(Composite Index)是将多个列放在同一个 B+ Tree 中组织的索引。它的核心法则是**最左前缀匹配原则**——理解这一点就能避开 80% 的索引设计失误。
-
-## 联合索引的结构
-
-```
-联合索引 idx(a, b, c) 的 B+ Tree 结构:
-
-叶子节点按 a 排序,a 相同时按 b 排序,a 和 b 都相同时按 c 排序:
-
-[a=1, b=10, c=3] → data
-[a=1, b=10, c=7] → data
-[a=1, b=20, c=1] → data
-[a=1, b=20, c=9] → data
-[a=2, b=5, c=2] → data
-[a=2, b=15, c=4] → data
-[a=3, b=10, c=1] → data
-[a=3, b=10, c=8] → data
-[a=3, b=30, c=5] → data
-```
-
-> [!QUESTION] 这意味着什么?
-> 联合索引的本质是**多级排序**。你可以把它想象成 SQL 的 `ORDER BY a, b, c`。索引中的数据已经按照这个顺序排好了。
-
-## 最左前缀法则详解
-
-```mermaid
-flowchart TD
- IDX["联合索引 idx(a, b, c)"]
-
- IDX --> U1["✅ WHERE a=1"]
- IDX --> U2["✅ WHERE a=1 AND b=2"]
- IDX --> U3["✅ WHERE a=1 AND b=2 AND c=3"]
- IDX --> X1["❌ WHERE b=2"]
- IDX --> X2["❌ WHERE c=3"]
- IDX --> X3["❌ WHERE b=2 AND c=3"]
- IDX --> U4["⚠️ WHERE a=1 AND c=3"]
-
- U1 -. "用 1 列" .-> _u1
- U2 -. "用 2 列" .-> _u2
- U3 -. "用全部" .-> _u3
- X1 -. "无效" .-> _x1
- X2 -. "无效" .-> _x2
- X3 -. "无效" .-> _x3
- U4 -. "仅用 a" .-> _u4
-
- style U1 fill:#00D866,color:#fff
- style U2 fill:#00D866,color:#fff
- style U3 fill:#00D866,color:#fff
- style X1 fill:#EE5A24,color:#fff
- style X2 fill:#EE5A24,color:#fff
- style X3 fill:#EE5A24,color:#fff
- style U4 fill:#FF9F43,color:#000
-```
-
-### 为什么 b=2 单独查不了?
-
-```
-查询 WHERE b=2 时,B+ Tree 的搜索路径是什么样的?
-
-[b=20, c=1] 的位置在 [b=10, c=7] 之后,但它们之间穿插着 [b=5, c=2]...
-数据在整个树中分散存放,没有连续的 b=2 区域 → 需要全树扫描
-
-而 WHERE a=1 则不同:
-[a=1, ...] 的所有记录都在树的左侧一段连续区域内 → 二分查找即可定位
-```
-
-## 范围查询后的断裂
-
-联合索引遇到范围查询(`>`, `<`, `BETWEEN`, `LIKE 'prefix%'`)后,右侧列的索引失效。
-
-```sql
--- 假设已有联合索引 idx(status, created_at, type)
-
--- ⚠️ 看起来像用上了 3 个列,实际上 type 不会走索引!
-SELECT * FROM orders WHERE status = 'paid'
- AND created_at > '2026-01-01' AND type = 'online';
-
--- 逐列分析:
--- status = 'paid' → ✅ 等值匹配,精确定位起始行
--- created_at > ... → ✅ 范围扫描,划定区间终点
--- type = 'online' → ❌ 区间内数据未按 type 排序 → 退化为内存过滤
-```
-
-> [!QUESTION] 为什么范围查询会打断后续列?
-> 想象一下,当 `status='paid'` 锁定一个子树后,该子树内部按 `created_at` 升序排列。一旦用 `>` 做了范围扫描,得到的是一个 `created_at` 连续递增的数据段——这个段内的 `type` 值是乱序穿插的,无法再用二分查找定位 `type='online'`。
-
-```mermaid
-graph LR
- A["等值列 = 定位起点"] -->|"精确找到起始位置"| B
- B["范围列 = 确定终点"] -->|"划定扫描区间"| C
- C["右侧列 = 失效"] -->|"区间内无序"| D["退化为 WHERE 过滤"]
-
- style A fill:#00D866,color:#fff
- style B fill:#00B6BC,color:#fff
- style D fill:#EE5A24,color:#fff
-```
-
-### 实战:调整联合索引的顺序
-
-```sql
--- 场景:订单表常用查询——按状态筛选 + 按时间范围 + 按类型过滤
-
--- ❌ 原始索引:范围列在中间,后续等值列无法使用
-CREATE INDEX idx_sct ON orders (status, created_at, type);
--- 查询 A: WHERE status=? AND created_at>? AND type=?
--- → type 无法用索引(created_at 的范围扫描打断了后续列)
-
--- ✅ 优化方案 1:将等值列提前
-CREATE INDEX idx_stc ON orders (status, type, created_at);
--- 查询 B: WHERE status=? AND type=? AND created_at>?
--- → 三个列全部用上!status 定位起点,type 进一步缩小,created_at 做范围
--- 💡 注意:即使 SQL 写的是 created_at 在前,优化器也会自动调整顺序匹配索引
-
--- ✅ 优化方案 2:如果两个查询频率差不多,拆成两个索引
-CREATE INDEX idx_status ON orders (status);
-CREATE INDEX idx_status_time ON orders (status, created_at);
--- 让每个索引专注于它的典型查询模式
-```
-
-> [!TIP] 联合索引列序黄金法则
-> 1. **等值优先于范围**:等值列放在前面
-> 2. **选择性高的列靠前**:区分度大的列(如 user_id)比区分度小的(如 gender)靠前
-> 3. **前缀长度要短**:如果需要 LIKE,尽量用较短的前缀
-
-## 索引失效的典型场景
-
-```sql
--- ❌ 场景 1:隐式类型转换
-ALTER TABLE users ADD INDEX idx_phone (phone);
--- phone 是 VARCHAR 类型
-SELECT * FROM users WHERE phone = 13800138000; -- 传入 INT!
--- MySQL 会把 varchar 转成 int 再比较 → 函数作用于列 → 索引失效
-
--- ❌ 场景 2:函数/表达式包裹索引列
-SELECT * FROM users WHERE YEAR(created_at) = 2026;
--- 应对:改为范围查询 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'
-
--- ❌ 场景 3:LIKE 以通配符开头
-SELECT * FROM users WHERE name LIKE '%abc%';
--- 应对:考虑全文索引 FT
-
--- ❌ 场景 4:OR 条件中有列没有索引
-SELECT * FROM users WHERE email = 'a@x.com' OR phone = '138...';
--- 如果 email 有索引但 phone 没有,整个查询不走索引
--- 应对:给 phone 加索引,或用 UNION ALL 拆分
-
--- ❌ 场景 5:字符集不一致导致隐式转换
--- 表 charset=utf8mb4,客户端 charset=gbk → 自动转换
--- 应对:确保连接字符集一致 SET NAMES utf8mb4
-```
-
-## 最左前缀的灵活应用
-
-一个设计良好的联合索引可以**同时服务 WHERE、ORDER BY、GROUP BY 三种需求**。
-
-```sql
--- 建索引:idx(status, type, created_at)
-CREATE INDEX idx_status_type_time ON orders (status, type, created_at);
-
--- ── 查询 1:等值 + 范围 ──
-SELECT * FROM orders WHERE status = 'pending'
- AND created_at > '2026-01-01';
--- → status 等值定位起点,created_at 范围扫描。type 虽在中间但没用到,不影响前两列生效
-
--- ── 查询 2:利用 ORDER BY ──
-SELECT * FROM orders WHERE status = 'pending'
-ORDER BY type ASC;
--- → WHERE 条件锁定了 status='pending' 的子区间,该区间内数据天然按 type 排序
--- → 无需额外 filesort!
-
--- ── 查询 3:利用 GROUP BY ──
-SELECT type, COUNT(*) FROM orders WHERE status = 'pending'
-GROUP BY type;
--- → 同上,子区间内 type 已有序,分组可以直接跳过
-```
-
-> [!NOTE] 关键理解
-> ORDER BY / GROUP BY 能复用联合索引,靠的不是"巧合",而是 B+ Tree **叶子节点本身有序**这一物理特性。只要 WHERE 过滤条件匹配了联合索引的左侧列,剩余列就是有序的——优化器只是利用了已有的顺序,并没有多做一次排序。
-
-> [!TIP] 一索引多用
-> 一个好的联合索引可以同时服务 WHERE、ORDER BY、GROUP BY 三种需求。在设计索引时要考虑查询的整体模式,而不是单一查询。
-
-## 关联笔记
-
-- [[hhs/MySQL/17-聚簇索引与二级索引]] — 聚簇索引本身就是特殊的联合索引 (PK)
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 用 EXPLAIN 验证联合索引是否按预期生效
-- [[hhs/MySQL/20-慢查询日志分析]] — 如何从 slow log 中识别索引未命中
-- [[hhs/MySQL/21-查询改写技巧]] — 将失效的查询改写为可利用索引的形式
diff --git a/hhs/MySQL/19-EXPLAIN 完全指南.md b/hhs/MySQL/19-EXPLAIN 完全指南.md
deleted file mode 100644
index ce8e8b7..0000000
--- a/hhs/MySQL/19-EXPLAIN 完全指南.md
+++ /dev/null
@@ -1,328 +0,0 @@
----
-tags: [MySQL, EXPLAIN, 执行计划, 性能分析]
-create time: 2026-05-16 00:00
----
-
-# EXPLAIN 完全指南
-
-## 概述
-
-`EXPLAIN` 是 MySQL 性能分析的入口工具——它展示 SQL 语句的执行计划,告诉你优化器打算怎么用索引、怎么 JOIN、会不会用临时表和文件排序。
-
-> [!QUESTION] 为什么不能直接靠"写了索引就一定能用到"?
-> 因为 MySQL 使用的是 **Cost-Based Optimizer (CBO)**:优化器会根据统计信息(行数、页大小、聚簇因子等)自行决定最优执行路径。有时优化器认为全表扫描比走索引更快(比如要查整张表 80% 的数据),这时即使有索引也不会使用。`EXPLAIN` 就是用来观察和优化器"想法"是否一致的镜子。
-
-## 基本用法
-
-```sql
--- 标准 EXPLAIN
-EXPLAIN SELECT * FROM users u
-JOIN orders o ON u.id = o.user_id
-WHERE u.status = 1 AND o.amount > 100;
-
--- JSON 格式(信息最全,推荐)
-EXPLAIN FORMAT=JSON SELECT * FROM users WHERE email = 'a@x.com';
-
--- 实际执行后再看成本(含统计信息)
-EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+
-```
-
-## 关键字段速查
-
-| 字段 | 含义 | 关注点 |
-|------|------|--------|
-| **type** | JOIN 类型 | 是否走索引(ref/range 优,ALL 差)|
-| **key** | 实际使用的索引 | NULL = 没用索引 |
-| **rows** | 预估扫描行数 | 越小越好 |
-| **Extra** | 附加信息 | 重点关注 Using where/index/filesort/temporary |
-| **cost** | 预估成本(JSON 格式) | optimizer_cost 越低越好 |
-
-## type 详解(最重要)
-
-```mermaid
-flowchart LR
- system["system
只有1行"] --> const["const
常量级访问"]
- const --> eq_ref["eq_ref
唯一索引 × 驱动行"]
- eq_ref --> ref["ref
等值匹配多行"]
- ref --> range["range
索引范围"]
- range --> index["index
全索引扫描"]
- index --> ALL["ALL
全表扫描 🔴"]
-
- style system fill:#00D866,color:#fff
- style const fill:#00D866,color:#fff
- style eq_ref fill:#00D866,color:#fff
- style ref fill:#00B6BC,color:#fff
- style range fill:#FF9F43,color:#000
- style index fill:#C44569,color:#fff
- style ALL fill:#EE5A24,color:#fff
-```
-
-### 各级别详解
-
-| 级别 | 名称 | 含义 | 示例 |
-|------|------|------|------|
-| **system** | 系统表 | 表只有一行(MyISAM 等引擎)| `SELECT * FROM (SELECT 1) t`|
-| **const** | 常量 | 最多一行匹配 | `WHERE primary_key = 1`|
-| **eq_ref** | 唯一索引 | 唯一索引扫描,每行匹配被驱动表一行 | `JOIN ON pk = ?` |
-| **ref** | 索引查找 | 等值匹配多行(非唯一索引 / 最左前缀的部分匹配)| `WHERE email_prefix = 'a@'` |
-| **range** | 范围扫描 | 索引上的范围查询 | `WHERE id > 100` |
-| **index** | 索引全扫 | 扫描整个索引树 | `SELECT COUNT(*)` |
-| **ALL** | 全表扫描 | 无可用索引,逐行扫描 | **需要优化** |
-
-> [!WARNING] 不要盲目追求 ref 以上级别
-> `range` 在某些场景(如范围不大)完全可以接受。关键是看 `rows` 字段和 `Extra` 的组合。
-
-## Extra 关键字解读
-
-> [!SUCCESS] Using index — 覆盖索引(Index Only Scan)
-> 数据在索引中全部找到,**无需回表**。这是最优的 Extra 信息。
-
-> [!INFO] Using where — 服务器层过滤
-> 读完索引后还需 WHERE 条件过滤。**正常现象**,不代表性能问题。
-> - **Using where + Using index** = 覆盖索引 + WHERE 过滤 → 最佳实践 ✅
-> - **Using where 无 Using index** = 回表后才过滤 → 可考虑加索引优化 ⚠️
-
-> [!TIP] Using index condition — 索引下推(ICP)
-> MySQL 5.6+ 引入,在存储引擎层预过滤,减少回表次数 ✅
-
-### 其他 Extra 信息速查
-
-| 关键词 | 含义 | 处理 |
-|--------|------|------|
-| `Impossible where` | WHERE 永远为假(如 `status = 1 AND status = 2`)| 检查 SQL 逻辑是否正确 |
-| `Select tables optimized` | 优化器发现子查询可展开 | 无需处理,已自动优化 ✅ |
-| `Using distinct` | 内部去重,类似 DISTINCT | 看能否改用 GROUP BY + 索引 |
-| `Scan & filter` | **InnoDB** 特有:全索引扫描后逐行过滤 | 考虑加更精确的索引 |
-
-### Using temporary:何时出现
-
-```sql
--- 常见触发场景
-EXPLAIN SELECT city, AVG(age) FROM users GROUP BY city;
--- Extra: Using temporary; Using filesort
--- 需要用临时表存储每个 city 的聚合结果
-
-EXPLAIN SELECT DISTINCT city FROM users;
--- Extra: Using temporary
--- DISTINCT 内部用临时表去重
-```
-
-> [!WARNING] Using temporary + Using filesort
-> 当 GROUP BY 的分组列和 ORDER BY 的排序列不一致时,MySQL 会先建临时表再额外排序。
-> **解决思路**:让索引同时满足 GROUP BY + ORDER BY 的顺序要求。
-
-### Using filesort:深度分析
-
-```mermaid
-flowchart TD
- A["WHERE status='pending'"] --> B["idx_status 索引扫描
拿到所有 pending 订单的 pk"]
- B --> C{"created_at 能在索引中找到吗?"}
-
- C -->|能:
联合索引 idx_status_created| D["直接有序返回
No filesort ✅"]
- C -->|不能:
单列索引 idx_status| E["取 pk 列表 → 回表拿完整行
→ 内存中排序
Filesort ⚠️"]
-
- D --> F["可能的解决方案"]
- E --> F
-
- F --> F1["添加联合索引 (status, created_at)"]
- F --> F2["调整 WHERE 条件使索引生效"]
- F --> F3["加大 sort_buffer_size"]
-
- style D fill:#00D866,color:#fff
- style E fill:#EE5A24,color:#fff
-```
-
-> [!QUESTION] 思考:ORDER BY 一定会触发 filesort 吗?
-> 不一定!如果查询使用了索引且排序字段与索引顺序一致,MySQL 可以直接按索引序扫描,跳过排序步骤。关键看 **索引是否天然有序**。
-
-```sql
--- ✅ No filesort — 走联合索引天然有序
-EXPLAIN SELECT * FROM orders
-WHERE status = 'pending'
-ORDER BY status, created_at;
--- key: idx_status_created, Extra: Using where
-
--- ❌ Using filesort — WHERE 用了另一个索引,无法利用排序
-EXPLAIN SELECT * FROM orders
-WHERE user_id = 42
-ORDER BY created_at DESC;
--- key: idx_user_id, Extra: Using where; Using filesort
-```
-
-## 实战案例分析
-
-```sql
--- 原始查询(慢)
-EXPLAIN SELECT u.username, o.amount
-FROM users u
-JOIN orders o ON u.id = o.user_id
-WHERE o.created_at >= '2026-01-01'
-ORDER BY o.created_at DESC
-LIMIT 20;
-
--- 典型 bad result:
--- type: ALL, rows: 1000000, Extra: Using where; Using filesort; Using temporary
--- → 全表扫描 + 临时表 + 文件排序
-
--- 修复方案
--- 1. 创建复合索引
-ALTER TABLE orders ADD INDEX idx_created_user (created_at, user_id);
-
--- 2. 查询改写(反向排序优化)
--- 先用索引定位 top 20 的 pk,再 JOIN 拿数据
-SELECT u.username, o.amount
-FROM orders o
-JOIN users u ON u.id = o.user_id
-WHERE o.created_at >= '2026-01-01'
-ORDER BY o.created_at DESC
-LIMIT 20;
-
--- 新的 EXPLAIN:
--- type: range on orders, ref on users
--- key: idx_created_user
--- Extra: Using index condition; Using where
--- → 索引范围扫描 + 快速消除 filesort
-```
-
-## EXPLAIN FORMAT=JSON 精读
-
-`FORMAT=JSON` 输出最完整的执行计划,包含嵌套子查询、Cost 信息、索引选择细节。
-
-```json
-{
- "query_block": {
- "select_id": 1,
- "table": {
- "table_name": "orders",
- "access_type": "range",
- "possible_keys": ["idx_created_user"],
- "key": "idx_created_user",
- "key_length": "8",
- "rows": 5000,
- "filtered": 100.0,
- "index_condition": "orders.created_at >= '2026-01-01'",
- "cost_information": {
- "total_cost": "53000", ← 总成本(越小越好)
- "optimizer_cost": "53000" ← 优化器计算的成本值
- }
- }
- }
-}
-```
-
-### 关键字段说明
-
-| JSON 字段 | 含义 | 实战要点 |
-|-----------|------|----------|
-| **access_type** | 与 `type` 等价 | range/ref 优先,ALL 需关注 |
-| **key_length** | 实际使用索引的字节数 | 越短说明用的列越少,可优化 |
-| **rows** | 预估扫描行数 | 估算值,可能与实际情况偏差 |
-| **filtered** | WHERE 筛选率 (%) | rows × filtered% = 进入下一步的行数 |
-| **index_condition** | ICP 预过滤条件 | 有 = 使用了索引下推 ✅ |
-| **using_temporary** / **using_filesort** | 布尔值 | true = 会触发对应操作 |
-
-### Cost 分析:如何判断查询是否健康?
-
-> [!TIP] Cost 评估经验法则
-> - **cost < 1000**:通常没问题 ✅
-> - **cost 1000 ~ 10000**:中等负载可接受,关注高频 SQL ⚠️
-> - **cost > 10000**:大概率需要优化 🔴
-
-关键点:**optimizer_cost 是绝对值而非相对值**,不同版本 MySQL 的计算方式可能变化。更有价值的是 **对比两种方案的 cost 差值**——比如加了一个索引后 cost 从 50000 降到 5300,这就是有效的索引设计。
-
-```mermaid
-flowchart LR
- A["编写慢 SQL"] --> B["EXPLAIN 看执行计划"]
- B --> C{"access_type?"}
- C -->|ALL 全表扫描| D["检查 possible_keys
添加合适的索引"]
- C -->|range/ref/optimal| E{"Extra 中有
filesort/temporary?"}
- E -->|无 → 健康✅| F["无需额外处理"]
- E -->|有 → 优化⚠️| G["调整索引顺序或改写 SQL"]
-
- style F fill:#00D866,color:#fff
- style G fill:#FF9F43,color:#000
- style D fill:#C44569,color:#fff
-```
-
-## EXPLAIN ANALYZE:看到真实执行代价
-
-`EXPLAIN ANALYZE` 是 MySQL 8.0.18+ 引入的功能——它会**真正执行一次 SQL**,然后返回实际运行时间、每行的实际扫描数等统计信息。
-
-> [!WARNING] 注意事项
-> EXPLAIN ANALYZE **会执行 SQL**。如果有写入操作(如 INSERT/UPDATE),需要谨慎;但对于纯 SELECT 查询可以放心使用。
-
-```sql
--- 传统方式 vs 现代方式
-EXPLAIN SELECT * FROM orders WHERE user_id = 42;
--- → 只有预估数据,可能不准
-
-EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
--- → 预估 + 实际运行结果
--- Extra: Rows examined: 42 (vs. rows estimate: 100)
-```
-
-### 典型输出解读
-
-```
--> Index lookup on orders using idx_user_id (user_id=42)
- (actual time=0.034..0.156 rows=42 loops=1)
-```
-
-- **actual time**:首行耗时 .. 总耗时(微秒级)
-- **rows**:实际扫描行数(与预估 `rows` 对比,差距大说明统计信息过时)
-- **loops**:循环次数,JOIN 场景尤其重要
-
-> [!QUESTION] 什么时候用 EXPLAIN vs EXPLAIN ANALYZE?
-> - **EXPLAIN**:快速预览,不执行 SQL,适合批量排查和 CI 门禁
-> - **EXPLAIN ANALYZE**:精准诊断,需要实际执行,适合深入分析某个慢查询
-> - 日常开发建议先用 `EXPLAIN` 快速筛查,再对问题 SQL 用 `EXPLAIN ANALYZE` 定位根因
-
-## 优化策略速查
-
-遇到问题 SQL,按以下步骤排查:
-
-```mermaid
-flowchart TD
- A["拿到慢 SQL"] --> B["EXPLAIN 看执行计划"]
- B --> C{"type = ALL?"}
- C -->|是| D["检查 possible_keys → 加索引"]
- C -->|否| E{"Extra 有 filesort?"}
- E -->|是| F["调整索引顺序,覆盖 ORDER BY"]
- E -->|否| G{"Extra 有 temporary?"}
- G -->|是| H["让 GROUP/DISTINCT 走索引"]
- G -->|否| I{"rows 很大?"}
- I -->|是→ rows × filtered% 仍大| J["优化 WHERE 条件或加更精确的索引"]
- I -->|否| K["查询健康 ✅"]
-
- style K fill:#00D866,color:#fff
- style D fill:#C44569,color:#fff
- style F fill:#FF9F43,color:#000
- style H fill:#FF9F43,color:#000
- style J fill:#FF9F43,color:#000
-```
-
-### 常见场景与对策
-
-| 症状 | 根因 | 对策 |
-|------|------|------|
-| `type=ALL, Extra=Using where` | 无可用索引 | 添加 WHERE 列的索引 |
-| `Using filesort` | ORDER BY 字段不在可用索引中 | 创建 (WHERE列, ORDER BY列) 联合索引 |
-| `Using temporary; Using filesort` | GROUP BY 和 ORDER BY 不一致 | 索引顺序同时满足两者 |
-| `rows 预估 >> 实际行数` | 统计信息过时 | `ANALYZE TABLE 表名` 更新统计信息 |
-| `possible_keys` 有但 `key` 为 NULL | 优化器选了全表扫描 | 用 `FORCE INDEX` 强制指定,或改写查询 |
-
-### 别忘了维护统计信息
-
-```sql
--- 当 EXPLAIN 预估严重偏离实际时
-ANALYZE TABLE orders;
-
--- InnoDB 支持自动分析,但大批量写入后建议手动触发
-SET GLOBAL innodb_stats_auto_recalc = ON;
-```
-
-## 关联笔记
-
-- [[hhs/MySQL/16-B+Tree 索引原理]] — type=ALL vs type=range 的底层差异
-- [[hhs/MySQL/19-慢查询日志分析]] — 如何用 pt-query-digest 配合 EXPLAIN
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引设计不当导致的 EXPLAIN 问题
diff --git a/hhs/MySQL/20-慢查询日志分析.md b/hhs/MySQL/20-慢查询日志分析.md
deleted file mode 100644
index 2af9508..0000000
--- a/hhs/MySQL/20-慢查询日志分析.md
+++ /dev/null
@@ -1,366 +0,0 @@
----
-tags: [MySQL, 慢查询日志, pt-query-digest, 性能分析, EXPLAIN]
-create time: 2026-05-16 10:30
----
-
-# 慢查询日志分析
-
-## 概述
-
-慢查询日志(Slow Query Log)是 MySQL 自带的性能诊断工具,记录超过指定时间的 SQL 语句。配合 `pt-query-digest` 等专业工具,可以系统地识别和优化慢查询,定位数据库性能瓶颈。
-
-> [!TIP] 核心思路
-> 慢查询日志本身只是"记录仪"——真正的价值在于**归因分析**。将日志中的原始数据聚合、分类、排名,才能把噪音变成可行动的信号。
-
-## 配置慢查询日志
-
-```ini
-# my.cnf
-[mysqld]
-slow_query_log = 1 # 开启
-slow_query_log_file = /var/log/mysql/slow.log
-long_query_time = 2 # 阈值(秒)
-min_examined_row_limit = 100 # 只记录扫描了至少 N 行的查询
-log_queries_not_using_indexes = 1 # 记录未使用索引的查询
-
-# 可选:实时捕获到表
-log_output = FILE,TABLE # 同时写入文件和 mysql.slow_log 表
-```
-
-```sql
--- 运行时动态开启(无需重启)
-SET GLOBAL slow_query_log = 'ON';
-SET GLOBAL long_query_time = 1;
-SET GLOBAL log_queries_not_using_indexes = 'ON';
-
--- 查看当前配置
-SHOW VARIABLES LIKE 'slow_query%';
-SHOW VARIABLES LIKE 'long_query_time';
-```
-
-> [!WARNING] 生产环境注意事项
-> - 不要盲目设置极低的 `long_query_time`(如 0.1s),否则会产生大量噪声。建议从 **1~5 秒**开始逐步下调。
-> - 开启 `log_queries_not_using_indexes` 会让所有无索引查询都被记录,即使它们只需要 0.01s。**谨慎启用**,最好配合 `min_examined_row_limit` 过滤无害查询。
-> - `TABLE` 模式会往 `mysql.slow_log` 写数据,需要评估写入开销。如果已有 FILE 模式,优先选 FILE。
-
-### min_examined_row_limit
-
-```sql
--- 设置 1000 意味着:只扫描了 < 1000 行的查询不会被记录
--- 过滤掉大量无害查询,让慢日志专注于真正有问题的 SQL
-SET GLOBAL min_examined_row_limit = 1000;
-```
-
-> [!NOTE] 为什么需要这个参数?
-> 一个看似"快"的查询可能扫描了大量行才找到目标数据——比如一次全表扫描回查 50 万行最终返回 1 条结果。这样的查询单看执行时间并不长(缓存命中时不到 0.1s),但放在高并发下就是 CPU 杀手。`min_examined_row_limit` 就是从**扫描行数**维度额外加了一层拦截。
-
-## 慢查询日志格式
-
-每一条慢查询由**元数据头 + 时间戳锚点 + SQL 体**三部分组成:
-
-```
-# Time: 2026-05-16T10:30:45.123456Z ← 执行时间(UTC)
-# User@Host: app_user[app_user] @ app-server-01 [10.0.1.50] Id: 12345 ← 哪个用户、哪台机器、连接 ID
-# Query_time: 3.456789 Lock_time: 0.000123 Rows_sent: 1 Rows_examined: 985432 ← 核心指标
-# Rows_affected: 0 ← 影响的行数(DML)
-# Bytes_received: 512 Bytes_sent: 65432 ← 网络传输量
-# Thread_id: 12345 Schema: app_db ← 线程 ID、所属数据库
-SET timestamp=1715848245; ← 锚点:SQL 实际执行时的 Unix 时间戳
-SELECT o.*, u.username FROM orders o ← SQL 语句本体
-JOIN users u ON o.user_id = u.id
-WHERE o.status = 'pending' AND o.amount > 100
-ORDER BY o.created_at DESC LIMIT 20;
-```
-
-### 关键字段含义
-
-| 字段 | 含义 | 关注点 |
-|------|------|--------|
-| **Query_time** | 从接收请求到返回结果的总耗时 | > `long_query_time` 即触发记录 |
-| **Lock_time** | 等待行锁/表锁的时间 | 占比过高 → 存在锁竞争 |
-| **Rows_sent** | 返回给客户端的行数 | 应用层真正收到的结果 |
-| **Rows_examined** | InnoDB 引擎扫描的索引+数据行数 | 与 `Rows_sent` 差距越大越危险 |
-| **Rows_affected** | INSERT/UPDATE/DELETE 实际变更的行数 | DML 操作的副作用评估 |
-
-> [!QUESTION] 如何判断查询效率是否健康?
-> ```
-> Rows_examined / Rows_sent < 10 → ✅ 合理——每条扫描的行大部分都返回了
-> Rows_examined / Rows_sent > 100 → ⚠️ 可疑——可能是全索引扫描或回查过多
-> Rows_examined / Rows_sent > 1000 → 🔴 严重低效——典型的全表扫描或错误的 WHERE 条件
-> ```
-> 理想情况是比值接近 1。但需注意:**对范围查询来说,这个比值高一些是正常的**(因为 B+Tree 范围内每页都要读)。
-
-#### Lock_time 专项解读
-
-```
-Lock_time 占 Query_time 的比例 诊断方向
-──────────────────────────────────────────
-< 1% 正常,无锁问题
-1% ~ 10% 轻度竞争,可接受
-> 10% 需要排查:是否有长事务或未命中索引
-> 50% 紧急:大概率死锁或锁升级
-```
-
-## mysqldumpslow 内置工具
-
-MySQL 自带的轻量级日志分析工具,适合快速查看 Top N:
-
-```bash
-# 最常用的参数组合:按执行时间排序,取前 10 条
-mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
-# -s t: 按 Query_time 排序;-t 10: 取前 10 条
-
-# 按扫描行数排序(关注"杀 CPU"的查询)
-mysqldumpslow -s r -t 10 /var/log/mysql/slow.log
-
-# 按出现频率排序(关注高频小额查询累积成的大问题)
-mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
-
-# 只看包含特定关键词的
-mysqldumpslow -s t -t 20 -g "order" /var/log/mysql/slow.log
-```
-
-### 输出解读
-
-```
-Count: 150 Time=3.50s (525s) Lock=0.00s (0s) Rows=1.0 (150), app_user@app-server-01
- SELECT o.*, u.username FROM orders o JOIN users u ON o.user_id=u.id
- WHERE o.status='pending' AND o.amount>100 ORDER BY o.created_at DESC LIMIT N
-```
-
-| 字段 | 含义 |
-|------|------|
-| **Count: 150** | 该模式在日志中出现了 150 次 |
-| **Time=3.50s** | 单次平均执行 3.5 秒 |
-| **(525s)** | 150 次累计占用 525 秒——这才是对生产真正造成伤害的数字 |
-| **Rows=1.0 (150)** | 每次返回约 1 行,累计 150 行 |
-
-> [!NOTE] Count × Average = Total 的意义
-> `Time=3.50s (525s)` 揭示了一个关键思路:**优化一个频繁执行的中等耗时查询,往往比优化一个极端耗时的罕见查询收益更大**。这就是为什么 `-s c`(按频率排序)有时能发现更隐蔽的性能问题。
-
-### 使用限制
-
-`mysqldumpslow` 的输出较为粗糙,它只能做**聚合统计**,无法提供:
-- SQL 指纹归因的详细分组(哪些参数值不同但 SQL 结构相同)
-- 趋势对比(和上次报告相比变化了多少)
-- HTML 可视化报告
-
-对于系统性诊断,推荐使用 `pt-query-digest`。
-
-## pt-query-digest(专业分析工具)
-
-Percona Toolkit 中最强大的 MySQL 分析工具,支持日志文件、Performance Schema、甚至直连生产库实时采样:
-
-```bash
-# 基本用法:输出完整分析报告
-pt-query-digest /var/log/mysql/slow.log
-
-# 按响应时间分组排名
-pt-query-digest --group-by latency /var/log/mysql/slow.log
-
-# 只分析报告 Top 5% 的查询(保留细节,去掉噪声)
-pt-query-digest --limit 5% /var/log/mysql/slow.log
-
-# 与历史基线对比(需提前用 --review/--history 采集数据)
-pt-query-digest --review D=perflive,h=localhost \
- --history D=perflive,h=localhost \
- /var/log/mysql/slow.log
-
-# 生成 HTML 可视化报告
-pt-query-digest --output=slowreport.html /var/log/mysql/slow.log
-
-# 分析最近 1 小时的慢查询
-pt-query-digest --since 1h /var/log/mysql/slow.log
-
-# 分析最近 30 分钟且执行时间 > 1s 的查询
-pt-query-digest --since 30m --filter '$event->{qt} > 1000000' /var/log/mysql/slow.log
-```
-
-### 输出解读框架
-
-`pt-query-digest` 的报告分为四个核心区域:
-
-```
-===== Profile (整体概况)
- Rank Query ID Response time Calls R/Call V/M Item
- ==== ================= ============== ===== ====== ===== ===========
- 1 0xA1B2C3D4 525.1234 50.0% 150 3.5008 0.00 SELECT orders
- 2 0xE5F6A7B8 180.5678 17.2% 50 3.6114 0.00 SELECT users
-
-===== Query 1: Hash = 0xA1B2C3D4
- # 该查询模式的详细统计(P95, Q3, Q1 等百分位)
- # Count: 150 → 出现次数
- # Exec time: 1~8s → 单次执行时间范围
- # Lock time: ... → 锁等待分布
- # Rows sent: 1 avg → 返回行数(平均值/中位数/最大最小)
-
- # EXPLAIN output → 执行计划(最关键部分!)
- # The query is above that you can use EXPLAIN to analyze
-```
-
-| 区域 | 作用 |
-|------|------|
-| **Profile** | 快速了解哪些查询占了大部分资源——通常是 Pareto 20/80 规律 |
-| **Query N** | 单个查询模式的详细统计,包含执行计划 |
-| **Flattened** | SQL 归一化后的指纹,展示参数替换前后的差异 |
-
-> [!TIP] 标准诊断流程
-> 拿到 `pt-query-digest` 报告后:**先看 Profile 找出 Top 3 耗时查询 → 再逐条看其 EXPLAIN 输出 → 最后决定优化方向**。不要一开始就陷入某一条 SQL 的细节。
-
-### 从实时 Performance Schema 分析
-
-当慢查询日志未开启或已关闭时,可以直接从 MySQL 内部采集:
-
-```sql
--- MySQL 5.7+ 启用 performance_schema
-SET GLOBAL performance_schema = ON;
-
--- 清理已有数据(可选)
-TRUNCATE performance_schema.events_statements_summary_by_digest;
-
--- 等业务跑一会儿后,用 pt-query-digest 直接分析
-pt-query-digest --processlist D=localhost,U=root \
- --no-report /var/log/mysql/slow.log
-
--- 或者直接用下面的方式从 Performance Schema 提取
-pt-query-digest --type processlist D=host:port,user,password
-```
-
-## 常见慢查询反模式
-
-即使有索引,SQL 写法不当也会导致索引失效。以下是生产中最常见的几种反模式:
-
-| 反模式 | 示例 | 为什么慢 | 优化方向 |
-|--------|------|----------|----------|
-| **前导通配符** | `WHERE name LIKE '%abc'` | 无法使用 B+Tree 前缀匹配,全表扫描 | 改用全文检索 (FULLTEXT) |
-| **隐式类型转换** | `WHERE phone = 13800138000` (phone 是 VARCHAR) | 字符型字段与数字比较触发隐式转换,索引失效 | 传参类型与列类型一致 |
-| **OR 条件未覆盖** | `WHERE idx_col = 1 OR other_col = 2` | 只有第一个字段有索引 | 拆分 UNION ALL 或用 BITMAP |
-| **函数包裹列名** | `WHERE YEAR(created_at) = 2025` | 对列计算函数导致索引失效 | 改为范围扫描:`created_at >= '2025-01-01' AND created_at < '2026-01-01'` |
-| **NOT IN / <>** | `WHERE id NOT IN (SELECT id FROM ...)`)` | 子查询难以走索引 | 改写为 LEFT JOIN ... IS NULL |
-| **SELECT \*** | `SELECT * FROM orders WHERE ...` | 回表额外开销 + 网络传输浪费 | 只查需要的列 |
-
-> [!QUESTION] 为什么 `SELECT *` 会拖慢查询?
-> 有两个原因:
-> 1. **回表**:如果用的是二级索引(非聚簇),`SELECT *` 需要拿着二级索引的值去聚簇索引回表取出所有列数据;而如果只 SELECT 了索引中包含的列,则可以直接"覆盖索引"取数,无需回表。
-> 2. **带宽浪费**:即使通过聚簇索引取数,多取的每列也会占用更多内存缓冲和网路传输时间。在高频查询场景下,每次省 2KB,一万次就是 20MB 的额外开销。
-
-## 慢查询优化工作流
-
-从发现慢查询到完成优化的完整闭环:
-
-```mermaid
-flowchart TD
- A["收集慢查询日志"] --> B["分类归因
pt-query-digest / mysqldumpslow"]
- B --> C{"定位 Top 瓶颈 SQL"}
-
- C --> D["EXPLAIN 执行计划分析"]
- D --> E{问题类型?}
-
- E -- "type=ALL" --> F["缺索引 → ADD INDEX"]
- E -- "Extra: Using filesort" --> G["调整联合索引顺序 / 覆盖索引"]
- E -- "Extra: Using temporary" --> H["改写 SQL,避免 GROUP BY 临时表"]
- E -- "rows 过大但 sent 很小" --> I["检查 WHERE 条件是否命中索引"]
- E -- "无锁等待但耗时高" --> J["排查深层原因:深分页/大事务/函数包裹列"]
- E -- "Lock_time 占比高" --> K["排查死锁 / 长事务 / 行锁竞争"]
-
- F --> L["优化后验证"]
- G --> L
- H --> L
- I --> L
- J --> L
- K --> L
-
- L --> M{"效果达标?"}
- M -- "是" --> N["🎉 上线 + 接入监控告警"]
- M -- "否" --> D
-```
-
-### 步骤详解
-
-#### Step 1: 收集与分组
-
-通过 `pt-query-digest` 将原始日志中的千条记录压缩为几十条"SQL 指纹"。每一条指纹代表一个查询模式,忽略参数差异(如 `'123'` vs `'456'`),聚焦结构。
-
-#### Step 2: 定位 TOP N
-
-按以下优先级排序关注对象:
-
-| 场景 | 排序方式 | 适用情况 |
-|------|----------|----------|
-| **总耗时最大** | `-s t`(总时间) | 找最拖后腿的 SQL |
-| **最常见** | `-s c`(频率) | 高频小额查询累积成痛 |
-| **扫描行数最多** | `-s r`(Rows_examined) | CPU 杀手型查询 |
-
-#### Step 3: EXPLAIN 诊断
-
-对 Top 3 的查询逐条执行 `EXPLAIN FORMAT=JSON ...` 获取结构化执行计划:
-
-```sql
-EXPLAIN FORMAT=JSON SELECT * FROM orders
-WHERE user_id = 100 AND status = 'pending'
-ORDER BY created_at DESC LIMIT 10;
-```
-
-重点关注 JSON 输出中的:
-- `table->access_type`(扫描方式:ALL / range / ref / index)
-- `table->key`(实际使用的索引)
-- `table->rows`(预估扫描行数)
-- `query_block->filesort` / `temporary_table` 是否存在
-
-更详细的 EXPLAIN 解读见 [[hhs/MySQL/19-EXPLAIN 完全指南]]。
-
-#### Step 4: 针对性优化
-
-根据诊断结果选择优化策略:
-
-```mermaid
-flowchart LR
- subgraph "索引类优化"
- A1["补充缺失索引"] --> A3["验证:type 从 ALL→ref/range"]
- A2["调整联合索引顺序"] --> A3
- A4["创建覆盖索引"] --> A5["Extra 去掉 Using filesort/index"]
- end
-
- subgraph "SQL 改写"
- B1["LIKE '%xxx' → FULLTEXT"] --> B3["验证:rows↓, time↓"]
- B2["YEAR(col) → range 扫描"] --> B3
- B3["NOT IN → LEFT JOIN IS NULL"] --> B3
- end
-
- subgraph "架构类优化"
- C1["深分页 OFFSET → 延迟关联"] --> C3["验证:P95 latency ↓"]
- C2["大事务拆小"] --> C3
- C3["读写分离 / 缓存"] --> C3
- end
-```
-
-详见:
-- [[hhs/MySQL/21-查询改写技巧]] — 常见 SQL 改写方案
-- [[hhs/MySQL/22-深分页优化]] — OFFSET 深分页的替代方案
-- [[hhs/MySQL/18-联合索引与最左前缀]] — 索引设计原则
-
-#### Step 5: 回归验证 & 监控接入
-
-优化不是一次性动作,需要建立持续监控机制:
-
-> [!TIP] 最小可落地的监控方案
-> ```sql
-> -- 定期从 Performance Schema 拉取 Top SQL
-> SELECT DIGEST_TEXT,
-> ROUND(SUM_TIMER_WAIT/1e12, 2) AS total_ms,
-> COUNT_STAR AS calls,
-> ROUND(AVG_TIMER_WAIT/1e12, 2) AS avg_ms
-> FROM performance_schema.events_statements_summary_by_digest
-> ORDER BY total_ms DESC
-> LIMIT 20;
-> ```
-> 将此查询放入定时任务(如每 5 分钟),用 Grafana 或 Prometheus 做可视化告警。
-
-## 关联笔记
-
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — EXPLAIN 各字段的详细解读与执行计划分析
-- [[hhs/MySQL/21-查询改写技巧]] — SQL 反模式改写方案
-- [[hhs/MySQL/22-深分页优化]] — OFFSET 深分页的替代方案
-- [[hhs/MySQL/16-B+Tree 索引原理]] — 索引底层结构,理解为何某些写法会让索引失效
-- [[hhs/MySQL/26-锁机制总览]] — 行锁/表锁/间隙锁,排查 Lock_time 偏高问题
-- [[hhs/MySQL/40-常见踩坑]] — MySQL 日常使用中的典型陷阱
diff --git a/hhs/MySQL/21-查询改写技巧.md b/hhs/MySQL/21-查询改写技巧.md
deleted file mode 100644
index 5625b1f..0000000
--- a/hhs/MySQL/21-查询改写技巧.md
+++ /dev/null
@@ -1,335 +0,0 @@
----
-tags: [MySQL, 查询优化, 重写, 性能调优]
-create time: 2026-05-16 00:00
----
-
-# 查询改写技巧
-
-## 概述
-
-同样的业务需求可能有多种 SQL 写法,性能差异可达几十倍。本章总结常用的 SQL 改写模式,帮助你将「能跑的 SQL」变成「跑得快的 SQL」。
-
-## 1. OR → UNION ALL
-
-```sql
--- ❌ 差:两个列各自独立,OR 会导致两边都无法走索引
-SELECT * FROM users
-WHERE email = 'alice@example.com' OR phone = '13800138000';
--- EXPLAIN: type=ALL, Rows=1000000 → 全表扫描
-
--- ✅ 好:拆成两次索引查询 + UNION ALL
-SELECT * FROM users WHERE email = 'alice@example.com'
-UNION ALL
-SELECT * FROM users WHERE phone = '13800138000';
--- 两次独立的 ref 查询,各走一个索引
-```
-
-> [!TIP] 什么时候不该改?
-> - 如果 OR 两边的列在**同一个联合索引**中,原写法可以利用最左前缀
-> - 如果结果集非常小(几行),优化器可能自行选择最优方案
-
-## 2. JOIN → EXISTS
-
-```sql
--- ❌ 存在重复行的风险
-SELECT DISTINCT u.* FROM users u
-JOIN orders o ON u.id = o.user_id
-WHERE o.amount > 1000;
--- 一个用户有多张大额订单时会重复,DISTINCT 又带来排序开销
-
--- ✅ 改用 EXISTS(找到第一条就停止)
-SELECT u.* FROM users u
-WHERE EXISTS (
- SELECT 1 FROM orders o
- WHERE o.user_id = u.id AND o.amount > 1000
-);
-```
-
-> [!NOTE] MySQL Optimizer 的智能
-> 现代 MySQL(8.0+)的优化器已经足够聪明,经常能把 JOIN 和 EXISTS 转换为相同的执行计划。但显式使用 EXISTS 语义更清晰,且在某些复杂场景下确实能引导优化器选更好的路径。
-
-## 3. 子查询 → JOIN
-
-```sql
--- ❌ 标量子查询可能导致 N+1
-SELECT u.username,
- (SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total_spent
-FROM users u;
-
--- ✅ 换成 GROUP BY + JOIN
-SELECT u.username, COALESCE(SUB.total_spent, 0) AS total_spent
-FROM users u
-LEFT JOIN (
- SELECT user_id, SUM(amount) AS total_spent
- FROM orders GROUP BY user_id
-) SUB ON u.id = SUB.user_id;
-```
-
-```mermaid
-flowchart TD
- subgraph "子查询方案"
- A1["10万用户"] --> A2["每个用户查一次 SUM()"]
- A2 --> A3["总计 10万次扫描 orders 表"]
- end
-
- subgraph "JOIN 方案"
- B1["1次 GROUP BY orders"] --> B2["1000个汇总结果"]
- B2 --> B3["10万次 LEFT JOIN 1000行
成本极低"]
- end
-
- style A3 fill:#EE5A24,color:#fff
- style B3 fill:#00D866,color:#fff
-```
-
-## 4. 避免函数包裹索引列
-
-```sql
--- ❌ YEAR() / DATE() 包裹索引列 → 索引失效
-SELECT * FROM orders
-WHERE YEAR(created_at) = 2026 AND MONTH(created_at) = 5;
--- EXPLAIN: type=ALL, Rows=1000000
-
--- ✅ 改用范围查询 → 走索引
-SELECT * FROM orders
-WHERE created_at >= '2026-05-01'
- AND created_at < '2026-06-01';
-```
-
-```mermaid
-flowchart LR
- Func["WHERE YEAR(col) = 2026"] --> F1["每一行都要计算 YEAR()"]
- F1 --> F2["无法利用 B+ Tree 二分查找"]
- F2 --> F3["全表扫描 ALL"]
-
- Range["WHERE col >= '2026-01-01' AND col < '2027-01-01'"] --> R1["直接在 B+ Tree 中定位范围"]
- R1 --> R2["索引范围扫描 range"]
- R2 --> R3["O(log N) 复杂度"]
-
- style F3 fill:#EE5A24,color:#fff
- style R3 fill:#00D866,color:#fff
-```
-
-## 5. 分页优化:延迟关联 + 游标分页
-
-### 5.1 延迟关联(Deferred Join)
-
-```sql
--- ❌ 深分页灾难
-SELECT * FROM articles
-WHERE status = 1
-ORDER BY created_at DESC
-LIMIT 999990, 10;
--- 扫描聚簇索引跳过 999990 行,每行都拉完整数据
-
--- ✅ 延迟关联:子查询只扫主键,外层精准 JOIN
-SELECT a.* FROM articles a
-INNER JOIN (
- SELECT id FROM articles
- WHERE status = 1
- ORDER BY created_at DESC
- LIMIT 999990, 10
-) tmp ON a.id = tmp.id;
--- 子查询只扫主键(极紧凑),外层精准 JOIN 10 条
-```
-
-```mermaid
-flowchart LR
- subgraph "传统方式"
- A1["扫描 999990 行完整数据"] --> A2["丢弃 999990 行"]
- A2 --> A3["返回 10 行"]
- end
-
- subgraph "延迟关联"
- B1["子查询扫 999990 行
仅提取主键(8 bytes)"] --> B2["精准 JOIN 10 个主键"]
- B2 --> B3["返回 10 行完整数据"]
- end
-
- A3 ---|"I/O 减少 90%+"| B3
-
- style A1 fill:#EE5A24,color:#fff
- style B1 fill:#00B6BC,color:#fff
- style B3 fill:#00D866,color:#fff
-```
-
-### 5.2 游标分页(Keyset Pagination)
-
-延迟关联能缓解,但**不是根治方案**。当 offset 极大时仍要遍历大量行。更优解是**基于上一页最后一个 ID 定位下一页起点**。
-
-```sql
--- ❌ offset 越大越慢,且并发翻页可能丢数据
-SELECT * FROM articles ORDER BY id DESC LIMIT 100000, 10;
-
--- ✅ 游标分页:记住上一页最后一条的 id
-SELECT * FROM articles
-WHERE id < 999980 -- 上一页最后一条的 id
-ORDER BY id DESC
-LIMIT 10;
-```
-
-> [!TIP] Go 应用层写法
-> ```go
-> // 第一页:无游标
-> db.Order("id DESC").Limit(10).Find(&articles)
->
-> // 后续页:用最后一条的 ID 做游标
-> lastID := articles[len(articles)-1].ID
-> db.Where("id < ?", lastID).Order("id DESC").Limit(10).Find(&articles)
-> ```
-
-> [!WARNING] 注意事项
-> - 要求排序列有**唯一索引**(如自增主键)。如果排序字段不唯一(如 `created_at`),需加辅助条件:`WHERE (created_at, id) < (?, ?)` 利用联合索引最左前缀。
-> - 前端无法直接跳到第 N 页——这是合理的 UX 约束。现代平台(Twitter、Instagram)均采用「加载更多」而非页码导航。
-
-## 6. 避免 SELECT * 陷阱
-
-```sql
--- ❌ 拉取不需要的列:浪费网络带宽 + 无法使用 Covering Index
-SELECT * FROM orders WHERE user_id = 42;
-
--- ✅ 只查需要的列 → 可能变成 Covering Index(完全不碰数据行)
-SELECT id, amount, status FROM orders WHERE user_id = 42;
-```
-
-> [!TIP] 为什么指定列更快?
-> - InnoDB 聚簇索引叶子节点存整行数据。如果查询的列都在辅助索引中,MySQL 直接从辅助索引取数(**Using index**),不需要回表。
-> - 网络传输的数据量也显著降低——去掉 TEXT/BLOB 列可能从 MB 级降到 KB 级。
-
-## 7. LIKE 与全文搜索
-
-```sql
--- ❌ 左模糊匹配导致全表扫描
-SELECT * FROM articles WHERE title LIKE '%MySQL%';
--- EXPLAIN: type=ALL
-
--- ✅ 前缀模糊走索引
-SELECT * FROM articles WHERE title LIKE 'MySQL%';
-
--- ✅ 大文本搜索用全文索引(FULLTEXT)
-ALTER TABLE articles ADD FULLTEXT INDEX ft_title (title);
-SELECT * FROM articles
-WHERE MATCH(title) AGAINST('MySQL' IN NATURAL LANGUAGE MODE);
-```
-
-> [!NOTE] LIKE vs FULLTEXT
-> - `LIKE 'prefix%'` 走 B+ Tree 范围扫描,适合精确前缀匹配
-> - `FULLTEXT` 支持分词、相关性排序( relevance ranking),适合搜索引擎场景
-> - MySQL 的 FULLTEXT 对中文效果有限(空格分隔分词器不适用于中文),中文场景建议对接 Elasticsearch
-
-## 8. 近似聚合:SUM(COL) vs SUM(1)
-
-> [!QUESTION] 下面两行 SQL 有什么区别?
-> ```sql
-> -- Q1: 这两行结果一样吗?哪个更快?
-> SELECT SUM(status = 1) FROM orders;
-> SELECT SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) FROM orders;
-> ```
-> **答案**: 结果相同,但写法 1 更简洁且执行效率略高。MySQL 将布尔表达式视为 0/1 整数,`SUM(TRUE)` 等价于计数。
-
-```sql
--- ✅ 简洁写法:布尔表达式直接参与算术
-SELECT
- SUM(status = 'pending') AS pending,
- SUM(status = 'shipped') AS shipped,
- SUM(status = 'completed') AS completed
-FROM orders;
-
--- 等价于多 CASE WHEN 写法,但更短、可读性更好
-```
-
-> [!WARNING] NULL 处理差异
-> - `SUM(col = val)` 在条件不满足时返回 0,在列为 NULL 时也返回 NULL(即 0+NULL=NULL)
-> - 如果需要对 NULL 安全计数,用 `SUM(CASE WHEN col = val THEN 1 ELSE 0 END)` 或加 `COALESCE`
-
-## 9. COUNT 优化策略
-
-### 9.1 利用索引覆盖计数
-
-```sql
--- ❌ 无索引:全表扫描
-SELECT COUNT(*) FROM large_table WHERE status = 1;
--- EXPLAIN: type=ALL → 遍历每一行判断
-
--- ✅ 建索引后:只扫索引树
-ALTER TABLE large_table ADD INDEX idx_status (status);
-SELECT COUNT(*) FROM large_table WHERE status = 1;
--- EXPLAIN: type=range, key=idx_status, Extra=Using where; Using index
-```
-
-### 9.2 近似计数(可容忍误差)
-
-| 方案 | 精度 | 适用场景 |
-|------|------|----------|
-| `SHOW TABLE STATUS` | 低(缓存导致偏差) | MyISAM 快速估算 |
-| 随机采样 × 膨胀系数 | 中 | 运营后台 Dashboard |
-| `EXPLAIN table WHERE ...` | 较高 | 预估行数范围 |
-
-> [!NOTE] InnoDB 的 COUNT(*)
-> InnoDB 没有维护表级别行数(因为有 MVCC,不同事务看到的行数可能不同)。`COUNT(*)` 需要实际扫描。对于高并发大表,考虑**异步统计表**或**Redis 计数器**。
-
-## 10. INSERT 批量优化
-
-```sql
--- ❌ 逐条插入(N 次网络往返 + N 次事务提交)
-INSERT INTO orders (...) VALUES (...);
-INSERT INTO orders (...) VALUES (...);
-INSERT INTO orders (...) VALUES (...);
-
--- ✅ 批量 INSERT(1 次网络往返 + 1 次事务)
-INSERT INTO orders (...) VALUES (...), (...), (...);
-
--- ✅ Go 中分批次提交
-for i := 0; i < len(items); i += 500 {
- batch := items[i : min(i+500, len(items))]
- db.Model(&Order{}).Create(batch)
-}
-```
-
-## 11. CASE WHEN 代替多次查询
-
-```sql
--- ❌ 应用层多次查询
-countPending = db.Where("status='pending'").Count()
-countShipped = db.Where("status='shipped'").Count()
-countCompleted = db.Where("status='completed'").Count()
-
--- ✅ 单次查询 + 服务端处理
-SELECT
- SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending,
- SUM(CASE WHEN status = 'shipped' THEN 1 ELSE 0 END) AS shipped,
- SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed
-FROM orders;
-```
-
-## 改写原则总结
-
-```mermaid
-mindmap
- root((SQL 改写
核心原则))
- 早过滤
- WHERE 先行
- 减少中间结果
- 走索引
- 避免函数包裹列
- OR 拆 UNION ALL
- LIKE 前缀匹配
- 少回表
- Covering Index
- SELECT 指定列(非*)
- 分批量
- 批量 INSERT
- 延迟关联
- 游标分页
- 换思路
- EXISTS vs JOIN
- CASE WHEN vs 多次查询
- 子查询转 JOIN
-```
-
-## 关联笔记
-
-- [[hhs/MySQL/19-EXPLAIN 完全指南]] — 改写后验证效果的必备工具
-- [[hhs/MySQL/20-慢查询日志分析]] — 从慢日志中发现需要改写的 SQL
-- [[hhs/MySQL/22-深分页优化]] — 游标分页的专项深入
-- [[hhs/MySQL/16-B+Tree 索引原理]] — 理解索引走与否的根本原因
-- [[hhs/MySQL/40-常见踩坑]] — 生产环境中的典型错误写法合集
-- [[hhs/GORM/15-性能优化]] — GORM 层的批量操作优化
diff --git a/hhs/MySQL/22-深分页优化.md b/hhs/MySQL/22-深分页优化.md
deleted file mode 100644
index d27fe3a..0000000
--- a/hhs/MySQL/22-深分页优化.md
+++ /dev/null
@@ -1,281 +0,0 @@
----
-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/README.md b/hhs/MySQL/README.md
index d04f996..8cf62db 100644
--- a/hhs/MySQL/README.md
+++ b/hhs/MySQL/README.md
@@ -43,47 +43,47 @@ create time: 2026-05-16 00:00
## 知识体系
-### 一、入门基础(了解 MySQL 是什么)
+### [一、入门基础(了解 MySQL 是什么)](./01-入门基础/README.md)
先装起来、连上去,知道能存什么类型的数据。
| # | 主题 | 说明 |
|---|------|------|
-| 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`、中文搜索不出来的坑 |
+| 1 | [MySQL 架构与入门](./01-入门基础/01-MySQL 架构与入门.md) | MySQL 分几层(客户端 → SQL → 引擎 → 存储);`mysqld` 是怎么启动的 |
+| 2 | [安装与初始化](./01-入门基础/02-安装与初始化.md) | 本地安装、Docker Compose 快速启动、基本配置文件 |
+| 3 | [客户端工具](./01-入门基础/03-客户端工具.md) | 命令行 `mysql`、DBeaver、Workbench,选一个顺手的就行 |
+| 4 | [数据类型全景](./01-入门基础/04-数据类型全景.md) | 整型、浮点、字符串、日期时间、JSON——每种类型适合存什么 |
+| 5 | [字符集与排序规则](./01-入门基础/05-字符集与排序规则.md) | 为什么一定要用 `utf8mb4`、中文搜索不出来的坑 |
> [!TIP] 选择数据类型时,遵循「够用就好」原则
> 能用 TINYINT 就别用 INT,能用 DATETIME 就别用 STRING 存日期——这直接影响索引效率和存储空间。
-### 二、SQL 核心(学会写最常用的语句)
+### [二、SQL 核心(学会写最常用的语句)](./02-SQL核心/README.md)
建表、插数据、查数据——这是日常开发 90% 的时间在干的事。
| # | 主题 | 说明 |
|---|------|------|
-| 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 保留重复怎么处理 |
+| 6 | [DDL — 建表与结构变更](./02-SQL核心/06-DDL 建表与结构变更.md) | 怎么创建和修改表结构、大表加字段会不会锁表 |
+| 7 | [DML — 增删改](./02-SQL核心/07-DML 增删改.md) | INSERT / UPDATE / DELETE 的常用技巧和坑 |
+| 8 | [DQL — SELECT 全解析](./02-SQL核心/08-DQL SELECT 全解析.md) | SELECT 的执行顺序、去重、分组、分页——查对数据是日常开发的核心能力 |
+| 9 | [JOIN 原理与优化](./02-SQL核心/09-JOIN 原理与优化.md) | 多张表怎么连起来查、Nested Loop 是怎么工作的 |
+| 10 | [子查询与派生表](./02-SQL核心/10-子查询与派生表.md) | WHERE / FROM 里的子查询什么时候用、EXISTS 和 IN 有什么区别 |
+| 11 | [UNION 与集合运算](./02-SQL核心/11-UNION 与集合运算.md) | 把多个查询结果合并在一起,去重 vs 保留重复怎么处理 |
-### 三、索引与查询优化(让 SQL 跑得更快)
+### [三、索引与查询优化(让 SQL 跑得更快)](./03-索引与查询优化/README.md)
学会写 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` 为什么慢、怎么优化 |
+| 12 | [B+Tree 索引原理](./03-索引与查询优化/12-B+Tree 索引原理.md) | MySQL 为什么选 B+ Tree、它比 Hash / 红黑树好在哪里 |
+| 13 | [聚簇索引与二级索引](./03-索引与查询优化/13-聚簇索引与二级索引.md) | 数据存在哪棵树上、查二级索引为什么要"回表" |
+| 14 | [联合索引与最左前缀](./03-索引与查询优化/14-联合索引与最左前缀.md) | 多个字段一起建索引怎么用、为什么跳列就用不上了 |
+| 15 | [EXPLAIN 完全指南](./03-索引与查询优化/15-EXPLAIN 完全指南.md) | 怎么看 SQL 的执行计划、type 从优到差排哪些 |
+| 16 | [慢查询日志分析](./03-索引与查询优化/16-慢查询日志分析.md) | 抓出执行慢的 SQL、用工具分析根因 |
+| 17 | [查询改写技巧](./03-索引与查询优化/17-查询改写技巧.md) | OR → UNION ALL、JOIN → EXISTS,同样的结果写法影响性能 |
+| 18 | [深分页优化](./03-索引与查询优化/18-深分页优化.md) | `LIMIT 1000000, 20` 为什么慢、怎么优化 |
```mermaid
flowchart LR
@@ -109,14 +109,14 @@ flowchart LR
> - `WHERE age = 20 AND sex = 'M'` ❌ 跳过了第一列 city
> - `WHERE city = 'Shanghai' AND sex = 'M'` ⚠️ 用到 city 部分,sex 需额外过滤
-### 四、存储引擎(InnoDB 到底是怎么存的?)
+### [四、存储引擎(InnoDB 到底是怎么存的?)](./04-存储引擎/README.md)
学会了怎么用,来看看底层是怎么存的。这部分不是必须立刻搞懂,但理解了会受益很多。
| # | 主题 | 说明 |
|---|------|------|
-| 19 | [InnoDB 深度解析](./19-InnoDB 深度解析.md) | InnoDB 是怎么存数据的、聚簇索引的工作原理、缓冲机制概览 |
-| 20 | [其他存储引擎概览](./20-其他存储引擎概览.md) | MyISAM / Memory / Archive 的适用场景,为什么生产几乎只用 InnoDB |
+| 19 | [InnoDB 深度解析](./04-存储引擎/19-InnoDB 深度解析.md) | InnoDB 是怎么存数据的、聚簇索引的工作原理、缓冲机制概览 |
+| 20 | [其他存储引擎概览](./04-存储引擎/20-其他存储引擎概览.md) | MyISAM / Memory / Archive 的适用场景,为什么生产几乎只用 InnoDB |
```mermaid
graph TB
@@ -153,27 +153,27 @@ graph TB
> - **Redo Log**:操作日志,数据库崩溃了靠它恢复数据
> - **Undo Log**:撤销日志,回滚和版本控制靠它
-### 五、表设计(怎么设计一张好表)
+### [五、表设计(怎么设计一张好表)](./05-表设计/README.md)
设计先行,写好的表结构能省去后面大量的麻烦。
| # | 主题 | 说明 |
|---|------|------|
-| 21 | [表结构设计三范式](./21-表结构设计三范式.md) | 什么是 1NF / 2NF / 3NF、什么时候该故意违反范式 |
-| 22 | [主键策略对比](./22-主键策略对比.md) | 自增 ID、UUID、雪花算法——各自对性能的影响 |
+| 21 | [表结构设计三范式](./05-表设计/21-表结构设计三范式.md) | 什么是 1NF / 2NF / 3NF、什么时候该故意违反范式 |
+| 22 | [主键策略对比](./05-表设计/22-主键策略对比.md) | 自增 ID、UUID、雪花算法——各自对性能的影响 |
-### 六、事务与并发控制(保证数据不出错)
+### [六、事务与并发控制(保证数据不出错)](./06-事务与并发控制/README.md)
当多个用户同时操作数据时,如何保证数据不乱?这是进阶的必经之路。
| # | 主题 | 说明 |
|---|------|------|
-| 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 又读到什么 |
+| 23 | [ACID 与原子性实现](./06-事务与并发控制/23-ACID 与原子性实现.md) | 什么是 ACID、Redo Log + Undo Log 怎么保证不出错 |
+| 24 | [隔离级别与可见性](./06-事务与并发控制/24-隔离级别与可见性.md) | 四个隔离级别各是什么意思,MySQL 默认的是哪个 |
+| 25 | [MVCC 原理](./06-事务与并发控制/25-MVCC 原理.md) | 不加锁也能并发读的秘密——多版本并发控制 |
+| 26 | [锁机制总览](./06-事务与并发控制/26-锁机制总览.md) | 从全局锁到行级锁,MySQL 在不同场景下锁什么 |
+| 27 | [死锁与排查](./06-事务与并发控制/27-死锁与排查.md) | 什么时候会发生死锁、怎么定位和避免 |
+| 28 | [一致性读 vs 当前读](./06-事务与并发控制/28-一致性读与当前读.md) | 普通 SELECT 读到什么、加 FOR UPDATE 又读到什么 |
```mermaid
sequenceDiagram
@@ -196,18 +196,18 @@ sequenceDiagram
> - **RC (Read Committed)**:每次查都能看到别的事务刚提交的数据 → 可能出现"不可重复读"
> - **RR (Repeatable Read)**:整个事务内看到的是同一个快照 → 数据一致,这也是 MySQL 的默认设置
-### 七、高可用与分布式(让数据库永不宕机)
+### [七、高可用与分布式(让数据库永不宕机)](./07-高可用与分布式/README.md)
单台 MySQL 扛不住了怎么办?这就涉及到集群和复制。
| # | 主题 | 说明 |
|---|------|------|
-| 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) | 全量备份和增量备份、恢复到任意时间点 |
+| 29 | [Binary Log(Binlog)](./07-高可用与分布式/29-Binary Log.md) | Binlog 记录了所有写操作,主从复制和恢复都靠它 |
+| 30 | [主从复制详解](./07-高可用与分布式/30-主从复制详解.md) | 数据怎么从主库同步到从库、半同步 vs 异步的区别 |
+| 31 | [MHA 与 Orchestrator](./07-高可用与分布式/31-MHA与Orchestrator.md) | 主库挂了怎么自动切换到从库 |
+| 32 | [Group Replication](./07-高可用与分布式/32-Group Replication.md) | 多主同时写入、冲突怎么处理 |
+| 33 | [InnoDB Cluster](./07-高可用与分布式/33-InnoDB Cluster.md) | MySQL 官方的高可用集群方案 |
+| 34 | [备份与恢复](./07-高可用与分布式/34-备份与恢复.md) | 全量备份和增量备份、恢复到任意时间点 |
```mermaid
flowchart LR
@@ -232,18 +232,18 @@ flowchart LR
> - 至少 **一主两从**,读写分离时注意刚写的数据可能读不到(有延迟)
> - 定期做 **灾备演练**,别等挂了才知道切换不成功
-### 八、工程实践(生产环境中怎么管)
+### [八、工程实践(生产环境中怎么管)](./08-工程实践/README.md)
部署上线后,连接池怎么配、数据大了怎么拆、出了问题怎么排查。
| # | 主题 | 说明 |
|---|------|------|
-| 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 自动更新 |
+| 35 | [Go 连接池配置](./08-工程实践/35-Go 连接池配置.md) | `sql.DB` 几个关键参数的含义和调优思路 |
+| 36 | [Schema 迁移管理](./08-工程实践/36-Schema 迁移管理.md) | 版本化的建表脚本、谁在用、怎么保证可回滚 |
+| 37 | [分库分表](./08-工程实践/37-分库分表.md) | 数据量太大时怎么拆分、跨分片查询怎么办 |
+| 38 | [监控指标](./08-工程实践/38-监控指标.md) | QPS/TPS、慢查询数、连接数——哪些数据决定数据库健康 |
+| 39 | [安全加固](./08-工程实践/39-安全加固.md) | 权限管理、加密传输、SQL 注入防护 |
+| 40 | [常见踩坑](./08-工程实践/40-常见踩坑.md) | ORDER BY 文件排序、COUNT 的区别、timestamp 自动更新 |
[[hhs/DEV/Go-Database]] — Go 中 sql.DB 的连接池使用模式