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

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

один UPDATE, три строки под условием — FOR EACH ROW сработает на каждой UPDATE orders SET status='PAID' под условие попали 3 строки строка 1 id=101 строка 2 id=102 строка 3 id=103 BEFORE ROW правит NEW таблица новая версия AFTER ROW пишет журнал audit_log UPDATE orders SET status='PAID' строка 1id=101 строка 2id=102 строка 3id=103 BEFORE ROWправит NEW таблицановая версия AFTER ROWпишет журнал строка 1: PAID строка 2: PAID строка 3: PAID функция вызвалась 6 раз: BEFORE и AFTER на каждой строке FOR EACH STATEMENTодин вызов на весь запрос — и одна запись в журнале

BEFORE-функция успевает поправить строку до записи, AFTER-функция видит её уже записанной и дописывает журнал. И то и другое — на каждую строку: под один UPDATE на три строки функции вызовутся шесть раз. FOR EACH STATEMENT — та же функция, но один вызов на весь запрос.

Обязательно

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

Аудит требует, чтобы любое изменение заказа попало в журнал, откуда бы оно ни пришло: из сервиса, из скрипта поддержки или из консоли администратора. В коде это не поймать, код обойдут; ловить надо в базе. Каждый раз, когда кто-то меняет строку в таблице, PostgreSQL может автоматически вызвать кусок кода на PL/pgSQL. Это и есть триггер, и состоит он из двух частей: функции и самого триггера, который эту функцию вызывает.

-- функция, которая выполняется по событию
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 NULL;
END;
$$ LANGUAGE plpgsql;

-- триггер: функция, таблица, событие
CREATE TRIGGER tr_order_doc_audit
AFTER INSERT OR UPDATE OR DELETE ON order_doc
FOR EACH ROW EXECUTE FUNCTION log_to_audit();

Слово FUNCTION в последней строке — написание, появившееся в PostgreSQL 11. В чужой базе и в старых миграциях на его месте встретится EXECUTE PROCEDURE; это ровно то же самое, просто прежнее название, и PostgreSQL принимает оба до сих пор. Разбираться, чем они отличаются, не надо — ничем.

Когда срабатывает

Момент срабатывания задают два слова, и выбор между ними это вопрос «хочу поправить строку или отреагировать на неё». BEFORE срабатывает до записи изменений, и функция может поменять значения перед сохранением; возвращаемое значение здесь имеет смысл: вернёт NEW, запись пойдёт с её правками, вернёт NULL, операция для этой строки молча не выполнится. AFTER срабатывает после записи: функция видит строку уже в новом виде и изменить её не может, а результат её работы PostgreSQL игнорирует, поэтому в примере выше RETURN NULL. «Уже записано» здесь не значит «зафиксировано»: транзакция ещё открыта, и если дальше случится откат, исчезнут и изменения, и всё, что натворил триггер.

И гранулярность:

  • FOR EACH ROW — функция вызывается отдельно для каждой изменённой строки.
  • FOR EACH STATEMENT — функция вызывается один раз на весь SQL-запрос, независимо от числа строк. Сами строки такой функции не видны: NEW и OLD в ней не заданы. Чтобы увидеть затронутые строки, триггер объявляют как AFTER с REFERENCING NEW TABLE AS new_rows (переходные таблицы появились в PostgreSQL 10).

Функции нужно знать, что именно случилось, и это ей дают четыре переменные. В TG_OP лежит операция, INSERT, UPDATE или DELETE, чтобы журнал различал вставку и удаление; в TG_TABLE_NAME имя таблицы, чтобы одну функцию вешать на несколько таблиц; в NEW новые значения строки, а в OLD старые, чтобы записать, что именно поменялось.

Почему бизнес-логика в триггерах — плохая идея

Логика в базе выглядит надёжно: она выполнится даже при UPDATE прямо из psql. На практике за это приходится платить.

Логику в триггере не видно. Разработчик пишет UPDATE order_doc SET status = 'paid' и не знает, что в этот момент тихо отработали ещё пять функций.

Триггеры сложно тестировать. Юнит-тест не запустишь без реальной базы: нужны интеграционные тесты с Testcontainers, а это медленнее и тяжелее.

