From f9d21f00267e32e326e7a7a41174a4968121e702 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Thu, 21 May 2026 19:31:28 +0800 Subject: [PATCH] vault backup: 2026-05-21 19:31:28 --- .../03-索引与查询优化/16-慢查询日志分析.md | 278 ++++++++++- hhs/MySQL/03-索引与查询优化/18-深分页优化.md | 39 +- hhs/MySQL/03-索引与查询优化/README.md | 1 + .../03-索引与查询优化/补充/N+1 查询问题.md | 462 ++++++++++++++++++ .../03-索引与查询优化/补充/索引失效情况.md | 370 ++++++++++++++ hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md | 130 ++++- .../06-事务与并发控制/23-ACID 与原子性实现.md | 2 +- .../06-事务与并发控制/24-隔离级别与可见性.md | 21 +- hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md | 65 ++- 9 files changed, 1290 insertions(+), 78 deletions(-) create mode 100644 hhs/MySQL/03-索引与查询优化/补充/N+1 查询问题.md create mode 100644 hhs/MySQL/03-索引与查询优化/补充/索引失效情况.md diff --git a/hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md b/hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md index 136892b..cda8111 100644 --- a/hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md +++ b/hhs/MySQL/03-索引与查询优化/16-慢查询日志分析.md @@ -42,6 +42,7 @@ SHOW VARIABLES LIKE 'long_query_time'; > - 不要盲目设置极低的 `long_query_time`(如 0.1s),否则会产生大量噪声。建议从 **1~5 秒**开始逐步下调。 > - 开启 `log_queries_not_using_indexes` 会让所有无索引查询都被记录,即使它们只需要 0.01s。**谨慎启用**,最好配合 `min_examined_row_limit` 过滤无害查询。 > - `TABLE` 模式会往 `mysql.slow_log` 写数据,需要评估写入开销。如果已有 FILE 模式,优先选 FILE。 +> - DDL 语句(`ALTER TABLE`、`CREATE INDEX`)默认不记录到慢查询日志。如有需要可开启 `log_slow_admin_statements = 1`,排查大表 DDL 阻塞。 ### min_examined_row_limit @@ -72,6 +73,9 @@ WHERE o.status = 'pending' AND o.amount > 100 ORDER BY o.created_at DESC LIMIT 20; ``` +> [!NOTE] `SET timestamp=1715848245;` 是什么? +> 这行不是用户执行的 SQL,而是 MySQL **自动注入**的时间锚点。它的作用是:当你用 `mysql < slow.log` 回放这条慢查询时,MySQL 会用这个 Unix 时间戳作为 `NOW()` 的基准,确保时间相关函数(`NOW()`、`CURDATE()` 等)在回放时的值与原始执行时一致。**不影响执行计划,仅供回放使用。** + ### 关键字段含义 | 字段 | 含义 | 关注点 | @@ -92,13 +96,17 @@ ORDER BY o.created_at DESC LIMIT 20; #### Lock_time 专项解读 -``` -Lock_time 占 Query_time 的比例 诊断方向 -────────────────────────────────────────── -< 1% 正常,无锁问题 -1% ~ 10% 轻度竞争,可接受 -> 10% 需要排查:是否有长事务或未命中索引 -> 50% 紧急:大概率死锁或锁升级 +`Lock_time` 占 `Query_time` 的比例越大,说明查询在"等锁"而非"执行"上浪费了越多时间。下面的诊断路径帮你快速判断严重程度: + +```mermaid +flowchart LR + A["计算 Lock_time / Query_time"] --> B{"< 1% ?"} + B -- "是" --> C["🟢 正常,无锁问题"] + B -- "否" --> D{"< 10% ?"} + D -- "是" --> E["🟡 轻度竞争,可接受"] + D -- "否" --> F{"< 50% ?"} + F -- "是" --> G["🟠 需排查:长事务 or 未命中索引"] + F -- "否" --> H["🔴 紧急:大概率死锁或锁升级"] ``` ## mysqldumpslow 内置工具 @@ -215,17 +223,80 @@ pt-query-digest --since 30m --filter '$event->{qt} > 1000000' /var/log/mysql/slo -- MySQL 5.7+ 启用 performance_schema SET GLOBAL performance_schema = ON; --- 清理已有数据(可选) +-- 清理已有数据(可选,重置统计基线) TRUNCATE performance_schema.events_statements_summary_by_digest; +``` --- 等业务跑一会儿后,用 pt-query-digest 直接分析 +等业务跑一段时间后,用 `pt-query-digest` 直接从 Performance Schema 提取分析: + +```bash +# 方式一:基于 processlist 实时采样 pt-query-digest --processlist D=localhost,U=root \ --no-report /var/log/mysql/slow.log --- 或者直接用下面的方式从 Performance Schema 提取 +# 方式二:直接从 digest 表提取 pt-query-digest --type processlist D=host:port,user,password ``` +## SHOW PROFILE(单条 SQL 微观分析) + +`pt-query-digest` 和 `mysqldumpslow` 解决的是"哪些 SQL 慢"的**宏观**问题;而当你已经定位到一条具体的慢 SQL,想进一步搞清楚**它的时间花在哪个阶段**时,就需要 `SHOW PROFILE`。 + +> [!TIP] EXPLAIN vs SHOW PROFILE +> - **EXPLAIN**:告诉你查询**将会**怎么执行(执行计划),是"预言家" +> - **SHOW PROFILE**:告诉你查询**已经**怎么执行(各阶段耗时),是"回放仪" +> 两者互补:先 EXPLAIN 看计划是否合理,再 PROFILE 看执行时到底慢在哪。 + +```sql +-- 开启当前会话的 profiling +SET profiling = 1; + +-- 执行你的目标 SQL +SELECT o.id, o.status, o.amount +FROM orders o +WHERE o.user_id = 100 AND o.status = 'pending' +ORDER BY o.created_at DESC LIMIT 20; + +-- 查看最近一次查询的各阶段耗时 +SHOW PROFILE; + +-- 更详细:查看 CPU 和 Block IO +SHOW PROFILE CPU, BLOCK IO FOR QUERY 1; +``` + +典型输出: + +``` ++----------------------+----------+ +| Status | Duration | ++----------------------+----------+ +| starting | 0.000089 | +| checking permissions | 0.000012 | +| Opening tables | 0.000035 | +| init | 0.000025 | +| System lock | 0.000015 | +| optimizing | 0.000018 | +| statistics | 0.000042 | +| preparing | 0.000020 | +| executing | 0.000008 | +| Sending data | 0.452300 | ← ⚠️ 99% 的时间在这里 +| end | 0.000012 | +| query end | 0.000008 | ++----------------------+----------+ +``` + +> [!NOTE] 如何解读各阶段 +> | 阶段 | 含义 | 如果占比过高意味着什么 | +> |------|------|----------------------| +> | **Sending data** | 扫描行、返回结果集 | 索引不佳或结果集过大 | +> | **Sorting result** | 排序操作 | 缺少 ORDER BY 对应索引 | +> | **Creating tmp table** | 创建临时表(内存/磁盘) | GROUP BY / DISTINCT 无法走索引 | +> | **System lock** | 等待表锁 | 存在锁竞争 | +> | **statistics** | 优化器统计信息收集 | 统计信息过旧,需 `ANALYZE TABLE` | + +> [!WARNING] 版本兼容性 +> `SHOW PROFILE` 在 MySQL 8.0 中已被标记为**废弃**(deprecated),官方推荐迁移到 `performance_schema.events_statements_*` 表。但在 MySQL 5.7 以及快速排查场景下,它依然是最轻便的选择。 + ## 常见慢查询反模式 即使有索引,SQL 写法不当也会导致索引失效。以下是生产中最常见的几种反模式: @@ -236,13 +307,13 @@ pt-query-digest --type processlist D=host:port,user,password | **隐式类型转换** | `WHERE phone = 13800138000` (phone 是 VARCHAR) | 字符型字段与数字比较触发隐式转换,索引失效 | 传参类型与列类型一致 | | **OR 条件未覆盖** | `WHERE idx_col = 1 OR other_col = 2` | 只有第一个字段有索引 | 拆分 UNION ALL 或用 BITMAP | | **函数包裹列名** | `WHERE YEAR(created_at) = 2025` | 对列计算函数导致索引失效 | 改为范围扫描:`created_at >= '2025-01-01' AND created_at < '2026-01-01'` | -| **NOT IN / <>** | `WHERE id NOT IN (SELECT id FROM ...)`)` | 子查询难以走索引 | 改写为 LEFT JOIN ... IS NULL | +| **NOT IN / <>** | `WHERE id NOT IN (SELECT id FROM ...)` | 子查询难以走索引 | 改写为 LEFT JOIN ... IS NULL | | **SELECT \*** | `SELECT * FROM orders WHERE ...` | 回表额外开销 + 网络传输浪费 | 只查需要的列 | > [!QUESTION] 为什么 `SELECT *` 会拖慢查询? > 有两个原因: > 1. **回表**:如果用的是二级索引(非聚簇),`SELECT *` 需要拿着二级索引的值去聚簇索引回表取出所有列数据;而如果只 SELECT 了索引中包含的列,则可以直接"覆盖索引"取数,无需回表。 -> 2. **带宽浪费**:即使通过聚簇索引取数,多取的每列也会占用更多内存缓冲和网路传输时间。在高频查询场景下,每次省 2KB,一万次就是 20MB 的额外开销。 +> 2. **带宽浪费**:即使通过聚簇索引取数,多取的每列也会占用更多内存缓冲和网络传输时间。在高频查询场景下,每次省 2KB,一万次就是 20MB 的额外开销。 ## 慢查询优化工作流 @@ -322,15 +393,23 @@ flowchart LR end subgraph "SQL 改写" - B1["LIKE '%xxx' → FULLTEXT"] --> B3["验证:rows↓, time↓"] - B2["YEAR(col) → range 扫描"] --> B3 - B3["NOT IN → LEFT JOIN IS NULL"] --> B3 + B1["LIKE '%xxx' → FULLTEXT"] + B2["YEAR(col) → range 扫描"] + B3["NOT IN → LEFT JOIN IS NULL"] + B4["验证:rows↓, time↓"] + B1 --> B4 + B2 --> B4 + B3 --> B4 end subgraph "架构类优化" - C1["深分页 OFFSET → 延迟关联"] --> C3["验证:P95 latency ↓"] - C2["大事务拆小"] --> C3 - C3["读写分离 / 缓存"] --> C3 + C1["深分页 OFFSET → 延迟关联"] + C2["大事务拆小"] + C3["读写分离 / 缓存"] + C4["验证:P95 latency ↓"] + C1 --> C4 + C2 --> C4 + C3 --> C4 end ``` @@ -356,6 +435,169 @@ flowchart LR > ``` > 将此查询放入定时任务(如每 5 分钟),用 Grafana 或 Prometheus 做可视化告警。 +## 日志轮转与清理策略 + +慢查询日志是持续增长的——高流量系统一天可以产生数 GB 的慢日志。没有轮转策略,磁盘会被撑满,甚至影响 MySQL 正常写入。 + +> [!WARNING] 真实案例 +> 曾有团队开启了慢查询日志但忘了清理,3 个月后磁盘 100%,MySQL 被迫停止服务。日志文件本身变成了"生产事故"。 + +### 方案一:MySQL 原生轮转 + +```bash +# 手动轮转(适合简单场景) +mv /var/log/mysql/slow.log /var/log/mysql/slow.log.$(date +%Y%m%d) +mysql -e "FLUSH SLOW LOGS;" # 让 MySQL 重新打开新的 slow.log +``` + +### 方案二:logrotate(推荐) + +```bash +# /etc/logrotate.d/mysql-slow +/var/log/mysql/slow.log { + daily # 每天轮转 + rotate 7 # 保留最近 7 份 + compress # gzip 压缩旧日志 + missingok # 文件不存在时不报错 + notifempty # 空文件不轮转 + create 640 mysql mysql + postrotate + mysql -e "FLUSH SLOW LOGS;" + endscript +} +``` + +> [!TIP] 搭配 pt-query-digest 做"先分析、再清理" +> ```bash +> # 在 logrotate 的 prerotate 中先分析,再轮转 +> prerotate +> pt-query-digest /var/log/mysql/slow.log > /var/log/mysql/slow_report_$(date +%Y%m%d).txt +> endscript +> ``` +> 这样每天轮转前自动保存一份分析报告,既不丢数据,又控制了磁盘占用。 + +## 实战 Walkthrough:从慢日志到上线优化 + +> [!NOTE] 场景设定 +> 电商系统,用户反馈"订单列表页加载慢"。DBA 介入排查。 + +### Step 1 — 采集慢日志 + +```bash +# 确认慢日志已开启 +mysql -e "SHOW VARIABLES LIKE 'slow_query_log%';" + +# 用 pt-query-digest 分析最近 1 小时 +pt-query-digest --since 1h /var/log/mysql/slow.log > /tmp/slow_report.txt +``` + +### Step 2 — 定位 Top 1 瓶颈 + +报告 Profile 区域显示: + +``` +Rank Query ID Response time Calls R/Call Item +==== ============== ============== ===== ====== =================== + 1 0xABCD1234 1850.5s 62.3% 3200 0.58s SELECT orders JOIN users +``` + +> [!TIP] 关键数字解读 +> **3200 次调用 × 0.58s = 1850s 总耗时**——高频 + 中等单次耗时,累积起来就是最大的性能杀手。 + +对应的 SQL 指纹: + +```sql +SELECT o.id, o.status, o.amount, o.created_at, u.username +FROM orders o +JOIN users u ON o.user_id = u.id +WHERE o.user_id = ? AND o.status = ? +ORDER BY o.created_at DESC +LIMIT 20 OFFSET ?; +``` + +### Step 3 — EXPLAIN 诊断 + +```sql +EXPLAIN FORMAT=JSON +SELECT o.id, o.status, o.amount, o.created_at, u.username +FROM orders o +JOIN users u ON o.user_id = u.id +WHERE o.user_id = 100 AND o.status = 'pending' +ORDER BY o.created_at DESC +LIMIT 20 OFFSET 5000; +``` + +关键输出(简化): + +```json +{ + "table": "o", + "access_type": "ALL", + "key": null, + "rows": 520000, + "filtered": 10.0, + "Extra": "Using where; Using filesort" +} +``` + +> [!QUESTION] 诊断结论是什么? +> 三个致命信号: +> 1. **`type=ALL`** — 全表扫描,52 万行逐行检查 +> 2. **`key=null`** — 没有任何索引被使用 +> 3. **`OFFSET 5000`** — 深分页,即使有索引也会丢弃前 5000 行 + +### Step 4 — 优化方案 + +**问题 A:缺少索引** — 添加联合索引: + +```sql +-- 覆盖 WHERE + ORDER BY + SELECT 列(覆盖索引) +ALTER TABLE orders ADD INDEX idx_user_status_created + (user_id, status, created_at, amount); +``` + +**问题 B:深分页** — 改用"延迟关联": + +```sql +-- 改写前:OFFSET 5000 需要扫描并丢弃 5000 行 +SELECT ... FROM orders WHERE user_id = ? AND status = ? +ORDER BY created_at DESC LIMIT 20 OFFSET 5000; + +-- 改写后:先用覆盖索引定位主键,再回表取数据 +SELECT o.id, o.status, o.amount, o.created_at, u.username +FROM orders o +JOIN users u ON o.user_id = u.id +WHERE o.id IN ( + SELECT id FROM orders + WHERE user_id = 100 AND status = 'pending' + ORDER BY created_at DESC + LIMIT 20 OFFSET 5000 +) +ORDER BY o.created_at DESC; +``` + +### Step 5 — 验证效果 + +优化前后的 `EXPLAIN` 对比: + +| 指标 | 优化前 | 优化后 | +|------|--------|--------| +| **access_type** | ALL | range | +| **key** | null | idx_user_status_created | +| **rows** | 520,000 | 20 | +| **Extra** | Using filesort | Using index condition | + +```bash +# 重新采集慢日志确认 +pt-query-digest --since 1h /var/log/mysql/slow.log +# → 该 SQL 指纹已不在 Top 10 中 ✅ +``` + +> [!TIP] 上线 Checklist +> - [ ] 在测试环境验证 EXPLAIN 输出符合预期 +> - [ ] 确认新索引不会影响写入性能(`ALTER TABLE` 可用 `pt-online-schema-change` 避免锁表) +> - [ ] 接入 Performance Schema 定时采集,设置告警阈值(如 Top SQL 单次 P95 > 2s 触发告警) + ## 关联笔记 - [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — EXPLAIN 各字段的详细解读与执行计划分析 diff --git a/hhs/MySQL/03-索引与查询优化/18-深分页优化.md b/hhs/MySQL/03-索引与查询优化/18-深分页优化.md index 494fb01..c4a0d07 100644 --- a/hhs/MySQL/03-索引与查询优化/18-深分页优化.md +++ b/hhs/MySQL/03-索引与查询优化/18-深分页优化.md @@ -9,7 +9,9 @@ create time: 2026-05-16 00:00 当 OFFSET 达到几十万、百万级别时,`LIMIT offset, size` 的性能会急剧下降。这不仅是 MySQL 的问题,而是所有关系型数据库的通病。本章提供三种成熟的解决方案。 -## 问题复现 +## 正文 + +### 问题复现 ```sql -- 典型的深分页查询 @@ -39,7 +41,7 @@ flowchart LR > [!QUESTION] 为什么不能像编程语言那样直接从数组索引取值? > 数据库不是内存数组。每一次 `LIMIT offset, N` 都需要从 B+ Tree 根部重新定位到第 offset 行——这是一次完整的扫描 + 排序操作。偏移量越大,越像是在浩瀚星海中逐粒计数沙子。 -## 方案一:延迟关联(Deferred Join) +### 方案一:延迟关联(Deferred Join) ```sql -- 核心思路:先用紧凑的主键索引定位 ID,再回表查数据 @@ -74,7 +76,7 @@ sequenceDiagram end ``` -### 性能对比 +#### 性能对比 | 指标 | 传统方式 | 延迟关联 | |------|---------|---------| @@ -116,7 +118,7 @@ func PaginatedQuery(db *gorm.DB, page, pageSize int) ([]Order, error) { > - `IN` 列表过大时(比如 > 1000 条)也会退化 > - 如果每行数据很小(< 50 bytes),收益有限 -## 方案二:游标分页(Seek Method / Keyset Pagination) +### 方案二:游标分页(Seek Method / Keyset Pagination) ```sql -- 首次查询(第一页) @@ -152,7 +154,7 @@ flowchart TD style R3 fill:#00D866,color:#fff ``` -### Go 实现 +#### Go 实现 ```go // Cursor-based pagination with stable sort @@ -226,7 +228,7 @@ ORDER BY created_at DESC, id DESC LIMIT 20; ``` -### 优缺点对比 +#### 优缺点对比 | | 游标分页 | 延迟关联 | OFFSET 分页 | |--|---------|---------|------------| @@ -236,7 +238,7 @@ LIMIT 20; | ORDER BY 要求 | 必须是有序主键/唯一键 | 可接受普通索引 | 任何 ORDER BY | | 适用场景 | 无限滚动 / 瀑布流 | 后台管理 / Excel 导出 | 小偏移量 (< 1万) | -## 方案三:限制最大页码 +### 方案三:限制最大页码 最朴素的方案——从业务层面限制深度,从根本上消除深分页问题: @@ -266,6 +268,29 @@ flowchart TD style D fill:#FF9F43,color:#000 ``` +### 如何选择方案? + +三种方案没有绝对优劣,关键在于匹配业务场景。下面的决策树可以快速帮你做出判断: + +```mermaid +flowchart TD + START["你的分页场景"] --> Q1{"需要跳页吗?"} + Q1 -->|"需要: 后台管理, 报表"| Q2{"偏移量会超过 1 万行?"} + Q1 -->|"不需要: 无限滚动, 瀑布流"| SOL_A["方案二: 游标分页"] + Q2 -->|"不会"| SOL_B["传统 OFFSET 即可"] + Q2 -->|"会"| SOL_C["方案一: 延迟关联"] + SOL_A --> OPT["组合: 方案三限制最大页码兜底"] + SOL_C --> OPT + + style SOL_A fill:#00D866,color:#fff + style SOL_B fill:#54A0FF,color:#fff + style SOL_C fill:#FF9F43,color:#000 + style OPT fill:#F8C291,color:#000 +``` + +> [!NOTE] 不要追求银弹 +> 实际项目中,三种方案经常组合使用。例如:电商后台商品列表用**延迟关联 + 最大页码限制**;C 端商品瀑布流用**游标分页**;数据导出接口用**主键范围分批**。理解原理后,按需混搭才是工程正道。 + > [!TIP] 实际工程建议 > - **前台展示**(商品列表、文章列表):用游标分页,用户体验最好 > - **后台管理**(运营后台、报表导出):允许 OFFSET 但限制最大页码 + 提供筛选条件 diff --git a/hhs/MySQL/03-索引与查询优化/README.md b/hhs/MySQL/03-索引与查询优化/README.md index b8cf681..7122171 100644 --- a/hhs/MySQL/03-索引与查询优化/README.md +++ b/hhs/MySQL/03-索引与查询优化/README.md @@ -20,6 +20,7 @@ create time: 2026-05-20 23:55 | 16 | [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] | 抓出执行慢的 SQL、用工具分析根因 | | 17 | [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] | OR → UNION ALL、JOIN → EXISTS,同样的结果写法影响性能 | | 18 | [[hhs/MySQL/03-索引与查询优化/18-深分页优化]] | `LIMIT 1000000, 20` 为什么慢、怎么优化 | +| — | [[索引失效情况]] | 拓展:索引失效的十大典型场景与排查速查 | ## 关联笔记 diff --git a/hhs/MySQL/03-索引与查询优化/补充/N+1 查询问题.md b/hhs/MySQL/03-索引与查询优化/补充/N+1 查询问题.md new file mode 100644 index 0000000..614db69 --- /dev/null +++ b/hhs/MySQL/03-索引与查询优化/补充/N+1 查询问题.md @@ -0,0 +1,462 @@ +--- +tags: [MySQL, N+1, 查询优化, ORM, 性能调优] +create time: 2026-05-21 13:37 +--- + +# N+1 查询问题 + +## 概述 + +一条查询取回 N 条记录,然后逐条再查关联数据——最终执行了 **1 + N** 次查询,这就是 N+1 问题。它不只出现在 ORM 中,SQL 标量子查询、应用层循环调用、微服务间逐条 RPC 都是它的"马甲"。本文按**从 SQL 到应用到服务**的层次,系统梳理所有可能触发 N+1 的场景,以及对应的改写思路。 + +## 正文 + +### 先建立直觉 + +> [!question] 为什么是 1 + N 而不是 N? +> 因为第一步通常是"拿到主表数据"这一条查询,之后才是为每条数据各补一次关联查询。如果不做任何优化,当 N = 10 万时,数据库要处理 10 万 + 1 次查询——每次都有网络往返、SQL 解析、执行计划生成的开销。 + +```mermaid +flowchart LR + A["1 次查询: 拿到 N 条订单"] --> B["循环 N 次"] + B --> C1["查客户 1"] + B --> C2["查客户 2"] + B --> C3["..."] + B --> CN["查客户 N"] + + style A fill:#00B6BC,color:#fff + style C1 fill:#EE5A24,color:#fff + style C2 fill:#EE5A24,color:#fff + style CN fill:#EE5A24,color:#fff +``` + +> [!abstract] 代价到底有多大?粗算一下 +> 假设单次查询耗时 **2ms**(含网络往返 + SQL 解析 + 执行),N = 10000: +> +> | 模式 | 查询次数 | 总耗时(串行) | +> |------|---------|---------------| +> | N+1 | 10,001 | ≈ **20 秒** | +> | 批量 IN | 1 | ≈ **2ms** | +> | JOIN | 1 | ≈ **2ms** | +> +> 差距是 **4 个数量级**。而且 N+1 不仅是慢——它还消耗连接池资源、放大数据库 QPS、拖垮监控面板。很多"数据库扛不住了"的报警,根因其实是应用层的 N+1。 + +--- + +### 速查总览 + +> [!tip] 按层级定位问题 +> N+1 不只是 ORM 的锅——SQL 写法、应用代码、服务架构都可能埋下隐患。先看全景图,再逐条深入。 + +```mermaid +mindmap + root(("N+1 触发场景")) + ("SQL 层") + ("标量子查询") + ("相关子查询") + ("WHERE IN 中的依赖子查询") + ("ORM 层") + ("惰性加载 Lazy Load") + ("序列化器访问关联字段") + ("应用层") + ("循环内逐条查询") + ("模板/视图中访问关联对象") + ("服务间调用") + ("逐条 RPC / HTTP 调用") +``` + +--- + +### 一、SQL 层面的 N+1 + +#### 1.1 标量子查询 + +```sql +-- ❌ 外层每行执行一次子查询 +SELECT u.username, + (SELECT SUM(amount) FROM orders WHERE user_id = u.id) AS total_spent +FROM users u; +-- users 有 10 万行 → 子查询执行 10 万次 +``` + +**为什么是 N+1**:标量子查询(Scalar Subquery)在 `SELECT` 列表中,对外层结果集的**每一行**都要执行一次。MySQL 优化器不一定能将其优化为 JOIN,尤其当子查询逻辑较复杂时。 + +**改写**:用 `GROUP BY` + `LEFT JOIN` 合并为一条查询。 + +```sql +-- ✅ 一次聚合 + 一次 JOIN +SELECT u.username, COALESCE(SUB.total, 0) AS total_spent +FROM users u +LEFT JOIN ( + SELECT user_id, SUM(amount) AS total + FROM orders GROUP BY user_id +) SUB ON u.id = SUB.user_id; +``` + +--- + +#### 1.2 相关子查询(EXISTS / NOT EXISTS) + +```sql +-- EXISTS 本身不一定是 N+1,但某些写法会退化 +SELECT * FROM customers c +WHERE EXISTS ( + SELECT 1 FROM orders o + WHERE o.customer_id = c.id + AND o.amount > 1000 +); +``` + +> [!note] EXISTS 不一定导致 N+1 +> 现代 MySQL(8.0+)优化器经常将 `EXISTS` 解除关联(decorrelation),转为半连接(Semi-Join)。但当子查询条件复杂、无法解除关联时,确实会逐行求值——此时仍然是 N+1 的变种。 + +**判断方法**:用 `EXPLAIN` 查看执行计划,关注 `type` 和 `Rows`。如果外层行数 × 子查询扫描行数接近 N × M,就说明关联未被优化。 + +--- + +#### 1.3 WHERE IN 中的标量依赖 + +```sql +-- ❌ 子查询依赖外层列,逐行求值 +SELECT * +FROM products p +WHERE category_name = ( + SELECT name FROM categories WHERE id = p.category_id +); +``` + +**原理**:子查询引用了外层的 `p.category_id`,每次迭代都要重新查 `categories` 表。改为 JOIN 即可消除: + +```sql +-- ✅ JOIN 合并 +SELECT p.* +FROM products p +JOIN categories c ON c.id = p.category_id +WHERE p.category_name = c.name; +``` + +--- + +### 二、ORM 层面的 N+1 + +ORM 是 N+1 的**重灾区**,因为框架默认的惰性加载机制天然就是"用到时再查"。 + +#### 2.1 惰性加载(Lazy Loading) + +```python +# Django ORM +orders = Order.objects.all() # 第 1 次查询 +for o in orders: + print(o.customer.name) # N 次:每次访问触发新查询 +``` + +```java +// JPA / Hibernate +List orders = em.createQuery("SELECT o FROM Order o").getResultList(); +for (Order o : orders) { + o.getCustomer().getName(); // N 次延迟加载代理调用 +} +``` + +```php +// Laravel Eloquent +$orders = Order::all(); // 1 次 +foreach ($orders as $o) { + echo $o->customer->name; // N 次 +} +``` + +**解法**:使用**预加载(Eager Loading)**: + +```python +# Django: select_related (一对一/外键) 或 prefetch_related (多对多) +orders = Order.objects.select_related('customer').all() # 1 次 JOIN 查询 +``` + +```java +// JPA: JOIN FETCH +@Query("SELECT o FROM Order o JOIN FETCH o.customer") +List findAllWithCustomer(); +``` + +```php +// Laravel: with() +$orders = Order::with('customer')->get(); // 2 次查询: orders + customers WHERE id IN (...) +``` + +```go +// Go GORM: ❌ 默认惰性加载 +var orders []Order +db.Find(&orders) // 第 1 次查询 +for i := range orders { + db.Model(&orders[i]).Association("Customer").Find(&orders[i].Customer) // N 次 +} + +// ✅ GORM Preload:拆成 2 条 SQL(orders + customers WHERE id IN ...) +var orders []Order +db.Preload("Customer").Find(&orders) + +// ✅ GORM Joins:用 LEFT JOIN 合并为 1 条 SQL +db.Joins("Customer").Find(&orders) +``` + +> [!question] `Preload` 和 `Joins` 什么时候该选哪个? +> - **Preload**:拆成 2 条 SQL,适合一对多(避免 JOIN 后行数膨胀) +> - **Joins**:合并为 1 条 SQL,适合一对一 / 外键关系,减少查询次数 +> - 如果关联数据量不大,两者性能差异很小;如果一方有大量子记录,Preload 通常更安全 + +--- + +#### 2.2 序列化器中访问关联字段 + +```python +# Django REST Framework +class OrderSerializer(serializers.ModelSerializer): + customer_name = serializers.SerializerMethodField() + + def get_customer_name(self, obj): + return obj.customer.name # ❌ 每个对象序列化时触发一次查询 +``` + +**解法**:在 ViewSet 的 `get_queryset()` 中预加载: + +```python +def get_queryset(self): + return Order.objects.select_related('customer').all() +``` + +--- + +### 三、应用层面的 N+1 + +#### 3.1 循环内逐条查询 + +```go +// ❌ Go: 循环中逐条查 +for _, uid := range userIDs { + var user User + db.Where("id = ?", uid).First(&user) // N 次查询 + users = append(users, user) +} + +// ✅ 批量查询 +var users []User +db.Where("id IN ?", userIDs).Find(&users) // 1 次查询 +``` + +```python +# ❌ Python: 循环逐条查 +users = [] +for uid in user_ids: + user = db.query("SELECT * FROM users WHERE id = %s", uid) # N 次 + users.append(user) + +# ✅ 批量 IN 查询 +users = db.query("SELECT * FROM users WHERE id IN %s", (user_ids,)) # 1 次 +``` + +> [!question] 为什么不直接 IN 查?有什么注意事项? +> `IN (...)` 在元素极多时(如 10 万+)可能超长或性能退化。实践中的做法是**分批**——每批 500~1000 个 ID: +> ```go +> for i := 0; i < len(ids); i += 500 { +> batch := ids[i:min(i+500, len(ids))] +> db.Where("id IN ?", batch).Find(&users) +> } +> ``` + +--- + +#### 3.2 模板 / 视图中访问关联对象 + +```erb + +<% @posts.each do |post| %> +

