数据库

SQL性能之战:子查询(Subquery) vs. JOIN,深度解析与最佳实践

引言

在SQL的世界里,将多张表的数据关联起来是日常操作。为了实现这一目标,我们有两个强大的工具:JOIN(连接)和子查询(Subquery)。初学者往往觉得子查询的嵌套逻辑更符合直觉,而经验丰富的开发者则更倾向于使用JOIN。这不仅仅是个人风格的差异,背后更深藏着关于性能、可读性和数据库优化原理的重要考量。

这两种方式在很多情况下可以得到相同的结果,但它们的执行效率可能天差地别。那么,到底该如何选择?本文将带你深入这场“性能之战”,彻底搞懂子查询和JOIN的差异、优劣以及最佳实践。

核心概念梳理

在深入比较之前,我们先快速回顾一下两个主角的基本概念。

什么是 JOIN?

JOIN 是一种用于根据两个或多个表中的相关列之间的关系,将这些表中的行组合起来的SQL子句。它是一种纯粹的“集合”操作,旨在创建一个包含来自所有连接表数据的新结果集。

graph LR
    A[表A] -- ON A.key = B.key --> B[表B]
    B -- 结果 --> C[组合结果集]

最常见的JOIN类型包括:

  • INNER JOIN: 只返回两个表中联接字段相匹配的行。
  • LEFT JOIN: 返回左表的所有行,以及右表中匹配的行。如果右表中没有匹配项,则结果是 NULL。
  • RIGHT JOIN: 与LEFT JOIN相反,返回右表的所有行。
  • FULL OUTER JOIN: 返回两个表中的所有行,只要其中一个表存在匹配。

什么是子查询?

子查询,也称为嵌套查询或内部查询,是嵌入在另一个SQL查询(外部查询)中的查询。它可以出现在SELECT, FROM, WHERE, HAVING等多个子句中。

根据与外部查询的依赖关系,子查询可分为:

  1. 简单子查询(非关联子查询):

    • 子查询可以独立于外部查询执行。
    • 它只执行一次,然后将其结果传递给外部查询。
    • 示例: SELECT * FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE country = 'USA');
  2. 关联子查询(Correlated Subquery):

    • 子查询的执行依赖于外部查询的当前行。
    • 对于外部查询处理的每一行,子查询都会重新执行一次。
    • 这类似于编程语言中的嵌套循环,通常性能较差。
    • 示例: SELECT * FROM employees e WHERE salary > (SELECT AVG(salary) FROM employees WHERE department_id = e.department_id);

场景一:何时应该用 JOIN 替换子查询?

JOIN通常被数据库查询优化器处理得更高效。当你可以用JOIN实现与子查询相同的功能时,优先选择JOIN。下面是几个典型的“子查询可被JOIN优化”的场景。

示例数据准备

我们将使用product(产品表)和sale(销售表)来进行说明。

product 表

id name cost year city
1 chair 245.00 2017 Chicago
2 armchair 500.00 2018 Chicago
3 desk 900.00 2019 Los Angeles
4 lamp 85.00 2017 Cleveland
5 bench 2000.00 2018 Seattle
6 stool 2500.00 2020 Austin
7 tv table 2000.00 2020 Austin

sale 表

id product_id price year city
1 2 2000.00 2020 Chicago
2 2 590.00 2020 New York
3 2 790.00 2020 Cleveland
5 3 800.00 2019 Cleveland
6 4 100.00 2020 Detroit
7 5 2300.00 2019 Seattle
8 7 2000.00 2020 New York

1. WHERE子句中的 IN 子查询

这是最常见的替换场景。我们想查找所有已售出的产品的名称和成本。

子查询版本:

SELECT
  name,
  cost
FROM product
WHERE id IN (SELECT product_id FROM sale);

思路: 先从sale表中找出所有被卖过的product_id,然后去product表中筛选出id在这些ID列表中的产品。

JOIN 版本:

SELECT DISTINCT
  p.name,
  p.cost
FROM product p
JOIN sale s ON p.id = s.product_id;

思路: 将product表和sale表通过product_id连接起来。INNER JOIN天然地只会保留那些在两个表中都存在的记录,即已售出的产品。

性能深度解析:

  • 子查询: 老旧的数据库优化器可能会将IN子查询视为两个独立步骤:1. 执行子查询,将结果存入临时表或内存。2. 遍历外部表product,对每一行检查其id是否存在于临时结果中。这个过程可能无法有效利用索引。
  • JOIN: 优化器对JOIN有多种成熟的执行策略(如嵌套循环连接、哈希连接、合并连接)。它会根据表的大小、索引情况和数据分布统计信息,选择最优的连接算法。例如,如果s.product_id上有索引,优化器可以高效地执行连接操作。
  • 现代优化器: 值得注意的是,现代数据库(如PostgreSQL, SQL Server, MySQL 8+)的优化器非常智能,很多时候能够自动将IN子查询“扁平化”或重写为等效的JOIN。但直接写成JOIN,意图更明确,也更能保证优化器会选择高效的路径。
  • DISTINCT 的必要性: 在sale表中,一个产品可能被卖了多次(如product_id=2),JOIN后会导致产品信息重复出现。因此,需要使用DISTINCT来去重。

