排序、分组与 Join 原理
ORDER BY、GROUP BY、JOIN 到底怎么执行的 —— 这三个是「SQL 为什么慢」追问链的终点,也是最能区分「会用」和「懂原理」的题目。
🔥 ORDER BY 的两种排序模式
查询 SELECT city, name, age FROM t WHERE city='深圳' ORDER BY name LIMIT 1000,假设 city 有索引、name 没有。
全字段排序
- 初始化
sort_buffer,放入name、city、age三个字段 - 从
city索引取出满足条件的主键,回表取这三个字段,存进sort_buffer - 对
sort_buffer按name快速排序 - 取前 1000 行返回
如果 sort_buffer_size 装不下,就要用外部排序 —— 把数据分成多份,每份在内存里排好序写成临时文件,最后做归并排序。explain 看不出来,要用:
SET optimizer_trace='enabled=on';
SELECT ...;
SELECT * FROM information_schema.OPTIMIZER_TRACE;
-- 看 number_of_tmp_files:0 表示全内存排序,>0 表示用了临时文件
rowid 排序
如果单行太大(字段多或字段长),放进 sort_buffer 的行数太少,归并的临时文件会很多。此时 MySQL 改用 rowid 排序:
sort_buffer里只放name和id- 按
name排序 - 按排好的顺序,再回表一次取
city、age返回
触发条件:单行长度超过 max_length_for_sort_data(默认 1024 字节)。
:::tip 两种模式的取舍是 MySQL 的一贯倾向
| 全字段排序 | rowid 排序 | |
|---|---|---|
| sort_buffer 里存 | 所有需要的字段 | 排序字段 + 主键 |
| 回表次数 | 1 次 | 2 次 |
| 内存占用 | 大 | 小 |
| 临时文件风险 | 高 | 低 |
MySQL 优先选全字段排序 —— 体现的是「如果内存够,就要多利用内存,尽量减少磁盘访问」这个设计原则。只有单行实在太大时才退化为 rowid 排序。
MySQL 8.0.20 之后 max_length_for_sort_data 已被弃用,由优化器自行判断。
:::
真正的优化:让索引天然有序
上面两种都是「不得不排序」。ORDER BY 并不一定需要排序 —— 如果索引本身就是按目标顺序组织的,直接顺序读就行:
ALTER TABLE t ADD INDEX city_name(city, name);
-- 现在 WHERE city='深圳' ORDER BY name 完全不需要排序
-- explain 的 Extra 里 Using filesort 消失
再进一步做成覆盖索引,连回表都省了:
ALTER TABLE t ADD INDEX city_name_age(city, name, age);
-- Extra: Using index
这是「ORDER BY 优化」的标准答案 —— 不是调大 sort_buffer_size,而是建对索引让排序消失。
ORDER BY ... LIMIT N 的优先队列优化
当 LIMIT N 的 N 很小时,MySQL 不做全量排序,而是用堆(优先队列):维护一个大小为 N 的堆,扫描过程中不断替换堆顶。复杂度从 O(n log n) 降到 O(n log N),内存从全量降到 N 行。
optimizer_trace 里会显示 "filesort_priority_queue_optimization": {"chosen": true}。
:::warning ORDER BY RAND() 为什么是性能杀手
SELECT * FROM t ORDER BY RAND() LIMIT 3 —— 要给每一行算一个随机数,然后对全表做排序(通常是 rowid 排序 + 临时文件),只为取 3 行。
正确做法:先 SELECT COUNT(*),再在应用层生成随机 offset,用 LIMIT offset, 1 单独取(或者用主键范围随机)。
:::
GROUP BY 的执行
没有合适索引时,GROUP BY 用内存临时表:
- 创建一个内存临时表,以分组字段为唯一索引
- 扫描原表,逐行往临时表里
INSERT ... ON DUPLICATE KEY UPDATE式地累加 - 临时表超过
tmp_table_size(默认 16MB)就转成磁盘临时表(InnoDB 引擎) - 最后对临时表排序返回
三种优化:
| 手段 | 做法 |
|---|---|
| 加索引(最优) | 让分组字段有序,MySQL 直接顺序扫描边读边聚合,Extra 显示 Using index for group-by,无需临时表 |
SQL_BIG_RESULT | 告诉优化器数据量很大,直接用排序代替临时表(排序能落盘,临时表转磁盘的开销更大) |
加 ORDER BY NULL | 5.7 及之前 GROUP BY 会隐式排序,加这个能去掉排序步骤 |
:::warning MySQL 8.0 移除了 GROUP BY 的隐式排序
5.7 及之前,GROUP BY c 会顺带按 c 排序。8.0 取消了这个行为。
后果:从 5.7 升级到 8.0 后,依赖隐式排序的代码结果顺序会变,而且不报错。排查这类「升级后顺序乱了」的问题,根因就在这里。凡是依赖顺序的查询都必须显式写 ORDER BY。
:::
🔥 Join 的三种算法
SELECT * FROM t1 STRAIGHT_JOIN t2 ON t1.a = t2.a;
STRAIGHT_JOIN 强制 t1 做驱动表(左表),便于分析。
Index Nested-Loop Join(NLJ)
被驱动表的关联字段有索引时使用:
遍历 t1(N 行)
↓ 每行取出 a 值
去 t2 的索引上查(树高 h,约 2·log₂M 次扫描)
- 扫描行数 ≈
N + N·2·log₂M - 复杂度由驱动表行数 N 主导
- 结论:小表做驱动表
Block Nested-Loop Join(BNL)
被驱动表没有索引时使用。注意不是朴素的 Simple Nested-Loop(那是 N×M 次磁盘扫描,MySQL 不用):
把 t1 整表读进 join_buffer(内存)
↓
顺序扫描 t2 一遍(M 行)
↓ 每行在 join_buffer 里做内存比较
- 磁盘扫描行数 = N + M(都只扫一遍)
- 内存比较次数 = N × M ← 代价在这里
join_buffer_size装不下 t1 时要分块:分 K 块就要扫 t2 K 遍,变成N + K·M
:::danger BNL 的三重危害
- 多次扫描被驱动表 —— 分块后 t2 被反复全表扫,大量磁盘 IO
- N×M 次内存比较 —— 单线程 CPU 打满
- 污染 Buffer Pool —— 被驱动表的数据页被反复读入。更阴险的是:如果被驱动表是大于 Buffer Pool old 区的冷表,且两次扫描间隔超过 1 秒(
innodb_old_blocks_time),这些冷数据页会晋升到 young 区,把真正的热点数据全部挤出去。业务的整体命中率下降,影响会持续很久
所以 explain 里看到 Using join buffer (Block Nested Loop) 要当成告警,优先给被驱动表的关联字段加索引。
:::
Hash Join(8.0.18+)
BNL 的 N×M 内存比较是纯粹的浪费 —— 这本该是哈希表的活。MySQL 8.0.18 终于加上了 Hash Join:
扫描 t1,在内存里建哈希表
↓
扫描 t2,每行用 O(1) 的哈希查找匹配
比较次数从 N×M 降到 N+M。MySQL 8.0.20 起彻底移除了 BNL,等值 join 无索引时一律走 hash join。
所以「join 没索引会很慢」这个结论在 8.0.18+ 已经大幅缓和 —— 面试能指出这个版本差异很加分。
MRR 与 BKA
MRR(Multi-Range Read) —— 优化回表。二级索引取出的主键是乱序的,逐个回表是随机 IO。MRR 先把主键攒起来排序,再按主键顺序回表,变成接近顺序的 IO,还能利用预读。
SET optimizer_switch='mrr_cost_based=off'; -- 让优化器更倾向用 MRR
-- Extra 显示 Using MRR
BKA(Batched Key Access) —— 把 MRR 的思想用到 join 上:攒一批驱动表的关联值,排序后批量去被驱动表查。
SET optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';
BKA 是对 NLJ 的优化(有索引时用),Hash Join 是对 BNL 的替代(无索引时用),两者解决的是不同情形。
Join 的实践规则
- 被驱动表的关联字段必须有索引(让它走 NLJ 而不是 BNL)
- 小表做驱动表 —— 注意「小」指的是参与 join 的数据量(过滤条件之后的行数 × 字段大小),不是表的总行数
- 控制 join 的表数量 —— 阿里规范是「禁止超过三张表 join」,因为优化器对多表 join 的顺序选择容易出错
LEFT JOIN的左表固定是驱动表,INNER JOIN由优化器决定(可能和你写的顺序不同,看explain)
🔥 COUNT(*) 的实现
为什么 InnoDB 的 COUNT(*) 比 MyISAM 慢?
- MyISAM 把总行数存在磁盘上,
COUNT(*)直接返回,O(1)。但加了WHERE条件就没这个优化了 - InnoDB 必须逐行扫描累计。根本原因是 MVCC —— 由于多版本并发控制,「这张表有多少行」这个问题在同一时刻对不同事务有不同的答案,所以不存在一个可以缓存的「真实行数」
InnoDB 的优化是:COUNT(*) 时优化器会选最小的那棵索引树来扫描(普通索引树比主键索引树小得多,因为不含完整行数据)。
几种 count 的性能排序:
COUNT(*) ≈ COUNT(1) < COUNT(主键 id) < COUNT(字段)
COUNT(*)—— 专门优化过,不取值,只累加行数COUNT(1)—— 遍历每行,放一个常数 1 进去判断非空,不解析行字段COUNT(id)—— 要把 id 字段取出来返回给 server 层判断非空COUNT(字段)—— 要取字段值 + 判断是否为 NULL(NULL 不计数,语义也不同)
结论:用 COUNT(*),它语义清楚且性能最好。
SHOW TABLE STATUS 的 Rows 不能用 —— 它是采样估算值,误差可达 40~50%。
用 Redis 存计数的问题:Redis 和 MySQL 是两个系统,无法放进同一个事务,所以必然存在「查到了计数但那条记录还没插入」或反之的不一致窗口。即使调整顺序也只能减小窗口不能消除。
正解是把计数放在 MySQL 的一行里 —— 更新计数和插入数据放进同一个事务,靠事务的隔离性保证「计数和实际数据永远一致」。性能靠行锁的并发控制和热点行拆分来解决。
LIMIT 的一个反直觉现象
SELECT * FROM t ORDER BY id LIMIT 1; -- 快
SELECT * FROM t ORDER BY name LIMIT 1; -- 可能很慢(要全表排序)
还有一个更隐蔽的:
SELECT * FROM t WHERE 条件 ORDER BY id LIMIT 1;
如果 条件 的选择性很差,优化器可能选择走主键索引顺序扫描(因为 ORDER BY id LIMIT 1 看起来只要扫一行就能停),结果扫了几百万行才找到第一个满足条件的。这类「加了 LIMIT 反而更慢」的案例,根因都是优化器误判。用 FORCE INDEX 或改写 SQL 解决。