SQL JOIN多个表时,如何确定最优JOIN顺序?

内容分享2天前发布
0 0 0

我们日常写SQL时,常常需要通过JOIN操作,关联多个数据表,来获取完整的业务信息。其间,我们有没有遇到过这样的情况?明明SQL逻辑正确,执行起来却慢得“卡住”;我们换个JOIN顺序,查询速度瞬间从几十秒跳变成毫秒级。这背后,实则隐藏着数据库执行引擎的底层逻辑:JOIN顺序,直接决定了查询效率的高低

一、数据库底层的三种JOIN方式

要理解JOIN顺序的重大性,我们得先搞清楚数据库是怎么“连接”两张表的。数据库执行JOIN主要有三种方式,每种方式对“谁先上场”特别敏感:

连表方式

适用场景

对“带头表”大小是否敏感?

通俗解释

嵌套循环(NLJ)

小表驱动大表,且被驱动表关联字段有索引

特别敏感!小表越小越快

像查字典:拿小表每条记录,去大表“精准查找”(靠索引)

哈希连接(Hash Join)

两表过滤后数据量较大、无索引但内存充足,且为等值连接(=)

敏感(需选小表建哈希表)

像查快递柜:先把小表建成哈希表(存内存),再拿大表逐行去哈希表匹配

合并连接(Merge Join)

两表已按关联字段排序(如:走索引)

不太敏感

像对答案:两人按顺序并排走,谁小谁先动

其中,MySQL(InnoDB)、日常业务查询(OLTP)默认多用 NLJ(嵌套循环),它们的前提是被驱动表的关联字段必须有索引;若被驱动表无索引,对于MySQL来说,会降级为“简单嵌套循环(Simple Nested Loop,SNL)”或 “块嵌套循环(Block Nested Loop,BNL),此时即使我们选小表当驱动表,性能也会大幅下降(需全表扫描被驱动表)。这就意味着,在绝大多数实际业务场景中,我们“让小表带头”不是提议,而是必须遵守的铁律

我们看一个极端例子:JOIN顺序错了,性能差几个数量级

假设我们要查:“2025年注册的活跃用户,在3月完成的订单,以及订单里的商品”,三张表情况如下:

  • users(用户表):过滤后剩100人(符合“2025年注册+活跃”)
  • orders(订单表):过滤后剩50万单(符合“3月完成”)
  • order_items(订单项表):关联后约250万条

错误顺序:让最大的表先上

FROM order_items oi  -- 250万条带头
JOIN orders o ON oi.order_id = o.order_id
JOIN users u ON o.user_id = u.user_id
WHERE ...

这个JOIN写法相当于:我们拿250万条记录,每一条都要去50万行的订单表里找匹配项,再拿着结果去100人的用户表里找……加上磁盘I/O和内存压力,直接卡住或超时是大致率事件

正确顺序:让最小的表先上

FROM users u  -- 100人带头
JOIN orders o ON u.user_id = o.user_id
JOIN order_items oi ON o.order_id = oi.order_id
WHERE ...

这次,我们只拿100个用户,每人去订单表(靠索引)找自己的订单(假设每人平均5单 → 500单),再拿这500单去订单项表(靠索引)找商品……由于有索引,我们一眼看,这就是高效查找,大致率是结果秒出,资源消耗极低

二、我们手把手,一起来选对的JOIN顺序

第一步:我们要算清楚“过滤后到底剩多少行?”别靠猜!

许多人以为“小表”就是物理上行数少的表,我们这样认为就是大错特错!真正决定JOIN效率的是“过滤条件应用后”的数据量(过滤条件需包含“所有WHERE条件”和 “JOIN关联的前置过滤”)。列如一个千万级用户表,加了“2025年注册+活跃”条件后只剩8000行,我们比较“小表”时用的数据量就是这“8000行”,如果它最少,那它就是当之无愧的“小表”。

方法1:我们直接数(最准,可能稍慢)

SELECT COUNT(*) FROM users 
WHERE register_time >= '2025-01-01' AND status = 1;
-- 假设返回8247行 → 这才是我们比较“小表”时用的数据量大小

方法2:我们让数据库预估(快,但依赖统计信息)

EXPLAIN FORMAT=JSON
SELECT * FROM users 
WHERE register_time >= '2025-01-01' AND status = 1;

我们看”rows”字段,列如”rows”: 8500,是数据库预估行数。注意:如果我们很久没执行ANALYZE TABLE 表名,统计信息过期,预估可能严重失真!

