PgBouncer упирается в одно ядро — ClickHouse показал, как это обойти

1 июля 2026 года команда ClickHouse опубликовала разбор давней проблемы PgBouncer — самого популярного пулера соединений для PostgreSQL: он однопоточный. Неважно, сколько ядер на сервере — один процесс PgBouncer использует ровно одно из них, и именно пулер, а не сама Postgres, чаще всего оказывается узким местом под нагрузкой.

В чём проблема на практике

На 16-ядерном сервере пятнадцать ядер простаивают, пока одно тянет весь пул соединений. ClickHouse на реальном железе (AWS c7i.4xlarge, 16 vCPU) показал это наглядно: с ростом числа клиентов с 8 до 256 throughput единственного процесса PgBouncer сначала растёт до ~87 000 транзакций в секунду, а затем падает до ~77 000 — все клиенты конкурируют за одно и то же ядро, которое утилизируется на 97%, при этом сервер в целом загружен меньше чем на 10%.

Решение — флот процессов на SO_REUSEPORT

Вместо одного процесса ClickHouse запускает несколько процессов PgBouncer, привязанных к одному и тому же порту через SO_REUSEPORT — балансировку между ними берёт на себя ядро Linux, а клиенты по-прежнему подключаются к одному эндпоинту, не зная о флоте за ним. Это официально задокументированный способ задействовать больше одного ядра для PgBouncer — просто мало кто его настраивает вручную.

Ловушка: запрос на отмену запроса (cancel) в Postgres приходит по новому соединению с отдельным ключом — и ядро может отправить его на другой процесс флота, который вообще не знает об этом запросе. Отмена просто теряется. Решение — peering: процессы флота знают друг о друге и пересылают cancel-запрос тому процессу, который реально держит нужную сессию.

Бюджет соединений (max_client_conn, max_db_connections) при этом делится между процессами флота, чтобы суммарно не превысить лимиты самой Postgres.

Результат на бенчмарках

Клиентов 1 процесс, TPS Флот из 16, TPS
8 8 910 6 450
32 54 203 64 244
64 86 570 219 439
128 83 463 320 547
256 76 893 336 469

При малом числе клиентов один процесс даже немного быстрее — делить нечего, а соединения флота распределяются тонким слоем. Разрыв открывается ровно там, где это важно — под реальной конкурентной нагрузкой, где один процесс упирается в потолок одного ядра, а флот продолжает расти почти в 4 раза быстрее.

Как повторить это на своём self-hosted сервере

В отличие от managed-решения ClickHouse, у тебя на VPS это придётся собрать руками — но принцип тот же самый.

1. Несколько инстансов PgBouncer с SO_REUSEPORT

Начиная с PgBouncer 1.19+ поддержка SO_REUSEPORT встроена. В pgbouncer.ini каждого процесса:

listen_addr = 0.0.0.0
listen_port = 6432
so_reuseport = 1

Все процессы слушают один и тот же порт — ядро само распределяет входящие соединения.

2. Peering между процессами

Для корректной обработки cancel-запросов процессы должны знать друг о друге — актуальные версии PgBouncer поддерживают конфигурацию peers:

[peers]
1 = host=127.0.0.1 port=6433
2 = host=127.0.0.1 port=6434

3. Запуск флота через systemd template unit

# /etc/systemd/system/pgbouncer@.service
[Unit]
Description=PgBouncer instance %i
After=network.target

[Service]
User=postgres
ExecStart=/usr/bin/pgbouncer /etc/pgbouncer/pgbouncer-%i.ini
Restart=on-failure

[Install]
WantedBy=multi-user.target
for i in $(seq 1 $(nproc)); do
  systemctl enable --now pgbouncer@$i
done

4. Деление бюджета соединений

Если раньше max_client_conn был, скажем, 1000 на единственный процесс — при флоте из 8 процессов раздели его на восемь (~125 на процесс), чтобы суммарный лимит остался прежним и Postgres не получила больше соединений, чем рассчитана.

Почему это важно для малого/среднего self-hosted проекта

Для типичного VPS с 4-8 ядрами, где крутится Postgres под нагрузкой (интернет-магазин, CRM, аналитика), единственный процесс PgBouncer часто остаётся узким местом ещё до того, как исчерпаны реальные возможности сервера — деньги за неиспользуемые ядра, по сути, платятся впустую. Флот из нескольких процессов на SO_REUSEPORT — бесплатное увеличение пропускной способности без апгрейда самого сервера.