2359 字
约 7 分钟
14
S11-X19 · MySQL 连接算法:Nested-Loop、BNL 与 Hash Join 的选择
2024-12-31
2026-11-09

两张表一 join 就变慢,加索引也没救——因为慢的不是"访问哪一行",是"访问多少行"。EXPLAIN 里那行 type=ALL 后面跟着的 Using join buffer,说明优化器放弃了索引驱动,改走块嵌套循环:被驱动表整段读进来,在内存里跟驱动表逐行比对。

连接算法的选择直接决定复杂度是 O(N log M) 还是 O(N × M)。下面把 MySQL 实际会用的四种算法拆开:各自的前提、EXPLAIN 里长什么样、什么条件下会切换。


1. 四种算法一览

MySQL 8.0 的 join 实现收敛到一张不大的清单:

算法 全称 复杂度量级 关键前提
NLJ Nested-Loop Join 被驱动表每次按索引查,O(N × log M) 被驱动表连接列有索引
BNL Block Nested-Loop O(N × M / buffer批量) 无索引,且有 join_buffer
Hash Join 哈希连接 O(N + M) 等值连接 + 无索引(8.0.18 起)
BKA Batched Key Access 批量回表,O(N × log M) 但 IO 更少 有索引 + MRR 开启

选择由优化器做,但它受三个外部条件约束:有没有可用索引、join_buffer_size 多大、optimizer_switch 开了什么。理解这三点,就能预测优化器会选哪个。

-- 先看优化器开关的真实值,不同版本默认值不同
SELECT @@optimizer_switch\G
-- 关注这几项:
--   block_nested_loop=on
--   batched_key_access=off    ← 默认关闭
--   hash_join=on              ← 8.0.18+ 才有
--   mrr=on

2. NLJ:最理想但最挑索引

嵌套循环连接就是两层 for 循环,外层是驱动表,内层是被驱动表。内层每一轮都用索引定位——所以它要求被驱动表的连接列上有可用的索引前缀。

EXPLAIN SELECT o.id, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.created_at >= '2026-01-01';
id table type key rows Extra
1 o range idx_created 1200 Using index condition
1 u eq_ref PRIMARY 1 NULL

eq_ref 是 NLJ 最健康的样子:被驱动表每轮只回一行,rows=1。判断口诀——驱动表 rows 是估计扫描量,被驱动表 rows=1 说明是精确索引命中。

rows 相乘就是这次 join 的大致工作量。上面是 1200 × 1。如果被驱动表显示 rows=800 且 type=ALL,那就是 1200 × 800 ≈ 96 万次比对,必然走 BNL。


3. BNL:没有索引时的兜底

当被驱动表在连接列上没有索引,优化器会把被驱动表的一段数据读进 join_buffer,然后拿驱动表的每一行去内存里比对。EXPLAIN 里会明确写出来:

EXPLAIN SELECT o.id, t.tag_name
FROM orders o
JOIN order_tags t ON t.order_id = o.id;
+----+-------+------+------+------+--------------------------------------------------+
| id | table | type | key  | rows | Extra                                            |
+----+-------+------+------+------+--------------------------------------------------+
|  1 | o     | ALL  | NULL | 5000 | NULL                                             |
|  1 | t     | ALL  | NULL | 8000 | Using where; Using join buffer (hash join)       |
+----+-------+------+------+------+--------------------------------------------------+

注意 8.0.18 之后同样是无索引场景,Extra 可能已经写成 (hash join) 而不是 (block nested loop)——版本差异会直接改变你读到的结论,必须按实际版本核对。

join_buffer_size 决定一次能装多少行。装不下就分块:被驱动表扫多遍,每遍装一块。

join_buffer_size 被驱动表扫描次数 代价
小于单块 多趟扫描 磁盘 IO 放大
覆盖全表 一趟 内存占用高
过大(数百 MB) 一趟但按连接分 并发高时内存爆
-- 会话级临时调大,用于验证"是否 buffer 太小导致多趟扫描"
SET SESSION join_buffer_size = 64 * 1024 * 1024;

这里的陷阱是:join_buffer_size 是每个连接、每个 join 分配的,不是全局共享。100 个并发连接各开 64MB,就是 6.4GB。


4. Hash Join:8.0.18 之后的主力

等值连接 + 被驱动表无索引,优化器会改走哈希连接:把小表建成哈希表,大表扫一遍探查。复杂度从 N × M 降到 N + M。

触发条件的完整清单(按 8.0.18+ 的规则):

