唯一约束中的 PostgreSQL 多个可为空的列

PostgreSQL multiple nullable columns in unique constraint

我们有一个遗留数据库架构,其中包含一些有趣的设计决策。直到最近,我们只支持 Oracle 和 SQL Server,但我们正在尝试添加对 PostgreSQL 的支持,这带来了一个有趣的问题。我搜索了 Stack Overflow 和互联网的其余部分,我不认为这种特殊情况是重复的。

Oracle 和 SQL 服务器在涉及唯一约束中的可空列时表现相同,即在执行唯一检查时实质上忽略为 NULL 的列。

假设我有以下 table 和约束:

CREATE TABLE EXAMPLE
(
    ID TEXT NOT NULL PRIMARY KEY,
    FIELD1 TEXT NULL,
    FIELD2 TEXT NULL,
    FIELD3 TEXT NULL,
    FIELD4 TEXT NULL,
    FIELD5 TEXT NULL,
    ...
);

CREATE UNIQUE INDEX EXAMPLE_INDEX ON EXAMPLE
(
    FIELD1 ASC,
    FIELD2 ASC,
    FIELD3 ASC,
    FIELD4 ASC,
    FIELD5 ASC
);

在 Oracle 和 SQL 服务器上,保留任何可为空的列 NULL 将导致仅对非空列执行唯一性检查。所以下面的插入只能做一次:

INSERT INTO EXAMPLE VALUES ('1','FIELD1_DATA', NULL, NULL, NULL, NULL );
INSERT INTO EXAMPLE VALUES ('2','FIELD1_DATA','FIELD2_DATA', NULL, NULL,'FIELD5_DATA');
-- These will succeed when they should violate the unique constraint:
INSERT INTO EXAMPLE VALUES ('3','FIELD1_DATA', NULL, NULL, NULL, NULL );
INSERT INTO EXAMPLE VALUES ('4','FIELD1_DATA','FIELD2_DATA', NULL, NULL,'FIELD5_DATA');

但是,因为 PostgreSQL(正确地)遵守 SQL 标准,这些插入(以及任何其他值的组合,只要其中之一为 NULL)将不会抛出错误并正确插入没问题。不幸的是,由于我们的遗留模式和支持代码,我们需要 PostgreSQL 的行为与 SQL Server 和 Oracle 相同。

我知道以下 Stack Overflow 问题及其答案:Create unique constraint with null columns。根据我的理解,有两种策略可以解决这个问题:

  1. 在可为空的列同时为NULLNOT NULL的情况下创建描述索引的部分索引(这导致部分索引的数量呈指数增长)
  2. 对索引中可为空的列使用带有标记值的 COAELSCE

(1) 的问题是我们需要创建的部分索引的数量随着我们想添加到约束中的每个额外的可为空的列呈指数增长(如果我没记错的话是 2^N) . (2) 的问题是标记值减少了该列的可用值数量以及所有潜在的性能问题。

我的问题:这些是解决此问题的仅有的两种方法吗?如果是这样,对于这个特定用例,它们之间的权衡是什么?一个好的答案是讨论每个解决方案的性能、可维护性、PostgreSQL 如何在简单的 SELECT 语句中利用这些索引,以及任何其他 "gotchas" 或需要注意的事情。请记住,5 个可为空的列仅作为示例;我们的模式中有一些 table,最多 10 个(是的,我每次看到它都会哭,但它就是这样)。

第三种方法:使用IS NOT DISTINCT FROM代替=来比较键列。 (这可以利用候选 natural 键上的现有索引)示例(查看最后一列)

SELECT *
    , EXISTS (SELECT * FROM example x
     WHERE x.FIELD1 IS NOT DISTINCT FROM e.FIELD1
     AND x.FIELD2 IS NOT DISTINCT FROM e.FIELD2
     AND x.FIELD3 IS NOT DISTINCT FROM e.FIELD3
     AND x.FIELD4 IS NOT DISTINCT FROM e.FIELD4
     AND x.FIELD5 IS NOT DISTINCT FROM e.FIELD5
     AND x.ID <> e.ID
    ) other_exists
FROM example e
    ;

下一步是将其放入触发器函数中,并在其上放置触发器。 (现在没时间,以后再说)


这里是触发函数(还不完美,但似乎可以工作):


CREATE FUNCTION example_check() RETURNS trigger AS $func$
BEGIN
    -- Check that empname and salary are given
    IF EXISTS (
     SELECT 666 FROM example x
     WHERE x.FIELD1 IS NOT DISTINCT FROM NEW.FIELD1
     AND x.FIELD2 IS NOT DISTINCT FROM NEW.FIELD2
     AND x.FIELD3 IS NOT DISTINCT FROM NEW.FIELD3
     AND x.FIELD4 IS NOT DISTINCT FROM NEW.FIELD4
     AND x.FIELD5 IS NOT DISTINCT FROM NEW.FIELD5
     AND x.ID <> NEW.ID
            ) THEN
        RAISE EXCEPTION 'MultiLul BV';
    END IF;


    RETURN NEW;
END;
$func$ LANGUAGE plpgsql;

