MySQL通关指南:从索引到优化的硬核知识

📅 2026/7/9 2:57:47 👁️ 阅读次数 📝 编程学习
MySQL通关指南:从索引到优化的硬核知识

面试官问完B+树后,我直接把这篇文章甩给了他

一个深夜,我盯着屏幕上跳动的 Redolog 指针,突然理解了什么叫"先写日志,再写数据"——那一刻,之前背过的所有八股文都活了。

这不是一篇普通的 MySQL 八股文合集。它是一张从"背过"到"真懂"的地图

我花了很长时间,把自己从"背了 B+ 树"到"真懂了为什么是 B+ 树"这个过程拆解出来。你会跟着我从一个最简单的需求出发,一步步重新发明 MySQL 的核心机制。

如果你是面试党——每个章节末尾都有"面试官会追着问"环节,直击痛点。
如果你是实战派——到处都有"生产事故"和"血泪教训"。
如果你是小白——我会用最朴素的比喻让你先建立直觉。

坐稳了,我们从一条 SQL 语句的"一生"开始。


第一章:一条 SQL 的奇幻漂流

想象你执行了这样一条语句:

SELECT*FROMuserWHEREid=42;

这一瞬间,MySQL 内部发生了什么?👇

你(客户端) │ ▼ 【连接器】"你是谁?权限够吗?"(建立连接,验证身份) │ ▼ 【分析器】"这是一条查询语句,查的是 user 表,条件是 id=42"(词法/语法分析) │ ▼ 【优化器】"嗯…有两个索引可用,走主键索引更快!"(选择执行计划) │ ▼ 【执行器】"存储引擎,去把数据拿过来!"(调用引擎 API) │ ▼ 【InnoDB 引擎】"好的,去 B+ 树上找 id=42..."

这个流程,就是一条 SQL 的完整生命周期。你每天写出成百上千条 SQL,每条都走一遍这个路径。

而整个路径中,最影响性能的瓶颈往往在最后一步——存储引擎怎么找数据

这就是为什么面试官最爱问:“MySQL 为什么用 B+ 树做索引?”


第二章:索引——数据库的"目录"是怎么设计的?

2.1 故事要从"找数据"说起

没有索引的数据库是什么样的?想象一个没有目录的《三国演义》——你想找"草船借箭"在哪一页,只能从第一页翻到最后一页。

这就是全表扫描

那你可能会说:“我用 HashMap 来存,O(1) 查找,不香吗?”

面试官追问:为什么不用 Hash 索引?

好问题。我们来看一下:

假设有一张用户表:

idnameage
1张三18
42李四25
100王五30

如果用 Hash 索引,查找id = 42确实快——哈希函数一算,直接定位。

但问题来了:

SELECT*FROMuserWHEREid>10ANDid<50;

Hash 索引直接崩溃——哈希表的本质是散列,数据是乱序存放的,你没法"范围查询"。

那用二叉平衡树呢?

平衡二叉树(AVL/红黑树)确实支持范围查询了,但有个致命问题——层数太高

在 MySQL 中,数据是存在磁盘上的。从磁盘读一次数据(一次 IO)大概需要 10ms,而从内存读只需要 50ns——差了20 万倍

树每深一层,就可能多一次磁盘 IO。如果一棵树有 20 层,查一次就要 200ms——这还是在"理想"情况下。

💡记住一个原则:数据库的性能瓶颈,90% 在磁盘 IO。谁能在最少的 IO 次数内找到数据,谁就是赢家。

那 B 树呢?B+ 树呢?

这两种都是"多路平衡查找树"——每个节点可以有多个子节点,所以树矮胖,层数少。

B 树 vs B+ 树的本质区别:

B 树: [键:值] → 指向子节点 [键:值] → 指向子节点 非叶子节点也存数据! B+ 树: [键:指针] → 指向子节点 ← 非叶子节点只存"目录" [键:指针] → 指向子节点 [数据][数据][数据]... ← 叶子节点才存真正数据 ●────●────●────●────● ← 叶子节点之间用双向链表串起来

