Триггер — это код внутри базы, который запускается сам при изменении данных. Звучит удобно: вставил строку — и дальше всё произошло само. Но чаще эта «магия» создаёт больше проблем, чем решает. Разберёмся, как триггеры работают, где они уместны и где лучше обойтись без них.
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.
Что почитать дальше
- Блокировки в PostgreSQL — почему триггер, который пишет в соседнюю таблицу, приводит к deadlock.
- Материализованные представления — альтернатива счётчику на триггере.
- VACUUM и bloat — что происходит с таблицей журнала, в которую триггер пишет на каждый UPDATE.
- Миграции без простоя — как выкатывать изменения схемы вместе с кодом.