CREATE TRIGGER example_check BEFORE INSERT OR UPDATE ON example
  FOR EACH ROW EXECUTE PROCEDURE example_check();

更新:唯一索引可以有时被包装成 约束(参见 postgres-9.4 docs, final example )你确实需要发明一个标记值;我这里用的是空字符串''


CREATE UNIQUE INDEX ex_12345 ON example
        (coalesce(FIELD1, '')
        , coalesce(FIELD2, '')
        , coalesce(FIELD3, '')
        , coalesce(FIELD4, '')
        , coalesce(FIELD5, '')
        )
        ;

ALTER TABLE example
        ADD CONSTRAINT con_ex_12345
        USING INDEX ex_12345;

但是 coalesce() 上的 "functional" 索引在此构造中是不允许的。 不过,唯一索引(OP 的选项 2)仍然有效:


ERROR:  index "ex_12345" contains expressions
LINE 2:  ADD CONSTRAINT con_ex_12345
             ^
DETAIL:  Cannot create a primary key or unique constraint using such an index.
INSERT 0 1
INSERT 0 1
ERROR:  duplicate key value violates unique constraint "ex_12345"

您可以创建一个规则,将所有 NULL 值而不是原始 table 插入到 partition_field1_nullable、partition_fiend2_nullable 等分区。这样您就可以在原始 table 只有(没有空值)。这将允许您仅将 not null 插入到 orig table(具有唯一性),并将尽可能多的 not null(并且相应地不唯一)值插入到 "nullable partitions"。 并且您可以仅对可空分区应用 COALESCE 或触发方法,以避免许多分散的部分索引并触发原始 table...

上的每个 DML

这对我来说效果很好:

CREATE UNIQUE INDEX index_name ON table_name ((
   ARRAY[field1, field2, field3, field4]
));

我不知道性能如何受到影响,但它应该接近理想(取决于优化数组在 postres 中的表现)

您正在努力实现与现有 OracleSQL 服务器兼容性 ] 实施。
这是一个presentation comparing physical row storage formats of the three involved RDBS.

由于 Oracle 根本没有在行存储中实现 NULL 值,因此它无法区分空字符串和 NULL 之间的区别。因此,在 Postgres 中也使用空字符串 ('') 而不是 NULL 值是否明智 - 对于 this 特定用例?

将唯一约束中包含的列定义为NOT NULL DEFAULT '',问题已解决:

CREATE TABLE example (
   example_id serial PRIMARY KEY
 , field1 text NOT NULL DEFAULT ''
 , field2 text NOT NULL DEFAULT ''
 , field3 text NOT NULL DEFAULT ''
 , field4 text NOT NULL DEFAULT ''
 , field5 text NOT NULL DEFAULT ''
 , CONSTRAINT example_index UNIQUE (field1, field2, field3, field4, field5)
);

注释

  • 你在问题中展示的是 唯一的 index:

    CREATE UNIQUE INDEX ...
    

    不是你一直在谈论的唯一约束。存在细微但重要的差异!

    • How does PostgreSQL enforce the UNIQUE constraint / what type of index does it use?

    我将其更改为实际约束,就像您将其作为 post 的主题一样。

  • 关键字 ASC 只是噪音,因为这是默认的排序顺序。我把它留下了。

  • 为简单起见,使用 serial PK 列,这是完全可选的,但通常比存储为 text.

  • 的数字更好

使用它

只需省略 INSERT:

中的空/空字段
INSERT INTO example(field1) VALUES ('F1_DATA');
INSERT INTO example(field1, field2, field5) VALUES ('F1_DATA', 'F2_DATA', 'F5_DATA');

重复这些插入中的任何一个都会违反唯一约束。

如果您坚持省略目标列(这在持久的 INSERT 语句中有点反模式):
对于需要列出所有列的批量插入:

INSERT INTO example VALUES
  ('1', 'F1_DATA', DEFAULT, DEFAULT, DEFAULT, DEFAULT)
, ('2', 'F1_DATA','F2_DATA', DEFAULT, DEFAULT,'F5_DATA');

简单地:

INSERT INTO example VALUES
  ('1', 'F1_DATA', '', '', '', '')
, ('2', 'F1_DATA','F2_DATA', '', '','F5_DATA');

或者您可以编写一个触发器 BEFORE INSERT OR UPDATENULL 转换为 ''

替代解决方案

如果您需要使用实际的 NULL 值,我会建议使用唯一的 indexCOALESCE 作为选项(2) 和

这样的 数组 上的索引很简单,但要昂贵得多。数组处理在 Postgres 中不是很便宜,数组开销类似于一行(24 字节):

  • Calculating and saving space in PostgreSQL

数组仅限于相同数据类型的列。如果有些列不是,您可以将所有列转换为 text,但这通常会进一步增加存储要求。或者您可以为异构数据类型使用众所周知的行类型...

一个极端情况:具有所有 NULL 值的数组(或行)类型被认为是相等的(!),因此只能有 1 行所有涉及的列为 NULL。可能会或可能不会如您所愿。如果你想禁止所有列为 NULL:

  • NOT NULL constraint over a set of columns