Частенько разработчики и администраторы PostgreSQL сталкиваются с вроде бы ошибкой PostgreSQL - на таблицу создан уникальный индекс, который "не работает". То есть в таблице есть строки, которые должны содержать некоторый набор полей, значение которых должно быть уникальным для всей таблице, но это условие не выполняется. Более подробный анализ дублей показывает, что все проблемные строки содержат хотя бы одно null'евое значение в одном из полей. А null != null (см. статью Сравнение с NULL). Отсюда и все "проблемы".
Пример описанной проблемы
Есть таблица test_func_index и уникальный индекс по полям name, cdate
CREATE TABLE test_func_index(
id serial PRIMARY KEY,
name varchar,
cdate date
);
CREATE UNIQUE INDEX i_test_func_index_unique
ON test_func_index
USING btree(name, cdate);
Проверим работу уникального индекса.
Инициализация таблицы начальными значениями
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-01');
insert 0 1
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-02');
insert 0 1
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-03');
insert 0 1
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', null);
insert 0 1
Проверяю работу уникального индекса с ненулевыми значениями. Индекс работает
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-03');
ERROR: duplicate key value violates unique constraint "i_test_func_index_unique"
DETAIL: key (name, cdate)=(name1, 2012-01-03) already exists.
Проверяю работу уникального индекса с NULL'евыми значениями. Индекс не работает
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', null);
insert 0 1
Проблема уникальности присутствует.
На данный момент есть три самых популярных решений описанной проблемы:
Каждое поле уникального индекса оборачивается функцией COALESCE, в итоге NULL'евые значения полей в индексе заменяются специальными предопределенными значениями.
В данном примере нулевая строка заменяется пустой строкой, а нулевая дата - бесконечной датой
CREATE UNIQUE INDEX i_test_func_index_func_unique
ON test_func_index
USING btree(COALESCE(name, ''), COALESCE(cdate, 'infinity'));
Проверим работу индекса.
Инициализация таблицы
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-01');
INSERT 0 1
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-02');
INSERT 0 1
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', null);
INSERT 0 1
Проверка работы уникального индекса для нулевых значений. Индекс работает.
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', null);
ERROR: duplicate key value violates unique constraint "i_test_func_index_func_unique"
DETAIL: Key ((COALESCE(name, ''::character varying)), (COALESCE(cdate, 'infinity'::date)))=(name1, infinity) already exists.
Работа индекса в обычных запросах. Индекс не работает.
denis=# EXPLAIN SELECT * FROM test_func_index WHERE cdate < now();
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on test_func_index (cost=10000000000.00..10000000027.40 rows=387 widt
Filter: (cdate < now())
(2 rows)
Работа индекса при использовании функции COALESCE, но с другими константами. Не работает
denis=# EXPLAIN SELECT * FROM test_func_index WHERE COALESCE(cdate, now()) < now();
QUERY PLAN
--------------------------------------------------------------------------------
Seq Scan on test_func_index (cost=10000000000.00..10000000033.20 rows=387 widt
Filter: (COALESCE((cdate)::timestamp with time zone, now()) < now())
(2 rows)
Работа индекса при использовании функции COALESCE и теми же параметрами. Работает.
denis=# EXPLAIN SELECT * FROM test_func_index WHERE COALESCE(cdate, 'infinity') < now();
QUERY PLAN
--------------------------------------------------------------------------------
Bitmap Heap Scan on test_func_index (cost=17.05..32.85 rows=387 width=40)
Recheck Cond: (COALESCE(cdate, 'infinity'::date) < now())
-> Bitmap Index Scan on i_test_func_index_func_unique (cost=0.00..16.95 row
Index Cond: (COALESCE(cdate, 'infinity'::date) < now())
(4 rows)
Плюсы
Минусы
Для каждого NULL'евого поля и/или комбинации NULL'евых полей создаётся свой индекс. Это необходимо, чтобы индексы не перекрывали друг друга (меньше нагрузки при вставке данных и меньше места для хранения индекс)
CREATE UNIQUE INDEX i_test_func_index_unique_1
ON test_func_index
USING btree(name, cdate)
WHERE name IS NOT NULL AND cdate IS NOT NULL;
CREATE UNIQUE INDEX i_test_func_index_unique_2
ON test_func_index
USING btree(name, cdate)
WHERE name IS NOT NULL AND cdate IS NULL;
CREATE UNIQUE INDEX i_test_func_index_unique_3
ON test_func_index
USING btree(name, cdate)
WHERE name IS NULL AND cdate IS NOT NULL;
CREATE UNIQUE INDEX i_test_func_index_unique_4
ON test_func_index
USING btree(name, cdate)
WHERE name IS NULL AND cdate IS NULL; -- Этот индекс "хранит" одну запись
Индексы не перекрываются, но при этом поддерживают уникальность по полям name и cdate
Плюсы
Минусы
Все поля, по которым надо поддерживать уникальность, склеиваются/складываются особым образом, чтобы получилось уникальное значение, которое и будет обеспечивать уникальность.
Функция склеивания полей
CREATE OR REPLACE FUNCTION uniq(_name varchar, _cdate date) RETURNS varchar AS
$$
SELECT (COALESCE(_name, '') || '-' || COALESCE(_cdate::varchar, 'NULL'))
$$ LANGUAGE sql IMMUTABLE;
Функциональный индекс
CREATE UNIQUE INDEX i_test_func_index_unique
ON test_func_index
USING btree(uniq(name, cdate));
Заполнение таблицы
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-01');
INSERT 0 1
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', NULL);
INSERT 0 1
Проверка работы индекса
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', NULL);
ERROR: duplicate key value violates unique constraint "i_test_func_index_unique"
DETAIL: Key (uniq(name, cdate))=(name1-NULL) already exists.
denis=# INSERT INTO test_func_index(name, cdate) VALUES('name1', '2012-01-01');
ERROR: duplicate key value violates unique constraint "i_test_func_index_unique"
DETAIL: Key (uniq(name, cdate))=(name1-2012-01-01) already exists.
Индекс работает как и ожидалось.
Плюсы
Минусы
Самым технологичным и удобным в использовании является способ с использованием частичных индексов. Затраты времени на создание всех необходимых индексов компенсируются полноценной работой индексов как для поддержания уникальности, так и для фильтрации данных.
Использование функционального индекса (первый способ) не всегда возможен и очень плох в поддержке (необходимо выделять специальные значения для NULL'ов и фильтрация работает только при соблюдении определенных условий).
Третий способ с суррогатным индексом можно применять только, если необходимо только поддержка уникальности, так как большего он предоставить не может.
Хорошим компромиссом может стать синергия обычного уникального индекса и суррогатного индекса для нулевых полей. Для вышеописанной таблицы это будет выглядеть так.
Обычный уникальный индекс, обеспечивающий уникальность для ненулевых полей и полноценную фильтрацию
CREATE UNIQUE INDEX i_test_func_index_unique
ON test_func_index
USING btree(name, cdate);
Суррогатный индекс, обеспечивающих уникальность для нулевых полей
CREATE UNIQUE INDEX i_test_func_index_unique_1
ON test_func_index
USING btree(uniq(name, cdate))
WHERE name IS NULL OR cdate IS NULL;
Да, индексы в некоторых случаях дублируют друг друга, но это компенсируется их простотой (как в архитектуре, так и поддержке / использовании)