方法3:我们按业务经验估算(适合超大表)

  • “最近30天订单”:每天1万 → 30万
  • “活跃用户”:500万注册 × 5%活跃率 → 25万
  • “某款口红销量”:100万总销量 × 10%占比 → 10万

第二步:按“过滤后行数”从小到大排序,串成JOIN链

我们的目标:每一步JOIN的左表,都尽可能小。

我们以刚才的例子整理成表:

表名

总行数

过滤条件

过滤后行数

关联字段有无索引?

users

5000万

2025年1月后注册 + 活跃

8247

有(idx_reg_status)

orders

2亿

2025年3月 + 已完成

48.2万

有(idx_time_status)

order_items

10亿

无独立条件(靠 orders 关联过滤)

约240万

有(idx_order_id)

排序后最优顺序:users(8千)→ orders(48万)→ order_items(240万)

第三步:查索引!没索引,再小的表也白搭

黄金规则:“有索引的小表” > “没索引的极小表” > “有索引的大表” > “没索引的大表”

列如我们选了users当“带头表”,如果orders.user_id没索引 → 每次查都要全表扫描 50 万行 × 8247 次 → 直接爆炸!

我们如何检查索引?

SHOW INDEX FROM orders;
SHOW INDEX FROM order_items;

没有索引,我们该怎么办?

  • 临时救急:我们用FORCE INDEX(MySQL)强制走某个索引
  • 长期解决:我们赶紧加索引!
CREATE INDEX idx_user_id ON orders(user_id);
CREATE INDEX idx_order_id ON order_items(order_id);

更优方案:如果“过滤+关联”字段固定,建联合索引!如:orders常查“已完成订单+关联用户”,建 (status, user_id) → 一步满足“过滤+关联”。

第四步:我们看“执行计划+实际测试”,别光靠理论

我们别以为排好序、加了索引就万事大吉了!数据库优化器偶尔也会“犯傻”,我们必须实测验证。

1、我们用EXPLAIN FORMAT=JSON看执行计划

我们重点要看:

  • “table”:执行顺序是否如我们所愿?
  • “type”:是否是 ref/range/eq_ref?避免 ALL(全表扫描)
  • “key”:是否命中我们加的索引?
  • “rows”:预估行数是否接近我们算的?差太远 → 统计信息过期

2、实际测速,做A/B对比

SET profiling = 1;
-- 执行方案A(users→orders→items)
-- 执行方案B(orders→users→items)
SHOW PROFILES;

一般A比B快几十到几百倍,数据不会骗我们。

三、遇到麻烦,我们该怎么办?

技巧1:我们用CTE提前过滤,避免优化器犯傻

优化器有时“估错行数”,列如把8247行估成8万,导致我们选错“带头表”。我们要用CTE明确告知它:“这部分数据很小!”

WITH 
filtered_users AS (
    SELECT user_id FROM users WHERE register_time >= '2025-01-01' AND status = 1
),
filtered_orders AS (
    SELECT order_id, user_id FROM orders WHERE order_time BETWEEN '2025-03-01' AND '2025-03-31' AND status = '已完成'
)
SELECT fu.user_id, oi.product_id, SUM(oi.price * oi.quantity) AS 总金额
FROM filtered_users fu
JOIN filtered_orders fo ON fu.user_id = fo.user_id
JOIN order_items oi ON fo.order_id = oi.order_id
GROUP BY fu.user_id, oi.product_id;

好处:逻辑清晰,优化器不易乱合并,数据量明确可控。

技巧2:强制JOIN顺序,数据库不听话我们就“硬来”

优化器死活不按我们写的顺序执行?我们必须强制它!注意:强制顺序是最后手段,仅在“优化器因统计信息过期/估算错误导致选错驱动表”等等,且“短期无法通过更新统计信息(ANALYZE TABLE)或加索引解决”时使用。我们不提议长期依赖,因数据量变化可能让强制顺序失效。

  • MySQL:用STRAIGHT_JOINSELECT STRAIGHT_JOIN u.user_id, oi.product_id …
    FROM users u JOIN orders o … JOIN order_items oi …
  • Oracle:用/*+ LEADING(u o oi) */
  • PostgreSQL:SET join_collapse_limit = 1;

提醒:多数时候优化器还是靠谱的,不要轻易强制!

技巧3:别让两个大表直接JOIN!我们分步骤来

两表过滤后都很大(如:100万 vs 200万)?拆成几步,我们用临时表过渡:

-- 第一步:过滤 users → 临时表
CREATE TEMPORARY TABLE tmp_users AS
SELECT user_id FROM users WHERE ...;

