PHP и узкое место «коннекты к базе»: пулеры, прокси и практика

Во многих стеках запросы уже не «убийцы», а прод всё равно падает в too many connections, remaining connection slots are reserved или подвисания сразу после деплоя. Частая причина — не медленный SQL, а арифметика соединений: модель запроса в PHP даёт всплески connect + auth + TLS, а у базы есть жёсткий потолок параллельных backend’ов. Пулеры и управляемые прокси как раз держат небольшой стабильный пул серверных сессий за большим числом короткоживущих клиентов в PHP.

Связанные материалы: БД под нагрузкой: запросы и масштабирование · Sail: базы и Docker-сервисы

Содержание


Почему PHP усугубляет проблему

Классический PHP-FPM обрабатывает запрос, ходит во внешние сервисы, отдаёт ответ и освобождает ресурсы запроса. Без persistent connections (и их компромиссов) каждый запрос к БД обычно открывает или забирает TCP-сессию, аутентифицируется, при необходимости TLS, затем выполняет запросы.

Под нагрузкой:

  • pm.max_children задаёт, сколько процессов PHP могут работать одновременно на машине. Если большинство запросов бьёт в БД, теоретически нужно до стольки же одновременных сессий на воркер-ноду.
  • Воркеры очередей (queue:work, Horizon) — долгоживущие процессы; каждый параллельный воркер часто держит одно или несколько открытых соединений на время джоба.
  • Горизонтальное масштабирование умножает всё: три ноды по восемьдесят детей — уже 240 потенциальных сессий, без cron, разовых CLI и прочего.

База видит шторм коннектов при деплое и скачках трафика. Даже если max_connections формально хватает, упираются память на backend (особенно Postgres) и CPU на аутентификацию.


Чем вы реально ограничены

  • max_connections у Postgres/MySQL — глобальный потолок; служебные и репликационные резервы уменьшают доступное приложению.
  • Память — у каждого backend есть буферы и состояние; «просто поднять лимит» может привести к OOM.
  • Задержка установки соединения — TLS, пароль, иногда LDAP добавляют миллисекунды и десятки миллисекунд на запрос при connect-per-request.
  • Thundering herd — после рестарта все PHP-процессы могут подключиться разом и забить accept/auth.

Практическое правило: считайте все программы, которые говорят с SQL (веб, воркеры, cron, админки, BI), а не только HTTP.


Пулеры и прокси «посередине»

Пулер стоит между PHP и СУБД. PHP открывает относительно дёшевое соединение к пулеру; пулер держит меньший пул настоящих соединений к Postgres/MySQL и переиспользует их между множеством клиентов.

Плюсы:

  • Меньше серверных backend’ов и RAM на инстансе БД.
  • Мультиплексирование: многие простаивающие PHP-клиенты не закрепляют за собой idle-сессию на сервере.
  • Более ровное поведение при скачках нагрузки.

Минусы:

  • Ещё один хоп (латентность, зона отказа, безопасность и мониторинг).
  • Семантика сессии меняется в зависимости от режима пулинга (см. PgBouncer).
  • Сам пулер нужно размерить, иначе он станет новым узким местом (CPU, FD, голод пула).

PostgreSQL: PgBouncer на практике

PgBouncer — фактический стандарт пулинга для Postgres в PHP-стеках.

Режимы:

Режим Поведение PHP / Laravel
Session Один серверный коннект на всю клиентскую сессию до disconnect Максимальная совместимость: SET, LISTEN, advisory locks, временные таблицы, prepared statements как при прямом подключении. Меньший выигрыш при долгих клиентах (воркеры) или connect-per-request.
Transaction Серверное соединение возвращается в пул после транзакции (COMMIT/ROLLBACK) Сильное мультиплексирование для коротких веб-запросов. Ломает сессионные вещи между транзакциями, LISTEN, долгоживущие temp tables; именованные prepared statements часто требуют настройки драйвера.
Statement Возврат после каждого оператора Для ORM почти не подходит; ломает многооператорные транзакции.

Prepared statements и transaction pooling: драйверы часто готовят запросы по имени в сессии. Если физическое соединение к серверу между запросами меняется, именованные prepare ломаются. В проде используют безымянные prepare, simple query или эмуляцию prepare на клиенте (PDO::ATTR_EMULATE_PREPARES и аналоги — по документации драйвера).

