По умолчанию, ограничения целостности в PostgreSQL проверяются немедленно, сразу после обновления каждой строки, что может быть неоднозначным при обновлении нескольких строк.
Рассмотрим пример с одной таблицей, имеющей один столбец с уникальным ограничением (и индексом):
CREATE TABLE numbers (number INT, UNIQUE (number)); INSERT INTO numbers VALUES (1), (2);
Если понадобится обновить строки, увеличив значение на 1, возникнет проблема:
UPDATE numbers SET number = number + 1; ERROR: duplicate key value violates unique constraint "numbers_number_key" DETAIL: Key (number)=(2) already exists.
Немедленная или отложенная проверка
По умолчанию, ограничения создаются как NOT DEFERRABLE INITIALLY IMMEDIATE.
INITIALLY IMMEDIATE означает, что по умолчанию, проверка ограничения выполняется после обновления каждой строки.
NOT DEFERRABLE означает, что мы не можем изменить этот параметр в транзакции.
Список ограничений целостности:
SELECT connamespace::regnamespace schema, conrelid::regclass table, conname, contype type, condeferrable deferrable, condeferred deferred FROM pg_constraint WHERE contype IN ('p', 'u') AND connamespace::regnamespace::text != 'pg_catalog' AND conname='numbers_number_key' ORDER BY 1, 2, 3, 4; schema | table | conname | type | deferrable | deferred -------+---------+--------------------+------+------------+---------- public | numbers | numbers_number_key | u | f | f (1 row)
ограничение действительно NOT DEFERRABLE (deferrable = false) и INITIALLY IMMEDIATE (deferrable = false).
Если ограничение DEFERRABLE, то проверка может откладываться до фиксации транзакции. Откладываемое ограничение может быть:
INITIALLY DEFERRED - по умолчанию проверяться при фиксации транзакции, свойство DEFERRABLE устанавливается автоматически
DEFERRABLE INITIALLY IMMEDIATE - по умолчанию проверяться немедленно, но может проверяться при фиксации транзакции при использовании одной из команд:
SET CONSTRAINTS numbers_number_key DEFERRED; SET CONSTRAINTS ALL DEFERRED;
Откладывать проверку могут ограничения целостности:
PRIMARY KEY
UNIQUE
REFERENCES (FOREIGN KEY)
EXCLUDE
На DEFERRABLE ограничения нельзя создать FOREIGN KEY:
CREATE TABLE authors (id INT UNIQUE DEFERRABLE); CREATE TABLE books (id INT PRIMARY KEY, author_id INT REFERENCES authors (id) DEFERRABLE INITIALLY DEFERRED); ERROR: cannot use a deferrable unique constraint for referenced table "authors"
В PostgreSQL не могут быть откладываемыми:
NOT NULL
CHECK
хотя по стандарту SQL они могут быть с отложенной проверкой.
Изменение ограничения
В PostgreSQL версии 9.4 (2014 год) добавлена возможность поменять откладываемость ограничения командой ALTER TABLE имя ALTER CONSTRAINT свойства;
Попробуем изменить существующее ограничение:
ALTER TABLE numbers ALTER CONSTRAINT numbers_number_key DEFERRABLE; ERROR: constraint "numbers_number_key" of relation "numbers" is not a foreign key constraint
В документации к команде ALTER TABLE написано: "В настоящее время только ограничения внешнего ключа могут быть изменены таким образом".
Это означает, что нам придётся полностью удалить ограничение и создать его заново. Это можно сделать одной командой:
ALTER TABLE numbers DROP CONSTRAINT numbers_number_key, ADD CONSTRAINT numbers_number_key UNIQUE (number) DEFERRABLE INITIALLY DEFERRED;
Если выполним запрос к pg_constraint, то увидим изменения в двух последних столбцах:
SELECT connamespace::regnamespace schema, conrelid::regclass table, conname, contype type, condeferrable deferrable, condeferred deferred FROM pg_constraint WHERE contype IN ('p', 'u') AND connamespace::regnamespace::text != 'pg_catalog' AND conname='numbers_number_key' ORDER BY 1, 2, 3, 4; schema | table | conname | type | deferrable | deferred --------+---------+--------------------+------+------------+---------- public | numbers | numbers_number_key | u | t | t
Повторим команду UPDATE, теперь она успешно выполнится:
UPDATE numbers SET number = number + 1; SELECT * FROM numbers; number -------- 2 3
Три варианта настройки ограничений целостности
В документации к команде ALTER TABLE видно, что допустимый синтаксис выглядит так:
ALTER CONSTRAINT constraint_name [ DEFERRABLE | NOT DEFERRABLE ] [ INITIALLY DEFERRED | INITIALLY IMMEDIATE ]
Это даёт три комбинации, которые можно указывать при создании ограничения:
NOT DEFERRABLE [INITIALLY IMMEDIATE](по умолчанию)
CREATE TABLE numbers (number INT, UNIQUE (number));
Ограничение проверяется немедленно, и этот параметр нельзя изменить внутри транзакции. Является значением по умолчанию, первичных и уникальных ключей.
DEFERRABLE [INITIALLY IMMEDIATE]
CREATE TABLE numbers (number INT, UNIQUE (number) DEFERRABLE INITIALLY IMMEDIATE);
Ограничения проверяются немедленно, но этот параметр может меняться внутри транзакции. Если не хочется, чтобы на таблицу ссылались внешние ключи, можно использовать это значение.
[DEFERRABLE] INITIALLY DEFERRED
CREATE TABLE numbers (number INT, UNIQUE (number) DEFERRABLE INITIALLY DEFERRED);
Проверка ограничений выполняется во время фиксации транзакции, но это можно поменять внутри транзакции и ограничение сможет проверяться немедленно.
Примеры использования
Есть несколько причин, по которым могут понадобиться отложенные ограничения.
Одна из причин наличия уникального ограничения для одного числового столбца в том, чтобы значения столбца хранили порядок для сортировки. Например, приложение для составления списков задач, где можно менять порядок задач, расставляя их по сроку выполнения или приоритету.
1. Столбцы "Позиция / Приоритет"
Изменение порядка задач возможно реализовать отложенными ограничениями:
CREATE TABLE todo_items (id SERIAL PRIMARY KEY, task TEXT NOT NULL, priority INTEGER NOT NULL, UNIQUE (priority) DEFERRABLE INITIALLY DEFERRED); -- Вставка двух задач INSERT INTO todo_items (task, priority) VALUES ('Clean the bathroom', 1) , ('Go grocery shopping', 2); -- Поменять две задачи местами BEGIN; UPDATE todo_items SET priority = 2 WHERE task = 'Clean the bathroom'; UPDATE todo_items SET priority = 1 WHERE task = 'Go grocery shopping'; COMMIT;
2. Циклическая зависимость между таблицами
Проектировать такие схемы не стоит, но схема хранения может быть унаследованной. Пример вставки строк, которые можно выполнить только в одной транзакции и только, если внешний ключ с отложенной проверкой:
CREATE TABLE manufacturers (name TEXT PRIMARY KEY, flagship_phone_name VARCHAR NOT NULL); CREATE TABLE phones (name TEXT PRIMARY KEY, manufacturer_name TEXT NOT NULL REFERENCES manufacturers (name) DEFERRABLE INITIALLY DEFERRED); -- Добавить foreign key, замыкающую круг ALTER TABLE manufacturers ADD CONSTRAINT manufacturers_latest_phone_id_fkey FOREIGN KEY (flagship_phone_name) REFERENCES phones (name) DEFERRABLE INITIALLY DEFERRED; -- Вставить несколько связанных по кругу строк BEGIN; INSERT INTO manufacturers (name, flagship_phone_name) VALUES ('Google', 'Pixel 6 Pro') , ('Apple', 'iPhone 13 Pro Max') , ('Samsung', 'Galaxy S22 Ultra'); INSERT INTO phones (manufacturer_name, name) VALUES ('Google', 'Pixel 6') , ('Google', 'Pixel 6 Pro') , ('Apple', 'iPhone 13 Pro') , ('Apple', 'iPhone 13 Pro Max') , ('Samsung', 'Galaxy S22') , ('Samsung', 'Galaxy S22 Ultra'); COMMIT;
3. Загрузка данных или восстановление дампа
Если есть скрипт в виде набора команд INSERT, которые расположены не в порядке, удовлетворяющим ограничениям, возможно, имеет смысл отложить выполнение всех ограничений до конца транзакции. В этом случае достаточно использовать DEFERRABLE INITIALLY IMMEDIATE ограничения, хотя они не имеют преимуществ по скорости вставки данных, по сравнению с DEFERRABLE INITIALLY DEFERRED.
Вот пример, который работает только потому, что ограничение внешнего ключа отложено:
CREATE TABLE authors (id SERIAL PRIMARY KEY, name VARCHAR NOT NULL); CREATE TABLE books (id SERIAL PRIMARY KEY, author_id INTEGER NOT NULL REFERENCES authors (id) DEFERRABLE INITIALLY DEFERRED, title VARCHAR NOT NULL); -- Вставить строки из файла,временно игнорируя связи между таблицами BEGIN; INSERT INTO books VALUES (1, 1, 'All Summer in a Day') , (2, 1, 'The Martian Chronicles') , (3, 2, 'Starship Troopers') , (4, 2, 'Podkayne of Mars'); INSERT INTO authors VALUES (1, 'Ray Bradbury') , (2, 'Robert A. Heinlein'); COMMIT;
4. Удаление строк в произвольном порядке
Вместо потенциально опасного подхода REFERENCES ... ON DELETE CASCADE можно использовать отложенные ограничения, позволяющие удалять строки из таблиц в произвольном порядке. Это может быть полезно при удалении тестовых данных - заглушек, созданных для тестирования интеграций.
CREATE TABLE countries (iso2 CHAR(2) PRIMARY KEY, name VARCHAR NOT NULL); CREATE TABLE cities (id SERIAL PRIMARY KEY, country_iso2 CHAR(2) NOT NULL REFERENCES countries (iso2) DEFERRABLE INITIALLY DEFERRED, name VARCHAR NOT NULL); -- Вставка тестовых строк INSERT INTO countries (iso2, name) VALUES ('IS', 'Iceland'), ('NZ', 'New Zealand'); INSERT INTO cities (country_iso2, name) VALUES ('IS', 'Reykjavík') , ('IS', 'Akureyri') , ('NZ', 'Christchurch') , ('NZ', 'Queenstown'); -- Удаление строк в произвольном порядке, нарушающим внешний ключ BEGIN; DELETE FROM countries WHERE iso2 = 'NZ'; DELETE FROM cities WHERE country_iso2 = 'NZ'; COMMIT;
Вопросы производительности
Отложенные ограничения кажутся отличной идеей, почему же они не используются по умолчанию? Или почему бы мне не создавать все свои ограничения как отложенные INITIALLY DEFERRED? Основная причина:
Уникальные индексы с откладываемой проверкой (pg_index.indimmediate=true) допускают наличие повторяющихся значений в один момент времени, что снижает возможности планировщика запросов по оптимизации запроса.
Планировщик запросов не может быть уверен, что уникальность соблюдается абсолютно в каждый момент времени в транзакции. Это может негативно сказаться при соединении по уникальным столбцам или при выполнении запроса с условием WHERE <unique_column> IN (...). Джо Нельсон дал более подробное объяснение в своей статье.
Создадим таблицу с данными и ограничением с немедленной проверкой:
DROP TABLE IF EXISTS numbers; CREATE TABLE numbers (number INT, UNIQUE (number)); insert into numbers select * from generate_series(1, 1000000); vacuum analyze numbers; Выполним тестовый запрос: EXPLAIN (analyze, buffers, timing off) SELECT * FROM numbers WHERE number IN (SELECT number FROM numbers); QUERY PLAN ------------------------------------------------ Seq Scan on numbers (cost=0.00..14425.00 rows=1000000 width=4) (actual rows=1000000 loops=1) Filter: (number IS NOT NULL) Buffers: shared hit=4425 Planning: Buffers: shared hit=51 Planning Time: 0.194 ms Execution Time: 209.181 ms
Планировщик может определить, что набор условий для таблицы обеспечивает уникальность результата. Если существует совместимый уникальный, не откладываемый индекс, планировщик может пропустить необходимый, в противном случае, шаг сортировки или проверки на уникальность или HashAggregate. Планировщик не может сделать это для откладываемых ограничений, потому что физически могут присутствовать повторяющиеся значения.
Поменяем ограничение на откладываемое и повторим тестовый запрос:
ALTER TABLE numbers DROP CONSTRAINT numbers_number_key, ADD CONSTRAINT numbers_number_key UNIQUE (number) DEFERRABLE INITIALLY IMMEDIATE; vacuum analyze numbers; EXPLAIN (analyze, buffers, timing off) SELECT * FROM numbers WHERE number IN (SELECT number FROM numbers); QUERY PLAN ------------------------------------------------ Merge Semi Join (cost=0.90..66960.90 rows=1000000 width=4) (actual rows=1000000 loops=1) Merge Cond: (numbers.number = numbers_1.number) Buffers: shared hit=2741 read=2731 -> Index Only Scan using numbers_number_key on numbers (cost=0.42..25980.42 rows=1000000 width=4) (actual rows=1000000 loops=1) Heap Fetches: 0 Buffers: shared hit=5 read=2731 -> Index Only Scan using numbers_number_key on numbers numbers_1 (cost=0.42..25980.42 rows=1000000 width=4) (actual rows=1000000 loops=1) Heap Fetches: 0 Buffers: shared hit=2736 Planning: Buffers: shared hit=32 read=5 Planning Time: 0.289 ms Execution Time: 694.485 ms
Запрос стал выполняться в 3,3 раза дольше. В PostgreSQL 10 появилась оптимизация, которая преобразует полусоединение (Semi Join) в подзапросе IN в соединение, если гарантируется уникальность столбца в подзапросе. С откладываемым ограничением оптимизация не работает и в последнем плане использовался Merge Semi Join.
Если ограничение PRIMARY KEY или UNIQUE откладываемое, то автоматически создаётся триггер со свойствами ограничения (свойства в примере DEFERRABLE INITIALLY IMMEDIATE):
select tgrelid::regclass tab, tgconstrindid::regclass index, tgfoid::regproc as function_name, tgenabled e, tgisinternal i, pg_get_triggerdef(oid) from pg_trigger where tgrelid::regclass::text='numbers'; tab | index | function_name | e | i | pg_get_triggerdef --------+--------------------+--------------------+---+---+----------------- numbers | numbers_number_key | unique_key_recheck | O | t | CREATE CONSTRAINT TRIGGER "Unique_ConstraintTrigger_16885" AFTER INSERT OR UPDATE ON public.numbers DEFERRABLE INITIALLY IMMEDIATE FOR EACH ROW EXECUTE FUNCTION unique_key_recheck() (1 row)
Если ограничение не откладываемое, то триггер не создаётся, так как проверка уникальности проверяется немедленно.
Если отключить триггеры, то ограничение целостности не будет срабатывать и можно будет вставить строки, нарушающие ограничение:
truncate numbers; set session_replication_role = replica; insert into numbers values (1); insert into numbers values (1); select * from numbers; number -------- 1 1 (2 rows) SELECT conrelid::regclass table, conname, contype, condeferrable, convalidated FROM pg_constraint WHERE conname='numbers_number_key'; table | conname | contype | condeferrable | convalidated ---------+--------------------+---------+---------------+-------------- numbers | numbers_number_key | u | t | t (1 row)
При этом, ограничение целостности будет иметь свойство VALIDATED. Не стоит полагаться на свойство VALIDATED у DERERRABLE ограничений целостности, оно не гарантирует отсутствие нарушений ограничения целостности существующими строками.
В PostgreSQL триггерами реализуются ограничения FOREIGN KEY, а также ограничения с откладываемой проверкой типов PRIMARY KEY, UNIQUE, EXCLUDE. Пример для EXCLUDE:
CREATE TABLE circles (c circle, EXCLUDE USING gist (c WITH &&) DEFERRABLE); select tgrelid::regclass tab, tgconstrindid::regclass index, tgname, tgfoid::regproc as function_name, tgtype t, CASE tgtype & 66 WHEN 2 THEN 'BEFORE ' WHEN 64 THEN 'INSTEAD OF ' ELSE 'AFTER ' END || CASE tgtype & 60 WHEN 4 THEN 'INSERT' WHEN 8 THEN 'DELETE' WHEN 16 THEN 'UPDATE' WHEN 20 THEN 'INSERT UPDATE' WHEN 32 THEN 'TRUNCATE' ELSE '?' END || ' FOR EACH ' || CASE WHEN tgtype & 1 = 1 THEN 'ROW' ELSE 'STATEMENT' END AS firing_conditions, tgenabled e, tgisinternal i, tgdeferrable d, tginitdeferred id from pg_trigger; tab | index | tgname | function_name | t | firing_conditions | e | i | d | id ---------+--------------------+--------------------------------+--------------------+----+----------------------------------+---+---+---+---- authors | authors_id_key | Unique_ConstraintTrigger_16800 | unique_key_recheck | 21 | AFTER INSERT UPDATE FOR EACH ROW | O | t | t | f numbers | numbers_number_key | Unique_ConstraintTrigger_16885 | unique_key_recheck | 21 | AFTER INSERT UPDATE FOR EACH ROW | O | t | t | f circles | circles_c_excl | Unique_ConstraintTrigger_16905 | unique_key_recheck | 21 | AFTER INSERT UPDATE FOR EACH ROW | O | t | t | f (3 rows)