B 树的问题在于:非叶子节点既存目录又存数据,一页 16KB 的空间装不下多少条目,树自然就高了。

而 B+ 树非叶子节点只存索引键(约 14 字节)+ 子节点指针,一页 16KB 能塞约1170 个单元

这带来了恐怖的存储能力:

第 1 层:1 个节点 → 约 1170 个指针 第 2 层:1170 个节点 → 约 1170 × 1170 = 137 万个指针 第 3 层(叶子):每页存约 16 行数据 总数据量 ≈ 1170 × 1170 × 16 ≈ 2190 万行 🚀

只用 3 层,就能撑起 2000 万数据。查任何一条数据,最多 3 次磁盘 IO。

而更妙的是 B+ 树的双向链表结构——叶子节点之间用指针串起来了。这意味着:

SELECT*FROMuserWHEREid>10ANDid<1000;

找到id=10之后,顺着链表往后扫就行,不需要一次次回根节点重新找

2.2 聚簇索引与非聚簇索引——叶子节点里到底存什么?

这个问题回答了另一个高频面试题。

聚簇索引——叶子节点存的是整行的完整数据

[叶子节点] id=1 | name='张三', age=18, phone='138xxxx' id=2 | name='李四', age=25, phone='139xxxx'

所以 InnoDB 必须要有一个聚簇索引。如果你没设主键,它会找第一个非空唯一索引;再没有,就自动生成一个隐藏的 row_id

这也意味着:一张表只能有一个聚簇索引——数据只有一份,不能既按 A 顺序存又按 B 顺序存。

非聚簇索引(二级索引)——叶子节点存的是索引列 + 主键值

[叶子节点] age=18 | id=1 age=25 | id=42

所以当你想查SELECT * FROM user WHERE age = 18时:

  1. 先用 age 索引找到主键 id=1
  2. 再用 id=1 去聚簇索引查完整数据

第 2 步就叫回表

回表为什么可怕?

因为每次回表都是一次随机 IO。回表 100 次 ≈ 磁盘上随机跳 100 个位置。

但 MySQL 有一个优化叫MRR(Multi-Range Read)

MRR 的思路很朴素:

  1. 从 age 索引查到一堆 id:[42, 1, 100, 7, 88]
  2. 先排个序:[1, 7, 42, 88, 100]
  3. 按顺序去主键索引回表

这样就把随机 IO 变成了顺序 IO。顺序 IO 比随机 IO 快大概10-100 倍

💡生产经验:曾经有个慢查询,加了MRR优化后从 3 秒降到 200ms。虽然 MRR 是 MySQL 自动优化的,但你需要注意——MRR 只对范围查询有效,而且需要mrr_cost_based=off才能强制使用。

覆盖索引——让回表"不存在"

当然,最优雅的解决方案是:根本就不回表

-- 假设有联合索引 (age, name)-- 需要回表:address 不在索引中SELECTage,name,addressFROMuserWHEREage=18;-- 无需回表(覆盖索引):所有字段都在索引里SELECTage,nameFROMuserWHEREage=18;

把查询需要的字段全部塞进索引里,索引本身就能"覆盖"查询需求——这就是覆盖索引。

💡优化技巧:如果你的查询列表总是固定的几个字段,可以考虑建一个包含这些字段的联合索引,用空间换时间。


2.3 从"最左前缀"到"索引下推"——联合索引的精髓

联合索引(a, b, c)怎么工作的?想象你在整理书架:

  1. 先按a分类(第一层目录)
  2. 在同一个 a 下,按b排序(第二层目录)
  3. 在同一个 a、b 下,按c排序

所以 MySQL 必须从左到右用这个索引:

WHEREa=1✅ 用到索引WHEREa=1ANDb=2✅ 用到索引(最强)WHEREa=1ANDc=3⚠️ a 用到索引,c 用不到WHEREb=2❌ 索引失效(跳过了 a)

这就是最左前缀原则

重点来了:a=1 AND c=3怎么办?