Версионирование неудобно. Код приложения и триггер живут в разных местах, и при откате релиза миграцию с триггером надо откатывать отдельно — про это легко забыть.

Привязка к 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 = ?;

Одна тонкость про now(): это не «сейчас», а момент начала транзакции. Сколько раз её внутри транзакции ни позови, ответ будет один и тот же. На правке одной строки разницы никакой, а вот когда в одной транзакции обновляют пачку строк или транзакция висит минуту, все строки получат одинаковый updated_at, не совпадающий с моментом реальной записи. Если поле читают как журнал изменений, это меняет его смысл. Настоящее текущее время даёт clock_timestamp() — оно меняется на каждый вызов; statement_timestamp() посередине — время начала текущего запроса.

Цепочки триггеров

Один триггер вызывает другой, тот — третий: при отладке такую цепочку разматывать мучительно. Лучше заменить её явным обработчиком событий в коде.

Ещё две ошибки из того же ряда — проверка данных триггером и уведомления из него — разобраны ниже, в разделах «Глубже».

WHEN: самый дешёвый способ починить существующий триггер

Триггер FOR EACH ROW вызывается на каждую строку, даже когда делать ему нечего: обновили описание товара — триггер пересчёта статуса всё равно запустился, проверил, что статус не менялся, и вышел. На массовом обновлении это миллионы бессмысленных вызовов.

Условие WHEN отсекает их до вызова функции:

CREATE TRIGGER order_status_changed
    AFTER UPDATE ON orders
    FOR EACH ROW
    WHEN (OLD.status IS DISTINCT FROM NEW.status)
    EXECUTE FUNCTION log_status_change();

Проверка выполняется в самом триггерном механизме, без входа в функцию, поэтому стоит она почти ничего. Это главный способ сделать уже написанный триггер дешёвым, не переписывая его на FOR EACH STATEMENT. Обратите внимание на IS DISTINCT FROM вместо <>: обычное сравнение с NULL даёт «неизвестно», и триггер на строках с пустым статусом молча не сработает.

Ограничения: в WHEN нельзя вызывать подзапросы, а для AFTER-триггеров с INSERT доступен только NEW, для DELETE — только OLD.

Половину триггеров заменяет генерируемая колонка

Самое частое применение триггера — «посчитать поле из других полей той же строки»: полное имя из имени и фамилии, сумма позиции из цены и количества, поисковый вектор из названия и описания. С PostgreSQL 12 для этого есть декларативная замена:

ALTER TABLE order_items
    ADD COLUMN line_total numeric(15,2)
    GENERATED ALWAYS AS (unit_price * quantity) STORED;

База сама пересчитывает значение при каждой записи, обойти это невозможно (записать в такую колонку нельзя), порядок срабатывания не при чём, и никакой функции поддерживать не надо. Ограничение одно и логичное: выражение должно зависеть только от колонок той же строки и быть неизменяемым — обратиться к другой таблице или к now() нельзя. Именно так в соседних статьях фазы строят поисковый вектор для полнотекстового поиска и вычисляемую геометрию.

Порядок срабатывания: по алфавиту

Когда на одну таблицу и одно событие навешано несколько триггеров, они выполняются в алфавитном порядке имён. Не в порядке создания, не в порядке важности — по имени.

Отсюда две практики. Если порядок важен, его закладывают в имена: 10_validate_order, 20_calc_total, 30_write_audit — и тогда он виден в \d таблицы без чтения кода. Если порядок не важен, лучше убедиться, что это правда: триггеры, меняющие NEW в BEFORE-фазе, влияют друг на друга, и результат зависит от того, кто отработал первым.

И общее следствие, ради которого статья и предупреждает про цепочки: UPDATE из триггера запускает триггеры целевой таблицы, те — свои, и отлаживать это приходится по журналу, потому что в коде приложения ничего этого не видно. max_stack_depth остановит бесконечную рекурсию, но уже в проде и с непонятной ошибкой.

Когда триггер оправдан

Ситуаций, где триггер выигрывает, немного.

Журнал изменений, который нельзя обойти