-- 第二步:关联 orders → 临时表
CREATE TEMPORARY TABLE tmp_orders AS
SELECT o.order_id, o.user_id
FROM tmp_users tu JOIN orders o ON tu.user_id = o.user_id WHERE ...;

-- 第三步:关联 order_items
SELECT ... FROM tmp_orders to JOIN order_items oi ON to.order_id = oi.order_id;

好处:每步可控,可加索引,比硬拼快得多。

技巧4:大表分区!我们只查需要的部分

表太大(如:2亿行)?按时间分区,如果查2025年数据,我们跳过2024年:

CREATE TABLE orders (...) 
PARTITION BY RANGE (YEAR(order_time)) (
    PARTITION p2024 VALUES LESS THAN (2025),
    PARTITION p2025 VALUES LESS THAN (2026),
    PARTITION p2026 VALUES LESS THAN (2027)
);

查2025年数据 → 只扫p2025分区(如:5000万行),速度提升几倍!

四、LEFT JOIN/ RIGHT JOIN:特殊情况特殊处理

老规矩:LEFT JOIN的左表、RIGHT JOIN的右表,必须是“带头表”!

列如我们要查“所有北京用户及其订单(无订单也要显示)”:

SELECT *
FROM users u  -- 左表,必须带头,哪怕有5000万行
LEFT JOIN orders o ON u.user_id = o.user_id AND o.status = '已完成'  -- 右表非NULL过滤放ON里
WHERE u.city = '北京';

优化要点:

1、我们要确保右表(orders)的user_id有索引;

2、右表条件放ON还是WHERE,取决于是否允许NULL:

  • 若需保留“左表无匹配右表”的记录(如:显示无订单用户),右表的非NULL过滤条件(如:o.status = '已完成')必须放ON里,放WHERE里会过滤掉o.status为NULL的行,导致LEFT JOIN退化为INNER JOIN;
  • 若需专门查询“左表有匹配且右表满足条件”的记录(如:“有已完成订单的北京用户”),可将右表条件放WHERE里(本质已等同于INNER JOIN,提议直接用INNER JOIN更清晰);
  • 若需查询“左表无匹配右表”的记录(如:“无订单的北京用户”),需将o.order_id IS NULL放WHERE里(放ON里无效)。

五、我们别踩这些坑!

误区1:“表本身小就是小表”,忘了过滤条件

我们看一个极端的例子:config表100行(无过滤),user_log表10亿行(过滤后剩10行)→ 10行的user_log才是真小表

误区2:“加了索引就万事大吉”,这些情况下索引会失效!

1、字段类型不一致:INT vs VARCHAR;
2、对字段用函数:ON YEAR(o.order_time) = 2025;
3、隐式类型转换:u.user_id = '123'(若:user_id是INT类型);
4、联合索引我们未用“最左前缀”:建了(status, user_id),却只用user_id。
5、避免使用!=/<>/NOT IN,可用NOT EXISTS或范围条件替代(如:user_id < 1 OR user_id > 3)。

避免方法:

  • 我们要统一字段类型(如:用户ID全用BIGINT);
  • 我们要避免函数操作 → 用范围条件:BETWEEN '2025-01-01' AND '2025-12-31';
  • 联合索引,我们按“过滤字段在前、关联字段在后”建立。

误区3:“GROUP BY/ ORDER BY不影响JOIN顺序”,如果我们这么认为,就大错特错!

GROUP BY影响JOIN顺序是“通过聚合提前缩小表的行数”,让聚合后的小表成为“驱动表”,减少后续JOIN的数据量。

列如我们查“每个商品的订单数”,若直接关联全量order_items和orders,数据量极大;我们先聚合order_items缩小行数,再关联(若需额外字段)或直接统计,效率会显著提升:

正确写法(需关联订单表时)

SELECT agg.product_id, COUNT(agg.order_id) AS 订单数, MAX(o.order_time) AS 最新订单时间
FROM (
    -- 先聚合order_items:去重(1个订单含多个同商品时,只算1次)
    SELECT product_id, order_id 
    FROM order_items 
    GROUP BY product_id, order_id
) AS agg
JOIN orders o ON agg.order_id = o.order_id  -- 仅关联需用到的订单字段
GROUP BY agg.product_id;

更优写法(无需关联时,直接聚合)

SELECT product_id, COUNT(DISTINCT order_id) AS 订单数
FROM order_items
GROUP BY product_id;

六、SQL优化到顶还慢?我们该上架构了!

企业级优化,不能止步与SQL,我们还要从架构上想办法。

1、读写分离:查数据别烦主库

  • 主库写,从库读
  • 从库,我们可建冗余索引、宽表,专为查询优化

