Пул соединений
Прикладной код пула не видит: репозиторий сам берёт соединение и сам его отдаёт. Эта страница — про то, что происходит под ним, и про три решения, которые всё-таки принимает человек: сколько соединений, что делать при сбое и как понять, что пул забит.
Как он устроен
Главное, что даёт PPA. Устроен по образцу HikariCP из мира Java: смысл не в переиспользовании как таковом, а в том, что соединения поддерживаются работоспособными.
воркер
└── пул (на класс конфигурации)
├── соединение 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:
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 |
0 (выкл.) |
Фоновая проверка простаивающих соединений |
$idleTimeout |
0 (никогда) |
Закрывать простаивающие дольше N секунд |
$minimumIdle |
0 (лениво) |
Сколько соединений держать наготове |
Трейт даёт значение по умолчанию каждому параметру, поэтому конфигурация объявляет только то, что меняет, и не ломается, когда в интерфейс добавляется новый.
Последние три включают фоновый обслуживающий таймер и работают только под Swoole. При нуле таймер не заводится вовсе.
Считайте потолок вместе с воркерами
Лимит — на воркер, а не на приложение. Сервер должен выдержать
число воркеров × poolMaxConnections × число конфигураций.
Восемь воркеров с пулом на 10 — это 80 соединений к базе от одного контейнера. При
max_connections = 100 на PostgreSQL второй такой контейнер уже не поднимется.
Посмотреть, что происходит
php call db poolMainMainDbConfig
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 проверке, которая решает, слать ли ему трафик.
Приложение без базы не платит ничего
Публикация запускается не при старте воркера, а при создании первого пула. Приложение, которое к базе не обращается, не заводит таймер, не пишет записей и не создаёт каталог хранилища.
Справочник PpaConnectionPool
Статический фасад. В прикладном коде обычно не нужен — им пользуются репозитории.
| Метод | Что делает |
|---|---|
db($configClass) |
соединение текущей единицы работы: из пула под Swoole, единственное — без него |
getConfigDb($configClass) |
регистрационный экземпляр конфигурации — читать настройки, не подключаться |
showDbConfigs() |
все зарегистрированные конфигурации, для health-проверок |
stats() |
занятость каждого пула: total, idle, active, maximum |
reportFailure($configClass, $error) |
сообщить о сбое на выданном соединении |
shutdown() |
закрыть всё, что процесс открыл, — при завершении воркера |
reset() |
забыть всё не закрывая — в потомке после fork() |
Разница между двумя последними существенная. shutdown() закрывает сокеты и снимает
таймер обслуживания: живой Timer::tick не даст реактору воркера завершиться. reset()
вызывают в потомке после форка — там сокеты нельзя закрывать, потому что дескрипторы
общие с родителем и закрытие оборвало бы его соединение.
Ядро вызывает это само
Форк-сброс, закрытие при остановке воркера, логгер пула, провайдер часового пояса и
хранилище телеметрии подключаются при старте — в Kernel::init() и на событиях воркера.
Пакет ничего из этого не берёт сам: он не тянется к глобальным объектам фреймворка, и
именно поэтому его можно использовать (и тестировать) без ядра.
Без Swoole
Процесс обслуживает одну единицу работы за раз, распределять нечего: на конфигурацию заводится одно самоподдерживающееся соединение на весь процесс. Проверки живости и срок жизни у него те же, поэтому долгоживущий CLI-воркер не просыпается с сокетом, который сервер закрыл несколько часов назад. Прикладной код одинаков в обоих случаях.
Дальше
- PHP Persistence API — обзор слоя и примеры запросов
- Конфигурация БД — где задаются учётные данные и драйвер
- Репозитории — то, ради чего соединение берут