上一篇我们建了表、写了 JOIN,但留了一个悬念:数据库凭什么在千万行数据里瞬间找到你要的那几行?答案就是索引。这一篇我们把 MySQL(InnoDB)最硬核也最实用的三块讲透:索引为什么快、事务隔离到底在防什么、慢查询来了怎么查。这三块吃透,你就超过了绝大多数只会写 SQL 的后端工程师。
索引为什么快:从二叉搜索树推出 B+ 树
不用索引的查询叫全表扫描:从第一行读到最后一行,逐行比较。一千万行就要读一千万行,没有任何捷径。索引就是给数据建一个「目录」,让查找从 O(n) 变成 O(log n)。这个目录为什么偏偏是 B+ 树?我们从最朴素的想法一步步推。
第一步:二叉搜索树(BST)。左小右大,查找一路二分下去,理想情况下 log₂n 次比较。问题是它可能退化:如果数据基本有序地插入,树会歪成一条链表,查找又退回 O(n)。
第二步:平衡二叉树(AVL / 红黑树)。通过旋转保持树的平衡,深度稳定在 log n,内存里的字典、map 就是这么干的。既然平衡树已经 O(log n),数据库为什么不直接用它?因为瓶颈不在比较次数,而在磁盘 I/O 次数。
第三步:引入磁盘视角。数据库的索引存在磁盘上,读盘按「页」(InnoDB 默认 16KB)为单位。用二叉树存一千万条索引,树深约 24 层,最坏情况要 24 次磁盘 I/O——每次 I/O 比一次内存比较慢几万倍,这才是真正的成本。
第四步:B 树——让树变矮变胖。既然一次读一页,那就让一个节点正好占满一页:一个节点不放 1 个键,放几百个键、几百个孩子。一千万条数据的索引,树深立刻从 24 层压到 3 层左右,最多 3 次磁盘 I/O。
第五步:B+ 树——B 树的终极形态。B+ 树在 B 树基础上做了两个关键改造:
- 数据只存在叶子节点,内部节点纯粹当路标。同样 16KB 一页,内部节点能塞的路标更多,树更矮;
- 所有叶子节点用双向链表串起来。这让范围查询(
WHERE age BETWEEN 20 AND 30)快得离谱:找到起点后顺着链表扫即可,而 B 树得回到树里反复中序遍历。

