MySQL连接算法和小表驱动大表的原理
MySQL连接算法和小表驱动大表的原理
MySql连接算法对比
MySQL连接算法概览
MySQL支持四种主要的连接算法,每种算法都有其特点和适用场景:
- Nested Loop Join (NLJ) - 基础嵌套循环连接
- Block Nested Loop Join (BNL) - 块嵌套循环连接
- Batched Key Access (BKA) - 批量键访问连接(MySQL 5.6+)
- Hash Join - 哈希连接(MySQL 8.0.18+)
Nested Loop Join (NLJ)
核心机制:
for each row in 驱动表:for each matching row in 被驱动表(通过索引查找):join the rows
关键特性:
- 核心机制:对驱动表的每一行,通过索引查找被驱动表中的匹配行。
- 不使用 Join Buffer
- 必须要求:被驱动表的连接字段有索引
- 时间复杂度:O(M×logN),其中 M 是驱动表行数,N 是被驱动表行数
- 执行计划标识:无特殊标记
Block Nested Loop Join (BNL)
核心机制:
for each batch of rows from 驱动表(存入Join Buffer):for each row in 被驱动表:compare against all rows in the bufferjoin matching rows
过程:
- 批量读取驱动表数据到Join Buffer: MySQL 会一次性从驱动表读取一批数据行(通常是256KB大小的数据,具体行数取决于行宽度)到内存中的 Join Buffer。
- 全表扫描被驱动表并与Buffer中所有行匹配: 然后,它会完整地扫描一遍被驱动表。在扫描被驱动表的过程中,被驱动表的每一行都会与 Join Buffer 中所有已加载的驱动表行进行比较,寻找匹配的行。
关键特性:
- 核心机制:批量读取驱动表数据到Join Buffer,然后将Join Buffer中的每一行与被驱动表进行全表扫描匹配。
- 使用 Join Buffer(大小由 join_buffer_size 控制)
- BNL仅用于被驱动表无索引的场景。
- 时间复杂度:O(M×N/B)(B=缓冲区容纳的行数)
- 执行计划标识:Using join buffer
Batched Key Access (BKA)
核心机制:
- 批量收集驱动表连接键
- 通过 MRR (Multi-Range Read) 按主键顺序访问被驱动表
关键特性:
-
需要手动启用:
SET optimizer_switch='batched_key_access=on';
-
依赖被驱动表的二级索引
-
结合 NLJ 的索引优势和 BNL 的批量处理
BKA算法的执行逻辑:
批量查询优化:通过MRR接口批量提交键值(非生成IN语句):
1. 从驱动表缓存一批行(Join Buffer)
2. 排序这批行的连接键
3. 按顺序访问被驱动表索引(减少随机I/O)
Hash Join
核心机制:
- 对小表构建内存哈希表
- 扫描大表并探测哈希表匹配
关键特性:
- 仅MySQL 8.0.18+支持,且仅用于等值连接(非等值连接仍用BNL/NLJ)
- 自动启用,不用我们去启用
- 无需索引支持
- 特别适合等值连接
- 执行计划标识:
Using hash join
MySQL如何选择使用什么连接算法
MySQL 是会通过成本模型自动选择算法来进行自动选择使用什么算法来进行连接的。
MySQL版本 | 默认可用算法 | 自动选择优先级 |
---|---|---|
5.6及以下 | NLJ, BNL | NLJ > BNL |
5.7 | NLJ, BNL, BKA(需手动开启) | NLJ > BKA > BNL |
8.0.18+ | NLJ, BNL, BKA(需手动开启), Hash Join | Hash Join > NLJ > BKA > BNL |
算法如下:
if (被驱动表有可用索引) {计算NLJ成本if (MySQL ≥5.6) 计算BKA成本
} else {计算BNL成本if (MySQL ≥8.0.18) 计算Hash Join成本
}
选择成本最低的方案
但是注意,可以人工干预MySQL使用的连接器的。
比如:
-- 强制小表驱动(当优化器选择不当时)
SELECT /*+ JOIN_ORDER(small, large) */ *
FROM small_table small JOIN large_table large ON small.id=large.id;-- 强制使用BNL(被驱动表无索引时)
SELECT /*+ BNL(small, large) */ *
FROM small_table small JOIN large_table large ON small.col=large.col;
小表驱动大表
小表驱动大表是MySQL JOIN优化的核心原则,本质是:
- 驱动表(外层循环表)应该选择两个表中过滤后结果集更小的表(不一定是物理表行数少,而是经过WHERE条件过滤后剩余行数少的表)
- 被驱动表(内层循环表)必须确保连接字段有索引(利用B+树的高效查询)
小表是哪个
这里说的小表不一定是物理表行数少,而是经过WHERE条件过滤后结果少的表。
例子:
-- big_table看物理记录数是大表,但过滤后这里假设变成小结果集,那么就适合下面这样写。
SELECT * FROM big_table b
JOIN small_table s ON b.id = s.id
WHERE b.create_time > '2023-01-01' -- 使b成为实际小表
怎么区分那边是驱动表
-
左连接(LEFT JOIN)
左连接时,左表是驱动表,右表是被驱动表。因此应该把小表放在左边:
-- 正确:小表驱动大表 SELECT * FROM small_table s LEFT JOIN big_table b ON s.id = b.id;-- 不正确:大表驱动小表 SELECT * FROM big_table b LEFT JOIN small_table s ON b.id = s.id;
-
右连接(RIGHT JOIN)
右连接时,右表是驱动表,因此应该把小表放在右边:
-- 正确:小表驱动大表 SELECT * FROM big_table b RIGHT JOIN small_table s ON b.id = s.id;-- 不正确:大表驱动小表 SELECT * FROM small_table s RIGHT JOIN big_table b ON s.id = b.id;
-
内连接(INNER JOIN)
对于内连接,MySQL优化器会自动选择小表作为驱动表,但显式指定更可靠:
-- 显式指定小表在前 SELECT * FROM small_table s INNER JOIN big_table b ON s.id = b.id;
强制驱动顺序:用STRAIGHT_JOIN强制左表为驱动表:
SELECT * FROM small_table s STRAIGHT_JOIN big_table b ON s.id = b.id; -- 强制s为驱动表
不同连接算法的小表驱动大表
场景:100行小表 vs 10万行大表(假设一行1KB,Join Buffer默认是256KB的,所以如果一个行记录是1KB,那么一次可以拿256条数据。)
算法 | 大表驱动小表 | 小表驱动大表 | 关键条件 |
---|---|---|---|
NLJ | 差:10万次驱动 × 小表索引查询时间 | 优:100次驱动 × 大表索引查询时间 | 被驱动表必须有索引 |
BNL | 差:分391批 × 全表扫描小表100行查询时间 | 中:分1批 × 全表扫描大表10万行查询时间 | 被驱动表无索引 |
BKA | 中:391批 × 多值查询小表索引查询时间 | 优:1批 × 多值查询大表索引查询时间 | 被驱动表有索引 + 启用BKA优化 |
Hash Join | 无影响(优化器自动选小表建哈希表) | 无影响 | MySQL 8.0.18+,等值连接 |
💡 关键结论:
- NLJ/BNL/BKA:必须小表驱动大表。
- Hash Join:驱动顺序不影响性能。
- BNL:尽量避免(无索引时性能最差)。
- 数据库的JOIN算法对表顺序的依赖不同,但“小表驱动大表”原则在多数场景下均适用。
注意:8.0.18+连接算法的优先级为Hash Join > NLJ > BKA(需手动开启) > BNL
Nested Loop Join算法执行原理
MySQL执行表连接时,通常采用嵌套循环连接(Nested Loop Join)算法,所以找了就讲这个算法:
- 从驱动表中取出一条记录
- 通过索引查找被驱动表匹配的记录
- 重复上述过程直到驱动表所有记录处理完毕
for 驱动表的每一行:通过索引快速查找被驱动表匹配行
NLJ性能对比分析
场景 | 驱动次数 | 每次I/O次数 | 总I/O次数 |
---|---|---|---|
小表(100)驱动大表(10w) | 100次 | 3次(大表索引高度) | 300次 |
大表(10w)驱动小表(100) | 10万次 | 2次(小表索引高度) | 200,000次 |
为什么Nested Loop Join小表驱动大表快,大表驱动小表不快?下来来分析一下:
- 小表(100)驱动大表(10w)
100次驱动 × 3次I/O(B+树高度)
= 300次索引节点访问
(假设大表索引高度=3)。 - 大表(10w)驱动小表(100)
100,000次驱动 × 2次I/O
= 200,000次索引节点访问
(假设小表索引高度=2)
他们都要进行100×100,000=10,000,000次匹配。被驱动表是可以走索引的,但是驱动表只能一个个去遍历数据。
mysql使用索引去查询的时间复杂度是O(log n),因为底层是B+树。如果B+树的高度是2,那么O(log n)就是2,如果B+树的高度是3,那么O(log n)就是3,使用索引查询就需要3的时间。
想象你在两个图书馆找书:
-
小表驱动:你拿着100本书的清单(小表),去有10万本书的图书馆(大表)找对应的书
- 你只需要去书架100次
- 每次都能通过图书编号快速定位
-
大表驱动:你拿着10万本书的清单(大表),去有100本书的小书架(小表)找对应的书
- 你需要来回跑10万次
- 虽然小书架容易找,但跑腿次数太多
BNL性能分析(被驱动表无索引)
注意,上面的例子算时间复杂度的时候,是Nested Loop Join算法中的。
如果被驱动表没有索引,那么就用Block Nested Loop Join连接。
在Block Nested Loop Join中要还要考虑Join Buffer的批量优化。优化的效果是,MySQL实际不会逐行处理,而是批量读取驱动表数据后(假设是一次读取256行驱动表的数据),一次性查询被驱动表(即,一次匹配多个被驱动表):
FOR batch IN (SELECT * FROM 驱动表 LIMIT 256)SELECT * FROM 被驱动表 WHERE id IN (batch.id_list)
分析如下:
场景 | 实际I/O次数 | 原因 |
---|---|---|
小表(100)驱动大表(10w) | 10万次(全表扫描1次) | 1批 × 大表全扫描 |
大表(10w)驱动小表(100) | 391万次(100×391次) | 391批 × 小表全扫描 |
10w/256=390.625,向上取整为391。
需要补充的实际情况:
-
行宽度的影响:
MySQL 的 join_buffer_size 参数以 字节(Bytes) 为单位(默认 256KB),如果单行数据1KB,256KB Buffer能缓存256行,如果单行10KB,只能缓存25行。如果一行是100KN,那么只能存储25行数据。即,如果一行的更大了,那么批次数会增加。
个人站点链接
我的博客链接:Luca的博客