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.

$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.

$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 :

$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)
]);
Attribut Signification Defaut
POOL_ENABLED Activer le pool false
POOL_MIN Nombre minimum de connexions maintenues ouvertes par le pool 0
POOL_MAX Nombre maximum de connexions simultanees 10
POOL_HEALTHCHECK_INTERVAL Frequence de verification que la connexion est active (en secondes) 0

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.

$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.

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.

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.

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 :

$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 :

Pas necessaire quand :

Pilotes supportes

Pilote Support du pool
pdo_mysql Oui
pdo_pgsql Oui
pdo_sqlite Oui
pdo_odbc Non

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 :

$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

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 ?