← назад к разделу

Триггер — это код внутри базы данных, который запускается автоматически при изменении данных. Звучит удобно: вставил строку — и сразу что-то произошло само собой. Но в большинстве случаев эта «магия» создаёт больше проблем, чем решает. Разберёмся: как работают триггеры, где они уместны, а где лучше обойтись без них.

Как работает триггер

Представьте: каждый раз, когда кто-то меняет строку в таблице, 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.
  • Секционирование таблиц — как разбить большую таблицу на части.