没有索引下推(ICP)时:

  1. a=1去索引里找到所有满足条件的记录 → 假设有 10000 条
  2. 把 10000 条数据全部传到 Server 层
  3. Server 层逐条过滤c=3→ 剩下 10 条
  4. 对剩下的 10 条回表

问题在哪?10000 条数据的传输和检查——这是 SQL 层面干的活,结果发现 9990 条是无用功。

有索引下推(ICP)后:

  1. a=1去索引里找到所有满足条件的记录
  2. 在引擎层直接用 c=3 过滤→ 剩下 10 条
  3. 只把 10 条传到 Server 层
  4. 只对 10 条回表

差距:10000 次回表 vs 10 次回表

📊数据说话:我在一个 500 万数据的表上测试过,一个本来要扫 30 万行的查询,开启 ICP 后只扫了 800 行。执行时间从 1.2 秒降到 70ms。

面试官会追着问:怎么建联合索引?

几个原则:

1. 区分度高的放前面

-- 区分度:独特值的数量 / 总行数-- 性别字段区分度:2 / 100万 ≈ 0.000002(极差)-- 身份证号区分度:100万 / 100万 = 1(极好)

把区分度高的字段放前面,能更早地过滤掉不需要的数据。

2. 高频查询优先

如果一个查询WHERE a = 1 AND b = 2每天跑 10 万次,另一个查询WHERE b = 2 AND c = 3每天跑 100 次——优先满足前者。

3. 考虑排序

如果ORDER BY b是高频操作,而(a, b)索引天然对 b 排好序了,MySQL 可以直接用索引排序,避免 filesort。

2.4 索引失效——面试官最爱挖的坑

下面这些场景,索引会失效,你知道为什么吗?

-- 1. 对索引列用了函数SELECT*FROMuserWHERESUBSTR(phone,1,3)='138';-- 原本 phone 索引是 '13812345678',你拿 '138' 去比,对不上号-- 2. LIKE 通配符开头SELECT*FROMuserWHEREnameLIKE'%三';-- B+ 树是按前缀排序的,'%三' 不知道从哪开始找-- 3. 违反最左前缀SELECT*FROMuserWHEREage=18;-- 联合索引 (name, age),跳过了 name-- 4. OR 连接非索引条件SELECT*FROMuserWHEREid=1ORage=18;-- age 没有索引,MySQL 干脆全表扫描-- 5. 隐式类型转换SELECT*FROMuserWHEREphone=13812345678;-- phone 是 VARCHAR,你传了数字,MySQL 偷偷给 phone 用了 CAST 函数

💡生产事故:同事写了个WHERE create_time > NOW() - INTERVAL 7 DAY,以为 create_time 索引会生效。结果因为字段类型是datetime,NOW() 返回的是datetime,看起来没问题——但如果有函数包裹了 create_time 列(比如DATE(create_time)),索引就废了。排查了一下午才发现。


第三章:事务——为什么你的"扣钱"和"加钱"不会乱?

3.1 从一个转账场景说起

假设你要给朋友转 100 块钱:

UPDATEaccountSETbalance=balance-100WHEREid=1;UPDATEaccountSETbalance=balance+100WHEREid=2;

这两条语句,要么都成功,要么都失败——不能出现"你扣了钱,朋友没收到"的情况。

这就是事务的原子性(Atomicity)

但光有原子性还不够。假设你转完钱,看了一眼余额——嗯,少了一百。然后服务器突然断电了,重启后发现账户余额又变回去了?

这就是持久性(Durability)要解决的问题。

MySQL 通过四大机制保证 ACID:

┌─ 原子性 ──────────────────┐ │ 依赖:Undo Log │ │ 机制:出错就回滚,像没发生过 │ └────────────────────────────┘ ┌─ 一致性 ──────────────────┐ │ 依赖:约束 + 应用逻辑 │ │ 机制:数据永远从一种合法状态 │ │ 到另一种合法状态 │ └────────────────────────────┘ ┌─ 隔离性 ──────────────────┐ │ 依赖:锁 + MVCC │ │ 机制:并发事务互不干扰 │ └────────────────────────────┘ ┌─ 持久性 ──────────────────┐ │ 依赖:Redo Log + 双写 │ │ 机制:提交了就绝对不能丢 │ └────────────────────────────┘

