PostgreSQL 里的 NULL:计算没有报错,不代表结果符合预期

2026-08-31 31 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:12 分钟

除以零,PostgreSQL 会直接报错;把 'abc' 转成整数,也会得到错误。但把一个值除以 NULL,查询通常可以顺利执行,结果只是变成 NULL。这正是 SQL 中最容易被低估的细节:NULL 不是一个普通值,而是“未知”状态的标记。

未知会沿着比较、算术、字符串拼接、聚合、窗口函数和 WHERE 条件传播。查询没有报错,并不意味着业务结果正确。理解这些规则,也就能理解为什么 NOT NULL 约束不是形式主义,而是数据建模的重要组成部分。

NULL 不等于任何东西

先看一个容易答错的表达式:

SELECT (NULL = NULL) = (NULL <> NULL) AS result;

结果仍然是 NULL。原因是:

  • NULL = NULL 的结果是未知;
  • NULL <> NULL 的结果也是未知;
  • 比较两个未知结果,仍然无法得到确定答案。

普通比较运算符在任意一侧为 NULL 时,通常返回 NULL,而不是 TRUEFALSE。所以不要写:

SELECT * FROM users WHERE email = NULL;

这不会匹配 emailNULL 的行。应该明确表达“这是未知状态”:

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;

结果分别为 TRUEFALSETRUEIS NOT DISTINCT FROM 会把两个 NULL 看作相同状态,适合用于特殊的连接条件、去重判断或缺失值匹配。

三值逻辑会悄悄过滤数据

SQL 的布尔表达式不只有真和假,还可能是未知。WHEREHAVING 只保留结果为 TRUE 的行,FALSENULL 都会被过滤。

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;

只要 quantityunit_price 有一个是 NULLtotal 就可能变成 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);

即使产品 13 并未停产,查询也可能因为子查询中的 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 值;
  • SUMAVGMINMAX 默认跳过 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 有三行,但平均分只基于两个有效评分。产品 2AVGSUM 会得到 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,但 lagleadfirst_valuelast_valuenth_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 LASTDESC 单独使用时,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 表示业务上的否,可以在查询中用 COALESCEIS NOT TRUE 转换;
  • 两个缺失键是否算匹配,需要使用 IS NOT DISTINCT FROM

不要依赖 transform_null_equals 来改变 = NULL 的行为。它只是一个兼容历史客户端的设置,而且只改写非常特定的表达式。生产 SQL 仍应使用清晰的 IS NULLIS NOT NULL

上线前的 NULL 检查清单

遇到结果异常时,可以沿着这些问题排查:

  • WHERE 少了行:条件是否返回了未知?
  • NOT IN 结果为空:列表或子查询中是否存在 NULL?优先改为 NOT EXISTS
  • 金额变成空值:算术输入是否有 NULL?在业务规则边界使用 COALESCE
  • 平均值不对:比较 COUNT(*)COUNT(column),确认缺失值是否应参与计算。
  • lagfirst_value 为空:窗口帧中的目标位置是否本来就是 NULL
  • 文本整体消失:是否使用了 || 拼接可选字段?考虑 concat_ws
  • 未评分排在排行榜顶部:补上 NULLS LAST
  • 字段本不该缺失:把规则写进 NOT NULLDEFAULTCHECK 约束。

NULL 的规则并不模糊,模糊的是我们是否明确说明了“未知”在业务中的含义。让模式约束承担它能承担的责任,再在查询中显式处理剩余的缺失状态,PostgreSQL 的计算结果才会更接近真正的业务意图。


相关推荐