
「为什么 MySQL 不推荐使用 join?」——美团、阿里、字节都问过。
字面看是个性能题,实际筛的是三件事 :
- 你了解 MySQL 的 join 算法吗
——嵌套循环 / Block Nested Loop / Hash Join 的差距;
- 你做过分库分表项目吗 ——禁 join 真正的原因不是性能,是分库分表场景里 join 根本跨不了库;
- 你知道大厂规约里这条规则的本意吗 ——《阿里巴巴 Java 开发手册》写「超过三个表禁止 join 」,背后是工程纪律不是技术绝对论。
只答「join 慢、单表查询效率高」,30 分;能讲清 MySQL 的 join 算法 + 索引利用差异,60 分;能从分库分表 / 缓存命中率 / 索引重建多个角度展开 + 解释为什么不是「绝对禁止」而是「默认禁止」,90 分。
下面按段位拆。
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
- 项目地址:https://github.com/YunaiV/ruoyi-vue-pro
- 视频教程:https://doc.iocoder.cn/video/
不是 MySQL 不让你用 join,而是生产里默认拆开查更稳 :
- 数据量大时 join 性能掉得很快 ——单表过百万级,多表 join 容易走全表扫;
- 应用层做关联可以用缓存 ——单表查询结果可以缓存到 Redis / Caffeine,join 后的复合结果很难复用;
- 分库分表后 join 跨不了库 ——这是大厂禁 join 的核心原因;
- schema 演进时 join SQL 不容易找 ——单表查询语法清晰,join 改动散落在多个文件里。
所以默认推荐 :单表查 → 应用层组装 → 必要时用 IN 替代 JOIN。
-- 不推荐
SELECT u.name, o.amount
FROM`user` u
JOIN`order` o ON u.id = o.user_id
WHERE u.status = 1;
-- 推荐
SELECTid, nameFROM`user`WHEREstatus = 1;
SELECT user_id, amount FROM`order`WHERE user_id IN (1, 2, 3, ...);
-- 应用层做 join
这一档的回答到这里 30 分 。但面试官会追问「join 内部怎么跑的」「IN 真的比 JOIN 快吗」——要继续往上必须讲算法。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
- 项目地址:https://github.com/YunaiV/yudao-cloud
- 视频教程:https://doc.iocoder.cn/video/
MySQL 的 join 不是单一算法,MySQL 8.0 之前 主要三种:
| | | |
|---|
| Index Nested Loop(INL) | | | |
| Block Nested Loop(BNL) | | | |
| Simple Nested Loop(SNL) | | | |
MySQL 8.0.18 之后 新增了 Hash Join ——关联字段无索引时性能比 BNL 强一个量级。但对应用代码无感,依然是 EXPLAIN 才能看出来。
SELECT * FROM `user` u JOIN `order` o ON u.id = o.user_id;
- 如果
order.user_id 上有索引——走 INL,用驱动表的每行去走索引查被驱动表 ,性能很好; - 如果没索引——走 BNL(8.0 之前)/ Hash Join(8.0+),扫被驱动表全表。
join 的性能不是「快慢绝对论」,是「索引能不能命中」 ——索引命中时 join 一点都不慢,命中不了哪怕单表都慢。
很多人以为「拆开 + IN」一定比 JOIN 快——不一定 。IN 的性能取决于:
-
IN 列表的大小(MySQL 内部按数量切换不同执行计划);
order 表的 user_id 字段如果有索引,那么:
- INNER JOIN :1 次 SQL,1 次 RTT;
- 拆开 + IN :2 次 SQL,2 次 RTT。
数据量小的时候 JOIN 反而更快。性能对比要拉 EXPLAIN 看,不要靠直觉 。
应用层拆开查的真正优势在缓存 :
- 单表查询结果好缓存——
user_by_id_82001 这种 key 随便缓; - JOIN 结果难缓存——
user_join_order_status_1_amount_gt_100 这种 key 命中率极低; - 一张表改变,所有 JOIN 缓存全失效;拆开查只影响该表的缓存。
这是 60 分答案的关键差异点 ——比性能更核心的是缓存策略。
大厂规约(阿里、美团、字节都类似)写「超过 N 张表禁止 join 」,N 通常是 3。这条规则的真正原因不是性能,是工程纪律 ——下面讲三个真实驱动因素。
这是禁 join 的最核心理由 。
业务规模上来了,user 表分到了 user_db_0 ~ user_db_3,order 表分到了 order_db_0 ~ order_db_3,路由规则按 user_id 取模:
SELECT u.name, o.amount FROM user u JOIN order o
ON u.id = o.user_id;
这条 SQL 在分库分表中间件里根本跑不了 ——MyCAT、ShardingSphere 跨库 join 表现都极差,同库 join 能力勉强,跨库基本只能拉到内存里 join,OOM 风险大。
所以大厂的规约是"为分库分表预留空间" ——单库阶段允许 join 的代码,到分库后要全部重写,与其将来熬夜重写,不如从一开始就拆开查。
国内项目里 MyBatis-Plus 的统治地位很高,MP 的 BaseMapper 默认设计就是单表查询 ——很多团队的 ORM 选型本身就在引导大家「先单表 + 后组装」。
「拆开查」最大的反对意见是 N+1 查询 ——查 100 个用户的订单,先查用户,再循环查订单,1 + N 次 SQL。
// 反面教材:N+1 查询
List users = userMapper.selectList(...);
for (User u : users) {
List orders = orderMapper.selectByUserId(u.getId()); // ← N 次 SQL
}
但 N+1 是有解的——用 IN 批量查 :
List users = userMapper.selectList(...);
List userIds = users.stream().map(User::getId).toList();
List orders = orderMapper.selectByUserIds(userIds); // ← 1 次 SQL
Map> grouped = orders.stream()
.collect(Collectors.groupingBy(Order::getUserId));
// 应用层组装
这是大厂代码的标准姿势 ——不靠 JOIN,靠 IN + 应用层 grouping。
JOIN 在多个文件里散落分布——UserService 一处、OrderService 一处、ReportService 一处。改 user 表加一个字段、改字段类型、加索引时,影响面分散在所有 JOIN 处 。
单表查询则集中在 Mapper 层——一张表对应一个 Mapper,改 schema 只改一个 Mapper。
这条规约本质是「降低系统耦合」 ,不是单纯的性能问题。
按场景:
| |
|---|
| 后台管理系统 | |
| 报表 / 数据分析 |
|
| C 端业务核心接口 | |
| 跨库 / 跨服务的关联 | |
| 批处理 / 离线任务 | |
「不推荐 join」的潜台词不是「禁止」,是「默认拆开 + 例外允许」 ——遇到合理场景该 join 还是要 join,不要一刀切。
按扣分严重程度倒序:
- 「join 性能差,所以不用」 ——只答这条 = 没做过分库分表,30 分封顶;
- 「IN 一定比 JOIN 快」 ——错。索引命中时 JOIN 反而更快,性能要看 EXPLAIN 不要靠直觉;
- 「join 会锁表」 ——基本错。InnoDB 的 join 是行级锁不是表锁,跟单表查询锁粒度一致;
- 「join 全表扫」 ——只对一半。INL 算法走索引,BNL / Hash Join 才扫表;
- 「拆开查肯定快」 ——也是只对一半。拆开查多了 RTT,量小场景反而更慢;
- 从来不提分库分表 ——大厂禁 join 的最核心原因没答出来,封顶 60 分;
- 「join 永远不该用」 ——绝对论。报表、后台、批处理场景 join 是更优解。
没有 。Hash Join 只是让无索引 join 比 BNL 强一些,但仍然要扫被驱动表全表,仍然不解决跨库问题,仍然不解决缓存命中率问题。应用层拆开的优势依然存在 。
三张表是个经验值——两张表 join 一般还能走 INL ,三张表往上 MySQL 优化器选错驱动表的概率急剧上升,调优成本远超拆开查。
这是拆开查的真正难点 ——单表查询每条独立 SQL,跨表写入没有事务一致性。
通用做法:
- 写场景:用 Spring
@Transactional 包住多个 Mapper 调用(同库),或用消息队列做最终一致性(跨库); - 强一致写:单库内还是用 join + 事务,生产里没人为了禁 join 而牺牲事务 。
国内项目几乎全用 MyBatis-Plus ——MP 的设计哲学就是单表 + 应用层组装。要 join 用 MP-Join 插件、或者直接写 XML、或者用 JOOQ 这种类型安全的查询构造器。
但更合理的做法是接受 MP 的限制,强制团队走「单表 + IN + 应用层组装」 ——这跟大厂规约是一致的。
ES 不支持 join,从设计上就要把数据拍平存进去(反范式)。ClickHouse 支持 join 但优化器没 MySQL 强。OLAP 场景的标准姿势是「写入时反范式,查询时单表」 ——这跟「拆开查 + 应用层组装」是同一思路。
「为什么 MySQL 不推荐 join」真正考的不是性能 ,是你对 ORM 选型 + 分库分表 + 工程纪律的理解 :
- 30 分 :知道 join 性能问题、应用层拆开查;
- 60 分 :能讲清 INL / BNL / Hash Join 的区别 + 缓存命中率差异;
- 90 分 :能从分库分表、N+1 解法、schema 演进、规约本意四个角度展开。
写 SQL 的核心心法很简单——默认单表 + 应用层组装,必要时回落 join,但要知道这是「逃生通道」不是「默认路径」 。
「不推荐 join」不是技术绝对论,是为系统未来的扩展性预留空间 ——当下 join 能省 5 行代码,未来分库时要重写 500 行——这账,大厂帮你算过了。