LISTEN / NOTIFY
Кухня Brew хочет видеть новые заказы в реальном времени: оформили — на экране у
бариста сразу всплыла карточка. Очередь из 09-02 для этого можно опрашивать в
цикле: SELECT новые заказы каждую секунду. Но опрос — это компромисс. Частишь —
жжёшь базу пустыми запросами «ничего нового?»; редишь — растёт задержка между
«заказ оформлен» и «карточка всплыла». Хочется наоборот: не спрашивать базу, а
чтобы база сама толкнула сигнал, как только что-то произошло.
Ровно это даёт пара LISTEN / NOTIFY — встроенная в Postgres шина
«издатель-подписчик».
Механика: канал, NOTIFY и слушатель
Одна сторона подписывается на именованный канал: LISTEN brew_events. Другая
шлёт в него сообщение: NOTIFY brew_events, 'payload' или, что удобнее из
кода/триггера, функцией pg_notify('brew_events', 'payload'). Все соединения,
которые сейчас слушают канал, мгновенно получают payload — без опроса, без
задержки.
Чаще всего NOTIFY дёргают не руками, а из триггера: «как только в таблицу
легла строка — пошли уведомление». В нашем демо триггер AFTER INSERT на
notify_lab вызывает pg_notify('brew_events', ...) с компактным json о новой
строке. Приложению не надо помнить, что после каждой вставки надо что-то
послать, — это делает сама база.
На стороне Go слушатель — это выделенное соединение. LISTEN привязан к
конкретному бэкенду, и читать уведомления надо с того же conn, что выполнял
LISTEN. В pgx это conn.WaitForNotification(ctx) — блокирующее ожидание
следующего уведомления (с таймаутом через context). Именно поэтому юнит —
raw-pgx (escape-hatch): WaitForNotification живёт на уровне соединения, в
sqlc такого метода нет.
Оговорка 1: NOTIFY транзакционен — он ждёт COMMIT
Главное, что должен знать разработчик: NOTIFY придерживается до COMMIT.
Если триггер сработал внутри транзакции, уведомление не уйдёт, пока транзакция не
закоммитится; если она откатится — уведомления не будет вовсе. Это хорошая
новость (нет «фантомных» сигналов о том, чего в базе не случилось), но и ловушка:
слушатель не увидит ничего, пока писатель держит транзакцию открытой. В демо мы
вставляем строку внутри транзакции, ждём 400 мс — тишина; делаем COMMIT — и
только теперь приходит payload. На таймлайне это так:
Писатель (одна транзакция) Слушатель (LISTEN brew_events)
BEGIN
INSERT → триггер pg_notify ····► придержано, ещё не видно
... 400 мс ... ждёт... тишина
COMMIT ─────────────────────────► payload приходит МГНОВЕННО
а если вместо COMMIT — откат:
ROLLBACK ───────────────────────► уведомления нет ВОВСЕОговорка 2: at-most-once — нет слушателя, потерян сигнал
Вторая ловушка важнее первой. NOTIFY не хранится. Если в момент отправки
канал никто не слушает, уведомление просто исчезает — его не положат в очередь и
не доставят позже. Это at-most-once: подписался поздно — пропустил. В демо мы
делаем UNLISTEN, вставляем строку (уведомление летит в пустоту), снова
LISTEN и ждём — ничего. Сигнал потерян безвозвратно.
Отсюда прямой контраст с outbox из 09-03. Outbox — это строки в таблице, они
переживут перезапуск и доставятся хотя бы один раз. NOTIFY — мимолётный пинок
«эй, посмотри», который существует только пока есть слушатель. Поэтому их часто
сочетают: NOTIFY будит relay/воркер сразу, а надёжность даёт outbox —
проснулся по сигналу ИЛИ по таймеру и вычитал всё, что накопилось.
Две колонки рядом — когда что брать:
| Ось | LISTEN/NOTIFY | outbox (09-03) |
|---|---|---|
| Хранение | не хранится — мимолётный пинок | строка в таблице, переживает рестарт |
| Гарантия | at-most-once (нет слушателя → потеря) | at-least-once (relay дочитает) |
| Транзакционность | ждёт COMMIT, откат → сигнала нет | факт и событие коммитятся атомарно |
| Задержка | мгновенно, без опроса | задержка на цикл relay |
| Размер | payload ≤ 8000 байт | размер строки / jsonb |
| Роль | сигнал «проснись» | надёжная доставка |
Что показывает наш код
Это raw-pgx юнит. Слушатель берёт выделенный коннект и подписывается:
lc, _ := pool.Acquire(ctx) // выделенное соединение под слушателя
defer lc.Release()
listener := lc.Conn()
listener.Exec(ctx, "LISTEN brew_events")Ожидание с таймаутом инкапсулировано в waitNotify: пришло — вернёт payload,
истёк таймаут — вернёт «ничего» (на этом и держатся обе оговорки):
n, err := conn.WaitForNotification(wctx)
if errors.Is(err, context.DeadlineExceeded) {
return nil, nil // уведомления нет — штатный исход
}Триггер, который шлёт pg_notify, создаётся в setupLab (AFTER INSERT →
pg_notify('brew_events', json_build_object('id', NEW.id, 'name', NEW.name))).
Запуск
docker compose up -d
make lecture L=09-writes-eventing-and-server-logic/09-04-listen-notify T=db-reset
make lecture L=09-writes-eventing-and-server-logic/09-04-listen-notifyT=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче:
make db-reset, затем make run.
1) notify_lab + триггер AFTER INSERT → pg_notify('brew_events', ...) готовы.
2) LISTEN brew_events (выделенное соединение слушает канал).
3) Транзакционность: INSERT внутри транзакции — уведомление ждёт COMMIT.
до COMMIT: ждём 400ms... уведомления нет (NOTIFY придержан до COMMIT).
COMMIT.
после COMMIT: получено уведомление, payload = {"id" : 1, "name" : "Эспрессо"}
4) At-most-once: если никто не слушает в момент NOTIFY — уведомление теряется.
UNLISTEN brew_events; INSERT 'Латте' (NOTIFY летит в пустоту).
LISTEN brew_events снова; ждём 400ms... уведомления нет (потеряно, NOTIFY не хранится).Сценарий 3 показывает транзакционность: до COMMIT — тишина (таймаут), после —
payload приходит мгновенно. Сценарий 4 показывает at-most-once: пока канал не
слушали, уведомление о «Латте» исчезло, и повторный LISTEN его уже не достанет.
Заборчик
- Payload ≤ 8000 байт. Слать туда весь объект — плохая идея: шли
идентификатор («заказ #42 изменился»), а тело подписчик дочитает обычным
SELECT. - Под нагрузкой шина сериализуется. Одинаковые уведомления схлопываются
(Postgres дедуплицирует идентичные payload в рамках транзакции), а в очень
высоконагруженных сценариях сама шина
NOTIFYидёт через общий лок — это не замена брокеру. - Слушатель держит коннект занятым всё время ожидания, а в режиме
транзакционного пулинга (PgBouncer, см. 10-04)
LISTENвообще ломается — уведомления приходят не на тот бэкенд. Слушателю нужен прямой session-mode коннект к базе, а не коннект из транзакционного пула; как это устроить в проде — вопрос к твоей инфраструктуре подключений. - Правило:
NOTIFY— это сигнал «проснись», а не канал доставки данных. Надёжность строй наoutbox/таблице, аNOTIFYиспользуй, чтобы не опрашивать её вхолостую.
Что забрать с собой
LISTEN / NOTIFY — встроенная в Postgres шина pub/sub: подписался на канал,
получаешь толчок, как только кто-то (часто — триггер через pg_notify) в него
написал; в pgx это conn.WaitForNotification на выделенном соединении. Две
оговорки определяют, где это применимо: NOTIFY транзакционен (ждёт COMMIT,
откат — нет уведомления) и at-most-once (нет слушателя в момент отправки —
сигнал потерян, он не хранится). Поэтому NOTIFY — это лёгкий «проснись», а
надёжную доставку оставь за outbox (09-03); их естественно сочетать. И помни
про лимит 8 КБ и про несовместимость с транзакционным пулингом.
Дальше — про сами триггеры подробнее и про то, чем за серверную логику
приходится платить: BEFORE-триггер для updated_at, AFTER-триггер аудита со
старым и новым значением, классификация функций IMMUTABLE/STABLE/VOLATILE — и
честная секция «когда логику в БД класть НЕ надо». Это 09-05, финал модуля.