DBOS разогнала LISTEN/NOTIFY в Postgres до 60 тысяч записей в секунду

Механизм LISTEN/NOTIFY в Postgres давно считается плохо масштабируемым из-за известного блог-поста на эту тему. В DBOS показали, что дело не в самом механизме, а в неочевидном способе его использования, и объяснили, как выжать из него высокую пропускную способность.
LISTEN/NOTIFY используют для потоковой передачи данных из базы: например, при построении стримов, где каждая новая порция данных (скажем, очередной токен ответа языковой модели), это новая строка в таблице. Читатели не хотят опрашивать таблицу поллингом (это либо повышает задержку, либо перегружает базу частыми запросами), поэтому они подписываются через LISTEN и мгновенно просыпаются на NOTIFY о новой записи.
В первой реализации DBOS триггер на таблице стримов вызывал NOTIFY при каждой вставке строки. Схема работала корректно и давала низкую задержку, но упиралась в потолок 2,9 тысячи записей в секунду, и это несмотря на то, что видимой нагрузки на CPU, память или диск не было. Причина оказалась в глобальной эксклюзивной блокировке, которую Postgres берёт при коммите транзакции, вызывающей NOTIFY. Блокировка нужна, чтобы гарантировать доставку уведомлений строго в порядке коммитов транзакций: уведомления складываются в общую внутреннюю очередь, а порядок коммитов становится известен только когда коммит уже завершён. Чтобы разрешить это противоречие, Postgres сериализует коммиты транзакций с NOTIFY через глобальную блокировку, и тем самым лишает их обычной оптимизации group commit (объединения нескольких коммитов в один вызов fsync). Поэтому запись стримов упиралась не в ресурсы сервера, а в скорость последовательных коммитов.
В статье также упоминают патч, который войдёт в Postgres 19: он не убирает глобальную блокировку и не решает описанную проблему, а лишь оптимизирует более узкий случай, когда каналов уведомлений много и каждый читатель слушает только свой канал.
Решение DBOS, не вызывать NOTIFY при каждой записи, а буферизовать уведомления в памяти и периодически сбрасывать их одной пакетной транзакцией. Тогда глобальная блокировка берётся только на момент сброса буфера, а сами вставки в таблицу стримов идут быстро и пользуются group commit. Минус подхода, при падении процесса буферизованные, но ещё не сброшенные уведомления теряются безвозвратно. Чтобы это не приводило к потере данных для читателя, добавили страховку: читатели помимо ожидания уведомлений периодически (и редко, чтобы не создавать лишнюю нагрузку) опрашивают таблицу, на случай, если запись прошла, а уведомление не дошло.
В результате при параллельных читателях система выдерживает до 60 тысяч записей стрима в секунду, в 20 раз больше, чем в исходной схеме, при задержке 15, 100 мс. На пиковой нагрузке процессор Postgres загружен полностью, то есть система упирается в реальные ресурсы сервера, а не в блокировку. Код бенчмарка опубликован на GitHub (dbos-inc/dbos-postgres-benchmark).
Ключевые факты
- Наивная реализация (NOTIFY из триггера на каждую вставку) упиралась в потолок 2,9 тысячи записей в секунду, без видимой нагрузки на CPU, память или диск.
- Причина, глобальная эксклюзивная блокировка, которую Postgres берёт при коммите транзакции с NOTIFY, чтобы гарантировать порядок доставки уведомлений; она сериализует коммиты и отключает оптимизацию group commit.
- Патч, который войдёт в Postgres 19, эту проблему не решает, он оптимизирует только узкий случай с множеством каналов и читателями, слушающими один конкретный канал.
- Решение DBOS: буферизовать уведомления в памяти и сбрасывать пакетом по таймеру, плюс редкий фоновый поллинг как страховка на случай потери уведомлений при падении процесса.
- Результат, до 60 тысяч записей в секунду (в 20 раз больше исходного) при задержке 15, 100 мс, с полной загрузкой CPU; код бенчмарка выложен на GitHub.
Почему это важно
Пост опровергает устоявшийся миф о том, что LISTEN/NOTIFY в Postgres «не масштабируется», и показывает конкретный путь обхода узкого места. Это снимает с Postgres репутационный барьер как со среды для стримов и pub/sub-нотификаций, задач, для которых команды часто по умолчанию тянут отдельный брокер сообщений (Kafka, Redis) вместо того, чтобы использовать уже имеющуюся базу.
Кому это важно
Инженерам, которые проектируют бэкенды с потоковой доставкой данных в реальном времени, стриминг токенов языковой модели в чат, live-уведомления, очереди событий, и рассматривают Postgres как основу для этого вместо отдельной инфраструктуры сообщений. Полезно и разработчикам самого Postgres, поскольку статья детально разбирает механику блокировки при NOTIFY и то, почему готовящийся в Postgres 19 патч не закрывает эту проблему.
Как это применить
Вместо вызова NOTIFY на каждую запись (например, из триггера), буферизовать уведомления в памяти и сбрасывать их одной пакетной транзакцией по таймеру: так глобальная блокировка берётся редко, а сами вставки в таблицу идут с group commit. Чтобы не терять уведомления при падении процесса до сброса буфера, читателям нужен резервный редкий поллинг таблицы. Полный код и методика бенчмарка выложены на GitHub в репозитории dbos-inc/dbos-postgres-benchmark.
Можно ли доверять
Материал, официальный технический блог компании DBOS, которая строит продукт поверх durable execution на Postgres и напрямую заинтересована в результатах теста. При этом объяснение механики блокировки соответствует документированному устройству Postgres, а код бенчмарка опубликован в открытом доступе для проверки. Независимого стороннего воспроизведения цифр в самом посте нет, это собственный тест DBOS на собственном стенде.
Риски и подводные камни
Буферизация уведомлений, компромисс: она жертвует частью мгновенности доставки (задержка выросла до 15, 100 мс) ради пропускной способности, а при сбое процесса до сброса буфера часть уведомлений теряется, эту дыру закрывает только резервный поллинг, а не сама схема буферизации. Цифра в 60 тысяч записей в секунду получена на конкретном железе и конфигурации DBOS и не гарантированно повторится один в один на другом стенде или в другой версии Postgres.
«При параллельных читателях мы можем выполнять до 60 тысяч записей стрима в секунду, в 20 раз больше, чем раньше, сохраняя при этом задержку 15, 100 мс.»
— блог DBOS