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 的连接池使用模式