Compare commits

...

3 Commits

Author SHA1 Message Date
hhs 2e84683d9c vault backup: 2026-05-21 12:20:51 2026-05-21 12:20:51 +08:00
hhs e176563c0b refactor: reorder MySQL docs by learning path
学习路径重排:SQL → 索引 → 存储引擎 → 表设计 → 事务 → 高可用 → 工程实践

关键改动:
- 删除旧编号,全部按新学习顺序重新编号(01-40)
- README 知识体系更新为 8 个渐进章节
- 每个章节增加了引导性说明文字
- 新增推荐学习顺序和新手/进阶/生产三条路线
- 修复了原顺序中 InnoDB(深)排在 SELECT(浅)之前的问题
2026-05-20 16:17:15 +08:00
hhs 115c952200 refactor: rewrite MySQL README overview and content descriptions
- 概述重写:去掉术语堆砌,改为大白话和三条学习路线
- 各节说明精简:去掉英文缩写,保留核心意思
- 学习路径重排:SQL → 索引 → 存储引擎 → 表设计
2026-05-20 16:14:41 +08:00
51 changed files with 2118 additions and 804 deletions
@@ -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 架构与入门]] — 理解客户端与服务端通信基础
@@ -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)对字符集的影响
+24
View File
@@ -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 知识库总目录
@@ -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<br/>-128 ~ 127"]
B --> D["SMALLINT<br/>-3.2 万 ~ 3.2 万"]
B --> E["MEDIUMINT<br/>-838 万 ~ 838 万"]
B --> F["INT<br/>-21 亿 ~ 21 亿"]
B --> G["BIGINT<br/>±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["在线<br/>读写不阻塞"]
Lock|SHARED| ReadConc["并发读<br/>写入等待"]
Lock|EXCLUSIVE| BlockAll["排他锁<br/>速度最快"]
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<br/>0=正常 1=已删除"]
Biz -->| truly optional | AllowNull["允许 NULL<br/>但加注释说明含义"]
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 迁移管理最佳实践
@@ -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<br/>确定数据源"] --> B["WHERE<br/>逐行过滤"]
B --> C["GROUP BY<br/>分组聚合"]
C --> D["HAVING<br/>分组后过滤"]
D --> E["SELECT 列计算<br/>包括窗口函数"]
E --> F["ORDER BY<br/>排序结果"]
F --> G["LIMIT / OFFSET<br/>截断输出"]
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["扫描聚簇索引<br/>跳过 999990 行"] --> A2["取出 10 行数据"]
end
subgraph "延迟关联方案"
B1["扫描聚簇索引<br/>跳过 999990 行"] --> B2["仅提取 10 个主键"]
B2 --> B3["JOIN 回聚簇索引<br/>精确查找 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 完全指南]] — 通过执行计划识别查询瓶颈
@@ -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<br/>user_id=1 ¥200"]
B2["order_id=102<br/>user_id=3 ¥150"]
B3["order_id=103<br/>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 万行<br/>匹配 100 次 ✅"]
L2["大表:100 万行"] -->|"驱动"| S2["小表:100 行<br/>匹配 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<br/>默认 256KB"]
B --> P{"Buffer 满了?"}
P -->|否| R["扫描被驱动表<br/>与 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<br/>得到 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 质量问题
@@ -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 -->|否:无关子查询<br/>Uncorrelated| UCN["先执行一次<br/>结果供外层复用 ✅"]
UCX -->|是:相关子查询<br/>Correlated| UCR["对每一行外层记录<br/>都要重新执行 ⚠️"]
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 | <derived2> | 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<br/>Extra: Using temporary, Using filesort"]
P1["PRIMARY → <derived2>"] -->|全表扫描| P2["type: ALL"]
P3["PRIMARY → u"] -->|全表扫描| P4["Extra: Using where"]
end
subgraph "✅ 可展开型派生表"
F1["select_type: SIMPLE"] -->|orders | F2["type: ALL<br/>Extra: NULL(无额外开销)"]
F3["SIMPLE → u"] -->|索引扫描| F4["type: index<br/>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<br/>别盲猜,数据说话"]
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
@@ -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["临时表 + 唯一索引<br/>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<br/>最优"]
B -->|否| D{"结果集是否已知不重复?"}
D -->|是| E["UNION ALL<br/>推荐"]
D -->|否| F["UNION<br/>去重"]
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<br/>别盲猜,看 select_type 确认分支数"]
C1 -->|看了| C2{"Extra 有 Using temporary 吗?"}
C2 -->|"是:UNION 去重"| D1["换 UNION ALL + 业务侧保证不重复"]
C2 -->|"否"| C3{"列数/列类型一致吗?"}
C3 -->|"不一致"| D2["CAST 统一类型<br/>否则可能隐式转换导致索引失效"]
C3 -->|"一致"| C4{"ORDER BY 在最终位置吗?"}
C4 -->|"否,放在中间"| D3["移动到最后<br/>或在每个分支外加子查询包裹"]
C4 -->|"是"| C5{"WHERE 对所有分支都生效吗?"}
C5 -->|"否"| D4["每个分子查询独立加条件"]
C5 -->|"是"| DONE["✅ SQL 结构正确,<br/>继续排查索引和数据分布"]
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 的互补关系
+25
View File
@@ -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 知识库总目录
@@ -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<br/>✅ 精确匹配一层"] --> S1["命中: R1, R2, R3"]
Q2["WHERE user_id = 10 AND status = 'paid'<br/>✅ 精确匹配两层"] --> S2["命中: R2"]
Q3["WHERE status = 'paid'<br/>❌ 缺少最左列 user_id"] --> S3["全表扫描"]
Q4["WHERE user_id = 10 AND created_at > ...<br/>⚠️ 只能用到 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 的物理含义与索引执行计划解读
@@ -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<br/>查到 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["在二级索引中直接判断<br/>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 实践
@@ -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-查询改写技巧]] — 将失效的查询改写为可利用索引的形式
@@ -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 问题
@@ -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 日常使用中的典型陷阱
@@ -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 层的批量操作优化
@@ -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 实现
@@ -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 知识库总目录
@@ -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 的特点对比
@@ -217,5 +217,5 @@ flowchart TD
## 关联笔记
- [[hhs/MySQL/06-InnoDB 深度解析]] — InnoDB 五大核心组件完整文档
- [[hhs/MySQL/04-存储引擎/19-InnoDB 深度解析]] — InnoDB 五大核心组件完整文档
- [[hhs/GORM/13-多数据库支持]] — GORM 在不同数据库间的移植注意事项
+21
View File
@@ -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 知识库总目录
+21
View File
@@ -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 知识库总目录
@@ -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 引擎层的对应关系
@@ -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-锁机制总览]] — 隔离级别与锁类型的对应关系
@@ -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、什么情况下需要加锁
@@ -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 事务中的锁获取时机
@@ -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 中数据库事务的最佳实践
@@ -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 用法
@@ -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 知识库总目录
@@ -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 时间点恢复
@@ -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 多主复制进阶方案
@@ -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 方案的对比
@@ -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 特有的监控指标
@@ -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 在集群环境中的使用注意事项
@@ -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 应用中处理数据库恢复错误的最佳实践
@@ -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 知识库总目录
@@ -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-监控指标]] — 分片集群的监控指标体系
@@ -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 级别的慢查询追踪
@@ -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-查询改写技巧]] — 各种问题的改写方案
+25
View File
@@ -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 知识库总目录
-251
View File
@@ -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["直接修改数据字典<br/>原地更新索引"]
B -->|"COPY"| D["创建临时表拷贝数据后替换"]
C --> E{"LOCK"}
D --> E
E -->|"NONE"| F["在线执行<br/>读写不阻塞"]
E -->|"SHARED"| G["并发读<br/>写入等待"]
E -->|"EXCLUSIVE"| H["阻塞全部操作<br/>速度最快"]
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<br/>0=正常 1=已删除"]
C -->|" truly optional "| E["允许 NULL<br/>但加注释说明含义"]
```
| 维度 | 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 迁移管理最佳实践
-238
View File
@@ -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["临时表 + 唯一索引<br/>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<br/>最优"]
B -->|否| D{"结果集是否已知不重复?"}
D -->|是| E["UNION ALL<br/>推荐"]
D -->|否| F["UNION<br/>去重"]
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 的互补关系
+158 -100
View File
@@ -7,37 +7,116 @@ create time: 2026-05-16 00:00
## 概述
本文件夹系统整理 MySQL 的核心知识点,从底层架构到生产实践,覆盖日常开发和高阶场景。MySQL 是世界上最流行的开源关系型数据库,掌握它不仅是后端开发的必备技能,更是理解"数据如何持久化"这一根本问题的钥匙。
本文件夹系统整理 MySQL 的核心知识点。MySQL 是目前世界上最流行的关系型数据库,几乎所有后端项目都在用它——存用户数据、订单记录、配置信息……本质上它就是**一个帮你安全存放和查询数据的程序**。
> [!QUESTION] 为什么选 MySQL?
> - **生态成熟**:社区活跃、文档完善、云厂商全覆盖(RDS、PolarDB、TencentDB)
> - **存储引擎可插拔**:InnoDB 默认提供事务和行锁,MyISAM 适合只读分析,Memory 用于缓存
> - **协议兼容性好**:标准 SQL + 丰富的方言扩展,驱动支持几乎所有主流语言
> - **性能可预期**:B+ Tree 索引 + Buffer Pool + Redo Log 三层架构,让写入和查询都有章可循
### 从零开始的三条学习路线
```
路线 A(日常开发) 路线 B(深入原理) 路线 C(运维 & 高可用)
↓ ↓ ↓
基础语法 → SQL 查询 存储引擎 / 索引底层 主从复制 / 备份恢复
表设计 → CRUD 操作 事务 / 隔离级别 / MVCC 高可用方案 / 故障切换
连接池 → ORM 框架 EXPLAIN / 慢查询优化 监控 / 分库分表
```
> [!QUESTION] 怎么选?
> - **如果你是刚入门的后端开发**:走路线 A 就够了,搞定增删改查和表设计就能上手干活
> - **如果你想面试大厂或解决线上性能问题**:继续走路线 B,理解索引和事务底层原理
> - **如果你要负责生产环境运维**:路线 C 是必修课,主从、备份、故障切换一个不能少
> - **理想状态**:A → B → C,循序渐进
### 核心概念速览(不用现在全懂)
下面的术语你在后面会逐一遇到,先有个印象就行:
| 概念 | 一句话理解 |
|------|-----------|
| **数据库 (Database)** | 存放表的容器,类似一个项目文件夹 |
| **表 (Table)** | 按行和列组织的数据,像 Excel 表格 |
| **SQL** | 跟数据库沟通的语言,比如"把这条数据查出来" |
| **索引 (Index)** | 书的目录,让查找更快,不加索引就像一页页翻书 |
| **事务 (Transaction)** | 一组操作要么全部成功、要么全部回滚,保证数据不出错 |
| **InnoDB** | MySQL 默认的存储引擎,支持事务、行锁,生产环境几乎都用它 |
> [!TIP] 学习建议
> 不要试图一口气吃成胖子。先学会写 `SELECT` 和 `INSERT`,看到能查出数据,再慢慢往下挖。每一个高级主题(比如事务隔离级别)都建立在前面的基础之上。
## 知识体系
### 一、入门基础
### [一、入门基础(了解 MySQL 是什么)](./01-入门基础/README.md)
先装起来、连上去,知道能存什么类型的数据。
| # | 主题 | 说明 |
|---|------|------|
| 1.1 | [MySQL 架构与进程模型](./01-MySQL 架构与进程模型.md) | Client-Server 模型、连接层 / SQL 层 / 引擎层 / 存储层;Server 线程 vs Worker 线程;`mysqld` 启动流程 |
| 1.2 | [安装与初始化](./02-安装与初始化.md) | Community Edition 安装、Docker Compose、`mysql_install_db`、配置文件层级 (`my.cnf`) |
| 1.3 | [客户端工具](./03-客户端工具.md) | `mysql CLI`、`mysqlsh`、Workbench、HeidiSQL、DBeaver;常用 `\G`、`\h` 等快捷命令 |
| 1.4 | [数据类型全景](./04-数据类型全景.md) | 整型(TINYINT~BIGINT)、浮点(FLOAT / DOUBLE / DECIMAL)、字符串(CHAR / VARCHAR / TEXT / BLOB)、日期时间(DATE / DATETIME / TIMESTAMP / TIME)、JSON |
| 1.5 | [字符集与排序规则](./05-字符集与排序规则.md) | utf8mb4 为什么比 utf8 重要、Collation 的 Binlog / Case 差异、`COLLATE` 关键字 |
| 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 核心(学会写最常用的语句)](./02-SQL核心/README.md)
建表、插数据、查数据——这是日常开发 90% 的时间在干的事。
| # | 主题 | 说明 |
|---|------|------|
| 2.1 | [InnoDB 深度解析](./06-InnoDB 深度解析.md) | Clustered Index、Change Buffer、Insert Buffer、Adaptive Hash Index |
| 2.2 | [其他存储引擎概览](./07-其他存储引擎概览.md) | MyISAM / Memory / Archive 各自适用场景、为什么生产环境几乎只用 InnoDB |
| 2.3 | [表结构设计三范式](./08-表结构设计三范式.md) | 1NF ~ 3NF、反范式取舍、冗余字段的设计哲学 |
| 2.4 | [主键策略对比](./09-主键策略对比.md) | Auto-increment / UUID / Snowflake / ULID —— 各方案对索引碎片化的影响 |
| 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 跑得更快)](./03-索引与查询优化/README.md)
学会写 SQL 之后,下一步是让 SQL 跑得快的——这就是索引的意义。
| # | 主题 | 说明 |
|---|------|------|
| 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
A["SELECT 语句"] --> B["Parse Tree"]
B --> C["Query Optimizer"]
C --> D["Execution Plan"]
D --> E{type}
E -->|"system"<| F["最优:只有1行"]
E -->|"const"<| G["常量访问"]
E -->|"eq_ref"<| H["唯一索引,每行1次"]
E -->|"ref"<| I["非唯一索引,多行匹配"]
E -->|"range"<| J["索引范围扫描"]
E -->|"index"<| K["全索引扫描"]
E -->|"ALL"<| L["全表扫描 ⚠️"]
style L fill:#EE5A24,color:#fff
style F fill:#00B6BC,color:#fff
```
> [!QUESTION] 什么是最左前缀法则?
> 假设联合索引 `(city, age, sex)`,以下情况哪些能用上索引?
> - `WHERE city = 'Shanghai'` ✅ 第一列命中
> - `WHERE city = 'Shanghai' AND age = 20` ✅ 前两列连续命中
> - `WHERE age = 20 AND sex = 'M'` ❌ 跳过了第一列 city
> - `WHERE city = 'Shanghai' AND sex = 'M'` ⚠️ 用到 city 部分,sex 需额外过滤
### [四、存储引擎(InnoDB 到底是怎么存的?)](./04-存储引擎/README.md)
学会了怎么用,来看看底层是怎么存的。这部分不是必须立刻搞懂,但理解了会受益很多。
| # | 主题 | 说明 |
|---|------|------|
| 19 | [InnoDB 深度解析](./04-存储引擎/19-InnoDB 深度解析.md) | InnoDB 是怎么存数据的、聚簇索引的工作原理、缓冲机制概览 |
| 20 | [其他存储引擎概览](./04-存储引擎/20-其他存储引擎概览.md) | MyISAM / Memory / Archive 的适用场景,为什么生产几乎只用 InnoDB |
```mermaid
graph TB
@@ -69,68 +148,32 @@ graph TB
style H fill:#A0AEC0,color:#fff
```
> [!NOTE] InnoDB 的核心缓冲机制
> - **Buffer Pool**:热数据页的内存缓存,命中率应在 99%+
> - **Redo Log**:保证 durability,循环写入,崩溃恢复时使用
> - **Undo Log**:支持 MVCC 和多版本读,rollback 也用它
> [!NOTE] InnoDB 的三个关键机制(先有个印象,后面详解)
> - **Buffer Pool**:内存里的"草稿纸",频繁访问的数据先放这里,减少磁盘读取
> - **Redo Log**:操作日志,数据库崩溃了靠它恢复数据
> - **Undo Log**:撤销日志,回滚和版本控制靠它
### 三、SQL 精解
### [五、表设计(怎么设计一张好表)](./05-表设计/README.md)
设计先行,写好的表结构能省去后面大量的麻烦。
| # | 主题 | 说明 |
|---|------|------|
| 3.1 | [DDL — 建表与结构变更](./10-DDL 建表与结构变更.md) | CREATE TABLE / ALTER TABLE、`ADD COLUMN` 在线变更、`ALGORITHM=INPLACE` |
| 3.2 | [DML — 增删改](./11-DML 增删改.md) | INSERT ... ON DUPLICATE KEY UPDATE、REPLACE INTO、DELETE vs TRUNCATE |
| 3.3 | [DQL — SELECT 全解析](./12-DQL SELECT 全解析.md) | SELECT 执行顺序、DISTINCT、GROUP BY 优化、LIMIT 深分页问题 |
| 3.4 | [JOIN 原理与优化](./13-JOIN 原理与优化.md) | Inner / Left / Right / Cross JOIN、Index Merge、Nested Loop Join、Block Nested Loop |
| 3.5 | [子查询与派生表](./14-子查询与派生表.md) | WHERE 子查询 vs FROM 子查询、EXISTS / IN / ANY 语义差异、Derived Table 物化 |
| 3.6 | [UNION 与集合运算](./15-UNION 与集合运算.md) | 集合运算、去重 vs 保留重复、合并字段类型推断 |
| 21 | [表结构设计三范式](./05-表设计/21-表结构设计三范式.md) | 什么是 1NF / 2NF / 3NF、什么时候该故意违反范式 |
| 22 | [主键策略对比](./05-表设计/22-主键策略对比.md) | 自增 ID、UUID、雪花算法——各自对性能的影响 |
### 四、索引与查询优化
### [六、事务与并发控制(保证数据不出错)](./06-事务与并发控制/README.md)
当多个用户同时操作数据时,如何保证数据不乱?这是进阶的必经之路。
| # | 主题 | 说明 |
|---|------|------|
| 4.1 | [B+ Tree 索引原理](./16-B+Tree 索引原理.md) | 为什么不用 B 树 / Hash / 红黑树?非叶子节点只存键、叶子节点链表串联 |
| 4.2 | [聚簇索引与二级索引](./17-聚簇索引与二级索引.md) | Secondary Index 回表、Covering Index(覆盖索引)、Index Only Scan |
| 4.3 | [联合索引与最左前缀](./18-联合索引与最左前缀.md) | (a,b,c) 的使用模式、为什么 b 单独查不了、隐式类型转换导致索引失效 |
| 4.4 | [EXPLAIN 完全指南](./19-EXPLAIN 完全指南.md) | type 字段(ALL/index/ref/range/eq_ref/system)、Extra(Using where/index/filesort/temporary)|
| 4.5 | [慢查询日志分析](./20-慢查询日志分析.md) | `slow_query_log` 配置、`pt-query-digest`、`mysqldumpslow` |
| 4.6 | [查询改写技巧](./21-查询改写技巧.md) | JOIN → EXISTS 转换、UNION ALL 拆分 OR、避免函数作用于索引列 |
| 4.7 | [深分页优化](./22-深分页优化.md) | `LIMIT 1000000, 20` 的陷阱、延迟关联(Deferred Join)、游标分页 |
```mermaid
flowchart LR
A["SELECT 语句"] --> B["Parse Tree"]
B --> C["Query Optimizer"]
C --> D["Execution Plan"]
D --> E{type}
E -->|"system"<| F["最优:只有1行"]
E -->|"const"<| G["常量访问"]
E -->|"eq_ref"<| H["唯一索引,每行1次"]
E -->|"ref"<| I["非唯一索引,多行匹配"]
E -->|"range"<| J["索引范围扫描"]
E -->|"index"<| K["全索引扫描"]
E -->|"ALL"<| L["全表扫描 ⚠️"]
style L fill:#EE5A24,color:#fff
style F fill:#00B6BC,color:#fff
```
> [!QUESTION] 什么是最左前缀法则?
> 假设联合索引 `(city, age, sex)`,以下情况哪些能用上索引?
> - `WHERE city = 'Shanghai'` ✅ 第一列命中
> - `WHERE city = 'Shanghai' AND age = 20` ✅ 前两列连续命中
> - `WHERE age = 20 AND sex = 'M'` ❌ 跳过了第一列 city
> - `WHERE city = 'Shanghai' AND sex = 'M'` ⚠️ 用到 city 部分,sex 需额外过滤
### 五、事务与并发控制
| # | 主题 | 说明 |
|---|------|------|
| 5.1 | [ACID 与原子性实现](./23-ACID 与原子性实现.md) | Redo Log(物理 WAL)+ Undo Log(逻辑回滚)的组合拳 |
| 5.2 | [隔离级别与可见性](./24-隔离级别与可见性.md) | Read Uncommitted / Read Committed / Repeatable Read / Serializable |
| 5.3 | [MVCC 原理](./25-MVCC 原理.md) | Read View + Undo Log version chain、RC 下每次 SELECT 新建 Read View、RR 下第一次 SELECT 创建 |
| 5.4 | [锁机制总览](./26-锁机制总览.md) | 全局锁、表级锁(MDL)、行级锁(Record Lock / Next-Key Lock / Gap Lock)|
| 5.5 | [死锁与排查](./27-死锁与排查.md) | `SHOW ENGINE INNODB STATUS`、等待图、减少锁竞争的策略 |
| 5.6 | [一致性读 vs 当前读](./28-一致性读与当前读.md) | `SELECT` 一致性读、`LOCK IN SHARE MODE / FOR UPDATE` 当前读 |
| 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
@@ -149,20 +192,22 @@ sequenceDiagram
T1->>DB: COMMIT;
```
> [!NOTE] RC vs RR 的关键区别
> - **RC (Read Committed)**:每次 SELECT 都新建 Read View → 能看到其他事务已提交的修改(不可重复读)
> - **RR (Repeatable Read)**:同一个事务内第一次 SELECT 创建 Read View → 整个事务看到一致快照(可重复读),配合 Next-Key Lock 解决幻影读
> [!NOTE] RC vs RR 的关键区别(面试常考)
> - **RC (Read Committed)**:每次查都能看到别的事务刚提交的数据 → 可能出现"不可重复读"
> - **RR (Repeatable Read)**:整个事务内看到的是同一个快照 → 数据一致,这也是 MySQL 的默认设置
### 六、高可用与分布式
### [七、高可用与分布式(让数据库永不宕机)](./07-高可用与分布式/README.md)
单台 MySQL 扛不住了怎么办?这就涉及到集群和复制。
| # | 主题 | 说明 |
|---|------|------|
| 6.1 | [Binary Log(Binlog)](./29-Binary Log.md) | Row / Statement / Mixed 格式、binlog_cache、`mysqlbinlog` 工具 |
| 6.2 | [主从复制详解](./30-主从复制详解.md) | Semi-Sync 半同步、GTID 复制、并行复制(MTS)、延迟监控 |
| 6.3 | [MHA 与 Orchestrator](./31-MHA与Orchestrator.md) | 自动故障检测与切换、脑裂处理 |
| 6.4 | [Group Replication](./32-Group Replication.md) | Paxos 共识、单主/多主模式、冲突检测 |
| 6.5 | [InnoDB Cluster](./33-InnoDB Cluster.md) | MySQL Shell + Router + Monitor,官方一键部署方案 |
| 6.6 | [备份与恢复](./34-备份与恢复.md) | mysqldump(逻辑)、Percona XtraBackup(物理)、PITR 时间点恢复 |
| 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
@@ -184,20 +229,21 @@ flowchart LR
```
> [!TIP] 生产环境的主从架构建议
> - 至少 **一主两从**,使用 GTID + Semi-Sync 保证数据不丢
> - 读写分离时注意 **弱一致性风险**:刚写的数据可能读不到
> - 定期做 **灾备演练**,验证切换时间和数据完整性
> - 至少 **一主两从**,读写分离时注意刚写的数据可能读不到(有延迟)
> - 定期做 **灾备演练**,别等挂了才知道切换不成功
### 七、工程实践
### [八、工程实践(生产环境中怎么管)](./08-工程实践/README.md)
部署上线后,连接池怎么配、数据大了怎么拆、出了问题怎么排查。
| # | 主题 | 说明 |
|---|------|------|
| 7.1 | [Go 连接池配置](./35-Go 连接池配置.md) | `sql.DB` 参数调优:MaxOpenConns、MaxIdleConns、ConnMaxLifetime、ConnMaxIdleTime |
| 7.2 | [Schema 迁移管理](./36-Schema 迁移管理.md) | flyway / goose / migrate、版本化迁移脚本、幂等性设计 |
| 7.3 | [分库分表](./37-分库分表.md) | ShardingSphere、Vitess、水平切分(Hash / Range / List)、跨分片查询 |
| 7.4 | [监控指标](./38-监控指标.md) | QPS/TPS、Slow Query Count、Innodb Buffer Pool Hit Rate、Threads Connected、Replication Lag |
| 7.5 | [安全加固](./39-安全加固.md) | 最小权限原则、SSL/TLS 加密传输、审计日志、防止 SQL 注入 |
| 7.6 | [常见踩坑](./40-常见踩坑.md) | `ORDER BY` 文件排序、`COUNT(*)` vs `COUNT(1)`、`timestamp` 自动更新 |
| 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 的连接池使用模式
@@ -205,21 +251,33 @@ flowchart LR
```mermaid
flowchart TD
BASE["一、入门基础<br/>架构 + 数据类型"] --> CORE["二、存储引擎与表设计"]
CORE --> SQL["三、SQL 精解<br/>DDL/DML/DQL"]
SQL --> INDEX["四、索引与查询优化"]
SQL --> TXN["五、事务与并发控制"]
INDEX --> HA["六、高可用与分布式"]
BASE["一、入门基础<br/>架构 + 数据类型"] --> SQL["二、SQL 核心<br/>DDL/DML/DQL"]
SQL --> INDEX["三、索引与优化<br/>B+Tree/EXPLAIN/改写"]
INDEX --> ENGINE["四、存储引擎<br/>InnoDB底层"]
INDEX --> DESIGN["五、表设计<br/>范式/主键"]
SQL --> TXN["六、事务与并发<br/>ACID/MVCC/锁"]
INDEX --> HA["七、高可用与分布式<br/>复制/集群/备份"]
TXN --> HA
HA --> ENG["七、工程实践<br/>分库分表 + 监控 + 安全"]
ENGINE --> PROD["八、工程实践<br/>连接池/监控/分库分表"]
DESIGN --> PROD
HA --> PROD
style BASE fill:#00B6BC,color:#fff
style SQL fill:#4FC08D,color:#000
style INDEX fill:#FF9F43,color:#000
style TXN fill:#C44569,color:#fff
style HA fill:#4FC08D,color:#fff
style ENG fill:#EE5A24,color:#fff
style ENGINE fill:#C44569,color:#fff
style DESIGN fill:#A0AEC0,color:#000
style TXN fill:#EE5A24,color:#fff
style HA fill:#0097E6,color:#fff
style PROD fill:#4A4A4A,color:#fff
```
**推荐学习顺序**:`一 → 二 → 三 → 六 → 四 → 五 → 七 → 八`
> - **新手**:一 → 二 → (够用)
> - **进阶**:一 → 二 → 三 → 六 → 四 → 五
> - **生产环境**:全学,重点在 三、六、七、八
## 关联笔记
- [[hhs/GORM/02-模型定义]] — GORM Model 如何映射到 MySQL 表和字段类型