下面我们重点讲两个最核心的:持久性(Redo Log)隔离性(MVCC)

3.2 持久性——数据库的"后悔药"

先想一个问题:为什么不直接把数据写到磁盘,还要搞个 Redo Log?

因为直接写磁盘太慢了!想象一下:

  • 你要修改一行数据,这一行所在的"数据页"(16KB)在磁盘上一个随机位置
  • 写这个数据页需要随机 IO(磁盘转一圈找到位置)
  • 一个事务可能要改 10 个不同位置的数据页
  • 如果每次提交都把这些页刷到磁盘→随机 IO × 10→ 性能灾难

那怎么办呢?MySQL 很聪明——先写日志,再写数据

事务提交 → ● 把"我要把 id=1 的 balance 改成 100"写进 Redo Log(顺序 IO,极快) ● 告诉客户端"好了,提交成功" ● 后台慢慢把数据页刷到磁盘(闲时再干)

这就是WAL(Write-Ahead Logging)技术——先写日志,再写数据。

万一断电了怎么办?重启时 MySQL 会检查 Redo Log,把没来得及写进磁盘的操作重放一遍

💡一句话人话版:Redo Log 像你考试时打的草稿——先在草稿上演算(顺序写,快),考完了再誊写到答题卡(随机写,慢)。就算停电了,草稿还在,重启后接着誊。

但还有个细节问题——“部分写”

MySQL 数据页是 16KB,操作系统一页是 4KB。刷一个数据页要写 4 个 OS 页。如果写了 2 个页时断电了——恭喜你,得到了一个损坏的数据页

这就是Doublewrite Buffer(双写缓冲区)解决的问题:

脏页刷盘 → 1. 先写到 2MB 连续的双写缓冲区(顺序 IO,极快) 2. 再写到实际的磁盘位置(随机 IO) 3. 如果第 2 步断电了,从双写缓冲区恢复

3.3 隔离性——4 个级别,3 种问题,1 个核心机制

先看三个经典问题

脏读:

-- 事务 A 事务 B-- 开始事务-- 把 id=1 的余额从 100 改成 200--(还没提交!)-- 读取 id=1 的余额 → 200(读到未提交的数据!)-- 事务回滚(余额变回 100)-- 事务 B 手里的 200 是"脏"的

不可重复读:

-- 事务 A 事务 B-- 第一次读 id=1 → 100-- 把 id=1 改成 200(提交)-- 第二次读 id=1 → 200-- ???我读同一个 id 两次,结果不一样??

幻读:

-- 事务 A 事务 B-- 查询 age > 10 的用户 → 3 条-- 插入一条 age=15 的新用户(提交)-- 再查 age > 10 的用户 → 4 条-- 多出来那条是"幻觉"吗??
四个隔离级别怎么解决这些问题
脏读 不可重复读 幻读 读未提交(Read Uncommitted) ✅ ✅ ✅ ← 全可能,最危险 读已提交(Read Committed) ❌ ✅ ✅ ← 解决了脏读 可重复读(Repeatable Read) ❌ ❌ ⚠️ ← MySQL 默认!解决了大部分 串行化(Serializable) ❌ ❌ ❌ ← 全都解决了,但性能最差 ✅ = 可能会出现 ❌ = 不会出现 ⚠️ = 基本解决

MySQL 默认是可重复读(RR),不是读已提交(RC——很多其他数据库的默认级别)。为什么?

历史原因:MySQL 5.0 的时候,Binlog 在 RC 模式下可能会造成主从数据不一致。虽然 5.0 之后用 Row 模式的 Binlog 可以解决,但 MySQL 一直保留 RR 作为默认。

面试官追问:那你觉得现在应该用哪个级别?
参考答案:如果对一致性要求极高(金融场景),用 RR;如果对性能要求更高,可以改成 RC + Row 模式 Binlog。RC 的锁竞争更少,并发更高。很多互联网大厂用的就是 RC。