再补一个 InnoDB 的关键设定:表数据本身就是按主键组织的 B+ 树(聚簇索引),叶子节点存整行数据。你建的其他索引(二级索引)叶子节点存的不是整行,而是主键值——先查二级索引拿到主键,再回主键索引查整行,这个动作叫回表。记住它,下面马上用到。
最左前缀:联合索引的使用说明书
联合索引 INDEX(a, b, c) 在 B+ 树里的排序规则是:先按 a 排,a 相同按 b 排,b 相同按 c 排——和电话簿先按姓、再按名排序一模一样。
由此推出最左前缀原则:查询条件必须从索引最左列开始连续匹配,索引才用得上:
-- 假设有索引 INDEX(last_name, first_name, age)
WHERE last_name = '张' AND first_name = '三' AND age = 20 -- 全用上 ✓
WHERE last_name = '张' AND first_name = '三' -- 用上前两列 ✓
WHERE last_name = '张' AND age = 20 -- 只用到 last_name
WHERE first_name = '三' -- 完全用不上 ✗
为什么 WHERE first_name = '三' 用不上?因为电话簿里「名」只有在姓相同的前提下才是有序的,抛开姓去查所有叫「三」的人,只能一页页翻——B+ 树同理。同理,LIKE '%abc' 这种前缀通配也用不上索引,而 LIKE 'abc%' 可以。
两个推论:范围查询会截断后续列(a 用范围条件后,b 列就不再整体有序,只能用于过滤不能用于定位);建联合索引时把区分度高、常等值查询的列放左边。
覆盖索引:能少回一次表就少一次
前面说了二级索引要回表。但如果索引里已经包含了查询要的所有列,就不用回表了——这叫覆盖索引,Extra 里会显示 Using index。
-- 有索引 INDEX(user_id, created_at)
SELECT user_id, created_at FROM orders WHERE user_id = 100;
-- 索引叶子上有 user_id 和 created_at,直接返回,零回表
这就是为什么高性能 SQL 里很少见到 SELECT *:选回来的列越多,用上覆盖索引的机会越小。写查询时只取需要的列,有时再配合一个量身定做的联合索引,慢查询当场消失。
EXPLAIN:查询优化器的口供
EXPLAIN 是数据库告诉你「这条 SQL 我打算怎么执行」的工具,慢查询排查全靠它:
EXPLAIN SELECT * FROM orders WHERE user_id = 100 ORDER BY created_at DESC;
输出有很多列,先盯死这四列:
- type:访问方式,从好到坏大致是
const > eq_ref > ref > range > index > ALL。看到ALL(全表扫描)就是大红旗; - key:实际用了哪个索引。NULL 说明没用上索引;
- rows:预估要扫描的行数。这个数字和实际返回行数差几个数量级时,说明扫描效率有问题;
- Extra:补充信息。
Using index(覆盖索引,好事)、Using where(回表后过滤)、Using filesort(没法用索引排序,额外排序)、Using temporary(临时表)——后两个在大数据量时都是性能杀手。
事务:ACID 与四种隔离级别
事务是把多个操作打包成「要么全成、要么全不成」的单元。经典例子是转账:A 扣 100、B 加 100,中间任何一步失败都必须整体回滚,绝不能出现 A 扣了钱 B 没收到。
ACID 四个字母拆开看:原子性(Atomicity,全做或全不做,靠 undo log 回滚);一致性(Consistency,事务前后数据满足所有约束,这是目标);隔离性(Isolation,并发事务互不干扰,本篇重点);持久性(Durability,提交了就丢不掉,靠 redo log)。
隔离性是为了防三种并发「怪现象」:
- 脏读:读到了别的事务还没提交的修改。对方一回滚,你读到的就是从未存在过的数据;
- 不可重复读:同一事务里两次读同一行,结果不一样——因为中间被别的事务改了(UPDATE);
- 幻读:同一事务里两次按同一条件查询,行数不一样——因为中间被别的事务插入/删除了行(INSERT/DELETE)。
SQL 标准定义了四种隔离级别,防护层层加码、性能层层递减:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | ✗ 会 | ✗ 会 | ✗ 会 |
| READ COMMITTED | ✓ 防 | ✗ 会 | ✗ 会 |
| REPEATABLE READ(MySQL 默认) | ✓ 防 | ✓ 防 | 基本防(InnoDB 用临键锁挡住大部分) |
| SERIALIZABLE | ✓ 防 | ✓ 防 | ✓ 防(但接近串行,性能最差) |
实务上记住:MySQL InnoDB 默认 REPEATABLE READ,配合 MVCC 和临键锁,绝大多数场景够用;Oracle、PostgreSQL 默认 READ COMMITTED。别动默认值,除非你确切知道自己在换什么。
MVCC:读写不打架的秘密
REPEATABLE READ 是怎么做到「别人在改,我却读得稳定」的?靠 MVCC(多版本并发控制)。直觉是这样的:InnoDB 给每行数据维护一条版本链(靠隐藏的版本号和 undo log 串起来)。你开启一个事务(第一次快照读)时,数据库相当于给你发了一张「此刻的数据快照」,之后你的每次普通 SELECT 都沿着版本链找到在你快照时刻已提交的那个版本。
效果非常优雅:读不阻塞写,写不阻塞读。别人 UPDATE 正在改的数据时,你照旧读老版本,互不等待。注意 MVCC 管的是「快照读」(普通 SELECT);SELECT ... FOR UPDATE 这种「当前读」读的永远是最新版本,靠锁来保证。隔离级别之间的差别,很大程度上就是快照什么时候拍:READ COMMITTED 每条语句拍一张,REPEATABLE READ 整个事务用同一张。
再补一个容易混淆的点:MVCC 不解决写与写的冲突。两个事务同时改同一行,该等锁还是得等锁——MVCC 优化的只是「读」这条路径。这也是为什么高并发热点行(比如秒杀库存)光靠调隔离级别没用,得靠排队、乐观锁或把计数挪进 Redis 来解。
慢查询排查流程:一套可以背下来的 SOP
线上接口变慢,怀疑是 SQL,按这个顺序走:
- 拿到嫌疑 SQL:开启慢查询日志(
long_query_time = 1),或从监控/APM 里捞; - EXPLAIN 它:看 type 是不是 ALL、key 是不是 NULL、rows 是不是天文数字、Extra 有没有 filesort/temporary;
- 对症下药:缺索引补索引(注意最左前缀);有索引没用上,检查是不是对索引列用了函数(
WHERE DATE(created_at) = '2026-01-01'会让索引失效)、隐式类型转换(字符串列用数字查)、LIKE '%xx'; - 改写法:能覆盖索引就别
SELECT *;深分页LIMIT 1000000, 20改成WHERE id > 上一页最大id LIMIT 20(游标分页); - 验证:再 EXPLAIN 确认扫描行数骤降,实测响应时间,最后记得关注新索引对写入的影响——索引不是免费的,每次写入都要维护 B+ 树。
常见误区
- 「索引越多越好」:错。每个索引都是一棵要维护的 B+ 树,写入性能随索引数线性下降,按查询需求建,定期清理没人用的索引;
- 「建了索引查询就一定走」:优化器会估算成本,小表或要回表大半张表时,它宁可全表扫描;
- 「事务时间越长越安全」:恰恰相反,长事务占着锁、堆着 undo log,是高并发系统的事故高发区。事务里绝不放网络调用;
- 「SERIALIZABLE 最安全就用它」:它基本等于把并发废掉,除非极特殊的账务场景,不要用;
- 「调低隔离级别就能提升性能」:隔离级别解决的是正确性问题,不是性能问题的首选开关;先查索引和慢 SQL,别在数据正确性上动脑筋。
小结
这一篇的知识链条是:索引用 B+ 树把查找压到几次磁盘 I/O,联合索引服从最左前缀,覆盖索引省掉回表,EXPLAIN 让优化器的计划无所遁形;事务用 ACID 保证正确性,四种隔离级别在正确与性能之间取档,MVCC 用版本链实现读写互不阻塞;慢查询来了,按「拿 SQL → EXPLAIN → 补索引/改写法 → 验证」的套路走。至此数据库的核心内功你已经具备,下一篇我们把视野拉回到单机性能的另一半:并发。
系列导航:上一篇《数据库(上):SQL、建模与范式》(/content/backend-expert-05-database-sql) · 下一篇《并发与性能:进程线程协程、锁与压测》(/content/backend-expert-07-concurrency)
