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

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

Каждая корутина получает своё соединение из пула. Все вызовы 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 возвращается в пул
});

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

Транзакции

Транзакции работают так же, как в обычном 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";
}

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

Дальше что?