Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Констрейнты PostgreSQL: CHECK, EXCLUDE и deferrable

Констрейнты Postgres: целостность данных на уровне БД

CHECK, EXCLUDE, deferrable и генерируемые колонки: как перенести бизнес-правила в базу, когда констрейнты дороже триггеров и как добавлять их без простоев.

Прямой ответ

Констрейнт — правило целостности, которое БД гарантирует сама (любой путь записи — приложение/скрипт/ручной UPDATE/миграция — нарушить нельзя — «валидация в Pydantic» защищает один вход из многих — констрейнт защищает данные — валидация-контекст); принцип: что может выразить БД — выражайте в БД (приложение-валидация — UX-слой (быстрая обратная связь), констрейнт — слой правды — «баг в коде пропустил мусор» — констрейнт — последний рубеж — OWASP A01-логика); арсенал кроме знакомых PK/FK/UNIQUE/NOT NULL: CHECK (произвольное условие на строку: CHECK (price >= 0), CHECK (status IN ('new','paid','shipped')) — enum-гигиена (или настоящий ENUM — но CHECK гибче: значения добавляются ALTER'ом без пересоздания типа), CHECK (ends_at > starts_at) — интервалы, CHECK (email ~* '^[^@]+@[^@]+$') — regex — осторожно: сложная логика в CHECK — боль поддержки — простые инварианты — да); GENERATED-колонки (total numeric GENERATED ALWAYS AS (price * qty) STORED — вычисляемое поле не рассинхронизируется — «total пересчитали не везде» — класс багов — денормализация под контролем); EXCLUDE (исключающие ограничения через GiST — «не перекрываться»: бронирования EXCLUDE USING gist (room_id WITH =, tstzrange(starts_at, ends_at) WITH &&) — пересекающийся интервал — отклонён БД атомарно (гонка двух транзакций (изоляции) — обе проверили «свободно» и вставили — приложение-проверка пропускает — EXCLUDE — нет — «двойное бронирование» умирает как класс; btree_gist-расширение для скаляров в EXCLUDE); DEFERRABLE (INITIALLY DEFERRED — проверка на COMMIT, не на строку — циклические FK (A→B, B→A — вставка пары — порядок невозможен — deferred решает), обмен значениями UNIQUE (swap двух email в транзакции — промежуточное нарушение — deferred пропускает до коммита) — цена: отложенная проверка — ошибки на COMMIT (транзакции — обработка в коде — «ошибка при commit» — не забыть); частичные UNIQUE (UNIQUE (email) WHERE deleted_at IS NULL — soft-delete — «email уникален среди живых» — удаление-паттерн; NOT VALID + VALIDATE (добавление без блокировок — см. сценарий — «ADD CONSTRAINT на 100M строк» — таблица встала — так не делают); CHECK vs FK vs триггер: FK — ссылочность (индексируем FK-колонки — каскады осознанно — ON DELETE-поведение), триггер — только когда констрейнт бессилен (кросс-табличные правила — триггер-цена — производительность/отладка — крайность); что НЕ надо в констрейнты: бизнес-логику с внешними вызовами/сложные вычисления («скидка от курса валют» — не CHECK — домен приложения — граница: инварианты данных — в БД, бизнес-процессы — в код — структура — ADR с критерием).

Практика: добавление констрейнта на большую таблицу без простоя

Проблема: ALTER TABLE orders ADD CONSTRAINT price_positive CHECK (price >= 0) на 80M строк — полный скан с ACCESS EXCLUSIVE-блокировкой (блокировки) — запись стоит минуты — прод-инцидент; правильный путь (два шага): (1) ADD CONSTRAINT price_positive CHECK (price >= 0) NOT VALID — мета-изменение (доли секунды — блокировка мгновенная — новые/обновляемые строки проверяются сразу, старые — нет); (2) чистка легаси-нарушений (найдены тем же CHECK — WHERE NOT (price >= 0) — 14 строк за 3 года — «валидация в коде не работала с 2023» — констрейнт как аудит — починка данных — миграция-батчи); (3) VALIDATE CONSTRAINT price_positive — скан существующих строк без блокировки записи (SHARE UPDATE EXCLUSIVE — чтение/запись идут — вакуум-подобный режим — vacuum-контекст — долгий скан на 80M — минуты — но сервис жив); Alembic-оформление (upgrade: add_check_constraint + validate отдельными шагами, downgrade: drop — тесты миграций — негативный кейс «вставка с -1 отклонена» — testcontainers); тот же паттерн для FK (ADD FOREIGN KEY ... NOT VALID → VALIDATE — кросс-табличный скан без простоя — «добавили FK на проде» — штатная операция); EXCLUDE-индекс — CONCURRENTLY-подобия нет (GiST-построение блокирует запись — окно/реплика-свитч — failover-контекст — «бронирования на 10M строк» — планировать окно); обработка нарушений в приложении: IntegrityError (psycopg — ошибка 23514 check_violation — обработка — 400 с понятным сообщением — не 500 — API-практика — «констрейнт сработал» — не авария, а защита — метрика нарушений (метрики — всплеск — баг приложения/атака — алерт); констрейнты как документация (доки-культура — COMMENT ON CONSTRAINT — «почему это правило» — следующий разработчик читает схему, не археологичит git).

Практический сценарий: три инцидента, которые закрыли констрейнты

Контекст: маркетплейс (Postgres 15, FastAPI+SQLAlchemy — ORM — валидация «вся в Pydantic»); инцидент 1: отрицательные остатки (баг в расчёте возврата — stock ушёл в -3 — «минус три товара на складе» — обнаружили через неделю по жалобе — расследование: два сервиса писали stock, один без валидации — «валидация в Pydantic» не покрывает прямой SQL-скрипт аналитика); фикс: CHECK (stock >= 0) (NOT VALID → чистка 7 строк → VALIDATE — паттерн выше) + обработка 23514 в обоих сервисах; эффект: через месяц баг повторился (дедлок-ретрай в Celery дважды вычел) — но данные остались целыми (констрейнт отбил — метрика check_violation вспыхнула — Grafana — баг найден за час по алерту, а не за неделю по жалобе — «констрейнт не предотвратил баг — предотвратил порчу данных и ускорил поиск» — правильная роль); инцидент 2: двойные слоты доставки (курьер-приложение — два запроса в гонке забронировали один слот — «проверка свободности» в коде — TOCTOU — транзакции — serialization помог бы ценой ретраев); фикс: EXCLUDE USING gist (courier_id WITH =, tstzrange(slot_start, slot_end) WITH &&) (btree_gist — окно 20 минут на 2M строк — блокировки-планирование) — двойные брони — ноль навсегда (гонка отбивается БД атомарно — код упростился — проверка-в-коде осталась для UX, но не для целостности); инцидент 3: рассинхрон total (скидочный промо-код пересчитал price, total пересчитан не у всех строк — батч-скрипт ошибся — «итог не сходится с суммой» — финансы-отчётность — расхождение нашёл dbt-тест — singular-assert); фикс: total → GENERATED ALWAYS AS (price * qty - discount) STORED (рассинхрон невозможен by construction — батч-скрипты total больше не трогают — миграция с пересчётом); итог квартала: три класса инцидентов закрыты на уровне данных (не «починили код» — «данные стало невозможно испортить этим способом» — техдолг-стратегия); ADR-правило команды: «новые инварианты данных — сначала констрейнт, потом код; NOT VALID+VALIDATE — стандарт для больших таблиц; каждое нарушение констрейнта — метрика+разбор» — схема БД стала спецификацией (контракт-мышление — перенесённое на данные).

FAQ

CHECK против валидации в приложении — не дублирование ли? Разные слои и разные гарантии: приложение (Pydantic/формы) — быстрый UX («поле красное до отправки» — валидация), констрейнт — инвариант при любом пути записи (другой сервис/скрипт/миграция/ручной SQL/баг — «у нас один сервис пишет» — сегодня — микросервисы — завтра три); дублирование осознанное (защита в глубину — OWASP-принцип — как CSRF-токен+SameSite — веб-защита); цена CHECK — микросекунды на строку (условие простое — незаметно — бенчмарк-дисциплина — измерено на сценарии — в шуме); «сложная валидация в CHECK» — антипаттерн (regex-монстры/подзапросы запрещены синтаксисом частично — бизнес-логика — код — граница из первого раздела); вывод: не «или», а «и» — с чёткими ролями.

Когда использовать deferrable-констрейнты и в чём подвох? Кейсы: циклические FK (родитель↔ребёнок — вставка пары), swap уникальных значений (перестановка email/slug в одной транзакции — промежуточный дубль), батч-переупорядочивание (order-позиции — массовый UPDATE с промежуточными коллизиями — ORDER BY-паттерны — deferred снимает боль); механика: DEFERRABLE INITIALLY DEFERRED — проверка на COMMIT (или SET CONSTRAINTS ... IMMEDIATE в точке — контроль вручную); подвохи: ошибка при коммите (не на INSERT — код должен ловить IntegrityError на commit — сессии — ORM-«ленивый commit» маскирует источник ошибки — стектрейс дальше от причины — отладка сложнее); производительность (проверка на коммите — батч-накладные — на горячих путях — измерять); «deferred по умолчанию везде» — нет (немедленная обратная связь ценнее гибкости — deferrable — точечно под кейсы — транзакции-практика).

Как добавить констрейнт на таблицу в 100M строк без остановки сервиса? CHECK/FK: NOT VALID + VALIDATE CONSTRAINT (раздел выше — добавление мгновенно, валидация фоново — запись не блокируется — единственный правильный путь — Alembic-шаги раздельными транзакциями); UNIQUE: CREATE UNIQUE INDEX CONCURRENTLY + ADD CONSTRAINT ... USING INDEX (индекс без блокировок — индексы-практика — «ADD UNIQUE на проде» — так); NOT NULL: сначала CHECK (col IS NOT NULL) NOT VALID → VALIDATE → SET NOT NULL (PG 12+ — SET NOT NULL при существующем валидном CHECK — мгновенно без скана); EXCLUDE: GiST без concurrently — окно обслуживания или build на реплике+свитч (failover); общие правила: low-traffic окно, statement_timeout на DDL (блокировки), мониторинг wait-событий во время VALIDATE (метрики), rollback-план — миграция-дисциплина; «ALTER TABLE ADD CONSTRAINT в час пик» — всегда инцидент — планирование дешевле извинений.

Что делать, когда констрейнт мешает легитимной операции? Сигнал к пересмотру дизайна: констрейнт выражает устаревшее правило (бизнес изменился — «статус 'refunded' добавили» — CHECK IN-список расширить — миграция — семver-мышление для схемы); или правило грубее реальности («price >= 0» против «бесплатные промо-товары» — CHECK скорректировать осознанно, не обходить); обходы-антипаттерны: SET session_replication_role = replica (отключение триггеров/FK — «на минуточку» — данные битые — миграции-опасность), DROP+ADD «быстренько» без NOT VALID (полная блокировка — сценарий выше); правильная процедура: метрика нарушений показала частый legit-кейс → анализ (логи — кто/когда нарушает) → решение: расширить правило (миграция+тесты — тесты-миграций) или исправить вызывающий код; констрейнт, который регулярно «мешает» — либо устарел, либо ловит реальные баги — оба случая требуют разбора, не обхода — ревью-культура.

Источники: документация PostgreSQL (constraints, ALTER TABLE, generated columns) — ссылки в шапке.