数据库基础体系 · 第 68/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

SQL 查询逻辑处理顺序:FROM、WHERE、GROUP、HAVING、SELECT 和 ORDER

SQL 语句通常按照下面的书写顺序出现:

SELECT ...
FROM ...
WHERE ...
GROUP BY ...
HAVING ...
ORDER BY ...

但数据库理解查询时,不能简单地把这看成“从左到右执行”。SQL 更重要的是逻辑处理顺序

FROM
→ WHERE
→ GROUP BY
→ HAVING
→ SELECT
→ DISTINCT
→ ORDER BY
→ LIMIT / OFFSET

其中,DISTINCTLIMITOFFSET 不在标题中,但它们是理解完整查询结果不可缺少的部分。窗口函数通常位于分组聚合之后、最终排序之前的逻辑阶段。

这是一种语义模型,不是数据库执行器必须遵守的物理执行顺序。优化器可以调整连接顺序、提前过滤、选择索引或改变聚合算法,只要最终结果符合 SQL 语义即可。

本文示例主要适用于 PostgreSQL 16+ 和 MySQL 8.4。示例均假设在单条 SQL 语句中执行,未依赖特定事务隔离级别;如果并发事务同时修改数据,实际可见行还会受到数据库隔离级别和事务快照的影响。


一、先区分 SQL 的书写顺序与逻辑顺序

例如:

SELECT region, SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) >= 250
ORDER BY total_amount DESC;

阅读这条语句时,应该按以下问题理解:

  1. FROM orders:数据从哪里来?
  2. WHERE status = 'paid':哪些明细行可以参与后续处理?
  3. GROUP BY region:剩余明细行如何分组?
  4. HAVING SUM(amount) >= 250:哪些分组可以保留?
  5. SELECT region, SUM(amount):每个分组最终输出哪些列?
  6. ORDER BY total_amount DESC:结果行如何排序?

因此,SELECT 虽然写在最前面,但从逻辑上并不是最先处理。

这解释了一个常见错误:

SELECT amount * 0.9 AS discounted_amount
FROM orders
WHERE discounted_amount > 100;

在标准 SQL 的一般语义中,WHERE 处理时,SELECT 列表还没有产生,因此不能稳定地引用这里定义的别名。通常应改写为:

SELECT amount * 0.9 AS discounted_amount
FROM orders
WHERE amount * 0.9 > 100;

或者使用派生表:

SELECT discounted_amount
FROM (
    SELECT amount * 0.9 AS discounted_amount
    FROM orders
) AS t
WHERE discounted_amount > 100;

这里的关键不是“别名在哪一行”,而是别名属于哪个查询块、该查询块在逻辑上何时产生结果


二、用于推导的示例数据

下面建立一组订单数据:

CREATE TABLE orders (
    order_id   INTEGER PRIMARY KEY,
    customer_id INTEGER NOT NULL,
    region     VARCHAR(20),
    amount     DECIMAL(10, 2) NOT NULL,
    status     VARCHAR(20) NOT NULL
);

INSERT INTO orders (order_id, customer_id, region, amount, status) VALUES
    (1, 101, 'East', 120.00, 'paid'),
    (2, 101, 'East',  80.00, 'pending'),
    (3, 102, 'East', 200.00, 'paid'),
    (4, 102, 'West',  50.00, 'paid'),
    (5, 103, 'West', 300.00, 'cancelled'),
    (6, 103, 'West', 100.00, 'paid'),
    (7, 104, NULL,    90.00, 'paid');

以下查询:

SELECT region,
       COUNT(*) AS order_count,
       SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) >= 250
ORDER BY total_amount DESC;

预期结果为:

region order_count total_amount
East 2 320.00

原因是:

  • East 的已支付订单为 120 和 200,总额 320;
  • West 的已支付订单为 50 和 100,总额 150;
  • region IS NULL 的已支付订单总额为 90;
  • 只有 East 满足 HAVING SUM(amount) >= 250

接下来逐阶段推导这条查询。


三、FROM:确定输入关系和行的来源

3.1 FROM 产生查询的初始行集合

FROM 决定查询从哪些表、视图、子查询、函数或其他表表达式中取得数据。

最简单的形式:

FROM orders