重点来了:MVCC——InnoDB 最牛的发明

MVCC = 多版本并发控制。这个名字很学术,但它的思想其实很简单。

核心思路:一条数据保留多个历史版本,读的时候读旧版本,写的时候写新版本,读写互不干扰。

具体怎么实现的?四个底层组件:

1. 隐藏字段

每一行数据都有三个隐藏列,你看不到但它们真实存在:

DB_TRX_ID → 最后一次修改这条数据的事务 ID DB_ROLL_PTR → 回滚指针,指向"上一个版本" DB_ROW_ID → 隐藏自增 ID(没有主键时用它)

2. Undo Log 版本链

每次修改,不会覆盖旧数据,而是把旧版本链到 Undo Log 里:

[当前版本] ← [版本1] ← [版本2] ← [版本3] ↑ ↑ ↑ ↑ trx_id=105 trx_id=102 trx_id=100 trx_id=98

3. Read View(读视图)——关键!

当你执行查询的那一刻,MySQL 会拍一张"快照",记录当前有哪些事务正在运行。

Read View 包含四个关键信息:

  • m_ids:当前正在运行、还没提交的事务 ID 列表
  • min_trx_id:这些事务中最小的 ID
  • max_trx_id:下一个要分配的事务 ID(最大的 + 1)
  • creator_trx_id:当前事务自己的 ID

4. 可见性规则——怎么判断哪个版本"该我读"

沿着版本链从新往旧找,找到第一个满足条件的版本:

这个版本的事务 ID │ ├── = 我自己的 ID → ✅ 可见(我自己改的) ├── < min_trx_id → ✅ 可见(在我之前就提交了) ├── > max_trx_id → ❌ 不可见(我"出生"之后的事务) └── 在 m_ids 里 → ❌ 不可见(还没提交呢) ↓ 去版本链找更旧的版本

💡生活例子:就像你去图书馆借书——你借书的时候(创建 Read View),先记录下现在谁在借书(m_ids)。别人还书之前,你看不到他们的修改。还了书(事务提交)之后,你再借书(创建新的 Read View)时才能看到。

读已提交(RC)vs 可重复读(RR)的本质区别:

  • RC:每条语句都创建新的 Read View → 同一事务中两次查询可能看到不同结果
  • RR:整个事务只在第一次查询时创建 Read View → 之后所有查询都基于同一份快照

这就是为什么 RR 能解决不可重复读——它在事务开始时就把"世界"定格了。


第四章:日志——数据库的"黑匣子"

4.1 三种核心日志的"人话版"对比

日志一句话人话版记录内容
Redo Log出了事恢复现场用的“page_id=123 上 offset=40 的数据从 18 变成 26”(物理日志)
Binlog出了事找回数据用的“执行了 UPDATE user SET age=26 WHERE id=2”(逻辑日志)
Undo Log后悔了撤销操作用的“id=2 的 age 原来是 18”(快照)

面试官高频追问:Redo Log 和 Binlog 有什么区别?

维度Redo LogBinlog
实现层级InnoDB 引擎层MySQL Server 层(所有引擎通用)
记录内容物理日志(数据页的字节变化)逻辑日志(SQL 语句或行变更)
写入方式循环写,固定大小,写满覆盖旧的追加写,无限增长
用途崩溃恢复,保证持久性主从复制,数据恢复

4.2 两阶段提交——崩溃后数据不乱的关键

想象一个场景:

事务提交了 → Redo Log 写成功 → Binlog 写一半,服务器断电了。

重启后:

  • Redo Log:事务已提交 ✅
  • Binlog:只写了一半 ❌

主从复制时,从库根据 Binlog 同步——这半条记录会让从库数据不对。

反过来呢?

Binlog 写完了 → Redo Log 还没标记提交,断电了 → 事务其实成功了,但 Redo Log 以为没提交 → 重启后数据丢了。

这就是"分布式事务"问题——两个日志之间不一致。

解决方法是两阶段提交

