📖 相关阅读 — MySQL 专题系列

本文是MySQL原理全景讲解(存储引擎、B+树、回表、事务、锁、Buffer Pool),为系列中最全面的理论篇。以下文件从不同实践角度覆盖相同话题:

MySQL 一条SQL的完整执行链路 应用程序 SELECT * FROM... MySQL Server (InnoDB) ① 解析器 SQL→AST语法树 ② 优化器 选索引/JOIN顺序 ③ 执行器 调用存储引擎接口 ④ Buffer Pool 内存缓存→命中? ⑤ 磁盘 (.ibd文件) B+树存储(未命中时读) B+ 树索引查找过程 Root: [10|50|100] Leaf: ≤10 Leaf: 11-50 Leaf: 51-100
一条SQL的5步执行链路:解析→优化→执行→Buffer Pool→磁盘。慢查询出在哪一步?看EXPLAIN的type/key/Extra

☕ MySQL 原理全景 — 从查字典搞懂数据库底层

不背八股文,用查字典的生活经验搞懂:存储引擎、B+树索引、回表、覆盖索引、事务隔离、锁

存储引擎 B+树索引 回表 & 覆盖索引 子查询原理 Filesort 事务 & 隔离级别 锁机制 Buffer Pool 10个实战场景
🚨 真实事故:一次缺少索引导致的系统瘫痪
时间:周五下午 3:47(最忙的时段)
现象:订单列表页面突然全部超时,用户疯狂投诉。Prometheus 告警:MySQL CPU 100%,慢查询数量暴涨 50 倍。
原因:一个新上线功能执行了 SELECT * FROM order_info WHERE DATE(create_time) = '2024-06-14'——在索引列上使用了函数,导致索引失效,全表扫描 2000 万行。
结果:临时加了 WHERE create_time >= '2024-06-14' AND create_time < '2024-06-15'(去掉函数),3 秒降到 12ms,立刻恢复正常。
教训:如果你不懂 B+ 树索引原理,你就不知道为什么 DATE(列) 会导致索引失效。这篇教程就是帮你从根上搞懂 MySQL 原理——不是背八股文,而是真正理解。
📍 这个真实故事贯穿全文:每一章都会回头看"为什么这个原理能解释这个事故"

📖 全文使用的示例数据表

后面所有的例子都围绕这几张表,你不需要每次重新理解业务:

mydb @ localhost:3306
-- 示例数据表(InnoDB引擎)
CREATE TABLE order_info (
  id INT PRIMARY KEY AUTO_INCREMENT,    -- 订单ID
  user_id INT NOT NULL,                -- 用户ID
  product_id INT NOT NULL,           -- 商品ID
  store_id INT NOT NULL,            -- 门店ID
  amount DECIMAL(10,2),           -- 金额
  status VARCHAR(20),              -- 状态
  created_at DATETIME,             -- 下单时间
  INDEX idx_user (user_id),        -- 用户索引
  INDEX idx_product (product_id),   -- 商品索引
  INDEX idx_created (created_at),    -- 时间索引
  INDEX idx_user_status (user_id, status) -- 联合索引
);

CREATE TABLE product (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100),             -- 商品名
  price DECIMAL(10,2),
  category VARCHAR(50),           -- 分类
  INDEX idx_category (category)
);

CREATE TABLE user (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(50),
  phone VARCHAR(20),
  level INT DEFAULT 1            -- 会员等级
);

CREATE TABLE store (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100),
  city VARCHAR(50),
  INDEX idx_city (city)
);
模拟数据
iduser_idproduct_idstore_idamountstatuscreated_at
11015118.00paid2025-06-01 10:30:00
21023222.00paid2025-06-01 11:00:00
31015118.00completed2025-06-02 09:15:00
41037135.00paid2025-06-02 14:00:00
51012315.00cancelled2025-06-03 16:30:00
61045218.00paid2025-06-03 17:00:00
71021112.00completed2025-06-04 10:00:00
81053222.00paid2025-06-04 13:20:00
91035118.00completed2025-06-05 08:45:00
101017335.00paid2025-06-05 11:30:00

🧱 第一章:存储引擎 — 数据的两种"存法"

想象一本厚厚的字典。字词的编排有两种方式:
方法A:每个字按拼音顺序排在正文里,旁边就是完整的解释(释义、例句、词性全都有)。想查哪个字,翻到那一页就全看到了。—— 这就是 InnoDB,数据按主键聚集存储。
方法B:字按录入顺序堆着,另附一个索引表查位置,解释和索引分开。—— 这就是 MyISAM,数据和索引分开存。

大部分字典(90%以上)都选方法A,为什么?往下看👇

📊 InnoDB vs MyISAM:一次看懂

特性InnoDB(默认)MyISAM(老引擎)生活类比
事务✅ 支持❌ 不支持查字过程要能"回退",MyISAM做不到
行级锁✅ 支持❌ 只有表锁两人同时查不同的字,InnoDB互不影响;MyISAM整本锁住
外键✅ 支持❌ 不支持每个词条必须属于某个部首
崩溃恢复✅ redolog❌ 可能丢数据突然停电,InnoDB能恢复到最后一秒
聚簇索引✅ 数据即索引索引和数据分离正文按拼音排列 vs 另附索引
全文索引✅ 5.6+支持✅ 原生支持搜索"包含某偏旁的字",MyISAM曾经更擅长
COUNT(*)要遍历索引直接存了总数InnoDB因为MVCC要数,MyISAM直接记住
🔑 关键结论:MySQL 5.5 之后默认引擎就是 InnoDB。你工作里 99% 用的都是 InnoDB,面试也主要问 InnoDB。MyISAM 知道它不支持事务就行了。

🖥️ 看看怎么查你的表用什么引擎