可以先抽象为一个关系:

R0=ordersR_0 = orders

此时包含 orders 的全部 7 行,包括:

  • pending 订单;
  • cancelled 订单;
  • regionNULL 的订单。

FROM 阶段不会因为后面的 SELECT 只需要两列,就自动改变逻辑上的行集合。数据库可能在物理执行时只读取必要列,但这属于优化,不改变语义。


3.2 FROM 中的多个表通常先形成连接结果

例如:

SELECT c.customer_id, c.customer_name, o.order_id
FROM customers AS c
JOIN orders AS o
  ON o.customer_id = c.customer_id;

这里 FROM 阶段不仅是“读取两张表”,还包括连接操作。

对于内连接,可以近似理解为:

  1. customersorders 的候选行组合;
  2. 保留满足 ON 条件的组合。

形式上,如果 C 是客户表,O 是订单表,连接条件是:

c.customer_id=o.customer_idc.customer\_id = o.customer\_id

则结果大致为:

Cc.customer_id=o.customer_idOC \bowtie_{c.customer\_id=o.customer\_id} O

实际数据库不会真的构造笛卡尔积后再逐行筛选,可能使用哈希连接、嵌套循环连接或归并连接。但这些是物理算法,不是逻辑定义。


3.3 CROSS JOIN 与隐式笛卡尔积

SELECT *
FROM customers
CROSS JOIN regions;

如果 customersmm 行,regionsnn 行,结果逻辑上有:

m×nm \times n

条候选行。

旧式写法:

FROM customers, regions

在没有连接条件时,也会形成笛卡尔积。工程中经常因为遗漏连接条件而意外产生大量数据:

SELECT *
FROM customers AS c, orders AS o
WHERE c.customer_id = o.customer_id;

这在结果上可能等价于内连接,但显式写成:

FROM customers AS c
JOIN orders AS o
  ON c.customer_id = o.customer_id

更容易审查,也更适合表达外连接语义。


3.4 外连接的关键:ON 和 WHERE 不等价

考虑客户表:

customer_id customer_name
101 Alice
102 Bob
103 Carol
104 David
105 Eve

查询所有客户及其已支付订单:

SELECT c.customer_id, c.customer_name, o.order_id, o.status
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
 AND o.status = 'paid';

LEFT JOIN 会保留左表的每一行。对于没有匹配已支付订单的客户,右表列填充为 NULL。因此 Eve 仍然会出现在结果中。

如果把状态条件写进 WHERE

SELECT c.customer_id, c.customer_name, o.order_id, o.status
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
WHERE o.status = 'paid';

逻辑过程变为:

  1. FROM 先执行 LEFT JOIN
  2. 没有匹配订单的 Eve 得到一行 o.status = NULL
  3. WHERE o.status = 'paid' 不保留该行。

于是这个查询实际上消除了没有匹配订单的客户,结果效果接近内连接。

这不是语法风格问题,而是处理阶段不同:

  • ON:决定连接时哪些右表行可以匹配;
  • WHERE:连接完成后,决定哪些连接结果行继续保留。

在内连接中,很多谓词放在 ONWHERE 结果相同;在外连接中,通常不相同。


四、WHERE:在分组前过滤明细行

4.1 WHERE 处理的是行,不是分组

对示例查询:

WHERE status = 'paid'

WHERE 处理的是 FROM 产生的每一行。它不会看到“East 组总金额”这样的分组结果。

原始数据经过 WHERE 后:

order_id region amount status
1 East 120.00 paid
3 East 200.00 paid
4 West 50.00 paid
6 West 100.00 paid
7 NULL 90.00 paid

订单 2、5 在这一阶段被删除,后续的 GROUP BYSUM 都看不到它们。

因此:

SUM(amount)

计算的是已支付订单的金额总和,而不是所有状态订单的金额总和。


4.2 WHERE 不能直接判断聚合结果

下面的写法通常是错误的:

SELECT region, SUM(amount)
FROM orders
WHERE SUM(amount) >= 250
GROUP BY region;

因为 WHERE 阶段发生在 GROUP BY 之前,此时每个区域的 SUM(amount) 尚不存在。

正确写法是:

SELECT region, SUM(amount) AS total_amount
FROM orders
GROUP BY region
HAVING SUM(amount) >= 250;

如果还需要先过滤订单状态:

SELECT region, SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) >= 250;

二者表达不同的问题:

  • WHERE status = 'paid':哪些明细行可以进入分组;
  • HAVING SUM(amount) >= 250:哪些已经形成的分组可以输出。

4.3 WHERE 的三值逻辑:TRUE、FALSE 和 UNKNOWN

SQL 中的条件结果不只有真和假,还有 UNKNOWN。这通常由 NULL 产生。

例如:

WHERE region = 'East'

regionNULL 时,表达式:

NULL = 'East'

不是 FALSE,而是 UNKNOWN

WHERE 只保留结果为 TRUE 的行,FALSEUNKNOWN 都会被过滤掉。

因此,下面两条语句不同:

WHERE region = 'East'

和:

WHERE region IS NULL

判断空值必须使用 IS NULLIS NOT NULL,不能使用:

WHERE region = NULL

后者的结果是 UNKNOWN,不会匹配任何行。

逻辑上,SQL 的 ANDOR 也遵循三值逻辑。例如:

条件 A 条件 B A AND B
TRUE UNKNOWN UNKNOWN
FALSE UNKNOWN FALSE
TRUE FALSE FALSE

因此,复杂过滤条件中的 NULL 行经常产生与二值逻辑不同的结果。


4.4 NOT IN 与 NULL 的陷阱

例如:

WHERE customer_id NOT IN (
    SELECT customer_id
    FROM blacklist
)

如果子查询结果中包含一个 NULL,那么某些比较会变成:

customer_id <> 101
AND customer_id <> 102
AND customer_id <> NULL

最后一项为 UNKNOWN,整个 AND 结果可能不是 TRUE,导致本应保留的行被过滤。

需要表达“没有匹配项”时,通常更稳妥的是使用 NOT EXISTS

WHERE NOT EXISTS (
    SELECT 1
    FROM blacklist AS b
    WHERE b.customer_id = orders.customer_id
)

这是反连接(anti join)语义:保留在黑名单中找不到匹配行的订单。


五、GROUP:GROUP BY 如何形成分组

标题中的 GROUP 在完整 SQL 语法中通常指 GROUP BY。它的作用不是简单排序,也不是任意去重,而是把行划分为若干组,使聚合函数可以对每组分别计算。

5.1 GROUP BY 的形式化定义

假设经过 WHERE 后得到关系 RR,分组键为表达式 g(r)g(r)

对于每个键值 kk,形成一个组:

Gk={rRg(r)=k}G_k = \{r \in R \mid g(r) = k\}

其中 SQL 的分组比较还涉及 NULL 的特殊规则:分组时,通常把同一分组键位置上的 NULL 放在同一个组中。

示例:

SELECT region, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY region;

分组结果为:

分组键 region 组内订单 COUNT(*) SUM(amount)
East 1, 3 2 320.00
West 4, 6 2 150.00
NULL 7 1 90.00

regionNULL 的订单不是每行独立形成一组,而是按照分组规则进入同一个 NULL 组。


5.2 SELECT 中的列为什么必须分组或聚合

下面的查询存在语义问题:

SELECT region, order_id, SUM(amount)
FROM orders
GROUP BY region;

一个 region 可能对应多个 order_id。例如 East 对应订单 1、2、3,数据库无法从中确定应该输出哪个 order_id

因此,一个分组查询中,非聚合输出表达式通常必须满足以下之一:

  1. 它出现在 GROUP BY 中;
  2. 它被聚合函数包裹;
  3. 数据库依据明确的函数依赖规则判断其值唯一。

具有跨数据库可移植性的写法是显式遵守前两条:

SELECT region,
       MIN(order_id) AS first_order_id,
       COUNT(*) AS order_count
FROM orders
GROUP BY region;

不要依赖某个数据库的宽松行为来选择“组内任意一行”。

MySQL 的 ONLY_FULL_GROUP_BY 会影响这类查询是否被接受;现代 MySQL 默认启用该模式。PostgreSQL 通常对分组列要求更严格,但在某些可证明的函数依赖场景下允许额外列。若 SQL 需要跨 PostgreSQL 和 MySQL 运行,显式分组或聚合更可靠。