Имеет смысл передавать application_name в параметрах подключения — проще разбирать pg_stat_activity.


MySQL и MariaDB: ProxySQL и аналоги

ProxySQL — популярный прокси по протоколу MySQL: маршрутизация, правила к запросам, разделение read/write и пул соединений с настраиваемым мультиплексированием.

Типичные задачи:

  • Ограничить backend-коннекты, пока к ProxySQL подключаются сотни детей FPM.
  • Направлять чтение на реплики по правилам (всё равно следите за лагом репликации).
  • Аккуратно переписывать или фильтровать классы запросов — помните, что логика в прокси — это операционная сложность.

MySQL Router и часть облачных балансировщиков ведут себя иначе — не всё, что слушает 3306, даёт тот же мультиплексинг, что ProxySQL; читайте документацию.

MariaDB MaxScale — маршрутизация и управление соединениями в зависимости от редакции и модулей; проверяйте лицензирование.


Облачные прокси (RDS Proxy и др.)

У облаков есть управляемые прокси перед RDS, Aurora, Cloud SQL и т.д. Обычно они дают пулинг, интеграцию с IAM/токенами и более мягкое поведение при failover.

Семантика СУБД не отменяется: при агрессивном мультиплексировании те же ограничения, что у self-hosted PgBouncer, в том числе по prepared statements и состоянию сессии — смотрите матрицу совместимости для вашего движка и драйвера.

Другие пулеры: PgCat, Odyssey, pgpool-II

  • PgCat и Odyssey — пулеры для Postgres с растущим использованием; сравните режимы пула, метрики и особенности драйверов с PgBouncer перед сменой инструмента.
  • pgpool-II — часто ставят ради репликации и маршрутизации не меньше, чем ради пулинга; тяжелее в эксплуатации, если нужен только мультиплексинг.

Заметки про Laravel

  • config/database.phpconnections.*.options и флаги драйвера: подогнать PDO под пулер (например эмуляция prepare).
  • Read/write — Laravel может слать SELECT на read; вместе с пулером проверьте sticky и ожидания по лагy реплик.
  • Octane / Swoole / FrankenPHP — долгоживущие воркеры: persistent connections могут быть удачны, но нельзя протекать состоянием транзакции между запросами; следите за idle timeout на сервере и пулере.
  • Horizon / queue:workconcurrency × workers даёт постоянную нагрузку на пул; либо пул на воркер, либо transaction mode с совместимыми настройками.
  • Telescope, debug-бары в проде держат транзакции дольше, чем кажется — оставьте их вне прода.

Пример: в .env хост указывает на пулер, не на прямой VIP Postgres:

DB_HOST=pgbouncer.internal
DB_PORT=6432
DB_DATABASE=app
DB_USERNAME=app_rw

default_pool_size, reserve_pool (PgBouncer) или лимиты ProxySQL нужно вязать с реальной параллельностью запросов, а не с числом PHP-процессов.


Что пулеры не лечат

  • N+1 и отсутствие индексов по-прежнему жгут CPU и I/O — пулер только ограничивает число сессий, через которые это выражается.
  • Длинные транзакции удерживают backend из пула — при transaction pooling выигрыш исчезает, если код держит транзакцию открытой на время внешнего HTTP.
  • Миграции и DDL — прогон migrate через перегруженный пулер может конфликтовать с блокировками; часто выделяют прямой админский путь к БД.

Чеклист

  1. Инвентаризация всех типов процессов с SQL (FPM max_children × ноды, воркеры Horizon, cron, CLI).
  2. Сравнить с max_connections и памятью на соединение — решить: пулер или сначала дисциплина в приложении.
  3. Выбрать режим пула (Postgres) или правила мультиплексирования (MySQL) под ORM и драйвер.
  4. Прогнать нагрузочный тест с prepared statements и нужными сессионными фичами (SET, temp tables, advisory locks).
  5. Мониторить очередь на пулер и активные коннекты на СУБД — если очередь растёт, узкое место всё ещё база или запросы.

Пулер «посередине» — это инфраструктура, которую вы эксплуатируете (или покупаете). При грамотной настройке он превращает «PHP открыл восемьсот соединений» в «Postgres видит шестьдесят занятых backend’ов» — ту форму OLTP, с которой СУБД умеет работать предсказуемо.