EnglishРусский Map

Postgres LISTEN/NOTIFY вполне способен масштабироваться

title
Postgres LISTEN/NOTIFY вполне способен масштабироваться
type
summary
summary
Как в DBOS разогнали Postgres LISTEN/NOTIFY с 2.9K до 60K записей в секунду с помощью батчинга уведомлений
tags
postgresql, databases, performance, pub-sub, concurrency
created
2026-07-29
updated
2026-07-29
lang
ru
source_updated
2026-07-29
translated
2026-09-01
translator
lllm/antigravity/gemini-3.7-flash-medium

DBOS написали этот материал в июле 2026 года как прямой ответ на разошедшуюся статью recall.ai, где утверждалось, что Postgres LISTEN/NOTIFY не масштабируется. В DBOS признают: замеры в той статье верны, а само поведение действительно не задокументировано. Но их тезис в том, что этот потолок вызван одной конкретной деталью реализации, и обойти её можно, не отказываясь от уведомлений: их потоки данных на базе LISTEN/NOTIFY выдерживают 60K записей в секунду на одном сервере Postgres с задержкой 15-100 мс.

Архитектура и место, где всё упирается в потолок

Продукт представляет собой поток данных (stream), поверх которого лежит таблица. Каждый фрагмент потока - скажем, один токен ответа LLM - это отдельная строка, а запись в поток сводится к INSERT. Чтение - более сложная часть задачи, поскольку читатель никак не может узнать, когда появится следующий фрагмент. Polling даёт невыгодный компромисс в обоих направлениях: при длинном интервале задержка слишком велика для интерактивного чата, а при коротком все читатели одновременно нагружают базу данных. LISTEN/NOTIFY меняет эту схему: читатели просто блокируются в ожидании, пока писатель не сообщит им о появлении новых данных.

Очевидная реализация - триггер на таблице потоков, который вызывает функцию отправки уведомления при каждой вставке. Она корректна и работает быстро в пересчёте на одно сообщение. Но на крупном экземпляре Postgres такая схема не смогла выдать больше 2.9K записей в секунду, и - деталь, которая делает диагностику особенно интересной, - она упёрлась в эту стену без заметной нагрузки на ресурсы Postgres. CPU, память и IOPS на этом потолке оставались практически ненагруженными.

Зачем там глобальная блокировка

Фиксация (commit) транзакции, вызвавшей NOTIFY, требует глобальной эксклюзивной блокировки. Блокировка захватывается в самом начале commit'а и удерживается до тех пор, пока транзакция не будет полностью зафиксирована и сброшена на диск через fsync().

Postgres держит эту блокировку, потому что гарантирует доставку уведомлений строго в порядке фиксации транзакций. Чтобы это обеспечить, все исходящие уведомления должны попадать в глобальную внутреннюю очередь ровно в порядке commit'ов, причём постановка в очередь должна происходить транзакционно прямо во время фиксации. Замкнутый круг возникает из-за того, что Postgres не присваивает транзакции её порядковый номер фиксации, пока commit не завершится (ведь commit'ы длятся разное время). В итоге транзакции нужно знать своё место в очереди до того, как сама эта очередь сформируется. Сериализация таких commit'ов под одной блокировкой решает проблему: если в каждый момент времени фиксируется только одна транзакция с уведомлениями, её позиция определена заранее.

Это объясняет результаты раннего бенчмарка. Каждая запись в поток сопровождалась NOTIFY, поэтому каждой записи приходилось удерживать глобальную блокировку на всё время своего commit'а, включая сброс на диск. Записи фиксировались строго одна за другой, что также лишало их возможности использовать group commit - оптимизацию, при которой Postgres объединяет несколько транзакций в один вызов fsync(). В итоге пропускная способность ограничивается скоростью, с которой Postgres может фиксировать транзакции строго по очереди, а ресурсы выглядят ненагруженными, потому что никто не выполняет работу: backend-процессы просто ждут.

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

Обходное решение

Ключевое наблюдение здесь касается назначения самого уведомления. В этой архитектуре, как и в большинстве случаев применения LISTEN/NOTIFY, уведомление само по себе не несёт данных. Единственным источником истины остаётся таблица, а уведомление лишь даёт читателю сигнал заглянуть в неё. Пинг, которому не требуется строгий глобальный порядок и абсолютная надёжность сохранения, можно буферизовать в памяти и периодически сбрасывать пачкой в одной транзакции.

Это убирает глобальную блокировку с горячего пути. Она захватывается один раз на сброс буфера, а не на каждую запись в поток, так что отдельные записи фиксируются штатно, к ним снова применяется group commit, а сброс буфера происходит в фоновом режиме. Разница между 2.9K и 60K записей в секунду практически целиком обеспечена этим изменением. На новом пределе CPU у Postgres нагружен полностью - свидетельство того, что база данных действительно загружена работой, а не простаивает в ожидании блокировок.

Буферизация влечёт за собой очевидный риск сбоя: если процесс падает, когда уведомления ещё находятся в памяти, они никогда не будут доставлены. Запасной вариант - добавить читателям медленный polling в дополнение к блокирующему ожиданию, чтобы записанные без уведомления данные всё равно были прочитаны. Поскольку опрос нужен только для подстраховки потерянных уведомлений, а не для штатной доставки, интервал можно сделать достаточно большим, чтобы он никак не влиял на нагрузку.

Код бенчмарка опубликован в dbos-inc/dbos-postgres-benchmark.

Заметки

Такой сценарий сбоя полезно распознавать в postgresql в целом: жёсткий потолок пропускной способности при ненагруженных ресурсах означает конкуренцию за блокировки (contention), а не исчерпание ёмкости системы. Противоположный случай описан в linux-7-postgres-regression, где изменение в планировщике ядра позволяло вытеснить процесс прямо во время page fault'а при удержании спинлока Postgres, из-за чего симптом был обратным - CPU сгорал на 100%, пока пропускная способность обваливалась. И то, и другое - последствия работы блокировок, но на графике CPU заметно только одно из них.

В более широком смысле речь идёт о том, какую нагрузку Postgres способен выдержать до того, как придётся подключать отдельную вторую систему. Надёжный pub/sub и потоки с низкой задержкой - типичная причина ставить выделенный брокер рядом с базой данных; приведённые здесь цифры показывают, что один сервер Postgres закрывает заметную часть таких задач. Тот же аргумент "одного хранилища достаточно, если понимать его ручки настройки" в масштабах поменьше встречается в sqlite-in-production, а цена незнания внутреннего устройства движка видна в valkey-secret-life-of-data, где недокументированный порог кодирования меняет расход памяти на 77%.