<%= post.title %>

+ 作者: <%= post.author.name %> <%# N 次查询 %> +<% end %> +``` + +这类问题特别隐蔽——**取数据在 Controller,访问关联在 View**,开发者在写 Controller 时可能根本没想到模板里会多触发 N 次查询。 + +**解法**:在 Controller 层预加载: + +```ruby +# Rails +@posts = Post.includes(:author).all +``` + +--- + +### 四、服务间的 N+1 + +当单体应用拆分为微服务后,N+1 从"数据库查询"变成了"网络调用",代价更大。 + +#### 4.1 逐条 RPC / HTTP 调用 + +``` +❌ 订单服务拿到 50 个订单 + → 逐个调用 用户服务 GET /users/{id} + → 50 次 HTTP 往返,每次 50ms ≈ 2.5 秒 +``` + +```mermaid +flowchart LR + subgraph "订单服务" + A["拿到 50 个订单"] + end + subgraph "用户服务" + B1["GET /users/1"] + B2["GET /users/2"] + B3["..."] + B50["GET /users/50"] + end + + A -->|"逐条调用"| B1 + A -->|"逐条调用"| B2 + A -->|"逐条调用"| B3 + A -->|"逐条调用"| B50 + + style B1 fill:#EE5A24,color:#fff + style B2 fill:#EE5A24,color:#fff + style B50 fill:#EE5A24,color:#fff +``` + +**解法**:提供批量接口,一次传入所有 ID: + +```protobuf +// gRPC 批量接口定义 +rpc GetUsers(GetUsersRequest) returns (GetUsersResponse); + +message GetUsersRequest { + repeated int64 user_ids = 1; +} +``` + +``` +✅ 调用一次 GetUsers([1, 2, ..., 50]) → 1 次 RPC,50ms +``` + +> [!tip] GraphQL 的 DataLoader 模式 +> GraphQL 的解析器天然容易产生 N+1。DataLoader 通过**同一批次合并请求**(batching) + **内存去重**(caching)解决: +> ```js +> // 每个 resolver 中调用 dataloader.load(id) +> // DataLoader 在同一事件循环 tick 内收集所有 id,合并为一次批量查询 +> const userLoader = new DataLoader(async (ids) => { +> const users = await db.query('SELECT * FROM users WHERE id IN (?)', [ids]); +> return ids.map(id => users.find(u => u.id === id)); +> }); +> ``` + +--- + +### 五、各场景速查对照表 + +| 层级 | 场景 | 根因 | 解法 | +|------|------|------|------| +| **SQL** | 标量子查询 | SELECT 列表中嵌套查询逐行求值 | `GROUP BY` + `JOIN` | +| **SQL** | 相关子查询 | WHERE/EXISTS 子查询引用外层列 | 优化器解除关联 / 改写为 JOIN | +| **ORM** | 惰性加载 | 属性访问时才触发查询 | 预加载(`select_related` / `JOIN FETCH` / `with()`) | +| **ORM** | 序列化器访问关联 | 取数与序列化未对齐 | 在 queryset 中 `select_related` | +| **应用** | 循环内逐条查询 | 缺少批量思维 | `WHERE id IN (...)` + 分批 | +| **应用** | 模板中访问关联 | 取数在 Controller,用数据在 View | Controller 层预加载 | +| **服务间** | 逐条 RPC/HTTP | 缺少批量接口 | 提供 `batch` 接口 / DataLoader | + +--- + +### 六、如何发现隐藏的 N+1 + +> [!warning] N+1 最可怕的地方不是性能差,而是**在开发和测试阶段完全看不出来** +> 本地数据库网络延迟 0ms、数据量小(几十条),N+1 和正常查询体验几乎无差异。到了线上几万行时才暴雷。 + +**排查手段**: + +| 手段 | 适用场景 | 说明 | +|------|---------|------| +| **SQL 日志** | ORM 项目 | 开启慢查询日志,观察短时间内的重复 SQL 模式 | +| **EXPLAIN** | SQL 层 | 关注子查询的执行次数 | +| **APM 工具** | 全链路 | SkyWalking / Datadog 可可视化每个 span 的 DB 调用次数 | +| **ORM 的 strict/lazy 禁用** | 开发阶段 | Django: `n_plus_one_db` checker;Rails: `bullet` gem;Laravel: `db:listen` | +| **代码审查** | 通用 | 搜索 `for` + `query/find/get` 的组合 | + +**实战:从慢查询日志中识别 N+1** + +开启 MySQL 慢查询日志后,你会在短时间内看到这样的重复模式: + +``` +# Time: 2026-05-21T10:32:01.001Z +SELECT * FROM orders WHERE status = 'pending'; +# Rows_examined: 1500 + +# Time: 2026-05-21T10:32:01.005Z +SELECT * FROM customers WHERE id = 101; +# Rows_examined: 1 + +# Time: 2026-05-21T10:32:01.008Z +SELECT * FROM customers WHERE id = 203; +# Rows_examined: 1 + +...(重复 1500 次,每次只有 id 不同) +``` + +> [!warning] 识别特征 +> - 短时间内出现**大量结构相同、仅参数不同**的 SQL +> - 每条 SQL 的 `Rows_examined` 都很小(1~几行) +> - 总执行时间 = N × 单条延迟,看起来每条都不慢,但累加惊人 + +--- + +### 七、N+1 一定是 bug 吗? + +> [!question] 什么时候 N+1 可以"放过"? +> 不是所有 N+1 都需要消灭。以下情况可以权衡: +> +> | 场景 | 理由 | +> |------|------| +> | **N 极小且固定**(如 N ≤ 5) | 5 次查询和 1 次查询的差距可忽略,代码可读性可能更重要 | +> | **关联数据命中率极低** | 如果 100 条订单只有 3 条有关联退款记录,JOIN 反而拉大了结果集 | +> | **缓存已兜底** | 关联数据在 Redis 中,逐条查的是缓存而非数据库 | +> +> **但要确保**:你不是在用"可以接受"来掩盖"懒得改"。如果 N 会随业务增长,今天放过的 N+1 就是明天的线上事故。 + +--- + +### 八、改写思路总结 + +```mermaid +flowchart TD + A["发现 N+1"] --> B{"发生在哪一层?"} + B -->|"SQL"| C["子查询改 JOIN / GROUP BY"] + B -->|"ORM"| D["开启预加载 Eager Loading"] + B -->|"应用代码"| E["循环查询改为 IN 批量"] + B -->|"微服务"| F["提供批量接口 / DataLoader"] + + C --> G["EXPLAIN 验证执行计划"] + D --> G + E --> G + F --> H["APM 监控 RPC 次数"] + + style A fill:#EE5A24,color:#fff + style G fill:#00D866,color:#fff + style H fill:#00D866,color:#fff +``` + +**核心原则**:凡是「循环 + 单条数据库/网络调用」的组合,都可能产生 N+1。消灭它的思路只有一个——**合并为批量操作**。 + +--- + +### 九、Code Review 快速检查清单 + +> [!tip] 遇到以下代码模式,立刻警觉 +> - [ ] `for` 循环内部有数据库查询 / HTTP 调用 +> - [ ] ORM 属性访问(`.author.name`)出现在序列化器或模板中 +> - [ ] SQL 的 `SELECT` 列表中有标量子查询 +> - [ ] 微服务接口只提供"单个 ID 查单条"的 API +> +> **一句话记忆**:看到"循环里的数据访问",先问自己——**能不能提到循环外面?** + +## 关联笔记 + +- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 子查询转 JOIN 的具体改写模式 +- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 验证改写后执行计划是否改善 +- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — 从慢查询日志中发现 N+1 的重复 SQL 模式 +- [[hhs/MySQL/03-索引与查询优化/补充/索引失效情况]] — 理解索引为何没被使用 diff --git a/hhs/MySQL/03-索引与查询优化/补充/索引失效情况.md b/hhs/MySQL/03-索引与查询优化/补充/索引失效情况.md new file mode 100644 index 0000000..361e76a --- /dev/null +++ b/hhs/MySQL/03-索引与查询优化/补充/索引失效情况.md @@ -0,0 +1,370 @@ +--- +tags: [MySQL, 索引, EXPLAIN, 查询优化, B+Tree] +create time: 2026-05-21 13:08 +--- + +# 索引失效情况 + +## 概述 + +明明建了索引,EXPLAIN 却显示 `type: ALL`——这种"索引建了等于没建"的情况在线上非常常见。本文系统梳理导致索引失效(或退化)的所有典型场景,并结合 B+ Tree 的底层原理给出每条规则的解释,帮你从"背规则"升级到"理解为什么"。 + +## 正文 + +### 速查总览 + +> [!tip] 先看全景图再逐条深入 +> 下图把常见的索引失效场景按"谁导致的"做了分类,后文逐一展开。 + +```mermaid +mindmap + root(("索引失效")) + ("SQL 写法问题") + ("隐式类型转换") + ("对索引列使用函数") + ("索引列参与运算") + ("LIKE 左模糊") + ("违反最左前缀") + ("范围查询截断后续列") + ("SELECT * 阻止覆盖索引") + ("OR 条件不当") + ("NOT IN / NOT EXISTS") + ("!= / <> / NOT LIKE") + ("数据与优化器") + ("优化器选择全表扫描") + ("隐式字符集转换") + ("IS NULL / IS NOT NULL") +``` + +--- + +### 1. 隐式类型转换 + +**现象**:`varchar` 列上建了索引,但用数值条件查询时索引失效。 + +```sql +-- phone 是 varchar 类型 +SELECT * FROM users WHERE phone = 13800138000; -- ❌ 索引失效 +SELECT * FROM users WHERE phone = '13800138000'; -- ✅ 走索引 +``` + +**原理**:MySQL 的类型转换规则是**将字符串转为数字**——相当于对索引列执行了 `CAST(phone AS DECIMAL)`。对索引列施加函数,B+ Tree 无法直接定位,只能逐行扫描。 + +> [!question] 反过来呢?数值列用字符串查会失效吗? +> 不会。`WHERE id = '123'`(id 是 `int`)等价于 `WHERE id = 123`,因为 `'123'` 被转为数字后直接用于比较,不涉及对索引列的函数调用。 + +--- + +### 2. LIKE 左模糊 / 两侧模糊 + +```sql +SELECT * FROM products WHERE name LIKE '%手机'; -- ❌ 全表扫描 +SELECT * FROM products WHERE name LIKE '手机%'; -- ✅ 走索引 +SELECT * FROM products WHERE name LIKE '%手机%'; -- ❌ 全表扫描 +``` + +**原理**:B+ Tree 按前缀有序排列。`'手机%'` 可以通过前缀定位到范围起点,而 `'%手机'` 左侧是未知的,无法利用有序性,只能全量遍历。 + +> [!tip] 模糊搜索的替代方案 +> 如果业务确实需要前后模糊匹配,可以考虑: +> - **覆盖索引**:索引包含所有查询字段,避免回表开销(虽然仍是全扫索引,但比扫聚簇索引快得多) +> - **全文索引**(`FULLTEXT`)或外部搜索引擎(Elasticsearch) + +--- + +### 3. 对索引列使用函数 + +```sql +-- ❌ 索引失效:对 create_time 调用了 YEAR() 函数 +SELECT * FROM orders WHERE YEAR(create_time) = 2026; + +-- ✅ 改写为范围查询,可以走索引 +SELECT * FROM orders WHERE create_time >= '2026-01-01' + AND create_time < '2027-01-01'; +``` + +**原理**:函数包裹索引列后,MySQL 拿到的不再是原始列值,而是函数的返回值。B+ Tree 上存的是原始值,无法对函数结果做二分查找。 + +常见的"隐形函数"还有: + +| 写法 | 等价函数调用 | +|------|------------| +| `WHERE DATE(col) = '2026-05-21'` | `DATE(col)` | +| `WHERE col + 1 = 10` | 加法运算(见下节) | +| `WHERE CONCAT(col, 'x') = 'abx'` | `CONCAT(col, 'x')` | + +> [!question] MySQL 8.0 的索引下推(ICP)能救吗? +> 不能。ICP(Index Condition Pushdown)优化的是**回表前的过滤**,前提仍是索引被命中。如果函数导致索引根本没用上,ICP 无从谈起。 + +--- + +### 4. 索引列参与运算 + +```sql +-- ❌ 对索引列做了运算 +SELECT * FROM accounts WHERE balance - 100 > 0; + +-- ✅ 把运算移到右边(常量侧) +SELECT * FROM accounts WHERE balance > 100; +``` + +**原理**:与函数同理——`balance - 100` 对每一行的 `balance` 做运算后才能比较,B+ Tree 上存的是 `balance` 原始值,无法直接定位。 + +**准则**:**索引列保持"干净",运算和函数尽量放到等号右侧。** + +--- + +### 5. 违反联合索引的最左前缀原则 + +假设联合索引为 `(a, b, c)`: + +| WHERE 条件 | 能否走索引 | 走到哪一列 | +|-----------|-----------|-----------| +| `a = 1` | ✅ | a | +| `a = 1 AND b = 2` | ✅ | a, b | +| `a = 1 AND b = 2 AND c = 3` | ✅ | a, b, c | +| `b = 2` | ❌ | — | +| `b = 2 AND c = 3` | ❌ | — | +| `a = 1 AND c = 3` | ⚠️ 部分 | a(c 无法跳过 b 使用)| + +**原理**:联合索引在 B+ Tree 中按 `a → b → c` 的顺序排序。没有 `a` 就无法确定在树中的起始位置,就像查字典时不知道首字母一样。 + +> [!tip] MySQL 8.0+ 的索引跳跃扫描(Index Skip Scan) +> 当联合索引首列基数(cardinality)很低时(例如 `gender` 只有 M/F),优化器可能拆分成两次索引查询,绕过最左前缀限制。但这只是优化器的"兜底"策略,不应依赖。 + +--- + +### 6. 范围查询截断联合索引的后续列 + +联合索引 `(a, b, c)` 中,一旦某列使用了范围查询(`>`、`<`、`BETWEEN`、`LIKE 'x%'`),**该列之后的索引列将无法继续用于索引定位**。 + +```sql +-- 联合索引 (a, b, c) +WHERE a = 1 AND b > 10 AND c = 20; +-- a: ✅ 等值定位 +-- b: ✅ 范围扫描(在 a=1 的范围内做 b > 10) +-- c: ❌ 无法走索引(b 是范围,c 被"截断") +``` + +**原理**:B+ Tree 先按 `a` 排序,`a` 相同时按 `b` 排序,`b` 相同时按 `c` 排序。当 `b > 10` 时,`b` 的值不再固定,那么同一个 `b` 值下可能对应不同的 `c`,`c` 在树中不再有序,无法二分查找。 + +> [!question] 那 `BETWEEN` 和 `LIKE 'x%'` 也会截断吗? +> 是的。`BETWEEN 1 AND 100` 本质也是范围,`LIKE '张%'` 同理——它们都让该列的值不再唯一确定,后续列的有序性被破坏。 + +> [!tip] 实战优化思路 +> 在设计联合索引时,**等值查询的列放在前面,范围查询的列放在后面**。例如,如果查询经常是 `WHERE status = 'active' AND create_time > '2026-01-01'`,索引应设计为 `(status, create_time)` 而非 `(create_time, status)`。 + +--- + +### 7. SELECT * 导致无法走覆盖索引 + +```sql +-- 假设有联合索引 idx_name_age (name, age) + +-- ✅ 覆盖索引:查询字段全在索引中,无需回表 +SELECT name, age FROM users WHERE name = '张三'; + +-- ❌ SELECT * 强制回表:即使 WHERE 走了索引,仍需回表取其他列 +SELECT * FROM users WHERE name = '张三'; +``` + +**原理**:覆盖索引(Covering Index)是指查询所需的所有字段都包含在索引中,MySQL 可以直接从索引返回结果,省去"回表"(回到聚簇索引取完整行)的开销。`SELECT *` 取所有列,索引中不可能全部包含,因此必定回表。 + +> [!question] 回表代价有多大? +> 每次回表都是一次**随机 I/O**。如果查询匹配 10 万行,就要做 10 万次随机读。这就是为什么 `SELECT *` + 大量行 = 慢查询的经典组合。 +> +> 通过 `EXPLAIN` 查看 `Extra` 列:出现 `Using index` 表示走覆盖索引,出现 `Using index condition` 表示走了索引但仍需回表。 + +> [!tip] 最佳实践 +> - 生产代码中禁止 `SELECT *`,只查需要的列 +> - 为高频查询设计"覆盖索引"——把 `SELECT` 中的字段也加入联合索引尾部 +> - 注意:索引列过多会增大写入代价,需权衡 + +--- + +### 8. OR 条件不当(一侧无索引) + +```sql +-- ❌ name 有索引,age 没索引 → 整体退化为全表扫描 +SELECT * FROM users WHERE name = '张三' OR age = 25; + +-- ✅ 用 UNION ALL 拆开,各自走各自的索引 +SELECT * FROM users WHERE name = '张三' +UNION ALL +SELECT * FROM users WHERE age = 25 AND name != '张三'; +``` + +**原理**:`OR` 要求两边条件取并集。如果其中一个条件无索引,MySQL 只能对全表扫描来保证结果完整。 + +> [!note] 如果 OR 两边的列都有索引呢? +> MySQL 会分别用两个索引扫描,再合并结果(`index_merge` 优化),通常是能走索引的。 + +--- + +### 9. NOT IN / NOT EXISTS / != / <> + +```sql +-- 以下写法可能导致索引失效(取决于数据分布和优化器判断) +SELECT * FROM orders WHERE status != 'completed'; +SELECT * FROM users WHERE id NOT IN (1, 2, 3); +SELECT * FROM users WHERE name NOT LIKE '张%'; +``` + +**原理**:不等于 / 不在集合中 / 不匹配,本质上是**排除**操作——需要扫描大量行来确认"不等于",优化器通常评估后认为全表扫描更快。 + +> [!question] 那 NOT EXISTS 一定比 NOT IN 慢吗? +> 恰恰相反。`NOT EXISTS` 使用关联子查询,往往能在子查询表上走索引;而 `NOT IN` 需要将子查询结果物化后逐一比对,当结果集大时更慢。但在"索引是否失效"这个维度,两者的行为取决于执行计划,不能一概而论。 + +--- + +### 10. IS NULL / IS NOT NULL + +```sql +SELECT * FROM users WHERE email IS NULL; +SELECT * FROM users WHERE email IS NOT NULL; +``` + +- **MySQL 5.6**:`IS NULL` 可以走索引,`IS NOT NULL` 通常不能。 +- **MySQL 8.0+**:两者都**可以**走索引,优化器会根据**数据分布**自行判断——如果大部分行都是 `NULL`,`IS NOT NULL` 反而更高效地走索引。 + +> [!tip] 设计层面的建议 +> 在设计表时,如果某列经常需要查询"非空"的记录,可以考虑将默认值设为一个特殊标记(如空字符串或 `0`),避免频繁 `IS NULL / IS NOT NULL` 判断。 + +--- + +### 11. 优化器主动放弃索引 + +即使索引"理论上"可用,优化器也可能主动选择全表扫描。 + +**核心判断逻辑**:优化器基于**成本估算**做决策——当回表代价高于直接全扫时,索引就会被放弃。 + +常见场景: + +| 场景 | 原因 | +|------|------| +| 查询返回表中超过 20%~30% 的行 | 回表次数太多,不如顺序扫描 | +| 表数据量很小(几百行以内) | 顺序扫描比 B+ Tree 查找更快 | +| 统计信息过期 | 优化器误判行数,做出错误决策 | + +```sql +-- 用 FORCE INDEX 强制走索引(仅用于验证,不建议生产使用) +SELECT * FROM orders FORCE INDEX(idx_create_time) +WHERE create_time > '2026-01-01'; +``` + +> [!note] 保持统计信息准确 +> `ANALYZE TABLE table_name` 可以刷新表的统计信息,帮助优化器做出更准确的判断。 + +--- + +### 12. 隐式字符集 / 排序规则转换 + +当 `JOIN` 两张表的关联字段字符集不一致时,MySQL 会对其中一列做隐式转换,导致该列的索引失效。 + +```sql +-- 表 A: name VARCHAR(50) CHARSET utf8mb4 COLLATE utf8mb4_general_ci +-- 表 B: name VARCHAR(50) CHARSET utf8 COLLATE utf8_general_ci + +SELECT * FROM A JOIN B ON A.name = B.name; +-- B.name 会被隐式转换为 utf8mb4,B 表侧索引可能失效 +``` + +**解决**:统一字符集和排序规则,或显式 `CONVERT()` 到低优先级字符集侧。 + +--- + +### 排查速查清单 + +当怀疑索引失效时,按以下流程排查: + +```mermaid +flowchart TD + A["怀疑索引失效"] --> B["EXPLAIN 查看执行计划"] + B --> C{"type = ALL?"} + C -->|"是"| D["检查 WHERE 条件"] + C -->|"否"| E["索引已命中, 检查其他慢点"] + D --> F{"索引列被函数/运算包裹?"} + F -->|"是"| G["改写: 函数/运算移到常量侧"] + F -->|"否"| H{"存在隐式类型转换?"} + H -->|"是"| I["统一参数类型与列类型"] + H -->|"否"| J{"联合索引是否满足最左前缀?"} + J -->|"否"| K["调整索引或查询条件顺序"] + J -->|"是"| L{"范围查询是否截断后续列?"} + L -->|"是"| M["调整索引列顺序: 等值在前, 范围在后"] + L -->|"否"| N{"OR 条件中有无索引列?"} + N -->|"是"| O["UNION ALL 拆分或补充索引"] + N -->|"否"| P["考虑数据分布, ANALYZE TABLE"] +``` + +--- + +### 实战:从一个慢查询到修复的全过程 + +> [!example] 真实场景还原 +> 线上告警:某订单查询接口 P99 耗时 3 秒。表 `orders` 约 500 万行,已有联合索引 `idx_status_time(status, create_time)`。 + +**第一步:拿到 SQL,跑 EXPLAIN** + +```sql +EXPLAIN SELECT * FROM orders +WHERE status = 'pending' + AND create_time > '2026-05-01' + AND YEAR(update_time) = 2026; +``` + +``` ++----+------+------+----------+----------+ +| id | type | key | key_len | Extra | ++----+------+------+----------+----------+ +| 1 | ALL | NULL | NULL | Using where | ++----+------+------+----------+----------+ +``` + +`type = ALL`,`key = NULL`——索引完全没用上。 + +**第二步:逐条排查** + +- `status = 'pending'`:等值查询,无问题。 +- `create_time > '2026-05-01'`:范围查询,索引 `(status, create_time)` 可以覆盖前两列。 +- `YEAR(update_time) = 2026`:**函数包裹了索引列**——如果 `update_time` 上有索引也会失效。更关键的是,这个条件让优化器评估后觉得"算了,全扫吧"。 + +**第三步:改写并验证** + +```sql +-- 去掉函数,改写为范围 +EXPLAIN SELECT * FROM orders +WHERE status = 'pending' + AND create_time > '2026-05-01' + AND update_time >= '2026-01-01' + AND update_time < '2027-01-01'; +``` + +``` ++----+-------+----------------+---------+-----------------------------+ +| id | type | key | key_len | Extra | ++----+-------+----------------+---------+-----------------------------+ +| 1 | range | idx_status_time| 68 | Using index condition | ++----+-------+----------------+---------+-----------------------------+ +``` + +`type = range`,索引命中!但 `Extra` 显示 `Using index condition`(ICP),说明仍需回表。 + +**第四步:进一步优化——覆盖索引** + +如果这个接口只需要 `order_id, status, amount`,可以建覆盖索引: + +```sql +ALTER TABLE orders ADD INDEX idx_cover(status, create_time, order_id, amount); +``` + +改写查询为 `SELECT order_id, status, amount FROM orders WHERE ...`,`Extra` 将变为 `Using index`,彻底消除回表。 + +> [!tip] 排查口诀 +> **先 EXPLAIN,看 type 和 key;再看 Extra,找 Using index;函数和类型,是最常见的坑。** + +## 关联笔记 + +- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] +- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] +- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] +- [[hhs/MySQL/03-索引与查询优化/12-B+Tree 索引原理]] diff --git a/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md b/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md index 5a69486..d353863 100644 --- a/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md +++ b/hhs/MySQL/04-存储引擎/19-InnoDB 深度解析.md @@ -1,5 +1,5 @@ --- -tags: [MySQL, InnoDB, Clustered Index, Buffer Pool, Redo Log, Undo Log, Change Buffer, MVCC] +tags: [MySQL, InnoDB, Clustered Index, Buffer Pool, Redo Log, Undo Log, Change Buffer, MVCC, Binlog, WAL] create time: 2026-05-16 07:30 --- @@ -125,14 +125,14 @@ graph BT F -.-> G G -.-> H H -.-> I - - note["注意: 叶子节点之间通过双向链表串联,维持主键有序"] - note ~~~ F - + style F fill:#00B6BC,color:#fff style I fill:#00B6BC,color:#fff ``` +> [!tip] 叶子节点的秘密 +> 图中虚线箭头表示叶子节点之间通过 **双向链表** 串联,且按主键顺序排列。这意味着即使你不知道具体主键,也能通过顺序扫描快速获取一段范围数据——这就是 **范围查询高效** 的根本原因。 + > [!TIP] 为什么一定要用自增主键? > 如果选随机值(如 UUID)作为主键,每次插入都可能在索引树的中间位置分叉,导致: > - **页分裂**:16KB 页面满了要拆成两个,触发大量 I/O @@ -165,12 +165,11 @@ flowchart LR style SI1 fill:#FF9F43,color:#000 style CI1 fill:#00D866,color:#fff - - note["注意: 虚线表示二级索引查找后需通过主键回表获取完整行数据"] - note ~~~ SI1 - note ~~~ CI1 ``` +> [!tip] 图中的虚线是什么意思? +> 虚线表示 **回表(Back to Table)**:先通过二级索引找到主键,再用主键去聚簇索引取完整行数据。这就是为什么走二级索引查 `SELECT *` 会比走聚簇索引慢——它实际上查了两棵树。 + ### 覆盖索引(Covering Index) 当查询需要的列全部在一个二级索引中时,**无需回表**,直接返回结果。这比普通查询快得多。 @@ -192,6 +191,14 @@ SELECT id FROM users WHERE email = 'alice@example.com'; ## Buffer Pool 详解 +> [!question] 为什么需要 Buffer Pool? +> +> 先看一组数字:内存随机访问延迟约 **100 纳秒**,SSD 约 **100 微秒**,机械硬盘约 **10 毫秒**。从磁盘读比从内存读慢 **100~100,000 倍**。 +> +> 如果每次 SQL 都去磁盘读写,性能会非常糟糕。**Buffer Pool 的本质就是一个大缓存**:把最常访问的数据页放在内存里,只有缓存未命中时才去磁盘取。这和浏览器缓存图片、Redis 缓存热点数据是同一个思路。 +> +> 可以把 Buffer Pool 想象成你桌面上的「常用文件架」——经常用的资料就放在手边,不常用的才去档案柜(磁盘)里拿。 + Buffer Pool 是 InnoDB 最重要的性能组件,它缓存了磁盘上的数据页和索引页。 ```mermaid @@ -202,10 +209,10 @@ flowchart TD Young --> Old["Old Sub-list"] Old --> Free["Free List"] - Note1["INSERT/UPDATE\n→ 放 New 区"] - Note2["读未命中\n→ 从磁盘加载到 New 区"] - Note3["频繁访问\n→ 保持 Young 区\n(防污染旧页)"] - Note4["不再访问\n→ 淘汰到 Free 区"] + Note1["写操作 → 放 New 区"] + Note2["读缓存未命中 → 从磁盘加载到 New 区"] + Note3["频繁访问 → 保持 Young 区"] + Note4["不再访问 → 淘汰到 Free 区"] end FlushList["Flush List, 脏页链表"] --> Disk["刷入磁盘 ibd 文件"] @@ -237,15 +244,25 @@ innodb_buffer_pool_load_at_startup = ON # 启动时恢复上一次状态 ## Redo Log(重做日志) -Redo Log 是 InnoDB 特有的**物理日志**,用于保证事务的 Durability。 +> [!question] 为什么需要 Redo Log?先看一个问题 +> +> 回顾前面写入路径,事务提交时只需要把 Redo Log 刷到磁盘,**不需要立即把脏页写回数据文件(.ibd)**。这带来了一个矛盾: +> +> 如果事务已提交,但脏页还在内存里(尚未刷盘),此时服务器突然断电——数据不就丢了吗? +> +> 答案就在于 **WAL(Write-Ahead Logging,预写日志)** 原则:**在修改数据页之前,必须先把修改内容写到日志里**。日志是顺序追加写,体积小、速度快;数据页是随机写,体积大、速度慢。只要日志先落盘了,即使数据页还没写回磁盘,crash 之后也能「重放日志」恢复到崩溃前的状态。 +> +> 一句话总结:**Redo Log 让 InnoDB 用顺序写换取了随机写的安全性——事务提交只 fsync 日志,脏页由后台慢慢刷盘。** + +Redo Log 是 InnoDB 特有的**物理日志**,实现了 WAL 原则,保证事务的 Durability(持久性)。它的记录粒度是「在哪个页(page)的哪个偏移(offset)改了哪些字节」,而不是 SQL 语句——这和后面要讲的 Binlog 有本质区别。 ```mermaid flowchart LR A["事务修改数据页\nBuffer Pool 中的脏页"] --> B["同时写入 Redo Log Buffer"] B --> C{flush_log_at_trx_commit} - C -->|1| D["立即刷盘"] - C -->|2| E["写入 OS Cache,每秒刷盘"] - C -->|0| F["每秒刷盘,commit 也异步"] + C -->|1| D["立即 fsync 刷盘"] + C -->|2| E["写入 OS Cache,每秒 fsync"] + C -->|0| F["每秒写 OS Cache + fsync,commit 异步"] D --> G["磁盘持久化 ✅"] E --> G @@ -254,12 +271,23 @@ flowchart LR style D fill:#00D866,color:#fff ``` +> [!NOTE] 物理日志 vs 逻辑日志,到底有什么区别? +> +> 假设执行 `UPDATE users SET name = 'Bob' WHERE id = 1;` +> +> | 日志类型 | 记录内容 | 恢复方式 | +> |---------|---------|---------| +> | **物理日志(Redo Log)** | `表空间 N 的第 X 页,偏移 128 字节处,写入 'Bob'` | 直接把字节写回原位置,无需解析 SQL | +> | **逻辑日志(Binlog / Undo Log)** | `UPDATE users SET name='Bob' WHERE id=1` | 重新执行这条 SQL | +> +> 物理日志恢复速度极快(直接写原始字节),但只能用于同一页结构的恢复;逻辑日志跨引擎通用,但恢复时需要经过 SQL 解析层。Redo Log 选物理日志的原因很简单:**崩溃恢复要尽可能快,越快数据库不可用时间越短**。 + ### Redo Log 的特性 -- **循环写入**:大小固定(默认各 48MB),写满后从头覆盖 -- **物理日志**:记录「在哪个页的哪个偏移改了什么字节」,而非 SQL +- **WAL 原则**:修改数据页前,日志必须先落盘——这是 crash safe 的根本保证 +- **循环写入**:大小固定(默认各 48MB),写满后从头覆盖,不能无限增长 +- **物理日志**:记录「在哪个页的哪个偏移改了什么字节」,恢复时直接写回字节 - **Crash Safe**:崩溃恢复时重放 Redo Log 恢复到一致状态 -- **两阶段提交(2PC)**:协调 Redo Log 和 Binlog 的一致性 > [!NOTE] flush_log_at_trx_commit 三种模式 > @@ -271,9 +299,37 @@ flowchart LR > > 生产环境强烈推荐 `1`。设为 `2` 能在大部分场景接近相同性能,但在 OS 崩溃时会丢数据。 +### 先搞清楚:Binlog 是什么? + +在讲两阶段提交之前,必须先理解另一个日志:**Binlog(Binary Log,二进制日志)**。前面一直在讲 Redo Log,但 2PC 涉及两种日志的协调,不认识 Binlog 就无法理解后面的内容。 + +> [!NOTE] Binlog 一句话定义 +> Binlog 是 **MySQL Server 层**(不是 InnoDB 引擎层)维护的一份**逻辑日志**,记录了所有修改数据的操作。它是**主从复制**和**数据恢复(PITR)** 的基石。 + +把 Redo Log 和 Binlog 放在一起对比,区别一目了然: + +| 对比项 | Redo Log | Binlog | +|--------|----------|--------| +| **归属** | InnoDB 引擎层(独占) | MySQL Server 层(所有引擎共享) | +| **日志类型** | 物理日志(页偏移 + 字节) | 逻辑日志(SQL 或行变更) | +| **写入方式** | 固定大小,循环覆盖 | 追加写入,文件持续增长 | +| **主要用途** | 崩溃恢复(Crash Recovery) | 主从复制、时间点恢复 | +| **生命周期** | Checkpoint 后可覆盖 | 按配置保留 N 天后清理 | + +> [!TIP] 一个类比帮助记忆 +> +> - **Redo Log** 像是你记在便签纸上的「操作步骤」——写完账本(数据页)后便签就可以扔掉。便签是循环使用的(固定大小),只给自己看(InnoDB 专用)。 +> - **Binlog** 像是公司存档的「操作记录」——永久保存、持续累积,给审计和分支机构(从库)用。 +> +> 这两种日志目的完全不同,**缺一不可**:没有 Redo Log,crash 后数据丢失;没有 Binlog,无法做主从复制和精确时间点恢复。 + +关于 Binlog 的三种格式(Statement / Row / Mixed)、文件结构、核心参数等详细内容,参见 → [[hhs/MySQL/07-高可用与分布式/29-Binary Log]] + +--- + ### 两阶段提交(Two-Phase Commit) -为什么 InnoDB 需要协调 Redo Log 和 Binlog?先想一个问题: +认识了两种日志之后,问题来了:它们各自独立写入,怎么保证「两边记录的一致性」?先想一个问题: > [!QUESTION] 如果只写一种日志,会出现什么后果? > @@ -309,11 +365,23 @@ flowchart LR > > **关键结论**:两阶段提交的本质是让 Redo Log 充当 "投票机制"——只有当事务真正安全地存在于两种日志中时才算完成。这保证了主从数据的一致性。 +> [!question] 思考:为什么不让 MySQL 先写 Binlog 再写 Redo Log? +> +> 从逻辑上讲,顺序调换似乎也能做"两阶段"。但有一个关键区别: +> - **Redo Log 是 InnoDB 自己的**,由存储引擎完全控制,崩溃恢复逻辑自洽 +> - **Binlog 是 Server 层的**,所有引擎共用,它无法感知 InnoDB 事务状态 +> +> 如果先写 Binlog 再写 Redo Log,在 Redo Log fsync 之前崩溃了——Binlog 里已经记录了这个事务(用于主从复制),但 InnoDB 自己的数据文件里并没有这个修改,**主从数据直接不一致**。 +> +> 所以顺序不能反过来。2PC 的设计让 InnoDB(Redo Log)保持「最终决策者」的角色。 + --- ## Undo Log(回滚日志) -与 Redo Log(向前恢复)不同,Undo Log 用于将数据**回退到修改前的状态**。它支持两个关键功能: +与 Redo Log(向前恢复)不同,Undo Log 用于将数据**回退到修改前的状态**。它本质上是 InnoDB 的「后悔药」——在你修改数据之前,先把旧值记下来,万一需要回退或者有别的事务想看旧数据,都能找到。 + +它主要支撑两个关键功能:**事务回滚** 和 **MVCC 多版本读取**。 ```mermaid flowchart LR @@ -350,6 +418,14 @@ flowchart LR ## Change Buffer(变更缓冲) +> [!question] 为什么二级索引的维护会成为性能瓶颈? +> +> 二级索引的叶子节点按索引列有序排列,而不是按主键排列。这意味着对二级索引的插入很可能是**随机 I/O**——新记录可能要插入到索引树的任意位置,如果目标页恰好不在 Buffer Pool 里,就必须先从磁盘读进来。 +> +> 想象你有一张千万级的用户表,每次 INSERT 都要维护 3 个二级索引,如果每次都触发随机磁盘读写……性能会严重下降。 +> +> **Change Buffer 就是为了解决这个问题**:它把「二级索引页不在内存时的写操作」先缓存起来,等该页被其他查询加载到 Buffer Pool 时,再一并合并写入,从而把多次随机 I/O 合并成一次。 + Change Buffer 缓存了对**非唯一二级索引页**的修改操作,等该页被读到时再合并写入磁盘。这对 INSERT-heavy 场景有显著加速效果。 ```mermaid @@ -376,6 +452,14 @@ flowchart TD 有了写入路径全景后,我们来看最后一个关键问题:**内存里的脏页什么时候被写回磁盘?** +> [!tip] 用一个类比理解 Checkpoint +> +> 把 Redo Log 想象成一本**固定页数的笔记本**,每次数据修改就往上记一笔。笔记本写满了怎么办?不能直接丢掉,因为里面记录的修改可能还没真正写到账本(磁盘数据文件)里。 +> +> **Checkpoint 就是「定期把这些记录落实到账本上」的动作**——一旦某页的修改已经刷到磁盘了,那本笔记本上对应的记录就可以安全擦除了,腾出空间写新记录。 +> +> Checkpoint 推进得越快,Redo Log 的空间就越充裕;推进得太慢,Redo Log 可能写满导致写入阻塞。这是 InnoDB 内部最重要的平衡之一。 + ### LRU List ↔ Flush List 的双链表协作 Buffer Pool 维护着两条核心链表,它们各自有不同的职责: @@ -383,7 +467,7 @@ Buffer Pool 维护着两条核心链表,它们各自有不同的职责: ```mermaid flowchart LR subgraph "LRU List, 访问顺序" - Old["Old Sub-list (最近经常访问)"] --> New["New Sub-list (新加载的页)"] + New["New Sub-list (新加载的页)"] --> Old["Old Sub-list (热点页)"] end subgraph "Flush List, 脏污程度" diff --git a/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md b/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md index 7194149..80d0785 100644 --- a/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md +++ b/hhs/MySQL/06-事务与并发控制/23-ACID 与原子性实现.md @@ -142,7 +142,7 @@ sequenceDiagram participant BGL as Binlog participant RDL as Redo Log - App->>S: BEGIN; UPDATE ... COMMIT; + App->>S: BEGIN → UPDATE → COMMIT S->>IB: Prepare 事务 IB->>RDL: 写入 Redo Log (prepare 状态) diff --git a/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md b/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md index 80e1eaa..0974177 100644 --- a/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md +++ b/hhs/MySQL/06-事务与并发控制/24-隔离级别与可见性.md @@ -124,25 +124,30 @@ sequenceDiagram participant B as Transaction B A->>RC_DB: BEGIN; - A->>RC_DB: SELECT * FROM t; -- Read View #1 - A->>RC_DB: SELECT * FROM t; -- Read View #2 (new each time) + A->>RC_DB: SELECT * FROM t + Note over A,RC_DB: Read View #1 + A->>RC_DB: SELECT * FROM t + Note over A,RC_DB: Read View #2 (new each time) B->>RC_DB: BEGIN; - B->>RC_DB: INSERT INTO t VALUES (...); + B->>RC_DB: INSERT INTO t VALUES (...) B->>RC_DB: COMMIT; - A->>RC_DB: SELECT * FROM t; -- Read View #3 + A->>RC_DB: SELECT * FROM t + Note over A,RC_DB: Read View #3 Note over RC_DB: Can see new rows committed by B! A->>RR_DB: BEGIN; - A->>RR_DB: SELECT * FROM t; -- Read View #1 - A->>RR_DB: SELECT * FROM t; -- Reuses Read View #1 + A->>RR_DB: SELECT * FROM t + Note over A,RR_DB: Read View #1 + A->>RR_DB: SELECT * FROM t + Note over A,RR_DB: Reuses Read View #1 B->>RR_DB: BEGIN; - B->>RR_DB: INSERT INTO t VALUES (...); + B->>RR_DB: INSERT INTO t VALUES (...) B->>RR_DB: COMMIT; - A->>RR_DB: SELECT * FROM t; + A->>RR_DB: SELECT * FROM t Note over RR_DB: Cannot see B's rows - RV#1 was created before B committed ``` diff --git a/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md b/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md index 94b3439..aa8f51e 100644 --- a/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md +++ b/hhs/MySQL/06-事务与并发控制/25-MVCC 原理.md @@ -12,16 +12,17 @@ MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB > [!TIP] 一句话理解 MVCC > MVCC 的本质是让每个事务看到「一个时间点的快照」,而不是磁盘上实时的数据。就像给数据库拍照——你在照片里看到的是拍那一瞬间的样子,之后别人怎么改都不会影响你。 -## 背后的三个支撑字段 +## 行记录的隐藏字段 -每行记录额外隐藏了两个字段: +每行记录在用户定义的列之外,InnoDB 还悄悄塞了两个隐藏字段: | 字段 | 类型 | 作用 | |------|------|------| | **DB_TRX_ID** | 6 bytes | 最近修改该行的事务 ID | | **DB_ROLL_PTR** | 7 bytes | 回滚指针,指向对应的 Undo Log | -此外,二级索引的叶子节点还保存了**主键值**(而不是完整数据),这也是 MVCC 能够工作的基础。 +> [!NOTE] 二级索引与 MVCC 的关系 +> 二级索引的叶子节点不存储完整行数据,只保存**主键值**。因此通过二级索引查数据时,必须「回表」到聚簇索引才能拿到完整的行记录(包括 DB_TRX_ID)。这意味着 MVCC 的可见性判断始终发生在聚簇索引层面。 ```mermaid flowchart LR @@ -43,6 +44,9 @@ flowchart LR ## Version Chain(版本链) +> [!TIP] 用 Git 理解版本链 +> 版本链就像 Git 的提交历史——每次 `UPDATE` 相当于一次 `git commit`,旧版本不会被覆盖,而是通过 `DB_ROLL_PTR`(类似 `parent commit` 指针)串联起来。你想看某个历史版本?沿着指针往前「checkout」就行了。 + 当一行被 UPDATE 时,InnoDB 不会直接修改原数据,而是: 1. 在 Undo Log 中保留旧版本 @@ -111,7 +115,7 @@ flowchart TD ``` > [!NOTE] min_trx_id / max_trx_id 的作用区间 -> 这两个字段一起定义了一个半开区间 `[min_trx_id, max_trx_id)`。trx_id 落在这个区间之外的,可以直接通过两条边界判断得出结论;只有落在这个区间的,才需要进一步检查是否在 m_ids 中。这样可以减少不必要的列表扫描。 +> 这两个字段一起定义了一个半开区间 `[min_trx_id, max_trx_id)`。trx_id 落在这个区间之外的,可以直接通过两条边界判断得出结论——小于 min 的一定可见,大于等于 max 的一定不可见;只有落在这个区间的,才需要进一步检查是否在 m_ids 中。这样可以减少不必要的列表扫描。 ## 可见性判断规则 @@ -119,36 +123,48 @@ flowchart TD ```mermaid flowchart TD - A["行记录的 trx_id"] --> B{"trx_id < min_trx_id?"} - B -->|是| V1["✅ 可见\n事务在 ReadView 前已提交"] + A["行记录的 trx_id"] --> S{"trx_id == creator_trx_id?"} + S -->|是| V0["✅ 可见\n我自己改的,当然看得到"] + + S -->|否| B{"trx_id < min_trx_id?"} + B -->|是| V1["✅ 可见\n在 ReadView 创建前已提交"] B -->|否| C{"trx_id >= max_trx_id?"} - C -->|是| V2["✅ 可见\n事务在 ReadView 后才开始"] + C -->|是| V2["❌ 不可见\n在 ReadView 创建后才启动"] C -->|否| D{"trx_id 在 m_ids 中?"} D -->|否| V3["✅ 可见\n不在活跃列表 → 已提交"] - D -->|是| V4["❌ 不可见\n创建时还在运行"] + D -->|是| V4["❌ 不可见\n创建 ReadView 时还在运行"] + style V0 fill:#00B6BC,color:#fff style V1 fill:#00D866,color:#fff - style V2 fill:#00D866,color:#fff + style V2 fill:#EE5A24,color:#fff style V3 fill:#00D866,color:#fff style V4 fill:#EE5A24,color:#fff ``` -### 四个规则的速记口诀 +### 五个规则的速记口诀 | 规则 | 条件 | 结论 | 通俗解释 | |------|------|------|---------| -| **早提交** | `trx_id < min_trx_id` | ✅ 可见 | 比你早起的人已经下班走了 → 你能看到 | -| **晚启动** | `trx_id >= max_trx_id` | ✅ 可见 | 比你晚来的人还没到办公室 → 你看不到他写的东西 | -| **已退出** | `trx_id ∉ m_ids` | ✅ 可见 | 名单上没有此人 → 他已经提交了 | -| **正活跃** | `trx_id ∈ m_ids` | ❌ 不可见 | 正在工位上坐着呢 → 别看他未完成的产出 | +| **自己改的** | `trx_id == creator_trx_id` | ✅ 可见 | 自己写的代码自己当然能看见 | +| **早提交** | `trx_id < min_trx_id` | ✅ 可见 | 比你早到的人已经提交下班了 → 你能看到他的产出 | +| **晚启动** | `trx_id >= max_trx_id` | ❌ 不可见 | 比你晚到的人还在加班 → 他的产出你还没资格看 | +| **已退出** | `trx_id ∉ m_ids` | ✅ 可见 | 名单上没有此人 → 说明他已经提交退出了 | +| **正活跃** | `trx_id ∈ m_ids` | ❌ 不可见 | 正在工位上坐着呢 → 别看他未完成的半成品 | > [!TIP] 判断顺序很重要 -> 实际代码中先比较 `min_trx_id` 和 `max_trx_id` 这两条边界,因为这是 O(1) 的比较操作。只有在落在两个边界之间时,才去扫描 m_ids 列表做成员检查。这种设计在高并发场景下能显著减少 CPU 开销。 +> 实际代码中先检查是否是自己改的(`trx_id == creator_trx_id`),然后比较 `min_trx_id` 和 `max_trx_id` 这两条边界,因为这些都是 O(1) 的比较操作。只有 trx_id 落在两个边界之间时,才去扫描 m_ids 列表做成员检查。这种分层判断设计在高并发场景下能显著减少 CPU 开销。 ## RC 与 RR 的关键区别 +> [!TIP] 一个生活化的比喻 +> 想象你在一家餐厅看菜单: +> - **RC(读已提交)** = 每次抬头看菜单,都是最新的版本。厨师中途改了价格,你下次看就能看到新价格。 +> - **RR(可重复读)** = 你坐下来那一刻菜单就「定格」了。不管厨师之后怎么改菜单,你这顿饭看到的永远是第一眼的版本。 + +核心差异就一句话:**ReadView 创建时机不同**。 + ```mermaid flowchart LR subgraph RC["RC: 每次 SELECT 新建 ReadView"] @@ -260,23 +276,30 @@ sequenceDiagram ```sql -- 会话 A(RR 模式,MySQL 默认) SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; -START TRANSACTION; +START TRANSACTION; -- 事务 T_A, trx_id = 500 SELECT * FROM accounts WHERE id = 1; --- 读到 balance = 500 +-- 第一次 SELECT → 创建 ReadView +-- m_ids = {500} (只有自己在跑) +-- min_trx_id = 500 +-- max_trx_id = 501 +-- 读到 balance = 500(行的 trx_id=400 < 500 → 早提交规则 → ✅ 可见) -- 会话 B -START TRANSACTION; +START TRANSACTION; -- 事务 T_B, trx_id = 600 UPDATE accounts SET balance = 1000 WHERE id = 1; -COMMIT; +COMMIT; -- 行的 trx_id 被更新为 600 -- 会话 A(同一个事务内再次查) SELECT * FROM accounts WHERE id = 1; --- 仍然读到 500!← ReadView 不让看 T_B 的改动 +-- RR 模式 → 复用第一次的 ReadView(m_ids={500}, max_trx_id=501) +-- 行的 trx_id=600 >= 501 → 晚启动规则 → ❌ 不可见 +-- 沿 ROLL_PTR 找到旧版本 trx_id=400 < 500 → ✅ 可见 +-- 所以仍然读到 balance = 500! -- 但如果用当前读呢? SELECT * FROM accounts WHERE id = 1 FOR UPDATE; --- 读到 1000 ← 当前读不走 MVCC,直接拿最新版本 +-- 当前读不走 MVCC,直接读磁盘上的最新行 → 读到 balance = 1000 ``` ## 关联笔记