京公网安备 11010802034615号
经营许可证编号:京B2-20210330
在写复杂 SQL 时,经常遇到这种需求:同一张表或同一个聚合子查询,要和主表关联多次,每次取不同维度的信息。比如:
新手最容易写出的"能用但难读"的写法,就是把同一个子查询复制粘贴好几遍,每次 LEFT JOIN 一次。SQL 能跑,但又长又慢,还容易写错。
本文系统梳理这类问题的几种标准优化写法,从可读性和性能两个维度对比。
先看一个典型的反面教材:
SELECT
o.order_id,
o.customer_id,
t1.spend_30d,
t2.spend_90d
FROM orders o
LEFT JOIN (
SELECT
customer_id,
SUM(order_amount) AS spend_30d
FROM orders
WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY customer_id
) t1 ON o.customer_id = t1.customer_id
LEFT JOIN (
SELECT
customer_id,
SUM(order_amount) AS spend_90d
FROM orders
WHERE order_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY customer_id
) t2 ON o.customer_id = t2.customer_id;
这段 SQL 能跑,但有三个明显问题:
最直接的优化是把重复的子查询提取到 WITH 子句里,定义一次,多处引用:
WITH spend_stats AS (
SELECT
customer_id,
SUM(CASE WHEN order_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
THEN order_amount ELSE 0 END) AS spend_30d,
SUM(CASE WHEN order_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
THEN order_amount ELSE 0 END) AS spend_90d
FROM orders
GROUP BY customer_id
)
SELECT
o.order_id,
o.customer_id,
s.spend_30d,
s.spend_90d
FROM orders o
LEFT JOIN spend_stats s ON o.customer_id = s.customer_id;
注意这里把"两次 JOIN"进一步合并成了"一次聚合 + 一次 JOIN",用 CASE WHEN 在聚合时就把两个时间窗的金额算出来。这是最推荐的写法:扫一遍表,出两个指标。
如果要 LEFT JOIN 的不是"聚合结果",而是"明细表中的不同时间点"(比如首次下单和最近一次下单),那 CTE 一次聚合可能不够,但仍可以用一次 JOIN 加条件聚合解决。
举个例子:要在用户表上同时取"首次下单日期"和"最近一次下单日期":
-- 反面写法:两次 LEFT JOIN 同一张订单表
SELECT
u.user_id,
first_order.first_date,
last_order.last_date
FROM users u
LEFT JOIN (
SELECT user_id, MIN(order_date) AS first_date
FROM orders GROUP BY user_id
) first_order ON u.user_id = first_order.user_id
LEFT JOIN (
SELECT user_id, MAX(order_date) AS last_date
FROM orders GROUP BY user_id
) last_order ON u.user_id = last_order.user_id;
其实这两个聚合完全可以一次算完:
SELECT
u.user_id,
s.first_date,
s.last_date
FROM users u
LEFT JOIN (
SELECT
user_id,
MIN(order_date) AS first_date,
MAX(order_date) AS last_date
FROM orders
GROUP BY user_id
) s ON u.user_id = s.user_id;
一次扫描订单表,同时算出首次和最近日期。规律是:凡是按同一个 GROUP BY 字段聚合出多个指标的,都应该合并到一个子查询里,而不是写多个子查询分别 JOIN。
另一种常见情况是:想给每个用户同时取"第1单"、"第2单"、"最新一单"的信息。新手会写多次自连接。
-- 反面写法:三次 LEFT JOIN 同一张订单表
SELECT
u.user_id,
o1.order_amount AS first_order_amount,
o2.order_amount AS second_order_amount,
o_latest.order_amount AS latest_order_amount
FROM users u
LEFT JOIN orders o1 ON ... -- 第1单
LEFT JOIN orders o2 ON ... -- 第2单
LEFT JOIN orders o_latest ON ... -- 最新单
WITH ranked_orders AS (
SELECT
user_id,
order_amount,
order_date,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date) AS rn_asc,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_date DESC) AS rn_desc
FROM orders
)
SELECT
u.user_id,
MAX(CASE WHEN r.rn_asc = 1 THEN r.order_amount END) AS first_order_amount,
MAX(CASE WHEN r.rn_asc = 2 THEN r.order_amount END) AS second_order_amount,
MAX(CASE WHEN r.rn_desc = 1 THEN r.order_amount END) AS latest_order_amount
FROM users u
LEFT JOIN ranked_orders r ON u.user_id = r.user_id
GROUP BY u.user_id;
只扫一遍订单表、做一次窗口函数,用 CASE WHEN + MAX 把不同排名位置的行"透视"到同一行。比多次自连接高效很多,尤其是大表场景。
如果这个子查询特别重(比如要 JOIN 好几张表、做复杂聚合、跑好几分钟),且在同一个 SQL 里要被引用多次,CTE 可能在某些数据库里被反复执行(MySQL 5.x 的 CTE 就是"每次引用都展开")。这时可以先把结果落到临时表:
-- 第一步:先把中间结果算出来存临时表
CREATE TEMPORARY TABLE tmp_customer_spend AS
SELECT
customer_id,
SUM(CASE WHEN order_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
THEN order_amount ELSE 0 END) AS spend_30d,
SUM(CASE WHEN order_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
THEN order_amount ELSE 0 END) AS spend_90d
FROM orders
GROUP BY customer_id;
-- 第二步:主查询直接 JOIN 临时表
SELECT
o.order_id,
s.spend_30d,
s.spend_90d
FROM orders o
LEFT JOIN tmp_customer_spend s ON o.customer_id = s.customer_id;
注意临时表会话结束自动销毁,不会污染正式库;用完记得 DROP TEMPORARY TABLE。
| 方案 | 可读性 | 性能 | 适用数据库 | 推荐场景 |
|---|---|---|---|---|
| 重复写子查询(反面示例) | 差 | 差(可能多次扫表) | 所有 | 不推荐 |
| CTE + 条件聚合合并指标 | 好 | 好(一次扫描) | MySQL 8+/PG/Hive/Spark | 大多数场景首选 |
| 窗口函数 + CASE 透视 | 好 | 好 | 支持窗口函数的库 | 取不同排名位置的行 |
| 临时表物化中间结果 | 中 | 最好(可控) | 所有 | 子查询特别重、老版本 MySQL |
| PIVOT 语法(特定库) | 好 | 中 | SQL Server/PG | 行列转换规整场景 |
多次 LEFT JOIN 同一张"一对多"关系表时,容易出现行数膨胀。比如订单表关联客户表再关联订单明细表,中间多对多关系会让结果行数爆炸。解决思路是:先聚合再 JOIN,把子查询 GROUP BY 到主键粒度后再 LEFT JOIN 主表,避免一对多 JOIN 产生笛卡尔积。
这点很容易踩坑:
所以在 MySQL 8 里如果 CTE 被引用多次且很重,优先考虑临时表方案。
合并的前提是:子查询的 GROUP BY 键相同、过滤条件能整合。如果两次 JOIN 的关联键完全不同(比如一次按 customer_id,一次按 product_id),硬合并反而会产生笛卡尔积。这种情况该写两个子查询还是要写两个,不要为了简洁而牺牲正确性。
写完之后用 EXPLAIN 看一下执行计划,确认订单表是不是真的只扫了一次。如果发现还是扫了多遍,可能是优化器没选中你预期的路径,这时再考虑临时表物化。
同一个子查询被多次 LEFT JOIN,本质是两个问题:重复书写导致维护困难,重复扫描导致性能差。最优解几乎总是这两条:
CASE WHEN 在一次 GROUP BY 里算完,再 JOIN 一次主表。记住这条经验法则:凡是"按同一个维度分组、算多个指标"的需求,先在脑子里想"能不能一次 GROUP BY 出来",再决定写几个 JOIN。绝大多数情况下,答案是能。

