PDO Pool : pool de connexions base de donnees

Le probleme

Lorsqu'on travaille avec des coroutines, le probleme du partage des descripteurs d'E/S se pose. Si le meme socket est utilise par deux coroutines qui ecrivent ou lisent simultanement des paquets differents, les donnees vont se melanger et le resultat sera imprevisible. Par consequent, vous ne pouvez pas simplement utiliser le meme objet PDO dans differentes coroutines !

D'un autre cote, creer une connexion separee pour chaque coroutine a chaque fois est une strategie tres couteuse. Cela annule les avantages des E/S concurrentes. C'est pourquoi les pools de connexions sont generalement utilises pour interagir avec les API externes, les bases de donnees et autres ressources.

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

// Dix coroutines utilisent simultanement le meme $pdo
for ($i = 0; $i < 10; $i++) {
    spawn(function() use ($pdo, $i) {
        $pdo->beginTransaction();
        $pdo->exec("INSERT INTO orders (user_id) VALUES ($i)");
        // Une autre coroutine a deja appele COMMIT sur cette meme connexion !
        $pdo->commit(); // Chaos
    });
}

Vous pourriez creer une connexion separee dans chaque coroutine, mais alors avec mille coroutines vous auriez mille connexions TCP. MySQL autorise 151 connexions simultanees par defaut. PostgreSQL -- 100.

La solution : PDO Pool

PDO Pool -- un pool de connexions base de donnees integre au coeur de PHP. Il donne automatiquement a chaque coroutine sa propre connexion depuis un ensemble pre-prepare et la restitue lorsque la coroutine a termine son travail.

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,
]);

// Dix coroutines -- chacune obtient sa propre connexion
for ($i = 0; $i < 10; $i++) {
    spawn(function() use ($pdo, $i) {
        // Le pool alloue automatiquement une connexion pour cette coroutine
        $pdo->beginTransaction();
        $pdo->exec("INSERT INTO orders (user_id) VALUES ($i)");
        $pdo->commit();
        // La connexion est restituee au pool
    });
}

De l'exterieur, le code ressemble a un travail avec un PDO ordinaire. Le pool est completement transparent.

Comment l'activer

Le pool est active via les attributs du constructeur PDO :

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED              => true,  // Activer le pool
    PDO::ATTR_POOL_MIN                  => 0,     // Connexions minimales (defaut 0)
    PDO::ATTR_POOL_MAX                  => 10,    // Connexions maximales (defaut 10)
    PDO::ATTR_POOL_HEALTHCHECK_INTERVAL => 30,    // Intervalle de verification de sante (sec, 0 = desactive)
]);
AttributSignificationDefaut
POOL_ENABLEDActiver le poolfalse
POOL_MINNombre minimum de connexions maintenues ouvertes par le pool0
POOL_MAXNombre maximum de connexions simultanees10
POOL_HEALTHCHECK_INTERVALFrequence de verification que la connexion est active (en secondes)0
POOL_STMT_CACHE_SIZETaille du cache de prepared statements par connexion physique0 (off)

Liaison des connexions aux coroutines

Chaque coroutine obtient sa propre connexion du pool. Tous les appels a query(), exec(), prepare() au sein d'une meme coroutine passent par la meme connexion.

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

$coro1 = spawn(function() use ($pdo) {
    // Les trois requetes passent par la connexion #1
    $pdo->query("SELECT 1");
    $pdo->query("SELECT 2");
    $pdo->query("SELECT 3");
    // Coroutine terminee -- la connexion #1 retourne au pool
});

$coro2 = spawn(function() use ($pdo) {
    // Toutes les requetes passent par la connexion #2
    $pdo->query("SELECT 4");
    // Coroutine terminee -- la connexion #2 retourne au pool
});

Si une coroutine n'utilise plus la connexion (pas de transactions ou de requetes actives), le pool peut la restituer plus tot -- sans attendre la fin de la coroutine.

Cache de prepared statements

Activé par l'attribut PDO::ATTR_POOL_STMT_CACHE_SIZE => N lors de la création du PDO. Le pool maintient par connexion physique un cache LRU des N derniers prepared statements. Lorsqu'une coroutine refait prepare() avec le même SQL, le pool renvoie le statement côté serveur déjà préparé — sans round-trip vers la BD.

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED         => true,
    PDO::ATTR_POOL_MAX             => 10,
    PDO::ATTR_POOL_STMT_CACHE_SIZE => 64,   // jusqu'à 64 stmt par connexion
]);

spawn(function () use ($pdo) {
    for ($i = 0; $i < 1000; $i++) {
        // Premier appel : véritable PREPARE côté serveur.
        // Tous les suivants sur cette connexion : hit cache, trafic réseau nul.
        $stmt = $pdo->prepare('SELECT name FROM users WHERE id = ?');
        $stmt->execute([$i]);
        $row = $stmt->fetch();
    }
});

