База данных · Пул

Пул соединений

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

Пакет flytachi/winter-ppaПо умолчанию 5 соединений на воркерОбразец HikariCP

Что такое пул соединений и зачем

Проблема. Соединение с базой — дорогая вещь: рукопожатие TCP, шифрование, аутентификация, а у PostgreSQL ещё и отдельный процесс на стороне сервера. В классическом PHP за это платили на каждом запросе, потому что процесс всё равно умирал следом.

Резидентный воркер живёт неделями — и здесь появляется обратная беда. Соединение, открытое однажды, не остаётся исправным само по себе: база перезапустилась, файрвол выбросил простоявший сокет, сервер закрыл его по своему таймауту. Воркер об этом не узнает и продолжит считать сокет рабочим — до первого запроса, который упадёт.

И третье: если открывать соединение под каждый входящий запрос, их число ничем не ограничено. Тысяча одновременных запросов — тысяча попыток подключиться, а в ответ too many connections, от которого падает всё приложение, а не только всплеск.

Решение. Набор заранее открытых соединений, которые выдаются корутинам на время работы и возвращаются сами. Пул проверяет их перед выдачей, заменяет умершие, обновляет состарившиеся и не даёт открыть больше, чем разрешено. Прикладной код при этом не меняется вовсе: соединение берёт репозиторий.

Как он устроен

Главное, что даёт PPA. Устроен по образцу HikariCP из мира Java: смысл не в переиспользовании как таковом, а в том, что соединения поддерживаются работоспособными.

text
воркер
└── пул (на класс конфигурации)
    ├── соединение 1  ← корутина A взяла на время запроса
    ├── соединение 2  ← корутина B
    └── соединение 3    свободно

Корутина берёт соединение при первом обращении к базе и возвращает автоматически, когда завершается. Освобождать вручную не нужно нигде — возврат навешивается defer.

Правила, применяемые при каждой выдаче

Проверка только простоявших. Соединение, пролежавшее без дела дольше 500 мс, перед выдачей проверяется; мёртвое — выбрасывается и заменяется свежим. Соединение, которым только что пользовались, не проверяется вообще.

Это осознанный компромисс: SELECT 1 перед каждым запросом стоил бы лишнего похода на сервер каждый раз. Проверяется только то, что действительно простаивало.

Ротация по возрасту. Соединение старше 30 минут заменяется заранее, до того как его закроет сервер или файрвол. Момент замены слегка разбрасывается во времени, чтобы весь пул не обновился разом.

Ограниченное ожидание. poolWaitTimeout — это бюджет всей выдачи, а не одного ожидания: из него оплачивается и ожидание свободного соединения, и выбрасывание мёртвых, и открытие замены. Кончился — запрос падает с PpaPoolException. Лучше быстрый понятный отказ, чем зависший навсегда запрос.

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

Что происходит при сбое

Пул различает две принципиально разные причины ошибки:

Причина Как определяется Что делает пул
Соединение умерло SQLSTATE класса 08, коды PostgreSQL 57P01/02/03, MySQL 2006/2013/2055 Выбрасывает соединение — следующий запрос получит новое
Запрос отвергнут Нарушение ограничения (23xxx), синтаксис (42xxx), взаимная блокировка Ничего: сервер здоров, соединение исправно

С PostgreSQL пришлось повозиться: PDO не сообщает о потере соединения кодом 08006. Когда сокета уже нет, брать SQLSTATE неоткуда, и ошибка приходит как HY000 с общим кодом libpq 7 — тем же, что у обычной синтаксической ошибки. Если вердикт драйвера настолько неинформативен, пул проверяет соединение и решает по ответу.

Упавший запрос не повторяется

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

Один запрос завершается ошибкой, соединение уходит в утиль. Решение о повторе — ваше, на уровне бизнес-логики.

Настройка

Пул есть у любой конфигурации базы — по умолчанию на 5 соединений. Чтобы задать свои значения, реализуйте PpaPoolConfigInterface через PpaPoolTrait:

main/MainDbConfig.php
use Flytachi\Winter\Cdo\Config\PgDbConfig;
use Flytachi\Winter\Ppa\Pool\{PpaPoolConfigInterface, PpaPoolTrait};