一、问题:为什么 ID 会"断号" 很多刚接触 MySQL 的同学都会有一个困惑:表里的主键 ID 是自增的,删了中间几行数据之后,ID 变 ...
2026-10-22一、问题场景:为什么会重复 JOIN 同一个子查询 在写复杂 SQL 时,经常遇到这种需求:同一张表或同一个聚合子查询,要和主表关联 ...
2026-10-22人力资源专员是HR体系的执行入口。招聘信息发布、简历初筛、面试安排、考勤汇总、入离职手续、劳动合同与档案管理,大多由这个岗 ...
2026-10-22人力资源经理问“必学技巧”,真正要解决的不是知识清单不够长,而是时间有限、业务催得紧,学完却用不上。更有效的路径是:先用 ...
2026-10-22内容营销岗位的核心任务,不是“把文案写得更漂亮”,而是围绕目标受众,用持续的内容供给影响认知、推动转化并沉淀复购。它至少 ...
2026-10-22理财经理培训课程很多,难的是判断“先学什么、后学什么、学完能做什么”。与其按课程名称罗列,不如先看岗位任务:客户分层与维 ...
2026-10-22理财经理的知识结构,可以先用一句话概括:合规与产品知识决定“不出错”,客户经营与资产配置决定“够不够专业”,市场研判与数 ...
2026-10-22经济学背景做数据分析,优势不在工具,而在问题意识:供需如何相互作用、成本与收益如何权衡、变量之间是相关还是因果。真正的缺 ...
2026-10-22需要,但要把“需要”理解准确。经济学本身就在处理数据:GDP、物价、就业、企业营收、问卷调研,都是经济分析的原料。真正的区 ...
2026-10-22经济学分析能力不是单一技能,它至少由三层构成:把现实问题转成可检验假设的理论框架能力、用数据与统计方法验证假设的工具能力 ...
2026-10-22金融分析师的知识提升,先补的不是更多模型,而是让结论可复核的底层能力。按任务顺序,大致是四层:财务与业务理解、指标口径管 ...
2026-10-22金融分析师的“通用技能”,指换到不同机构、不同行业组仍用得上的底层能力,而非某家公司内部系统的操作经验。按任务拆,日常要 ...
2026-10-22先给结论:数据分析证书不是金融分析师的准入条件,招聘方也不会因为一张证书就直接判断你能做分析。要不要考,取决于你当前任务 ...
2026-10-22金融分析师的核心技能,不是“会看K线”或“会讲宏观”,而是能把一家公司、一个行业或一类资产,转化为可核查的数据判断与书面 ...
2026-10-22产品经理的知识提升,不是把需求文档、原型、SQL、数据分析、项目管理都学一遍,而是先对准目标岗位的真实任务,找出自己最缺的 ...
2026-10-22产品专员提升工作知识,最有效的方式不是先囤课程,而是围绕日常交付的四件事来补:需求调研与优先级判断、原型与PRD表达、竞品 ...
2026-10-22产品运营专员提升个人能力,常见误区是先把Excel、SQL、Python列成一张学习清单,然后从第一个工具开始学。更有效的做法是先回答 ...
2026-10-22产品运营的能力提升,最怕从“课程目录”出发:今天学增长模型,明天背SQL语法,学完却说不清自己负责的功能为什么激活率低。更 ...
2026-10-22产品经理的技能和影响力不是两条分开的赛道。技能决定你能不能把问题分析清楚、把方案设计对;影响力决定你的判断能否被团队接受 ...
2026-10-22产品经理要学的数据分析能力,不是“会写SQL”或“会用BI工具”这么简单。更准确地说,是围绕需求评估、功能迭代、指标监控、实 ...
2026-10-22