POOL
♻️
Deep Dive · Magento 2 Design Patterns

Object Pool Pattern:
Objekte wiederverwenden statt neu erstellen

Wie Magento DB-Connections, HTTP-Clients und ressourcenintensive Objekte intern pooled — und wie du das Pattern für eigene teure Initialisierungen nutzt.

12 min Lesezeit
Magento 2.4.8 · PHP 8.4
GoF Creational Pattern

Das Object Pool Pattern ist eines der oft übersehenen Creational Patterns aus dem GoF-Katalog. Die Idee ist simpel: Statt ein teures Objekt jedes Mal neu zu erstellen und zu zerstören, legst du einen Vorrat (Pool) solcher Objekte an und leihst sie bei Bedarf aus — und gibst sie nach der Nutzung zurück. In Sprachen wie Java oder C# ist das unverzichtbar für Thread-Pools und Connection-Pools. In PHP-per-Request-Architektur verändert sich der Kontext — aber das Pattern bleibt hochrelevant, wie Magento selbst beweist.

1. Was ist das Object Pool Pattern?

Das GoF-Object-Pool-Pattern verwaltet einen Satz vorinitialisierter Objekte, die teuer in der Erstellung sind. Der Client fragt den Pool nach einem Objekt, nutzt es, und gibt es zurück — anstatt es zu vernichten.


+------------------+         acquire()        +------------------+
|   Object Pool    | -----------------------> |   Pooled Object  |
|                  | <----------------------- |   (in use)       |
|  [obj1] [obj2]   |         release()        +------------------+
|  [obj3] [obj4]   |
+------------------+
         |
         | creates new if empty
         v
+------------------+
| Object Factory   |
+------------------+
    

Die drei Kernoperationen:

  • acquire() — Ein freies Objekt aus dem Pool holen (oder neu erstellen, falls Pool leer)
  • release() — Objekt zurück in den Pool legen (auf Initialzustand zurücksetzen)
  • create() — Neue Instanz erzeugen, wenn Pool erschöpft ist (optional: max-size begrenzen)

2. Wann ist Pooling sinnvoll?

Nicht jedes Objekt profitiert von Pooling. Die Faustregeln:

Szenario Erstellungskosten Pool sinnvoll?
Datenbank-Connection TCP-Handshake, Auth: ~50–200ms ✓ Ja
HTTP-Client (cURL) SSL-Handshake: ~30–100ms ✓ Ja (keep-alive)
PDF-Generator (Puppeteer/wkhtmltopdf) Browser-Start: ~500ms–2s ✓ Ja
Elasticsearch-Client Connection + Auth: ~20–80ms ✓ Ja
Einfaches DataObject <1ms ✗ Nein (Overhead überwiegt)
ViewModel mit DI <1ms (Shared) ✗ Nein (Singleton reicht)

3. PHP 8.4 Implementierung

Eine generische Pool-Implementierung mit PHP 8.4 Constructor Property Promotion und Typed Properties:


<?php

declare(strict_types=1);

namespace Mironsoft\Core\Pool;

use SplQueue;
use Throwable;

/**
 * Generic object pool for expensive-to-create resources.
 *
 * @template T of object
 */
final class ObjectPool
{
    /** @var SplQueue<T> */
    private readonly SplQueue $available;

    private int $currentSize = 0;

    /**
     * @param callable(): T $factory   Creates a new instance when pool is empty
     * @param callable(T): void $reset Resets object state before returning to pool
     * @param int $maxSize             Maximum pool size (0 = unlimited)
     */
    public function __construct(
        private readonly \Closure $factory,
        private readonly \Closure $reset,
        private readonly int $maxSize = 10,
    ) {
        $this->available = new SplQueue();
    }

    /**
     * Acquires an object from the pool, creating one if necessary.
     *
     * @return T
     */
    public function acquire(): object
    {
        if (!$this->available->isEmpty()) {
            return $this->available->dequeue();
        }

        $this->currentSize++;
        return ($this->factory)();
    }

    /**
     * Returns an object to the pool after use.
     *
     * @param T $object
     */
    public function release(object $object): void
    {
        if ($this->maxSize > 0 && $this->available->count() >= $this->maxSize) {
            // Pool is full — let the object be garbage collected
            $this->currentSize--;
            return;
        }

        ($this->reset)($object);
        $this->available->enqueue($object);
    }

    /**
     * Returns current pool statistics.
     *
     * @return array{available: int, total: int}
     */
    public function stats(): array
    {
        return [
            'available' => $this->available->count(),
            'total'     => $this->currentSize,
        ];
    }
}
    

Verwendung mit einem konkreten Ressourcentyp:


<?php

// Build a pool of cURL handles (example of pool usage)
$curlPool = new ObjectPool(
    factory: static function (): \CurlHandle {
        $handle = curl_init();
        curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($handle, CURLOPT_TIMEOUT, 10);
        return $handle;
    },
    reset: static function (\CurlHandle $handle): void {
        curl_reset($handle);
        curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($handle, CURLOPT_TIMEOUT, 10);
    },
    maxSize: 5,
);

// Use the pool
$handle = $curlPool->acquire();
curl_setopt($handle, CURLOPT_URL, 'https://api.example.com/data');
$response = curl_exec($handle);
$curlPool->release($handle); // Returns to pool instead of destroying
    

4. Magento: DB-Connection-Pool (ResourceConnection)

Magento implementiert intern ein Connection-Pool-Konzept über Magento\Framework\App\ResourceConnection. Statt bei jedem Datenbankzugriff eine neue TCP-Verbindung aufzubauen, werden Verbindungen gecacht und wiederverwendet.


<?php

declare(strict_types=1);

namespace Mironsoft\Example\Model;

use Magento\Framework\App\ResourceConnection;
use Magento\Framework\DB\Adapter\AdapterInterface;

/**
 * Demonstrates how Magento pools database connections internally.
 */
final class ProductRepository
{
    public function __construct(
        private readonly ResourceConnection $resourceConnection,
    ) {}

    /**
     * Magento's getConnection() returns the SAME AdapterInterface
     * instance every time within one request — this IS the pool.
     */
    public function getActiveProductCount(): int
    {
        // First call: opens TCP connection to MySQL, creates Adapter
        $connection = $this->resourceConnection->getConnection();

        // Second call anywhere in the request: returns SAME instance
        // No new TCP handshake, no re-authentication
        $connection2 = $this->resourceConnection->getConnection();

        // $connection === $connection2 → true (same object)

        $table = $this->resourceConnection->getTableName('catalog_product_entity');

        return (int) $connection->fetchOne(
            $connection->select()
                ->from($table, ['COUNT(*)'])
                ->where('status = ?', 1)
        );
    }

    /**
     * Named connections: separate pool entries for read/write splitting.
     */
    public function getWithReadReplica(): AdapterInterface
    {
        // Magento supports named connections (read replica)
        // Each name maps to a separate pooled connection
        return $this->resourceConnection->getConnection('read');
    }
}
    

Die interne Pooling-Logik in ResourceConnection::getConnection():


<?php

// Simplified from vendor/magento/framework/App/ResourceConnection.php
// The actual implementation uses _connections[] array as the pool

class ResourceConnection
{
    /** @var AdapterInterface[] Pool indexed by connection name */
    private array $connections = [];

    public function getConnection(string $resourceName = self::DEFAULT_CONNECTION): AdapterInterface
    {
        $connectionName = $this->getConnectionName($resourceName);

        // Pool lookup: return existing connection if available
        if (isset($this->connections[$connectionName])) {
            return $this->connections[$connectionName]; // ← Pool hit
        }

        // Pool miss: create new connection and store in pool
        $this->connections[$connectionName] = $this->connectionFactory->create(
            $this->deploymentConfig->get("db/connection/{$connectionName}")
        );

        return $this->connections[$connectionName];
    }
}
    

Das ist klassisches Object Pool Pattern — nur ohne explizites release(), da PHP am Request-Ende den Speicher freigibt.

5. HTTP-Client-Pool in Magento

Beim Ansprechen externer APIs (Payment-Gateways, ERP-Systeme) lohnt sich ein cURL-Pool, um SSL-Handshakes zu vermeiden:


<?php

declare(strict_types=1);

namespace Mironsoft\Integration\Http;

/**
 * Pools cURL handles to reuse SSL sessions within a single request.
 */
final class CurlHandlePool
{
    /** @var \CurlHandle[] */
    private array $available = [];

    /** @var \CurlHandle[] */
    private array $inUse = [];

    private const MAX_POOL_SIZE = 8;

    /**
     * Acquires a cURL handle, reusing an existing one if possible.
     */
    public function acquire(): \CurlHandle
    {
        if (!empty($this->available)) {
            $handle = array_pop($this->available);
            $this->inUse[spl_object_id($handle)] = $handle;
            return $handle;
        }

        $handle = $this->createHandle();
        $this->inUse[spl_object_id($handle)] = $handle;
        return $handle;
    }

    /**
     * Returns a handle to the pool and resets its state.
     */
    public function release(\CurlHandle $handle): void
    {
        $id = spl_object_id($handle);
        unset($this->inUse[$id]);

        if (count($this->available) < self::MAX_POOL_SIZE) {
            curl_reset($handle);
            curl_setopt_array($handle, $this->defaultOptions());
            $this->available[] = $handle;
        } else {
            curl_close($handle);
        }
    }

    private function createHandle(): \CurlHandle
    {
        $handle = curl_init();
        curl_setopt_array($handle, $this->defaultOptions());
        return $handle;
    }

    /** @return array<int, mixed> */
    private function defaultOptions(): array
    {
        return [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_HTTP_VERSION   => CURL_HTTP_VERSION_2_0,
            CURLOPT_TCP_KEEPALIVE  => 1,
        ];
    }
}
    

Di.xml-Registrierung als Shared-Objekt (Pool muss Singleton sein):


<!-- app/code/Mironsoft/Integration/etc/di.xml -->
<config>
    <type name="Mironsoft\Integration\Http\CurlHandlePool">
        <!-- shared="true" is the default — explicitly stating it for clarity -->
        <!-- The pool MUST be a singleton so all callers share the same pool -->
        <arguments>
            <!-- Max pool size configurable via di.xml -->
        </arguments>
    </type>
</config>
    

6. Eigener Pool: PDF-Generator-Beispiel

Ein realistisches Magento-Szenario: PDF-Generierung über einen externen Prozess (z.B. headless Chrome via Puppeteer). Der Prozessstart kostet ~1–2 Sekunden — mit Pooling nur einmal pro Request-Lebenszyklus.


<?php

declare(strict_types=1);

namespace Mironsoft\Pdf\Pool;

use Psr\Log\LoggerInterface;

/**
 * Manages a pool of external PDF renderer processes.
 */
final class PdfRendererPool
{
    /** @var PdfRenderer[] */
    private array $available = [];

    private int $created = 0;

    public function __construct(
        private readonly PdfRendererFactory $rendererFactory,
        private readonly LoggerInterface $logger,
        private readonly int $maxPoolSize = 3,
        private readonly int $maxIdleTime = 300, // seconds
    ) {}

    /**
     * Acquires a PDF renderer, starting a new process if necessary.
     */
    public function acquire(): PdfRenderer
    {
        // Remove stale renderers first
        $this->evictStale();

        if (!empty($this->available)) {
            return array_pop($this->available);
        }

        if ($this->created >= $this->maxPoolSize) {
            // Block and wait — or throw if strict mode
            throw new \RuntimeException(
                "PDF renderer pool exhausted (max: {$this->maxPoolSize})"
            );
        }

        $this->logger->info('Starting new PDF renderer process');
        $renderer = $this->rendererFactory->create();
        $this->created++;
        return $renderer;
    }

    /**
     * Returns a renderer to the pool after use.
     */
    public function release(PdfRenderer $renderer): void
    {
        if (!$renderer->isHealthy()) {
            $renderer->terminate();
            $this->created--;
            return;
        }

        $renderer->markLastUsed(time());
        $this->available[] = $renderer;
    }

    /**
     * Removes renderers that have been idle too long.
     */
    private function evictStale(): void
    {
        $now = time();
        $this->available = array_filter(
            $this->available,
            function (PdfRenderer $r) use ($now): bool {
                if ($now - $r->lastUsed() > $this->maxIdleTime) {
                    $r->terminate();
                    $this->created--;
                    return false;
                }
                return true;
            }
        );
    }
}
    

Der Service, der den Pool nutzt:


<?php

declare(strict_types=1);

namespace Mironsoft\Pdf\Service;

use Mironsoft\Pdf\Pool\PdfRendererPool;
use Magento\Sales\Api\Data\OrderInterface;

/**
 * Generates order PDF documents using a pooled renderer.
 */
final class OrderPdfService
{
    public function __construct(
        private readonly PdfRendererPool $rendererPool,
    ) {}

    /**
     * Generates a PDF for the given order.
     */
    public function generateOrderPdf(OrderInterface $order): string
    {
        $renderer = $this->rendererPool->acquire();
        try {
            $html = $this->buildOrderHtml($order);
            return $renderer->render($html, ['format' => 'A4']);
        } finally {
            // Always release back to pool, even on exception
            $this->rendererPool->release($renderer);
        }
    }

    private function buildOrderHtml(OrderInterface $order): string
    {
        return sprintf(
            '<html><body><h1>Order #%s</h1><p>Total: %s</p></body></html>',
            $order->getIncrementId(),
            $order->getGrandTotal()
        );
    }
}
    

Das finally-Block ist entscheidend: Der Renderer muss immer zurück in den Pool, auch bei Exceptions — sonst leakt der Pool.

7. PHP-Request-Scope: Warum klassisches Pooling begrenzt ist

PHP ist per Default Share-Nothing-Architektur: Jeder Request startet einen neuen PHP-Prozess (oder FPM-Worker), der am Ende vollständig bereinigt wird. Das bedeutet:

PHP-Besonderheiten beim Object Pool

  • Kein Cross-Request-Pooling: Ein Pool lebt nur innerhalb eines einzelnen Requests. Für dauerhaftes Pooling brauchst du einen externen Proxy (PgBouncer, ProxySQL, HAProxy).
  • PHP-FPM Worker als impliziter Pool: FPM selbst pooled PHP-Worker-Prozesse. Persistent Connections (pconnect) nutzen diesen Worker-Pool.
  • Swoole/RoadRunner: Mit Coroutine-Frameworks existiert der Prozess über mehrere Requests — dann ist Cross-Request-Pooling möglich und sinnvoll.
  • Sinnvoll trotzdem: Innerhalb eines einzelnen langen Requests (Batch-Import, Queue-Consumer) mit vielen DB-Queries oder API-Calls lohnt sich intra-request Pooling sehr.

Für Magento-Queue-Consumer, die als Long-Running-Prozesse laufen, ist Object Pool besonders relevant:


<?php

declare(strict_types=1);

namespace Mironsoft\Queue\Consumer;

use Magento\Framework\MessageQueue\ConsumerInterface;
use Magento\Framework\MessageQueue\EnvelopeInterface;
use Mironsoft\Integration\Http\CurlHandlePool;

/**
 * Long-running queue consumer that benefits from cURL handle pooling.
 * This process may handle thousands of messages before being recycled.
 */
final class WebhookDispatcher implements ConsumerInterface
{
    public function __construct(
        private readonly CurlHandlePool $curlPool,
    ) {}

    public function process(EnvelopeInterface $envelope): void
    {
        $handle = $this->curlPool->acquire();
        try {
            $message = json_decode($envelope->getBody(), true, 512, JSON_THROW_ON_ERROR);
            curl_setopt($handle, CURLOPT_URL, $message['webhook_url']);
            curl_setopt($handle, CURLOPT_POSTFIELDS, json_encode($message['payload']));
            curl_exec($handle);
        } finally {
            $this->curlPool->release($handle);
        }
    }
}
    

8. Pool vs. Singleton vs. Factory

Pattern Instanzen Zustand Ideal für
Singleton 1 (immer) Geteilt (gefährlich) Stateless Services, Konfiguration
Factory N (jedes Mal neu) Frisch, isoliert Non-injectable, günstige Erstellung
Object Pool M (begrenzt, wiederverwendet) Reset zwischen Nutzungen Teure Erstellung, häufige Nutzung
Prototype N (via clone) Kopiert vom Original Komplexe Initialzustände, Templates

Die Entscheidungsregel: Wenn das Erstellen eines Objekts messbar Zeit kostet (>10ms) und du es häufig brauchst, ist ein Pool sinnvoll. Wenn der Zustand nicht resettbar ist, verwende Factory. Wenn du nur eine Instanz brauchst und der Zustand stateless ist, verwende Singleton (shared di.xml).

9. Object Pools testen

Beim Testen von Pools gibt es drei kritische Szenarien:


<?php

declare(strict_types=1);

namespace Mironsoft\Core\Test\Unit\Pool;

use Mironsoft\Core\Pool\ObjectPool;
use PHPUnit\Framework\TestCase;

final class ObjectPoolTest extends TestCase
{
    private int $createCount = 0;

    private function buildPool(int $maxSize = 5): ObjectPool
    {
        return new ObjectPool(
            factory: function (): \stdClass {
                $this->createCount++;
                $obj = new \stdClass();
                $obj->id = $this->createCount;
                $obj->value = null;
                return $obj;
            },
            reset: static function (\stdClass $obj): void {
                $obj->value = null; // Reset state before returning to pool
            },
            maxSize: $maxSize,
        );
    }

    public function testAcquireCreatesNewObjectWhenPoolEmpty(): void
    {
        $pool = $this->buildPool();
        $obj = $pool->acquire();

        $this->assertSame(1, $this->createCount);
        $this->assertInstanceOf(\stdClass::class, $obj);
    }

    public function testReleaseAndReacquireReusesObject(): void
    {
        $pool = $this->buildPool();
        $obj1 = $pool->acquire();
        $pool->release($obj1);

        $obj2 = $pool->acquire();

        // Only ONE object was ever created
        $this->assertSame(1, $this->createCount);
        // It's the SAME instance
        $this->assertSame($obj1, $obj2);
    }

    public function testResetIsCalledOnRelease(): void
    {
        $pool = $this->buildPool();
        $obj = $pool->acquire();
        $obj->value = 'dirty-state';
        $pool->release($obj);

        $reacquired = $pool->acquire();
        // State must be reset
        $this->assertNull($reacquired->value);
    }

    public function testPoolDoesNotExceedMaxSize(): void
    {
        $pool = $this->buildPool(maxSize: 2);

        $a = $pool->acquire();
        $b = $pool->acquire();
        $c = $pool->acquire(); // Creates 3rd

        // Release all three
        $pool->release($a);
        $pool->release($b);
        $pool->release($c); // Pool is full — $c gets discarded

        $stats = $pool->stats();
        $this->assertLessThanOrEqual(2, $stats['available']);
    }

    public function testConcurrentAcquireCreatesSeparateObjects(): void
    {
        $pool = $this->buildPool();
        $obj1 = $pool->acquire();
        $obj2 = $pool->acquire(); // Pool empty — new object

        $this->assertSame(2, $this->createCount);
        $this->assertNotSame($obj1, $obj2);
    }
}
    

10. Fazit: Wann Object Pool in Magento einsetzen?

Das Object Pool Pattern ist in Magento an den richtigen Stellen bereits eingesetzt (DB-Connections, HTTP-Clients) und du solltest es gezielt für eigene Anforderungen verwenden:

✓ Pool nutzen wenn...

  • Erstellung kostet >10ms (Connection, Prozess)
  • Objekt innerhalb eines Requests mehrfach gebraucht
  • Zustand nach Nutzung resettbar ist
  • Long-running Queue-Consumer (Magento MQ)
  • Externe API-Verbindungen mit SSL-Overhead

✗ Pool vermeiden wenn...

  • Erstellung günstig ist (<1ms)
  • Objekte mutable und nicht resetbar sind
  • Nur eine Instanz nötig (dann: Singleton/Shared)
  • Standard-HTTP-Request mit 1-2 DB-Queries
  • Zustand zwischen Nutzungen kritisch unterschiedlich

Magento selbst zeigt den richtigen Weg: ResourceConnection pooled DB-Connections transparent, ohne dass der Entwickler sich darum kümmern muss. Das ist gutes Object Pool Design — einfach zu nutzen, schwer zu missbrauchen.

Zusammenfassung