class MainDbConfig extends PgDbConfig implements PpaPoolConfigInterface
{
  use PpaPoolTrait;

  public int   $poolMaxConnections = 10;
  public float $poolWaitTimeout    = 5.0;

  public function setUp(): void
  {
      $this->host     = env('DB_HOST', 'localhost');
      $this->database = env('DB_NAME', 'app');
      $this->username = env('DB_USER', 'postgres');
      $this->password = env('DB_PASS', '');
  }
}
Свойство По умолчанию Что задаёт
$poolMaxConnections 5 Потолок соединений на конфигурацию
$poolWaitTimeout 3.0 Дедлайн всей выдачи: ожидание, разбор мёртвых и переоткрытие
$keepaliveTime 120.0 Фоновая проверка простаивающих соединений (0 — выкл.)
$idleTimeout 600.0 Закрывать простаивающие дольше N секунд (0 — никогда)
$minimumIdle 0 (лениво) Сколько соединений держать наготове

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

Последние три работают только под Swoole и управляют фоновым обслуживающим таймером; при нулях он не заводится вовсе. По умолчанию он включён, и не случайно: пул живёт в памяти воркера и осматривается, только когда из него берут соединение. Приложение между всплесками держало бы свои сокеты бесконечно и не заметило, как сервер или файрвол их порвали, — а платил бы за это первый вернувшийся запрос, разбирая мёртвые соединения и переоткрывая. Раз в две минуты простаивающие пингуются, через десять минут простоя отдаются базе.

Числа берут ordering-правило HikariCP — keepaliveTime < idleTimeout < maxLifetime, и пул сам обнулит keepaliveTime, до которого соединение не доживает. minimumIdle намеренно остаётся ленивым: тёплый пол здесь умножается на число воркеров, в отличие от пула, который живёт один на JVM. Подробнее — в политике пула.

Считайте потолок вместе с воркерами

Лимит — на воркер, а не на приложение. Сервер должен выдержать число воркеров × poolMaxConnections × число конфигураций.

Восемь воркеров с пулом на 10 — это 80 соединений к базе от одного контейнера. При max_connections = 100 на PostgreSQL второй такой контейнер уже не поднимется.

Посмотреть, что происходит

bash
php call db pool
text
MainMainDbConfig
active 12 · idle 3 · total 15 · maximum 20 · workers 2
saturated  1 of 2 workers                    [SATURATED]
per worker
worker#0  MainMainDbConfig  active=2  idle=3 total=5  max=10  age=0s
worker#1  MainMainDbConfig  active=10 idle=0 total=10 max=10  age=3s

Смотреть нужно на строки воркеров, а не на общий итог: запрос встаёт в очередь к пулу своего воркера, поэтому один забитый воркер — это реальные задержки, даже когда суммарно места полно.

Консоль — отдельный процесс и в память работающего сервера заглянуть не может, поэтому воркеры сами публикуют статистику по таймеру. Интервал задаётся переменной PPA_POOL_TELEMETRY (секунды, по умолчанию 5, 0 выключает). Те же цифры отдаёт эндпоинт /actuator/pools, если включён актуатор.

Загрузка пула намеренно не входит в /actuator/health: доступность базы и заполненность пула — разные вопросы. Занятый, но исправно работающий сервис не должен отвечать degraded проверке, которая решает, слать ли ему трафик.

Приложение без базы не платит ничего

Публикация запускается не при старте воркера, а при создании первого пула. Приложение, которое к базе не обращается, не заводит таймер, не пишет записей и не создаёт каталог хранилища.

Без Swoole

Процесс обслуживает одну единицу работы за раз, распределять нечего: на конфигурацию заводится одно самоподдерживающееся соединение на весь процесс. Проверки живости и срок жизни у него те же, поэтому долгоживущий CLI-воркер не просыпается с сокетом, который сервер закрыл несколько часов назад. Прикладной код одинаков в обоих случаях.


Справочник PpaConnectionPool

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

Обращение статическое, без внедрения зависимостей: пул хранит состояние процесса, и второго экземпляра у него быть не может.

db()

Возвращает соединение текущей единицы работы.