阶段 1(Prepare): Redo Log 写入:事务状态 → "prepare" Redo Log 刷盘 ✅ 阶段 2(Commit): Binlog 写入 ✅ Redo Log 状态更新:"prepare" → "commit"

崩溃恢复时,InnoDB 会扫描所有处于 prepare 状态的 Redo Log:

发现 Redo Log = "prepare" ├── Binlog 完整 → 提交事务(数据不会丢) └── Binlog 不完整 → 回滚事务(数据不会乱) 发现 Redo Log = "commit" → 正常恢复(不用管 Binlog 了)

这样一来,任何时候崩溃,数据要么一致,要么回滚。永远不会有"半提交"的状态。


4.3 主从复制——为什么你能睡个好觉?

你在生产环境肯定遇到过这种场景:凌晨 2 点,搞了个误操作,把一张核心表删了。

这时候你慌不慌?——不慌,因为有从库。

这就是主从复制的价值之一——数据冗余

主从复制有三个核心线程:

主库 从库 ┌──────────────────┐ ┌──────────────────┐ │ Binlog │ │ Relay Log │ │ │──Binlog─────→ │ │ │ Binlog Dump线程 │ Dump 线程 │ IO 线程(接收) │ └──────────────────┘ │ SQL 线程(重放) │ └──────────────────┘

三个阶段:

阶段一:主库事务提交,写入 Binlog
阶段二:从库 IO 线程连上主库,Binlog Dump 线程把日志推过去,从库写入 Relay Log
阶段三:从库 SQL 线程读取 Relay Log,一条条执行

三种复制模式
异步复制:主库写完 Binlog 就返回"成功",不管从库死活 性能最高 ⚡ 一致性最弱 ❌ 半同步复制:至少等一个从库确认收到 Binlog,才返回"成功" 性能中等 ⚡⚡ 一致性中等 ✅ 同步复制:所有从库都写完了,才返回"成功" 性能最差 ❌ 一致性最强 ✅✅

大多数公司用的是异步复制半同步复制。同步复制的性能开销太大了。

💡生产经验:我曾经遇到主库被一个慢查询拖垮,切换从库后发现从库少了 5 秒的数据——因为异步复制有延迟。后来对核心库启用了半同步复制,对非核心业务容忍异步。不要一刀切,根据业务对一致性的要求选模式。

面试官会追着问:主从延迟怎么解决?

这是个好问题,因为主从延迟不可避免——从库要先接收 binlog,再执行 SQL,这中间有时间差。高并发写的时候尤其明显。

三种常见解法:

方案 1:关键查询走主库

# 支付结果查询 → 走主库(强一致)ifquery_type=='payment':db=master()# 历史订单查询 → 走从库(可以接受延迟)else:db=slave()

方案 2:业务容忍 + 提示
用户看到的不是错误数据,而是"数据同步中,请稍后刷新"——对非关键业务够用了。

方案 3:强制读主
有些框架(如 ShardingSphere)支持HintManager,可以在代码里强制某条 SQL 走主库。


第五章:锁——并发控制的"交通警察"

5.1 行锁的三兄弟

InnoDB 最细粒度的锁是行锁。但"行锁"其实是个统称,下面有三种具体形态:

行锁(Row Lock) │ ├── 记录锁(Record Lock) │ "就锁这一行,别的不动" │ 例子:WHERE id = 42(主键等值查询命中) │ ├── 间隙锁(Gap Lock) │ "锁住这个范围,谁也别想插进来" │ 例子:WHERE id > 10 AND id < 20(范围查询) │ └── 临键锁(Next-Key Lock) "记录锁 + 间隙锁 的组合体" 默认的行锁类型
临键锁的退化规则

这是面试的"隐藏关卡":

-- 案例 1:等值查询,命中记录SELECT*FROMuserWHEREid=42FORUPDATE;-- ➡ 退化为记录锁(只锁 id=42 这一行)-- 案例 2:等值查询,没命中任何记录SELECT*FROMuserWHEREid=100FORUPDATE;-- id=100 不存在-- ➡ 退化为间隙锁(锁住 id=100 附近的间隙,防止插入)-- 案例 3:范围查询SELECT*FROMuserWHEREid>10ANDid<20FORUPDATE;-- ➡ 保持临键锁(锁住这个范围的数据和间隙)

