2801 字
约 9 分钟
15
S11-X06 · 为什么不推荐多表 JOIN:从执行代价到工程边界
2024-12-31
2026-11-08

一条三表关联的订单列表 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. 自测

  1. Extra 里出现 Using join buffer (hash join),说明优化器做了什么判断?
  2. 什么情况下 STRAIGHT_JOIN 是止血手段而不是解决方案?
  3. 分库分表之后,"不能 JOIN"是执行代价问题还是拓扑问题?
  4. 应用层 JOIN 做分页时,为什么必须让主表单独参与分页?
  5. EXPLAIN 的 rows 乘积很大但 type 全是 ref,还需要担心什么?
  6. 冗余字段方案里,"数据不一致"应该靠什么语义来界定?
S11-X06 · 为什么不推荐多表 JOIN:从执行代价到工程边界
https://www.clxhxhhr.top/posts/5798/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。