DEBUG.WIKI
ZgoCloud:多地区部署,按需选节点;日本、美国、香港、德国
雨云;给项目一台云服务器
RackNerd;$21.99/年 · 1GB KVM

PostgreSQL 联合 UNIQUE 约束仍允许插入含 NULL 的重复记录

PostgreSQL 默认 UNIQUE 允许含 NULL 的重复组合。核对实际索引定义,在 15 及以上用 NULLS NOT DISTINCT 明确空值唯一性,并先处理已有重复、锁表和回退条件。

表上已经有联合 UNIQUE 约束,同一份数据导入两次却都成功。把重复行逐列对照,非空值完全相同,只有其中一个受约束的字段是 NULL。如果把这个字段换成普通值再重复插入,才出现唯一约束冲突。

PostgreSQL 默认在唯一性判断中把空值视为彼此不同。联合唯一约束比较整个列组合;只要组合中存在空值,就可能允许上述重复。约束已经生效,实际采用的空值规则却与业务要求不同。

下面把“同一组键、相同空值位置也只能出现一次”作为目标。修复语法 NULLS NOT DISTINCT 从 PostgreSQL 15 起提供,示例限定普通非分区表;旧版本不能直接照搬。

核对实际约束,而不是只看索引名称

先确认服务器版本和目标表上的索引定义。示例中的 schema 与表名需要替换为自己的值:

SHOW server_version;

SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
  AND tablename = 'import_records';

普通 UNIQUE 约束会建立唯一 B-tree 索引。检查定义是否真的是唯一索引、包含哪些键列,以及是否带有 NULLS NOT DISTINCT。名称里有 unique 不足以说明这些条件已经满足。

再对照实际写入值,特别是导入程序把空字段转换成了 NULL,还是保存为空字符串。空字符串不是空值;为了让错误消失而随意把 NULL 改成 '',会把缺失状态与真实的空字符串混在一起。

如果受约束的所有列都非空,重复组合仍能写入,就不属于这里的空值规则。继续检查实际写入的表、schema、索引键和索引条件,不要用本节修复替代这些检查。

用临时表确认目标规则

以下示例只用于 PostgreSQL 15 及以上的本机测试数据库。新建一个无业务事务的连接,逐条执行;两次预期冲突分别观察,不要把整段作为必须全部成功的迁移脚本运行。临时表只保留到连接结束,创建失败时应停止,不要继续操作同名对象。

CREATE TEMP TABLE unique_null_demo (
    source_id integer NOT NULL,
    external_key text,
    CONSTRAINT unique_null_demo_key
        UNIQUE NULLS NOT DISTINCT (source_id, external_key)
);

INSERT INTO unique_null_demo VALUES (1, NULL);
INSERT INTO unique_null_demo VALUES (1, NULL);

第二条插入应触发唯一约束冲突,因为两行的 source_id 相同,external_key 又同为 NULL。在没有显式开启事务的逐条执行方式下,再分别检查另外两个组合:

INSERT INTO unique_null_demo VALUES (2, NULL);
INSERT INTO unique_null_demo VALUES (1, 'item-a');
INSERT INTO unique_null_demo VALUES (1, 'item-a');

(2, NULL) 与 (1, NULL) 是不同组合,前者可以写入;非空的 (1, 'item-a') 仍然只能出现一次。这个选项不是“整张表只能有一个空值”,也不会把联合约束变成每列分别唯一。

如果测试工具自动开启事务,预期错误会使当前事务无法继续正常执行,应结束这次专用测试事务或重开测试连接后再做其他检查。不要在承载未提交业务修改的连接里试验错误分支。

已有数据先检查,再增加约束

修改已有表之前,备份数据和表定义,确认键列的业务含义。下面假设实际表为 public.import_records,唯一键就是两个普通列 source_id、external_key:

SELECT source_id, external_key, count(*) AS row_count
FROM public.import_records
GROUP BY source_id, external_key
HAVING count(*) > 1;

这会把相同组合,包括相同位置上的空值,归入同一组。返回的组需要逐条核实:两行是否真是重复导入,是否关联了不同的业务记录,应该保留、合并还是补全缺失的键。不要直接按最小 ID 删除其余行,也不要给空值填入一个可能与真实数据重合的占位字符串。

查询没有返回记录,也只代表该次读取看见的数据没有这些重复;并发导入仍可能继续写入。安排变更窗口、协调写入,并在迁移验证中处理新出现的冲突。确认数据符合新规则后,可以新增一个名字明确的约束:

ALTER TABLE public.import_records
ADD CONSTRAINT import_records_key_null_equal
UNIQUE NULLS NOT DISTINCT (source_id, external_key);

这条命令会验证已有数据并建立对应的唯一索引。普通 ADD CONSTRAINT UNIQUE 会取得 ACCESS EXCLUSIVE 表锁,可能阻塞其他访问;大表不能当作无停顿操作执行。它也不能靠 NOT VALID 跳过唯一性检查。

不要先删除原唯一约束再试新规则。新约束成功前保留原约束,可以避免迁移失败时连原有保护也丢失。两者暂时共存会增加索引存储和写入维护成本;是否移除旧约束,要另外核实依赖及回退要求。

确认业务是否真的需要合并空值

NULLS NOT DISTINCT 适合把相同缺失组合视为同一个业务键的场景。如果多个未确定外部编号的记录本来就应该独立存在,默认规则可能符合需求;应另用稳定业务标识去重,而不是强制它们发生冲突。

如果键列业务上根本不允许缺失,则应查清导入链路并按业务规则补全已有数据,再考虑 NOT NULL。这与“允许缺失,但同一组合只留一行”是两种要求,不能互换。

上线验收需要同时覆盖重复非空键、重复空值组合、不同非空键和不同组合中的空值。导入程序应能识别预期的唯一冲突,不能把被拒绝的记录仍记为导入成功。本文示例没有替业务决定如何处理冲突,也没有删除任何既有重复行。

若需要回退新规则,在确认原约束仍在且没有新增依赖后,可撤销本次新增的约束;这会重新允许原规则接受的空值组合,不能当作无影响的性能调整。保留数据清理的独立备份,因为撤销约束不会恢复此前被合并或删除的记录。测试临时表可以通过关闭专用测试连接清理。