为什么会有间隙锁?

为了解决幻读

如果没有间隙锁,事务 A 执行SELECT * FROM user WHERE age > 10 FOR UPDATE,事务 B 在这时候插入了一条 age=15 的新记录——事务 A 再查一次,多了一条。这就是幻读。

间隙锁锁住的是"范围",不让任何人往这个范围内插入新数据。

5.2 意向锁——“省事的聪明锁”

想象一下这样的场景:

事务 A 给表里的 100 行数据加了行锁。这时候事务 B 想给整张表加表锁。

如果没有意向锁,MySQL 需要逐行检查这 100 行有没有被锁——这得做 100 次判断。

有了意向锁就简单了:

事务 A 给某行加锁前 → 先在表上标记"我打算加行锁"(加意向锁) 事务 B 想加表锁时 → 一看表上有意向锁 → 知道有人在用行锁 → 直接等待 不用逐行检查!

💡一句话总结:意向锁不是用来"锁"的,是用来"通知"的——告诉别人:“我要加行锁了,你想加表锁的话先等着。”


第六章:从理论到实战——优化篇

理论知识学完了,落地到真实场景怎么用?

6.1 三个优化案例(来自真实生产环境)

案例一:深分页的血泪史

问题

-- 前台说"第 100001 页加载不出来"SELECT*FROMorderWHEREstatus=1LIMIT1000000,20;

这条 SQL 看似只取 20 条,但 MySQL 实际上扫了 1000020 行,然后丢掉前 1000000 行。

解法——游标分页

-- 记住上一页最后一条的 idSELECT*FROMorderWHEREstatus=1ANDid>1000000ORDERBYidLIMIT20;

MySQL 直接通过索引定位到 id=1000000 的位置,然后往后取 20 条。前面的 100 万行?不用扫。

案例二:Select * 的低调杀手

问题:一个小改动,把一个 10ms 的查询变成了 500ms。

原来:

SELECTid,name,statusFROMuserWHEREphone='138xxxx';-- phone 有唯一索引,查询很快改成:SELECT*FROMuserWHEREphone='138xxxx';-- * 包含了所有字段,回表了!多了一堆不必要的 IO

原则

  1. SELECT *可能让覆盖索引失效,被迫回表
  2. 多传的字段增加了网络传输开销
  3. 如果表有 TEXT/BLOB 字段,SELECT *会让查询直接爆炸

解法:明确写出你需要的字段。

案例三:or 导致索引失效

问题

SELECT*FROMuserWHEREid=1ORphone='138xxxx';-- phone 没有索引 → 全表扫描

为什么?MySQL 的逻辑是:id 走索引找到一条 → phone 没有索引,需要全表扫描 → 那不如干脆全表扫描算了。

解法

-- 给 phone 加索引-- 或者改成两个查询 UNIONSELECT*FROMuserWHEREid=1UNIONSELECT*FROMuserWHEREphone='138xxxx';

6.2 用 EXPLAIN 读懂执行计划

EXPLAIN 是 MySQL 优化师的第一工具,输出中的type字段是最直观的性能指标:

type = system → ⭐ 巅峰!表只有一行(几乎是理论值) type = const → ⭐⭐⭐ 完美!主键/唯一索引等值查询 type = eq_ref → ⭐⭐⭐ 优秀!联表查询,每表只返回一行 type = ref → ⭐⭐ 还行!非唯一索引等值查询 type = range → ⭐ 将就!索引范围查询 type = index → ⚠️ 小心!遍历整个索引树 type = ALL → ❌ 灾难!全表扫描

生产原则:

  • OLTP 系统(高并发在线交易):type必须在ref及以上
  • 报表/离线系统:可以容忍range甚至index
  • 看到 ALL → 必须优化

6.3 表结构优化:分库分表

什么时候需要分库分表?