В регулируемой отрасли — банк, медицина — фиксировать нужно каждое изменение. Триггер tr_order_doc_audit из начала статьи делает это независимо от того, кто пришёл менять данные: даже ручной UPDATE из psql не пройдёт мимо журнала.

Гарантия не абсолютная: владелец таблицы может выключить триггер командой ALTER TABLE ... DISABLE TRIGGER, а TRUNCATE вызывает только триггеры уровня запроса — ROW-триггеры на нём не срабатывают.

Альтернатива — outbox и код приложения — гибче, но держится на дисциплине: запись в журнале нужно не забыть создать. Триггер снимает эту ответственность с команды.

База как источник истины для нескольких приложений

Если к одной базе подключается несколько приложений на разных языках, общая логика в триггере — единственное место, где она выполнится независимо от того, кто сделал изменение.

Денормализация с гарантией консистентности

Если нужен счётчик, который всегда точен прямо в момент транзакции (без задержки) — триггер справляется:

CREATE TRIGGER tr_update_post_count
AFTER INSERT OR DELETE ON post
FOR EACH ROW EXECUTE FUNCTION update_forum_post_count();

Но чаще хватает более простого: материализованного представления, если счётчик может отставать на минуты, или подсчёта на лету при чтении, если записей немного.

Дополнительно: при первом чтении можно пропустить

Глубже: Как это выглядит на моделирасширенное

Цена гранулярности видна и без базы: ниже таблица из трёх строк и один UPDATE по ним. Заодно видно, как BEFORE правит колонку, которой в запросе не было.

живой пример

import java.util.ArrayList;
import java.util.List;

public class TriggerDemo {

    static final String[] status = {"NEW", "NEW", "NEW"};
    static final int[] updatedAt = {0, 0, 0};
    static final List<String> audit = new ArrayList<>();
    static int calls;

    public static void main(String[] args) {
        update("PAID", 42, true);
        System.out.println("FOR EACH ROW:       вызовов функции " + calls + ", записей в журнале " + audit.size());
        calls = 0;
        audit.clear();
        update("SHIPPED", 77, false);
        System.out.println("FOR EACH STATEMENT: вызовов функции " + calls + ", записей в журнале " + audit.size());
        System.out.println("updated_at строки 1 = " + updatedAt[0] + ", хотя в запросе этой колонки не было");
    }

