SELECT:投影、去重与集合
SELECT 你写了千百遍。但你想过 DISTINCT 为什么是「去重」、UNION 为什么默认也去重、LIMIT 1 拿到的到底是不是「第一条」吗?这些问题的答案,全藏在上一章那句话里:关系是集合。
SELECT 列表:投影,加上「顺手算一下」
SELECT 的核心是投影(π)——决定结果里有哪些列。但它不止能「选列」,还能在选的同时算表达式:
SELECT title,
price,
price * 0.9 AS sale_price, -- 现算一列
upper(genre) AS genre_up, -- 调函数
price > 40 AS is_pricey -- 布尔表达式也是一列
FROM books;
结果里的每一列,要么是原表的列,要么是由原表的列算出来的表达式。AS 给它起个别名,否则 PostgreSQL 会给它一个诸如 ?column? 的难看默认名。这里已经能看出「当真」:price > 40 的结果是真正的 boolean 类型(t / f),不是 MySQL 里那个假装成布尔的 1 / 0。
DISTINCT:因为集合不重复
投影常会「制造」重复行。比如只投影 genre 一列:
SELECT genre FROM books; -- scifi, scifi, scifi, fiction, fiction, fantasy —— 6 行,一堆重复
六本书投影出六个 genre,重复得厉害。如果你想问的其实是「一共有哪几种类型」,就要去重——用 DISTINCT:
SELECT DISTINCT genre FROM books; -- scifi, fiction, fantasy —— 3 行
为什么有 DISTINCT 这个词?因为关系本该是集合、集合不重复(第 3 章)。但 SQL 出于性能考虑,默认不去重(去重要排序或哈希,不便宜)。所以 DISTINCT 是你在说:「这一步,我要回到纯粹的集合语义。」
PostgreSQL 有个标准 SQL 没有的扩展 DISTINCT ON,解决一个超常见的需求:每个分组取一行。比如「每种类型里最贵的那本书」:
SELECT DISTINCT ON (genre) genre, title, price FROM books ORDER BY genre, price DESC; -- ON 的列必须排在 ORDER BY 最前
它按 genre 分组,每组只留 ORDER BY 排下来的第一行。在别的数据库里,这通常要写一个带窗口函数或相关子查询的啰嗦查询(第 9 章会讲那种写法)。DISTINCT ON 一行搞定,是 PG 用户的心头好。
集合运算:把两个查询的结果相加减
既然每个查询的结果都是一个集合,那两个结果就能做集合运算——并、交、差:
UNION:并集(A 或 B 里有的)。INTERSECT:交集(A 和 B 里都有的)。EXCEPT:差集(A 有但 B 没有的)。
三者都有一个 ALL 变体。默认(不带 ALL)会去重——又是「集合不重复」;带 ALL 则保留重复,也更快(省掉去重那一步)。要求是:两个查询的列数相同、类型兼容。
下面这台 demo 里,A = 科幻书名的集合,B = 有库存的书名的集合。挨个点四种运算,看结果怎么变。注意 UNION 和 UNION ALL 的行数差别——那个差,正是被去掉的重复。
数一下:A 有 3 个书名,B 有 4 个。A UNION B 得 5 行(两个集合里出现过的所有书名,Left Hand 和 Three-Body 在两边都有、只算一次);A UNION ALL B 得 7 行(3+4,不去重);A INTERSECT B 得 2 行(两边都有的 Left Hand、Three-Body);A EXCEPT B 得 1 行(科幻但没库存的 Dispossessed)。这些数字都是 demo 当场算的——你能对着 A、B 两个集合亲手验证。
ORDER BY 与 LIMIT:给「无序的集合」强加一个顺序
第 3 章那条纪律,这里要还债了:表里的行本质无序。所以「拿第一条」这个说法,脱离 ORDER BY 就没有意义:
-- ✗ 危险:没有 ORDER BY,「第一条」是哪条全看运气 SELECT * FROM books LIMIT 1; -- ✓ 当真:先定义顺序,再取第一条 SELECT * FROM books ORDER BY price DESC LIMIT 1; -- 最贵的一本
没有 ORDER BY 的 LIMIT,返回哪一行是不确定的——它可能受索引、受物理存储顺序、受并发影响,今天返回这条、VACUUM 之后返回另一条(卷 IV 会讲 VACUUM 怎么挪动行)。这是无数「本地好好的、线上偶发」bug 的根源。要顺序,就明确写出来。
分页用 LIMIT n OFFSET m。但要提醒一句:OFFSET 很大时会很慢(数据库得先算出前 m 行再扔掉),大数据集分页更好的办法是「键集分页」(keyset pagination,WHERE id > 上一页最后一个 id),这个卷 V 谈性能时再说。
NULL 参与排序时,PostgreSQL 默认把它排在最后(ASC 时 NULL 最大,DESC 时 NULL 最前)。要控制就显式写 ORDER BY col NULLS FIRST / NULLS LAST。这一点各数据库默认不同(MySQL 把 NULL 当最小值排最前),迁移时容易被这个细节坑到。下一章我们专门收拾 NULL。
- INTERSECT / EXCEPT:PG 一直有;MySQL 直到 8.0.31 才补上。老 MySQL 里你只能用
JOIN(模拟交集)或LEFT JOIN ... IS NULL(模拟差集)来绕。 - DISTINCT ON:PG 独有,MySQL 没有对应语法,得用窗口函数或
GROUP BY + 相关子查询实现「每组取一行」。 - LIMIT 语法:PG 是
LIMIT n OFFSET m;MySQL 支持简写LIMIT m, n(注意顺序是「先偏移后行数」,和直觉相反),PG 不认这种简写。 - 布尔:
SELECT price > 40在 PG 里返回真正的boolean(t/f),MySQL 返回1/0(它没有独立的 boolean 类型,BOOLEAN只是TINYINT(1)的别名)。
这一章的一句话
SELECT 是投影;DISTINCT、UNION 默认去重是因为「关系是集合」;LIMIT 脱离 ORDER BY 就是在赌运气,因为行本无序。
说到 NULL 排哪、NULL 参与运算——这是每个数据库用户都栽过跟头的地方。下一章,我们把 NULL 和它背后的三值逻辑彻底讲清。