写 SQL 的人每天都在用 ORDER BY,绝大多数时候它表现得像一条直线——写个列名,结果就按那列排。但一旦别名和原列在表达式、大小写或修饰符上产生分歧,Postgres 会悄悄切换到另一条解析路径,而你毫不知情。
先看一道谜题。建一张四行小表:
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。别名 c 明明就在语句里,解析器却说找不到。
这就是 ORDER BY 的丛林。下面把地图画出来。
两条路径,两个世界
ORDER BY 接受的东西看起来只有一种——"排序依据"。但解析器把它们分成了两条完全不同的路:
- 名字路径(SQL-92):裸标识符,比如
ORDER BY created_at。解析器先扫描SELECT列表,找同名别名,用别名的输出值排序。 - 表达式路径(SQL-99):任何不是裸标识符的东西,比如
ORDER BY date(created_at)。解析器只看FROM子句的表列,别名根本不在视野里。
两条路径从不看同一个地方。名字路径看 SELECT 列表;表达式路径看 FROM 表。这就是所有意外的根源。
同样的输出,不同的逻辑
回到第一条谜题:
SELECT -a AS a FROM nums ORDER BY a;
ORDER BY a 是裸标识符,走名字路径。解析器在 SELECT 列表里找到别名 a(实际值是 -a),按别名的输出值排序。-3, -2, -1, 0 升序,结果就是 -3, -2, -1, 0。
第二条:
SELECT -a AS a FROM nums ORDER BY -a;
ORDER BY -a 是表达式(一元负号 + 列引用),走表达式路径。解析器只认 nums.a,对输入值取负后排序。输入值 0,1,2,3 取负得 0,-1,-2,-3,升序排列同样是 -3,-2,-1,0。
输出碰巧一样,逻辑完全不同。 把 SELECT 列表里的负号去掉,只保留 ORDER BY 里的,差异就暴露了:
SELECT a AS c FROM nums ORDER BY -a;
结果:
c
---
3
2
1
0
ORDER BY -a 走表达式路径,按 -input_a 升序排,等于 input_a 降序。别名 c 从未被参考。而 ORDER BY -c 直接报错——表达式路径只看 FROM,表里没有列 c。
修饰符的陷阱:哪些算"裸",哪些不算
知道规则后,下面的现象就都能解释了,但直觉仍然会出错。
类型转换是表达式:
SELECT 'hello' AS x FROM nums ORDER BY x::text;
-- ERROR: column "x" does not exist
加了 ::text,x 就不再是裸标识符,走表达式路径,找不到表列 x。
DESC / NULLS FIRST 不是表达式:
SELECT a AS c FROM nums ORDER BY c DESC NULLS FIRST;
-- 正常工作,按别名 c 降序、nulls first 排序
DESC 和 NULLS FIRST 是排序修饰符,挂在解析树的上层,不改变 c 本身的身份。解析器仍然看到裸标识符 c,走名字路径找到别名,再在结果上应用修饰符。
COLLATE 是表达式——这是最坑的:
SELECT 'A'::text AS x FROM nums ORDER BY x COLLATE "C";
-- ERROR: column "x" does not exist
COLLATE 在解析树上包裹了 x,把它从裸标识符变成了表达式。人类觉得它和 DESC 一样是排序修饰符,解析器说它是表达式的一部分。
括号是特例:
SELECT -a AS a FROM nums ORDER BY (a);
-- 正常工作,按别名排序
Postgres 在做裸标识符检查之前会先折叠冗余括号,所以 (a) 仍然是裸 a。这造成了一种不对称:COLLATE 对人来说是"名字上的修饰",对解析器是表达式;(a) 对人来说是"表达式",对解析器是名字。两种错误直觉同时存在。
一元正号:
SELECT -a AS a FROM nums ORDER BY +a;
+a 和 a 的值相同,但解析节点不同。一元正号让标识符变成了表达式,走表达式路径,按原列 nums.a 排序。输出顺序和 ORDER BY a(走名字路径按别名排)不一样。一个你根本不会注意的 + 号,改变了结果行的顺序。
表名限定:
SELECT -a AS a FROM nums ORDER BY nums.a;
nums.a 看起来是标识符,但解析后是两个名字部分(表名 + 列名),裸标识符快速路径只对单部分名字生效。于是走表达式路径,按 nums.a 原列排序。两条看似等价的 ORDER BY,输出顺序截然不同:
-- ORDER BY a → 按别名 -a 排序:-3, -2, -1, 0
-- ORDER BY nums.a → 按原列 a 排序:0, -1, -2, -3
大小写与引号:ORM 常踩的坑
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 被 Postgres 折叠为小写 a,和 ORDER BY a 匹配,走名字路径。加引号的 "A" 保留原大小写,和 a 不匹配,名字路径找不到,落入表达式路径,按原列排序。查询成功执行,但排序逻辑和你预期的完全不同。
GROUP BY 走反方向
GROUP BY 和 ORDER BY 都接受裸标识符,都能解析到表列或别名——但检查顺序相反:
ORDER BY a:先看SELECT列表,再看FROM。GROUP BY a:先看FROM,再看SELECT列表。
当别名和原列同名但值不同时,两条 clauses 对同一个标识符的理解就不一样了:
SELECT a/2 AS a, count(*) FROM nums GROUP BY a ORDER BY a;
GROUP BY a 先看 FROM,选原列 nums.a(四个不同值,四组,每组一行)。ORDER BY a 先看 SELECT 列表,选别名 a/2。结果:
a | count
---+-------
0 | 1
0 | 1
1 | 1
1 | 1
同一个 a,GROUP BY 指原列,ORDER BY 指别名。四行里有重复的 a 值,因为分组粒度比投影粒度更细。
窗口函数和 UNION:更窄的视野
窗口 ORDER BY 完全不看 SELECT 列表:
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 只允许裸名字:
(SELECT a FROM nums) UNION ALL (SELECT 9) ORDER BY a; -- OK
(SELECT a FROM nums) UNION ALL (SELECT 9) ORDER BY -a; -- ERROR
(SELECT a FROM nums) UNION ALL (SELECT 9) ORDER BY a COLLATE "C"; -- ERROR
集合操作没有统一的 FROM 作用域,表达式路径完全关闭。只能用裸名字引用结果列。
源码里的接缝
打开 src/backend/parser/parse_clause.c,找到 findTargetlistEntrySQL92。四十行注释,两个 if 块,一个 return。
第一个块——裸名字路径:检查 ColumnRef 是否恰好只有一个名字部分且为字符串标识符。通过后遍历 SELECT 列表找匹配的 resname。GROUP BY 在这个块里有个小例外:先查 FROM 作用域,命中则跳过目标列表循环。没找到匹配别名时,这个块不返回,控制权落入下一个阶段。
第二个块——位置路径:A_Const 整数常量作为 1-based 位置索引。ORDER BY -1 在语法层面就被折叠成单个整数常量,和 ORDER BY 1 走同一条路。这个块不会落入下一阶段。
落入点——一切没被上面捕获的东西:
/* Otherwise, we have an expression, so process it per SQL99 rules. */
return findTargetlistEntrySQL99(pstate, node, tlist, exprKind);
这就是接缝。SQL-92 只在两种窄形状上成功:匹配到别名的裸标识符,或范围内的正整数位置。其余一切——包括没匹配到别名的裸标识符——都变成 SQL-99 表达式,对 FROM 作用域解析。
实操:让别名在表达式里可见
想在 ORDER BY 表达式里引用别名,标准做法是把查询包进子查询,让别名变成外层的真实列:
-- 直接写 ORDER BY x+0 会报错(x 是别名,表达式路径看不到)
SELECT * FROM (
SELECT -a AS x FROM nums
) s
ORDER BY x + 0;
现在 x 是 FROM 作用域里的真实列,表达式路径能找到它。这本质上就是你想让引擎替你做的事,但 SQL-99 标准不要求引擎这么做,Postgres 和 SQL Server 都明确文档化了"别名不能出现在 ORDER BY 表达式里"的限制。所以你得手动搬。
检查清单
日常写 SQL 时,记住这几条就够了:
- 裸列名走别名,其余走原列——
ORDER BY a和ORDER BY -a可能指不同的东西。 - 别名和原列同名同值时两条路径结果一致——这是大多数查询不出问题的原因,也是意外发生时最难排查的原因。
- ORM 生成的引号别名可能让排序悄悄切换路径——检查生成的 SQL,看别名是否被加了引号。
COLLATE是表达式,DESC不是——需要排序修饰符时,先确认它会不会改变路径。- 窗口
ORDER BY和UNION ORDER BY有各自的作用域限制——别指望它们能看SELECT列表。 GROUP BY和ORDER BY对同名标识符的解析顺序相反——别名遮蔽原列时,两个 clauses 可能指不同的列。
读完这篇文章,如果你面对 SELECT -a AS a FROM nums ORDER BY a COLLATE "C" 还需要停顿一下才能判断结果——那是正确的反应。说明你已经有了正确的心理模型:COLLATE 让 a 变成表达式,走 FROM 路径,按原列排序。两条路径的接缝在 1990 年代末缝合,至今仍在默默分歧。