Разрушение PostgreSQL БД некорректными ограничениями целостности типа CHECK
Я в ударе! После того, как я написал о разрушении вашей базы данных и обертывании идентификатора транзакции, здесь я пишу о повреждениях, вызванных CHECK
ограничениями!
Поверьте мне, я не хочу разрушать базы данных PostgreSQL. Просто это сообщение в Твиттере привлекло мое внимание и вызвало воспоминания о других сообщениях о CHECK
ограничениях, вызвавших проблемы.
Когда CHECK ограничение является корректным?
Документация предупреждает, что при использовании CHECK
ограничений необходимо соблюдать определенные правила:
PostgreSQL предполагает, что условия ограничений
CHECK
неизменны, то есть они всегда будут давать один и тот же результат для одной и той же входной строки. Именно это предположение оправдывает проверку ограниченийCHECK
только при вставке или обновлении строк, а не в другое время.
В этом есть смысл. Иначе PostgreSQL пришлось бы проверять условие всякий раз, когда в базе данных что-либо изменяется (или по прошествии времени), что нежелательно. Однако PostgreSQL не требует, чтобы выражение ограничения было IMMUTABLE
.
Почему IMMUTABLE не применяется в CHECK выражениях ограничений?
Обычно PostgreSQL не оставляет подобные требования на усмотрение пользователя. Например, попытка использовать функцию, которой нет IMMUTABLE
в определении индекса, вызывает ошибку:
ERROR: functions in index expression must be marked IMMUTABLE
Так почему же PostgreSQL не применяет это для CHECK
ограничений? Причина в том, что существуют допустимые сценарии использования для CHECK
ограничений, когда выражение не является IMMUTABLE
в буквальном смысле (но ограничение является таковым). Этого требует даже стандарт SQL. Процитируем ISO/IEC 9075-2:2003, глава 11, параграф 9:
<check constraint definition> ::= CHECK <left paren> <search condition> <right paren>
5) <search condition> должно просто содержать <boolean value expression>, которое является ретроспективно детерминированным.
Ранее в стандарте объяснялось:
Ретроспективно детерминированное <boolean value expression> обладает тем свойством, что если оно True в какой-то момент времени, то оно будет True и во все последующие моменты времени при повторной оценке идентичных SQL-данных произвольным пользователем с идентичным набором привилегий. Точное определение содержится в подразделе 6.34, «<boolean value expression>».
Я избавлю вас от «точного» определения понятия «ретроспективно детерминированный». Этого достаточно, чтобы вызвать слезы на глазах, и это яркий пример того, как попытка формализовать идею может пойти не так. Из него можно сделать вывод, что комитет по стандартизации имел в виду нечто подобное:
CHECK (colname < current_timestamp - INTERVAL '1' DAY)
Это не IMMUTABLE
выражение (current_timestamp
завтра будет иметь другое значение), но, безусловно, разумное CHECK
ограничение. Любая строка, удовлетворяющая этому условию сейчас, будет удовлетворять ему и в будущем.
Нарушение допустимых UPDATE с плохим CHECK ограничением
Плохое ограничение для проверки формата строки
Представьте, что у нас есть таблица
CREATE TABLE data (
id bigint PRIMARY KEY,
lirum text NOT NULL
);
и у нас есть таблица для запоминания удаленных строк
CREATE TABLE history (
id bigint NOT NULL,
archived_at timestamp with time zone NOT NULL,
row text NOT NULL,
view_count integer DEFAULT 0 NOT NULL
);
которая заполняется триггером
CREATE FUNCTION archive() RETURNS trigger
LANGUAGE plpgsql AS
$$BEGIN
INSERT INTO history
(id, archived_at, row)
VALUES (OLD.id, current_timestamp, OLD::text);
RETURN NULL;
END;$$;
CREATE TRIGGER archive AFTER DELETE ON data
FOR EACH ROW EXECUTE FUNCTION archive();
Мы хотим создать CHECK
ограничение, обеспечивающее правильный формат строки. Поэтому мы определяем
/* не обращайте внимания на IS NOT NULL, приведение типа является проверкой */
ALTER TABLE history ADD CONSTRAINT row_correct
CHECK (row::data IS NOT NULL);
Демонстрация проблемы
Давайте добавим и удалим некоторые данные:
INSERT INTO data (id, lirum)
VALUES (1, 'some data');
DELETE FROM data WHERE id = 1;
Конечно, теперь есть строка history
:
TABLE history;
id │ archived_at │ row │ view_count
════╪═══════════════════════════════╪═════════════════╪════════════
1 │ 2023-01-26 07:26:07.460679+01 │ (1,"some data") │ 0
(1 row)
Позже нам нужно будет создать новый столбец для нашей таблицы:
ALTER TABLE data ADD larum text NOT NULL;
В следующий раз, когда мы посмотрим на таблицу history
и захотим увеличить view_count
, мы получим:
UPDATE history
SET view_count = view_count + 1
WHERE id = 1
RETURNING id, archived_at, row;
ERROR: malformed record literal: "(1,"some data")"
DETAIL: Too few columns.
Что пошло не так, и что мы должны были сделать вместо этого?
Что произошло?
Добавив столбец в таблицу data
, мы также изменили одноименный составной тип данных. Таким образом, мы нарушили «ретроспективный детерминизм», потому что данные больше не соответствуют измененному типу данных. Несмотря на то, что мы вообще не изменили проверяемое значение, новая строка больше не удовлетворяет CHECK
ограничению.
Лучшим решением для этого требования был бы триггер. Вы можете настроить триггер так, чтобы он проверял данные только один раз, при вставке строки. Также можно настроить триггер таким образом, чтобы он проверял данные при каждом обновлении строки, но только если row
изменен.
Нарушение восстановления при неправильном CHECK ограничении
Неправильное ограничение, проверяющее зависимости между таблицами
Для примера рассмотрим систему управления арендой автомобилей:
CREATE TABLE vehicle (
id bigint PRIMARY KEY,
model text NOT NULL,
seats smallint NOT NULL
);
CREATE TABLE client (
id bigint PRIMARY KEY,
name text NOT NULL,
group_size smallint NOT NULL
);
/* for the exclusion constraint */
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE rented (
vehicle_id bigint REFERENCES vehicle NOT NULL,
client_id bigint REFERENCES client NOT NULL,
from_to daterange NOT NULL,
PRIMARY KEY (vehicle_id, client_id),
EXCLUDE USING gist (from_to WITH &&, vehicle_id WITH =)
);
Сейчас не очень хорошо сдавать в аренду маленькую машину большой группе, и мы знаем, что хорошей идеей является применение ограничений в базе данных. Поэтому мы хотим иметь ограничение, которое не позволит нам назначить автомобиль клиенту, если размер группы превышает количество мест в автомобиле:
ALTER TABLE rented ADD CHECK (
(SELECT vehicle.seats FROM vehicle
WHERE vehicle.id = rented.vehicle_id) >=
(SELECT client.group_size FROM client
WHERE client.id = rented.client_id)
);
ERROR: cannot use subquery in check constraint
Какое неприятное ограничение!
Оно указывает, что PostgreSQL действительно накладывает некоторые ограничения на то, что нам разрешено делать в CHECK
ограничении.
К счастью, мы можем обойти это с помощью функций:
CREATE FUNCTION get_seats(bigint) RETURNS smallint
BEGIN ATOMIC
SELECT seats FROM vehicle WHERE id = $1;
END;
CREATE FUNCTION get_group_size(bigint) RETURNS smallint
BEGIN ATOMIC
SELECT group_size FROM client WHERE id = $1;
END;
ALTER TABLE rented ADD CHECK
(coalesce(get_seats(vehicle_id), 0) >=
coalesce(get_group_size(client_id), 0));
Отлично! В любом случае это гораздо более читабельно!
Проверка ограничения
Давайте проверим, что это работает так, как задумано:
INSERT INTO vehicle (id, model, seats) VALUES
(1, 'Mercedes Vito', 9),
(2, 'Audi TT', 4);
INSERT INTO client (id, name, group_size) VALUES
(100, 'Albe', 5),
(101, 'McDuck', 1);
INSERT INTO rented (vehicle_id, client_id, from_to) VALUES
(2, 100, '[2022-07-01,2022-07-14]');
ERROR: new row for relation "rented" violates check constraint "rented_check"
DETAIL: Failing row contains (2, 100, [2022-07-01,2022-07-15)).
INSERT INTO rented (vehicle_id, client_id, from_to) VALUES
(1, 100, '[2022-07-01,2022-07-14]');
Выглядит неплохо!
Неработающая резервная копия
Мы выполняем резервное копирование базы данных с помощью
pg_dump -F c -f dumpfile cars
В черный день потери данных, когда мы попытаемся восстановить нашу резервную копию, мы получим неожиданное сообщение об ошибке:
pg_restore -d cars dumpfile
pg_restore: error: COPY failed for table "rented": ERROR: new row for relation "rented" violates check constraint "rented_check"
DETAIL: Failing row contains (1, 100, [2022-07-01,2022-07-15)).
CONTEXT: COPY rented, line 1: "1 100 [2022-07-01,2022-07-15)"
pg_restore: warning: errors ignored on restore: 1
Что пошло не так, и что мы должны были сделать вместо этого?
PostgreSQL выводит таблицы в алфавитном порядке, чтобы быть детерминированным. Хотя большинство ограничений выбрасывается в конце, чтобы ускорить загрузку данных и предотвратить проблемы с целостностью, ограничения CHECK
считаются частью определения таблицы и выбрасываются в начале. Это нормально, поскольку выражения в CHECK
ограничениях должны быть ретроспективно детерминированными и зависеть только от самой строки.
К сожалению, наше симпатичное ограничение не удовлетворяет этому требованию. PostgreSQL создает таблицы, затем восстанавливает данные для таблиц client
, rented
и vehicle
именно в таком порядке. Поэтому, когда данные для таблицы rented
загружаются, таблица vehicle
все еще пуста и get_seats
возвращает 0, и ограничение не выполняется.
Опять же, правильным решением был бы триггер, который выбрасывает ошибку при нарушении условия. В то время как ограничения должны выполняться постоянно, триггеры срабатывают только при определенных событиях. Дамп содержит определения триггеров в конце, и при восстановлении дампа триггеры не срабатывают вообще.
Что бы быть уверенным, что условие всегда выполняется, можно пойти другим путем: мы можем добавить копию seats
и group_size
в rented
и включить эти столбцы в ограничения FOREIGN KEY
, чтобы убедиться, что они всегда идентичны.
Тогда CHECK
ограничению придется сравнивать только столбцы rented
, и проблема исчезнет.
Заключение
Мы видели два примера того, как плохо определенное CHECK
ограничение может разрушить базу данных. Особенно неприятным был сбой восстановления. К сожалению, это не академические случаи, и в списках рассылки были сообщения о сбоях восстановления. Поэтому убедитесь, что ваши CHECK
ограничения определены правильно, даже если PostgreSQL не может этого обеспечить.
Кроме того, мы выучили претенциозную фразу «ретроспективно детерминированный», которая может вызывать восхищение или покачивание головой, в зависимости от того, насколько доверчива аудитория.