2、预聚合表:提前算好,查时直接用

每天凌晨预计算“用户月度订单汇总”,存入汇总表:

CREATE TABLE user_monthly_summary (
    user_id BIGINT,
    month VARCHAR(7),
    order_count INT,
    total_amount DECIMAL(10,2),
    PRIMARY KEY (user_id, month)
);

-- 每日凌晨增量更新
INSERT INTO ... ON DUPLICATE KEY UPDATE ...

我们查时直接JOIN汇总表 → 速度提升一个数量级+

3、换OLAP引擎:复杂查询交给专业工具

若我们的业务中存在大量“多表关联+复杂聚合”的分析类查询(如:“按地区、月份统计商品销量TOP10”),传统关系型数据库(MySQL/PostgreSQL)可能力不从心。此时我们应引入专为分析设计的OLAP引擎,如:Doris、ClickHouse、StarRocks:

  • 列式存储:将同一字段的所有数据连续存储,结合高效压缩算法(如:字典编码),查询时仅读取所需字段的压缩数据,既避免无关字段的冗余读取,又大幅减少磁盘I/O和内存占用(例如:统计‘销量总和’时,列式存储可批量处理‘销量’列的压缩数据,效率远高于行存储的离散字段读取);
  • 向量化引擎:批量处理数据(而非逐行处理),减少CPU上下文切换开销;
  • 智能过滤:结合分区、分桶策略,自动跳过无关数据(如:查“2025年3月”数据时,不扫描其他月份分区)。

例如:我们用ClickHouse查“各商品3月销量”,即使order_items有10亿行,也能在秒级返回结果:

SELECT product_id, SUM(quantity) AS total_sales
FROM order_items
WHERE order_time BETWEEN '2025-03-01 00:00:00' AND '2025-03-31 23:59:59'
GROUP BY product_id
ORDER BY total_sales DESC
LIMIT 10;

4、应用层缓存:常用小表别老查数据库

对于高频访问、更新频率低的“基础表”(如:users用户基础信息、products商品属性表),我们可将数据缓存到Redis或本地缓存(如:Guava Cache),减少数据库的JOIN压力:
1、缓存策略:按“主键/唯一键”缓存(如:user:1001 → {user_id:1001, name:”张三”, …});
2、查询流程:先从缓存获取所需的用户/商品ID列表 → 用WHERE id IN (…)批量从数据库拉取数据 → 应用层拼接结果,避免数据库多表JOIN;
3、失效机制:表数据更新时(如:用户改昵称),同步更新缓存(或设置合理过期时间),确保数据一致性。

七、写JOIN前,我们必看!

检查项

要做啥

工具/命令

1. 过滤后行数?

算清真实数据量(非物理表大小)

SELECT COUNT(*) …

2. 关联字段有索引?

确保每个JOIN条件的被驱动表字段有索引

SHOW INDEX FROM 表名

3. 能提前过滤?

用CTE/子查询先筛小表,避免JOIN后过滤

WITH … AS (…)

4. 执行计划对吗?

看表执行顺序、是否走索引(避免ALL)

EXPLAIN FORMAT=JSON

5. 有大表JOIN大表?

避免两表过滤后仍超10万行的直接JOIN

看rows字段 + 业务经验

6. 需强制顺序?

仅优化器选错时用(最后手段)

STRAIGHT_JOIN

/ LEADING

7. 字段类型一致?

避免隐式转换(如:INT vs VARCHAR)

DESC 表名

8. 统计信息新吗?

避免优化器估错行数

ANALYZE TABLE 表名

9. 大表分区了吗?

亿级表必须按时间/业务维度分区

SHOW CREATE TABLE 表名

10. 能架构优化吗?

高频查询思考缓存/预聚合,复杂分析用OLAP

架构评审 + 性能测试

综上所述,JOIN顺序的优化就是我们让:“过滤后数据量最小的表”先作为驱动表,确保每一步JOIN的被驱动表都有索引,能提前过滤就不延后,同时结合执行计划验证效果;若SQL优化到顶仍不满足性能,再从“缓存、预聚合、OLAP引擎”等架构层面突破。JOIN顺序的优化,不仅要求我们有写SQL的技巧,更要求我们理解数据库“逐行处理、索引利用、资源消耗”的底层逻辑。许多时候,一个“反常识”的JOIN顺序调整(列如:让过滤后极小的大表带头),就能让查询从“超时”变“秒出”,也能让我们瞬间理解了SQL执行顺序,让我们成为一个“能看透数据库执行逻辑”的SQL码砖人!

© 版权声明

相关文章

暂无评论

none
暂无评论...