Pattern
Object Pool verwaltet eine begrenzte Anzahl teurer Objekte zur Wiederverwendung statt Neuerstellung
Magento Core
ResourceConnection pooled DB-Adapter-Instanzen; alle getConnection()-Aufrufe geben dieselbe Instanz zurück
PHP-Scope
Pool lebt pro Request (FPM); Cross-Request-Pooling nur mit Swoole/RoadRunner oder externem Proxy möglich
Pflicht
Immer finally-Block für release() — Pool darf nie durch unbehandelte Exceptions leer laufen

Object Pool in der Praxis

Connection-Pools analysieren, HTTP-Clients optimieren oder Queue-Consumer beschleunigen.

????
Pool-Analyse
ResourceConnection-Nutzung und Pool-Hit-Rates analysieren
cURL-Optimierung
HTTP-Client-Pool für externe API-Integrationen implementieren
????
Queue-Consumer
Long-running Magento MQ Consumer mit Ressource-Pooling

Häufige Fragen zum Object Pool Pattern

Was ist das Object Pool Pattern? +
Das Object Pool Pattern ist ein Creational Design Pattern, das einen Vorrat vorinitialisierter Objekte verwaltet. Statt jedes Mal ein teures Objekt zu erstellen und zu zerstören, werden Objekte ausgeliehen (acquire) und nach Nutzung zurückgegeben (release) und dabei auf den Initialzustand zurückgesetzt.
Nutzt Magento das Object Pool Pattern intern? +
Ja. Magento's ResourceConnection implementiert intern ein Connection-Pool-Konzept: getConnection() gibt bei jedem Aufruf dieselbe Adapter-Instanz zurück, statt eine neue TCP-Verbindung aufzubauen. Das ist klassisches Object Pool Pattern, nur ohne explizites release().
Wann lohnt sich ein Object Pool in PHP/Magento? +
Wenn die Objekterstellung messbar Zeit kostet (>10ms) — z.B. Datenbankverbindungen (TCP-Handshake), HTTPS-Clients (SSL-Handshake), externe Prozesse (PDF-Generator). Bei günstigen Objekten überwiegt der Pool-Overhead.
Warum ist Pool in PHP begrenzt sinnvoll? +
PHP ist standardmäßig eine Share-Nothing-Architektur: Jeder Request startet mit frischem Speicher. Ein Object Pool lebt nur innerhalb eines Requests. Cross-Request-Pooling ist nur mit Swoole, RoadRunner oder externen Proxies möglich.
Was ist der Unterschied zwischen Object Pool und Singleton? +
Ein Singleton erlaubt genau eine Instanz, die immer geteilt wird. Ein Object Pool erlaubt M Instanzen, die nacheinander genutzt werden, mit explizitem Reset. Der Pool skaliert besser bei gleichzeitigen Anforderungen.
Warum ist der finally-Block beim Pool-Release wichtig? +
Ohne finally kann eine Exception dazu führen, dass das ausgeliehene Objekt nie zurückgegeben wird. Der Pool erschöpft sich über die Zeit (Pool-Leak). try/finally stellt sicher, dass release() immer ausgeführt wird.
Wie funktioniert Magento's DB-Connection-Pooling konkret? +
ResourceConnection::getConnection() prüft ein internes $_connections-Array. Existiert bereits eine Verbindung für den angeforderten Namen, wird diese zurückgegeben. Nur beim ersten Aufruf wird eine echte TCP-Verbindung zu MySQL aufgebaut.
Kann ich den Pool mit Magento-DI konfigurieren? +
Ja. Der Pool muss als Singleton (shared=true, Standard) registriert sein, damit alle Klassen denselben Pool teilen. Konfigurationsparameter wie maxPoolSize können über di.xml-Arguments gesetzt werden.
Was muss beim Reset eines Pool-Objekts beachtet werden? +
Der Reset muss das Objekt in denselben Zustand zurückversetzen wie nach der Erstellung. Für cURL-Handles: curl_reset(). Für eigene Klassen: alle Properties auf Default zurücksetzen. Unvollständiger Reset führt zu subtilen Bugs.
Eignet sich Object Pool für Magento Queue-Consumer? +
Ja, das ist einer der besten Anwendungsfälle. Queue-Consumer laufen als Long-Running-Prozesse und verarbeiten Tausende von Messages. Ohne Pooling würde jede Message einen neuen cURL-SSL-Handshake verursachen. Mit Pooling wird die Verbindung wiederverwendet.