这不是一个"要不要"的问题,而是"什么时候不得不"的问题。

单表 500 万行以下 → 正常建索引就行 单表 1000 万行左右 → 考虑分区表或垂直拆分 单表 5000 万行以上 → 水平分库分表

拆分的两种方式:

水平拆分——把数据"横着切",按规则分到多张结构相同的表。

拆分规则优点缺点
哈希取模数据均匀分布扩容要重新哈希,麻烦
按时间范围范围查询友好,扩容简单数据不均匀(热数据集中)
按业务键(user_id)天然避免跨分片查询分片键选错了就完了

实践建议:优先用user_idorder_id作为分片键,因为大部分查询都是"查某个用户的所有订单"——分片键选对了,90% 的查询都不会跨分片。

分库分表后的三个大坑

坑 1:跨表 JOIN

-- 分库前:一条 JOIN 搞定SELECT*FROMorderoJOINuseruONo.user_id=u.id-- 分库后:订单在 order_db,用户在 user_db → JOIN 不了

解法:冗余字段 + 应用层组装。在订单表里冗余用户名、手机号。

坑 2:全局唯一 ID
原来的自增 ID 不能用了——不同分片的自增会重复。

常用方案:

  • 雪花算法(Snowflake):64 位整数,全局唯一(时间戳 + 机器 ID + 序列号)
  • Redis 发号器:用 Redis INCR 命令生成 ID
  • 号段模式:一次拿一批 ID(如 Leaf 算法)

坑 3:跨分片分页排序
“查所有用户中第 100-110 名的订单”——每个分片都要取前 110 条,在内存中排序后取第 100-110 条。分片越多,性能越差。

解法:禁止深分页,或者用"二次查询"方案。


终章:一条路线图

MySQL 的知识体系看起来庞大,但只要你抓住了这条主线,一切都串起来了:

┌─────────────────────────────────────────────────────┐ │ MySQL 知识地图 │ ├─────────────────────────────────────────────────────┤ │ │ │ 一条 SQL 的旅程 │ │ ↓ │ │ 连接器 → 分析器 → 优化器 → 执行器 → 存储引擎 │ │ ↓ │ │ B+ 树索引 │ │ / \ │ │ 聚簇索引 非聚簇索引 │ │ ↓ ↓ │ │ 回表问题 ← 覆盖索引消灭回表 │ │ ↓ │ │ 联合索引 │ │ / \ │ │ 最左前缀 索引下推(ICP) │ │ │ │ 写入一条数据 │ │ ↓ │ │ Buffer Pool → Redo Log(Prepare) → Binlog → Commit │ │ ↓ │ │ 两阶段提交(崩溃恢复) │ │ ↓ │ │ 主从复制(异步/半同步/同步) │ │ │ │ 多个事务并发 │ │ ↓ │ │ 隔离级别:RU → RC → RR → Serializable │ │ ↓ │ │ MVCC → Read View + Undo 版本链 │ │ ↓ │ │ 行锁(Record/Gap/Next-Key) │ └─────────────────────────────────────────────────────┘

建议的学习路线

第一阶段(入门):理解 B+ 树和索引 → 懂 EXPLAIN → 会写带索引的 SQL
第二阶段(进阶):理解事务和隔离级别 → 懂 MVCC → 会分析死锁
第三阶段(深入):理解 Redo Log 和 Binlog → 懂两阶段提交 → 能排查主从延迟
第四阶段(高手):分库分表设计 → SQL 调优实战 → 故障演练

推荐的工具和资料

工具用途必会程度
EXPLAINSQL 执行计划分析⭐⭐⭐⭐⭐
SHOW PROFILESQL 各阶段耗时分析⭐⭐⭐⭐
performance_schema数据库性能监控⭐⭐⭐⭐
pt-query-digest慢查询日志分析(Percona Toolkit)⭐⭐⭐
sys.schema_unused_indexes查找从未用过的索引⭐⭐⭐

💬 欢迎在评论区留言讨论,有问必答。如果你在生产环境中遇到过"诡异"的 MySQL 问题,欢迎分享你的故事。