2. WHERE子句中的 NOT IN 子查询

目标:查找所有未被售出的产品。

子查询版本:

SELECT
  name,
  cost
FROM product
WHERE id NOT IN (SELECT product_id FROM sale);

JOIN 版本:

SELECT
  p.name,
  p.cost
FROM product p
LEFT JOIN sale s ON p.id = s.product_id
WHERE s.product_id IS NULL;

性能与陷阱深度解析:

  • LEFT JOIN ... IS NULL 模式: 这是一个非常经典且高效的模式。LEFT JOIN会保留左表(product)的所有记录。对于那些在右表(sale)中找不到匹配项的记录(即未售出的产品),右表的所有列都会填充为NULL。我们只需筛选出这些s.product_id IS NULL的行即可。这个操作同样能从JOIN的各种优化策略中受益。
  • NOT IN 的重大陷阱: NOT IN有一个致命缺陷:如果子查询返回的结果集中包含任何NULL值,整个外部查询将不会返回任何行!这是因为id NOT IN (val1, val2, NULL)的逻辑会被解释为id <> val1 AND id <> val2 AND id <> NULL。而任何值与NULL的比较结果都是UNKNOWN(而不是TRUE或FALSE),导致WHERE条件永远不为真。在我们的sale表中,product_id是外键,可能不允许为NULL,但这是一个必须警惕的通用问题。而LEFT JOIN模式则完全没有这个问题。

3. EXISTS 和 NOT EXISTS 子查询

目标:查找所有未在2020年售出过的产品信息。

子查询 (NOT EXISTS) 版本:

SELECT
  name,
  cost,
  city
FROM product p
WHERE NOT EXISTS (
  SELECT 1
  FROM sale s
  WHERE s.year = 2020 AND s.product_id = p.id
);

思路: 这是一个关联子查询。对于product表中的每一行,它都会去sale表中检查是否存在一条记录,满足product_id相同且年份是2020。如果不存在(NOT EXISTS),则外部查询的这一行被保留。

JOIN 版本:

SELECT
  p.name,
  p.cost,
  p.city
FROM product p
LEFT JOIN sale s ON p.id = s.product_id AND s.year = 2020
WHERE s.id IS NULL; -- 或者 s.product_id IS NULL 也可以

性能深度解析:

  • 关联子查询的执行模型:

    graph TD
        A[开始遍历 product 表的第一行 p1] --> B{执行子查询: SELECT ... WHERE s.product_id = p1.id AND s.year = 2020};
        B --> C{子查询返回结果了吗?};
        C -- 是 --> D[丢弃 p1];
        C -- 否 --> E[保留 p1];
        E --> F[遍历 product 表的下一行 p2];
        D --> F;
        F --> B;
    

    这种“逐行检查”的循环模式,在product表非常大时,会导致子查询被执行成千上万次,性能开销巨大。

  • JOIN 的执行模型: LEFT JOIN将两个表(或其中一个的子集)进行一次性的集合操作。优化器会选择最高效的方式来完成这个连接。ON子句中可以包含多个条件,我们将s.year = 2020也放入ON条件中,这样可以先过滤sale表,减少连接的数据量。最后通过WHERE s.id IS NULL一次性筛选出所有不匹配的行。

  • EXISTS vs IN: EXISTS通常比IN性能更好,因为它遵循“半连接(semi-join)”语义。一旦在子查询中找到一个匹配项,它就会立即停止搜索并返回TRUE,而不需要像IN那样扫描完整个子查询结果集。但即便如此,LEFT JOIN通常还是更优的选择。

场景二:何时必须或最好使用子查询?

尽管JOIN很强大,但有些场景下,子查询是不可或缺的,甚至是更优雅的解决方案。

1. 派生表(Derived Table):FROM子句中的子查询

当你需要对一个聚合后的结果集进行进一步操作(如连接、过滤)时,子查询是标准做法。

目标: 查找总销售额低于2100美元的城市及其总销售额。

子查询版本:

SELECT
  city,
  sum_price
FROM (
  SELECT
    city,
    SUM(price) AS sum_price
  FROM sale
  GROUP BY city
) AS sales_by_city
WHERE sum_price < 2100;

