一条三表关联的订单列表 SQL,从 40ms 涨到 3s,复盘会上的结论常常是"以后禁止写 JOIN"。这个结论把因果搞反了:让它变慢的可能是驱动表从 200 行涨到 20 万行,也可能是被驱动表上那个索引在上次发版时被顺手删了。JOIN 是关系代数里的一种操作,它本身没有性能属性;有性能属性的是执行计划。
这篇把"不推荐多表 JOIN"拆成三层:执行代价从哪来、大厂禁令背后的真实约束是什么、以及什么条件下 JOIN 仍然是正确选择。
1. 先把"不推荐"还原成条件
技术规范一旦脱离条件就变成教条。同一句"禁止 JOIN",在下面三种语境里含义完全不同:
| 语境 | 真实约束 | 是否与 JOIN 本身有关 |
|---|---|---|
| 单库、千万级以内、索引齐备 | 无 | 无关,属于误传 |
已分库分表,order 与 user 不同实例 |
跨库无法 JOIN | 无关,是拓扑限制 |
表按团队划分,order 归交易域、user 归账号域 |
边界与发布节奏 | 无关,是组织约束 |
| 单表十亿行 + 多表 JOIN + 无覆盖索引 | 扫描量与临时表 | 有关,是执行代价 |
只有最后一行是真正的"JOIN 慢"。前三行说的是"JOIN 做不到"或"JOIN 不该越过边界",两个不同的东西被口语合并成了同一句话。
2. 三种执行算法,先看它到底怎么跑
MySQL 8.0 里能见到的关联执行方式有三种,看 EXPLAIN 的 Extra 就能区分:
| 算法 | 触发条件 | Extra 表现 |
代价特征 | 从哪个版本起 |
|---|---|---|---|---|
| NLJ(嵌套循环 + 索引查找) | 被驱动表关联列有索引 | type=ref/eq_ref |
驱动表行数 × 单次索引查找 | 一直都有 |
| BNL(块嵌套循环) | 被驱动表无索引,用小表做 join buffer | Using join buffer (Block Nested Loop) |
驱动表行数 × 被驱动表全扫一遍 | 5.6 起 |
| Hash Join | 等值关联且无法用索引,8.0 默认替代 BNL | Using join buffer (hash join) |
建哈希表一次 + 探测一遍 | 8.0.18 引入内连接,8.0.20 扩展到外连接与半连接 |
EXPLAIN FORMAT=TREE
SELECT o.id, o.amount, u.nickname
FROM t_order o
JOIN t_user u ON u.id = o.user_id
WHERE o.status = 1 AND o.created_at >= '2026-01-01';
-> Inner hash join (o.user_id = u.id) (cost=... rows=...)
-> Filter: ((o.status = 1) and (o.created_at >= '2026-01-01'))
-> Table scan on o
-> Hash
-> Table scan on u
看到 hash join 说明优化器判定索引走不通,改走内存哈希。数据量小时它反而更快,t_user 上千万行时会把内存吃满——join_buffer_size 是上限,超出部分落盘。
3. 驱动表选错:最常见的性能来源
优化器按统计信息估行数决定谁做驱动表。估错的场合很固定:WHERE 里有函数、隐式类型转换、LIKE '%x'、以及统计信息过期。
| 现象 | 优化器看到 | 实际 | 后果 |
|---|---|---|---|
WHERE DATE(created_at)='2026-01-01' |
无法用索引,估全表 | 只命中当天 | 小表被当成大表 |
WHERE user_id = '123'(列是 bigint) |
隐式转换 | 索引失效 | 驱动表变成全扫 |
WHERE status != 0 |
选择性极低 | 选择性低 | 大表驱动大表 |
统计信息 ANALYZE 很久没跑 |
行数估计偏差大 | 已增长数倍 | 计划长期错误 |
需要人工干预时用 STRAIGHT_JOIN 固定顺序,把确定的小结果集放左边:
SELECT STRAIGHT_JOIN o.id, o.amount
FROM (SELECT id, user_id, amount FROM t_order
WHERE user_id = 12345 AND status = 1) o
JOIN t_user u ON u.id = o.user_id;
代价是优化器彻底失去调整空间——数据分布一变,这条 SQL 就永久锁死在错误的顺序上。所以 STRAIGHT_JOIN 是止血手段,根因还是补索引或改条件写法。
4. 被驱动表没索引:全表扫描的放大器
-- 关联列上没有索引,最典型的坏计划
EXPLAIN SELECT o.id FROM t_order o JOIN t_order_item i ON i.order_no = o.order_no;
+----+------+-------+------+------------------------------------+
| id | type | table | rows | Extra |
+----+------+-------+------+------------------------------------+
| 1 | ALL | o | 800K | NULL |
| 1 | ALL | i | 2.1M | Using where; Using join buffer (hash join) |
+----+------+-------+------+------------------------------------+
两处 ALL 意味着两次全表扫描,再做哈希匹配。补一个 idx_order_no (order_no) 之后 i 的 type 会变成 ref,Extra 里消失的 join buffer 就是省下的内存。
比缺索引更隐蔽的是关联列类型不一致:varchar 对 bigint、字符集不同(utf8mb4 对 utf8)都会让索引失效,而 EXPLAIN 里只表现为 type 从 ref 掉到 ALL。
5. 临时表与 filesort:JOIN 之后的第二笔账
多表 JOIN 常伴随 GROUP BY、ORDER BY、DISTINCT。这些操作在多表结果集上执行,代价跟 JOIN 的结果行数成正比:
Extra 片段 |
含义 | 处理方向 |
|---|---|---|
Using temporary |
建了内部临时表 | 减少参与排序的列,或先聚合再关联 |
Using filesort |
无法用索引排序 | 让排序字段落在驱动表的索引前缀上 |
Using temporary; Using filesort |
临时表 + 排序叠加 | 通常是 GROUP BY 与 ORDER BY 字段不一致 |
Using index condition |
索引下推 | 正常,不是问题 |
一个实用判断:如果 rows 的乘积超过百万级,且 Extra 同时出现 temporary 与 filesort,这条 SQL 在数据增长下必定先崩。
6. 大厂"不让 JOIN"背后的四件事
| 禁令动机 | 具体机制 | 与 JOIN 的执行代价的关系 |
|---|---|---|
| 分库分表后跨库 | order 在 shard-1、user 在 shard-3,物理上不可关联 |
无关,拓扑问题 |
| 表边界 = 团队边界 | 交易域改表不能牵着账号域一起发版 | 无关,组织问题 |
| 慢 SQL 定位成本 | 三表 JOIN 的耗时无法归到单表,索引归属模糊 | 半相关 |
| 缓存与索引策略 | 单表可整表缓存,JOIN 结果集难以命中缓存 | 半相关 |
第一、二条是硬约束,换数据库也不会消失;第三、四条是运维成本,能靠规范与工具缓解。
7. 替代方案的代价对照
| 方案 | 做法 | 换来什么 | 付出什么 |
|---|---|---|---|
| 多次单表查询 + 内存拼装 | 先查 t_order,收集 user_id,再 WHERE id IN (...) |
每个查询都能单独走索引 | 多一次网络往返;IN 列表要分批;分页逻辑要自己写 |
| 冗余字段 | 下单时把 nickname 写进 t_order_snapshot |
单表查询、零关联 | 源数据变更后不一致;需要定义"以哪个时刻为准" |
| 宽表 / 物化视图 | 离线或 CDC 同步出一张 order_wide |
查询极简、可加任意索引 | 同步链路、延迟、口径漂移 |
| 搜索引擎 | 数据同步到 ES,检索与展示在 ES 完成 | 复杂条件与全文检索 | 双写一致性、ES 与 DB 的字段语义差异 |
| 应用层 JOIN | 两批数据拉到 JVM 里按 key 合并 | 灵活 | 分页会错,见下 |
最后一行是最高频的坑:两表各自 offset/limit 再合并,页大小与总数都会算错。正确做法是主表分页、从表按主键集合批量补齐:
List<Order> orders = orderMapper.page(offset, limit); // 只有主表参与分页
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(Collectors.toSet());
Map<Long, User> userMap = userMapper.selectByIds(userIds) // 一次 IN 查询
.stream().collect(Collectors.toMap(User::getId, u -> u));
orders.forEach(o -> o.setNickname(userMap.getOrDefault(o.getUserId(), User.EMPTY).getNickname()));
long total = orderMapper.count(query); // 总数单独算
8. 用四个 EXPLAIN 字段判断 JOIN 好坏
| 字段 | 看什么 | 好 | 坏 |
|---|---|---|---|
type |
访问方式 | const / eq_ref / ref / range |
ALL / index(全索引扫) |
rows |
预估扫描行数 | 每张表都在千级以内 | 出现百万级,或乘积过大 |
Extra 含 Using join buffer |
说明关联列没走索引 | 不出现 | 出现,尤其 (Block Nested Loop) |
Extra 含 Using temporary |
有中间结果集 | 不出现 | 出现,且 rows 大 |
filtered |
条件过滤后剩余比例 | 接近 100% 或明确偏小 | 很低却仍被选为驱动表 |
Using join buffer (hash join) 在 8.0 里比 Block Nested Loop 好,但它依然是"索引不可用"的信号,不是优化成功的标志。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 加了一张表后慢十倍 | 新表无关联索引,Extra 变成 join buffer |
补关联列索引,确认字符集一致 |
| 数据量翻倍后计划突变 | 统计信息过期,驱动表换人 | ANALYZE TABLE,必要时 STRAIGHT_JOIN 止血 |
| 单表查询快、JOIN 慢 | 结果集放大(一对多) | 先聚合再关联,或改成两次查询 |
| 拆成 N+1 后更慢 | 循环里查库,往返次数放大 | 改批量 IN 查询,控制单批条数 |
| 应用层 JOIN 分页错乱 | 主从表各自分页 | 主表分页 + 从表按主键补齐 |
10. 边界
| 场景 | 建议 | 理由 |
|---|---|---|
| 同库、单表千万级以内、索引齐备 | 用 JOIN | 一次往返,优化器有完整统计信息 |
| 强一致关联查询(对账、报表小范围) | 用 JOIN | 应用层拼装会引入时间窗不一致 |
| 跨库 / 分库分表 | 不用 JOIN | 物理不可达,只能应用层合并或宽表 |
| 一对多且结果集放大明显 | 慎用或改写 | 用 GROUP BY 预聚合或分两次查 |
| 参与表超过 4 张 | 拆开 | 计划不可读,慢 SQL 无法归因 |
| 高频热点接口 | 宽表或缓存 | JOIN 无法被整体缓存 |
一句话:禁的不是 JOIN,是"不可解释的执行计划"。能拿出 EXPLAIN 说明每张表都走了 ref、rows 在可控范围、没有 temporary,这条 JOIN 就该留;拿不出来,改成单表查询只是把问题挪到别处。
11. 自测
Extra里出现Using join buffer (hash join),说明优化器做了什么判断?- 什么情况下
STRAIGHT_JOIN是止血手段而不是解决方案? - 分库分表之后,"不能 JOIN"是执行代价问题还是拓扑问题?
- 应用层 JOIN 做分页时,为什么必须让主表单独参与分页?
EXPLAIN的rows乘积很大但type全是ref,还需要担心什么?- 冗余字段方案里,"数据不一致"应该靠什么语义来界定?