MAATRIX / Блог / Сколько соединений к PostgreSQL до деградации: находим свою точку перелома

Сколько соединений к PostgreSQL до деградации: находим свою точку перелома

MAATRIX

Рано или поздно любой, кто настраивает PostgreSQL под нагрузку, ищет одно и то же число: сколько соединений сервер выдержит, прежде чем всё начнёт тормозить. И почти всегда натыкается на чужие цифры из форумов и статей — «мы держим 500», «у нас упало на 1200». Проблема в том, что это число не переносится с сервера на сервер: у PostgreSQL нет жёсткого порога, после которого он рушится, — есть постепенная деградация, которая начинается в разных точках в зависимости от вашей памяти, CPU, дисков и характера запросов. Разберём, из чего складывается цена одного соединения, почему увеличение max_connections часто делает только хуже, и как найти свою точку перелома, а не полагаться на чужую.

Из чего складывается цена одного соединения

PostgreSQL использует модель «процесс на соединение»: на каждое новое подключение сервер форкает отдельный backend-процесс (в отличие от MySQL и многих других СУБД, где чаще используются потоки). Это даёт надёжность — падение одного backend не роняет весь сервер, — но цена платится памятью и переключениями контекста.

У каждого backend-процесса есть базовый overhead: код процесса, локальные структуры, копии части разделяемой памяти через copy-on-write после fork. Точная цифра зависит от версии PostgreSQL, ОС, загруженных расширений и от того, сколько объектов схемы кэшируется в relcache/syscache — не берите чужие цифры, измерьте свою:

# базовая память до открытия дополнительных соединений
ps -o pid,rss,vsz,cmd -C postgres --sort=-rss | head -5

# открываем N holостых подключений (например, через pgbench -C или пул) и сравниваем RSS
ps -o rss= -C postgres | awk '{sum+=$1} END {print sum/1024" MB суммарно"}'

Разница между «до» и «после» N подключений, делённая на N, — это реальный базовый overhead на соединение на вашем сервере с вашей схемой.

Дальше — главный переменный компонент, work_mem. Это не память на соединение, а память на *операцию сортировки или хеширования* внутри одного запроса. Ключевой нюанс: сложный запрос с несколькими join, сортировками и агрегациями открывает несколько таких операций сразу, и каждая может выделить свой work_mem. План с четырьмя узлами sort/hash способен потребить примерно четыре work_mem одновременно, в рамках одного запроса на одном соединении. Проверить число таких узлов можно так:

EXPLAIN (ANALYZE, BUFFERS) SELECT ...;

и посмотреть, сколько операций Sort/Hash/HashAggregate встречается в плане — это множитель, на который умножается work_mem в худшем случае для одного запроса.

Третий компонент — maintenance_work_mem, который используется при VACUUM, CREATE INDEX, ALTER TABLE ... ADD FOREIGN KEY и в процессах автовакуума. Он не привязан к обычным клиентским соединениям напрямую, но конкурирует за ту же память, если автовакуум активен параллельно с пиковой нагрузкой.

Как max_connections привязан к памяти и CPU сервера

Формула теоретического пикового потребления памяти выглядит примерно так:

RAM_worst_case ≈ shared_buffers
               + max_connections × (overhead_backend + work_mem × N_операций_в_плане)
               + maintenance_work_mem × число_параллельных_vacuum/autovacuum_workers

Это *теоретический потолок*, а не то, что реально используется в среднем — большинство соединений простаивают (idle) или выполняют лёгкие запросы без сортировок. Но если пиковая нагрузка одновременно поднимает много тяжёлых аналитических запросов на большом max_connections, разрыв между «обычно» и «в худшем случае» может стать причиной внезапного OOM, когда всё вроде бы было настроено штатно. Разбор такого сценария — в статье PostgreSQL: Out of Memory — причины и решение.

С CPU связь другая. Каждое активное соединение — отдельный процесс, за которым следит планировщик ОС. При росте числа одновременно выполняющих запросы backend-процессов (именно *активных*, а не просто открытых idle-соединений) растёт число переключений контекста, конкуренция за CPU-кэш и, что часто недооценивают, за внутренние блокировки PostgreSQL — например, ProcArrayLock, который берётся при построении снапшота видимости транзакций и при коммитах. Чем больше активных backend'ов, тем дороже эта служебная работа, причём нелинейно: до определённой точки рост числа активных соединений почти не влияет на латентность запроса, а после — каждое новое соединение отбирает больше, чем даёт.

Понять, где вы находитесь относительно этой кривой, помогают штатные представления:

-- сколько соединений открыто и сколько из них реально что-то делают
SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count(*) DESC;

-- на что реально ждут активные backend'ы прямо сейчас
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY 3 DESC;

