Триггер — это код внутри базы данных, который запускается автоматически при изменении данных. Звучит удобно: вставил строку — и сразу что-то произошло само собой. Но в большинстве случаев эта «магия» создаёт больше проблем, чем решает. Разберёмся: как работают триггеры, где они уместны, а где лучше обойтись без них.
Как работает триггер
Представьте: каждый раз, когда кто-то меняет строку в таблице, PostgreSQL автоматически вызывает кусок кода на pl/pgsql. Это и есть триггер.
Минимальный триггер состоит из двух частей: функции и самого триггера, который эту функцию вызывает.
-- 1. Функция, которая выполняется по событию
CREATE OR REPLACE FUNCTION log_to_audit()
RETURNS trigger AS $$
BEGIN
INSERT INTO audit_log (table_name, operation, changed_at)
VALUES (TG_TABLE_NAME, TG_OP, now());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 2. Триггер, привязывающий функцию к таблице и событию
CREATE TRIGGER tr_order_doc_audit
AFTER INSERT OR UPDATE OR DELETE ON order_doc
FOR EACH ROW EXECUTE FUNCTION log_to_audit();
Когда срабатывает
У триггера есть два параметра, которые определяют момент срабатывания:
- BEFORE — до записи изменений. Функция может поменять значения перед сохранением.
- AFTER — после записи. Функция видит уже зафиксированные данные, но изменить их уже нельзя.
И гранулярность:
- FOR EACH ROW — функция вызывается отдельно для каждой изменённой строки.
- FOR EACH STATEMENT — функция вызывается один раз на весь SQL-запрос, независимо от числа строк.
Внутри функции триггера доступны специальные переменные: TG_OP (что произошло: INSERT, UPDATE или DELETE), TG_TABLE_NAME (имя таблицы), NEW (новые значения строки), OLD (старые значения).
Почему бизнес-логика в триггерах — плохая идея
Кажется, что держать логику в базе удобно: она всегда выполнится, даже если кто-то запустит UPDATE прямо из psql. Но на практике это оборачивается головной болью.
Логику в триггере не видно. Разработчик пишет UPDATE order_doc SET status = 'paid' и не знает, что в этот момент тихо запускается ещё пять функций. Отлаживать такой код мучительно.
Триггеры сложно тестировать. Юнит-тест не запустишь без реальной базы данных. Нужны интеграционные тесты с Testcontainers или полноценной PostgreSQL — это медленнее и тяжелее.
Версионирование неудобно. Код приложения и триггер живут в разных местах. При откате релиза нужно синхронно откатить и миграцию с триггером — это отдельная операция, про которую легко забыть.
Привязка к PostgreSQL. Логика на pl/pgsql не перенесётся на другую базу данных без переписывания.
Поэтому основной принцип: бизнес-логика живёт в коде приложения. Триггеры — исключение, а не правило.
Частые ошибки: что делают триггерами, но не стоит
Обновление updated_at через триггер
Это самая распространённая ошибка.
-- Частая ошибка — триггер для updated_at
CREATE TRIGGER tr_set_updated_at
BEFORE UPDATE ON order_doc FOR EACH ROW EXECUTE FUNCTION set_updated_at();
Проблема: разработчик пишет UPDATE order_doc SET status = 'paid' WHERE id = 1 и не подозревает, что триггер тихо меняет updated_at. Это скрытое поведение. Когда что-то идёт не так — найти причину непросто.
Как правильно: задать DEFAULT now() в схеме и явно указывать updated_at = now() в коде приложения.
-- Схема
CREATE TABLE order_doc (
id bigint PRIMARY KEY,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Запрос в коде — явно, ничего не скрыто
UPDATE order_doc SET status = ?, updated_at = now() WHERE id = ?;
Валидация данных через триггер
Для простых ограничений есть CHECK-условия — они понятнее и быстрее триггера.
-- Правильно: CHECK прямо в схеме
ALTER TABLE order_doc
ADD CONSTRAINT ck_order_total_positive CHECK (total_amount >= 0);
Если ограничение сложнее (например, «в один момент времени не более одного врача дежурит в отделении») — лучше реализовать проверку в коде приложения с SELECT FOR UPDATE, чем писать триггер. Так логику проще понять и протестировать.
Цепочки триггеров
Когда один триггер вызывает другой, который вызывает третий — это гарантированный кошмар при отладке. Лучше заменить такую цепочку явным обработчиком событий в коде приложения.
Уведомления и HTTP-вызовы из триггера
NOTIFY из триггера — хрупкая конструкция. Если получатель не слушает, сообщение потеряется. Вместо этого используют паттерн Outbox: триггер (или код приложения) пишет событие в специальную таблицу, а отдельный процесс доставляет его дальше.
Когда триггер оправдан
Есть несколько ситуаций, где триггер действительно помогает.
Audit-trail для compliance
Если ваш продукт работает в банке, медицинском учреждении или другой регулируемой отрасли — нужно фиксировать каждое изменение данных. Триггер гарантирует: ни одно изменение не пройдёт мимо журнала, даже если разработчик запустил UPDATE вручную в psql.
CREATE TRIGGER tr_order_doc_audit
AFTER INSERT OR UPDATE OR DELETE ON order_doc
FOR EACH ROW EXECUTE FUNCTION log_to_audit();
Альтернатива — outbox и код приложения — гибче, но требует дисциплины: каждый разработчик должен помнить создавать запись в журнале. Триггер снимает эту ответственность с команды.
База как источник истины для нескольких приложений
Если к одной PostgreSQL-базе подключается несколько приложений, написанных на разных языках, — вынести общую логику в триггер разумно. Это единственное место, где она гарантированно выполнится независимо от того, кто сделал изменение.
Денормализация с гарантией консистентности
Если нужен счётчик, который всегда точен прямо в момент транзакции (без задержки) — триггер справляется:
CREATE TRIGGER tr_update_post_count
AFTER INSERT OR DELETE ON post
FOR EACH ROW EXECUTE FUNCTION update_forum_post_count();
Но чаще достаточно более простых альтернатив:
- Materialized view — обновляется по расписанию, подходит, когда данные могут быть немного устаревшими.
- Подсчёт на лету при чтении — если записей немного.
- Read model через event handler в коде — гибко и тестируемо.
Хранимые процедуры
Хранимые процедуры (stored procedures) — это функции на pl/pgsql, которые вызываются явно из приложения. В отличие от обычных функций PostgreSQL, процедуры (начиная с PostgreSQL 11) умеют делать COMMIT и ROLLBACK внутри себя.
В большинстве случаев они не нужны: управление транзакциями в коде приложения покрывает почти все сценарии.
Когда процедура оправдана:
- Очень тяжёлые SQL-операции, где каждый сетевой round-trip дорого стоит — например, обработка миллионов строк.
- ETL: читает гигабайты, агрегирует, пишет пачками с промежуточными коммитами.
-- Архивация старых заказов пачками, с промежуточными коммитами
CREATE PROCEDURE archive_old_orders() LANGUAGE plpgsql AS $$
DECLARE rows_affected integer := 1;
BEGIN
WHILE rows_affected > 0 LOOP
WITH deleted AS (
DELETE FROM order_doc
WHERE created_at < now() - interval '1 year'
RETURNING *
)
INSERT INTO order_archive SELECT * FROM deleted;
GET DIAGNOSTICS rows_affected = ROW_COUNT;
COMMIT;
END LOOP;
END;
$$;
Для задач по расписанию альтернатива — планировщик в коде приложения с пакетной логикой. Он тестируется, виден в репозитории, не привязан к PostgreSQL.
Производительность: почему FOR EACH ROW опасен на больших объёмах
FOR EACH ROW — это буквально один вызов функции на каждую затронутую строку. При обычных операциях это незаметно. Но при массовых вставках стоимость резко вырастает.
INSERT INTO target SELECT * FROM source — если в source миллион строк, триггер FOR EACH ROW вызовется миллион раз. Это может замедлить операцию в разы.
Если триггер всё же нужен на таблице с массовыми операциями — используйте FOR EACH STATEMENT: функция вызывается один раз на весь запрос.
Ещё одна скрытая опасность: взаимная блокировка (deadlock). Если триггер обновляет другую таблицу, порядок захвата блокировок может конфликтовать с другими транзакциями, которые обращаются к тем же таблицам в другом порядке. PostgreSQL обнаружит deadlock и прервёт одну из транзакций с ошибкой.
Как найти триггеры в базе
Если вы пришли в новый проект и хотите понять, какие триггеры уже существуют:
-- Все триггеры на конкретной таблице
SELECT trigger_name, event_manipulation, action_timing, action_statement
FROM information_schema.triggers
WHERE event_object_table = 'order_doc';
-- Исходный код функции триггера
SELECT prosrc FROM pg_proc WHERE proname = 'set_updated_at';
Для отладки можно добавить RAISE NOTICE прямо в функцию триггера — сообщение появится в логах:
CREATE OR REPLACE FUNCTION debug_trigger()
RETURNS trigger AS $$
BEGIN
RAISE NOTICE 'TRIGGER fired: % on table %', TG_OP, TG_TABLE_NAME;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Коротко
- Триггер — это код в базе, который запускается автоматически при INSERT, UPDATE или DELETE.
- BEFORE срабатывает до записи и может изменить данные, AFTER — после.
- FOR EACH ROW вызывается для каждой строки; FOR EACH STATEMENT — один раз на запрос.
- Бизнес-логика принадлежит коду приложения: там она тестируется, версионируется и видна.
updated_atчерез триггер — частая ошибка; правильно — явныйupdated_at = now()в запросе.- Триггер оправдан для compliance-журналирования, когда каждое изменение обязательно должно быть записано.
FOR EACH ROWна массовых операциях (миллионы строк) существенно замедляет работу.- Хранимые процедуры полезны для тяжёлых ETL с промежуточными коммитами; в остальных случаях достаточно кода приложения.
Что почитать дальше
- Индексы в PostgreSQL — как ускорить запросы без дополнительной логики в базе.
- VACUUM и bloat — как PostgreSQL убирает «мусор» после UPDATE и DELETE.
- Секционирование таблиц — как разбить большую таблицу на части.