Справочник DBA: базы данных изнутри читается по порядку

Статья 7.4 · раздел 7 «Проектирование схемы и данных» · 50 из 109

Ограничения как инструмент DBA: NOT VALID, EXCLUDE и внешний ключ без индекса

опубликовано
читать
≈ 9 мин
автор
среда и версия
PostgreSQL 18.6 в контейнере postgres:18; опыт выполнен 28 октября 2026; таблица child на 10 млн строк (422 МБ), прогретый кеш
содержание решать задачи SQL на собеседовании

Ограничение на большой таблице либо держит блокировку записи на время полного скана, либо ставится в два шага без остановки. Внешний ключ без индекса на дочернем столбце молча превращает DELETE родителя в полный скан. Синтаксис — в статьях про CREATE TABLE и ALTER TABLE; здесь — цена ограничений.

DBA / 01

DELETE родителя: проверка внешнего ключа без индекса и с ним

DELETE родителя: проверка внешнего ключа без индекса и с нимСлева вверху DELETE одной строки родителя. Стрелка ведёт в RI-триггер, который выполняет запрос SELECT 1 FROM ONLY child x WHERE $1 = parent_id FOR KEY SHARE OF x. Левая ветка, индекса на child.parent_id нет: seq_scan = 1, прочитано 10 000 000 строк, триггер работает 212 мс на одного родителя и 10 722 мс на 50 родителей. Правая ветка, индекс child_parent_id_idx размером 66 МБ: idx_scan = 1, seq_tup_read = 0, триггер 0,23 мс на одного родителя и 0,89 мс на 50.DELETE FROM parentWHERE id = 1002RI-триггер child_parent_id_fkeySELECT 1 FROM ONLY child xWHERE $1 = parent_id FOR KEY SHARE OF xиндекса на parent_id нетseq_scan = 1 по child:10 000 000 строкодин родитель: 212 мс50 родителей: 10 722 мсchild_parent_id_idx, 66 МБidx_scan = 1 по parent_id:seq_tup_read = 0один родитель: 0,23 мс50 родителей: 0,89 мс
Внешний ключ без индекса превращает каждое удаление родителя в полный скан дочерней таблицы. Индекс на ссылающемся столбце сводит ту же проверку к одному заходу в дерево.

Опыт 1: DELETE родителя при дочерней таблице с индексом и без

Стенд: родитель на 1 001 строку, дочерняя таблица child на 10 000 000 строк по 10 000 на родителя. Индекс есть только на первичном ключе child.id; на parent_id его нет. Таблица занимает 422 МБ, индекс первичного ключа — 214 МБ.

docker run -d --name dba-constraints-as-dba-tool -e POSTGRES_PASSWORD=pg postgres:18
docker exec -i dba-constraints-as-dba-tool psql -U postgres <<'SQL'
CREATE EXTENSION btree_gist;
CREATE TABLE parent (id int PRIMARY KEY, name text);
CREATE TABLE child (id bigserial PRIMARY KEY, parent_id int NOT NULL REFERENCES parent(id), pad text);
INSERT INTO parent SELECT g, 'p'||g FROM generate_series(1,1001) g;
INSERT INTO child (parent_id, pad) SELECT 1 + g % 1000, 'x' FROM generate_series(1,10000000) g;
INSERT INTO parent SELECT g, 'q' FROM generate_series(1002,1051) g;
VACUUM ANALYZE parent, child;
SQL

Родители 1002–1051 созданы без детей: их удаление разрешено внешним ключом, и вся цена остаётся в проверке. Внешние ключи без индекса находит запрос по каталогу: ссылающиеся столбцы должны стоять в начале какого-нибудь валидного индекса.

SELECT c.conrelid::regclass AS tbl, c.conname, pg_get_constraintdef(c.oid) AS def
FROM pg_constraint c
WHERE c.contype = 'f'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid
      AND i.indisvalid
      AND (i.indkey::int2[])[0:cardinality(c.conkey) - 1] @> c.conkey
      AND (i.indkey::int2[])[0:cardinality(c.conkey) - 1] <@ c.conkey);
 tbl   |       conname        |                      def
-------+----------------------+-----------------------------------------------
 child | child_parent_id_fkey | FOREIGN KEY (parent_id) REFERENCES parent(id)

Дальше — DELETE одного родителя в откатываемой транзакции; EXPLAIN ANALYZE показывает время триггера внешнего ключа отдельной строкой, а счётчики pg_stat_user_tables — как читалась child:

SELECT pg_stat_reset();
BEGIN; EXPLAIN (ANALYZE, COSTS off) DELETE FROM parent WHERE id = 1002; ROLLBACK;
SELECT pg_stat_force_next_flush();
SELECT seq_scan, seq_tup_read, idx_scan FROM pg_stat_user_tables WHERE relname = 'child';
Trigger for constraint child_parent_id_fkey: time=212.103 calls=1
Execution Time: 212.198 ms

1|10000000|0

Сам DELETE занял около 0,1 мс (разность Execution Time и времени триггера), а 212 мс ушло в триггер: один seq_scan по child и 10 000 000 прочитанных строк (при другом прогоне — 206 мс). Запрос триггера собирает функция ri_restrict, а комментарий в ней показывает его вид (ri_triggers.c, строки 771–773, тег REL_18_6):

		 * The query string built is
		 *	SELECT 1 FROM [ONLY] <fktable> x WHERE $1 = fkatt1 [AND ...]
		 *		   FOR KEY SHARE OF x

Запрос обычный, и планировщик выбирает для него то, что есть: без индекса на parent_id это полный скан (seq_scan = 1). Тот же опыт после CREATE INDEX child_parent_id_idx ON child (parent_id) (66 МБ) и ANALYZE child:

Trigger for constraint child_parent_id_fkey: time=0.225 calls=1
Execution Time: 0.317 ms

0|0|1

212,103 мс против 0,225 мс (при другом прогоне 206,107 и 0,243): примерно в 900 раз. Пакет страшнее одной строки: DELETE FROM parent WHERE id BETWEEN 1002 AND 1051 запускает триггер 50 раз.

Trigger for constraint child_parent_id_fkey: time=10721.964 calls=50
Trigger for constraint child_parent_id_fkey: time=0.888 calls=50

Первая строка — без индекса, вторая — с ним: 10,7 секунды против 0,89 мс при тех же 50 строках. Измерено на прогретом кеше: вся таблица в 422 МБ лежала в памяти.

Проверка держит и замок: триггер берёт FOR KEY SHARE на строку родителя. Сессия A вставила в child строку с parent_id = 1001 и не закрыла транзакцию; сессия B с lock_timeout = '1s' обновила parent (UPDATE ... SET name) за 1,5 мс, а DELETE FROM parent WHERE id = 1001 получила ERROR: canceling statement due to lock timeout с CONTEXT: while deleting tuple (5,109) in relation "parent" после 1000,268 мс. Эти замки участвуют в ожиданиях наравне с остальными, а цикл из ожиданий находит детектор взаимоблокировок.

Опыт 2: NOT VALID и VALIDATE под второй сессией

Новое ограничение CHECK сначала проверяет все существующие строки. Сессия A добавляет его обычной командой в транзакции и держит её четыре секунды (pg_sleep), чтобы сессия B успела посмотреть pg_locks и попробовать записать; в автокоммите замок снимается в конце оператора.

-- сессия A
BEGIN;
ALTER TABLE child ADD CONSTRAINT child_pad_chk2 CHECK (pad <> '');
SELECT pg_sleep(4);
COMMIT;

-- сессия B, на 2,5-й секунде
SELECT l.mode, l.granted FROM pg_locks l
WHERE l.relation = 'child'::regclass AND l.locktype = 'relation' AND l.pid <> pg_backend_pid();
SET lock_timeout = '1s';
INSERT INTO child (parent_id, pad) VALUES (1, 'y');
        mode         | granted
---------------------+---------
 AccessExclusiveLock | t

ERROR:  canceling statement due to lock timeout
Time: 1000.150 ms (00:01.000)

Скан 10 млн строк занял 271 мс (ALTER в сессии A), замок держался до конца транзакции, и запись встала. Чтения за ACCESS EXCLUSIVE тоже выстраиваются в очередь. Теперь в два шага, сессия B пробует запись и другой DDL:

-- сессия A
ALTER TABLE child ADD CONSTRAINT child_pad_chk CHECK (pad <> '') NOT VALID;
BEGIN;
ALTER TABLE child VALIDATE CONSTRAINT child_pad_chk;
SELECT pg_sleep(4);
COMMIT;

-- сессия B, на 2,5-й секунде: тот же запрос к pg_locks, затем
INSERT INTO child (parent_id, pad) VALUES (1, 'y');
UPDATE child SET pad = 'z' WHERE id = 5;
SET lock_timeout = '1s';
ALTER TABLE child ADD COLUMN extra int;
           mode           | granted
--------------------------+---------
 ShareUpdateExclusiveLock | t

Time: 5.195 ms
Time: 2.282 ms
Time: 0.186 ms
ERROR:  canceling statement due to lock timeout
Time: 1000.292 ms (00:01.000)