Если в wait_event_type регулярно всплывает Lock или LWLock с ростом конкурентности — вы уже видите конкуренцию за внутренние ресурсы, а не просто «сервер устал». Это отличается от ситуации, когда узкое место — диск (wait_event_type = IO) или сеть, и лечится разными способами.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему «просто накрутить max_connections» часто делает хуже

max_connections — это не регулятор производительности, а верхняя граница допустимого. Поднять её «на всякий случай» с расчётом, что PostgreSQL сам разберётся, — частая ошибка, и вот почему она обычно не помогает, а иногда и вредит:

  • Память резервируется под теоретический пик, а не под факт. Даже если реально активных соединений мало, ОС и мониторинг должны быть готовы, что max_connections может быть достигнут одновременно активными backend'ами — иначе вы просто откладываете OOM на момент пиковой нагрузки.
  • Больше соединений — больше конкуренции за одни и те же строки. Если узкое место — горячая таблица с частыми UPDATE, дополнительные соединения не ускорят обработку очереди, а лишь увеличат её.
  • Растёт стоимость служебной работы. С ростом числа активных backend-процессов растёт стоимость операций, учитывающих состояние всех соединений — например, построение снапшота для MVCC.
  • Проблема часто не в лимите, а в его симптоме. FATAL: too many connections обычно означает, что где-то не закрываются соединения (утечка пула в приложении) или что нет пулинга вообще, а не что серверу физически не хватает max_connections. Разбор этой ошибки — в статье PostgreSQL не принимает подключения: причины и решение.

Деградация в PostgreSQL почти никогда не выглядит как обрыв по вертикальной линии — это постепенно растущий p95/p99, который в какой-то момент начинает расти быстрее нагрузки. Это ускорение и есть точка перелома, а не момент, когда сервер падает целиком.

PgBouncer: как разорвать прямую связь между клиентами и backend-процессами

Раз каждое соединение к PostgreSQL — отдельный процесс с ощутимым overhead, естественное решение — не открывать backend-процесс на каждого клиента, а держать небольшой пул реальных соединений и раздавать их по очереди. Это делает PgBouncer — лёгкий connection pooler между приложением и PostgreSQL.

У PgBouncer три режима пулинга, и разница между ними принципиальна:

РежимКогда соединение возвращается в пулПлюсыОграничения
sessionПосле явного отключения клиентаПолная совместимость: работают prepared statements, LISTEN/NOTIFY, temp-таблицы, advisory locksЭкономии соединений почти нет — фактически 1:1 с клиентами
transactionПосле завершения каждой транзакцииМаксимальная экономия backend-соединений, подходит для веб-приложений с короткими транзакциямиЛомается всё, что привязано к сессии: SET на уровне сессии, PREPARE, temp-таблицы между транзакциями
statementПосле каждого отдельного запросаСамая агрессивная экономияНе поддерживает многошаговые транзакции — годится только для автокоммитных запросов

На практике для веб- и API-нагрузок чаще используют transaction-режим — он даёт основную экономию соединений. Но именно в нём чаще всего ловят неожиданные ошибки, если ORM полагается на server-side prepared statements или на состояние сессии между запросами. Разбор такой ситуации — в статье PgBouncer в режиме transaction сломал prepared-запросы.

Базовый конфиг pgbouncer.ini для старта:

[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
reserve_pool_size = 5

Ключевая идея: max_client_conn — сколько клиентов приложения подключается к PgBouncer (может быть большим, эти соединения дешёвые), а default_pool_size — сколько реальных backend-соединений к PostgreSQL держится на базу (должно укладываться в реальный max_connections с запасом на служебные подключения — мониторинг, репликацию). Именно это число вы подбираете нагрузочным тестом, а не переписываете из чужого конфига. Установка описана в статье Как установить и настроить PgBouncer на VPS.

Честно: PgBouncer не бесплатен — он сам потребляет CPU на проксирование, добавляет сетевой хоп и накладывает ограничения режима transaction. Для нагрузок с короткими транзакциями выигрыш обычно перевешивает издержки, но это тоже стоит проверить, а не принимать на веру.

Как построить свой нагрузочный тест и найти точку перелома

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

Базовый инструмент, который идёт в комплекте с PostgreSQL, — pgbench. Он не заменит тест с реальными запросами приложения, но хорошо подходит для первой прикидки и для проверки эффекта от изменения max_connections или включения пулинга:

# инициализация тестовой базы (масштаб влияет на размер данных)
pgbench -i -s 50 mydb

# сам тест: -c число клиентских соединений, -j воркеры pgbench, -T время в секундах
pgbench -c 20 -j 4 -T 60 -P 5 mydb
pgbench -c 50 -j 4 -T 60 -P 5 mydb
pgbench -c 100 -j 4 -T 60 -P 5 mydb
pgbench -c 200 -j 4 -T 60 -P 5 mydb

Ключевая методика — не один тест с фиксированной конкурентностью, а серия с растущим -c и график двух величин от числа соединений: транзакций в секунду (tps) и латентности, лучше не средней, а перцентилей (флаг --latency-limit). Для рабочей нагрузки вместо встроенного сценария используйте -f script.sql с реальными запросами приложения — синтетический SELECT-бенчмарк не отразит вашу реальную нагрузку на диск и блокировки.

Форма точки перелома обычно такая: tps растёт почти линейно с ростом -c, затем рост замедляется, tps выходит на плато или падает, а латентность в этот момент начинает расти уже не линейно, а резко. Момент, где tps перестаёт расти линейно, а латентность ускоряется — и есть точка деградации, а не момент отказа в соединении.

Снимайте показания не только с PostgreSQL, но и с ОС — деградация может упираться в память, CPU или диск, и это разные диагнозы:

# переключения контекста и очередь на CPU
vmstat 1

# утилизация дисков и очередь на I/O
iostat -x 1

# память: сколько реально свободно и сколько уходит в page cache
free -m

Если с ростом -c растёт wa (iowait) в vmstat — узкое место диск, и увеличение max_connections не поможет вообще. Если растёт cs (context switches) без пропорционального роста tps — вы упёрлись в CPU-конкуренцию между backend-процессами. Если free показывает исчерпание памяти и рост swap — вы в шаге от OOM, и это тот сценарий, который лучше поймать на тесте, а не на проде.

Тестируйте на окружении, близком к продакшену: тот же порядок объёма данных, та же версия PostgreSQL, те же индексы и autovacuum-настройки. Тест на пустой базе с игрушечным датасетом покажет совсем другую точку перелома, чем реальная база с фрагментированными индексами и активным автовакуумом на фоне.

Что делать с результатами: настройка под найденный предел

Когда точка перелома найдена, дальше — настройка конкретных параметров под неё, а не под общие советы из интернета.

Практический порядок действий:

  1. Зафиксируйте безопасный max_connections. Возьмите число активных соединений в момент, предшествующий точке перелома, добавьте разумный запас на непокрытые тестом пики и на служебные соединения — superuser_reserved_connections, мониторинг, репликацию.
  2. Пересчитайте work_mem под этот max_connections и вашу RAM. Ориентир, не формула на все случаи: доступная память делится не на max_connections, а на реалистичное число одновременно активных сложных запросов — иначе либо занизите work_mem и получите уход операций на диск (temp files, видно в pg_stat_database.temp_files), либо завысите и вернётесь к риску OOM.
  3. Разверните пулинг между приложением и базой, если ещё не сделали — это отдельный рычаг, снижающий реальное число backend-процессов независимо от числа клиентских соединений.
  4. Перепроверьте настройку тем же тестом. После изменений прогоните ту же серию — точка перелома должна сдвинуться дальше по оси конкурентности, и это единственное объективное подтверждение, что изменения помогли.
  5. Мониторьте pg_stat_activity и wait_event на проде постоянно, а не только во время теста — точка перелома, найденная сегодня, через полгода роста базы и трафика может сдвинуться.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Есть ли универсальное число max_connections, которое подходит большинству серверов?

Нет. Значение по умолчанию (100) — консервативный компромисс разработчиков PostgreSQL, а не рекомендация под вашу нагрузку. Правильное число зависит от RAM, характера запросов и того, используете ли вы пулинг.

Можно ли ориентироваться на формулы вида «(число ядер × 2) + число дисков» для размера пула?

Такая эвристика периодически встречается в обсуждениях пулинга и может быть отправной точкой для первого теста, но это не закон — она не учитывает характер запросов и профиль ввода-вывода. Используйте её только как стартовую точку, которую затем проверяете тестом.

PgBouncer в режиме transaction сломал часть запросов приложения — что делать?

Проверьте, использует ли ORM server-side prepared statements, SET на уровне сессии или temp-таблицы между запросами одной операции — это несовместимо с transaction-режимом. Решается настройками драйвера (отключение prepared statements) или переводом части трафика на session-режим через отдельный порт PgBouncer.

Если тест не показывает явного излома, а латентность растёт плавно — что считать точкой перелома?

Ориентируйтесь на бизнес-требование: SLA по латентности (например, p95 не выше определённого значения). Точка, где p95 пересекает этот порог, и есть практическая точка деградации.

Нужно ли повторять нагрузочный тест после каждого релиза?

Периодически — да, особенно после изменений схемы запросов, новых индексов или заметного роста объёма данных. Точка перелома не статична — она меняется вместе с нагрузкой.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →