Триггеры в SQL: когда использовать и когда нет
Что такое триггер
Триггер — это функция или процедура, которая запускается автоматически при определённых событиях на таблице: INSERT, UPDATE, DELETE или TRUNCATE. Вы не вызываете триггер вручную — БД сама выполняет его в нужный момент.
Пример: когда в таблицу orders вставляется новая строка, триггер автоматически создаёт запись в audit_log с информацией о том, кто и когда сделал вставку. Пользователь сделал обычный INSERT, а в фоне произошло ещё одно действие, о котором он, возможно, даже не знает.
Это одновременно и сила, и слабость триггеров. Сила — автоматизация без изменения клиентского кода. Слабость — неявность: глядя на INSERT, не понимаешь, что ещё произойдёт за кулисами.
Зачем они нужны
Четыре классических сценария использования:
Аудит изменений. Когда кто-то меняет важную таблицу — зарплаты сотрудников, права доступа, цены товаров — нужно знать кто, когда и что изменил. Триггер логирует каждое UPDATE в отдельную таблицу с timestamp и user_id.
Поддержка денормализованных данных. В users хранится поле orders_count. При каждом новом заказе триггер на orders увеличивает это поле у соответствующего пользователя. Денормализация ускоряет чтение, триггер гарантирует синхронность.
Валидация сложных правил. Проверка, которую нельзя выразить через CHECK constraint. Например: «нельзя снизить цену товара более чем на 50% за один раз». Триггер на UPDATE проверяет разницу и отклоняет операцию при нарушении.
Автоматические поля. Триггер на INSERT/UPDATE ставит updated_at = NOW() без участия приложения. Или генерирует slug из title. Или вычисляет full_name из first_name и last_name.
Синтаксис в PostgreSQL
В Postgres триггер состоит из двух частей: функция-обработчик и собственно триггер, который эту функцию вызывает. Пример для аудита:
-- Таблица для логов
CREATE TABLE audit_log (
log_id BIGSERIAL PRIMARY KEY,
table_name TEXT,
operation TEXT,
changed_at TIMESTAMPTZ DEFAULT NOW(),
changed_by TEXT,
old_data JSONB,
new_data JSONB
);
-- Функция-обработчик
CREATE OR REPLACE FUNCTION audit_changes()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, changed_by, old_data, new_data)
VALUES (
TG_TABLE_NAME,
TG_OP,
current_user,
CASE WHEN TG_OP IN ('UPDATE', 'DELETE') THEN row_to_json(OLD)::jsonb ELSE NULL END,
CASE WHEN TG_OP IN ('UPDATE', 'INSERT') THEN row_to_json(NEW)::jsonb ELSE NULL END
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- Собственно триггер
CREATE TRIGGER orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW
EXECUTE FUNCTION audit_changes();В функции доступны специальные переменные: NEW (новая строка при INSERT/UPDATE), OLD (старая при UPDATE/DELETE), TG_OP (тип операции: INSERT, UPDATE, DELETE), TG_TABLE_NAME (имя таблицы). Это даёт контекст, какое именно изменение произошло.
BEFORE vs AFTER
Триггер можно выполнять до или после операции.
BEFORE триггеры видят изменения до их применения. Могут модифицировать NEW (например, дописать поле) или отменить операцию через RETURN NULL. Типичное использование — валидация и автоматические поля.
AFTER триггеры видят уже применённые изменения. Не могут повлиять на саму операцию, но могут делать побочные эффекты — логировать, обновлять другие таблицы. Используются для аудита и денормализации.
-- Пример BEFORE: автоматически заполнять updated_at
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_set_updated_at
BEFORE UPDATE ON orders
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();Теперь при любом UPDATE поле updated_at автоматически встаёт на текущее время. Приложение об этом может не знать.
FOR EACH ROW vs FOR EACH STATEMENT
Триггер может срабатывать на каждую строку отдельно или один раз на весь statement.
FOR EACH ROW — вызывается для каждой изменённой строки. Если UPDATE обновил 1000 строк, триггер сработает 1000 раз. Внутри доступны NEW и OLD.
FOR EACH STATEMENT — один раз на весь statement, независимо от количества строк. NEW и OLD недоступны, но есть таблицы переходов (REFERENCING).
Для аудита и денормализации обычно нужен ROW. Для batch-логов или подсчётов можно STATEMENT.
Когда триггеры — плохая идея
Триггеры — это скрытая логика. Код приложения делает INSERT, а за кулисами запускается каскад из 5 триггеров, которые обновляют 10 таблиц. Отладить это сложно: ошибка вылезает где-то в триггере на третьей таблице, а причина — в первой.
Несколько сценариев, когда триггеры усложняют жизнь:
Бизнес-логика в триггерах. Расчёт комиссий, валидация бизнес-правил, генерация документов — всё это лучше в приложении. Триггеры увидят изменения, но будущий разработчик не будет знать, что смотреть в коде приложения и в коде триггеров.
Каскадные триггеры. Триггер на таблице A меняет таблицу B, которая запускает свой триггер, меняющий C, и так далее. Быстро превращается в спагетти.
Производительность. Триггер на каждой строке UPDATE превращает быстрый batch-update в медленный построчный. Для миллиона строк разница может быть в 100 раз.
Тестирование. Триггеры сложно тестировать. Unit-тест приложения проверяет, что вызвался INSERT. Интеграционный нужен, чтобы проверить, что триггер сработал правильно.
Разобраться, где триггеры оправданы, а где лучше оставить логику в приложении — это senior-уровень. В тренажёре Карьерник есть задачи на проектирование схемы данных с компромиссами между триггерами, приложением и ETL.
Альтернативы
Перед тем как написать триггер, подумайте, нет ли более простого варианта.
CHECK constraints. Для простой валидации (amount > 0, status IN ('paid', 'pending')) — CHECK лучше триггера. Быстрее и декларативно.
DEFAULT values. Для автоматических полей с фиксированным значением — DEFAULT NOW() или DEFAULT 'pending'. Работает без триггера.
Generated columns. Для вычисляемых полей — GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED. Тоже без триггера.
Приложение. Часто автоматические поля удобнее ставить в приложении через ORM или middleware. Прозрачно и легко тестировать.
CDC (Change Data Capture). Для аудита в современных стеках — Debezium или аналоги. Читают WAL-лог БД и стримят изменения в Kafka. Не замедляют основные запросы.
ETL / dbt. Для денормализации и агрегатов часто лучше периодический ETL, чем триггер. Обновление раз в час — это ОК, триггер на каждой строке — слишком.
Производительность
Триггеры — частая причина медленных запросов. Два типичных сценария:
Первый — лавинообразные каскады. Один UPDATE запускает 5 триггеров, каждый из которых делает ещё по 3 UPDATE-а с собственными триггерами. Простая операция превращается в ураган.
Второй — неэффективный код внутри триггера. Если триггер делает SELECT с JOIN, этот JOIN выполняется для каждой строки. UPDATE на миллион строк → миллион JOIN-ов.
Как проверить, что триггер тормозит:
EXPLAIN ANALYZE UPDATE orders SET status = 'paid' WHERE order_id IN (...);В плане выполнения будут видны вызовы триггеров и время на каждый.
Если триггер нужен, но медленный — оптимизации те же, что для любого SQL: индексы на таблицах, используемых в триггере, ограничение условий в WHERE, batch-обработка где возможно.
Отключение и удаление
Иногда нужно временно отключить триггер — например, при массовой миграции данных, когда аудит создаст миллионы лишних записей.
-- Отключить триггер
ALTER TABLE orders DISABLE TRIGGER orders_audit;
-- Выполнить миграцию
UPDATE orders SET ...;
-- Включить обратно
ALTER TABLE orders ENABLE TRIGGER orders_audit;
-- Удалить совсем
DROP TRIGGER orders_audit ON orders;Осторожно с отключением в production — можно забыть включить обратно, и аудит перестанет работать.
Ошибки новичков
Забыть про триггеры при анализе. Запрос работает странно, данные кажутся неконсистентными — возможно, где-то триггер их меняет. Всегда проверяйте через \d table_name в psql, какие триггеры висят.
Писать всю логику в триггерах. Как с процедурами — соблазн велик, но потом никто не разберёт.
Не обрабатывать ошибки. Триггер падает — весь statement откатывается. Если это критичный путь приложения, пользователи будут видеть ошибки на ровном месте.
Циклические триггеры. Триггер A меняет таблицу B, триггер на B меняет A, который запускает триггер... Может зациклиться, если нет guard-условий.
Читайте также
FAQ
Триггеры замедляют UPDATE?
Да, особенно ROW-level триггеры. Для массовых UPDATE лучше отключать триггеры на время операции или переходить на STATEMENT-level.
Можно ли триггер на VIEW?
В PostgreSQL — да, через INSTEAD OF триггеры. Это единственный способ сделать VIEW updatable с нетривиальной логикой.
Что быстрее: триггер или проверка в приложении?
Проверка в приложении обычно быстрее, потому что не создаёт round-trip в БД. Триггер оправдан, когда к БД обращаются из нескольких приложений и важна гарантированная валидация.
Как отлаживать триггер?
RAISE NOTICE внутри триггера выводит сообщения в лог. Для серьёзной отладки — pg_stat_statements показывает, сколько времени тратится на триггеры. В крайних случаях — ставить LOG-триггер вокруг проблемного.