PDO Pool: Datenbankverbindungspool

Das Problem

Bei der Arbeit mit Koroutinen entsteht das Problem der gemeinsamen Nutzung von I/O-Deskriptoren. Wenn derselbe Socket von zwei Koroutinen verwendet wird, die gleichzeitig verschiedene Pakete schreiben oder lesen, werden die Daten durcheinander gebracht und das Ergebnis ist unvorhersehbar. Daher können Sie nicht einfach dasselbe PDO-Objekt in verschiedenen Koroutinen verwenden!

Andererseits ist das wiederholte Erstellen einer separaten Verbindung für jede Koroutine eine sehr verschwenderische Strategie. Dadurch werden die Vorteile der gleichzeitigen I/O zunichte gemacht. Deshalb werden üblicherweise Verbindungspools für die Interaktion mit externen APIs, Datenbanken und anderen Ressourcen verwendet.

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

// Zehn Koroutinen verwenden gleichzeitig dasselbe $pdo
for ($i = 0; $i < 10; $i++) {
    spawn(function() use ($pdo, $i) {
        $pdo->beginTransaction();
        $pdo->exec("INSERT INTO orders (user_id) VALUES ($i)");
        // Eine andere Koroutine hat bereits COMMIT auf derselben Verbindung aufgerufen!
        $pdo->commit(); // Chaos
    });
}

Sie könnten in jeder Koroutine eine separate Verbindung erstellen, aber bei tausend Koroutinen hätten Sie tausend TCP-Verbindungen. MySQL erlaubt standardmäßig 151 gleichzeitige Verbindungen. PostgreSQL -- 100.

Die Lösung: PDO Pool

PDO Pool -- ein in den PHP-Kern integrierter Datenbankverbindungspool. Er gibt jeder Koroutine automatisch eine eigene Verbindung aus einem vorbereiteten Satz und gibt sie zurück, wenn die Koroutine fertig ist.

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

// Zehn Koroutinen -- jede bekommt ihre eigene Verbindung
for ($i = 0; $i < 10; $i++) {
    spawn(function() use ($pdo, $i) {
        // Pool weist dieser Koroutine automatisch eine Verbindung zu
        $pdo->beginTransaction();
        $pdo->exec("INSERT INTO orders (user_id) VALUES ($i)");
        $pdo->commit();
        // Verbindung wird an den Pool zurückgegeben
    });
}

Von außen sieht der Code so aus, als würden Sie mit einem regulären PDO arbeiten. Der Pool ist vollständig transparent.

Aktivierung

Der Pool wird über Attribute des PDO-Konstruktors aktiviert:

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED              => true,  // Pool aktivieren
    PDO::ATTR_POOL_MIN                  => 0,     // Mindestverbindungen (Standard 0)
    PDO::ATTR_POOL_MAX                  => 10,    // Maximale Verbindungen (Standard 10)
    PDO::ATTR_POOL_HEALTHCHECK_INTERVAL => 30,    // Gesundheitsprüfungsintervall (Sek., 0 = deaktiviert)
]);
AttributBedeutungStandard
POOL_ENABLEDPool aktivierenfalse
POOL_MINMindestanzahl an Verbindungen, die der Pool offen hält0
POOL_MAXMaximale Anzahl gleichzeitiger Verbindungen10
POOL_HEALTHCHECK_INTERVALWie oft geprüft wird, ob eine Verbindung noch aktiv ist (in Sekunden)0
POOL_STMT_CACHE_SIZEGröße des Prepared-Statement-Caches pro physischer Verbindung0 (aus)

Bindung von Verbindungen an Koroutinen

Jede Koroutine bekommt ihre eigene Verbindung aus dem Pool. Alle Aufrufe von query(), exec(), prepare() innerhalb einer einzelnen Koroutine gehen über dieselbe Verbindung.

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

$coro1 = spawn(function() use ($pdo) {
    // Alle drei Abfragen gehen über Verbindung #1
    $pdo->query("SELECT 1");
    $pdo->query("SELECT 2");
    $pdo->query("SELECT 3");
    // Koroutine beendet -- Verbindung #1 kehrt zum Pool zurück
});

$coro2 = spawn(function() use ($pdo) {
    // Alle Abfragen gehen über Verbindung #2
    $pdo->query("SELECT 4");
    // Koroutine beendet -- Verbindung #2 kehrt zum Pool zurück
});

Wenn eine Koroutine die Verbindung nicht mehr verwendet (keine aktiven Transaktionen oder Statements), kann der Pool sie früher zurückgeben -- ohne auf das Ende der Koroutine zu warten.

Prepared-Statement-Cache

Aktiviert über das Attribut PDO::ATTR_POOL_STMT_CACHE_SIZE => N beim Erstellen des PDO. Der Pool hält pro physischer Verbindung einen LRU-Cache der letzten N Prepared Statements. Wenn eine Koroutine prepare() mit demselben SQL wiederholt, gibt der Pool das bereits vorbereitete serverseitige Statement zurück — ohne Round-Trip zur DB.

php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_POOL_ENABLED         => true,
    PDO::ATTR_POOL_MAX             => 10,
    PDO::ATTR_POOL_STMT_CACHE_SIZE => 64,   // bis zu 64 Statements pro Verbindung
]);

spawn(function () use ($pdo) {
    for ($i = 0; $i < 1000; $i++) {
        // Erster Aufruf: echtes PREPARE auf dem Server.
        // Alle folgenden auf derselben Verbindung: Cache-Hit, kein Wire-Traffic.
        $stmt = $pdo->prepare('SELECT name FROM users WHERE id = ?');
        $stmt->execute([$i]);
        $row = $stmt->fetch();
    }
});

In einer engen Schleife prepare → execute → fetch ergibt das einen ~2,9× Speedup (abhängig von Treiber und Last).

Unterstützte Treiber

pdo_pgsql, pdo_mysql, pdo_sqlite.

Wann der Cache nicht greift

Der Cache wird in folgenden Fällen automatisch übersprungen, um die Semantik zu wahren:

  • PDO_CURSOR_SCROLL — der serverseitige Cursor eines scrollbaren Resultats lässt sich nicht wiederverwenden.
  • PDO::ATTR_EMULATE_PREPARES = true — emulierte Statements haben kein serverseitiges Stmt.
  • PGSQL_ATTR_DISABLE_PREPARES — expliziter Verzicht auf Prepare auf der PG-Treiberseite.

Cache-Invalidierung bei Schema-/Planänderungen

Wenn sich das Schema einer Tabelle ändert (ALTER TABLE), kann der serverseitige Plan eines alten Stmts ungültig werden. Der Pool erkennt solche Fehler und führt die Anfrage transparent erneut aus: das alte Stmt wird aus dem Cache verworfen, ein neues prepare ausgeführt, und der User-Code erhält beim ersten Aufruf ein erfolgreiches Ergebnis.

TreiberFehlercodes, die einen Retry auslösen
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)

Wie groß wählen

Die LRU arbeitet unabhängig auf jeder physischen Verbindung, daher liegt der gesamte Speicher auf dem DB-Server bei ca. POOL_MAX × POOL_STMT_CACHE_SIZE aktiv vorbereiteten Stmts.

Sinnvolle Werte:

  • Webanwendung mit ein paar Dutzend einzigartiger SQL-Statements — 16..32;
  • Service mit vielen unterschiedlichen Anfragen — 64..256;
  • wenn SQL praktisch immer einzigartig ist — der Cache bringt nichts, lassen Sie 0.

Transaktionen

Transaktionen funktionieren genauso wie bei regulärem PDO. Aber der Pool garantiert, dass die Verbindung während einer aktiven Transaktion an die Koroutine gebunden bleibt und nicht zum Pool zurückkehrt.

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();
    // Erst nach dem Commit kann die Verbindung zum Pool zurückkehren
});

Automatisches Rollback

Wenn eine Koroutine endet, ohne commit() aufzurufen, führt der Pool automatisch ein Rollback der Transaktion durch, bevor die Verbindung an den Pool zurückgegeben wird. Dies ist eine Sicherheitsmaßnahme gegen versehentlichen Datenverlust.

php
spawn(function() use ($pdo) {
    $pdo->beginTransaction();
    $pdo->exec("DELETE FROM users WHERE id = 1");
    // commit() vergessen
    // Koroutine beendet -- Pool ruft automatisch ROLLBACK auf
});

Verbindungslebenszyklus

Verbindungslebenszyklus im Pool

Ein detailliertes technisches Diagramm mit internen Aufrufen finden Sie in der PDO Pool-Architektur.

Zugriff auf das Pool-Objekt

Die Methode getPool() gibt das Async\Pool-Objekt zurück, über das Sie Statistiken abrufen können:

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

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

Wenn der Pool nicht aktiviert ist, gibt getPool() null zurück.

Wann verwenden

PDO Pool verwenden, wenn:

  • Die Anwendung im asynchronen Modus mit TrueAsync läuft
  • Mehrere Koroutinen gleichzeitig auf die Datenbank zugreifen
  • Die Anzahl der Verbindungen zur Datenbank begrenzt werden soll

Nicht benötigt, wenn:

  • Die Anwendung synchron arbeitet (klassisches PHP)
  • Nur eine Koroutine mit der Datenbank arbeitet
  • Persistente Verbindungen verwendet werden (sie sind mit dem Pool inkompatibel)

Unterstützte Treiber

TreiberPool-Unterstützung
pdo_mysqlJa
pdo_pgsqlJa
pdo_sqliteJa
pdo_odbcNein

Fehlerbehandlung

Wenn der Pool keine Verbindung erstellen kann (falsche Anmeldedaten, nicht erreichbarer Server), wird die Ausnahme an die Koroutine weitergeleitet, die die Verbindung angefordert hat:

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

Beachten Sie POOL_MIN => 0: Wenn Sie das Minimum höher als null setzen, versucht der Pool im Voraus Verbindungen zu erstellen, und der Fehler tritt beim Erstellen des PDO-Objekts auf.

Praxisbeispiel: Parallele Bestellverarbeitung

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

// Liste der zu verarbeitenden Bestellungen abrufen
$orders = [101, 102, 103, 104, 105, 106, 107, 108, 109, 110];

$coroutines = [];
foreach ($orders as $orderId) {
    $coroutines[] = spawn(function() use ($pdo, $orderId) {
        // Jede Koroutine bekommt ihre eigene Verbindung aus dem 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;
    });
}

// Auf den Abschluss aller Koroutinen warten
foreach ($coroutines as $coro) {
    $processedId = await($coro);
    echo "Bestellung #$processedId verarbeitet\n";
}

Zehn Bestellungen werden gleichzeitig verarbeitet, aber über maximal fünf Datenbankverbindungen. Jede Transaktion ist isoliert. Verbindungen werden zwischen Koroutinen wiederverwendet.

Wie geht es weiter?