PDO Pool: пул соединений с базой данных

Проблема

При работе с корутинами возникает проблема шаринга дескрипторов ввода вывода. Если один и тот же сокет будет в двух корутинах, которые одновременно станут писать или читать из него разные пакеты, то данные перемешаются, и результат будет непредсказуемым. Поэтому невозможно просто так использовать один и тот же объект PDO в разных корутинах!

С другой стороны создавать отдельное соединение для каждой корутины снова и снова очень расточительная стратегия. Она сводит на нет преймущества конкретного ввода вывода. Поэтому как правило для взаимодействия с внешними API, Базами Данных и другими ресурсами, используют пул соединений.

php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'secret');

// Десять корутин одновременно используют один $pdo
for ($i = 0; $i < 10; $i++) {
    spawn(function() use ($pdo, $i) {
        $pdo->beginTransaction();
        $pdo->exec("INSERT INTO orders (user_id) VALUES ($i)");
        // Другая корутина уже вызвала COMMIT на этом же соединении!
        $pdo->commit(); // Хаос
    });
}

Можно создавать отдельное соединение в каждой корутине, но тогда при тысяче корутин вы получите тысячу TCP-соединений. MySQL по умолчанию разрешает 151 одновременное соединение. PostgreSQL — 100.

Решение: PDO Pool

PDO Pool — встроенный в ядро PHP пул соединений с базой данных. Он автоматически выдаёт каждой корутине своё соединение из заранее подготовленного набора и возвращает его обратно, когда корутина закончила работу.

php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'secret', [
    PDO::ATTR_POOL_ENABLED => true,
    PDO::ATTR_POOL_MIN => 2,
    PDO::ATTR_POOL_MAX => 10,
]);

// Десять корутин — каждая получает своё соединение
for ($i = 0; $i < 10; $i++) {
    spawn(function() use ($pdo, $i) {
        // Pool автоматически выделит соединение для этой корутины
        $pdo->beginTransaction();
        $pdo->exec("INSERT INTO orders (user_id) VALUES ($i)");
        $pdo->commit();
        // Соединение возвращается в пул
    });
}

Снаружи код выглядит так, будто вы работаете с обычным PDO. Пул полностью прозрачен.

Как включить

Пул включается через атрибуты конструктора PDO:

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED              => true,  // Включить пул
    PDO::ATTR_POOL_MIN                  => 0,     // Минимум соединений (по умолчанию 0)
    PDO::ATTR_POOL_MAX                  => 10,    // Максимум соединений (по умолчанию 10)
    PDO::ATTR_POOL_HEALTHCHECK_INTERVAL => 30,    // Интервал проверки здоровья (сек, 0 = выключено)
]);
АтрибутЗначениеПо умолчанию
POOL_ENABLEDВключить пулfalse
POOL_MINМинимальное количество соединений, которые пул поддерживает открытыми0
POOL_MAXМаксимальное количество одновременных соединений10
POOL_HEALTHCHECK_INTERVALКак часто проверять, что соединение живое (в секундах)0
POOL_STMT_CACHE_SIZEРазмер кэша prepared statements на одно физическое соединение0 (выкл)

Привязка соединения к корутине

Каждая корутина получает своё соединение из пула. Все вызовы query(), exec(), prepare() внутри одной корутины идут через одно и то же соединение.

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED => true,
    PDO::ATTR_POOL_MAX => 5,
]);

$coro1 = spawn(function() use ($pdo) {
    // Все три запроса идут через соединение #1
    $pdo->query("SELECT 1");
    $pdo->query("SELECT 2");
    $pdo->query("SELECT 3");
    // Корутина завершилась — соединение #1 возвращается в пул
});

$coro2 = spawn(function() use ($pdo) {
    // Все запросы идут через соединение #2
    $pdo->query("SELECT 4");
    // Корутина завершилась — соединение #2 возвращается в пул
});

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

Кэш prepared statements

Включается атрибутом PDO::ATTR_POOL_STMT_CACHE_SIZE => N при создании PDO. Пул держит на каждое физическое соединение LRU-кэш из последних N подготовленных запросов. Когда корутина повторно делает prepare() с тем же SQL, пул возвращает уже подготовленный серверный statement — без round-trip к БД.

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED         => true,
    PDO::ATTR_POOL_MAX             => 10,
    PDO::ATTR_POOL_STMT_CACHE_SIZE => 64,   // до 64 stmt-ов на каждом соединении
]);

spawn(function () use ($pdo) {
    for ($i = 0; $i < 1000; $i++) {
        // Первый вызов: реальный PREPARE на сервере.
        // Все последующие на этом соединении: попадание в кэш, нулевой wire-трафик.
        $stmt = $pdo->prepare('SELECT name FROM users WHERE id = ?');
        $stmt->execute([$i]);
        $row = $stmt->fetch();
    }
});

