缓存时间:
2026/05/15 15:01
# 欢迎来到 ORDER BY 丛林
来源:https://boringsql.com/posts/order-by-jungle/
SQL 很有趣,一点也不无聊。Markus Winand 的最新文章《Order by 走过了很长的路》(https://modern-sql.com/blog/2026-05/order-by-history) 让我踏上了一段旅程。
首先,创建一个名为 `nums` 的表,包含一个整数列和四行数据:
```
CREATE TABLE nums (a int);
INSERT INTO nums VALUES (0), (1), (2), (3);
```
猜猜这两个查询返回什么结果。
```
SELECT -a AS a FROM nums ORDER BY a;
SELECT -a AS a FROM nums ORDER BY -a;
```
大多数人会认为结果行相同,只是顺序不同。实际答案是:它们**产生完全相同的行,且顺序完全一致**。按照同样的逻辑,你可能会预期:
```
SELECT a AS c FROM nums ORDER BY -c;
```
会做同样的事。但事实并非如此。它会报错 `column "c" does not exist`,尽管别名就在语句中。欢迎来到 ORDER BY 丛林。
## 名称和表达式不是一回事
https://boringsql.com/posts/order-by-jungle/#names-and-expressions-are-not-the-same
如果你问大多数开发者 `ORDER BY` 如何工作,他们会说“你把列名放进去,它就对行进行排序”。在 99% 的查询中,这确实就是发生的事情。人们按 `created_at` 或 `id` 排序,然后继续。
严格来说,如果把 `ORDER BY 1` 也算进去的话,有三种方式。按位置引用本身就是一个坑,不在本文讨论范围。
但 `ORDER BY` 接受两种不同的东西:
```
SELECT created_at, user_id FROM events ORDER BY created_at;
SELECT created_at, user_id FROM events ORDER BY date(created_at);
```
两者都很自然。而没人告诉你的是,它们在解析器中走的是完全不同的代码路径。不同的作用域规则,不同的查找方式,不同的错误信息。第一个查看你的 `SELECT` 列表。第二个查看你的 `FROM` 子句。它们从不查看同一个地方。
## 同一个答案,两种不同的排序方式
https://boringsql.com/posts/order-by-jungle/#same-answer-two-different-sorts
再看第一个查询:
```
SELECT -a AS a FROM nums ORDER BY a;
```
你写了 `ORDER BY a`。一个裸标识符,没有装饰。Postgres 走的是**名称路径**。它扫描 `SELECT` 列表,查找名为 `a` 的内容,找到别名列 `-a AS a`,然后按其输出值排序。取反后的值是 `-3, -2, -1, 0`,升序为 `-3, -2, -1, 0`。这就是输出的结果。
现在看它的孪生兄弟:
```
SELECT -a AS a FROM nums ORDER BY -a;
```
你写了 `ORDER BY -a`。这不再是一个标识符。它是一个表达式:一元负号包裹一个列引用。解析器甚至不尝试同样的逻辑。
相反,它切换到**表达式路径**,在这里它知道的唯一 `a` 是 `nums` 中的列,然后对输入值取反后排序。由于算术上的巧合,这两个查询最终得到了相同的行顺序。相同的输出,但逻辑完全不同。如果你不相信这只是巧合,请从 `SELECT` 列表中移除取反操作,同时在 `ORDER BY` 中保留它:
```
SELECT a AS c FROM nums ORDER BY -a;
```
```
c
---
3
2
1
0
(4 rows)
```
`ORDER BY -a` 是一个表达式,所以它按 `-input_a` 升序排序,这实际上是 `input_a` 降序。别名 `c` 从未被考虑。结果与 `c` 是什么毫无关系。
而 `ORDER BY -c` 现在就很明显了。`-c` 是一个表达式,所以解析器在 `FROM` 中查找列 `c`,找不到,于是报错。别名存在,但在该代码路径无法访问的作用域中。
## 在标识符之上,还是包裹着它
https://boringsql.com/posts/order-by-jungle/#above-the-identifier-or-around-it
一旦规则明确(裸标识符命中 `SELECT` 列表,其他任何东西都命中表),剩下的惊喜就一一浮现了。
```
SELECT 'hello' AS x FROM nums ORDER BY x::text;
-- ERROR: column "x" does not exist
```
**类型转换**被视为表达式,并将查找推向表,这可能并不令人惊讶。
令人惊讶的可能是:
```
SELECT a AS c FROM nums ORDER BY c DESC NULLS FIRST;
```
这会按预期工作。`DESC` 和 `NULLS FIRST` 都是排序子句本身的一部分,而不是排序表达式的一部分。它们在解析树中位于标识符之上,因此从不触碰标识符。解析器仍然看到裸 `c`,走快速路径,找到别名,按其排序,然后在已解析的键之上应用“降序,空值优先”。
但**排序规则**则不同。
```
SELECT 'A'::text AS x FROM nums ORDER BY x COLLATE "C";
-- ERROR: column "x" does not exist
```
这很糟糕。`COLLATE` 看起来和排序修饰符一样,但并不是。它在解析树中包裹了表达式。
括号是一个特例:
```
SELECT -a AS a FROM nums ORDER BY (a);
-- 运行正常,按别名排序
```
Postgres 在裸标识符检查之前会折叠冗余括号,所以 `(a)` 仍然是裸 `a`。这种不对称性以最大化困惑的方式存在:`COLLATE` 是“对人类来说仍然是一个名称,对解析器来说是一个表达式”,而 `(a)` 是“对人类来说是一个表达式,对解析器来说仍然是一个名称”。这两种直觉在这里混杂在一起。
**一元正号。** `+a` 和 `a` 计算结果相同,但它们在解析中生成的节点不同。
```
SELECT -a AS a FROM nums ORDER BY a;
SELECT -a AS a FROM nums ORDER BY +a;
```
一个你甚至不会考虑的正号,会改变哪些行以何种顺序输出。
解析器将列引用存储为名称部分的列表:未限定时是一个部分,添加表或模式后变为两个或更多部分。快速路径只对长度为 1 的列表触发。
最后,**模式限定和表限定的引用**。`ORDER BY nums.a` 看起来像标识符,但并不是。
```
SELECT -a AS a FROM nums ORDER BY a;
SELECT -a AS a FROM nums ORDER BY nums.a;
```
```
a
----
-3
-2
-1
0
(4 rows)
a
----
0
-1
-2
-3
(4 rows)
```
## 不是你想象中的别名
https://boringsql.com/posts/order-by-jungle/#aliases-that-aren-t-the-names-you-think
这里有一个曾让我浪费了整个下午的例子。一旦 ORM 或生成的视图为你声明了别名,就容易遇到这种情况。SQLAlchemy、Hibernate、jOOQ 以及大多数代码生成器都会对任何不是纯小写的内容加引号。两个查询,除了一个中别名被引号括起来外完全相同。两个不同的结果集。
```
SELECT -a AS A FROM nums ORDER BY a; -- 按别名排序 (-3,-2,-1,0)
SELECT -a AS "A" FROM nums ORDER BY a; -- 按输入排序 (0,-1,-2,-3)
```
裸标识符的检查使用 `strcmp` 比较名称。未加引号的 `A` 转换为小写 `a` 并匹配。加引号的 `"A"` 保留大小写,仍然是 `A`,并且不匹配 `ORDER BY` 中的小写 `a`。查找失败,解析器回退到表达式路径,表达式路径在 `nums` 中找到列 `a`,查询成功运行,但结果与你本意不同。
## GROUP BY 先检查相反的作用域
https://boringsql.com/posts/order-by-jungle/#group-by-checks-the-opposite-scope-first
`GROUP BY` 和 `ORDER BY` 都接受裸标识符,并且都可以通过两种方式解析:要么是表列,要么是 `SELECT` 列表别名。区别在于它们检查的顺序:
- `ORDER BY a` 先查看 `SELECT` 列表,然后查看表。
- `GROUP BY a` 先查看表,然后查看 `SELECT` 列表。
对于大多数查询,这无关紧要。两个子句最终会选择同一个东西,因为没有东西被遮蔽。当别名与基列同名但值不同时,意外就发生了:
```
SELECT a/2 AS a, count(*)
FROM nums
GROUP BY a
ORDER BY a;
```
现在两个子句对 `a` 的含义有不同理解。`GROUP BY a` 选择输入列(四个不同的值,四个分组,每行一个)。`ORDER BY a` 选择别名,即 `a/2`。结果有四行,因为分组是基于比投影更细粒度的键:
```
a | count
---+-------
0 | 1
0 | 1
1 | 1
1 | 1
```
两行 `a/2 = 0`(来自输入 `0` 和 `1`),两行 `a/2 = 1`(来自输入 `2` 和 `3`)。重复是真实的。同一个标识符在一个查询的两个相邻子句中表示两个不同的列。
## 窗口 ORDER BY 甚至不假装
https://boringsql.com/posts/order-by-jungle/#window-order-by-does-not-even-pretend
这个会让人们抓狂,因为它看起来不像一个不同的子句:
```
SELECT a, -a AS neg, row_number() OVER (ORDER BY neg) FROM nums;
-- ERROR: column "neg" does not exist
```
`OVER (ORDER BY ...)` 是一条完全不同的解析路径。它根本不检查目标列表,只检查 FROM 作用域。裸名称快速路径在这里根本不存在。
```
SELECT a, -a AS neg, row_number() OVER (ORDER BY -a) FROM nums;
-- 这个能工作
```
同一个查询中有两个 `ORDER BY` 子句,两个不同的作用域规则。
## UNION ORDER BY 仅限名称
https://boringsql.com/posts/order-by-jungle/#union-order-by-is-name-only
当 `ORDER BY` 跟在 `UNION` 后面时,两条路径都不完全开放。
```
-- 正常
(SELECT a FROM nums) UNION ALL (SELECT 9) ORDER BY a;
-- 报错
(SELECT a FROM nums) UNION ALL (SELECT 9) ORDER BY -a;
-- 报错
(SELECT a FROM nums) UNION ALL (SELECT 9) ORDER BY a COLLATE "C";
```
错误信息异常有用:
> 只能使用结果列名称,不能使用表达式或函数。提示:将表达式/函数添加到每个 SELECT 中,或者将 UNION 移到 FROM 子句中。
集合操作没有单一的 FROM 作用域可供回退,因此表达式路径完全关闭。只能是裸名称,没有其他选择。
## 源代码中的接缝
https://boringsql.com/posts/order-by-jungle/#the-seam-in-the-source
坦白说:这一节我写错了三次,直到 Claude Code 帮我追踪了实际的解析树。整晚沉迷于 `ORDER BY` 导致睡眠不足是另一个说得通的解释。
打开 `src/backend/parser/parse_clause.c` 找到 `findTargetlistEntrySQL92`。这是一个四十行的注释,两个 `if` 块,以及最后的 `return`。SQL92 的两个解析规则先尝试;SQL99 是回退方案。
**块一:裸名称路径。** 入口是一个 `ColumnRef` 节点,且只有一个名称部分,该部分必须是一个字符串标识符(不是 `*`,它也是 `ColumnRef`,但有 `A_Star` 字段)。如果节点通过,函数遍历目标列表,查找一个非 `resjunk` 的条目,其 `resname` 等于该标识符。循环会在第一个匹配后继续,以检测歧义:相同的表达式没问题(这就是 `SELECT a, a FROM nums ORDER BY a` 能工作的原因),不同的表达式则报错。如果找到唯一匹配,返回。
如果循环找不到任何东西,该块**不会**返回。控制流继续向下。这就是前面文章中提到引用别名意外背后的情况:`AS "A"` 存储了 `resname = "A"`,`ORDER BY a` 查找 `resname = "a"`,`strcmp` 失败,函数继续执行,就像没有应用 SQL92 快速路径一样。
`GROUP BY` 是这个块内部的微小例外。名称首先在 `FROM` 作用域中测试,如果命中,则跳过目标列表循环。这就是 `GROUP BY` 最终偏好输入列的原因。
**块二:按位置路径。** 入口是 `IsA(node, A_Const)`。非整数常量会立即报错("non-integer constant in ..."),这捕获了 `ORDER BY NULL`、`ORDER BY 'a'`、`ORDER BY TRUE`。整数被用作基于 1 的位置索引,指向非 `resjunk` 目标列表;超出范围则报错 "position %d is not in select list"。块二从不继续往下。
`1` 和 `-1` 都以整数 `A_Const` 的形式到达这里。语法中的 `doNegate` 在函数运行之前就将 `'-' Iconst` 折叠成一个整数常量,所以 `ORDER BY 1` 和 `ORDER BY -1` 经过相同的代码,只有整数值(以及位置查找的结果)不同。
**继续往下。** 任何未被上述捕获的内容都会到达最后一行:
```
/*
* 否则,我们有一个表达式,因此按 SQL99 规则处理。
*/
return findTargetlistEntrySQL99(pstate, node, tlist, exprKind);
```
这就是接缝。SQL92 只在两种狭窄情况下成功:一个裸标识符且存在匹配的别名,或者一个在范围内的正整数。其他所有情况,包括查找别名未果的裸标识符,都变成针对 `FROM` 解析的 SQL99 表达式。
## 一个有用的变通方法
https://boringsql.com/posts/order-by-jungle/#a-useful-workaround
如果你想在 `ORDER BY` 的表达式内部使用别名,便携的技巧是将查询包装在子查询中:
```
SELECT *
FROM (SELECT -a AS x FROM nums) s
ORDER BY x + 0;
```
现在 `x` 是外部查询 FROM 作用域中的一个真实列。表达式路径能找到它。接缝被移开了。
这在概念上是当你直接写 `ORDER BY x + 0` 时你希望引擎为你做的事情。但 SQL-99 标准实际上并不要求这样,而且 Postgres(以及 SQL Server)明确记录在 `ORDER BY` 表达式中使用别名是不受支持的。因此你需要手动完成。
## 无聊的总结
https://boringsql.com/posts/order-by-jungle/#the-boring-takeaway
大多数时候,这些都不重要。你按刚刚选择的列排序,别名和输入列具有相同的名称和相同的值,无论哪条解析器路径都会给出相同的答案。接缝是不可见的。
一旦别名和输入列在表达式、值、大小写或任何包裹标识符的东西上存在分歧,解析器就会默默地选择其中一种,依据的规则比大多数在岗程序员都老。
有两个解析器。裸名称路径是 SQL-92,表达式路径是 SQL-99,它们是在 1990 年代后期拼接在一起的。它们仍然对你的标识符属于哪个作用域有分歧,而知道触发了哪个路径就能告诉你作用域是什么。
如果你在读完这篇文章后,在预测以下查询会做什么时还得停下来思考一分钟:
```
SELECT -a AS a FROM nums ORDER BY a COLLATE "C";
```
那是正确的反应。这意味着你有了这个心智模型。
---
*开头的谜题查询来自 Jamie Brandon 在 Lobsters 帖子(https://lobste.rs/s/zp6fnc/sql_s_order_by_has_come_long_way)下的评论(https://lobste.rs/s/zp6fnc/sql_s_order_by_has_come_long_way),该帖子讨论了 Markus Winand 在 modern-sql.com 上关于 `ORDER BY` 的历史(https://modern-sql.com/blog/2026-05/order-by-history)。后面所有内容都是该评论未给出的解释。两篇文章都值得单独阅读。*