INSERT (5,2 мс) и UPDATE (2,3 мс) прошли, ALTER TABLE не дождался. VALIDATE CONSTRAINT взял ShareUpdateExclusiveLock: пускает запись, не пускает другой DDL и VACUUM. Этот уровень записан в AlterTableGetLockLevel (tablecmds.c, строки 4824–4826):

			case AT_ValidateConstraint: /* Uses MVCC in getConstraints() */
				cmd_lockmode = ShareUpdateExclusiveLock;
				break;

Сам скан занял 261 мс, а первый шаг, NOT VALID, — 2,6 мс, но в транзакции он держит AccessExclusiveLock на child, поэтому её не оставляют открытой. Для внешнего ключа pg_locks в той же сессии показал (строки AccessShareLock от самого запроса опущены):

ALTER TABLE child ADD CONSTRAINT child_fk2 FOREIGN KEY (parent_id) REFERENCES parent(id) NOT VALID;
 child  | ShareRowExclusiveLock
 parent | ShareRowExclusiveLock

ALTER TABLE child VALIDATE CONSTRAINT child_fk2;       (436 мс)
 child  | ShareUpdateExclusiveLock
 parent | RowShareLock

ShareRowExclusiveLock конфликтует с записью на обеих таблицах, но данные не читает: скан уходит в VALIDATE под более слабыми замками.

После NOT VALID новые строки проверяются сразу, старые — только при VALIDATE:

CREATE TABLE t (id int, v int); INSERT INTO t VALUES (1, -5);
ALTER TABLE t ADD CONSTRAINT v_pos CHECK (v > 0) NOT VALID;
INSERT INTO t VALUES (2, -1);
ALTER TABLE t VALIDATE CONSTRAINT v_pos;
SELECT conname, convalidated FROM pg_constraint WHERE conrelid = 't'::regclass;
ERROR:  new row for relation "t" violates check constraint "v_pos"
DETAIL:  Failing row contains (2, -1).
ERROR:  check constraint "v_pos" of relation "t" is violated by some row
 conname | convalidated
---------+--------------
 v_pos   | f

convalidated остаётся f: старую строку (1, -5) придётся исправить и повторить VALIDATE.

Опыт 3: EXCLUDE, WITHOUT OVERLAPS и DEFERRABLE

UNIQUE не умеет запрещать пересекающиеся интервалы: два бронирования одной комнаты с разными началами — разные ключи. Для этого есть EXCLUDE, он строит GiST-индекс и проверяет оператор && на каждую вставку (для room WITH = в GiST нужно расширение btree_gist, оно создано в начале опыта 1):

CREATE TABLE booking (
  room   int       NOT NULL,
  during tstzrange NOT NULL,
  EXCLUDE USING gist (room WITH =, during WITH &&)
);
INSERT INTO booking VALUES (1, '[2026-10-28 10:00, 2026-10-28 12:00)');
INSERT INTO booking VALUES (2, '[2026-10-28 11:00, 2026-10-28 12:00)');
INSERT INTO booking VALUES (1, '[2026-10-28 12:00, 2026-10-28 13:00)');
INSERT INTO booking VALUES (1, '[2026-10-28 11:30, 2026-10-28 12:30)');
INSERT 0 1
INSERT 0 1
INSERT 0 1
ERROR:  conflicting key value violates exclusion constraint "booking_room_during_excl"
DETAIL:  Key (room, during)=(1, ["2026-10-28 11:30:00+00","2026-10-28 12:30:00+00")) conflicts with existing key (room, during)=(1, ["2026-10-28 10:00:00+00","2026-10-28 12:00:00+00")).

Первые три прошли, включая [12:00, 13:00) встык с [10:00, 12:00): правая граница полуоткрытого интервала не входит в него. Четвёртая пересекла первую и отклонена. Примечания к PostgreSQL 18 добавляют WITHOUT OVERLAPS для PRIMARY KEY и UNIQUE и PERIOD для внешних ключей: «all applied to the last specified column». В контейнере 18.6:

CREATE TABLE booking_p (
  room   int       NOT NULL,
  during tstzrange NOT NULL,
  PRIMARY KEY (room, during WITHOUT OVERLAPS)
);
INSERT INTO booking_p VALUES (1, '[2026-10-28 10:00, 2026-10-28 12:00)');
INSERT INTO booking_p VALUES (1, '[2026-10-28 11:30, 2026-10-28 12:30)');
SELECT c.conname, c.contype, c.conperiod, i.indisexclusion AS excl, am.amname
FROM pg_constraint c
JOIN pg_index i ON i.indexrelid = c.conindid
JOIN pg_class ic ON ic.oid = i.indexrelid
JOIN pg_am am ON am.oid = ic.relam
WHERE c.conrelid IN ('booking'::regclass, 'booking_p'::regclass) AND c.contype IN ('p', 'x')
ORDER BY 1;
ERROR:  conflicting key value violates exclusion constraint "booking_p_pkey"
DETAIL:  Key (room, during)=(1, ["2026-10-28 11:30:00+00","2026-10-28 12:30:00+00")) conflicts with existing key (room, during)=(1, ["2026-10-28 10:00:00+00","2026-10-28 12:00:00+00")).

         conname          | contype | conperiod | excl | amname
--------------------------+---------+-----------+------+--------
 booking_p_pkey           | p       | t         | t    | gist
 booking_room_during_excl | x       | f         | t    | gist

Внутри это тот же механизм: WITHOUT OVERLAPS создаёт GiST-индекс с флагом indisexclusion (excl = t), ошибка называется «exclusion constraint». Отличие в том, что это настоящий первичный ключ (contype = p, conperiod = t), на который по примечаниям к выпуску можно сослаться внешним ключом с PERIOD; EXCLUDE остаётся для случаев, когда нужны другие операторы.

DEFERRABLE переносит проверку с оператора на COMMIT. Пара «отдел — руководитель, сотрудник — отдел» ссылается друг на друга:

CREATE TABLE dept (id int PRIMARY KEY, head int);
CREATE TABLE emp (id int PRIMARY KEY, dept int NOT NULL REFERENCES dept(id));
ALTER TABLE dept ADD CONSTRAINT dept_head_fk FOREIGN KEY (head) REFERENCES emp(id);
BEGIN;
INSERT INTO dept VALUES (1, 10);
ERROR:  insert or update on table "dept" violates foreign key constraint "dept_head_fk"
DETAIL:  Key (head)=(10) is not present in table "emp".

Отдел с руководителем падает на первом операторе: сотрудника 10 ещё нет. После ALTER TABLE dept ALTER CONSTRAINT dept_head_fk DEFERRABLE INITIALLY DEFERRED те же две вставки в одной транзакции проходят, а отдел с несуществующим руководителем падает на COMMIT:

BEGIN; INSERT INTO dept VALUES (1, 10); INSERT INTO emp VALUES (10, 1); COMMIT;
BEGIN
INSERT 0 1
INSERT 0 1
COMMIT
BEGIN; INSERT INTO dept VALUES (2, 20); COMMIT;
BEGIN
INSERT 0 1
ERROR:  insert or update on table "dept" violates foreign key constraint "dept_head_fk"
DETAIL:  Key (head)=(20) is not present in table "emp".

Цена отсрочки — ошибка приходит поздно, из кода, который уже считает операции удавшимися.

Что из этого следует

  • Ограничение на живой таблице — два шага. VALIDATE CONSTRAINT сканирует 10 млн строк за 261 мс (CHECK) и 436 мс (внешний ключ), не останавливая запись; сам NOT VALID занял 2,6 мс. Одним оператором то же сканирование держало ACCESS EXCLUSIVE: вставка не прошла за 1 с.
  • Каждому внешнему ключу нужен индекс на ссылающемся столбце. Без него один DELETE родителя читает всю дочернюю таблицу: 212 мс на 10 млн строк, 10,7 с на 50 родителей. Индекс стоит 66 МБ и сводит это к 0,23 мс и 0,89 мс.
  • Пересекающиеся интервалы запрещает EXCLUDE или WITHOUT OVERLAPS, а не код приложения. Оба строят GiST-индекс; в PostgreSQL 18 диапазонный первичный ключ записывается прямо в схеме.
  • DEFERRABLE — для циклических ссылок, не для всех ключей. Он снимает проблему порядка вставки, но переносит ошибку на COMMIT.

Чек-лист

  1. Перед ADD CONSTRAINT на большой таблице используй NOT VALID, потом VALIDATE CONSTRAINT в отдельной транзакции; следи, чтобы транзакция с NOT VALID не оставалась открытой.
  2. Раз в релиз гоняй запрос поиска внешних ключей без индекса и для каждого ответа реши: индекс или осознанный отказ (родитель никогда не удаляется).
  3. Проверь convalidated в pg_constraint: f значит, что старые строки не проверены.
  4. Для бронирований и действующих периодов бери EXCLUDE ... gist или WITHOUT OVERLAPS (PostgreSQL 18).
  5. Убери контейнер вместе с данными: docker rm -f -v dba-constraints-as-dba-tool.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru