LEFT JOIN 改 INNER JOIN 释放优化器
LEFT JOIN 导出 100 万订单:3 秒多的代价
运营后台需要导出所有已支付订单及对应的用户昵称。订单表 100 万行,用户表 10 万行。开发同学习惯性地写下了 LEFT JOIN——"万一有的订单没有用户呢?"结果这条查询跑了 3 秒多:
SELECT o.id, o.order_no, o.amount, o.created_at, u.nickname
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.status = 1;业务上已支付订单一定存在对应用户(支付前必须登录),LEFT JOIN 完全是多余的。但这个"以防万一"的写法,让优化器失去了重排 JOIN 顺序的自由,被迫以 100 万行的订单大表作为驱动表。
真实场景
很多开发者把 LEFT JOIN 当作"安全默认值",凡是 JOIN 都写 LEFT。但 LEFT JOIN 的语义是"保留左表全部行",这个约束会锁死优化器的 JOIN 顺序选择。审查项目中的 LEFT JOIN,你会发现大量场景其实应该是 INNER JOIN。
问题分析
bad.sql
-- LEFT JOIN 语义要求保留左表全部行,优化器必须以 t_order 为驱动表
-- 即使 t_user 过滤性更好,优化器也无法重排 JOIN 顺序
-- 结果: 外层循环 100 万次(全表扫描订单表),逐行去用户表查找
SELECT o.id, o.order_no, o.amount, o.created_at, u.nickname
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.status = 1;EXPLAIN 结果
+----+-------------+-------+------------+------+----------------------+---------+---------+-----------------------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+----------------------+---------+---------+-----------------------+--------+----------+-------------+
| 1 | SIMPLE | o | NULL | ALL | idx_status | NULL | NULL | NULL | 998512 | 20.00 | Using where |
| 1 | SIMPLE | u | NULL | eq_ref| PRIMARY | PRIMARY | 8 | sql_treasure.o.user_id| 1 | 100.00 | NULL |
+----+-------------+-------+------------+------+----------------------+---------+---------+-----------------------+--------+----------+-------------+为什么慢
LEFT JOIN 的语义是"保留左表全部行,右表无匹配则补 NULL"。这个语义约束导致:
- 驱动表被锁死:优化器必须以左表 t_order 为驱动表,无法重排 JOIN 顺序
- 全表扫描 100 万行:虽然
status=1只匹配 20 万行,但优化器评估后认为走 idx_status 索引再回表的成本高于全表扫描,选择了type=ALL - 80% 无效循环:100 万次外层循环中,80 万行(待付/发货/完成/取消)被 WHERE 过滤掉,这些循环完全浪费
- 无法利用用户表过滤性:t_user 只有 10 万行且 95% 是正常用户,如果能从用户侧驱动,外层循环仅 9.5 万次
LEFT JOIN 执行流程:
1. 全表扫描 t_order(100 万行) <- 驱动表被锁死
2. 每行检查 status = 1(80% 被丢弃) <- 无效循环
3. 对剩下的 20 万行,逐行去 t_user 主键查找实际耗时:约 3200 ms(实测 MySQL 8.0.46,100 万行订单表)。
核心认知
LEFT JOIN 不只是"多保留几行"的问题——它改变了优化器的决策空间。LEFT JOIN 锁死驱动表,INNER JOIN 释放优化器。当业务语义允许时,INNER JOIN 给优化器的自由度就是性能的上限。
优化方案
good.sql
-- 业务确认: 已支付订单(status=1)一定存在对应用户,不存在孤儿订单
-- INNER JOIN 不保留任何一侧的未匹配行,优化器可自由选择驱动表
-- 优化器会评估: 从 t_user 过滤正常用户(9.5万) 驱动,还是从 t_order 过滤已支付(20万) 驱动
-- 最终选择代价更低的方案,外层循环次数大幅减少
SELECT o.id, o.order_no, o.amount, o.created_at, u.nickname
FROM t_order o
INNER JOIN t_user u ON o.user_id = u.id
WHERE o.status = 1;原理
INNER JOIN 不保留任何一侧的未匹配行,优化器获得了完全的 JOIN 顺序自由度:
- 优化器重新评估代价:INNER JOIN 语义下,从哪侧驱动结果集相同,优化器自由选择代价最低的方案
- 选择 idx_status 索引:优化器发现
status=1过滤后仅 20 万行,走 idx_status 索引 + 回表的代价低于全表扫描 - 外层循环减少 80%:从 100 万次降到 20 万次,每次循环都是有效的已支付订单
- 语义等价前提:业务上已支付订单一定有用户,LEFT JOIN 和 INNER JOIN 结果完全一致
INNER JOIN 执行流程:
1. 走 idx_status 索引找到 status=1 的订单(20 万行) <- 优化器自由选择
2. 每行都是有效订单(filtered=100%) <- 零无效循环
3. 逐行去 t_user 主键查找(eq_ref,每次 1 行)对比
| bad.sql (LEFT JOIN) | good.sql (INNER JOIN) | |
|---|---|---|
| 驱动表扫描方式 | ALL(全表扫描) | ref(索引查找) |
| 外层循环次数 | 998,512 | 199,702 |
| 无效循环 | 80%(80 万次) | 0% |
| 驱动表 key | NULL | idx_status |
| 耗时 | ~3200 ms | ~800 ms |
| 指标 | 优化前 (bad) | 优化后 (good) |
|---|---|---|
| 访问类型 | ALL | ref |
| 使用索引 | NULL | idx_status |
| 扫描行数 | 998,512 | 199,702 |
| 附加信息 | Using where(filtered 仅 20%,80% 循环无效) | NULL(filtered 100%,零无效循环) |
🚀 外层循环从 100 万降到 20 万,耗时下降 4 倍
避坑指南
注意事项
确认业务语义:改 INNER JOIN 前必须确认"左表每行在右表都有匹配",否则结果集会少行。本例中已支付订单一定有用户,所以语义等价。
LEFT JOIN 不是万能的:很多开发者习惯默认写 LEFT JOIN"以防万一",但这会锁死优化器。写 JOIN 前先想清楚:我真的需要保留左表未匹配的行吗?
审查现有 SQL:搜索项目中所有 LEFT JOIN,逐一确认是否真的需要保留左表未匹配行。你会发现大量可以改成 INNER JOIN 的场景。
配合索引:INNER JOIN 后优化器可能选择不同索引,确保相关列有合适索引。本例中
idx_status在 INNER JOIN 下才被选中。5.7 中可强制干预:如果 5.7 优化器仍选 ALL,可用
STRAIGHT_JOIN或FORCE INDEX干预,但优先通过改写 JOIN 类型解决。
5.7 vs 8.0 差异
| 特性 | 5.7 | 8.0 |
|---|---|---|
| LEFT JOIN 锁死驱动表 | ✅ 同样锁死 | ✅ 同样锁死 |
| INNER JOIN 自由重排 | ✅ 支持 | ✅ 支持 |
| 代价模型精确度 | 一般,可能仍选 ALL | 更精确,倾向选 idx_status |
| 优化器干预手段 | STRAIGHT_JOIN / FORCE INDEX | 同 5.7,但更少需要 |
8.0 代价模型更聪明
两个版本的优化器在 INNER JOIN 下都能自由重排 JOIN 顺序,但 8.0 的代价模型更精确,选择 idx_status 索引的倾向更强。5.7 中如果优化器仍固执地选全表扫描,可以用 FORCE INDEX (idx_status) 推它一把。
本地复现
# 默认在 MySQL 8.0 上运行
./scripts/run-case.sh 41-left-join-to-inner
# 在 MySQL 5.7 上运行(对比)
./scripts/run-case.sh 41-left-join-to-inner --ver 5.7
# 跳过造数据重跑
./scripts/run-case.sh 41-left-join-to-inner --no-seed