Sur une boucle serrée prepare → execute → fetch, cela donne un gain d'environ 2,9× (dépend du driver et de la charge).

Drivers supportés

pdo_pgsql, pdo_mysql, pdo_sqlite.

Quand le cache ne s'applique pas

Le cache est automatiquement contourné dans les cas suivants, pour ne pas casser la sémantique :

  • PDO_CURSOR_SCROLL : un curseur serveur sur un résultat scrollable n'est pas réutilisable.
  • PDO::ATTR_EMULATE_PREPARES = true : les requêtes émulées n'ont pas de statement côté serveur.
  • PGSQL_ATTR_DISABLE_PREPARES : refus explicite du prepare côté driver PG.

Invalidation du cache au changement de schéma / de plan

Si le schéma d'une table change (ALTER TABLE), le plan serveur d'un ancien stmt peut devenir invalide. Le pool reconnaît ces erreurs et réexécute la requête de façon transparente : l'ancien stmt est éjecté du cache, un nouveau prepare est fait, et le code utilisateur reçoit un résultat correct dès la première tentative.

DriverCodes d'erreur déclenchant le 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)

Quelle valeur choisir

Le LRU fonctionne indépendamment sur chaque connexion physique, donc la consommation mémoire totale côté serveur BD est de l'ordre de POOL_MAX × POOL_STMT_CACHE_SIZE stmts préparés simultanément.

Valeurs raisonnables :

  • application web avec quelques dizaines de SQL uniques : 16..32 ;
  • service avec beaucoup de requêtes différentes : 64..256 ;
  • si en pratique le SQL est presque toujours unique, le cache est inutile, laissez 0.

Transactions

Les transactions fonctionnent de la meme maniere que dans PDO ordinaire. Mais le pool garantit que tant qu'une transaction est active, la connexion est epinglee a la coroutine et ne retournera pas au pool.

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();
    // Ce n'est qu'apres le commit que la connexion peut retourner au pool
});

Rollback automatique

Si une coroutine se termine sans appeler commit(), le pool annule automatiquement la transaction avant de restituer la connexion au pool. C'est une protection contre la perte accidentelle de donnees.

php
spawn(function() use ($pdo) {
    $pdo->beginTransaction();
    $pdo->exec("DELETE FROM users WHERE id = 1");
    // Oubli de commit()
    // Coroutine terminee -- le pool appellera automatiquement ROLLBACK
});

Cycle de vie d'une connexion

Cycle de vie d'une connexion dans le pool

Un diagramme technique detaille avec les appels internes se trouve dans l'architecture du PDO Pool.

Acces a l'objet Pool

La methode getPool() retourne l'objet Async\Pool a travers lequel vous pouvez obtenir des statistiques :

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

if ($pool !== null) {
    echo "Pool actif : " . get_class($pool) . "\n"; // Async\Pool
}

Si le pool n'est pas active, getPool() retourne null.

Quand l'utiliser

Utilisez PDO Pool quand :

  • L'application s'execute en mode asynchrone avec TrueAsync
  • Plusieurs coroutines accedent simultanement a la base de donnees
  • Vous devez limiter le nombre de connexions a la base de donnees

Pas necessaire quand :

  • L'application est synchrone (PHP classique)
  • Une seule coroutine travaille avec la base de donnees
  • Les connexions persistantes sont utilisees (elles sont incompatibles avec le pool)

Pilotes supportes

PiloteSupport du pool
pdo_mysqlOui
pdo_pgsqlOui
pdo_sqliteOui
pdo_odbcNon

Gestion des erreurs

Si le pool ne peut pas creer une connexion (mauvais identifiants, serveur indisponible), l'exception est propagee a la coroutine qui a demande la connexion :

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 "Echec de connexion : " . $e->getMessage() . "\n";
    }
});

Notez POOL_MIN => 0 : si vous definissez le minimum superieur a zero, le pool essaiera de creer des connexions a l'avance, et l'erreur se produira lors de la creation de l'objet PDO.

Exemple concret : traitement parallele de commandes

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,
]);

// Obtenir la liste des commandes a traiter
$orders = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110];

$coroutines = [];
foreach ($orders as $orderId) {
    $coroutines[] = spawn(function() use ($pdo, $orderId) {
        // Chaque coroutine obtient sa propre connexion du pool
        $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;
    });
}

// Attendre la fin de toutes les coroutines
foreach ($coroutines as $coro) {
    $processedId = await($coro);
    echo "Commande #$processedId traitee\n";
}

Dix commandes sont traitees en concurrence, mais a travers un maximum de cinq connexions a la base de donnees. Chaque transaction est isolee. Les connexions sont reutilisees entre les coroutines.

Et ensuite ?