На тугом цикле prepare → execute → fetch это даёт ~2.9× ускорение (зависит от драйвера и нагрузки).

Поддерживаемые драйверы

pdo_pgsql, pdo_mysql, pdo_sqlite.

Когда кэш не работает

Кэш автоматически пропускается в следующих случаях, чтобы не нарушить семантику:

  • PDO_CURSOR_SCROLL — серверный курсор у скроллируемого результата нельзя переиспользовать.
  • PDO::ATTR_EMULATE_PREPARES = true — эмулируемые запросы не имеют серверного stmt.
  • PGSQL_ATTR_DISABLE_PREPARES — явный отказ от prepare на стороне PG-драйвера.

Инвалидация кэша при изменении схемы / плана

Если схема таблицы изменилась (ALTER TABLE), серверный план старого stmt может перестать быть валидным. Пул распознаёт такие ошибки и прозрачно перевыполняет запрос: старый stmt выбрасывается из кэша, делается новый prepare, и пользовательский код получает успешный результат с первой попытки.

ДрайверКоды ошибок, вызывающие retry
PostgreSQLSQLSTATE 0A000 (feature not supported, cached plan must not change result type), 26000 (invalid SQL statement name)
MySQL1243 (unknown prepared statement handler), 1615 (prepared statement needs to be re-prepared), 2057 (statement has wrong column count)

Сколько ставить

LRU работает независимо на каждом физическом соединении, поэтому суммарное потребление памяти на сервере БД ≈ POOL_MAX × POOL_STMT_CACHE_SIZE подготовленных stmt-ов в моменте.

Разумные значения:

  • веб-приложение с парой десятков уникальных SQL — 16..32;
  • сервис с большим количеством разных запросов — 64..256;
  • если SQL по факту уникален почти всегда — кэш бесполезен, оставьте 0.

Транзакции

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

php
spawn(function() use ($pdo) {
    $pdo->beginTransaction();

    $pdo->exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
    $pdo->exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");

    $pdo->commit();
    // Только после commit соединение может вернуться в пул
});

Автоматический rollback

Если корутина завершилась, не вызвав commit(), пул автоматически откатит транзакцию перед возвратом соединения в пул. Это защита от случайной потери данных.

php
spawn(function() use ($pdo) {
    $pdo->beginTransaction();
    $pdo->exec("DELETE FROM users WHERE id = 1");
    // Забыли commit()
    // Корутина завершилась — пул вызовет ROLLBACK автоматически
});

Жизненный цикл соединения

Жизненный цикл соединения в пуле

Подробная техническая диаграмма с внутренними вызовами — в архитектуре PDO Pool.

Доступ к объекту пула

Метод getPool() возвращает объект Async\Pool, через который можно получить статистику:

php
$pool = $pdo->getPool();

if ($pool !== null) {
    echo "Пул активен: " . get_class($pool) . "\n"; // Async\Pool
}

Если пул не включён, getPool() возвращает null.

Когда использовать

Используйте PDO Pool, когда:

  • Приложение работает в асинхронном режиме с TrueAsync
  • Несколько корутин одновременно обращаются к базе данных
  • Нужно ограничить количество соединений к БД

Не нужен, когда:

  • Приложение синхронное (классический PHP)
  • Только одна корутина работает с базой
  • Используются persistent-соединения (они несовместимы с пулом)

Поддерживаемые драйверы

ДрайверПоддержка пула
pdo_mysqlДа
pdo_pgsqlДа
pdo_sqliteДа
pdo_odbcНет

Обработка ошибок

Если пул не смог создать соединение (неверные credentials, недоступный сервер), исключение пробрасывается в корутину, которая запросила соединение:

php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'wrong_password', [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_POOL_ENABLED => true,
    PDO::ATTR_POOL_MIN => 0,
]);

spawn(function() use ($pdo) {
    try {
        $pdo->query("SELECT 1");
    } catch (PDOException $e) {
        echo "Не удалось подключиться: " . $e->getMessage() . "\n";
    }
});

Обратите внимание на POOL_MIN => 0: если установить минимум больше нуля, пул попытается создать соединения заранее, и ошибка возникнет уже при создании объекта PDO.

Восстановление разорванных соединений

В долгоживущем асинхронном приложении соединения могут оборваться в любой момент: сервер БД перезапустился, DBA убил сессию, сетевой сбой разорвал TCP-соединение или корутина была отменена во время выполнения запроса. Без специальной обработки разорванное соединение вернулось бы в пул, и следующая корутина получила бы непонятную ошибку вроде MySQL server has gone away.

PDO Pool решает это автоматически на двух уровнях.

Автоматическое обнаружение при возврате

Когда соединение возвращается в пул (после завершения корутины или её освобождения), пул проверяет, живо ли оно. Если соединение разорвано — оно уничтожается, а не помещается обратно в пул. Следующая корутина получит свежее, работающее соединение.

