0 / 63 (0%)

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. На таймлайне это так:

plaintext
Писатель (одна транзакция)           Слушатель (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/NOTIFYoutbox (09-03)
Хранениене хранится — мимолётный пинокстрока в таблице, переживает рестарт
Гарантияat-most-once (нет слушателя → потеря)at-least-once (relay дочитает)
Транзакционностьждёт COMMIT, откат → сигнала нетфакт и событие коммитятся атомарно
Задержкамгновенно, без опросазадержка на цикл relay
Размерpayload ≤ 8000 байтразмер строки / jsonb
Рольсигнал «проснись»надёжная доставка

Что показывает наш код

Это raw-pgx юнит. Слушатель берёт выделенный коннект и подписывается:

go
lc, _ := pool.Acquire(ctx)        // выделенное соединение под слушателя
defer lc.Release()
listener := lc.Conn()
listener.Exec(ctx, "LISTEN brew_events")

Ожидание с таймаутом инкапсулировано в waitNotify: пришло — вернёт payload, истёк таймаут — вернёт «ничего» (на этом и держатся обе оговорки):

go
n, err := conn.WaitForNotification(wctx)
if errors.Is(err, context.DeadlineExceeded) {
    return nil, nil // уведомления нет — штатный исход
}

Триггер, который шлёт pg_notify, создаётся в setupLab (AFTER INSERTpg_notify('brew_events', json_build_object('id', NEW.id, 'name', NEW.name))).

Запуск

sh
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-notify

T=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче: make db-reset, затем make run.

plaintext
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, финал модуля.

·Модуль 10

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Запись / LISTEN / NOTIFY