条件 要求 不满足时
连接类型 必须是等值(=) 退回 BNL
索引 被驱动表无可用索引 有索引时仍优先 NLJ
内存 能在 join_buffer_size 内建表 分批,可能落盘
类型 两侧比较类型可哈希 类型不一致可能失效
-- 强制验证 hash join 的收益:临时关掉再对比
SET SESSION optimizer_switch = 'block_nested_loop=off';
-- 8.0.20+ 可以用 hint 逐语句指定
EXPLAIN /*+ HASH_JOIN(o, t) */ SELECT ... ;

一个反直觉的点:给被驱动表加上索引,可能让查询变快或变慢。加了索引优化器走 NLJ,如果驱动表行数很大而每次回表都是随机 IO,NLJ 反而比一次性哈希建表更慢。所以"加索引"不是无条件正确,要看驱动表的行数级。


5. BKA 与 MRR:批量回表

BKA(Batched Key Access)本质是 NLJ 的 IO 优化:先从驱动表攒一批 key,排序后一次性提交给存储引擎,让随机回表变成近似顺序读。它依赖 MRR(Multi-Range Read)。

SET SESSION optimizer_switch = 'mrr=on,mrr_cost_based=off,batched_key_access=on';
开关 默认 作用
mrr on 允许范围扫描批量回表
mrr_cost_based on 由代价决定是否用 MRR;关掉则强制
batched_key_access off BKA 的总开关,默认关

batched_key_access 默认关闭不是遗漏:BKA 的代价模型在某些数据分布下会高估收益,导致选了更差的路。开启前必须在真实数据量下对比,不能只看单条 SQL 的 EXPLAIN。

BKA 生效时 EXPLAIN 的 Extra 会出现 Using join buffer (Batched Key Access),且 rows 列会显示批量倍数。


6. 驱动表的选择:谁当外层

优化器选驱动表看的是"过滤后行数",不是表的总行数。但它的估计依赖统计信息,统计信息不准就会选错。

-- 看统计信息新鲜度
SELECT table_name, last_update, n_rows
FROM mysql.innodb_table_stats
WHERE table_name IN ('orders','order_tags');
判断依据 含义 选错的表现
rows 估计 过滤后预期行数 小表被当外层才正常
filtered 剩余比例 联合估计误差放大
直方图 列值分布 无直方图时等值条件估计极粗
-- 给倾斜列建直方图,能显著改善 join 顺序选择
ANALYZE TABLE orders UPDATE HISTOGRAM ON status, user_id WITH 64 BUCKETS;

STRAIGHT_JOIN 是强制指定驱动顺序的逃生口,但它只应在确认优化器选错时使用——一旦数据分布变化,写死的顺序可能变成新的瓶颈。


7. 排错:五种 join 变慢的真实原因

现象 真实原因 处理
Extra 显示 Using join buffer 且查询慢 被驱动表连接列无索引 建索引;索引建不上再评估 hash join
rows 乘积巨大但实际返回很少 统计信息过期,估计偏差 ANALYZE TABLE,必要时加直方图
内存持续上涨伴随 join join_buffer_size 按连接 × 按 join 分配 降单值,或拆查询
加了索引反而更慢 NLJ 随机回表次数超过哈希建表成本 对比两种计划的真实耗时,别只看 type
hash join 用了但没提速 建表侧超过 join_buffer_size,分批落盘 调大 buffer 或先收窄子查询

还有一类不属于算法的慢:ON 条件里写了函数(ON DATE(t.created)=o.date),索引直接失效,任何算法都救不回来。


8. 边界:什么时候该在应用层拆

场景 建议 理由
两张超大表等值 join,结果集很小 先各自过滤再 join 减少参与哈希的行数
需要跨库 / 跨实例 join 应用层分两次查再内存拼装 MySQL 不支持跨实例 join
join 后要做复杂聚合 先落临时表再聚合 避免优化器在 join 与聚合间反复权衡
只取被驱动表少量列 覆盖索引或子查询预聚合 减少回表
分页 + join 先对驱动表分页再 join 否则 LIMIT 在 join 之后才生效

最后一条最常被忽略:ORDER BY ... LIMIT 20 配 join 时,如果排序键在被驱动表上,MySQL 必须把 join 结果全物化再排序。先分页取驱动表的 20 个 id,再按 id 批量取明细,通常快一个量级。


9. 自测

  1. EXPLAIN 中被驱动表 type=eq_ref 且 rows=1,说明走的是哪种算法?
  2. Using join buffer (block nested loop) 出现的直接前提是什么?
  3. 8.0.18 之后同样无索引的等值连接,Extra 可能变成什么?
  4. join_buffer_size 是全局共享还是按连接分配?
  5. batched_key_access 为什么默认关闭?
  6. 给被驱动表加索引一定更快吗?什么情况下不是?
S11-X19 · MySQL 连接算法:Nested-Loop、BNL 与 Hash Join 的选择
https://www.clxhxhhr.top/posts/6024/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。