查看表的引擎
SHOW CREATE TABLE order_info\G
Create TableCREATE TABLE `order_info` (
  `id` int NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,
  `product_id` int NOT NULL,
  `store_id` int NOT NULL,
  `amount` decimal(10,2) DEFAULT NULL,
  `status` varchar(20) DEFAULT NULL,
  `created_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_user` (`user_id`),
  KEY `idx_product` (`product_id`),
  KEY `idx_created` (`created_at`),
  KEY `idx_user_status` (`user_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

📖 InnoDB 的"聚簇索引"到底是什么意思?

在 InnoDB 里,数据本身就是按主键排好的一棵B+树。这棵树叫"聚簇索引"(Clustered Index)。

类比:字典的正文,从第1页到第1000页,就是按 拼音a, b, c, ... 排好的。
"聚簇"的意思就是:数据的物理存储顺序 = 主键的排序顺序。

这意味着:如果你查 WHERE id=5,直接翻到第5页就拿到了,超级快。
但如果你查 WHERE user_id=101,怎么办?你没有按user_id排的订单本啊!—— 这就需要二级索引了,后面讲。
✅ 存储引擎搞懂了:InnoDB用B+树存数据 → 下面是核心:B+树索引怎么让你3毫秒找到2000万行中的某一条

📖 第二章:B+树索引 — 数据库怎么快速找到一条记录

🤔 先搞明白:没有索引会怎样?
假设 order_info 表有 100万行 数据,你要找 id = 500000 的订单。

没有索引:MySQL 从第1行开始,逐行对比 id,直到找到第50万行。
 → 最坏情况要看 100万行,时间复杂度 O(N)
 → 就像一本没有目录的字典,你要从第1页翻到第5000页

有索引:MySQL 用 B+树 结构,像一个"多级目录"。
 → 100万行的表,B+树大约只有 3层
 → 也就是说,最多翻 3页 就能找到任何一条数据!
 → 时间复杂度 O(log N) ≈ 找10亿条数据也只需要 4次翻页
📖 生活类比:B+树就像"字典的多级目录"
想象一本大字典有 8个正文区块,每个区块若干页。

第1层(部首目录):字典开头的部首总表,写着"一部→第10区,人部→第20区,口部→第30区"
第2层(检字表):每个部首下的检字表,写着"一画→第101页,二画→第103页"等
第3层(正文页面):正文里每个字的完整解释,每页底部写着"下续第XX页"(链表

你要查 "何"字(编号5):
 ① 翻部首目录 → "何"在人部,翻到人部
 ② 查检字表 → 5画,翻到5画那页
 ③ 翻到正文页 → 找到"何"字的完整解释!
整个过程只需要翻3次(部首目录→检字表→正文),不需要从第1页逐页翻。

📦 第一步:理解"数据页"(Page)

MySQL 不是一行一行存数据的,而是把数据放在 "页" 里。
一页 = 16KB(固定大小),就像一本固定厚度的笔记本。

每一页里可以放很多行数据。InnoDB 存储引擎的所有数据(索引 + 行数据)都存在页里面。
📄 什么是数据页(Page)?16KB/页
页头 (Header)
页号、页类型
上一页/下一页指针
行数统计
行数据 (Records)
一行行真实数据
按主键顺序排列
行之间用链表串起
页目录 (Page Dir)
页内的"小目录"
加速页内查找
类似书的章节索引
一个数据页(16KB) 大约能存 200-500行 数据(取决于行大小)
💡 关键概念:B+树的每个"节点"就是一个数据页。一个节点占16KB。
非叶子节点(目录页)只存 key + 指针,所以一页能放几百个指针,意味着树的分叉很多,树就很矮。

🌳 第二步:聚簇索引(主键索引)— 完整行数据存在叶子节点

聚簇索引 = 按主键(id)排列的 B+树。
它的叶子节点存的是完整的行数据(id, user_id, product_id, amount, status, created_at 全都有)。
一张表只能有一个聚簇索引(因为数据只能按一种方式物理排列)。

📖 类比:字典正文,按拼音排列,每个字有完整解释(读音、释义、例句全都有)。
第3层 — 叶子节点(存完整行数据)
📄 页A (叶子)
id=1
user=101, product=5, amount=18.00, status=paid
id=2
user=102, product=3, amount=22.00, status=paid
📄 页B (叶子)
id=3
user=101, product=5, amount=18.00, status=completed
id=4
user=103, product=7, amount=35.00, status=paid
📄 页C (叶子)
id=5
user=101, product=5, amount=18.00, status=completed
id=6
user=104, product=2, amount=12.00, status=paid
📄 页D (叶子)
id=7
user=102, product=5, amount=18.00, status=cancelled
id=8
user=105, product=3, amount=22.00, status=paid
页A页B页C页D ← 叶子节点之间双向链表连接,支持范围查询
⬆ ⬆ ⬆ ⬆
第2层 — 非叶子节点(只存 key + 页号指针)
📄 页E (目录)
≤3
→页F
≤6
→页G
这个非叶子节点存的是:key(分界值) + 指针(指向下一层的页号)
不存 amount、status 等数据!只存"目录信息"
第1层 — Root根节点(整棵树的入口)
本例简化为3层。实际上,百万行数据也只需3-4层!
因为一个非叶子节点只存 key+指针(很小),一页16KB能放几百个指针
✅ 你现在应该理解了
聚簇索引(主键索引)的叶子节点:存的是 完整的行数据(id + user_id + product_id + amount + status + created_at)
非叶子节点:只存 key(主键值) + 指针(子节点页号),不存实际数据
为什么 B+树矮?:非叶子节点只存很小的 key+指针,一页能放几百个,所以3-4层就能索引千万行

🚶 第三步:手把手走一遍 — SELECT * FROM order_info WHERE id = 5

1
读取 Root 根页
MySQL 从磁盘读取根页到内存。根页里存着:key≤3 → 页F, key≤6 → 页G
我们要找 id=5。5 > 3 所以不走页F。5 ≤ 6 所以走页G
类比:查部首目录 → "何"在人部
2
读取页G(非叶子节点)
页G里存着:key≤4 → 页B, key≤6 → 页C
5 > 4 所以不走页B。5 ≤ 6 所以走页C
类比:查检字表 → 5画在第103页
3
读取页C(叶子节点)— 找到了!
页C是叶子节点,里面存着完整的行数据:
id=5 user=101, product=5, amount=18.00, status=completed
只读了3个页,就找到了 id=5 的全部数据!
类比:翻到正文页 → 看到"何"的完整解释
💡 重点:不管表有8行还是800万行,B+树的层数都是3-4层。也就是说,找任何一条数据都只需要3-4次磁盘IO。这就是B+树牛的地方。

🗂️ 第四步:二级索引 — 按非主键字段查找

现实中的查询大多不是按主键id查的。比如:
 • SELECT * FROM order_info WHERE user_id = 101 ← 按 user_id 查
 • SELECT * FROM order_info WHERE amount > 20 ← 按 amount 查

但主键索引是按 id 排列的,按 user_id 查怎么办?

答案:MySQL 会为 user_id 单独建一棵 B+树,这棵树按 user_id 排序。
但这棵树的叶子节点不存完整行数据,只存 user_id + 主键id

📖 类比:你做了一本按用户编号排列的小册子,每页写着"用户101 → 订单1号"、"用户101 → 订单3号"。
你用这本小册子查到用户101下了哪些订单号,然后拿着这些订单号,回到主订单本去取完整信息。

🔍 第五步:二级索引 idx_user 的 B+树 — 放大看存了什么

🌳 聚簇索引(主键索引)
按 id 排列
叶子节点存的:
叶子节点
id=1
user=101, prod=5, amt=18, status=paid, time=...
id=2
user=102, prod=3, amt=15, status=paid, time=...
→ 存的是完整行数据
→ amount、status、created_at 全都有
🌳 二级索引 idx_user(user_id索引)
按 user_id 排列
叶子节点存的:
叶子节点
user=101
→ id=1
user=101
→ id=3
user=101
→ id=5
user=102
→ id=2
→ 只存 user_id + 主键id
amount、status、created_at 都没有!
🔴 核心区别(一定要记住)
聚簇索引的叶子节点:存 完整行数据(所有列都有)→ 找到就等于拿到全部信息
二级索引的叶子节点:只存 索引列值 + 主键(其他列都没有)→ 找到之后还要去聚簇索引拿完整数据

这个"去聚簇索引拿完整数据"的动作,就叫 回表

📐 二级索引 idx_user 的完整 B+树结构

idx_user 索引树(按 user_id 排列)
≤102
≤104
↓                       ↓
≤101
 
≤102
≤103
 
≤105
↓       ↓         ↓         ↓
101→id1
101→id3
101→id5
102→id2
102→id7
103→id4
104→id6
105→id8
💡 注意看:二级索引的叶子节点里只有 user_id → id 这两个值。如果你要查 amountstatus,这个树里根本没有这些信息!必须拿着 id 回到聚簇索引那棵树去找。

🔄 第六步:回表的完整过程 — 两棵树配合工作

查询:SELECT * FROM order_info WHERE user_id = 101

这个查询需要两棵树配合:
1
在二级索引树(idx_user)里找 user_id=101
从根节点开始 → 沿着指针往下走 → 到达叶子节点
找到 3 条记录:user_id=101 → id=1, user_id=101 → id=3, user_id=101 → id=5
2
拿着 id=1 回到聚簇索引树
从聚簇索引根节点 → 沿着指针走 → 找到 id=1 的叶子节点
拿到完整数据:id=1, user=101, product=5, amount=18.00, status=paid, time=...
3
拿着 id=3 回到聚簇索引树
同样从根节点走 → 找到 id=3 的完整数据:id=3, user=101, product=5, amount=18.00, status=completed
4
拿着 id=5 回到聚簇索引树
同样从根节点走 → 找到 id=5 的完整数据:id=5, user=101, product=5, amount=18.00, status=completed
回表的代价:匹配 N 条 → 回表 N 次
idx_user找3条 回表×3 3条完整数据
⚠️ 如果 user_id=101 匹配了 10000条,就要回表 10000次!这时候可能还不如全表扫描快。
💡 回表代价公式:回表次数 = 二级索引匹配的行数。
如果二级索引过滤性不好(匹配太多行),MySQL 可能直接放弃索引,走全表扫描。
这就是为什么 EXPLAIN 有时候显示 type=ALL 即使你建了索引 —— MySQL 觉得回表代价太大,不如直接扫。

📐 B+树 vs B树 vs Hash(面试必问对比)

B+树(InnoDB使用)
✅ 数据只在叶子节点
✅ 非叶子只存 key+指针 → 一页放更多 → 树更矮
✅ 叶子节点有链表 → 范围查询快
✅ 每次查找路径长度一样(稳定)
✅ 天然有序 → 支持 ORDER BY

100万行 → 树高约3层
10亿行 → 树高约4层
B树
每个节点都可能存数据
❌ 非叶子也存数据 → 一页放更少 → 树更高
❌ 没有链表 → 范围查询要中序遍历
⚠️ 有时快(数据在根节点就找到了)
⚠️ 但不稳定(有时3层有时1层)

面试答法:InnoDB选B+树因为
①中间节点不存数据,扇出大
②叶子链表支持范围查

🖥️ 实战:用 EXPLAIN 看看索引有没有被用上

走了索引的情况
EXPLAIN SELECT * FROM order_info WHERE user_id = 101;
typekeyrowsExtra
ref idx_user 4 NULL
没走索引的情况
EXPLAIN SELECT * FROM order_info WHERE amount > 20;
typekeyrowsExtra
ALL NULL 10 Using where
amount 字段没有索引,MySQL 只能从第1行翻到最后一行,看每一行的 amount 是否 > 20。

🔄 第三章:回表 & 覆盖索引 — 多走一步 vs 一步到位

回表:你先在"部首检字表"(二级索引)里找到 user_id=101 对应的主键 id=1,3,5,10。然后拿着这些id,回到"字典正文"(聚簇索引)去取完整数据。这就是回表。

覆盖索引:如果你的查询只需要 user_id 和 id,那在"部首检字表"里就已经拿到了,不用再翻正文!—— 这就是覆盖索引,"索引已经覆盖了你要查的所有字段"。

类比:你只想知道"有多少个字属于人部"。部首检字表上已经列了所有人部字的页码,数一下就行,不用翻正文看解释。

📊 回表过程可视化 — 两棵树怎么配合

SELECT * FROM order_info WHERE user_id = 101
🌳 二级索引树 idx_user
根:user_id ≤ 102 | ≤ 104
≤101 | ≤102 | ≤103
101→id1
101→id3
101→id5
← 找到3条匹配
拿id
回表 ↓
🌳 聚簇索引树(主键)
根:id ≤ 3 | ≤ 6
≤2 | ≤4 | ≤6
id=1 ✅ 全部字段
id=3 ✅ 全部字段
id=5 ✅ 全部字段
← 每次回表走一遍B+树
总结:3条匹配 = 1次索引查找 + 3次回表 = 4次B+树遍历。匹配越多行,回表越慢!

✅ 覆盖索引:不用回表

覆盖索引示例
-- 查询只选了 user_id 和 id(二级索引里都有)
EXPLAIN SELECT user_id, id FROM order_info WHERE user_id = 101;
typekeyrowsExtra
ref idx_user 4 Using index
对比:需要回表的情况
-- 查询选了 amount,索引里没有这个字段
EXPLAIN SELECT user_id, id, amount FROM order_info WHERE user_id = 101;
typekeyrowsExtra
ref idx_user 4 NULL

📖 什么是联合索引?

联合索引就是 在多个列上建一个索引,而不是每个列各建一个。

之前我们用的是单列索引:INDEX idx_user (user_id) — 只按 user_id 一列建索引。
联合索引是:INDEX idx_user_status (user_id, status) — 按 user_id + status 两列一起建索引。

类比:单列索引像"只按姓氏查"的通讯录;联合索引像"先按部门,再按姓名"排的两级通讯录。
建立联合索引
-- 建表时直接声明(上面建表语句里已有)
INDEX idx_user_status (user_id, status)

-- 或者对已有表单独添加
CREATE INDEX idx_user_status ON order_info (user_id, status);
⚠️ 关键问题:联合索引的B+树叶子节点,到底存了什么?
这是理解"覆盖索引"和"最左前缀"的核心!让我画给你看👇

🌳 联合索引 B+树 叶子节点里存了什么?

联合索引 idx_user_status (user_id, status) 的 B+树结构
📚 排序规则(先排第一列,第一列相同的再排第二列)
排序顺序:user_id ASC, status ASC
也就是:先按 user_id 从小到大排,user_id 相同的 再按 status 字母序排
类比:电话簿先按姓排,同姓再按名排 → (张,三) (张,四) (李,四) (王,五)
📋 原始数据(聚簇索引/字典正文里的完整行)
id=1: user_id=101, status='paid', amount=25.00, product_id=1
id=2: user_id=102, status='pending', amount=30.00, product_id=2
id=3: user_id=101, status='completed', amount=18.00, product_id=3
id=4: user_id=103, status='paid', amount=45.00, product_id=4
id=5: user_id=101, status='pending', amount=22.00, product_id=5
id=6: user_id=102, status='completed', amount=35.00, product_id=1
🔥 联合索引叶子节点 = 只存索引列 + 主键(不存其他列!)
user_id(第1列)status(第2列)id(主键)行指针
101completed3
101paid1
101pending5
102completed6
102pending2
103paid4
⚠️ 注意看:没有 amount!没有 product_id!没有 created_at!
联合索引只存了 user_id + status + id 这三样东西。
🔑 所以:为什么 SELECT * 会回表?
因为 SELECT * 要取 全部字段(amount, product_id, created_at...),但联合索引里 只有 user_id, status, id
缺少的字段只能拿着 id → 回到聚簇索引树 → 取完整行数据。这就是回表。

🔥 为什么联合索引能"覆盖"——不用回表?

覆盖索引的本质:你的 SELECT 只需要 user_id, status, id 这几列 → 联合索引的叶子节点 全部都有 → 不用去聚簇索引取数据 → 不用回表!
❌ 需要回表
SELECT user_id, amount
FROM order_info
WHERE user_id = 101;
需要取: user_id, amount
索引里有: user_id
索引里有: amount ❌ 没有!
→ 拿着 id 去聚簇索引取 amount
→ 这就是回表
✅ 覆盖索引,不用回表!
SELECT user_id, status
FROM order_info
WHERE user_id = 101;
需要取: user_id, status
索引里有: user_id
索引里有: status
→ 全都在索引里!直接返回
不回表! Using index 🎉
验证:覆盖索引
-- 只查 user_id 和 status(都在联合索引里)
EXPLAIN SELECT user_id, status FROM order_info WHERE user_id = 101;
typekeyrowsExtra
ref idx_user_status 4 Using index
验证:需要回表(对比)
-- 加了 amount(索引里没有这个字段)
EXPLAIN SELECT user_id, status, amount FROM order_info WHERE user_id = 101;
typekeyrowsExtra
ref idx_user_status 4 NULL

📝 一张表搞懂:查什么字段,会不会回表?

联合索引 idx_user_status (user_id, status) → 叶子存了:user_id + status + id
SQL语句需要取的字段索引里有?结果
SELECT id FROM order_info WHERE user_id=101 id ✅ 全有 Using index 覆盖
SELECT user_id, status FROM order_info WHERE user_id=101 user_id, status ✅ 全有 Using index 覆盖
SELECT user_id, status, id FROM order_info WHERE user_id=101 user_id, status, id ✅ 全有 Using index 覆盖
SELECT user_id, amount FROM order_info WHERE user_id=101 user_id, amount ❌ 缺 amount 回表!
SELECT * FROM order_info WHERE user_id=101 全部字段 ❌ 缺很多 回表!
💡 判断方法很简单:看你的 SELECT 后面要取哪些字段 → 去联合索引的叶子节点里找 → 全找得到 = 覆盖索引 → 有找不到的 = 回表。
💡 覆盖索引的实战意义:如果你发现某个高频查询总是回表,可以考虑建一个联合索引,让索引"覆盖"查询需要的所有字段。这样查询只需要扫索引树,不碰数据行,速度能快好几倍。

举个例子:首页总是 SELECT user_id, status FROM order_info WHERE user_id = ?
→ 这个查询只需要 user_id 和 status → 联合索引 idx_user_status 里就有 → 不回表!
→ 如果没有联合索引,只有单列索引 idx_user,那 status 不在索引里 → 回表!

📖 索引下推(Index Condition Pushdown, ICP)

MySQL 5.6 之后有个优化叫"索引下推"。举个例子:

联合索引 idx_user_status (user_id, status)
查询:SELECT * FROM order_info WHERE user_id = 101 AND status LIKE 'comp%'

没有ICP:在索引里找到所有 user_id=101 的记录(4条),全部回表,再在回表后的数据上过滤 status LIKE 'comp%'。
有ICP:在索引里找 user_id=101 的同时,直接在索引里判断 status LIKE 'comp%',只有满足条件的才回表(可能只剩1条要回表了)。

类比:你在检字表里查某个部首时,顺便看看笔画数那一列,把笔画不对的直接跳过,不用白白翻正文。
ICP 效果
EXPLAIN SELECT * FROM order_info WHERE user_id = 101 AND status LIKE 'comp%';
typekeyrowsExtra
range idx_user_status 2 Using index condition

🔀 第四章:子查询原理 — 为什么子查询有时候很慢?

子查询就是 SQL 里套 SQL:SELECT * FROM A WHERE id IN (SELECT id FROM B)

很多人以为子查询和 JOIN 是一样的,其实执行方式完全不同
理解子查询的执行方式,是避免慢查询的关键。

📊 子查询的两种执行方式

❌ 相关子查询(Dependent Subquery)— 通常很慢
SELECT * FROM order_info o WHERE amount > ( SELECT AVG(amount) FROM order_info WHERE user_id = o.user_id );
执行过程
1. 外层取第1行 order_info → 拿到 user_id=101
2. 执行内层:SELECT AVG(amount) ... WHERE user_id=101
3. 外层取第2行 → 拿到 user_id=102
4. 再执行内层:SELECT AVG(amount) ... WHERE user_id=102
5. ... 重复 N 次!
外层有多少行,内层就执行多少次!外层10000行 → 内层执行10000次!
✅ 非相关子查询 — 通常较快
SELECT * FROM order_info WHERE user_id IN ( SELECT id FROM user WHERE status = 'active' );
执行过程
1. 先执行内层:SELECT id FROM user WHERE status='active'
  → 结果集 (101, 103, 105)
2. 再执行外层:SELECT * FROM order_info WHERE user_id IN (101,103,105)
内层只执行一次!
内层独立执行一次,结果传给外层。效率高得多!

🔑 怎么判断是哪种子查询?

判断标准:内层有没有引用外层的字段?

内层引用了 o.user_id(来自外层)→ 相关子查询(慢)
内层完全独立,不依赖外层 → 非相关子查询(快)

EXPLAIN 里看到 Select_type: DEPENDENT SUBQUERY 就是相关子查询,要警惕!

🔄 子查询 → JOIN 改写(最常见的优化手法)

很多慢子查询可以改写为 JOIN,让 MySQL 优化器自由选择执行顺序,性能往往好很多。
慢子查询改写为 JOIN效果
SELECT * FROM order_info
WHERE user_id IN (
  SELECT id FROM user
  WHERE status='active'
);
SELECT o.*
FROM order_info o
JOIN user u ON o.user_id = u.id
WHERE u.status='active';
优化器可选择驱动表
性能更好
SELECT * FROM order_info o
WHERE EXISTS (
  SELECT 1 FROM user
  WHERE id = o.user_id
  AND status='active'
);
SELECT o.*
FROM order_info o
JOIN user u ON o.user_id = u.id
WHERE u.status='active';
同上,改写后更灵活
SELECT * FROM order_info
WHERE user_id NOT IN (
  SELECT id FROM user
);
SELECT o.*
FROM order_info o
LEFT JOIN user u
  ON o.user_id = u.id
WHERE u.id IS NULL;
NOT IN → LEFT JOIN
避免 NOT IN 的 NULL 陷阱

⚠️ 子查询常见的坑

坑1:NOT IN 遇到 NULL
-- 如果子查询结果里有 NULL SELECT * FROM order_info WHERE user_id NOT IN (101, NULL, 103); -- 结果:空!一条都没有! -- 因为 user_id != NULL 的结果是 NULL(不是true) -- 整个 NOT IN 条件变成 NULL → 全部不匹配
解决:用 LEFT JOIN ... IS NULL 代替,或在子查询里加 WHERE id IS NOT NULL
坑2:IN 子查询结果集太大
-- 如果子查询返回10万条ID SELECT * FROM order_info WHERE user_id IN ( SELECT id FROM big_table WHERE create_time > '2026-01-01' ); -- MySQL 可能不走索引 -- 而是把10万个ID逐个匹配
解决:改写为 JOIN,让优化器选择最优执行计划
坑3:EXISTS vs IN 选错
子查询结果集小 → 用 IN(先执行子查询)
外层表小 → 用 EXISTS(先遍历外层)
不确定 → 用 JOIN(优化器自动选)

🖥️ 实战:EXPLAIN 识别子查询类型

查看子查询执行计划
EXPLAIN SELECT * FROM order_info WHERE user_id IN (SELECT id FROM user WHERE status = 'active');
idselect_typetabletypekeyrowsExtra
1 SIMPLE u ref idx_status 50 Using index
1 SIMPLE o ref idx_user_id 10 NULL
子查询实战总结
① 看到 DEPENDENT SUBQUERY → 警惕,可能需要改写为 JOIN
② NOT IN 小心 NULL → 改用 LEFT JOIN ... IS NULL
③ MySQL 8.0 优化器很聪明,很多子查询会自动优化为 SEMI JOIN
④ 不确定性能时,用 EXPLAIN 看执行计划,对比子查询和 JOIN 的 rows 差异
🧠 子查询在 MySQL 内部是怎么跑的?(内存 vs 磁盘)
前面讲了"子查询分两种"和"改写为 JOIN"。但你可能还是纳闷:子查询的结果到底存在哪里?内存还是磁盘?

这就是子查询的物化(Materialization)机制。理解了它,你就能明白为什么有的子查询快、有的慢、有的直接报错。
类比:你帮同事查资料,查到后要把结果写下来交给同事。
  · 结果少(比如3个ID)→ 写在便利贴上(内存),秒交
  · 结果多(比如10万条)→ 写在A4纸上(磁盘临时表),慢一些
  · 结果特别多(比如500万条)→ A4纸写不下,可能直接撑爆

MySQL 也是一样:子查询结果少→放内存;结果多→写磁盘;太多→报错或巨慢。
📦 一、物化(Materialization)— 子查询结果的"暂存区"
MySQL 执行非相关子查询时,一般分 两步

第1步:先执行内层子查询,把结果存起来(这个过程叫"物化")
第2步:外层查询去这个"暂存区"里找数据

关键问题:这个暂存区在内存还是磁盘?
✅ 结果小 → 放内存(快速)
条件:子查询结果 < tmp_table_size(默认16MB)
存储方式:MySQL 在内存中创建一个内部临时表
数据结构:默认用哈希表(查找 O(1),极快)

例子
-- 内层返回3个ID → 放内存哈希表 SELECT * FROM order_info WHERE user_id IN ( SELECT id FROM user WHERE status = 'active' ); -- 内层结果:(101, 103, 105) -- MySQL 在内存中建哈希表:{101, 103, 105} -- 外层每取一行 order_info,查哈希表 O(1) → 极快
内存哈希查找:外层每行都只要 O(1) 就能判断"在不在里面"
⚠️ 结果大 → 写磁盘(变慢)
条件:子查询结果 > tmp_table_size(默认16MB)
存储方式:内存装不下 → 写到磁盘临时表(InnoDB 或 MyISAM)
数据结构:从哈希表变成B+树(查找 O(logN),慢不少)

例子
-- 内层返回10万个ID → 内存装不下 → 写磁盘 SELECT * FROM order_info WHERE user_id IN ( SELECT user_id FROM big_table WHERE create_time > '2025-01-01' ); -- 内层结果:10万条 → 超过 tmp_table_size -- MySQL 在磁盘创建临时表(B+树索引) -- 外层每行查 B+树 → 比内存哈希慢 10-100 倍
内存→磁盘的转换是自动的,但对性能影响巨大!
判断方法:EXPLAIN 的 Extra 列看到 Using temporary 就说明用了临时表。
如果想确认是内存还是磁盘,执行后查:SHOW STATUS LIKE 'Created_tmp%';
SHOW STATUS LIKE 'Created_tmp%'; -- 结果: -- Created_tmp_disk_tables: 2 ← 写磁盘临时表的次数(慢!) -- Created_tmp_tables: 15 ← 创建临时表的总次数(含内存) -- -- 如果 Created_tmp_disk_tables 很大 → 说明 tmp_table_size 不够,需要调大
📊 二、三种物化策略对比
策略结果大小存放位置数据结构查找速度什么时候触发
内存物化 < tmp_table_size
(默认16MB)
内存 哈希表 O(1) 极快 IN 子查询结果集小
(几百~几千条)
磁盘物化 > tmp_table_size 磁盘临时表
(/tmp 目录)
B+树 O(logN) 较慢 IN 子查询结果集大
(几万~几十万条)
不物化
(相关子查询)
无暂存 不存!每行重新查 O(N×M)
极慢
相关子查询
(DEPENDENT SUBQUERY)
🔄 三、非相关子查询的完整执行流程图
第①步:执行内层子查询
第②步:结果物化
  · 结果 < 16MB → 内存哈希表 ✅
  · 结果 > 16MB → 磁盘临时表(B+树)⚠️
第③步:外层查询
  · 扫描 order_info 的每一行
  · 对每行 user_id,去"暂存区"查找
  · 内存哈希 → O(1) 秒查
  · 磁盘B+树 → O(logN) 稍慢
第④步:匹配的行返回给客户端
🚨 四、数据量大时会发生什么?
💀 场景1:子查询结果撑爆 tmp_table_size
-- 500万条活跃用户 SELECT * FROM order_info WHERE user_id IN ( SELECT id FROM user WHERE create_time > '2024-01-01' ); -- 内层返回 500万 个 ID -- 每个 ID 8 字节 → 40MB → 超过 tmp_table_size(16MB) -- -- MySQL 自动切换到磁盘临时表: -- 1. 在 /tmp 目录创建临时文件(#sql_xxxx.MYD) -- 2. 把 500万 个 ID 写入磁盘 B+树 -- 3. 外层每行都去磁盘查 B+树 -- -- 耗时:从 0.05s → 3-10s(慢 60-200 倍)
解决:改写为 JOIN,让优化器选择更优的执行计划
⚠️ 场景2:磁盘临时表占满 /tmp
-- 如果磁盘临时表文件很大 -- /tmp/#sql_xxxx.MYD → 2GB -- 多个慢查询同时跑 → /tmp 目录被占满 -- 报错:ERROR 126 (ER_TMP_TABLE): -- "Incorrect key file for table '/tmp/#sql_xxxx.MYI'" -- -- 此时整个数据库响应变慢,甚至影响其他查询
解决:调大 tmp_table_sizemax_heap_table_size,或改写为 JOIN
🔁 场景3:相关子查询 → 无物化 → 逐行扫描
-- 相关子查询:根本不会物化! SELECT * FROM order_info o WHERE amount > ( SELECT AVG(amount) FROM order_info WHERE user_id = o.user_id ); -- -- 执行过程(无暂存区!): -- 外层第1行 → 执行内层 → 算 AVG -- 外层第2行 → 执行内层 → 算 AVG -- 外层第3行 → 执行内层 → 算 AVG -- ... 重复 10万次! -- -- 每次内层都是一次完整的索引查找+计算 -- 10万行 × 每次0.1ms = 10秒
解决:改写为 JOIN + GROUP BY,AVG 只算一次
🛠️ 五、怎么调优?关键参数
-- 查看当前临时表相关参数 SHOW VARIABLES LIKE 'tmp_table_size'; -- 默认 16MB(内存临时表上限) SHOW VARIABLES LIKE 'max_heap_table_size'; -- 默认 16MB(MEMORY表上限) SHOW VARIABLES LIKE 'internal_tmp_disk_storage_engine'; -- MySQL 8.0: InnoDB -- 调大内存临时表上限(让更多子查询结果留在内存) SET GLOBAL tmp_table_size = 64 * 1024 * 1024; -- 16MB → 64MB SET GLOBAL max_heap_table_size = 64 * 1024 * 1024; -- 同步调大 -- 查看临时表使用情况 SHOW STATUS LIKE 'Created_tmp_disk_tables'; -- 写磁盘次数(越小越好) SHOW STATUS LIKE 'Created_tmp_tables'; -- 总临时表次数
调优经验
tmp_table_sizemax_heap_table_size 必须同时设置,取较小的那个生效
② 一般设 64MB~256MB 就够了,不建议太大(占用太多内存)
③ 如果 Created_tmp_disk_tables / Created_tmp_tables > 30% → 说明磁盘临时表太多,需要调大参数或优化SQL
最根本的解决方案:把大子查询改写为 JOIN,减少临时表的使用
📋 六、MySQL 8.0 的优化器做了什么?
MySQL 8.0 的优化器比老版本聪明很多,会自动对子查询做以下优化:
优化策略触发条件做了什么EXPLAIN 特征
物化 + 半连接
(Semi-Join)
IN 子查询
非相关
把子查询结果物化后,
与外层做半连接(找到匹配就停,不继续)
select_type = SIMPLE
Extra 可见 FirstMatch
或 LooseScan
首匹配
(FirstMatch)
IN 子查询
外层有重复值
外层遇到第一个匹配就停,
不继续找后面的
Extra: FirstMatch(o)
松散扫描
(LooseScan)
IN 子查询
子查询有重复值
跳过子查询中重复的值,
减少外层匹配次数
Extra: LooseScan
重复物化
(Duplicate Weedout)
IN 子查询
可能产生重复
先做 JOIN,
再用临时表去重
Extra: Using temporary;
Start temporary
-- MySQL 8.0 自动优化演示 EXPLAIN SELECT * FROM order_info WHERE user_id IN (SELECT id FROM user WHERE status = 'active'); -- MySQL 5.7 的 EXPLAIN 结果(子查询是独立步骤): -- +----+-------------+-------+...+ select_type | Extra -- | 1 | PRIMARY | o |...| PRIMARY | Using where -- | 2 | SUBQUERY | u |...| SUBQUERY | Using index -- -- MySQL 8.0 的 EXPLAIN 结果(优化器重写为 JOIN!): -- +----+-------------+-------+...+ select_type | Extra -- | 1 | SIMPLE | u |...| SIMPLE | Using index -- | 1 | SIMPLE | o |...| SIMPLE | Using where; FirstMatch(u) -- -- 注意:select_type 从 PRIMARY+SUBQUERY 变成了两个 SIMPLE -- 优化器自动把子查询改写为 Semi-Join,性能提升很大!
子查询内部原理总结
① 非相关子查询 → 先物化(存结果),再匹配。结果小放内存(哈希),大放磁盘(B+树)
② 相关子查询 → 不物化!外层每行都重新执行内层,极慢
tmp_table_size 决定了内存临时表的上限(默认16MB),超过就写磁盘
④ 用 SHOW STATUS LIKE 'Created_tmp%' 监控临时表使用情况
⑤ MySQL 8.0 优化器会自动把 IN 子查询改写为 Semi-Join,性能提升很大
最根本的优化:大子查询改写为 JOIN,让优化器灵活选择执行计划

📂 第五章:Filesort — 排序的代价

你让 MySQL 按 created_at 排序。两种情况:

情况A(走索引排序):created_at 上有索引,B+树本身就是按 created_at 排好的,直接按顺序读出来就行了。就像字典正文已经按拼音排好了。

情况B(Filesort):你要按 amount 排序,但 amount 上没有索引。MySQL 只能先把所有数据捞出来,在内存里(或磁盘上)做一次排序。就像你拿到一堆乱序的卡片,在桌上重新排列。

Filesort 不一定慢(小数据量在内存里排很快),但数据量大时 Filesort 会很恐怖——要写临时文件到磁盘,IO爆炸。

✅ 走索引排序(没有 Filesort)

ORDER BY 走索引
EXPLAIN SELECT * FROM order_info ORDER BY created_at LIMIT 5;
typekeyrowsExtra
index idx_created 5 NULL
💡 因为 idx_created 的B+树叶子节点已经是按 created_at 排好的,直接顺序读就行。

❌ Filesort:排序字段没有索引

ORDER BY 触发 Filesort
EXPLAIN SELECT * FROM order_info ORDER BY amount LIMIT 5;
typekeyrowsExtra
ALL NULL 10 Using filesort

📖 Filesort 的两种排序算法

双路排序(老版本)
第一次读:取出排序字段 + 主键 → 排序
第二次读:按排序后的主键回表取完整数据
→ 两次IO,老了去了

单路排序(新版本默认)
一次性取出所有需要的字段 → 在 sort_buffer 里排序
→ 一次IO,但 sort_buffer 放不下就写磁盘,更恐怖

类比:双路排序像"先按笔画把所有字的页码排好,再逐页翻过去取解释"。单路排序像"一次性把所有页的解释都抄出来,铺在桌上排序"。如果桌子(sort_buffer)不够大,只能分批搬到隔壁房间(磁盘临时文件)。
面试速答:"InnoDB和MyISAM区别" → InnoDB支持事务/行锁/外键,MyISAM只有表锁。现在默认InnoDB,MyISAM基本淘汰。
⚠️ sort_buffer 不够用怎么办?
MySQL 的 sort_buffer_size 默认 256KB。如果要排序的数据超过这个大小,就会分批排:
① 在 sort_buffer 里排一批 → 写入临时文件
② 再排下一批 → 写入临时文件
③ 最后对所有临时文件做归并排序
→ 磁盘IO 爆炸!

实战建议:与其调大 sort_buffer_size,不如让 ORDER BY 走索引(避免 Filesort)。

📋 什么时候 ORDER BY 能走索引?

SQL能走索引?原因
ORDER BY created_atidx_created 索引天然有序
ORDER BY created_at DESCB+树叶子可以反向扫描
WHERE user_id=101 ORDER BY statusidx_user_status 联合索引,user_id等值后status有序
ORDER BY amountamount没有索引
ORDER BY user_id, created_at没有这个联合索引
WHERE status='paid' ORDER BY created_at索引是(user_id,status),status不是最左列
ORDER BY user_id DESC, status ASC排序方向不一致

🖥️ 工作场景:发现 ORDER BY 慢查询

# 线上收到慢查询告警
mysql> SHOW STATUS LIKE 'Sort_merge_passes';
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Sort_merge_passes | 127   | ← 🔥 归并排序127次!sort_buffer严重不够
+-------------------+-------+
Sort_merge_passes > 0 说明 Filesort 溢出到磁盘了。解决方案不是调大 sort_buffer,而是让 ORDER BY 走索引。

🖥️ Java 中避免 Filesort 的实践

Filesort 的本质是排序没走索引。在 Java 开发中,最常见的场景就是"列表排序"。
// ========= 场景:订单列表按时间倒序 ========= // ❌ 错误:在 Java 内存里排序(数据量大时 OOM) @Select("SELECT * FROM order_info WHERE user_id = #{userId}") List findByUserId(@Param("userId") Long userId); // Java端排序: // orders.sort(Comparator.comparing(Order::getCreatedAt).reversed()); // 问题:如果有10万条 → 全部加载到内存 → 再排序 → OOM风险! // ✅ 正确:让 MySQL 排序(利用索引避免 Filesort) // 1. 建索引:CREATE INDEX idx_user_time ON order_info(user_id, created_at); // 2. SQL 加 ORDER BY: @Select("SELECT * FROM order_info " + "WHERE user_id = #{userId} " + "ORDER BY created_at DESC") List findByUserIdOrderByTime(@Param("userId") Long userId); // MySQL 用 idx_user_time: WHERE 用 user_id, ORDER BY 用 created_at // 叶子节点按 created_at 有序 → 不需要 filesort ✅ // ========= 另一个场景:分页排序 ========= // PageHelper 自动处理 LIMIT + ORDER BY @GetMapping("/orders") public Result<PageInfo<OrderVO>> list( @RequestParam Long userId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "created_at") String sort, @RequestParam(defaultValue = "desc") String order) { // ⚠️ 注意 sort/order 参数不能用 ${} 直接拼! // 要用白名单校验 List<String> allowedSorts = List.of("created_at", "amount", "id"); List<String> allowedOrders = List.of("asc", "desc"); if (!allowedSorts.contains(sort) || !allowedOrders.contains(order)) { throw new BusinessException("不支持的排序字段"); } // 白名单校验后才能用 ${} PageHelper.startPage(page, size); List<OrderVO> list = orderMapper .listSorted(userId, sort, order); return Result.success(new PageInfo<>(list)); } // Mapper: // @Select("<script>" // + "SELECT * FROM order_info " // + "WHERE user_id = #{userId} " // + "ORDER BY ${sort} ${order}" // + "</script>") // List<OrderVO> listSorted( // @Param("userId") Long userId, // @Param("sort") String sort, // @Param("order") String order);

🔒 第六章:事务 & 隔离级别 — "提交事务"流程

一个典型的交易流程:
① 选择商品② 付款③ 扣库存④ 生成订单

这4步要么全部成功,要么全部失败。付了钱但库存没扣?扣了库存但订单没生成?都不行!

这就是 事务(Transaction):一组操作,要成功全成功,要失败全失败。
用 MySQL 的话说就是 ACID
A(原子性):要么全做,要么不做 —— undo log 保证
C(一致性):数据从一个正确状态变到另一个正确状态 —— 由A/I/D共同保证
I(隔离性):多个事务互相不干扰 —— 锁 + MVCC 保证
D(持久性):提交了就不会丢 —— redo log 保证

📊 四种隔离级别

假设两个用户同时操作数据库(两个事务并发执行):

READ UNCOMMITTED(读未提交)
事务A刚改了某行数据(还没提交),事务B立刻看到了新值。万一事务A回滚了?事务B看到的就是"脏数据"。—— 脏读

READ COMMITTED(读已提交)
只有事务A提交了,事务B才能看到新值。但事务A改了两次,事务B两次读到的值不一样。—— 不可重复读

REPEATABLE READ(可重复读,InnoDB默认)
事务B在整个事务期间,不管事务A怎么改,读到的都是同一个值。—— InnoDB 还额外防止了幻读(通过 MVCC + 间隙锁)

SERIALIZABLE(串行化)
所有事务排队一个一个来。最安全,但最慢。
隔离级别脏读不可重复读幻读性能InnoDB默认
READ UNCOMMITTED可能可能可能最快
READ COMMITTED不会可能可能
REPEATABLE READ不会不会不会*中等✅ 默认
SERIALIZABLE不会不会不会最慢
* InnoDB 的 RR 级别通过 MVCC + 间隙锁在很大程度防止了幻读,但某些特殊场景下仍可能出现。

📖 MVCC 是什么?(多版本并发控制)

MVCC = 让读写互不阻塞的秘密武器。

类比:数据库的每一行数据都有当前版本历史版本(通过 undo log 链保存)。

• 写事务:直接修改当前数据,但旧版本通过 undo log 保留着
• 读事务:看到的是事务开始时刻的"快照",不管当前数据怎么被别人改

每行数据都藏着两个隐藏列:
trx_id:最后修改这行的事务ID
roll_pointer:指向这行的上一个版本(undo log链)

读的时候,如果发现当前版本的事务ID > 自己的事务ID(说明是别人改的),就顺着 roll_pointer 往前找,找到一个"自己能看到"的版本。
MVCC 版本链示意(读取 amount 的过程)
当前版本
amount=35
trx_id=108
← roll_pointer 历史版本1
amount=22
trx_id=105
← roll_pointer 历史版本2
amount=18
trx_id=100
事务102来读时:当前trx_id=108 > 102,不可见 → 找到历史版本1 trx_id=105 > 102,不可见 → 找到历史版本2 trx_id=100 ≤ 102,✅ 可见!返回 amount=18

🖥️ 工作场景:查看和修改隔离级别

查看隔离级别
-- 查看当前隔离级别
SELECT @@transaction_isolation;
@@transaction_isolation
REPEATABLE-READ
修改隔离级别
-- 改为读已提交(某些场景需要,比如主从延迟检测)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

🖥️ 演示:不可重复读 vs 可重复读

-- ===== 终端A(小丽的查询)=====
mysql> BEGIN;
mysql> SELECT amount FROM order_info WHERE id=1; -- 第1次读
+--------+
| amount |
+--------+
| 18.00  |
+--------+
-- 此时终端B改了这条数据并提交
mysql> SELECT amount FROM order_info WHERE id=1; -- 第2次读
+--------+
| amount |
+--------+
| 18.00  | ← 🔥 依然是18.00!这就是RR(可重复读)
+--------+
-- 因为MVCC,小丽读到的是自己事务开始时的快照版本

-- 如果是RC(读已提交)级别,第2次读会看到B修改后的新值

🖥️ Spring Boot 中怎么用事务?(@Transactional)

上面是 MySQL 命令行的事务操作。但日常开发中,你99%的时间是在 Java 代码 里用事务。
Spring Boot 提供了 @Transactional 注解,一行搞定。
✅ 基本用法
@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private ProductMapper productMapper; @Autowired private AccountMapper accountMapper; // 下单 = 扣库存 + 创建订单 + 扣余额 // 三个操作要在一个事务里 @Transactional public void createOrder(CreateOrderRequest req) { // 1. 扣库存 productMapper.decreaseStock( req.getProductId(), req.getQuantity()); // 2. 创建订单 orderMapper.insert(req.toOrder()); // 3. 扣余额 accountMapper.deduct(req.getUserId(), req.getAmount()); // 如果第3步报异常 → 1、2自动回滚! } }
⚠️ @Transactional 常见坑
// 坑1:默认只对 RuntimeException 回滚 @Transactional public void updateOrder() { orderMapper.update(...); // 如果抛 checked Exception(如 IOException) // 默认不回滚! // 解决:指定回滚类型 } // ✅ 正确写法: @Transactional(rollbackFor = Exception.class) public void updateOrder() { ... } // 坑2:同类方法调用,事务不生效! // ❌ 因为 Spring AOP 基于代理,内部调用绕过代理 public void methodA() { this.methodB(); // methodB的@Transactional不生效 } @Transactional public void methodB() { ... } // 坑3:方法必须是 public // @Transactional 只对 public 方法生效
📋 事务传播行为(Propagation)— 了解最常用的两种
传播行为含义什么时候用
REQUIRED(默认)有事务就加入,没有就新建99%的场景用这个
REQUIRES_NEW总是新建事务,外层事务挂起日志记录(不受主事务回滚影响)
NESTED嵌套事务,外层回滚它也回滚,它回滚外层不受影响子操作独立回滚
🔄 Spring 事务 + MySQL 隔离级别配合
// 可以在注解里指定隔离级别(一般用默认的就行) @Transactional( isolation = Isolation.REPEATABLE_READ, // MySQL默认RR,一般不改 rollbackFor = Exception.class, timeout = 30 // 超时30秒自动回滚,防止长事务 ) public void createOrder(CreateOrderRequest req) { ... } // application.yml 配置全局超时 // spring: // transaction: // default-timeout: 30

🔐 第七章:锁机制 — 两个人同时修改同一条数据怎么办?

商品库存只剩1件。两个用户同时下单要同一件商品。

没锁:两个事务都成功了,但库存变成 -1 💀
表锁:事务A修改时,整张表暂停服务,其他事务啥也干不了。安全但太慢。
行锁:只锁住被修改的那一行。其他事务操作不同行完全不受影响。✅ InnoDB 就是这样!

InnoDB 的锁类型:
共享锁(S锁,读锁):允许多人同时读,但不能写。SELECT ... LOCK IN SHARE MODE
排他锁(X锁,写锁):只有持锁者能读写。UPDATE/DELETE 自动加
意向锁:表级标记,快速判断表里有没有行锁。不用你管。
间隙锁(Gap Lock):锁住两条记录之间的"间隙",防止别人往这里插入。RR级别才有。
临键锁(Next-Key Lock):行锁 + 间隙锁的组合,InnoDB 在 RR 级别默认的行锁算法。

📋 锁的分类和关系

InnoDB 锁体系
全局锁
FLUSH TABLES WITH READ LOCK
↓ 用于全库备份
表级锁 意向锁(IS/IX) 元数据锁(MDL)
↓ InnoDB 主要用行锁
行级锁 Record Lock
(锁单行)
+ Gap Lock
(锁间隙)
= Next-Key Lock
(锁行+间隙)

🖥️ 实战演示:行锁的效果

-- ===== 终端A =====
mysql> BEGIN;
mysql> UPDATE order_info SET amount=20.00 WHERE id=1; -- 给 id=1 加了X锁
Query OK, 1 row affected

-- ===== 终端B =====
mysql> UPDATE order_info SET amount=25.00 WHERE id=1; -- 也要给 id=1 加X锁
⏳ 阻塞!等待终端A释放锁...

-- ===== 终端A =====
mysql> COMMIT; -- 提交,释放锁

-- ===== 终端B =====
Query OK, 1 row affected -- 终于拿到了锁,执行成功
💡 两个事务同时修改同一行,后到的必须等前面的提交。这就是行锁在保护数据一致性。

⚠️ 锁升级:行锁 → 表锁的坑

如果没有走索引,行锁会变成表锁!

比如:UPDATE order_info SET status='cancelled' WHERE amount=18.00
amount 没有索引,MySQL 不知道该锁哪些行,只好把所有行都锁住。

类比:你只想锁一行,但因为没有索引定位,只好把整张表都锁了。

这就是为什么 UPDATE/DELETE 一定要走索引!
验证锁情况
-- 查看当前锁等待
SELECT * FROM performance_schema.data_lock_waits\G

🖥️ Java 中的锁方案:乐观锁 vs 悲观锁

上面讲了 MySQL 的行锁机制。但在 Java 代码层面,你有更灵活的选择:
✅ 乐观锁(推荐,大部分场景)
不真的加锁。更新时检查"版本号有没有变"。
-- 1. 表加 version 字段 ALTER TABLE product ADD COLUMN version INT DEFAULT 0; -- 2. 更新时带版本号 UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 5 AND version = 3; -- 如果 version 变了 → 影响行数 = 0 → 说明被别人改过了 -- 3. MyBatis 写法 // ProductMapper.java @Update("UPDATE product SET stock = stock - #{qty}, " + "version = version + 1 " + "WHERE id = #{id} AND version = #{version}") int decreaseStock( @Param("id") Long id, @Param("qty") int qty, @Param("version") int version); // Service 层:CAS 重试 public boolean tryDecreaseStock(Long id, int qty) { int maxRetry = 3; for (int i = 0; i < maxRetry; i++) { Product p = productMapper.selectById(id); int rows = productMapper.decreaseStock( id, qty, p.getVersion()); if (rows > 0) return true; // version 不匹配,重试 } throw new BusinessException("库存扣减失败,请重试"); }
优点:不加锁,并发高。适合:读多写少、冲突概率低的场景。
🔒 悲观锁(高并发抢购)
先锁住,再操作。MySQL 层面用 SELECT ... FOR UPDATE
@Transactional(rollbackFor = Exception.class) public boolean decreaseStockWithLock(Long id, int qty) { // 1. 先锁住这一行(SELECT ... FOR UPDATE) // MySQL 会给这行加 X 锁 Product p = productMapper .selectForUpdate(id); // @Select("SELECT * FROM product " // + "WHERE id = #{id} FOR UPDATE") if (p.getStock() < qty) { throw new BusinessException("库存不足"); } // 2. 再更新 productMapper.decreaseStock(id, qty); return true; } // 注意:FOR UPDATE 必须在事务里! // 而且 WHERE 条件必须走索引,否则锁全表!
缺点:锁等待,并发低。适合:秒杀、抢购等高冲突场景。
选型指南
• 99% 的场景用乐观锁(version字段 + CAS重试)
• 秒杀/抢购用悲观锁(SELECT FOR UPDATE)
• 跨服务用分布式锁(Redis SETNX / Redisson)

🖥️ 工作场景:排查死锁

-- ===== 终端A =====
mysql> BEGIN;
mysql> UPDATE order_info SET amount=20 WHERE id=1; -- 锁住 id=1

-- ===== 终端B =====
mysql> BEGIN;
mysql> UPDATE order_info SET amount=30 WHERE id=3; -- 锁住 id=3

-- ===== 终端A =====
mysql> UPDATE order_info SET amount=40 WHERE id=3; -- 要id=3,被B锁着 → 等待

-- ===== 终端B =====
mysql> UPDATE order_info SET amount=50 WHERE id=1; -- 要id=1,被A锁着 → 死锁!
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
# 查看最近的死锁信息
mysql> SHOW ENGINE INNODB STATUS\G
...
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION: TRANSACTION 108
*** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 58 page no 4 ...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... id=3
*** (2) TRANSACTION: TRANSACTION 109
*** (2) HOLDS THE LOCK(S): RECORD LOCKS ... id=3
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS ... id=1
*** WE ROLL BACK TRANSACTION (2) ← MySQL自动回滚代价较小的事务
⚠️ 死锁排查四步法
SHOW ENGINE INNODB STATUS 看死锁日志
② 找到两个事务分别持有什么锁、等什么锁
③ 定位到具体的 SQL
④ 分析为什么会产生交叉等待(通常是更新顺序不一致)
🖥️ 死锁在 Java 代码中的体现 — 错误 vs 正确
上面的 MySQL 命令行演示了死锁是怎么产生的。但在实际开发中,你很少会手动开两个终端去 UPDATE。
真正的死锁发生在 Java 代码并发执行多个事务 时。下面用「批量更新订单状态」这个真实场景演示。
❌ 错误写法:随机顺序更新 → 死锁
场景:运营后台"批量取消过期订单",一次传了多个订单ID。
@Service public class OrderBatchService { @Autowired private OrderMapper orderMapper; // ❌ 线程A传入 ids=[1, 3] // 线程B传入 ids=[3, 1] // → 跟上面MySQL演示的死锁一模一样! @Transactional(rollbackFor = Exception.class) public void batchCancel(List<Long> ids) { for (Long id : ids) { // 线程A: 先锁id=1,再锁id=3 // 线程B: 先锁id=3,再锁id=1 // → 交叉等待 → 死锁! orderMapper.updateStatus( id, "cancelled"); } } } // 报错日志: // Caused by: com.mysql.cj.jdbc.exceptions // .MySQLTransactionRollbackException: // Deadlock found when trying to get lock; // try restarting transaction
✅ 正确写法1:按 ID 排序后再更新
所有线程都按 id 升序更新 → 锁的获取顺序一致 → 不会交叉等待。
@Service public class OrderBatchService { @Autowired private OrderMapper orderMapper; @Transactional(rollbackFor = Exception.class) public void batchCancel(List<Long> ids) { // ✅ 核心:排序!保证所有线程按同一顺序拿锁 List<Long> sortedIds = ids.stream() .sorted() .collect(Collectors.toList()); for (Long id : sortedIds) { // 线程A ids=[1,3] → 锁id=1, 再锁id=3 // 线程B ids=[3,1] → 锁id=1, 再锁id=3 // → 顺序一致,不会死锁! orderMapper.updateStatus( id, "cancelled"); } } }
✅ 正确写法2:拆成小事务 + 单条更新
每条UPDATE单独一个事务 → 不会同时持有多个锁 → 不可能死锁。
@Service public class OrderBatchService { @Autowired private OrderMapper orderMapper; @Autowired private TransactionTemplate txTemplate; public BatchResult batchCancel(List<Long> ids) { int success = 0, fail = 0; for (Long id : ids) { try { // ✅ 每条单独事务 txTemplate.execute(status -> { orderMapper.updateStatus( id, "cancelled"); return null; }); success++; } catch (Exception e) { // 单条失败不影响其他 fail++; log.warn("取消订单{}失败", id, e); } } return new BatchResult(success, fail); } }
✅ 正确写法3:一条 SQL 批量更新
最简单:一条 UPDATE ... IN (...) 搞定,只加一次锁。
// Mapper @Update("<script>" + "UPDATE order_info SET status = #{status} " + "WHERE id IN " + "<foreach collection='ids' item='id' " + " open='(' separator=',' close=')'>" + " #{id}" + "</foreach>" + "</script>") int batchUpdateStatus( @Param("ids") List<Long> ids, @Param("status") String status); // Service — 一行搞定 @Transactional(rollbackFor = Exception.class) public void batchCancel(List<Long> ids) { // ✅ 一条SQL更新所有行 // MySQL 内部按id顺序加锁(因为WHERE id IN) // 不会死锁 orderMapper.batchUpdateStatus( ids, "cancelled"); } // ⚠️ 注意:ids 数量不要超过500 // 否则SQL太长,且锁太多行影响并发
🖥️ 死锁发生后的 Java 端处理
// 死锁发生时 MySQL 会自动回滚其中一个事务 // Java 端会收到 MySQLTransactionRollbackException // 实战建议:捕获死锁异常,自动重试 @Service public class OrderService { @Autowired private OrderMapper orderMapper; private static final int MAX_RETRY = 3; public void updateWithRetry(Long id, String status) { for (int i = 0; i < MAX_RETRY; i++) { try { doUpdate(id, status); return; // 成功就退出 } catch (MySQLTransactionRollbackException e) { // 死锁异常,可以重试 log.warn("死锁重试第{}次, id={}", i + 1, id); if (i == MAX_RETRY - 1) { throw new BusinessException( "系统繁忙,请稍后再试"); } // 短暂等待再重试 try { Thread.sleep(100); } catch (InterruptedException ignored) {} } } } @Transactional(rollbackFor = Exception.class) public void doUpdate(Long id, String status) { orderMapper.updateStatus(id, status); } }
死锁预防总结
按固定顺序更新(ID排序)→ 最有效的预防手段
大事务拆小→ 减少锁持有时间,降低交叉等待概率
一条SQL批量操作→ 只加一次锁,不会死锁
Java端加重试→ 死锁不可避免但可以重试,用户体验不受影响
UPDATE 的 WHERE 必须走索引→ 否则行锁退化为表锁,死锁概率暴增

💾 第八章:Buffer Pool — MySQL 的内存缓存

MySQL 读数据不是每次都去硬盘拿。它有一个内存缓存叫 Buffer Pool

类比:你有一个小书桌,上面放着最近常用的几页书。
• 要查 id=1 的数据 → 先看内存里有没有 → 有,直接用(缓存命中
• 内存没有 → 去磁盘读取 → 放到 Buffer Pool → 下次不用再读磁盘
• 缓存满了 → 把最久没用的数据页淘汰(LRU淘汰

Buffer Pool 默认128MB,线上一般调到物理内存的 60%~80%。
如果 Buffer Pool 太小,你的数据库会疯狂读磁盘,性能暴跌。

📐 Buffer Pool 内部长什么样?

Buffer Pool 结构
Buffer Pool
(默认128MB)
包含 ↓
数据页
Data Page
(16KB/页)
索引页
Index Page
自适应哈希
AHI
锁信息
Lock Info
LRU链表管理:热数据在前面,冷数据在后面被淘汰

📖 改进的 LRU 算法(冷热分离)

MySQL 没用普通 LRU,而是用了 冷热分离的改进 LRU

新生代(young 区):5/8 的空间,放经常访问的热数据
老生代(old 区):3/8 的空间,放新读入的数据

新读入的数据先进 old 区,如果 在 old 区停留超过 1 秒 后再次被访问,才提升到 young 区。

为什么? 防止一次全表扫描把所有热数据都挤出 Buffer Pool!

类比:新数据先进"冷区",被访问超过1秒后才升到"热区"。避免一次全表扫描把所有热数据挤走。

🖥️ 监控 Buffer Pool 状态

查看 Buffer Pool 命中率
SHOW STATUS LIKE 'Innodb_buffer_pool%';
Variable_nameValue
Innodb_buffer_pool_read_requests1,284,567
Innodb_buffer_pool_reads3,891
# 计算命中率
# 命中率 = 1 - (disk_reads / total_reads)
# = 1 - (3891 / 1284567) = 99.7%
Buffer Pool 命中率: 99.7% ← 正常!应该 > 95%
⚠️ 如果命中率 < 95%,说明 Buffer Pool 太小,频繁读磁盘。需要调大 innodb_buffer_pool_size

📋 redo log 和 undo log

这两个 log 跟 Buffer Pool 紧密相关:

redo log(重做日志)
• 你在 Buffer Pool 里改了数据,还没刷到磁盘。如果突然断电,改了白改?
• 所以修改的同时,先把"改了什么"写到 redo log 里(WAL = Write-Ahead Logging)。
• redo log 是顺序写,很快。断电后重启,通过 redo log 重做一遍,恢复数据。

undo log(回滚日志)
• 你要回滚事务?得知道"改之前长什么样"。undo log 就是存旧版本的。
• MVCC 的版本链就是通过 undo log 实现的。

类比:
• redo log = "操作日志"(记录做了什么,断电后可以重做)
• undo log = "修改前快照"(记录改之前的样子,需要时可以撤回)
一次 UPDATE 的完整流程
① 读取数据页
到 Buffer Pool
② 写 undo log
(旧值)
③ 修改 Buffer Pool
中的数据
④ 写 redo log
(新值)
⑤ 提交事务
COMMIT
数据页不立即写磁盘(随机写慢),靠 redo log(顺序写快)保证不丢。后台线程异步刷盘。

🎬 第九章:10 个实战场景 — 原理在工作中怎么用

场景1索引失效明明建了索引,为什么还是全表扫描?

📅 周一早上,产品经理说:"用户反馈'我的订单'页面打开要5秒!"
排查慢查询
-- 这个查询很慢
SELECT * FROM order_info WHERE user_id + 0 = 101;
typekeyrowsExtra
ALLNULL 10 Using where
user_id + 0 = 101 → 对索引列做了运算!B+树里存的是 user_id 原始值,加了0之后MySQL不知道怎么查了。
修复:去掉运算
-- 修复后
SELECT * FROM order_info WHERE user_id = 101;
typekeyrowsExtra
ref idx_user 4 NULL
索引失效的常见原因(都跟B+树原理有关):
① 对索引列做运算/函数:WHERE YEAR(created_at)=2025 → 用 WHERE created_at >= '2025-01-01'
② 隐式类型转换:WHERE user_id='101'(user_id是int,传了string)
③ LIKE 左模糊:WHERE name LIKE '%珍珠'
④ OR 条件有一边没索引
⑤ 联合索引不满足最左前缀

🖥️ Java 中导致索引失效的常见写法

❌ MyBatis 中对索引列做运算
// 需求:查询 user_id + 0 等于某个值的订单 // (虽然实际很少这么写,但类似的错误更隐蔽) // ❌ 在 Java 里拼接运算表达式 @Select("SELECT * FROM order_info " + "WHERE user_id + 0 = #{userId}") List findByCalc(@Param("userId") int userId); // MySQL: type=ALL, 全表扫描! // ❌ 使用 MySQL 函数 @Select("SELECT * FROM order_info " + "WHERE DATE(created_at) = #{date}") List findByDate(@Param("date") String date); // DATE() 函数让 created_at 索引失效! // ✅ 正确:用范围查询代替函数 @Select("SELECT * FROM order_info " + "WHERE created_at >= #{start} " + "AND created_at < #{end}") List findByDateRange( @Param("start") String start, @Param("end") String end); // 传入 start="2025-06-01", end="2025-06-02"
❌ 隐式类型转换(最容易踩的坑)
// phone 是 VARCHAR,有索引 idx_phone // ❌ Mapper 参数类型不对 @Select("SELECT * FROM user " + "WHERE phone = #{phone}") User findByPhone(@Param("phone") Long phone); // Java 传 Long → MyBatis 传数字 → MySQL 做隐式转换 // → 索引失效!type=ALL // ✅ 正确:参数类型匹配字段类型 @Select("SELECT * FROM user " + "WHERE phone = #{phone}") User findByPhone(@Param("phone") String phone); // Java 传 String → MyBatis 传字符串 → 走索引! // 💡 规律:Java 参数类型要跟 MySQL 字段类型一致 // DB是VARCHAR → Java用String // DB是INT → Java用Integer/Long // DB是BIGINT → Java用Long // DB是DECIMAL → Java用BigDecimal

场景2深翻页第10000页的数据怎么这么慢?

📅 运营后台要导出数据,翻到第10000页超时了。
深翻页问题
-- 第10000页,每页10条
SELECT * FROM order_info ORDER BY id LIMIT 100000, 10;
为什么慢? MySQL 不是直接跳到第100000行。它是从第1行开始读,读 100010 行,然后扔掉前 100000 行。

而且 SELECT * 要回表,100010 次回表!

类比:你要翻到笔记本的第100页,笨办法是从第1页逐页翻过去,而不是直接跳到第100页。
优化方案:游标分页
-- ✅ 方案1:游标分页(记住上次的最大ID)
SELECT * FROM order_info WHERE id > 100000 ORDER BY id LIMIT 10;

-- ✅ 方案2:延迟关联(先查ID再回表)
SELECT o.* FROM order_info o
INNER JOIN (
  SELECT id FROM order_info ORDER BY id LIMIT 100000, 10
) tmp ON o.id = tmp.id;
💡 方案2的子查询 SELECT id FROM order_info 走覆盖索引(不需要回表),只取10个ID再去回表,从100010次回表降到10次!

🖥️ Java 中的分页实现

方式一:MyBatis PageHelper(最常用)
// 1. 添加依赖 // <dependency> // <groupId>com.github.pagehelper</groupId> // <artifactId>pagehelper-spring-boot-starter</artifactId> // </dependency> // 2. Controller @GetMapping("/orders") public Result<PageInfo<OrderVO>> listOrders( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { // 一行搞定分页! PageHelper.startPage(page, size); List<OrderVO> list = orderService.list(); PageInfo<OrderVO> pageInfo = new PageInfo<>(list); return Result.success(pageInfo); // pageInfo 包含:list, total, pageNum, pageSize, pages } // 3. 底层自动改写 SQL: // SELECT * FROM order_info // LIMIT (page-1)*size, size // 底层用 MySQL 的 LIMIT 实现分页
方式二:游标分页(大数据量推荐)
// App 端"下拉加载更多"的场景 // 不用 page/size,用 lastId @GetMapping("/orders/cursor") public Result<List<OrderVO>> loadMore( @RequestParam Long lastId, @RequestParam(defaultValue = "20") int size) { // SELECT * FROM order_info // WHERE id > #{lastId} // ORDER BY id ASC LIMIT #{size} List<OrderVO> list = orderService .listAfterId(lastId, size); return Result.success(list); } // MyBatis XML: // <select id="listAfterId" resultType="OrderVO"> // SELECT * FROM order_info // WHERE id > #{lastId} // ORDER BY id ASC LIMIT #{size} // </select>

场景3联合索引最左前缀为什么 WHERE status='paid' 不走索引?

📅 新同事写了条SQL,WHERE 条件里用了联合索引的第二列,发现没走索引。
联合索引 idx_user_status (user_id, status)
-- ❌ 只用第二列
EXPLAIN SELECT * FROM order_info WHERE status = 'paid';
typekeyrowsExtra
ALLNULL 10 Using where
为什么? 联合索引 (user_id, status) 在B+树里是先按 user_id 排序,user_id 相同的再按 status 排序。

类比:电话簿先按姓排,同姓再按名排。你只按名查"叫小明的人",电话簿帮不了你,因为小明可能分散在不同姓里。
🌳 最左前缀原理图解 — idx_user_status (user_id, status)
联合索引的排序:先 user_id,再 status
叶子节点排列:
(101, completed) → (101, paid) → (101, pending) → (102, completed) → (102, pending) → (103, paid)
↑ user_id=101的排一起了,status在其中有序
❌ WHERE status = 'paid'
你要找 status='paid' 的行。
但索引先排的是 user_id!
paid 分布在:
→ user_id=101 里有一个 paid
→ user_id=103 里有一个 paid
分散在不同 user_id 里,
B+树没法快速定位
→ 只能扫全部叶子 = 全表扫描
✅ WHERE user_id = 101 AND status = 'paid'
先用 user_id=101:
→ B+树快速定位到 user_id=101 区间
在这个区间里 status 是有序的:
completed → paid → pending
再用 status='paid':
→ 在小区间里直接找到 paid
B+树完美配合
→ 走索引 ref
最左前缀原则:联合索引 (A, B, C) 的排序是 A → B → C。
能走索引:WHERE A / WHERE A, B / WHERE A, B, C
不能走索引:WHERE B / WHERE C / WHERE B, C(跳过了最左列A)
正确用法
-- ✅ 用了最左列
EXPLAIN SELECT * FROM order_info WHERE user_id = 101 AND status = 'paid';
typekeyrowsExtra
ref idx_user_status 2 NULL

🖥️ Java 端实现:只查需要的字段

❌ SELECT * 浪费性能
// Entity 包含所有字段 @Data public class Order { private Long id; private Integer userId; private Integer productId; private Integer storeId; private BigDecimal amount; private String status; private LocalDateTime createdAt; } // ❌ 用 SELECT * → 查了所有字段 // 但前端列表页只需要 id, userId, status, createdAt @Select("SELECT * FROM order_info " + "ORDER BY created_at DESC LIMIT 10") List findRecent(); // → 回表取 amount, productId, storeId... // → 白白浪费 IO!
✅ 只查需要的字段 + 覆盖索引
// 用轻量 VO 代替完整 Entity @Data public class OrderListVO { private Long id; private Integer userId; private String status; private LocalDateTime createdAt; } // ✅ 只查4个字段 → 如果有合适的索引就是覆盖索引 @Select("SELECT id, user_id, status, created_at " + "FROM order_info " + "ORDER BY created_at DESC LIMIT 10") List findRecentList(); // → 如果 idx_created 覆盖了这些字段 → Using index // → 不回表,快 10~100 倍! // 💡 最佳实践:建联合索引 // CREATE INDEX idx_created_cover // ON order_info(created_at, user_id, status); // → created_at 排序 + user_id/status 覆盖 = 完美

场景4EXPLAIN type 详解这些 type 值分别代表什么?

面试必考!EXPLAIN 结果的 type 列从好到差排列:
type含义生活类比性能
system表中只有一行表里只有一行🟢 最快
const主键/唯一索引等值查按主键直接查🟢 极快
eq_refJOIN时主键/唯一索引JOIN时按主键匹配🟢 很快
ref普通索引等值查按普通索引查,找到几条🟢 快
range索引范围查询BETWEEN 5 AND 10🟡 还行
index全索引扫描遍历整棵索引树🟠 勉强
ALL全表扫描把所有行扫一遍🔴 最慢
💡 面试回答要点:至少要达到 ref 级别才算"走了索引"。出现 ALL 就要优化(加索引/改SQL)。

场景5事务回滚事务没包完整导致数据不一致?

📅 数据不一致:操作执行了一半就中断了!—— 典型的事务没包完整。
❌ 错误写法:没有用事务
-- 步骤1:扣钱
UPDATE user SET level = level - 1 WHERE id = 101;
-- 💥 这里如果程序崩溃了...
-- 步骤2:生成订单
INSERT INTO order_info (user_id, product_id, amount, status)
VALUES (101, 5, 18.00, 'paid');
✅ 正确写法:用事务包起来
BEGIN;
-- 步骤1:扣钱
UPDATE user SET level = level - 1 WHERE id = 101;
-- 步骤2:生成订单
INSERT INTO order_info (user_id, product_id, amount, status)
VALUES (101, 5, 18.00, 'paid');
COMMIT; -- 两步都成功才提交
-- 如果中间任何一步失败:ROLLBACK; 两步都撤销
💡 这就是 ACID 中 A(原子性)的应用:要成功全成功,要失败全失败。undo log 保证了这一点。

🖥️ Java 代码对比:不加事务 vs 加事务

❌ 没有事务 — 中间异常导致数据不一致
@Service public class OrderService { @Autowired private UserMapper userMapper; @Autowired private OrderMapper orderMapper; // ❌ 没有 @Transactional public void createOrder(CreateOrderRequest req) { // 步骤1:扣用户等级 userMapper.deductLevel(req.getUserId()); // 💥 如果这里抛异常(如 OOM、NPE) // 用户等级扣了,但订单没生成! // 数据不一致了! // 步骤2:创建订单 orderMapper.insert(req.toOrder()); } } // 结果:user 表 level 少了1,但 order_info 表没有新记录 // 客服接到投诉:"我等级扣了但没看到订单!"
✅ 有事务 — 异常自动回滚
@Service public class OrderService { @Autowired private UserMapper userMapper; @Autowired private OrderMapper orderMapper; // ✅ 加了 @Transactional @Transactional(rollbackFor = Exception.class) public void createOrder(CreateOrderRequest req) { // 步骤1:扣用户等级 userMapper.deductLevel(req.getUserId()); // 💥 即使这里抛异常 // Spring 会自动 ROLLBACK // 步骤1的修改也会被撤销! // 步骤2:创建订单 orderMapper.insert(req.toOrder()); } // 要么两步都成功,要么两步都撤销 // ACID 的 A(原子性)保证! }

场景6覆盖索引优化慢查询从2秒优化到20毫秒

📅 首页要展示"最近10条记录的 user_id 和 status",原来要2秒。
优化前
SELECT * FROM order_info ORDER BY created_at DESC LIMIT 10;
typekeyrowsExtra
index idx_created 10 回表10次取全部字段
优化后:只查需要的字段
-- 前端只需要 id, user_id, status, created_at
SELECT id, user_id, status, created_at
FROM order_info ORDER BY created_at DESC LIMIT 10;
typekeyrowsExtra
index idx_created 10 Using index(覆盖索引)
💡 虽然都是走 idx_created,但优化后不需要回表(所有查询字段在二级索引 + 主键里都有)。从2秒 → 20毫秒!

⚡ 日常开发必会的 MySQL 技巧 — 避坑 + 实战

前面的章节讲了原理。但日常开发中,还有一些非常实用的技巧和常见的坑,面试和工作中都会用到。
下面按场景梳理,每个都是"踩过才知道"的经验。

高频坑 深分页优化 — LIMIT 100000, 10 为什么慢?

问题:分页查询到第10000页时,SQL 变成 LIMIT 100000, 10
MySQL 的执行方式是:先扫描前100010行,再丢弃前100000行,只返回最后10行
也就是说,越往后翻页,扫描的行数越多,速度越慢。
❌ 原始写法(越翻越慢)
SELECT * FROM order_info WHERE user_id = 101 ORDER BY id DESC LIMIT 100000, 10; -- 扫描100010行,回表100010次 -- 第1页:扫描10行 ✅ 快 -- 第10000页:扫描100010行 ❌ 慢!
✅ 延迟关联(推荐)
-- 第一步:用覆盖索引取ID(不回表) SELECT id FROM order_info WHERE user_id = 101 ORDER BY id DESC LIMIT 100000, 10; -- Extra: Using index,快! -- 第二步:拿ID去回表取完整数据 SELECT * FROM order_info WHERE id IN ( SELECT id FROM order_info WHERE user_id = 101 ORDER BY id DESC LIMIT 100000, 10 ); -- 只回表10次!
原理:覆盖索引取 ID 很快(Using index),然后用少量 ID 回表取完整数据。
从 100010 次回表 → 只有 10 次回表,性能提升巨大!

面试必问 COUNT(*) vs COUNT(1) vs COUNT(字段) 的区别

写法统计什么NULL算不算性能
COUNT(*)总行数最快(InnoDB专门优化)
COUNT(1)总行数和 COUNT(*) 一样
COUNT(id)id 不为 NULL 的行不算(但主键不可能NULL)走二级索引扫描
COUNT(status)status 不为 NULL 的行不算NULL!最慢,要判断每行是否NULL
结论:统计总行数一律用 COUNT(*),MySQL 官方推荐,InnoDB 有专门优化(选最小的索引来扫描)。
⚠️ 注意:InnoDB 的 COUNT(*) 不能缓存结果(因为MVCC,不同事务看到的行数可能不同),每次都要扫描。

最隐蔽的坑 隐式类型转换 — 明明有索引却不走!

❌ 类型不匹配导致索引失效
-- phone 字段是 VARCHAR 类型,有索引 CREATE INDEX idx_phone ON user(phone); -- 用字符串查 → 走索引 ✅ SELECT * FROM user WHERE phone = '13800138000'; -- type: ref, key: idx_phone -- 用数字查 → 不走索引!❌ SELECT * FROM user WHERE phone = 13800138000; -- type: ALL, key: NULL -- 因为 MySQL 要把每行的 phone 转成数字来比较 -- → 索引失效 → 全表扫描!
✅ 常见场景和规律
字段类型传入值走索引?
VARCHAR'13800138000'(字符串)✅ 走
VARCHAR13800138000(数字)❌ 不走
INT123(数字)✅ 走
INT'123'(字符串)✅ 走(MySQL自动转字符串为数字)
规律:字符串字段传数字 → 索引失效(要转每一行)
数字字段传字符串 → 索引OK(只转传入值一次)

生产必知 UPDATE 常见坑 — 别把线上锁死了

坑1:WHERE 不走索引 → 锁全表
-- amount 没有索引 UPDATE order_info SET status = 'cancelled' WHERE amount < 10; -- InnoDB 行锁靠索引实现 -- 没索引 → 退化为表锁! -- 其他事务全部被阻塞!😱
原则:UPDATE 的 WHERE 条件必须有索引!
坑2:大事务 UPDATE 锁太久
BEGIN; -- 更新100万行 UPDATE order_info SET status = 'expired' WHERE create_time < '2025-01-01'; -- 还没 COMMIT,锁还没释放 -- 其他事务等着...等着...超时了! COMMIT; -- 5分钟后才提交
原则:大批量 UPDATE 分批执行,每批1000条就 COMMIT
坑3:忘了写 WHERE
-- 😱 这会更新全表! UPDATE order_info SET status = 'cancelled'; -- 永远先 SELECT 确认范围 SELECT COUNT(*) FROM order_info WHERE create_time < '2025-01-01'; -- 确认数量合理后再 UPDATE -- 或者用事务包裹,先看再提交 BEGIN; UPDATE ... WHERE ...; -- SELECT 确认 → COMMIT / ROLLBACK

小技巧 LIMIT 1 和 EXISTS — 微优化也有用

判断是否存在,不要用 COUNT(*)
-- ❌ 扫描全部符合条件的行 SELECT COUNT(*) FROM user WHERE phone = '13800138000'; -- 如果有100万条匹配,全扫完才知道 > 0 -- ✅ 找到第一条就返回 SELECT 1 FROM user WHERE phone = '13800138000' LIMIT 1; -- 找到1条就结束,不继续扫描 -- ✅ 或者用 EXISTS(语义更清晰) SELECT EXISTS( SELECT 1 FROM user WHERE phone = '13800138000' ); -- 返回 1(存在)或 0(不存在)
WHERE 条件唯一时加 LIMIT 1
-- 如果能确定只返回一条(如按主键查) SELECT * FROM user WHERE id = 123 LIMIT 1; -- MySQL 找到后立即停止,不再继续扫 -- 按唯一索引查也一样 SELECT * FROM user WHERE phone = '13800138000' LIMIT 1; -- phone 有唯一索引,只可能有一条 -- 加 LIMIT 1 让 MySQL 更早停止扫描

性能优化 INSERT 批量写入 — 别一条一条插

❌ 循环单条插入(Java中常见)
// Java 代码 for (Order order : orders) { // 每次都:建立连接→解析SQL→写B+树→写redo log mapper.insert(order); } // 1000条 → 1000次网络IO + 1000次提交
✅ 批量插入
-- 一条SQL插入多行 INSERT INTO order_info (user_id, status, amount) VALUES (101, 'paid', 15.0), (102, 'pending', 28.0), (103, 'paid', 9.9); -- 1000条 → 1次网络IO + 1次提交 -- 快 10~50 倍! -- MyBatis 批量写法: // mapper.xml INSERT INTO order_info (user_id, status, amount) VALUES (#{o.userId}, #{o.status}, #{o.amount})
原理:每次 INSERT 都要:网络往返 + 解析SQL + 写 B+ 树索引 + 写 redo log + 提交事务。
批量 INSERT 只需要做一次,省掉 N-1 次开销。
注意:一次批量不要超过 500~1000 条,否则 SQL 太长可能报错或撑爆内存。

📋 全文总结:MySQL 原理一张图

MySQL 核心原理关系图
存储引擎
InnoDB
数据组织
B+树索引
查询优化
回表/覆盖索引
索引下推
事务
ACID
隔离级别
MVCC
锁机制
行锁/间隙锁
Buffer Pool
内存管理
redo log
持久性保证
+ undo log
原子性保证

🔑 核心知识点速记

原理一句话理解工作中的应用
InnoDB数据按主键B+树存储,支持事务行锁99%场景用InnoDB
B+树3-4层就能存千万行,范围查询靠叶子链表理解索引为什么快
回表二级索引只存索引列+主键,查其他字段要回聚簇索引SELECT * 导致大量回表
覆盖索引查询的所有字段都在索引里,不用回表EXPLAIN Extra=Using index
子查询优先改写为JOIN,让优化器灵活选择慢查询优化常见手法
FilesortORDER BY没走索引就要额外排序监控Sort_merge_passes
MVCC通过版本链实现读写不冲突理解RR级别的可重复读
行锁靠索引实现,没索引变表锁UPDATE必须走索引
Buffer Pool内存缓存池,命中率应>95%调innodb_buffer_pool_size
redo/undo logredo保持久性,undo保原子性理解事务的底层保障
☕ Java 端必须知道的 MySQL 知识

安全必知 MyBatis #{} vs ${} — SQL 注入就差这一个符号

✅ #{} — 预编译,安全
// Mapper @Select("SELECT * FROM user WHERE id = #{id}") User selectById(@Param("id") Long id); // MyBatis 底层会用 PreparedStatement: // SELECT * FROM user WHERE id = ? // 参数通过 setLong() 设置,不拼接进 SQL // → SQL注入不可能!
❌ ${} — 字符串拼接,危险!
// ❌ 危险写法 @Select("SELECT * FROM user WHERE name = '${name}'") User selectByName(@Param("name") String name); // 如果有人传入 name = "'; DROP TABLE user; --" // 实际执行的SQL变成: // SELECT * FROM user WHERE name = ''; // DROP TABLE user; --' // 表被删了!!!😱 // ${} 唯一合理用途:动态表名/列名 @Select("SELECT * FROM ${tableName} LIMIT 10") List queryTable(@Param("tableName") String table); // 但也要白名单校验 tableName!
铁律:所有用户输入的值一律用 #{}。只有动态表名/列名/ORDER BY 列名才能用 ${},且必须做白名单校验!

配置必知 HikariCP 连接池 — Java 连 MySQL 的"大门"

Java 程序不直接连 MySQL,而是通过连接池管理连接。
连接池就像一个"接线员":预先建好若干连接放在池子里,谁要用就取一个,用完还回去。
Spring Boot 默认用 HikariCP(目前最快的连接池)。
# application.yml — HikariCP 常用配置 spring: datasource: url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver hikari: # 连接池大小(推荐 = CPU核数 * 2 + 磁盘数) maximum-pool-size: 10 # 最小空闲连接 minimum-idle: 5 # 连接超时时间(毫秒) connection-timeout: 30000 # 空闲连接最大存活时间(毫秒) idle-timeout: 600000 # 连接最大存活时间(毫秒),建议 < MySQL的wait_timeout max-lifetime: 1800000 # 连接测试语句 connection-test-query: SELECT 1
配置项推荐值为什么
maximum-pool-size10~20不是越大越好!太多连接会让MySQL压力更大,反而变慢
max-lifetime1800000(30分钟)必须 < MySQL 的 wait_timeout(默认8小时),否则MySQL端已断开但Java还以为连着
connection-timeout30000(30秒)获取连接的超时时间。如果经常超时 → 连接池太小或SQL太慢

开发必知 MyBatis 常用写法速查

// ========= Mapper 接口常用写法 ========= // 1. 查询单条 @Select("SELECT * FROM order_info WHERE id = #{id}") Order selectById(@Param("id") Long id); // 2. 查询列表 @Select("SELECT * FROM order_info WHERE user_id = #{userId} " + "ORDER BY create_time DESC LIMIT #{limit}") List listByUserId( @Param("userId") Long userId, @Param("limit") int limit); // 3. 插入(返回自增主键) @Insert("INSERT INTO order_info(user_id, status, amount) " + "VALUES(#{userId}, #{status}, #{amount})") @Options(useGeneratedKeys = true, keyProperty = "id") int insert(Order order); // 插入后 order.getId() 就能拿到自增ID // 4. 更新 @Update("UPDATE order_info SET status = #{status} " + "WHERE id = #{id} AND version = #{version}") int updateStatus( @Param("id") Long id, @Param("status") String status, @Param("version") int version); // 乐观锁:返回 0 表示 version 不匹配 // 5. 批量插入(需要 XML) // mapper.xml: // <insert id="batchInsert"> // INSERT INTO order_info(user_id, status, amount) // VALUES // <foreach collection="list" item="o" separator=","> // (#{o.userId}, #{o.status}, #{o.amount}) // </foreach> // </insert> // 6. 动态 SQL(需要 XML) // <select id="search" resultType="OrderVO"> // SELECT * FROM order_info // <where> // <if test="userId != null"> // AND user_id = #{userId} // </if> // <if test="status != null"> // AND status = #{status} // </if> // </where> // ORDER BY create_time DESC // </select>

🗺️ 索引决策全景图 — 一张图搞懂"什么时候建什么索引"

🤔 问题:SQL 里这么多子句,索引到底是为谁建的?

你写一条 SQL:SELECT user_id, status FROM order_info WHERE user_id = 101 ORDER BY create_time DESC LIMIT 10;

这条 SQL 里出现了 SELECT、WHERE、ORDER BY、LIMIT —— 索引跟它们都有关系!
很多人只会想"WHERE 后面的字段要建索引",但这远远不够。
下面这张图,把所有需要索引的场景统一梳理,让你一次性建立系统认知。

📊 SQL 各子句 & 索引关系全景图

一条 SQL 的完整生命周期 & 索引介入点
📝 先看一条完整 SQL:
SELECT user_id, status, amount
FROM order_info
WHERE user_id = 101 AND status = 'paid'
ORDER BY create_time DESC
LIMIT 10
每种颜色的部分,都跟索引有关系!下面逐个说明 ↓
WHERE 过滤条件 — 索引最核心的作用 最重要
作用:WHERE 决定了 MySQL 要从表里找哪些行
如果 WHERE 的字段没有索引 → 全表扫描(type=ALL)→ 100万行逐行检查。
有索引 → B+ 树二分查找 → 只扫描几行。
-- WHERE 条件决定用索引的"查找"能力 -- 这是最常见需要建索引的场景 SELECT * FROM order_info WHERE user_id = 101; -- 索引:idx_user_id(user_id) -- EXPLAIN: type=ref ✅ -- 联合 WHERE 条件 → 联合索引 WHERE user_id = 101 AND status = 'paid' -- 索引:idx_user_status(user_id, status) -- EXPLAIN: type=ref, 用了联合索引 ✅
记忆点:WHERE 字段 → 索引用于"找到那几行",是联合索引中排在最前面的列。
ORDER BY 排序 — 索引的第二个功能 经常被忽略
问题:没索引时,MySQL 找到数据后还要在内存里排一遍(filesort)。
100万行排序 → CPU飙升 → 慢查询。

关键:B+ 树的叶子节点本身就是有序的
如果 ORDER BY 的字段在索引里 → 直接按索引顺序读取 → 天然有序 → 不需要额外排序
-- 场景1:ORDER BY 字段有索引 SELECT * FROM order_info ORDER BY create_time DESC; -- 索引:idx_create_time(create_time) -- Extra: 没有 Using filesort ✅ -- 场景2:没索引 SELECT * FROM order_info ORDER BY create_time DESC; -- Extra: Using filesort ❌ 要额外排序! -- 场景3(最优):WHERE + ORDER BY 联合索引 SELECT * FROM order_info WHERE user_id = 101 ORDER BY create_time DESC; -- 索引:idx_user_time(user_id, create_time) -- WHERE 用 user_id 找到范围 -- 叶子节点已按 create_time 排好 -- Extra: 没有 filesort ✅✅ 一箭双雕!
记忆点:ORDER BY 字段放在联合索引的最后面,利用 B+ 树叶子节点天然有序的特性,避免 filesort。
SELECT 查询的列 — 决定要不要回表 决定回表
问题:索引的叶子节点只存了索引列的值 + 主键。如果你 SELECT 的字段不在索引里 → 必须回表。

覆盖索引:SELECT 的字段恰好都在索引里 → 不用回表 → Extra: Using index。

这就是为什么 SELECT * 通常是坏习惯:你很少建一个包含所有字段的索引。
-- 索引:idx_user_status(user_id, status) -- 叶子节点存了:user_id, status, id -- ❌ 需要回表:amount 不在索引里 SELECT user_id, status, amount FROM order_info WHERE user_id = 101; -- Extra: NULL(回表了) -- ✅ 覆盖索引:只要 user_id, status, id SELECT user_id, status, id FROM order_info WHERE user_id = 101; -- Extra: Using index(不回表!) -- 💡 实战技巧:把常查的字段加入联合索引末尾 CREATE INDEX idx_user_status_time ON order_info(user_id, status, create_time); -- 现在 SELECT user_id, status, create_time -- 也能覆盖索引了!
记忆点:SELECT 的字段决定了能不能用覆盖索引。联合索引可以把常查的字段加到末尾,让更多查询实现覆盖索引。
GROUP BY 分组 — 和 ORDER BY 类似
原理:GROUP BY 需要先排序再分组。如果分组字段没有索引 → MySQL 建临时表 + filesort。
Extra 出现 Using temporary; Using filesort → 最差组合。

和 ORDER BY 的关系:如果联合索引是 (a, b)
WHERE a = ? ORDER BY b ✅ 叶子天然按 b 排好
WHERE a = ? GROUP BY b ✅ 叶子天然按 b 排好,分组也快
-- 没索引时 GROUP BY SELECT status, COUNT(*) FROM order_info GROUP BY status; -- Extra: Using temporary; Using filesort ❌❌ -- 加索引 CREATE INDEX idx_status ON order_info(status); -- Extra: 只有 Using index ✅ -- 更优:联合索引同时满足 WHERE + GROUP BY SELECT status, COUNT(*) FROM order_info WHERE user_id = 101 GROUP BY status; -- 索引:idx_user_status(user_id, status) -- WHERE 用 user_id 查找 -- 叶子按 status 有序 → GROUP BY 不用额外排序 ✅
记忆点:GROUP BY 和 ORDER BY 共享索引的有序性。放在联合索引的 WHERE 之后、合理位置即可。
JOIN 关联 — ON 字段必须建索引
原理:JOIN 时 MySQL 用 Nested Loop(嵌套循环):
驱动表每取一行 → 去被驱动表找匹配行。
如果被驱动表的 ON 字段没索引 → 每行都全表扫描 → M×N 次!

规则:被驱动表的 JOIN 字段必须有索引。
SELECT o.*, u.name FROM order_info o JOIN user u ON o.user_id = u.id WHERE u.status = 'active'; -- 被驱动表是 user -- ON 条件:o.user_id = u.id -- 需要索引:user.id(主键,已有 ✅) -- 反过来也适用 SELECT o.*, u.name FROM order_info o JOIN user u ON o.user_id = u.id WHERE o.status = 'paid'; -- 被驱动表可能是 order_info -- 需要:order_info.user_id 有索引 -- CREATE INDEX idx_order_user ON order_info(user_id);
记忆点:JOIN 的 ON 字段 = 索引必建项。特别是被驱动表的字段。Extra 出现 Using join buffer 就说明缺索引。

🔄 索引设计决策流程(实战版)

拿到一条慢 SQL → 按这个顺序思考 ↓
1
WHERE 字段 → 联合索引的前面几列
最重要!决定了能不能快速找到数据。
等值查询的字段放前面,范围查询(>, <, BETWEEN)放后面。
2
ORDER BY / GROUP BY → 加在 WHERE 列后面
利用 B+ 树叶子节点天然有序,避免 filesort。
WHERE user_id = ? ORDER BY create_time → idx(user_id, create_time)
3
SELECT 的列 → 加在最后(可选,看是否要覆盖索引)
如果常查的字段都在索引里 → 覆盖索引(Using index)→ 不回表。
idx(user_id, status, create_time) → SELECT user_id, status, create_time 不回表
4
JOIN ON 字段 → 被驱动表必须建索引
否则 Nested Loop 每行都全表扫描。Extra 出现 Using join buffer 就该加索引了。
口诀:WHERE定范围ORDER排好序SELECT省回表JOIN要有路

🧪 6 个实战案例:从 SQL 到索引设计

用 order_info 表(id, user_id, status, amount, product_id, create_time)演示:
案例1:只查某个用户的订单
SELECT * FROM order_info WHERE user_id = 101;
分析:WHERE user_id → 需要在 user_id 上建索引
索引CREATE INDEX idx_user_id ON order_info(user_id);
效果:type=ref,不用全表扫描
案例2:查用户订单并按时间排序
SELECT * FROM order_info WHERE user_id = 101 ORDER BY create_time DESC LIMIT 10;
分析:WHERE user_id(过滤)+ ORDER BY create_time(排序)→ 联合索引
索引CREATE INDEX idx_user_time ON order_info(user_id, create_time);
效果:WHERE 用 user_id 找范围,叶子按 create_time 有序,不用 filesort ✅
⚠️ 如果只建 idx_user_id → ORDER BY create_time 还是要 filesort!
案例3:覆盖索引 — 不回表
SELECT user_id, status FROM order_info WHERE user_id = 101 AND status = 'paid';
分析:WHERE user_id + status(过滤),SELECT user_id + status(只查这两列)
索引CREATE INDEX idx_user_status ON order_info(user_id, status);
效果:叶子节点存了 user_id + status + id → SELECT 的字段全有 → 覆盖索引,不回表
Extra: Using index 🎉
案例4:用户订单分组统计
SELECT status, COUNT(*) AS cnt FROM order_info WHERE user_id = 101 GROUP BY status;
分析:WHERE user_id(过滤)+ GROUP BY status(分组)
索引CREATE INDEX idx_user_status ON order_info(user_id, status);
效果:WHERE 用 user_id 找范围,叶子按 status 有序 → 分组不用建临时表 ✅
如果没有这个联合索引 → Extra: Using temporary; Using filesort ❌❌
案例5:三合一 — 过滤+排序+覆盖
SELECT user_id, status, create_time FROM order_info WHERE user_id = 101 AND status = 'paid' ORDER BY create_time DESC LIMIT 10;
分析:WHERE user_id + status(过滤)+ ORDER BY create_time(排序)+ SELECT 三个字段
索引CREATE INDEX idx_user_status_time ON order_info(user_id, status, create_time);
效果
  WHERE user_id = 101 → 用第1列找到范围 ✅
  AND status = 'paid' → 用第2列继续过滤 ✅
  ORDER BY create_time → 叶子按 create_time 有序,不用 filesort ✅
  SELECT 三个字段都在索引里 → 覆盖索引,不回表 ✅
一个索引满足四个需求!这就是联合索引的威力。
案例6:JOIN 关联查询
SELECT o.id, o.amount, u.name, u.phone FROM order_info o JOIN user u ON o.user_id = u.id WHERE u.status = 'active' ORDER BY o.create_time DESC LIMIT 20;
分析:WHERE u.status(user表过滤)+ JOIN ON o.user_id(关联)+ ORDER BY o.create_time
索引
  user表:CREATE INDEX idx_user_status ON user(status);(WHERE 过滤)
  order_info表:CREATE INDEX idx_order_user_time ON order_info(user_id, create_time);(JOIN + 排序)
效果:user 表走 status 索引找到 active 用户 → 拿 id 去 order_info 走联合索引 → JOIN + 排序都有索引 ✅

📋 速查表:SQL 子句 × 索引作用

SQL 子句索引的作用在联合索引中的位置没有索引的后果
WHERE 快速找到目标行 最前面(第1~N列) type=ALL 全表扫描
ORDER BY 利用 B+ 树有序性,避免额外排序 WHERE 列之后 Using filesort 额外排序
GROUP BY 利用 B+ 树有序性,避免临时表 WHERE 列之后 Using temporary + filesort
SELECT 字段都在索引里 → 覆盖索引不回表 可以加到末尾 需要回表取完整行
JOIN ON 被驱动表快速匹配关联行 被驱动表独立索引 Using join buffer,M×N 扫描
一句话总结
WHERE 定范围 + ORDER/GROUP 保有序 + SELECT 省回表 + JOIN 有索引
= 一条联合索引覆盖全流程

📐 索引设计规则 — 什么时候该建索引?

✅ 该建索引的场景

1. WHERE 高频过滤字段
SELECT * FROM order_info WHERE user_id = 123
→ user_id 是高频查询条件,必须建索引
CREATE INDEX idx_user_id ON order_info(user_id);
2. JOIN 关联字段
SELECT * FROM order o JOIN user u ON o.user_id = u.id
→ JOIN 的 ON 字段必须有索引,否则 Nested Loop 极慢
CREATE INDEX idx_order_user_id ON order_info(user_id);
3. ORDER BY / GROUP BY 字段
SELECT * FROM order_info ORDER BY create_time DESC
→ 不建索引就要 filesort(额外排序),数据量大时很慢
CREATE INDEX idx_create_time ON order_info(create_time);
4. 高选择性字段(区分度高)
选择性 = DISTINCT(col) / COUNT(*)
→ 选择性越接近 1 越好。手机号、订单号很好;性别只有2种,很差
-- 查看选择性 SELECT COUNT(DISTINCT user_id) / COUNT(*) AS selectivity FROM order_info; -- 结果: 0.85 → 很好,值得建索引

❌ 不该建(或谨慎建)索引的场景

1. 数据量小的表(< 1000行)
小表全表扫描可能比走索引还快
→ 优化器可能自动忽略你的索引,建了也白建
2. 频繁更新的字段
每次 UPDATE 都要维护 B+ 树
→ 索引越多,INSERT/UPDATE/DELETE 越慢
3. 低选择性字段(性别、状态只有2-3种值)
WHERE gender = 'M' → 过滤不掉多少数据
→ 但可以放在联合索引的后面列,配合其他字段一起用
4. 很少出现在 WHERE 中的字段
用不到的索引 = 白占磁盘 + 拖慢写入
→ 用以下SQL找出从未使用过的索引
-- 查看索引使用统计 SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'your_db';

🔧 联合索引设计原则

口诀:最左前缀 → 把"最常查询 + 最高选择性"的列放最左边

假设你的查询有这几种模式:
① WHERE user_id = ? AND status = ?
② WHERE user_id = ?
③ WHERE user_id = ? AND status = ? ORDER BY create_time

→ 一个联合索引就能全部覆盖:
CREATE INDEX idx_user_status_time ON order_info(user_id, status, create_time);
查询模式能不能用 idx(user_id, status, create_time)为什么
WHERE user_id = ?✅ 能用第1列最左前缀匹配
WHERE user_id = ? AND status = ?✅ 能用前2列连续匹配
WHERE user_id = ? AND status = ? ORDER BY create_time✅ 全部用上,还避免filesortORDER BY也走索引
WHERE status = ?❌ 用不上跳过了最左列 user_id
WHERE create_time = ?❌ 用不上跳过了前两列
WHERE user_id = ? AND create_time = ?⚠️ 只用第1列中间跳了 status,create_time 不能走索引

📊 索引维护成本可视化

索引不是免费的!每次写入都要更新所有相关索引的 B+ 树。

0个索引 INSERT 只写一次数据
3个索引 INSERT 写1次数据 + 更新3棵 B+ 树
10个索引 INSERT 写1次数据 + 更新10棵 B+ 树 😱
经验:单表索引一般不超过 5-6 个,超过就要考虑合并或删除
实战建议:先用 EXPLAIN 确认慢查询缺什么索引 → 按需建联合索引(覆盖多个查询)→ 定期用 sys.schema_unused_indexes 清理无用索引 → 监控写入性能
🎉 恭喜你读完了 MySQL 原理全景!
记住:原理不是用来背的,是用来理解"为什么这条SQL慢"和"怎么优化"的。
遇到慢查询 → EXPLAIN → 看 type/key/Extra → 对照这篇原理 → 找到优化方向!

❓ 面试高频FAQ

为什么在索引列上使用函数会导致索引失效?
B+树索引存的是列的原始值。当你写 WHERE DATE(create_time) = '2024-06-14',MySQL需要先对每一行执行DATE()函数再比较——B+树里存的是完整的datetime值,无法直接用树查找定位。正确写法:WHERE create_time >= '2024-06-14' AND create_time < '2024-06-15',这样MySQL可以直接在B+树上范围查找。
回表一定会导致性能问题吗?
不一定。回表的代价取决于扫描多少行。如果通过索引筛选后只剩10行,回表10次完全没问题。但如果筛选后还有10万行,回表10万次就很慢。解决方案是覆盖索引——把查询需要的所有列都放进联合索引,这样在索引上就能拿到所有数据,不需要回表。
什么情况下SELECT * 反而比SELECT字段快?
几乎不会。SELECT * 通常更慢,因为:①传输更多数据 ②无法使用覆盖索引优化 ③如果表有很多大字段(TEXT/BLOB),每行数据量巨大。唯一例外:查询本身就需要所有字段(比如导出全表数据),这时候SELECT * 和SELECT所有字段没区别。
为什么建议用自增主键而不是UUID?
InnoDB的聚簇索引按主键顺序存储数据。自增主键:新数据永远追加到最后,页的填充率高,插入快。UUID:随机值导致新数据可能插入到中间位置,触发页分裂(page split),大量数据搬移,写入性能下降50%+,且产生碎片。
Buffer Pool设置多大合适?
经验法则:专用MySQL服务器设置为物理内存的60-80%。比如32GB内存的服务器,innodb_buffer_pool_size = 20G。注意要预留内存给操作系统、连接数、排序缓存等。可以通过 SHOW STATUS LIKE 'Innodb_buffer_pool_read_requests' vs Innodb_buffer_pool_reads 计算命中率(应>99%)。
怎么判断一个索引该不该建?
①看WHERE条件:频繁查询的列考虑建索引 ②看JOIN条件:连接字段建索引 ③看ORDER BY/GROUP BY:排序分组字段建索引 ④区分度:SELECT COUNT(DISTINCT col)/COUNT(*) FROM table,低于0.1的列建索引意义不大 ⑤用 sys.schema_unused_indexes 定期清理无用索引
🎯 全文一句话总结
存储引擎管怎么存 → B+树管怎么快速找 → 回表/覆盖索引管找几次 → 事务/锁管并发安全 → Buffer Pool管减少磁盘IO。慢查询排查链路:EXPLAIN → 看 type(访问类型) + key(用了哪个索引) + Extra(额外信息) → 对照原理找优化方向。