mysql查询性能与原理
mysql查询性能与原理
1. MySQL多表联查(Join)会导致缓慢吗?
不一定。多表联查本身不一定会慢,但在“姿势不对”或者“数据量过大”时,由于其算法机制,非常容易导致性能雪崩。
下面我将由浅入深,分三个层次为你详细拆解。
第一层:直观理解 —— 为什么联查可能会慢?
想象一下,你有两本书:
书A(学生表): 有100页,每页写着一个学生的名字和班级ID。
书B(班级表): 有10页,每页写着班级ID和班级名称。
现在你要找出“张三”是哪个班级名称的。
-
情况1(快):
你在书A的目录(索引)里找到了“张三”,看到班级ID是5。然后你去书B的目录(索引)找到ID 5,得知是“三年二班”。这很快。 -
情况2(慢):
这两本书都没有目录(没有索引)。
你需要从书A的第一页开始翻,翻到“张三”拿到ID 5。然后拿着ID 5去书B,从第一页开始翻,直到找到ID 5为止。如果你要找所有学生的班级名称,你得把书A的每一行都拿出来,去书B里翻一遍。这就非常慢。
结论: 联查慢,通常是因为数据库在“傻乎乎地翻书”,而不是“查目录”。
第二层:底层原理 —— MySQL是怎么执行Join的?
要理解为什么慢,必须了解MySQL默认的联表算法:Index Nested-Loop Join(基于索引的嵌套循环连接)。
假设 SQL 是:SELECT * FROM table_A JOIN table_B ON table_A.col = table_B.col;
MySQL 的执行逻辑大概是写了两个 for 循环:
Java
// 伪代码逻辑
for (Row rowA : table_A) { // 外层循环(驱动表)
// 取出 A 表的一行数据
// 去 B 表里查找匹配的数据
Row rowB = searchInTableB(rowA.col); // 内层循环(被驱动表)
if (rowB != null) {
return combine(rowA, rowB);
}
}
这里就是决定快慢的核心:
- 驱动表(外层循环): 也就是上面的 table_A。如果这个表数据量很大,外层循环次数就多。
- 被驱动表(内层循环): 也就是 table_B。searchInTableB 这一步至关重要。
- 如果有索引: 查找复杂度是 O(logN),非常快。
- 如果没索引: 变成了全表扫描,复杂度是 O(N)。
算一笔账:
假设 A 表 1万条,B 表 1万条。
- 有索引: A循环1万次,每次去B里用索引找(很快)。总操作量级大概是 10000×13 (log2(10000)≈13)。
- 无索引: A循环1万次,每次都要把B表从头扫到尾。总操作量级是 10000×10000=1亿 次!性能直接爆炸。
第三层:实战场景 —— 到底什么情况会慢?
作为Java开发人员,你在写代码或Review SQL时,遇到以下情况要警铃大作:
1. 关联字段没有索引 (最常见)
这是新手最容易犯的错。
- 场景: User 表关联 Order 表,用 user_id 关联。但是 Order 表的 user_id 字段没建索引。
- 后果: 典型的“全表扫描”,数据量一上来,查询时间从毫秒级变秒级甚至超时。
- 关联字段数据类型不一致 (隐形杀手)
- 场景: 表A的 id 是 VARCHAR 字符串类型,表B的 id 是 INT 数字类型。
ON table_A.varchar_id = table_B.int_id - 后果: MySQL 必须在内存中把类型转成一样的才能比较。这会导致索引失效! 也就是虽然你建了索引,但MySQL用不上,依然慢。
- 场景: 表A的 id 是 VARCHAR 字符串类型,表B的 id 是 INT 数字类型。
3. 字符集不一致 (隐形杀手)
- 场景: 表A是 utf8,表B是 utf8mb4。
- 后果: 和上面一样,需要进行字符集转换,导致索引失效。这是老系统升级时常见的问题。
4. 小表驱动大表原则被破坏
- 原理: 我们希望外层循环(驱动表)越少越好。
- 场景: 你用一个 100万行的大表去 Join 一个 100行的小表,而且还没用好索引。虽然MySQL优化器会自动优化谁在左谁在右,但在复杂查询中优化器有时会选错。
5. 关联的表太多 (复杂度爆炸)
- 场景: A JOIN B JOIN C JOIN D JOIN E …
- 后果: 每多一张表,MySQL优化器计算最佳执行计划的复杂度就呈指数级上升,且产生锁竞争的概率增加。一旦其中一张表出现锁等待,整个链条都会卡住。
- 业界规范: 阿里巴巴Java开发手册建议,联表查询不要超过3张表。
6. 使用了 Select *
- 场景: SELECT * FROM A JOIN B …
- 后果: 联表会产生临时表。如果 SELECT * 包含大字段(如 text/blob),临时表体积变大,内存放不下时会转入磁盘(磁盘临时表),速度急剧下降。
给初级开发的建议(解决方案)
既然知道了原因,我们该怎么办?
- EXPLAIN 是你的好朋友:
在所有你觉得可能会慢的 SQL 前面加上 EXPLAIN 关键字执行一下。
-
看 type 列:如果是 ALL,说明全表扫描了,必须优化。最好是 ref 或 eq_ref。
-
看 key 列:显示 NULL 说明没用上索引。
-
看 rows 列:预估扫描了多少行。
-
确保 ON 后面的字段有索引:
-
被关联的字段(尤其是被驱动表)必须建立索引。
-
单表查询 + Java内存组装(推荐):
-
在微服务和高并发系统中,现在流行少用Join。
- 做法:
- 先查表A:SELECT * FROM A WHERE … (得到一堆 user_id)
- 在Java代码里收集这些 user_id。
- 再查表B:SELECT * FROM B WHERE user_id IN (1, 2, 3…)
- 在Java代码里把数据拼装起来。
- 好处: 简单、对数据库压力小、方便利用缓存(Redis)、方便分库分表。
字段类型要对齐: - 建表时,作为外键关联的字段,名称、类型、长度、字符集必须完全一致。
2. 单表索引查,单表非索引查和链表查的性能
1. 第一点:单表查索引行 -> O(logN)
✅ 完全正确。
解析: MySQL(InnoDB引擎)的主键索引是 B+ 树结构。查找时,是从树根往下找,就像翻一本有序的字典,不需要全看,只需要几次比较就能找到,效率极高。
2. 第二点:单表查非索引行 -> “全表查O(N)查到索引再回表”
❌ 这里有误解。
你把“全表扫描”和“回表”两个概念搞混了。
纠正: 如果你查的字段没有索引(比如 WHERE age = 18,age没建索引),MySQL 会直接在聚簇索引(也就是存放实际数据的主键索引树的叶子节点)上进行遍历。
它不是“查到索引再回表”。
它是直接挨个把数据行拿出来看,如果不符合条件就扔掉,符合就留下。
过程: **直接扫描全表数据 O(N)。没有“回表”这个动作,**因为你已经在“表”里了。
那什么是“回表”?
- 场景: 你查 WHERE name = ‘张三’,name 是普通索引(二级索引)。
- 步骤1: 先去 name 的 B+ 树索引里找,找到了 ‘张三’,这棵树的叶子节点里存的是主键ID(比如 ID=5)。
- 步骤2: 拿着 ID=5,去主键索引(聚簇索引)的大树里,把这一行完整的记录(包括age, address等)取出来。
- 结论: 步骤2 拿着ID回去查数据的过程,才叫“回表”。
总结一下修正后的逻辑:
查非索引字段: 直接全表扫描 O(N),不涉及回表。
查普通索引字段: 先查二级索引 O(logN) -> 拿到ID -> 回表查主键索引 O(logN)。
3. 第三点:联表查询,若都用非索引值 -> 性能很差
✅ 核心逻辑正确,但有一点要细化。
你提到的“驱动表和被驱动表都是用非索引值查询”,这个表述里包含了两层意思,我们拆解一下谁是“最大的凶手”:
驱动表(A表)没索引:
- 后果:A表需要全表扫描。
- 影响:虽然慢,但只是第一步慢(比如扫1万行)。这是 O(N)。
被驱动表(B表)的关联字段(Join Key)没索引:
- 后果:这才是灾难的根源。
- 意味着:A表查出来的每一行(假设1万行),都要去B表里进行一次全表扫描(假设B表也是1万行)。
- 运算量:10,000×10,000=1亿次。这是 O(N×M)。
修正后的精确理解:
联表查询慢,最最核心的原因是 被驱动表(第二张表)的连接字段没有索引。只要这个有了索引,就算驱动表(第一张表)是全表扫描,整体速度通常也是可以接受的。
给你的最终修正版记忆口诀
为了方便记忆,你可以更新一下你的知识库:
- 有索引查: 走 B+ 树,极快,O(logN)。
- 无索引查: 只能全表硬扫,O(N)。(注意:此时没有回表)。
- 普通索引查: 查普通索引树拿到ID,再回表查数据。(多了一步路,但在数据量大时依然比全表扫快得多)。
- 联表Join:
第一张表(驱动表)最好能用 WHERE 过滤得少一点。
死命令: 第二张表(被驱动表)的 ON 字段必须有索引,否则就是笛卡尔积级别的灾难。
3. 除了聚簇索引(主键索引)的字段并不是自动成为二级索引
1. 核心误区纠正
误区: “不是主键就应该是二级索引。”
真相: 大错特错。
默认情况: 一张表建好后,默认 只有 主键索引(聚簇索引)。
其他字段: 除非你 显式(手动) 去创建索引,否则其他字段(比如 name, age, create_time)就是普通的“平民”字段,没有任何索引。
状态: 它们只是存储在叶子节点里的“数据”而已,数据库并不为它们维护 B+ 树。
我们会根据查询需求,给很多常用字段手动建立二级索引。
2. 图解:什么叫“没索引” vs “有二级索引”
假设有一张 用户表 (User):
id (主键, PK)
phone (手机号)
场景: 你要根据手机号查人:SELECT * FROM User WHERE phone = '13800000000';
- 状态 A:只建了主键索引(新手常犯的情况)
- 结构: 只有一棵 B+ 树(按 ID 排序)。
- 执行: 数据库只知道 ID 排好了序。它完全不知道 phone 是怎么排的(乱序的)。
- 查询: 为了找这个手机号,它必须从 ID=1 开始,一行一行把数据拿出来比对手机号。
- 结果: 全表扫描 O(N)。如果表有100万行,就比对100万次。巨慢。
- 状态 B:你手动给 phone 建了二级索引
- 结构: 数据库会额外多建一棵 B+ 树。这棵树按照 phone 排序,叶子节点存的是 phone 和 id。
- 执行:
去 phone 的树里二分查找,O(logN) 找到 ID。
拿着 ID 去主键树里“回表”拿数据。 - 结果: 毫秒级响应。
3. 怎么建立二级索引?
作为 Java 开发,通常有三种方式:
- 方式一:在建表语句(DDL)中直接写
这是最标准的做法。
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
phone VARCHAR(20),
-- 这里建立了一个名为 idx_phone 的普通二级索引
INDEX idx_phone (phone),
-- 这里建立了一个名为 uk_username 的唯一二级索引
-- (唯一索引也是二级索引的一种,它还强制字段值不能重复)
UNIQUE KEY uk_username (username)
);
- 方式二:表已经建好了,后期补加
如果表已经上线了,发现查询慢,可以用 ALTER 命令加。
-- 给 phone 字段创建一个普通索引
ALTER TABLE users ADD INDEX idx_phone (phone);
-- 给 username 字段创建一个唯一索引
ALTER TABLE users ADD UNIQUE INDEX uk_username (username);
4. 联表查询谁在左谁在右的依据
对于 LEFT JOIN、RIGHT JOIN 和 INNER JOIN,判断“谁在左、谁在右”的依据是完全不同的。
我们需要分两种情况来讨论:逻辑需求(业务) 和 性能优化。
情况一:INNER JOIN(内连接)
-
语法:
SELECT * FROM A JOIN B ON A.id = B.id -
逻辑上: 左右没区别。A JOIN B 和 B JOIN A 查出来的结果集是完全一样的(取交集)。
-
性能上(谁在左?):
- 原则: 小表驱动大表。
但是! 你不需要手动去调整 A 和 B 的顺序。
为什么? 因为 MySQL 的查询优化器(Optimizer) 非常聪明。它在执行 SQL 前,会自动评估 A 表和 B 表的数据量及索引情况,自动把“小表”放到驱动表的位置(也就是把它当成左表),把“大表”放到被驱动表的位置。
- 原则: 小表驱动大表。
-
结论: 写 INNER JOIN 时,你随便写,MySQL 会帮你搞定顺序。
情况二:LEFT JOIN / RIGHT JOIN(外连接)
语法: SELECT * FROM A LEFT JOIN B ON A.id = B.id
这里就大不相同了。
1. 这里的“左右”首先由【业务逻辑】决定
这是你必须遵守的第一原则,不能为了性能随便改。
-
LEFT JOIN: 左表(A) 是主表。我想看A表的所有人,不管他在B表里有没有数据。
例子: 查所有“用户”,顺便看看他们有没有“下订单”。如果没下订单,订单信息显示 NULL,但用户必须要在。写法:
FROM User LEFT JOIN Order -
RIGHT JOIN: 右表(B) 是主表。
备注: 实际开发中,极少使用 RIGHT JOIN。因为阅读习惯是从左到右。如果你觉得右表重要,直接把左右表位置换一下,改用 LEFT JOIN 就行了。
2. 既然左右由业务定了,那怎么利用“小表驱动大表”优化?
这就是最纠结的地方。
-
在 LEFT JOIN 中:
左边(LEFT) 的表,固定就是 驱动表。
右边(RIGHT) 的表,固定就是 被驱动表。
MySQL 优化器不能随意改变这个顺序,否则查出来的结果集(行数)就不对了。
尴尬的情况来了:
如果业务要求:我们要查 所有用户(100万) 和他们的订单(User LEFT JOIN Order)。
这里 User 是驱动表。
此时,大表(User)做驱动表。
这违背了“小表驱动大表”的原则。
怎么解决?
-
硬抗(如果必须查全量):
如果你确实需要导出这100万用户的报表,那就只能让它慢一点。但在被驱动表(Order表)的连接字段(user_id)上必须建立索引。只要被驱动表有索引,大表驱动大表也是勉强能跑的(虽然不是最优)。 -
在左表加 WHERE 条件(变相变小表):
通常业务并不需要一次查100万人。SELECT * FROM User u LEFT JOIN Order o ON u.id = o.user_id WHERE u.create_time > '2023-01-01' -- 这里过滤后,u表可能只剩1000人了
此时,虽然写的还是 User 在左,但参与 Join 的实际数据量变小了,就实现了逻辑上的小表驱动大表。
总结:应该怎么判断?
-
如果是 INNER JOIN:
不用管左右。 闭着眼睛写,MySQL 优化器会自动把小表放到前面去驱动大表。
-
如果是 LEFT JOIN:
- 第一步(看业务): 哪个表的数据必须要全部显示?那个表放左边。
- 第二步(看性能):
- 如果你发现左边的表非常大(比如日志表、全量用户表),就要警惕了。
- 尽量给左边的表加上 WHERE 条件,让它先把自己过滤变小。
- 必须确保 右边表的 ON 字段建了索引。
-
初级开发的“保命”口诀:
INNER JOIN: 随便写。
LEFT JOIN: 重要的主表放左边。
索引: 无论哪种 Join,ON 后面右边那个表的字段,一定要加索引!
更多推荐

所有评论(0)