大厂禁 JOIN 的真正原因,拆到第四层才清楚

0 阅读5分钟

禁 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 也不是烂算法。问题在于它把计算压力押在了系统里最不能扩容的那块资源上,而且写法与分布式演进方向天然相悖。规范禁的不是语法,是这种思维习惯。

你们团队遇到"订单要显示用户名"这种需求,是选冗余字段还是应用层组装?有没有踩过冗余字段漏更新、导致用户昵称对不上的坑?评论区聊聊。

觉得有用的话点个赞,后面继续拆大厂面试里那些"看似会、一追问就露馅"的题。