php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'secret', [
    PDO::ATTR_ERRMODE      => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_POOL_ENABLED => true,
    PDO::ATTR_POOL_MAX     => 5,
]);

spawn(function () use ($pdo) {
    $pdo->query("SELECT SLEEP(30)");
    // Тем временем DBA выполняет: KILL <connection_id>
    // Запрос выбрасывает PDOException.
    // Когда корутина завершается, пул обнаруживает разорванное соединение
    // и уничтожает его вместо возврата в пул.
});

spawn(function () use ($pdo) {
    // Эта корутина получает совершенно новое здоровое соединение,
    // а НЕ убитое от предыдущей корутины.
    $rows = $pdo->query("SELECT * FROM users")->fetchAll();
});

Прозрачный реконнект при повторной попытке

Когда корутина ловит ошибку от разорванного соединения и повторяет запрос на том же объекте $pdo, пул прозрачно отбрасывает разорванное соединение и выделяет новое. Никакого ручного кода реконнекта не нужно:

php
spawn(function () use ($pdo) {
    try {
        $pdo->query("SELECT 1");
    } catch (PDOException $e) {
        // Соединение оборвалось (перезапуск сервера, сетевая проблема и т.д.)
        // Пул уже внутренне отбросил разорванное соединение.

        // Просто повторите — пул выдаст этой корутине новое соединение:
        $pdo->query("SELECT 1"); // работает
    }
});

Таким образом, простой try/catch с повторной попыткой — это всё, что нужно для надёжной работы с соединениями. Вам не нужно создавать новый объект PDO или вручную переподключаться — пул делает это за вас.

Покрываемые сценарии

СценарийЧто происходит
Перезапуск сервера БДРазорванные соединения обнаруживаются и уничтожаются при возврате в пул
DBA убивает сессиюАналогично — убитое соединение никогда не попадёт к другой корутине
Отмена корутины во время запросаСоединение определяется как разорванное и уничтожается
Сетевой таймаут / TCP resetПул отбрасывает соединение, следующий запрос получает свежее

Изоляция состояния ошибок

В пуле каждая корутина использует один и тот же PHP-объект $pdo, но разное реальное соединение с базой. PDO Pool гарантирует, что состояние ошибок одной корутины никогда не просочится в другую.

errorCode() и errorInfo()

Каждая корутина видит только своё состояние ошибок через $pdo->errorCode() и $pdo->errorInfo(). Неудачный запрос в одной корутине не влияет на код ошибки, видимый другой:

php
$pdo = new PDO('mysql:host=localhost;dbname=app', 'root', 'secret', [
    PDO::ATTR_POOL_ENABLED => true,
]);

$coro1 = spawn(function () use ($pdo) {
    $pdo->query("SELECT * FROM nonexistent_table"); // ошибка
    echo $pdo->errorCode(); // например "42S02" — ошибка только этой корутины
});

$coro2 = spawn(function () use ($pdo) {
    $pdo->query("SELECT 1"); // успешно
    echo $pdo->errorCode(); // "00000" — не затронута ошибкой coro1
});

Консистентное начальное состояние

$pdo->errorCode() всегда возвращает "00000" перед первым запросом в корутине, даже когда несколько корутин стартуют одновременно на свежих соединениях.

php
spawn(function () use ($pdo) {
    // Гарантированно "00000", никогда NULL — даже на свежем соединении пула
    echo $pdo->errorCode(); // "00000"
});

Реальный пример: параллельная обработка заказов

php
use function Async\spawn;
use function Async\await;

$pdo = new PDO('mysql:host=localhost;dbname=shop', 'app', 'secret', [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_POOL_ENABLED       => true,
    PDO::ATTR_POOL_MIN           => 2,
    PDO::ATTR_POOL_MAX           => 5,
]);

// Получаем список заказов для обработки
$orders = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110];

$coroutines = [];
foreach ($orders as $orderId) {
    $coroutines[] = spawn(function() use ($pdo, $orderId) {
        // Каждая корутина получает своё соединение из пула
        $pdo->beginTransaction();

        $stmt = $pdo->prepare("SELECT * FROM orders WHERE id = ? FOR UPDATE");
        $stmt->execute([$orderId]);
        $order = $stmt->fetch();

        if ($order['status'] === 'pending') {
            $pdo->exec("UPDATE orders SET status = 'processing' WHERE id = $orderId");
            $pdo->exec("INSERT INTO order_log (order_id, action) VALUES ($orderId, 'started')");
        }

        $pdo->commit();
        return $orderId;
    });
}

// Ждём завершения всех корутин
foreach ($coroutines as $coro) {
    $processedId = await($coro);
    echo "Заказ #$processedId обработан\n";
}

Десять заказов обрабатываются конкурентно, но через максимум пять соединений к базе. Каждая транзакция изолирована. Соединения переиспользуются между корутинами.

Дальше что?