除以零,PostgreSQL 会直接报错;把 'abc' 转成整数,也会得到错误。但把一个值除以 NULL,查询通常可以顺利执行,结果只是变成 NULL。这正是 SQL 中最容易被低估的细节:NULL 不是一个普通值,而是“未知”状态的标记。
未知会沿着比较、算术、字符串拼接、聚合、窗口函数和 WHERE 条件传播。查询没有报错,并不意味着业务结果正确。理解这些规则,也就能理解为什么 NOT NULL 约束不是形式主义,而是数据建模的重要组成部分。
NULL 不等于任何东西
先看一个容易答错的表达式:
SELECT (NULL = NULL) = (NULL <> NULL) AS result;
结果仍然是 NULL。原因是:
NULL = NULL的结果是未知;NULL <> NULL的结果也是未知;- 比较两个未知结果,仍然无法得到确定答案。
普通比较运算符在任意一侧为 NULL 时,通常返回 NULL,而不是 TRUE 或 FALSE。所以不要写:
SELECT * FROM users WHERE email = NULL;
这不会匹配 email 为 NULL 的行。应该明确表达“这是未知状态”:
SELECT * FROM users WHERE email IS NULL;
SELECT * FROM users WHERE email IS NOT NULL;
如果业务上需要把两个缺失值视为相同,可以使用 PostgreSQL 的三值逻辑逃生口:
SELECT NULL IS NOT DISTINCT FROM NULL AS same_missing_value,
1 IS NOT DISTINCT FROM NULL AS one_vs_missing,
1 IS DISTINCT FROM NULL AS one_is_distinct;
结果分别为 TRUE、FALSE 和 TRUE。IS NOT DISTINCT FROM 会把两个 NULL 看作相同状态,适合用于特殊的连接条件、去重判断或缺失值匹配。
三值逻辑会悄悄过滤数据
SQL 的布尔表达式不只有真和假,还可能是未知。WHERE 和 HAVING 只保留结果为 TRUE 的行,FALSE 和 NULL 都会被过滤。
CREATE TEMP TABLE flags (id int, active boolean);
INSERT INTO flags VALUES
(1, true),
(2, false),
(3, NULL);
SELECT *
FROM flags
WHERE active OR NOT active;
很多人会认为 active OR NOT active 是永真条件,因此三行都应该返回。但第 3 行的 active 是未知,NULL OR NOT NULL 仍然是 NULL,所以它被过滤掉。
可以根据业务含义选择不同写法:
-- NULL 按 false 处理
SELECT * FROM flags
WHERE COALESCE(active, false);
-- 只排除明确为 false 的行,保留 true 和 NULL
SELECT * FROM flags
WHERE active IS NOT FALSE;
-- 只选择 NULL
SELECT * FROM flags
WHERE active IS UNKNOWN;
这几种写法含义不同,不能机械替换。关键是先确定:NULL 在这个字段里代表“否”、 “尚未设置”,还是“无法判断”。
算术和字符串拼接:未知会继续传播
普通算术遇到 NULL,结果也通常是 NULL:
SELECT 1 + NULL AS add_result,
10 * NULL AS multiply_result,
NULL::integer / 2 AS divide_result,
abs(NULL::integer) AS abs_result;
这类行为经常出现在订单金额、报表指标和更新语句中:
UPDATE orders
SET total = quantity * unit_price;
只要 quantity 或 unit_price 有一个是 NULL,total 就可能变成 NULL。不要随意把所有缺失值都替换成零,应在明确业务规则的位置使用 COALESCE:
-- 缺少数量按 0 处理;缺少单价也按 0 处理
SELECT quantity * COALESCE(unit_price, 0) AS line_total
FROM orders;
如果缺少单价意味着“价格未知”,而不是“价格为零”,那么更合理的做法可能是保留 NULL,并通过 NOT NULL 或校验约束阻止不完整数据进入订单表。
字符串拼接也有类似差异。|| 遇到 NULL 会得到 NULL:
SELECT 'Hello, ' || NULL || '!' AS greeting;
构造包含可选字段的名称、地址或标签时,通常更适合使用 concat_ws:
SELECT concat_ws(' ', 'Ada', NULL, 'Lovelace') AS display_name;
结果是 Ada Lovelace,不会因为中间名缺失而让整段文本消失,也不会产生多余空格。
NOT IN 是最危险的 NULL 陷阱之一
NOT IN 可以理解为多个“不等于”条件的组合。只要列表中出现一个 NULL,数据库就无法证明某些比较成立,最终可能导致查询返回空结果:
CREATE TEMP TABLE products (id int, name text);
CREATE TEMP TABLE discontinued (product_id int);
INSERT INTO products VALUES
(1, 'widget'),
(2, 'gadget'),
(3, 'gizmo');
INSERT INTO discontinued VALUES
(2),
(NULL);
SELECT name
FROM products
WHERE id NOT IN (SELECT product_id FROM discontinued);
即使产品 1 和 3 并未停产,查询也可能因为子查询中的 NULL 而无法返回预期结果。
更稳妥的默认写法是 NOT EXISTS:
SELECT p.name
FROM products AS p
WHERE NOT EXISTS (
SELECT 1
FROM discontinued AS d
WHERE d.product_id = p.id
);
也可以使用反连接:
SELECT p.name
FROM products AS p
LEFT JOIN discontinued AS d
ON d.product_id = p.id
WHERE d.product_id IS NULL;
如果确实要使用 NOT IN,至少在子查询中排除 NULL,但这要求业务确认“缺失的停产产品编号应该被忽略”:
SELECT name
FROM products
WHERE id NOT IN (
SELECT product_id
FROM discontinued
WHERE product_id IS NOT NULL
);
聚合会忽略 NULL,但不是所有统计都一样
聚合函数有一套独立规则:
COUNT(*)统计行数;COUNT(column)只统计非NULL值;SUM、AVG、MIN和MAX默认跳过NULL输入。
CREATE TEMP TABLE reviews (product_id int, rating int);
INSERT INTO reviews VALUES
(1, 5),
(1, 1),
(1, NULL),
(2, NULL),
(2, NULL);
SELECT product_id,
COUNT(*) AS rows_seen,
COUNT(rating) AS rated_rows,
AVG(rating) AS average_rating,
SUM(rating) AS rating_sum
FROM reviews
GROUP BY product_id
ORDER BY product_id;
产品 1 有三行,但平均分只基于两个有效评分。产品 2 的 AVG 和 SUM 会得到 NULL,因为没有任何非空评分。
如果未评分应当按零参与平均值,必须显式写出这个规则:
SELECT product_id,
AVG(COALESCE(rating, 0)) AS average_including_blanks
FROM reviews
GROUP BY product_id;
还要注意,AVG(rating) 等价于 SUM(rating) / COUNT(rating),不是 SUM(rating) / COUNT(*)。在整数类型上自行计算时,也要显式转换,避免整数除法截断:
SELECT SUM(rating)::numeric / COUNT(rating) AS average_rating
FROM reviews
WHERE product_id = 1;
窗口函数、排序与约束设计
窗口聚合仍然会跳过 NULL,但 lag、lead、first_value、last_value 和 nth_value 读取的是具体位置。如果那个位置的值是 NULL,结果就是 NULL,它们不会自动寻找最近的非空值。
CREATE TEMP TABLE readings (ts int, temp numeric);
INSERT INTO readings VALUES
(1, 20),
(2, NULL),
(3, 22);
SELECT ts,
temp,
lag(temp) OVER (ORDER BY ts) AS previous_temp,
SUM(temp) OVER (ORDER BY ts) AS running_sum
FROM readings;
在支持相应版本和语法的 PostgreSQL 环境中,可以使用 IGNORE NULLS 让部分位置型窗口函数跳过缺失值;在其他版本中,则需要通过子查询、过滤或 DISTINCT ON 等方式实现相同业务逻辑。不能把这个选项套用到所有窗口函数上。
排序也容易和聚合规则混淆。PostgreSQL 默认把 NULL 排在所有非空值之后,因此:
SELECT name, points
FROM scores
ORDER BY points DESC NULLS LAST;
如果排行榜要求“高分在前,未评分在底部”,应明确写出 NULLS LAST。DESC 单独使用时,NULL 默认可能排在前面。
让 NULL 的含义进入模式设计
处理 NULL 的最佳位置往往不是查询,而是表结构。如果字段必须存在,就直接表达约束:
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
quantity integer NOT NULL CHECK (quantity >= 0),
unit_price numeric(12, 2) NOT NULL CHECK (unit_price >= 0),
tax_rate numeric(5, 4) NOT NULL DEFAULT 0
);
这样可以避免应用层忘记处理缺失值,也能让计算逻辑更简单。对于确实允许缺失的字段,要在查询边界明确业务解释:
NULL表示未知,不应当当作零;NULL表示未填写,需要在报表中显示为空;NULL表示业务上的否,可以在查询中用COALESCE或IS NOT TRUE转换;- 两个缺失键是否算匹配,需要使用
IS NOT DISTINCT FROM。
不要依赖 transform_null_equals 来改变 = NULL 的行为。它只是一个兼容历史客户端的设置,而且只改写非常特定的表达式。生产 SQL 仍应使用清晰的 IS NULL 和 IS NOT NULL。
上线前的 NULL 检查清单
遇到结果异常时,可以沿着这些问题排查:
WHERE少了行:条件是否返回了未知?NOT IN结果为空:列表或子查询中是否存在NULL?优先改为NOT EXISTS。- 金额变成空值:算术输入是否有
NULL?在业务规则边界使用COALESCE。 - 平均值不对:比较
COUNT(*)和COUNT(column),确认缺失值是否应参与计算。 lag或first_value为空:窗口帧中的目标位置是否本来就是NULL?- 文本整体消失:是否使用了
||拼接可选字段?考虑concat_ws。 - 未评分排在排行榜顶部:补上
NULLS LAST。 - 字段本不该缺失:把规则写进
NOT NULL、DEFAULT和CHECK约束。
NULL 的规则并不模糊,模糊的是我们是否明确说明了“未知”在业务中的含义。让模式约束承担它能承担的责任,再在查询中显式处理剩余的缺失状态,PostgreSQL 的计算结果才会更接近真正的业务意图。