JOIN 大表时驱动表选择不当导致慢,怎么优化?
How to optimize a slow JOIN on large tables caused by an incorrect driving table
考察要点
这道题面试官想看你是否深入理解数据库 JOIN 的内部执行原理,而不仅仅是会写 JOIN 语法。考察的核心点包括:
- 驱动表与被驱动表的概念:是否明白
JOIN操作中两张表的角色和执行顺序。 JOIN执行算法:是否了解Index Nested-Loop Join、Block Nested-Loop Join、Hash Join等不同算法的原理、优劣和触发条件。- 优化器的角色:是否知道优化器(CBO)如何基于成本估算来选择驱动表和
JOIN算法,以及“小表驱动大表”的真正含义。
核心概念
要理解驱动表选择问题,必须先掌握以下几个核心概念。
1. 驱动表 (Driving Table) 与被驱动表 (Driven Table)
在 JOIN 查询中,数据库优化器会选择一张表作为基础,遍历其数据,然后根据 JOIN 条件去另一张表中查找匹配的数据。
- 驱动表:首先被访问的表,相当于循环中的外层循环(Outer Loop)。
- 被驱动表:根据驱动表返回的每一行数据,去其中查找匹配数据的表,相当于内层循环(Inner Loop)。
执行逻辑伪代码:
1FOR each row R1 in Driving_Table:2 FOR each row R2 in Driven_Table:3 IF join_condition_is_met(R1, R2):4 output (R1, R2)
关键点:驱动表的选择直接决定了对被驱动表的访问次数。如果驱动表有 N 行,那么被驱动表理论上将被访问 N 次。因此,减少外层循环的次数是优化的关键。
2. JOIN 执行算法
MySQL 主要依赖以下几种算法来执行 JOIN。
| 算法 | 原理 | 触发条件 | 性能 |
|---|---|---|---|
| Index Nested-Loop Join (INLJ) | 遍历驱动表,对于驱动表的每一行,使用被驱动表上的索引来查找匹配行。 | 被驱动表的 JOIN 关联列上有索引。 | 最高效。访问被驱动表的成本从全表扫描 降为索引查找 。 |
| Block Nested-Loop Join (BNLJ) | 将驱动表的多行数据读入内存中的 join_buffer,然后一次性地全表扫描被驱动表,与 join_buffer 中的所有行进行匹配。 | 被驱动表的 JOIN 关联列上没有可用索引。 | 性能较差。会多次全表扫描被驱动表(取决于驱动表大小和 join_buffer_size)。MySQL 8.0.18 已废弃 BNLJ,默认使用 Hash Join。 |
| Hash Join | 1. Build Phase: 选择小表(驱动表),在内存中基于 JOIN 键构建一个哈希表。 <br> 2. Probe Phase: 遍历大表(被驱动表),用每一行的 JOIN 键去哈希表中探测(Probe)匹配的行。 | MySQL 8.0.18+ 默认在无索引时使用。PostgreSQL/Oracle 等数据库早已是标配。 | 性能远好于 BNLJ。两张表都只需扫描一次,时间复杂度近似 。 |
ASCII 图示:INLJ vs BNLJ
1graph TD2 subgraph INLJ (高效)3 A[驱动表 t1] -- 逐行读取 --> B{row1}4 B -- "ON t1.id = t2.t1_id" --> C[使用 t2.t1_id 的索引]5 C -- O(log M) 快速定位 --> D[被驱动表 t2]6 end78 subgraph BNLJ (低效, MySQL < 8.0.18)9 E[驱动表 t1] -- 批量读取 --> F[join_buffer]10 F -- "ON t1.id = t2.t1_id" --> G{与 t2 中每一行比较}11 G -- O(M) 全表扫描 --> H[被驱动表 t2]12 end
3. 优化器选择驱动表的原则
MySQL 优化器遵循 成本估算模型(Cost-Based Optimizer, CBO)。其核心目标是选择一个总成本最低的执行计划。
“小表驱动大表”的正确理解:
这里的“小”和“大”指的不是表的总行数,而是经过 WHERE 条件过滤后,参与 JOIN 的结果集大小。
成本估算公式(简化版):
Total_Cost = Cost(Driving_Table) + Rows(Driving_Table) * Cost(Driven_Table_Per_Access)
Cost(Driving_Table): 访问驱动表的成本(全表扫描或索引扫描)。Rows(Driving_Table): 驱动表过滤后的行数估算。Cost(Driven_Table_Per_Access): 访问被驱动表一次的成本。- 如果是 INLJ,成本是索引查找,非常低 ()。
- 如果是 BNLJ/Hash Join,成本是全表扫描或哈希探测,相对较高。
结论:优化器会优先选择过滤后结果集更小的表作为驱动表。同时,它会极力促成 INLJ,即让有索引的表成为被驱动表。如果 t1 过滤后有 100 行,t2 过滤后有 10000 行:
- 如果
t2的JOIN键有索引,优化器会选t1做驱动表,t2做被驱动表。 - 如果
t1的JOIN键有索引,t2没有,优化器会选t2做驱动表,t1做被驱动表。
常见低分回答
-
“
LEFT JOIN左边的表就是驱动表,INNER JOIN看谁写在前面”- 错误:这是最常见的误解。对于
INNER JOIN,优化器会自由交换表的顺序。对于LEFT JOIN,虽然语义上要求保留左表所有行,但在某些情况下(如WHERE条件将LEFT JOIN降级为INNER JOIN),优化器也可能重排。驱动表的选择权在优化器,不由 SQL 书写顺序决定。
- 错误:这是最常见的误解。对于
-
“行数少的表就是小表,应该做驱动表”
- 错误:如上所述,“小”指的是过滤后的结果集大小。一个有 1 亿行但
WHERE id = 1的表(结果集 1 行)远小于一个 10 万行但无WHERE条件的表。
- 错误:如上所述,“小”指的是过滤后的结果集大小。一个有 1 亿行但
-
“优化
JOIN就是让小表驱动大表”- 不完整:这只说对了一半。更关键的是确保被驱动表的关联列上有索引,以触发高效的 INLJ 算法。如果两张表在关联列上都没有索引,即使遵循“小表驱动大表”,性能依然会很差(退化为 BNLJ 或 Hash Join)。
-
“直接用
STRAIGHT_JOIN就行了”- 危险:
STRAIGHT_JOIN是强制指定JOIN顺序的“核武器”,它会完全禁用优化器的重排能力。这只应在你百分百确定优化器选错了,并且通过EXPLAIN证实了的情况下作为最后手段。滥用STRAIGHT_JOIN会让数据库失去应对数据变化的自适应能力。
- 危险:
工程实践与优化
1. 如何发现驱动表选择不当?
使用 EXPLAIN 是不二法门。
1EXPLAIN SELECT u.name, o.order_no2FROM `user` u3JOIN `order` o ON u.id = o.user_id4WHERE u.city = 'Beijing';
查看 EXPLAIN 输出:
- 表的顺序:
EXPLAIN结果中,第一行出现的表就是驱动表。 type列:- 如果被驱动表的
type是ALL,意味着正在进行全表扫描,这是巨大的性能隐患。 - 理想情况下,被驱动表的
type应该是eq_ref(主键/唯一索引等值连接)或ref(普通索引等值连接)。
- 如果被驱动表的
key列:如果被驱动表的key列为NULL,说明没有使用索引。Extra列:- 如果出现
Using join buffer (Block Nested Loop),说明发生了低效的 BNLJ,必须优化。 - 如果出现
Using hash join,说明无索引,但使用了比 BNLJ 更优的 Hash Join。
- 如果出现
2. 如何优化?
核心原则:创造条件让优化器选择最优计划。
-
为被驱动表的关联列创建索引(首选方案) 这是最根本、最有效的优化手段。
sql1-- 假设 order 表是 user_id 上的被驱动表2ALTER TABLE `order` ADD INDEX `idx_user_id` (`user_id`);创建索引后,优化器大概率会采用 INLJ,性能会得到数量级的提升。
-
保证索引有效 即使有索引,以下情况也会导致索引失效,退化为 BNLJ/Hash Join:
- 类型不匹配:
JOIN的两列类型不同,如varchar和int。MySQL 会进行隐式类型转换,导致索引失效。 - 字符集/校对规则不匹配:
JOIN的两列字符集或校对规则不同。 - 对被驱动表的关联列使用函数:如
ON t1.id = YEAR(t2.create_time)。
- 类型不匹配:
-
使用
STRAIGHT_JOIN强制指定驱动表(最后手段) 当你通过EXPLAIN确认优化器因统计信息不准等原因选错了驱动表,可以手动干预。sql1-- 假设 user 表过滤后结果集更小,但优化器误选了 order 做驱动表2SELECT STRAIGHT_JOIN u.name, o.order_no3FROM `user` u -- 强制 user 为驱动表4JOIN `order` o ON u.id = o.user_id5WHERE u.city = 'Beijing'; -
升级 MySQL 版本 如果还在使用低于 8.0.18 的版本,并且无法添加索引,升级到新版本可以使用 Hash Join 替代 BNLJ,也能获得显著的性能提升。
常见追问与延伸题目
-
追问:如果数据量增长 100 倍,这条 SQL 性能怎样?怎么优化?
- 回答要点 1 (性能分析):性能退化程度取决于当前的
JOIN算法。- INLJ:总成本约 。如果驱动表
N增长 100 倍,查询时间约增长 100 倍,性能退化是线性的,相对可控。 - BNLJ/Hash Join:BNLJ 成本约 ,Hash Join 成本约 。如果两表都增长 100 倍,BNLJ 性能可能下降 10000 倍,完全不可接受。Hash Join 性能下降约 100 倍,与 INLJ 类似,但常数因子更大。
- INLJ:总成本约 。如果驱动表
- 回答要点 2 (优化方案):在大数据量下,除了保证 INLJ,还需要考虑:
- 索引覆盖:在索引中包含所有查询所需字段,避免回表,进一步提升性能。
- 分区表:如果
JOIN键和过滤条件高度相关(如按时间、地域),可以对两张大表使用相同的分区键进行分区。这样JOIN可以在分区内部进行(Partition-wise Join),大大减少了需要扫描的数据量。 - 业务拆分/SQL 改写:将一个复杂的大
JOIN拆分成多个小查询,通过中间结果或临时表来完成,降低单次查询的复杂度和锁定的资源。
- 回答要点 1 (性能分析):性能退化程度取决于当前的
-
追问:
STRAIGHT_JOIN和INNER JOIN的ON条件、WHERE条件有什么区别?- 回答要点:
INNER JOIN中,写在ON后的条件用于确定两表如何关联,写在WHERE后的条件用于对关联后的结果集进行过滤。但对于INNER JOIN,优化器可能会为了效率重写ON和WHERE的条件,逻辑上是等价的。STRAIGHT_JOIN强制了表的连接顺序,但ON和WHERE的作用与INNER JOIN相同。它的主要影响是固定了驱动表和被驱动表,使得优化器无法交换它们。
- 回答要点:
-
追问:如果换成 PostgreSQL / TiDB,
JOIN优化有什么不同?- 回答要点 1 (PostgreSQL):PostgreSQL 的优化器更成熟,除了 INLJ 和 Hash Join,还支持 Merge Join。Merge Join 要求两张表在
JOIN键上都已排序,然后像拉链一样合并。如果数据已经有序(例如通过索引扫描得到),Merge Join 效率非常高。PG 的优化器会在这三种JOIN策略中选择成本最低的一个,通常不需要用户过多干预。 - 回答要点 2 (TiDB/分布式数据库):在分布式数据库中,
JOIN优化增加了数据分布的维度。- Index Lookup Join: 类似单机 INLJ,通过驱动表数据去远程查询被驱动表,有网络开销。
- Hash Join: 分为 Shuffle Hash Join(将两张大表按
JOIN键重新哈希分布到不同节点再JOIN,网络开销大)和 Broadcast Hash Join(将小表广播到所有节点,与大表在本地JOIN,适合“小表驱动大表”场景)。 - TiDB Colocated Join: 如果两张表按
JOIN键进行了分片(Placement Rules),且分片位置相同,JOIN就可以在本地完成,无需网络传输,是最高效的方式。 - 结论:在分布式数据库中,除了考虑索引和结果集大小,还必须考虑数据亲和性,尽量避免跨网络的数据 shuffle。
- 回答要点 1 (PostgreSQL):PostgreSQL 的优化器更成熟,除了 INLJ 和 Hash Join,还支持 Merge Join。Merge Join 要求两张表在