上个月帮一个团队看一条报表 SQL,对方信誓旦旦说自己"用了 ALL 运算符去取每个部门的最高薪",结果跑出来一万多条重复记录,还坚称是数据库版本问题。我扫了一眼 SQL,发现他写的是SELECT ALL salary FROM emp——把SELECT ALL这个默认行为当成了聚合函数在干活。类似的误会我见过太多次:有人把ALL当成MAX的别名,有人以为> ALL和> MAX完全等价,还有人被NOT IN返回空结果折磨了半天,最后发现根因是子查询里藏了一个 NULL,而<> ALL只是换了个写法,坑一个没少。
ALL在 SQL 里其实身兼数职:它可以是比较谓词上的量词(> ALL、<> ALL),可以是UNION ALL里"保留重复行"的开关,可以是SELECT ALL这个几乎没人写的默认修饰,还能在部分分析型数据库里变成GROUP BY ALL的语法糖。同一个词在不同上下文里语义完全不一样,混着用必然出事。这篇就把ALL这一族用法拆开讲清楚:哪些写法是对的、哪些是等效改写、哪些地方会因为空集和 NULL 给你一个"看起来没报错但结果全错"的答案,以及跑不动的时候该怎么改。不管你是刚学 SQL 三个月的业务开发,还是写了五六年还在被 NULL 坑的熟手,下面这些内容应该都能对得上号。
1. 先把 ALL 的身份摆正:它是比较谓词的量词,不是聚合函数
1.1 从一条写错的 SQL 说起:MAX 与 ALL 的语义分叉
很多人的第一反应是"ALL 就是把所有值合起来算一个结果",这个直觉来自SUM、COUNT、AVG这类聚合函数的命名习惯,但ALL完全不在这个体系里。它是一个量词,功能上更接近自然语言里的"所有",必须挂在一个比较运算符后面才有意义:>、<、>=、<=、=、<>后面跟ALL (子查询),表达的是"左边这个值,与子查询返回的每一行逐一比较,全部成立才为真"。
所以WHERE salary > ALL (SELECT salary FROM emp WHERE dept = 'B')读出来就是"这个人的薪水,比 B 部门每一个人的薪水都高",也就是"比 B 部门最高薪还高"。而SELECT ALL salary FROM emp里的ALL是另一个语法位置上的东西,它是SELECT [DISTINCT | ALL]这个可选项,ALL表示"不去重",是默认行为,写不写都一样。两个 ALL 长得一样,语义差着十万八千里。
这里有个必须点明的推论:> ALL在数学上确实等于> MAX,但它们在 SQL 里的边界行为并不完全一致,后面第 3 节会专门讲空集那个坎。先记住一句话——ALL不产生聚合结果,它只产生一个布尔判断,你没法SELECT ALL(salary)拿到一个数。
1.2 量词家族的对照:ALL、ANY、SOME 与 IN 的等价图谱
SQL 标准里的量词有三个:ALL、ANY、SOME。其中SOME是ANY的同义词,纯粹为了读起来顺口,功能一模一样,主流数据库都支持但用得极少。它们必须和比较运算符配对出现,不能单独使用,写了WHERE ALL (...)会直接语法报错。
| 写法 | 语义 | 无 NULL、子查询非空时的等价形式 |
|---|---|---|
x = ANY (sub) | 等于其中任意一个 | x IN (sub) |
x <> ALL (sub) | 不等于其中任何一个 | x NOT IN (sub) |
x > ALL (sub) | 大于其中最大的 | x > (SELECT MAX(c) FROM ...) |
x < ALL (sub) | 小于其中最小的 | x < (SELECT MIN(c) FROM ...) |
x > ANY (sub) | 大于其中最小的 | x > (SELECT MIN(c) FROM ...) |
x < ANY (sub) | 小于其中最大的 | x < (SELECT MAX(c) FROM ...) |
x = ALL (sub) | 与每一个都相等 | 子查询去重后至多一个值,且x等于它 |
这张表是理解ALL的地基。IN和NOT IN并不是独立语法,它们在标准定义里就是= ANY和<> ALL的简写形式,这也是为什么第 3 节要讲的 NULL 陷阱同时适用于两套写法——它们本来就是同一个东西的两种外衣。
这里有个容易被忽略的细节:> ALL和> ANY对极值的依赖是相反的。> ALL关心最大值,因为要赢过所有人就得赢过最强的那个;> ANY只关心最小值,赢过最弱的就算赢。写反了不会报错,只会安静地给你一批错的记录,这种错误在测试数据量小的时候极难发现。
1.3 三个反直觉的返回值:空集为真、NULL 传染、= ALL 的怪癖
ALL有三个行为会让第一次遇到的人愣住,我按踩坑频率从高到低排。
第一个是空集返回 TRUE。如果子查询一行都没返回,x > ALL (空集)的结果是TRUE,不是FALSE也不是NULL。逻辑上讲得通——"比空集合里的所有元素都大"这句话确实没有反例,全称命题在空域上为真。但业务上这是个炸弹:当你用> ALL做"超过所有竞品价格"的筛选,而竞品表当天被清空或条件写错导致查不到数据时,SQL 不会报错,它会把主表所有行都返回给你,因为没有竞争对手意味着"你超过了所有对手"。这个结果看起来像"筛选条件失效",很多人第一反应是去查索引、查权限,实际上根因在语义。
第二个是 NULL 传染。比较运算只要有一侧是 NULL,结果就是UNKNOWN;而ALL要求所有比较都为TRUE才返回TRUE,只要出现一个FALSE就锁死为FALSE,如果没有FALSE但存在UNKNOWN,整体就是UNKNOWN。WHERE子句只放行TRUE,UNKNOWN和FALSE一样被过滤掉。于是<> ALL遇到子查询里的 NULL 时,行为和NOT IN完全一致:所有行都变成UNKNOWN,结果集直接为空。
第三个是= ALL的怪癖。它在子查询返回空集时为TRUE,返回单值时为普通等值比较,返回多个不同值时为FALSE。也就是说WHERE dept = ALL (SELECT dept FROM emp WHERE ...)这种写法,一旦子查询查不到数据,会把整张表全捞出来。这个特性偶尔能当"全量一致性校验"用,但绝大多数时候是误用。
三值逻辑的完整真值表值得贴出来备查:
| 比较结果集合 | x op ALL (sub) | x op ANY (sub) |
|---|---|---|
| 全部 TRUE | TRUE | TRUE |
| 存在 FALSE | FALSE | TRUE(只要有一个 TRUE) |
| 全为 FALSE | FALSE | FALSE |
| 无 FALSE,含 UNKNOWN | UNKNOWN | UNKNOWN |
| 空集 | TRUE | FALSE |
提示:
UNKNOWN在WHERE、HAVING、ON里的处理等同于FALSE,但在NOT运算后仍然是UNKNOWN,不会翻成TRUE。这是NOT IN返回空集的真正原因。
2. 四类真实场景下的 ALL 写法拆解
2.1 "全面超过所有对手":> ALL 与 < ALL 的业务语义
> ALL最自然的落点是价格、分数、时间这类有全序关系的字段。举个具体例子:某电商后台要拉出"定价高于同类目所有在售商品"的商品,用来排查定价异常或做竞品分析。
SELECT p.id, p.name, p.price FROM product p WHERE p.price > ALL ( SELECT c.price FROM product c WHERE c.category_id = p.category_id AND c.status = 'on_sale' AND c.id <> p.id AND c.price IS NOT NULL );这条 SQL 的语义是清晰的,但有两个地方必须主动处理。一是c.price IS NOT NULL不能省,否则只要类目里有一个未填价格的商品,> ALL就会因为UNKNOWN把整行过滤掉,"定价最高"的商品反而查不出来。二是c.id <> p.id得加上,否则商品自己会和自己比较,p.price > p.price恒为FALSE,结果永远是空集——这个坑我在两套系统里都见过,症状是"这个查询怎么永远查不到数据"。
< ALL的典型用法是找"比所有人都早"的记录,比如首单时间、最早入库时间。逻辑对称,但同样要处理 NULL 和自比较。我一般建议在子查询里显式写WHERE c.ts IS NOT NULL,把不确定性挡在外面,而不是指望外层去兜。
2.2 用 <> ALL 表达"不在任何一组里",以及它和 NOT IN 的关系
<> ALL是NOT IN的标准写法,两者在语法层完全等价,执行层各家优化器也基本做同样处理。那为什么还要用<> ALL?说实话,多数场景下没必要,NOT IN更短更常见。但有一种情况我会主动选<> ALL:当代码里已经大量使用> ALL、< ALL时,保持量词风格一致,读起来语义更连贯,尤其是多条件拼装的时候。
真正值得讨论的不是写哪个,而是两者共有的 NULL 陷阱怎么绕。假设要查"没有下过任何订单的用户":
-- 有坑:orders.user_id 里只要有一个 NULL,整条 SQL 返回空 SELECT * FROM users WHERE id <> ALL (SELECT user_id FROM orders); -- 安全写法一:在子查询里排除 NULL SELECT * FROM users WHERE id <> ALL (SELECT user_id FROM orders WHERE user_id IS NOT NULL); -- 安全写法二:改写成 NOT EXISTS,天然免疫 NULL SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);我个人的取向是:只要子查询字段允许为 NULL(尤其外键、关联 ID 这类字段经常因为历史数据或软删除变成 NULL),就直接用NOT EXISTS,不给自己留解释的机会。如果因为团队规范必须用<> ALL,那WHERE ... IS NOT NULL这半句要当成模板的一部分固定下来。
2.3 = ALL 的罕用但合理场景:全量一致性校验
= ALL在业务代码里几乎见不到,因为"与所有值都相等"这个条件太苛刻,实际数据里很少成立。但它有一个我觉得还挺实用的场景:校验一批数据是否全部满足某个共同值。
比如有个同步任务,需要确认某个订单下的所有明细行都属于同一个仓库,只要有一行串仓就报警:
SELECT order_id FROM order_item GROUP BY order_id HAVING MIN(warehouse_id) = ALL ( SELECT warehouse_id FROM order_item i2 WHERE i2.order_id = order_item.order_id );不过说实话,这个写法可读性一般,用COUNT(DISTINCT warehouse_id) = 1更直观也更快。我列出来的目的是让你在看到别人写= ALL时不至于懵,而不是鼓励你用。真要判断"全部一致",聚合函数的表达力通常更好。
2.4 关联子查询里的 ALL:小心退化成逐行比较
上面 2.1 的例子是关联子查询——子查询里引用了外层的p.category_id。这种写法语义正确,但执行代价很容易失控:外层每扫一行,子查询就得重新执行一次,如果子查询本身还要扫几千行,总比较次数就是两边的乘积级别。
-- 关联 ALL:外层 N 行 → 子查询执行 N 次 WHERE p.price > ALL ( SELECT c.price FROM product c WHERE c.category_id = p.category_id AND c.price IS NOT NULL )优化器面对关联ALL时能做的事很有限,因为它没法像EXISTS那样做半连接(semi-join)改写——ALL需要的是"全称判断",而半连接只擅长"存在性判断"。所以关联ALL基本注定是嵌套循环的执行形态。我在数据量过十万的表上遇到这种写法,几乎都会动手改。
改的方向有两个:一是把关联拆成"先聚合再关联",用GROUP BY category_id算出每个类目的最大价格,再和主表 join 比较;二是如果确实要保留逐行语义,考虑用窗口函数MAX(price) OVER (PARTITION BY category_id)预先算好。这两种都能把 N 次子查询压成一次扫描,具体在第 4 节展开。
3. 空集与 NULL:ALL 翻车的两大源头与逐步排查
3.1 空集返回 TRUE 的三值逻辑推演
空集返回TRUE这件事,光看结论很难记住,得从定义推一遍。x > ALL (S)的定义是"对 S 中的每一个元素 y,都有 x > y"。当 S 是空集时,不存在任何 y 使得x > y为假,因为压根没有 y。全称命题在空域上恒真,这是数理逻辑的标准约定,SQL 照搬了它。
落到实操上,这个约定会制造一类特别隐蔽的故障:筛选条件"消失"。设想这样一个需求——"找出价格高于所有已下架商品价格的商品",用来做价格区间分析。某天运营把下架商品全部恢复了,子查询返回空集,这条 SQL 突然返回全表数据。业务方看到数据量暴涨,第一反应通常是"是不是没加条件",去翻 SQL 发现条件明明在,然后陷入困惑。
排查这类问题的链路我总结成三步。第一步,把ALL子查询单独拎出来跑一遍,SELECT COUNT(*) FROM (...),确认它到底返回几行。第二步,如果返回 0 行,就往"空集为真"方向想,别再去查索引和权限了。第三步,在应用层或 SQL 层显式兜底:要么在子查询里加一个保证非空的来源,要么在WHERE里补一句AND EXISTS (子查询)把空集这条路径掐掉。
-- 加 EXISTS 兜底:子查询为空时整体返回空集,而不是全表 WHERE p.price > ALL (SELECT c.price FROM product c WHERE c.status = 'off_shelf') AND EXISTS (SELECT 1 FROM product c WHERE c.status = 'off_shelf')多写这半句看起来冗余,但它把"空集"这个边界情况从"静默返回全表"变成了"明确返回空集",故障表现从"数据莫名其妙变多"变成"数据为空",后者的排查难度低一个数量级。
3.2 NULL 让 <> ALL 静默返回空结果的全过程
NULL 的问题比空集更常见,因为它不需要子查询为空,只要有一行是 NULL 就够。把过程一步步展开:
假设orders.user_id有三行值:100、200、NULL。现在对users.id = 300执行300 <> ALL (SELECT user_id FROM orders)。逐行比较的结果是:300 <> 100为TRUE,300 <> 200为TRUE,300 <> NULL为UNKNOWN。按真值表,没有FALSE但存在UNKNOWN,整体为UNKNOWN。WHERE只放行TRUE,于是这一行被过滤。
关键在于这个结论对所有users行都成立——不管id是多少,只要子查询里有 NULL,每一行的<> ALL都是UNKNOWN。所以整条查询返回零行。用户看到的现象是"用户表明明有几千条记录,查'没下过单的用户'结果一条都没有",非常反直觉。
我在给团队做 SQL 评审时,看到NOT IN或<> ALL会条件反射去看子查询字段的NOT NULL约束。如果这个字段没有非空约束,就直接标红。因为这类 bug 不会在测试环境暴露——测试数据里往往没有 NULL,上线后历史数据一进来就炸。
注意:
NOT IN列表里写常量也会踩同样的坑,比如WHERE status NOT IN ('A', 'B', NULL)永远返回空集。这种错误在动态拼接 SQL 时特别容易发生,某个变量取不到值就拼成了 NULL。
3.3 一条可复用的排查链路:从结果异常到数据定位
我把这套排查流程固化成几个动作,遇到"结果不对但没报错"时按顺序走一遍,基本能定位到八成的问题。
先确认子查询的行数:
SELECT COUNT(*) AS total, COUNT(col) AS non_null_cnt FROM (SELECT user_id AS col FROM orders) t;如果total = 0,走空集分支;如果total > non_null_cnt,说明有 NULL,走 NULL 分支;两者都没问题,才去怀疑外层条件和数据本身。
确认有 NULL 之后,验证一下假设:
-- 预期返回空,如果真返回空就验证了 NULL 污染 SELECT COUNT(*) FROM users WHERE id <> ALL (SELECT user_id FROM orders); -- 加上 IS NOT NULL 过滤后应该恢复正常数量 SELECT COUNT(*) FROM users WHERE id <> ALL (SELECT user_id FROM orders WHERE user_id IS NOT NULL);两次计数一对比,差异就是 NULL 造成的。这套动作我一般直接在客户端里跑,比翻文档快得多。
还有一种情况值得单独提:子查询本身是LEFT JOIN的产物。比如SELECT o.user_id FROM orders o LEFT JOIN refunds r ON ...,当某订单没有退款记录时r侧字段全是 NULL,如果子查询选的是r.xxx而不是o.user_id,就会凭空引入 NULL。这类问题在多层嵌套的报表 SQL 里特别难发现,因为NOT NULL约束只管基表,管不到 join 之后的结果集。
4. 执行计划里的 ALL:为什么它经常跑不出索引,以及三种改写
4.1 看执行计划:不相关子查询与关联子查询的两种形态
ALL子查询分两类,性能表现完全不同。不相关子查询不引用外层字段,引擎可以在外层扫描开始前把它算一次,物化成临时结果,然后外层每行跟这批结果比。关联子查询引用外层字段,只能逐行重算。前者的成本是O(N + M)到O(N * M)之间(取决于能否用上索引或哈希),后者基本就是O(N * M)。
在 MySQL 里对不相关ALL子查询执行EXPLAIN,通常会看到子查询被标记为SUBQUERY或物化成<derived>表,外层type可能是ALL(全表扫描)——这个ALL是访问类型的名字,和量词ALL毫无关系,纯属命名巧合,我第一次看执行计划时也愣了几秒。外层的过滤条件如果作用在子查询的结果上,就享受不到索引,因为那批值此时还在临时表里。
PostgreSQL 的表现类似,EXPLAIN里能看到SubPlan节点,执行次数取决于是否相关。如果看到SubPlan上的loops等于外层行数,那就是关联执行,代价按倍数放大。
有个细节值得注意:即使子查询的结果集只有几行,物化本身也有开销。我见过一个场景,子查询返回 2 行,但有 50 万行外层记录,每条都要跟这 2 行比较——单次比较很便宜,50 万次乘 2 就不便宜了,而且完全没走索引。
4.2 改写成 MAX/MIN 聚合:最稳的一刀
对于不相关子查询,x > ALL (sub)直接改成x > (SELECT MAX(c) FROM ...)是收益最高的一步。原因有三:标量子查询只执行一次;MAX在有索引的情况下可以走索引的MAX优化(MySQL 的Select tables optimized away,PostgreSQL 的Index Only Scan取最右端);外层条件变成一个常量比较,可以正常用索引。
-- 改写前 SELECT * FROM product WHERE price > ALL (SELECT price FROM product WHERE category_id = 7 AND price IS NOT NULL); -- 改写后 SELECT * FROM product WHERE price > (SELECT MAX(price) FROM product WHERE category_id = 7);改写后要注意:MAX会忽略 NULL,所以原来手动加的IS NOT NULL可以去掉,结果一致。但空集行为变了——MAX在空集上返回NULL,price > NULL是UNKNOWN,整条查询返回空集;而原来的> ALL在空集上返回全表。这个差异是好事还是坏事取决于业务,但你必须知道它变了,别改完之后发现"数据怎么没了"又慌一遍。
< ALL对称地改写成< (SELECT MIN(c) ...)。> ANY改成> (SELECT MIN(c) ...),< ANY改成< (SELECT MAX(c) ...)。这四个映射记熟了,日常改写基本够用。
4.3 改写成 NOT EXISTS:处理 NULL 与早退更彻底
<> ALL的改写路径不一样,因为它的语义是"不存在任何一个相等",天然适合用NOT EXISTS表达:
-- 改写前 SELECT * FROM users WHERE id <> ALL (SELECT user_id FROM orders); -- 改写后 SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);这个改写有三个好处。一是 NULL 免疫,o.user_id = u.id遇到 NULL 是UNKNOWN,EXISTS只关心有没有TRUE,UNKNOWN自动被忽略,行为正好符合预期。二是能做反连接(anti-join)优化,MySQL 8.0 和 PostgreSQL 都可能把NOT EXISTS转成哈希反连接,代价从乘积级降到线性级。三是索引友好,orders(user_id)上有索引就能走上。
代价是它从"不相关"变成了"相关",看起来像是要逐行执行。但优化器对NOT EXISTS的相关子查询处理成熟得多,反连接改写的触发率高,实际跑起来通常远快于<> ALL的物化比较。这条我做过不止一次对比,在百万级数据上差异经常是几十倍。
4.4 改写前后实测对比与取舍
我在一个测试环境里搭了个模拟业务库:orders表 200 万行,其中user_id有 3% 是 NULL,users表 20 万行。三种写法的耗时大致如下(机器配置普通,只作为量级参考):
| 写法 | 执行形态 | 耗时量级 | 结果正确性 |
|---|---|---|---|
id NOT IN (SELECT user_id FROM orders) | 子查询物化 + 逐行比较 | 数十秒 | 错(返回 0 行) |
id <> ALL (SELECT user_id FROM orders) | 同上,完全一致 | 数十秒 | 错(返回 0 行) |
<> ALL (SELECT user_id FROM orders WHERE user_id IS NOT NULL) | 物化 + 逐行比较 | 数秒 | 对 |
NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id) | 哈希反连接 | 亚秒级 | 对 |
这张表里最有信息量的是第二行和第三行:加一个IS NOT NULL,耗时降了一个数量级。原因不只是结果集变小,更关键的是子查询物化后含 NULL 时,比较无法走哈希优化,退化成了逐值扫描。这是我之前没意识到的细节,测出来才明白NULL不光是正确性问题,还是性能问题。
至于> ALL改写成MAX那条路,我也测了:不相关> ALL在 200 万行外层表上耗时十几秒,改成> (SELECT MAX(...))之后降到毫秒级,因为外层能走price索引做范围扫描。这个差距没有任何理由不改。
5. 方言差异:ALL 在 MySQL、PostgreSQL、SQL Server、Oracle、SQLite 里的不同脾气
5.1 支持矩阵与语法约束
ALL是 SQL 标准里的东西,主流关系库都支持,但细节上各有脾气。下表是我在实际项目里验证过的情况:
| 数据库 | op ALL (子查询) | op ANY/SOME (子查询) | 备注 |
|---|---|---|---|
| MySQL 5.7 / 8.0 | 支持 | 支持 | = ANY等同于IN,优化器按同一路径处理 |
| PostgreSQL | 支持 | 支持 | 额外支持op ALL (数组)形式 |
| SQL Server | 支持 | 支持 | SOME与ANY完全同义 |
| Oracle | 支持 | 支持 | 语法与标准一致 |
| SQLite | 支持 | 支持 | 文档明确说明与IN/NOT IN的等价关系 |
| Spark SQL | 部分支持 | 部分支持 | 版本差异较大,建议实测后再上生产 |
Spark SQL 这类分布式引擎我要单独提醒一句:ALL子查询的支持度和优化器成熟度跟关系库不是一个量级,某些版本对关联ALL会直接退化成广播嵌套循环,或者干脆报不支持。如果你的场景是数仓宽表加工,看到ALL先跑一个最小可复现的例子验证,别假设它和 MySQL 一样好使。
5.2 PostgreSQL 的 ALL(array) 与 MySQL 的优化器差异
PostgreSQL 有个别家没有的能力:ANY和ALL可以直接作用于数组,不需要子查询。比如:
SELECT * FROM product WHERE price > ALL (ARRAY[10, 20, 30]);这在处理标签、白名单这类"参数本身就是列表"的场景下很顺手。等价的x <> ALL(ARRAY[...])常被用来做排除,比一堆AND x <> 1 AND x <> 2干净得多。需要注意的是数组里如果有 NULL,行为跟子查询里的 NULL 一模一样——整条判断变UNKNOWN。PG 提供了array_remove(arr, NULL)来清理,我一般在构造参数时就把 NULL 剔掉。
MySQL 这边没有数组形式,但优化器对= ANY和IN做了统一处理,两者EXPLAIN结果完全一致。而> ALL这类非等值量词,MySQL 8.0 会尝试把不相关子查询物化,能否用上索引取决于外层条件的选择性。有个经验:如果EXPLAIN里子查询那行的select_type是SUBQUERY且外层type是ALL,基本可以判定这条 SQL 在大表上会出问题,该考虑改写了。
另外一个容易被忽略的点是排序规则和类型转换。> ALL比较的两个值如果类型不一致,MySQL 会做隐式转换,可能把字符串比较变成数值比较,结果和预期完全不符。我遇到过一次price是VARCHAR、子查询里字面量是数字的情况,比较结果按字符串字典序走,"9" > "100"为真,价格筛选全乱。这种问题跟ALL本身无关,但因为ALL的语义隐蔽,排查时容易绕远路。
5.3 与 ALL 同名的其他语法:UNION ALL、SELECT ALL、GROUP BY ALL
最后把同名的几个东西集中澄清,避免以后再混。
UNION ALL里的ALL表示"保留重复行",与UNION(默认去重)相对。它的性能优势明显,因为UNION需要额外做一次排序或哈希去重。我在做数据汇总时优先用UNION ALL,只有在确认必须去重时才换成UNION。一个常见的坑是:本意是想合并两张表并去重,写成了UNION ALL,结果数据翻倍,而且因为没报错,往往到下游报表才对不上账。
SELECT ALL是SELECT DISTINCT的反面,ALL是默认值,写不写完全等价。它存在只是因为标准语法要求SELECT [ALL | DISTINCT]。如果有人认真写了SELECT ALL,通常说明他在强调"这里不要去重",但也可能是误以为它有别的含义——我开头提到的那位就属于后者。
GROUP BY ALL是部分分析型数据库(如 BigQuery、Databricks、DuckDB)提供的语法糖,表示"把所有未聚合的列都作为分组键",省去重复书写列名。它在 MySQL、PostgreSQL 里不支持,写了直接报错。这类方言差异在跨库迁移时特别容易踩,我在把一段分析 SQL 从分析型引擎搬到 MySQL 时就栽过,报错信息还挺不直观。
6. 上线前的自检清单与两条硬经验
6.1 写完 ALL 必做的五个检查
我现在写任何带ALL的 SQL,都会按这五条过一遍,基本能在提交前拦掉大部分问题。
第一,确认子查询字段是否可能为 NULL。查表结构,看有没有NOT NULL约束;没有就加过滤或用NOT EXISTS。这条拦下的问题最多。
第二,确认子查询可能为空集,并想清楚空集时业务上应该返回什么。如果"返回全表"是不可接受的,加EXISTS兜底。
第三,确认是否相关子查询。相关的,评估数据量;两张表都上万行的,直接改写成聚合或窗口函数。
第四,确认比较方向没写反。> ALL配MAX,> ANY配MIN,画个两个元素的小例子在纸上验一遍,比盯着 SQL 看靠谱。
第五,在空表、单行、含 NULL 行三种数据状态各跑一次。这三种状态覆盖了ALL几乎所有的边界行为,跑完心里就有底了。
6.2 我个人踩过的两次坑
第一次是给一个库存系统写"找出库存低于所有同类商品"的查询。测试环境数据干净,跑得好好的;上线第二天报警说报表数据全空。查了半天,发现生产环境的stock字段允许 NULL,而测试库因为初始化脚本的关系全是 0。一个IS NOT NULL的事,代价是半个晚上的排查。从那以后我养成了一个习惯:看到子查询的字段,先去information_schema.columns里确认is_nullable,不看一眼不放心。
第二次更隐蔽。有个定时任务用<> ALL做增量同步的差集计算,跑了三个月都正常。某天上游做了一次数据清洗,把一批外键置成了 NULL,任务照常执行、照常返回成功,但同步的数据量变成了 0。因为没有任何报错,监控也没配数据量告警,等发现时已经积了三天的数据缺口。这件事之后我把所有NOT IN/<> ALL全部换成了NOT EXISTS,并且在任务里加了一条"如果结果集为空则告警"的兜底。技术上是个小改动,但它把一类静默失败变成了能被发现的问题。
ALL这个东西,语法只有几行,坑却集中在边界上。我的建议是:能用聚合函数和EXISTS表达清楚的,就别用量词;非要用的,把 NULL 和空集这两个分支当成必填项处理,别指望它自己会处理好。