Postgres LISTEN/NOTIFY часто ругают за плохую масштабируемость, и виной тому — нашумевший блог-пост, где утверждалось, что механизм не тянет высокие нагрузки. На деле всё хитрее: производительность упирается в глобальную блокировку, о которой почти не пишут в документации. В DBOS разобрались с этим и заставили стримы на базе LISTEN/NOTIFY выдавать 60 тысяч записей в секунду на одном сервере Postgres с задержкой в миллисекундах.
Базовая схема проста: под каждым потоком лежит таблица, куда новые чанки (например, токены ответа LLM) вставляются строками. Чтение осложняется непредсказуемостью появления данных. Опрос таблицы даёт либо большую задержку при редких проверках, либо перегружает базу при частых. LISTEN/NOTIFY решает это изящно: читатель блокируется и ждёт уведомления от писателя, моментально просыпаясь при появлении чанка.
Первая реализация вешала триггер на таблицу, который дёргал NOTIFY при каждой вставке. Латенси оказалась низкой, а вот пропускная способность — всего 2.9K записей в секунду, причём ни CPU, ни память, ни дисковые операции не были загружены. Узким горлом стала глобальная эксклюзивная блокировка, которую Postgres берёт при коммите транзакции с NOTIFY и держит весь коммит до сброса на диск через fsync(). Это гарантирует порядок уведомлений в соответствии с очерёдностью коммитов, но сериализует все такие транзакции и ломает групповой коммит (group commit).
Разработчики DBOS учли, что для стримов уведомление — не источник истины, а лишь сигнал «проверь таблицу». Значит, идеальный порядок и абсолютная долговечность ни к чему. Решение: буферизовать NOTIFY в памяти и периодически отправлять их пачкой в одной транзакции. Тогда глобальная блокировка захватывается только при сбросе буфера, а основная масса записей проходит быстро и может группироваться. Чтобы не терять уведомления при падении процесса, читатели добавили fallback-механизм: помимо ожидания сигнала они изредка опрашивают базу. Частота опроса низкая, потому что это лишь страховка, и на производительности практически не сказывается.
После оптимизации throughput взлетел до 60K записей в секунду — рост в 20 раз, пиковая задержка укладывается в 15–100 мс. При этом CPU Postgres загружен полностью, а не простаивает в ожидании лока. Попутно стоит отметить, что готовящийся для Postgres 19 патч проблему глобальной блокировки не убирает: он затачивается под случай, когда много каналов и каждый слушатель ждёт только свой. Код бенчмарков выложен на GitHub — github.com/dbos-inc/dbos-postgres-benchmark.