5.3 GROUP BY 不一定必须配合聚合函数

下面的查询可以执行:

SELECT region
FROM orders
GROUP BY region;

它会为每个 region 输出一行,效果类似于:

SELECT DISTINCT region
FROM orders;

但二者表达意图不同:

  • DISTINCT:对最终投影结果去重;
  • GROUP BY:构造分组,通常为聚合计算服务。

当没有聚合需求时,使用 DISTINCT 往往更直接。


5.4 没有 GROUP BY 时的单组聚合

下面的查询没有写 GROUP BY

SELECT COUNT(*) AS order_count,
       SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid';

如果查询包含聚合函数而没有 GROUP BY,逻辑上通常将所有通过 WHERE 的行视为一个组:

order_count total_amount
5 560.00

如果 WHERE 过滤后没有任何行:

  • COUNT(*) 返回 0;
  • SUM(amount)AVG(amount)MIN(amount)MAX(amount) 通常返回 NULL

例如:

SELECT COUNT(*) AS count_value,
       SUM(amount) AS sum_value
FROM orders
WHERE status = 'not-exist';

结果逻辑上是:

count_value sum_value
0 NULL

如果业务希望空集合的总金额显示为 0,应显式处理:

SELECT COALESCE(SUM(amount), 0) AS total_amount
FROM orders
WHERE status = 'not-exist';

COALESCE 返回参数列表中第一个非 NULL 的值。


5.5 COUNT(*)、COUNT(column) 和 COUNT(DISTINCT column)

这三个表达式不能混为一谈:

COUNT(*)
COUNT(region)
COUNT(DISTINCT region)

对如下数据:

region
East
East
NULL

结果是:

  • COUNT(*) = 3:统计行数;
  • COUNT(region) = 2:只统计 regionNULL 的行;
  • COUNT(DISTINCT region) = 1:统计非 NULL 的不同区域值。

聚合函数是否忽略 NULL,必须单独确认,不能把 NULL 当成普通值处理。


六、HAVING:在分组后过滤分组

6.1 HAVING 的输入是分组

示例:

SELECT region,
       COUNT(*) AS order_count,
       SUM(amount) AS total_amount
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) >= 250;

逻辑上:

  1. FROM 得到 7 行;
  2. WHERE 保留 5 行;
  3. GROUP BY 形成 East、West、NULL 三组;
  4. HAVING 只保留总额至少为 250 的组。

HAVING 的判断单位已经不是订单行,而是区域组。

因此,下面的条件含义不同:

WHERE amount >= 100

表示过滤掉金额低于 100 的明细订单。

HAVING SUM(amount) >= 100

表示保留订单总额至少为 100 的区域组。


6.2 HAVING 可以使用分组键,也可以使用聚合结果

SELECT region, SUM(amount) AS total_amount
FROM orders
GROUP BY region
HAVING region IS NOT NULL
   AND SUM(amount) >= 200;

它同时过滤:

  • 分组键不是 NULL
  • 组内金额总和至少为 200。

在跨数据库 SQL 中,为了减少别名可见性差异,通常直接重复聚合表达式:

HAVING SUM(amount) >= 200

而不是依赖:

HAVING total_amount >= 200

不同数据库对 SELECT 别名在 GROUP BYHAVING 中的可见性存在差异;ORDER BY 对输出别名的支持则更普遍。


6.3 没有 GROUP BY 也可以使用 HAVING

因为没有 GROUP BY 的聚合查询逻辑上只有一个总组,所以可以写:

SELECT SUM(amount) AS total_amount
FROM orders
HAVING SUM(amount) >= 1000;

如果所有订单总额满足条件,返回一行;否则返回零行。

这与下面查询的语义不同:

SELECT SUM(amount) AS total_amount
FROM orders
WHERE amount >= 1000;

第二条是先筛选单笔金额至少为 1000 的订单,再计算总和;第一条是计算全部订单总和,再判断总和是否至少为 1000。


七、SELECT:生成最终投影和表达式

7.1 SELECT 负责投影,不只是“选择列”

关系代数中的投影,是从输入关系中选择列并计算新的表达式。

例如:

SELECT region,
       SUM(amount) AS total_amount,
       SUM(amount) * 0.9 AS discounted_total
