禁 JOIN 跟"慢"关系不大。索引能压住扫描次数,压不住 join buffer 的内存;分库分表一落地,跨库 JOIN 连语法都不成立。慢是症状,架构上不可演进才是病根。
阿里手册那条红线
《阿里巴巴 Java 开发手册》里那条强制规定,原文大意是:超过三个表禁止 join,需要 join 的字段类型必须绝对一致,被关联字段必须有索引。
注意措辞——三个表是红线,两个表要谨慎。
面试官的第一问通常是"你为什么滥用多表关联",这题好答,背过手册的人都能应付。真正的杀招在后面:JOIN 要扫数据,单表查询也要扫数据,凭什么就你不推荐?
答"慢"的人,第一轮就出局
慢是个形容词,不是理由。IN 批量查也可能慢,全表扫描也可能慢,为什么偏偏 JOIN 被写进规范?
面试官要听的不是"慢",而是慢在哪儿、慢到什么程度、以及为什么这个慢在架构演进中救不回来。
嵌套循环的真身:10 万 × 10 万 = 100 亿次比对
MySQL 的 JOIN 默认走 Nested Loop Join。逻辑一点都不神秘:挑一张过滤后数据量小、条件更严格的表当驱动表,逐行取出来,拿关联字段去被驱动表里一条条比对。
flowchart TD
A[选出小表作驱动表] --> B[取出驱动表的一行]
B --> C[拿关联字段去被驱动表比对]
C --> D{关联字段有索引吗}
D -->|有| E[B+树定位匹配行]
D -->|没有| F[全表扫描被驱动表]
E --> G[输出这一行的匹配结果]
F --> G
G --> H{驱动表还有下一行吗}
H -->|有| B
H -->|没有| I[合并结果集返回]
拿订单表关联用户表演示一下:
SELECT u.name, o.id, o.amount
FROM user u
JOIN order o ON u.id = o.user_id
WHERE u.city = 'Hangzhou';
假设 user 过滤后 10 万行,order 也是 10 万行,而 order.user_id 没有索引,EXPLAIN 出来大概是这样:
+----+-------+------+----------+--------+-------------+
| id | table | type | key | rows | Extra |
+----+-------+------+----------+--------+-------------+
| 1 | u | ref | idx_city | 100000 | Using where |
| 1 | o | ALL | NULL | 100000 | Using where |
+----+-------+------+----------+--------+-------------+
o 那行的 type=ALL 就是判决书:驱动表每出一行,被驱动表全表扫一遍。10 万 × 10 万 = 100 亿次比对。一条 SQL 把数据库 CPU 吃满,所有业务跟着一起躺。
索引治得了扫描,治不了内存
索引当然有用。走 B+ 树之后,被驱动表的扫描从"全表 10 万行"降到"树高 3~4 层 + 少量回表",单条 SQL 的耗时立刻好看很多。
但索引只解决扫描次数,解决不了内存。
MySQL 执行这类查询时还会用一块叫 join_buffer 的缓存(Block Nested-Loop Join 的产物)。优化器把驱动表的一批行塞进这块内存,再去被驱动表里批量比对。join_buffer_size 默认才 256KB,平时低并发根本看不出问题。
双十一这种场景,几百条并发同时跑 JOIN,每一条都在申请自己的 join buffer,中间临时结果堆在内存里。CPU 打满只是前菜,内存被抽干才是正餐,整台数据库服务器跟着抖。
Java 能加 Pod,数据库不能
这里才是架构层面最核心的认知差。
Java 业务服务扛不住?K8s 里 kubectl scale 加两个 Pod,横向扩容几分钟搞定。数据库也能这么玩吗?不行。
数据天生带着关联性和一致性,加机器解决不了锁竞争,也解决不了连接数上限。分片、主从、一致性协议,每一样都比加 Pod 贵一个数量级。
数据库是整条链路里最难扩容、最昂贵、最脆弱的资源。 设计上因此有一条铁律:绝不允许它被高 CPU、高内存的重型操作长期霸占。JOIN 恰恰就是这种操作。
分库之后,JOIN 连语法都不成立
业务一扩,分库分表迟早要上。用户表在 A 库,订单表在 B 库,甚至两台机器物理隔离。
这时候你写跨库 JOIN,MySQL 自己根本不支持。硬要靠中间件实现,它只能把两张表的全量数据拉到应用程序内存里做匹配——数据量一大,直接 OOM,整个服务炸掉。
flowchart TD
Q[跨库JOIN查询] -->|中间件改写| A[拉取A库用户表全量数据]
Q -->|中间件改写| B[拉取B库订单表全量数据]
A --> M[应用内存做匹配]
B --> M
M -->|数据量过大| OOM[OOM 服务崩溃]
M -->|数据量可控| R[返回结果]
大厂禁 JOIN,禁的是它在系统演进路上埋的那颗定时炸弹。 今天能跑,不代表明天分库之后还能跑。
不许 JOIN,订单列表还得显示用户名
面试官最后一刀通常是:"不许 JOIN,但订单列表必须显示用户名,你怎么实现?"
方案一:订单表里塞一个 user_name
直接在订单表里冗余一个 user_name 字段,下单那一刻写入。查询变成单表,where user_id = ? 走主键索引,快得离谱。
代价是一致性:用户改了昵称,历史订单里的冗余字段得同步更新。字段少、读多写少、能接受最终一致的场景,这套最划算。
方案二:拆成两次单表查询,在 Java 里拼
把数据库本来要干的关联,搬到应用层分步完成:
// 1. 先查订单表,拿到所有关联的 userId
List<Order> orders = orderMapper.selectByUserId(userId);
Set<Long> userIds = orders.stream()
.map(Order::getUserId)
.collect(Collectors.toSet());
// 2. IN 批量查用户表,走主键索引
List<User> users = userMapper.selectByIds(userIds);
// 3. 内存里用 Map 组装
Map<Long, String> nameMap = users.stream()
.collect(Collectors.toMap(User::getId, User::getName));
orders.forEach(o -> o.setUserName(nameMap.get(o.getUserId())));
sequenceDiagram
participant C as 业务代码
participant DB1 as 订单库
participant DB2 as 用户库
C->>DB1: 查询订单列表
DB1-->>C: 返回订单及用户ID
C->>DB2: IN批量查询用户
DB2-->>C: 返回用户集合
C->>C: 内存组装订单与用户名
两次查询都走索引,网络往返两次,但每次的数据量都可控。这是目前大厂最通用的做法,分库分表之后依然成立。
怎么选
| 维度 | 冗余字段 | 应用层组装 |
|---|---|---|
| 查询次数 | 1 次 | 2 次 |
| 一致性 | 需同步更新,有漏更风险 | 天然强一致 |
| 改造成本 | 历史数据要刷一遍 | 只改代码,不动表结构 |
| 适用场景 | 字段少、读远多于写 | 关联维度多、实时性要求高 |
写在最后
JOIN 本身不是原罪,Nested Loop 也不是烂算法。问题在于它把计算压力押在了系统里最不能扩容的那块资源上,而且写法与分布式演进方向天然相悖。规范禁的不是语法,是这种思维习惯。
你们团队遇到"订单要显示用户名"这种需求,是选冗余字段还是应用层组装?有没有踩过冗余字段漏更新、导致用户昵称对不上的坑?评论区聊聊。
觉得有用的话点个赞,后面继续拆大厂面试里那些"看似会、一追问就露馅"的题。