    static void update(String newStatus, int now, boolean forEachRow) {
        for (int i = 0; i < status.length; i++) {
            if (forEachRow) {
                calls++;
                updatedAt[i] = now;
            }
            status[i] = newStatus;
            if (forEachRow) {
                calls++;
                audit.add("строка " + (i + 1) + " -> " + newStatus);
            }
        }
        if (!forEachRow) {
            calls++;
            audit.add("изменено строк: " + status.length);
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Три строки — шесть вызовов против одного; на миллионе строк — два миллиона вызовов против одного.

Глубже: Валидация данных через триггеррасширенное

Для простых ограничений есть CHECK-условия — они понятнее и быстрее триггера.

-- правильно: CHECK прямо в схеме
ALTER TABLE order_doc
    ADD CONSTRAINT ck_order_status CHECK (status IN ('new', 'paid', 'shipped'));

Ограничение сложнее — скажем, «две смены одного врача не должны пересекаться по времени» — тянет проверить в коде приложения: взять SELECT FOR UPDATE, посмотреть соседние смены, и если пересечения нет, вставить свою. Это не работает, и ошибка коварная: SELECT FOR UPDATE блокирует строки, которые уже лежат в таблице. Две параллельные вставки новых строк друг друга просто не видят — обе смотрят на одни и те же старые данные, обе решают, что пересечения нет, и обе проходят. Триггер здесь тоже не спасает: он проверял бы то же самое и с той же дырой.

У PostgreSQL для таких случаев есть отдельный вид ограничения — EXCLUDE. Он проверяет непересечение внутри самой базы, там же, где живут блокировки:

CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE shift
    ADD CONSTRAINT ex_shift_overlap
    EXCLUDE USING gist (doctor_id WITH =, period WITH &&);

Подробнее про этот механизм — в статье про расширения.

Глубже: Уведомления и HTTP-вызовы из триггерарасширенное

NOTIFY из триггера хрупок: если получатель в этот момент не слушает канал, сообщение просто потеряется. Вместо этого событие пишут в отдельную таблицу, а доставляет его фоновый процесс.

Ходить из триггера в чужой сервис по сети — через dblink или расширения вроде http — ещё хуже. Триггер работает внутри транзакции, и пока он ждёт ответа чужого сервиса, транзакция не закрывается: строки заблокированы, соединение занято, а чужой сервис имеет полное право отвечать пять секунд или не ответить вовсе. Обратной дороги тоже нет: если транзакция потом откатится, база свои изменения вернёт, а отправленный запрос уже не отзовёшь. Внешние вызовы делают после фиксации — тем же фоновым процессом, который читает таблицу событий.

Глубже: Хранимые процедурырасширенное

Хранимые процедуры (stored procedures) — это код на PL/pgSQL, который вызывают из приложения командой CALL. В отличие от обычных функций PostgreSQL, процедуры (начиная с PostgreSQL 11) умеют делать COMMIT и ROLLBACK внутри себя.

В большинстве случаев они не нужны: управление транзакциями в коде приложения покрывает почти все сценарии.

Когда процедура оправдана:

  • Очень тяжёлые SQL-операции, где дорого стоит каждое обращение к базе по сети — например, обработка миллионов строк.
  • Перекладывание данных пачками: читает гигабайты, агрегирует, пишет с промежуточными коммитами.
-- архивация пачками, с промежуточными коммитами
CREATE PROCEDURE archive_old_orders() LANGUAGE plpgsql AS $$
DECLARE moved integer := 1;
BEGIN
    WHILE moved > 0 LOOP
        WITH batch AS (
            DELETE FROM order_doc WHERE ctid IN (
                SELECT ctid FROM order_doc
                WHERE created_at < now() - interval '1 year' LIMIT 10000)
            RETURNING id, status, created_at, updated_at
        )
        INSERT INTO order_archive (id, status, created_at, updated_at)
        SELECT id, status, created_at, updated_at FROM batch;
        GET DIAGNOSTICS moved = ROW_COUNT;
        COMMIT;
    END LOOP;
END;
$$;

Колонки здесь перечислены не из любви к многословию. Короткий вариант — RETURNING * и INSERT INTO order_archive SELECT * FROM batch — молча требует, чтобы order_archive совпадала с order_doc по числу и порядку колонок. Пока обе таблицы свежие, так и есть. А потом кто-нибудь добавит колонку в одну из них — и процедура либо упадёт посреди ночного прогона, либо, если типы случайно совпали, разложит значения по чужим колонкам и никому об этом не скажет.

Альтернатива — планировщик в коде приложения: он тестируется, виден в репозитории и не привязан к PostgreSQL.

Глубже: Производительность: почему FOR EACH ROW опасен на больших объёмахрасширенное

На точечных правках вызов на каждую строку незаметен, а на массовых стоимость растёт линейно: в INSERT INTO target SELECT * FROM source на миллион строк функция отработает миллион раз и замедлит операцию в разы.

Если триггер всё же нужен на таблице с массовыми операциями — используйте FOR EACH STATEMENT с переходной таблицей: функция вызывается один раз и обрабатывает все затронутые строки одним запросом.

Вторая скрытая опасность — взаимная блокировка (deadlock). Триггер, который правит соседнюю таблицу, захватывает блокировки в своём порядке, а другая транзакция берёт те же таблицы в обратном: PostgreSQL обнаружит тупик и прервёт одну из транзакций с ошибкой.

Триггеры в эксплуатации: заливка, репликация, партиции, права

Массовая заливка. Штатный способ выключить все триггеры на время загрузки — SET session_replication_role = replica; в той же сессии: в этом режиме обычные пользовательские триггеры не срабатывают, а внешние ключи не проверяются. После загрузки возвращают origin и обязательно приводят данные в согласованное состояние — пересчитывают то, что должны были посчитать триггеры. Точечная альтернатива — ALTER TABLE … DISABLE TRIGGER, но она берёт тяжёлую блокировку на таблицу и влияет на всех, а не только на вашу сессию.

Логическая репликация. На подписчике пользовательские триггеры по умолчанию не срабатывают — применение изменений идёт в режиме реплики. Это обычно и нужно (иначе журнал аудита писался бы дважды), но иногда нужен и обратный вариант: тогда триггер помечают ALTER TABLE … ENABLE ALWAYS TRIGGER …. Помнить об этом стоит заранее: логика, которая на основном сервере держится триггером, на подписчике просто не выполнится.

Партиционированные таблицы. С PostgreSQL 13 FOR EACH ROW-триггер, созданный на родительской таблице, распространяется на все её части, включая созданные позже. На более старых версиях его приходилось вешать на каждую часть отдельно — и не забывать про новые, что и было источником «на августе работает, на сентябре нет».

Права. Триггерная функция по умолчанию выполняется с правами того, кто сделал UPDATE. Поэтому «журнал, который нельзя обойти», ломается на правах: у роли приложения может не быть доступа к таблице аудита, и операция упадёт. Решение — объявить функцию SECURITY DEFINER, тогда она работает с правами владельца. Цена — обязательная аккуратность: у такой функции фиксируют search_path (SET search_path = pg_catalog, public), иначе подменённая схема позволяет выполнить чужой код с чужими правами.

Глубже: Как найти триггеры в базерасширенное

Что уже висит на таблицах незнакомой базы:

-- Все триггеры на конкретной таблице
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 'сработал % на %', TG_OP, TG_TABLE_NAME; — сообщение появится в консоли psql; в журнал сервера NOTICE по умолчанию не пишется.

Коротко

  • Триггер — это функция на PL/pgSQL плюс объявление CREATE TRIGGER, которое привязывает её к таблице и событию: INSERT, UPDATE, DELETE. EXECUTE FUNCTION и старое EXECUTE PROCEDURE — одно и то же.
  • BEFORE срабатывает до записи и может поправить строку: вернула NEW — строка пойдёт с правками, вернула NULL — операция для этой строки молча не выполнится. AFTER видит строку уже записанной, его результат PostgreSQL игнорирует.
  • FOR EACH ROW вызывает функцию на каждую строку, FOR EACH STATEMENT — один раз на весь запрос (строки дают через REFERENCING NEW TABLE AS new_rows). Лишние вызовы отсекает условие WHEN (OLD.x IS DISTINCT FROM NEW.x) — самый дешёвый способ починить готовый триггер.
  • Бизнес-логика живёт в коде приложения: там она видна, тестируется и версионируется. Триггер — исключение, а не правило, и половину случаев «посчитать поле из соседних» закрывает генерируемая колонка GENERATED ALWAYS AS (…) STORED.
  • updated_at через триггер — самая частая ошибка. Правильно — DEFAULT now() в схеме и явный updated_at = now() в запросе; now() — момент начала транзакции, настоящее текущее время даёт clock_timestamp().
  • Проверка данных — не триггером: простые ограничения через CHECK, непересечение интервалов через EXCLUDE USING gist. SELECT FOR UPDATE в коде новых строк не видит, две параллельные вставки пройдут обе.
  • NOTIFY и HTTP-вызовы из триггера — нет: сообщение теряется, а транзакция висит, пока чужой сервис отвечает. Событие пишут в таблицу, доставляет его фоновый процесс после фиксации.
  • Триггер оправдан для журнала изменений, который нельзя обойти, и для общей логики нескольких приложений над одной базой. Границы гарантии — ALTER TABLE ... DISABLE TRIGGER и TRUNCATE, на котором ROW-триггеры не срабатывают.
  • FOR EACH ROW на миллионе строк — миллион вызовов и замедление в разы; несколько триггеров на одном событии идут по алфавиту имён, а триггер, пишущий в соседнюю таблицу, — кандидат на deadlock. Хранимые процедуры (CALL) умеют COMMIT внутри себя и нужны только для тяжёлой пакетной обработки.
  • В эксплуатации: session_replication_role = replica выключает триггеры на время заливки, на подписчике они не срабатывают без ENABLE ALWAYS, с PG 13 триггер на родителе покрывает все части, а запись в аудит требует SECURITY DEFINER с фиксированным search_path.

Что почитать дальше