思路:

  1. 内部查询: 首先,我们需要计算每个城市的总销售额。GROUP BY city和SUM(price)完成了这个聚合操作。
  2. 外部查询: 然后,我们需要对这个聚合结果进行过滤。SQL标准不允许在WHERE子句中使用聚合函数(WHERE SUM(price) < 2100是错误的)。虽然可以使用HAVING,但如果逻辑更复杂,将其作为一个派生表(sales_by_city)供外部查询使用,会使逻辑更清晰。

无法用简单JOIN替代: 这个场景无法用一个简单的JOIN来完成,因为它涉及“先聚合,再过滤”的两步操作。

现代替代方案:CTE (Common Table Expressions)

为了提高可读性,特别是当有多层嵌套时,强烈推荐使用CTE(WITH子句)。

WITH sales_by_city AS (
  SELECT
    city,
    SUM(price) AS sum_price
  FROM sale
  GROUP BY city
)
SELECT
  city,
  sum_price
FROM sales_by_city
WHERE sum_price < 2100;

CTE在功能上与派生表类似,但它将复杂的逻辑拆分成了独立的、可命名的块,大大提升了代码的可读性和可维护性。


2. 标量子查询(Scalar Subquery):WHERE或SELECT中的单值比较

当需要将一列的值与一个单一的聚合值进行比较时,标量子查询非常方便。

目标: 查找成本低于所有商品平均销售价格的产品名称。

子查询版本:

SELECT name
FROM product
WHERE cost < (SELECT AVG(price) FROM sale);

思路:

  1. 子查询: 计算出sale表中所有销售记录的平均价格AVG(price)。这是一个单一值(标量)。
  2. 外部查询: 遍历product表,将每一行的cost与这个计算出的平均值进行比较。

为什么不用JOIN: 你当然可以尝试用JOIN来解决,但会变得非常复杂,例如使用交叉连接(CROSS JOIN)将平均值附加到每一行,但这远没有子查询来得直接和清晰。子查询在这里是最高效且最易读的方案。


3. ALL / ANY / SOME 子查询

当需要将一个值与子查询返回的所有值或任意一个值进行比较时,这些操作符非常有用。

目标: 查找成本高于所有销售记录中最高单价的产品。(这个例子有点极端,但能说明问题)

子查询版本:

SELECT name
FROM product
WHERE cost > ALL (SELECT price FROM sale);

-- 这等价于:
SELECT name
FROM product
WHERE cost > (SELECT MAX(price) FROM sale);

思路: > ALL 意味着必须大于子查询返回的列表中的每一个值。这通常可以被MAX()、MIN()等聚合函数改写,但ALL和ANY提供了更灵活的比较语义。例如,> ANY 意味着只需大于列表中的任意一个值即可。

这种场景下,子查询是表达这种特定逻辑的唯一直接方式。

性能总结与最佳实践

场景 推荐方法 性能考量与原因
数据关联 (A中找B) JOIN 优化器有多种高效连接策略(Hash, Merge, Nested Loop)。意图明确,符合SQL的集合思维。
存在性检查 (IN, EXISTS) JOIN LEFT JOIN ... IS NULL 或 INNER JOIN 模式通常比关联子查询更优,避免了逐行执行的开销。
不存在性检查 (NOT IN, NOT EXISTS) LEFT JOIN ... IS NULL 性能更优,且能完美避开 NOT IN 的 NULL 值陷阱。
先聚合,后操作 派生表 或 CTE 必须分步操作,子查询或CTE是实现此逻辑的标准方式。CTE可读性更佳。
与单一聚合值比较 标量子查询 最直接、清晰、高效的方式。执行计划简单:子查询计算一次,结果被重用。
与集合进行ALL/ANY比较 子查询 这是这些操作符被设计的目的,是表达此类逻辑的直接方式。

核心原则:

  1. 能 JOIN 就 JOIN: 如果你的目的是根据共同的键来组合多个表的数据,JOIN 应该是你的首选。
  2. 警惕关联子查询: 特别是在WHERE子句中,关联子查询是主要的性能杀手之一。在动手写之前,先思考是否可以转换为JOIN。
  3. 拥抱 CTE: 对于多步骤的复杂查询,使用 CTE 代替层层嵌套的派生表。它能让你的 SQL 逻辑清晰如画。
  4. 相信但不盲从优化器: 现代数据库优化器很强大,但不是万能的。编写清晰、符合标准实践的SQL是让优化器更好地为你工作的前提。
  5. 使用 EXPLAIN 分析执行计划: 当你不确定哪个查询更优时,使用EXPLAIN (或 EXPLAIN ANALYZE) 查看数据库为你的查询生成的实际执行计划。这是性能调优的终极武器。