当前位置: 代码网 > it编程>数据库>Mysql > MySQL通关指南之从索引到优化的硬核知识

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

2026年09月18日 Mysql 我要评论
一个深夜,我盯着屏幕上跳动的 redolog 指针,突然理解了什么叫"先写日志,再写数据"——那一刻,之前背过的所有八股文都活了。这不是一篇普通的 mysq

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

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

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

如果你是面试党——每个章节末尾都有"面试官会追着问"环节,直击痛点。

如果你是实战派——到处都有"生产事故"和"血泪教训"。

如果你是小白——我会用最朴素的比喻让你先建立直觉。

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

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

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

select * from user where id = 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 * from user where id > 10 and id < 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 * from user where id > 10 and id < 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 不在索引中
select age, name, address from user where age = 18;

-- 无需回表(覆盖索引):所有字段都在索引里
select age, name from user where age = 18;

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

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

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

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

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

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

where a = 1            ✅ 用到索引
where a = 1 and b = 2  ✅ 用到索引(最强)
where a = 1 and c = 3  ⚠️ a 用到索引,c 用不到
where b = 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 * from user where substr(phone, 1, 3) = '138';
-- 原本 phone 索引是 '13812345678',你拿 '138' 去比,对不上号

-- 2. like 通配符开头
select * from user where name like '%三';
-- b+ 树是按前缀排序的,'%三' 不知道从哪开始找

-- 3. 违反最左前缀
select * from user where age = 18;
-- 联合索引 (name, age),跳过了 name

-- 4. or 连接非索引条件
select * from user where id = 1 or age = 18;
-- age 没有索引,mysql 干脆全表扫描

-- 5. 隐式类型转换
select * from user where phone = 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 块钱:

update account set balance = balance - 100 where id = 1;
update account set balance = balance + 100 where id = 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:关键查询走主库

# 支付结果查询 → 走主库(强一致)
if query_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 * from user where id = 42 for update;
-- ➡ 退化为记录锁(只锁 id=42 这一行)

-- 案例 2:等值查询,没命中任何记录
select * from user where id = 100 for update;  -- id=100 不存在
-- ➡ 退化为间隙锁(锁住 id=100 附近的间隙,防止插入)

-- 案例 3:范围查询
select * from user where id > 10 and id < 20 for update;
-- ➡ 保持临键锁(锁住这个范围的数据和间隙)

为什么会有间隙锁?

为了解决幻读

如果没有间隙锁,事务 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 * from order where status = 1 limit 1000000, 20;

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

解法——游标分页

-- 记住上一页最后一条的 id
select * from order 
where status = 1 and id > 1000000
order by id 
limit 20;

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

案例二:select * 的低调杀手

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

原来:

select id, name, status from user where phone = '138xxxx';
-- phone 有唯一索引,查询很快
改成:
select * from user where phone = '138xxxx';
-- * 包含了所有字段,回表了!多了一堆不必要的 io

原则

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

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

案例三:or 导致索引失效

问题

select * from user where id = 1 or phone = '138xxxx';
-- phone 没有索引 → 全表扫描

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

解法

-- 给 phone 加索引
-- 或者改成两个查询 union
select * from user where id = 1
union
select * from user where phone = '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 * from order o join user u on o.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通关指南之从索引到优化硬核知识的文章就介绍到这了,更多相关mysql索引到优化的知识内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2026  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com