Под Swoole берёт соединение из пула при первом обращении в корутине, запоминает его в контексте корутины и вешает defer, который вернёт соединение в пул, когда корутина завершится. Все запросы одной корутины поэтому идут по одному соединению — это то, на чём держатся транзакции.

Без Swoole пула нет вовсе: метод отдаёт единственное соединение процесса.

Синтаксис

php
public static function db(string $configClass): CDO

Параметры

$configClass — класс конфигурации базы, MainDbConfig::class.

Возвращает

CDO — соединение, уже привязанное к текущей корутине. Отпускать его вручную не нужно.

Ошибки

PpaPoolException — если за poolWaitTimeout выдача так и не дошла до живого соединения. Причина в тексте разная и её стоит читать: все соединения заняты; открыть новое не удалось (тогда ошибка драйвера внутри); окно ушло на мёртвые соединения — сервер доступен, но не обслуживает.

getConfigDb()

Возвращает регистрационный экземпляр конфигурации.

Нужен, чтобы прочитать настройки, не открывая соединения: драйвер, схему, адрес. Экземпляр создаётся один раз на класс и переиспользуется — именно на нём вызван setUp().

Синтаксис

php
public static function getConfigDb(string $configClass): DbConfigInterface

Параметры

$configClass — класс конфигурации.

Возвращает

Экземпляр конфигурации. Соединение при этом не открывается.

showDbConfigs()

Возвращает все конфигурации, зарегистрированные в процессе.

Это дверь для health-проверок и диагностики: обойти список и опросить каждую базу pingDetail(), не зная заранее, сколько их в проекте.

Синтаксис

php
public static function showDbConfigs(): array

Возвращает

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

stats()

Возвращает занятость каждого пула.

Те же числа, что печатает call db pool и отдаёт /actuator/pools. Считаются для текущего воркера: у каждого воркера свой пул, и запрос видит тот, который его обслужил.

Синтаксис

php
public static function stats(): array

Возвращает

Массив, где ключ — класс конфигурации, а значение — четыре числа: total (открыто всего), idle (свободно), active (выдано) и maximum (потолок).

Без Swoole массив пуст: пулов там нет.

reportFailure()

Сообщает пулу о сбое, случившемся на выданном соединении.

Метод решает, виновато ли соединение. Разрыв связи — соединение выбрасывается, и следующий запрос получит новое. Нарушение ограничения или синтаксическая ошибка — соединение исправно, и его никто не трогает.

Вызывают его репозитории; писать это в прикладном коде обычно не приходится.

Синтаксис

php
public static function reportFailure(string $configClass, Throwable $error): bool

Параметры

$configClass — класс конфигурации, на соединении которой произошёл сбой.

$error — исключение так, как его бросил CDO или PDO.

Возвращает

true, если ошибка классифицирована как потеря соединения и соединение выброшено; false, если сервер здоров и виноват сам запрос.

shutdown()

Закрывает все пулы и соединения, которыми владеет процесс.

Вызывается при остановке воркера. Кроме сокетов снимает таймер обслуживания: живой Timer::tick держит ссылку на пул и не даёт реактору воркера завершиться.

Синтаксис

php
public static function shutdown(): void

reset()

Забывает все пулы и соединения, не закрывая их.

Вызывается в дочернем процессе сразу после fork(). Разница с shutdown() принципиальная: форк копирует файловые дескрипторы, поэтому сокет, унаследованный от родителя, физически тот же самый — закрыв его в потомке, вы оборвёте соединение родителя. Потомок обязан о них просто забыть и открыть свои заново.

Синтаксис

php
public static function reset(): void

`shutdown()` и `reset()` не взаимозаменяемы

Перепутать их — значит либо оборвать соединения родительского процесса (shutdown() в потомке), либо оставить воркер, который не может завершиться, потому что его таймер жив (reset() при остановке).

В приложении на Winter вызывать их вручную не нужно: ядро подключает и то, и другое само.

Ядро вызывает это само

Форк-сброс, закрытие при остановке воркера, логгер пула, провайдер часового пояса и хранилище телеметрии подключаются при старте — в Kernel::init() и на событиях воркера. Пакет ничего из этого не берёт сам: он не тянется к глобальным объектам фреймворка, и именно поэтому его можно использовать (и тестировать) без ядра.

Дальше