FROM orders
WHERE status = 'paid'
GROUP BY region;

SELECT 阶段对每个分组产生一行:

region total_amount discounted_total
East 320.00 288.00
West 150.00 135.00
NULL 90.00 81.00

这里的 discounted_total 是基于聚合结果计算出的新表达式。


7.2 SELECT 别名属于当前查询块的输出

SELECT amount * 0.9 AS discounted_amount
FROM orders;

discounted_amount 是输出列名,不是表中原本存在的列。

在同一个查询块中,下面的写法通常不能使用这个别名:

SELECT amount * 0.9 AS discounted_amount
FROM orders
WHERE discounted_amount > 100;

因为 WHERE 逻辑上先于 SELECT

可以用子查询创建新的查询边界:

SELECT discounted_amount
FROM (
    SELECT amount * 0.9 AS discounted_amount
    FROM orders
) AS x
WHERE discounted_amount > 100;

内层查询先产生 discounted_amount,外层查询的 FROM 再把它作为输入列提供给外层 WHERE


7.3 为什么 ORDER BY 经常可以引用 SELECT 别名

SELECT region,
       SUM(amount) AS total_amount
FROM orders
GROUP BY region
ORDER BY total_amount DESC;

ORDER BY 位于投影之后的逻辑阶段,因此可以引用输出列别名。

也可以写:

ORDER BY SUM(amount) DESC;

或:

ORDER BY 2 DESC;

其中 2 表示按 SELECT 列表中的第二列排序。序号写法虽然常见,但当 SELECT 列表发生调整时容易改变含义,不适合复杂查询。

ORDER BY 使用别名的能力比 WHERE 更稳定,但不同数据库对同名列、表达式别名和 DISTINCT 场景仍可能有细节差异。


八、DISTINCT:投影之后的去重

虽然标题没有列出 DISTINCT,但它位于 SELECT 之后,对理解结果行十分重要。

SELECT DISTINCT region
FROM orders;

逻辑上先生成投影结果:

region
East
East
West
West
West
NULL

然后删除重复行:

region
East
West
NULL

DISTINCT 是对整个输出行去重,而不是只对某一列“单独去重”。

SELECT DISTINCT region, status
FROM orders;

去重依据是 (region, status) 这个完整组合。

聚合表达式也会先计算,再参与最终行的去重:

SELECT DISTINCT region, SUM(amount)
FROM orders
GROUP BY region;

实际使用中,GROUP BYDISTINCT 的组合经常说明查询还可以重新审查,因为它们可能重复表达去重意图,但不是语义上绝对错误。


九、ORDER:最终结果排序

9.1 ORDER BY 只定义结果顺序

SELECT order_id, amount
FROM orders
ORDER BY amount DESC;

ORDER BY amount DESC 表示按金额降序输出。

如果金额相同,没有额外排序条件时,这些行之间的相对顺序通常不受保证:

SELECT order_id, amount
FROM orders
ORDER BY amount DESC;

如果程序需要稳定顺序,应增加唯一或足够区分行的键:

SELECT order_id, amount
FROM orders
ORDER BY amount DESC, order_id ASC;

这在分页中特别重要。否则两次查询可能出现相同金额行顺序变化,导致某些行重复出现在不同页面,或某些行被跳过。


9.2 ASC、DESC 与 NULL 的顺序

升序和降序控制非空值的顺序,但 NULL 位于前面还是后面存在数据库差异。

PostgreSQL 支持显式指定:

ORDER BY region ASC NULLS LAST;

也可以写:

ORDER BY region DESC NULLS FIRST;

MySQL 没有完全相同的 NULLS FIRST/LAST 语法。可以通过排序表达式显式控制:

ORDER BY (region IS NULL) ASC, region ASC;

其含义是:

  1. (region IS NULL)FALSE 的行先来;
  2. regionNULL 的行排在后面;
  3. 非空区域内部按 region 升序。

如果 SQL 需要在 PostgreSQL 和 MySQL 之间移植,不要依赖默认的 NULL 排序位置。


9.3 ORDER BY 不代表表本身被永久排序

表没有“天然顺序”。即使某次查询没有 ORDER BY 恰好返回了按主键递增的结果,也不能把它当成保证。

下面的查询没有顺序保证:

SELECT *
FROM orders;

索引扫描、顺序扫描、并行执行、统计信息变化或数据页变化,都可能改变返回顺序。

排序只对当前查询结果生效:

SELECT *
FROM orders
ORDER BY order_id;

它不会改变表的存储顺序,也不会影响之后其他查询。


十、LIMIT 和 OFFSET:排序后的截取

在常见的逻辑模型中,LIMITOFFSET 位于 ORDER BY 之后:

SELECT order_id, amount
FROM orders
ORDER BY amount DESC, order_id ASC
LIMIT 3;

逻辑步骤是:

  1. 取订单;
  2. 按金额降序、订单号升序排列;
  3. 截取前 3 行。

不能把下面两条语句视为等价:

SELECT order_id, amount
FROM orders
ORDER BY amount DESC
LIMIT 3;

和:

SELECT order_id, amount
FROM orders
LIMIT 3
ORDER BY amount DESC;

后者本身就是语法错误;而且即使数据库先物理读取了少量行,也必须保证语义等价于排序后再截取。


10.1 分页必须配合确定性排序

不推荐:

SELECT *
FROM orders
ORDER BY amount DESC
LIMIT 20 OFFSET 20;

因为金额相同的订单没有确定的内部顺序。

至少应写成:

SELECT *
FROM orders
ORDER BY amount DESC, order_id ASC
LIMIT 20 OFFSET 20;

更大的偏移量还可能造成扫描和丢弃大量前置行。基于主键的“键集分页”通常更稳定:

SELECT *
FROM orders
WHERE order_id > 1000
ORDER BY order_id
LIMIT 20;

这改变的是分页策略,不改变 SQL 的逻辑顺序:仍然是先由 WHERE 筛选,再排序,再截取。


十一、窗口函数位于聚合之后,但不等于 GROUP BY

窗口函数用于在保留明细行的同时,计算同一窗口内的统计值。

例如:

SELECT order_id,
       region,
       amount,
       SUM(amount) OVER (PARTITION BY region) AS region_total
FROM orders
WHERE status = 'paid'
ORDER BY region, order_id;

结果类似:

order_id region amount region_total
1 East 120.00 320.00
3 East 200.00 320.00
4 West 50.00 150.00
6 West 100.00 150.00
7 NULL 90.00 90.00

GROUP BY 的区别:

GROUP BY region

会把同一区域的多行压缩成一行。

SUM(amount) OVER (PARTITION BY region)

不会压缩行,而是在每一行旁边附加区域总额。

常见的逻辑理解是:

FROM
→ WHERE
→ GROUP BY / HAVING
→ 窗口计算
→ SELECT 输出
→ DISTINCT
→ ORDER BY
→ LIMIT

具体实现可以把窗口表达式作为 SELECT 阶段的一部分处理,但窗口函数不能直接在 WHERE 中使用:

SELECT order_id,
       ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_id) AS rn
FROM orders
WHERE rn = 1;

因为 WHERE 处理时窗口编号还没有产生。应使用外层查询:

SELECT order_id, customer_id, amount
FROM (
    SELECT order_id,
           customer_id,
           amount,
           ROW_NUMBER() OVER (
               PARTITION BY customer_id
               ORDER BY order_id
           ) AS rn
    FROM orders
) AS x
WHERE rn = 1;

这个查询的含义是:

  1. 内层查询为每个客户的订单编号;
  2. 外层查询保留每个客户编号为 1 的订单。

十二、完整查询的逐步推导

再看一条包含连接、过滤、分组、聚合、分组过滤、排序和分页的查询:

SELECT c.customer_id,
       c.customer_name,
       COUNT(o.order_id) AS paid_order_count,
       SUM(o.amount) AS paid_total
FROM customers AS c
LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
 AND o.status = 'paid'
WHERE c.customer_id >= 101
GROUP BY c.customer_id, c.customer_name
HAVING COALESCE(SUM(o.amount), 0) >= 100
ORDER BY paid_total DESC, c.customer_id ASC
LIMIT 10;

其逻辑过程如下。

第一步:FROM 和 LEFT JOIN

从客户表开始,并寻找每个客户的已支付订单。

注意状态条件在 ON 中:

ON o.customer_id = c.customer_id
AND o.status = 'paid'

因此,没有已支付订单的客户仍保留,只是右表列为 NULL


第二步:WHERE

WHERE c.customer_id >= 101

过滤客户行。它不是过滤订单金额,也不是过滤分组总额。


第三步:GROUP BY

GROUP BY c.customer_id, c.customer_name

每个客户形成一个组。

之所以同时按 customer_idcustomer_name 分组,是为了让输出中的两个非聚合列都明确属于分组键。


第四步:计算聚合

COUNT(o.order_id)
SUM(o.amount)

这里使用 COUNT(o.order_id) 而不是 COUNT(*) 很重要。

对于没有匹配订单的客户,LEFT JOIN 仍会产生一条连接结果,但 o.order_idNULL

  • COUNT(*) 会把这条补齐行计数为 1;
  • COUNT(o.order_id) 不会计数。

所以这里的 COUNT(o.order_id) 才表示实际匹配到的订单数。


第五步:HAVING

HAVING COALESCE(SUM(o.amount), 0) >= 100

它过滤的是客户组。

对于没有已支付订单的客户:

SUM(o.amount)

通常为 NULL,通过 COALESCE 转成 0 后,不满足至少 100 的条件。


第六步:SELECT

生成:

  • 客户编号;
  • 客户姓名;
  • 已支付订单数;
  • 已支付金额总和。

第七步:ORDER BY

ORDER BY paid_total DESC, c.customer_id ASC

先按总额降序;总额相同时按客户编号升序,形成确定性排序。


第八步:LIMIT

LIMIT 10

最终只取排序后的前 10 个客户。

优化器可能在执行层面提前使用索引、使用 Top-N 算法或改变连接顺序,但不能因此把“前 10 个未排序客户”当成结果。


十三、子查询中的 ORDER BY 不会自动传递到外层

每个查询块都有自己的逻辑处理过程。

例如:

SELECT *
FROM (
    SELECT order_id, amount
    FROM orders
    ORDER BY amount DESC
) AS x;

即使内层写了 ORDER BY,外层查询没有指定排序时,也不应把最终结果视为有序。

要保证最终结果顺序,应在最外层排序:

SELECT *
FROM (
    SELECT order_id, amount
    FROM orders
) AS x
ORDER BY amount DESC, order_id ASC;

内层排序在与 LIMIT 配合时有明确用途:

SELECT *
FROM (
    SELECT order_id, amount
    FROM orders
    ORDER BY amount DESC, order_id ASC
    LIMIT 3
) AS top_orders
ORDER BY amount DESC, order_id ASC;

这里内层 ORDER BY ... LIMIT 3 定义了要选入派生表的 3 行;外层排序则定义最终输出顺序。即使某些数据库在特定情况下保留内层顺序,也不应依赖这种未由外层声明的行为。


十四、逻辑顺序不是执行计划

下面这条语句逻辑上是:

FROM
→ WHERE
→ GROUP BY
→ HAVING
→ SELECT
→ ORDER BY

但数据库可能实际执行为:

  1. 使用 orders(status) 索引直接定位已支付订单;
  2. 先按 region 做哈希聚合;
  3. 在聚合结果上应用 HAVING
  4. 对少量分组结果排序。

也可能选择:

  1. 先连接客户和订单;
  2. 再过滤;
  3. 使用排序聚合;
  4. 用 Top-N 排序实现 ORDER BY ... LIMIT

这些物理计划都可能合法。

可以用执行计划诊断真实执行方式:

PostgreSQL:

EXPLAIN (ANALYZE, BUFFERS)
SELECT region, SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) >= 250
ORDER BY SUM(amount) DESC;

MySQL:

EXPLAIN ANALYZE
SELECT region, SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY region
HAVING SUM(amount) >= 250
ORDER BY SUM(amount) DESC;

需要区分两件事:

  • 逻辑顺序:决定查询结果的语义;
  • 执行计划:决定数据库如何高效得到该结果。

优化器可以安全地做等价变换。例如,如果条件只引用基础表列,可能把部分 HAVING 条件提前到 WHERE。但它不能把依赖聚合结果的条件任意提前,否则结果会改变。


十五、常见误解与失败表现

15.1 把 SELECT 认为是第一阶段

错误理解:

SELECT 写在最前面,所以先计算 SELECT 表达式。

实际情况是,FROMWHERE、分组等阶段先决定输入和分组,SELECT 再生成输出投影。

失败表现通常是:

WHERE select_alias = ...

报“列不存在”,或在不同数据库中表现不一致。


15.2 用 WHERE 过滤聚合结果

错误:

WHERE COUNT(*) > 10

正确:

HAVING COUNT(*) > 10

前者试图在行级阶段使用组级结果,后者明确在分组后判断。


15.3 用 HAVING 代替所有 WHERE

下面两条语句不一定等价:

SELECT region, SUM(amount)
FROM orders
WHERE status = 'paid'
GROUP BY region;
SELECT region, SUM(amount)
FROM orders
GROUP BY region
HAVING SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) > 0;

第一条只让已支付订单进入分组,所以 COUNT(*)AVG(amount) 等所有聚合都只针对已支付订单。

第二条仍然把所有状态订单放入分组,只是在最终判断时计算已支付金额。其他聚合列的含义会不同。


15.4 忽略外连接中 NULL 补齐行

错误地写:

LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
WHERE o.status = 'paid';

如果目标是“保留所有客户,同时只统计已支付订单”,状态条件应考虑放在 ON 中:

LEFT JOIN orders AS o
  ON o.customer_id = c.customer_id
 AND o.status = 'paid';

如果目标是“只要有已支付订单的客户”,放在 WHERE 中则可能正是所需语义。关键在于明确目标,而不是机械地把条件放入某个位置。


15.5 认为没有 ORDER BY 也会按主键返回

没有 ORDER BY 就没有可依赖的结果顺序。即使当前执行计划“看起来稳定”,索引变化、并行执行或统计信息更新都可能改变顺序。


15.6 认为 ORDER BY 能保证重复值之间的顺序

ORDER BY amount DESC

只规定金额之间的顺序。金额相同的行仍没有确定的相对顺序。

需要稳定结果时:

ORDER BY amount DESC, order_id ASC

十六、用逻辑顺序检查查询是否表达正确

分析一条复杂查询时,可以依次问:

1. FROM 得到的行是什么?

  • 是否有意外笛卡尔积?
  • 连接条件是否完整?
  • 是内连接、左外连接,还是其他连接?
  • 过滤条件应该属于 ON 还是 WHERE

2. WHERE 删除了哪些明细行?

  • 是否错误地过滤了 NULL
  • 是否把聚合条件写进了 WHERE
  • NOT IN 的子查询是否可能返回 NULL

3. GROUP BY 形成了哪些组?

  • 分组键是否完整?
  • NULL 分组是否符合业务语义?
  • 是否把需要保留的明细列误写进分组查询?

4. HAVING 过滤的是哪些组?

  • 条件是否依赖聚合结果?
  • 空组或 SUM(...) IS NULL 是否需要 COALESCE
  • 这个条件是否实际上可以在行级提前过滤?

5. SELECT 输出的是什么?

  • 非聚合列是否属于分组键?
  • 别名在哪个查询块中产生?
  • COUNT(*)COUNT(column) 是否语义正确?

6. ORDER BY 的顺序是否完整?

  • 是否需要指定 NULLS FIRST/LAST
  • 是否需要追加唯一键以保证稳定排序?
  • 分页是否建立在确定性顺序之上?

7. LIMIT 截取的是哪一批行?

  • 是否先排序再截取?
  • 是否把内层查询的顺序错误地当成外层顺序?
  • 大偏移分页是否符合性能和一致性要求?

SQL 的逻辑处理顺序本质上是在回答一个问题:每个子句看到的输入是什么、能引用哪些结果、输出会如何变化

FROM 决定行的来源和连接结果,WHERE 过滤明细行,GROUP BY 把行组织成组,HAVING 过滤分组,SELECT 生成投影,ORDER BY 排列最终结果,LIMIT 再从确定的结果序列中截取。掌握这条数据流,连接条件、聚合错误、别名不可见、窗口函数嵌套和分页不稳定等问题,都可以从处理阶段自然推导出来,而不必依赖记忆规则。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。