DataLoader Pattern in GraphQL PHP praktisch implementieren
AI generated
{ }
type
GraphQL · PHP · Performance · Resolver
DataLoader Pattern in GraphQL PHP praktisch implementieren
Batching statt N+1: eine eigene DataLoader-Klasse Schritt für Schritt

Wer in GraphQL-Resolvern direkt aus der Datenbank lädt, produziert bei verschachtelten Feldern schnell Hunderte Einzelqueries pro Request. Das DataLoader Pattern sammelt diese Ladeanfragen innerhalb eines Tick, führt sie gebündelt aus und cached die Ergebnisse pro Request, ohne dass Resolver ihre einfache, isolierte Struktur verlieren.

18 Min. Lesezeit DataLoader · Batching · Promise-Queue · Request-Cache webonyx/graphql-php · PHP 8.4

1. Das N+1 Problem in GraphQL PHP verstehen

GraphQL erlaubt Clients, in einer einzigen Anfrage beliebig tief verschachtelte Daten abzurufen. Genau diese Flexibilität ist die Ursache für das sogenannte N+1 Problem: Eine Liste von zehn Bestellungen führt zu einer Query für die Liste selbst, und danach zu zehn weiteren Einzelqueries, wenn jeder Bestellung-Resolver den zugehörigen Kunden separat nachlädt. Ohne das DataLoader Pattern steigt die Anzahl der Datenbankaufrufe linear mit der Listengröße, und ein Feld, das im Schema harmlos aussieht, wird zum Performance-Killer im Produktivbetrieb.

In PHP-GraphQL-Implementierungen wie webonyx/graphql-php wird jeder Resolver isoliert pro Feld und pro Objekt in der Ergebnisliste aufgerufen. Das ist architektonisch sauber, weil ein Resolver keine Kenntnis von seinen Geschwistern in der Liste braucht, führt aber ohne Gegenmaßnahme zu genau diesem Duplizieren von Datenbankzugriffen. Das DataLoader Pattern löst das Problem, ohne die Resolver-Isolation aufzugeben: Jeder Resolver bleibt einfach und fragt weiterhin nur nach einem einzelnen Kunden, die Bündelung passiert dahinter, unsichtbar für den Resolver-Code selbst.

2. Was das DataLoader Pattern anders macht als naives Batching

Ein naiver Ansatz gegen N+1 wäre, alle IDs vorab zu sammeln und in einer einzigen WHERE id IN (...)-Query zu laden, bevor die Resolver überhaupt starten. Das funktioniert bei flachen Listen, scheitert aber bei tief verschachtelten oder dynamisch aufgebauten Query-Bäumen, weil zum Zeitpunkt der Ausführung noch nicht bekannt ist, welche IDs später gebraucht werden. Das DataLoader Pattern, ursprünglich aus dem JavaScript-Ökosystem von Facebook stammend, löst das über einen Event-Loop-Tick: Jeder Aufruf von load($id) registriert die ID in einer Warteschlange und gibt sofort ein Promise zurück, ohne die Datenbank zu berühren.

Erst wenn der aktuelle synchrone Ausführungsabschnitt abgeschlossen ist, also alle Resolver ihre load()-Aufrufe gemacht haben, löst der DataLoader die gesammelte Warteschlange in einem einzigen Batch auf. In PHP gibt es keinen echten Event-Loop wie in Node.js, weshalb Bibliotheken wie webonyx/graphql-php mit SyncPromise arbeiten und die Batch-Ausführung explizit über den PromiseAdapter triggern. Das DataLoader Pattern bleibt konzeptionell identisch, die technische Umsetzung des "Ticks" unterscheidet sich aber deutlich von der asynchronen JavaScript-Variante.

3. Eine DataLoader-Klasse in PHP implementieren

Der Kern jeder DataLoader-Implementierung ist eine Batch-Load-Funktion, die ein Array von Keys entgegennimmt und ein Array von Werten in exakt derselben Reihenfolge zurückgibt. Diese Garantie ist entscheidend: Der DataLoader selbst kümmert sich um die Zuordnung von Ergebnis zu ursprünglicher Anfrage, die Batch-Funktion muss sich nur um effizientes Laden kümmern. Die folgende Implementierung sammelt Keys in einer Queue, nutzt SyncPromise für die verzögerte Auflösung und dedupliziert Keys automatisch, bevor die Batch-Funktion aufgerufen wird.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlPerformance\DataLoader;

use GraphQL\Executor\Promise\Adapter\SyncPromise;
use GraphQL\Executor\Promise\Promise;
use GraphQL\Executor\Promise\PromiseAdapter;

/**
 * Generic DataLoader implementation for webonyx/graphql-php.
 * Collects load() calls into a queue and resolves them as a single batch.
 */
final class DataLoader
{
    /** @var array<string, Promise> */
    private array $promiseCache = [];

    /** @var array<int, string|int> */
    private array $queue = [];

    /** @var array<string, callable> */
    private array $pendingResolvers = [];

    /**
     * @param PromiseAdapter $promiseAdapter Promise adapter from the GraphQL executor
     * @param callable $batchLoadFn function(array $keys): array<mixed>, must preserve key order
     */
    public function __construct(
        private readonly PromiseAdapter $promiseAdapter,
        private readonly mixed $batchLoadFn,
    ) {
    }

    /**
     * Queues a single key for batched loading and returns a deferred promise.
     *
     * @param string|int $key Identifier to load, e.g. a customer ID
     * @return Promise Promise resolving to the loaded value
     */
    public function load(string|int $key): Promise
    {
        $cacheKey = (string) $key;

        if (isset($this->promiseCache[$cacheKey])) {
            return $this->promiseCache[$cacheKey];
        }

        if (!in_array($key, $this->queue, true)) {
            $this->queue[] = $key;
        }

        $promise = $this->promiseAdapter->create(
            function (callable $resolve) use ($key): void {
                // Deferred resolution — actual value is filled in by dispatchQueue()
                $this->pendingResolvers[(string) $key] = $resolve;
            }
        );

        $this->promiseCache[$cacheKey] = $promise;

        return $promise;
    }

    /**
     * Executes the batch load function once for all queued keys and
     * resolves every pending promise with its matching result.
     *
     * @return void
     */
    public function dispatchQueue(): void
    {
        if ($this->queue === []) {
            return;
        }

        $keys = $this->queue;
        $this->queue = [];

        /** @var array<int, mixed> $results */
        $results = ($this->batchLoadFn)($keys);

        foreach ($keys as $index => $key) {
            $resolve = $this->pendingResolvers[(string) $key] ?? null;
            if ($resolve !== null) {
                $resolve($results[$index] ?? null);
            }
        }
    }
}

In der Praxis reicht diese Basisklasse nicht ganz aus, ohne dass ein zentraler "Tick-Trigger" existiert, der dispatchQueue() zum richtigen Zeitpunkt aufruft. In webonyx/graphql-php übernimmt das der SyncPromiseAdapter, der beim Warten auf ein Promise automatisch alle offenen Queues abarbeitet. Das DataLoader Pattern steht und fällt mit diesem Trigger-Mechanismus, weshalb die Adapter-Integration nicht optional, sondern der eigentliche Kern der Implementierung ist.

4. Integration mit webonyx/graphql-php Resolvern

Damit Resolver einen DataLoader nutzen können, muss die Instanz pro Request neu erzeugt und über den GraphQL-Kontext bereitgestellt werden. Ein häufiger Fehler ist, den DataLoader als Singleton zu registrieren: Dann würden Ergebnisse aus einem früheren Request in einem neuen Request wiederverwendet, was zu veralteten oder sogar falschen Daten führt. Der Context-Aufbau pro Request ist deshalb ein zentraler Bestandteil jeder produktiven DataLoader-Integration.


<?php

declare(strict_types=1);

namespace Mironsoft\GraphQlPerformance\Resolver;

use Mironsoft\GraphQlPerformance\DataLoader\DataLoader;
use Mironsoft\GraphQlPerformance\Repository\CustomerRepositoryInterface;

/**
 * Field resolver for Order.customer using the DataLoader Pattern.
 */
final class OrderCustomerResolver
{
    public function __construct(
        private readonly CustomerRepositoryInterface $customerRepository,
    ) {
    }

    /**
     * Creates a fresh DataLoader instance, scoped to a single request context.
     *
     * @param mixed $context GraphQL request context, holds the promise adapter
     * @return DataLoader Configured customer loader
     */
    public function createLoader(mixed $context): DataLoader
    {
        return new DataLoader(
            $context->promiseAdapter,
            function (array $customerIds): array {
                // Single query for all customer IDs collected in this tick
                $customers = $this->customerRepository->getByIds($customerIds);
                $indexed = [];
                foreach ($customers as $customer) {
                    $indexed[$customer->getId()] = $customer;
                }
                // Preserve input order, fill gaps with null for missing customers
                return array_map(
                    static fn (int $id) => $indexed[$id] ?? null,
                    $customerIds
                );
            }
        );
    }

    /**
     * Resolves Order.customer via the batched, deduplicated loader.
     *
     * @param object $order Order object exposing a customer_id field
     * @param array<string, mixed> $args GraphQL field arguments
     * @param mixed $context Request context holding the shared DataLoader
     * @return mixed Promise resolving to the customer object
     */
    public function resolve(object $order, array $args, mixed $context): mixed
    {
        // Loader is created once per request and reused across all orders in the list
        $loader = $context->loaders['customer'] ??= $this->createLoader($context);

        return $loader->load($order->customer_id);
    }
}

Wichtig ist die ??=-Zuweisung im Resolver: Der Loader wird nur beim ersten Aufruf pro Feldtyp und Request erzeugt, jeder weitere Aufruf für dasselbe Feld in derselben Liste nutzt dieselbe Instanz und damit dieselbe Warteschlange. Genau dieses Teilen der Loader-Instanz über alle Geschwister-Resolver hinweg ist der Mechanismus, der aus zehn Einzelqueries eine einzige Batch-Query macht. Ohne geteilte Instanz würde jeder Resolver seinen eigenen, isolierten DataLoader erzeugen, und das DataLoader Pattern hätte keinen Effekt.

5. Request-Cache: Wann der DataLoader nicht cachen darf

Der interne promiseCache im DataLoader übernimmt zwei Aufgaben gleichzeitig: Deduplizierung von Keys innerhalb der Warteschlange und Zwischenspeicherung von Ergebnissen für wiederholte load()-Aufrufe mit demselben Key innerhalb desselben Requests. Dieses Verhalten ist bewusst auf die Lebensdauer eines einzelnen Requests begrenzt. Ein DataLoader, dessen Cache über mehrere Requests hinweg bestehen bleibt, liefert bei sich ändernden Daten veraltete Ergebnisse, etwa wenn zwischen zwei Requests ein Kundendatensatz aktualisiert wurde.

Ein zweiter Fallstrick: Wenn innerhalb desselben Requests ein Datensatz durch eine Mutation verändert wird, muss der zugehörige Cache-Eintrag im DataLoader explizit invalidiert werden, sonst liest ein nachfolgender load()-Aufruf den veralteten, vor der Mutation zwischengespeicherten Wert. Eine clear($key)-Methode, die den entsprechenden Eintrag aus promiseCache entfernt, gehört deshalb in jede produktionsreife DataLoader-Implementierung, insbesondere in Schemas, die Query und Mutation in derselben Ausführung mischen.

6. DataLoader für n:m-Beziehungen einsetzen

Bei 1:1- oder n:1-Beziehungen wie Bestellung zu Kunde liefert die Batch-Funktion pro Key genau einen Wert. Bei n:m-Beziehungen, etwa Produkt zu Kategorien, muss die Batch-Funktion pro Key ein Array von Werten zurückgeben. Das DataLoader Pattern bleibt strukturell identisch, nur die Rückgabeform der Batch-Funktion ändert sich: Statt [$id => $wert] wird [$id => [$wert1, $wert2, ...]] erwartet, wobei jeder Eintrag wieder in der Reihenfolge der Input-Keys stehen muss.


<?php

// Batch function for a many-to-many relation: product -> categories
function createCategoriesByProductLoader(PromiseAdapter $adapter, CategoryRepositoryInterface $repo): DataLoader
{
    return new DataLoader(
        $adapter,
        function (array $productIds) use ($repo): array {
            // Single query joining product_category for all requested product IDs
            $rows = $repo->getCategoriesGroupedByProductIds($productIds);

            // Group rows by product ID, default to empty array for products without categories
            $grouped = array_fill_keys($productIds, []);
            foreach ($rows as $row) {
                $grouped[$row->product_id][] = $row->category;
            }

            return array_values($grouped);
        }
    );
}

Die Gruppierung mit array_fill_keys() stellt sicher, dass auch Produkte ohne zugeordnete Kategorien einen leeren Array-Eintrag statt null erhalten, was im GraphQL-Schema für ein Listenfeld korrekt ist. Dieses Muster lässt sich auf beliebige n:m-Beziehungen übertragen, etwa Tags zu Artikeln oder Berechtigungen zu Benutzerrollen, und ist einer der Fälle, in denen das DataLoader Pattern gegenüber naivem Eager-Loading die größten Performance-Gewinne bringt.

7. Fehlerbehandlung im Batch: einzelne Fehler pro Key

Eine Batch-Query, die für neun von zehn IDs erfolgreich ist und bei der zehnten einen Datenbankfehler auslöst, darf nicht den kompletten Batch fehlschlagen lassen. Die Spezifikation des DataLoader Pattern sieht deshalb vor, dass die Batch-Funktion für einzelne Keys ein Error-Objekt statt eines Werts zurückgeben kann. Der DataLoader erkennt diesen Fall und lässt nur das betroffene Promise fehlschlagen, während alle anderen Promises im selben Batch normal aufgelöst werden.

In der GraphQL-Ausführung übersetzt sich ein fehlgeschlagenes Promise in einen partiellen Fehler: Das entsprechende Feld im Ergebnis wird null, ein Eintrag erscheint im errors-Array der Response, aber Geschwisterfelder und andere Listenelemente bleiben unberührt. Dieses Verhalten folgt exakt dem GraphQL-Fehlermodell für nullable Felder und ist einer der Gründe, warum Resolver-Ketten mit dem DataLoader Pattern robuster sind als ein einzelner großer SQL-Join, bei dem ein Fehler die gesamte Query zum Scheitern bringt.

8. Performance messen: Query-Zähler vor und nach dem DataLoader Pattern

Ohne Messung bleibt die Wirkung des DataLoader Pattern eine Behauptung. Ein einfacher, aber wirkungsvoller Ansatz ist ein Query-Zähler, der als Middleware um den Datenbank-Layer gelegt wird und pro Request die Anzahl ausgeführter Queries protokolliert. Der Vergleich vor und nach der Umstellung macht den Effekt sofort sichtbar, meist von linear wachsender Query-Zahl zu einer konstanten, kleinen Anzahl von Batch-Queries pro Feldtyp.


# Compare query counts before and after introducing the DataLoader Pattern
# Requires a query counter middleware that logs to /var/log/graphql/queries.log

# Before: naive per-row loading
curl -s -X POST https://api.example.test/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ orders(first: 10) { id customer { name } } }"}' > /dev/null
grep -c "SELECT" /var/log/graphql/queries.log
# Typical output: 11 (1 list query + 10 individual customer queries)

# After: DataLoader Pattern with batching
grep -c "SELECT" /var/log/graphql/queries.log
# Typical output: 2 (1 list query + 1 batched customer query)

Bei realen Deployments ist der Unterschied selten so klein wie im Beispiel: Verschachtelte Felder wie Bestellung, Kunde, Adresse und Zahlungsart potenzieren die Anzahl der Einzelqueries, während der DataLoader pro Feldtyp konstant bei einer einzigen Batch-Query bleibt, unabhängig von der Listengröße. Neben dem Query-Zähler lohnt sich ein Blick auf die p95-Latenz der GraphQL-Response im APM-Tool, da die Reduktion der Datenbank-Roundtrips sich direkt auf die Antwortzeit unter Last auswirkt.

9. DataLoader Pattern im Vergleich zu Alternativen

Es gibt mehrere Strategien gegen N+1-Probleme in GraphQL, die sich in Aufwand, Flexibilität und Wartbarkeit deutlich unterscheiden. Die folgende Tabelle stellt das DataLoader Pattern den gängigen Alternativen gegenüber.

Strategie Aufwand Deckt tiefe Verschachtelung ab Eignung
Naives Resolver-Loading Sehr gering Nein Prototypen, kleine Listen
Eager-Loading mit JOIN Mittel Begrenzt Feste, flache Schemas
DataLoader Pattern Mittel, einmalig Ja Produktive GraphQL-APIs jeder Größe
Response-Cache pro Feld Gering Nein Ergänzend zum DataLoader Pattern
Manuelles Query-Batching pro Endpunkt Hoch Teilweise Legacy-APIs ohne Resolver-Architektur

Die Tabelle zeigt, warum das DataLoader Pattern in der Praxis den anderen Optionen meist vorgezogen wird: Es kombiniert vollständige Abdeckung beliebig tiefer Verschachtelung mit einem überschaubaren, einmaligen Implementierungsaufwand pro Datentyp. Response-Cache und DataLoader Pattern schließen sich dabei nicht aus, sondern ergänzen sich, weil der Response-Cache über Requests hinweg wirkt, während der DataLoader-Cache strikt auf einen einzelnen Request begrenzt bleibt.

Mironsoft

GraphQL-Architektur, Performance-Tuning und Magento-Integration

GraphQL-Resolver, die auch unter Last schnell bleiben?

Wir analysieren bestehende GraphQL-Schemas, finden N+1-Stellen in Resolver-Ketten und implementieren das DataLoader Pattern dort, wo es echten Performance-Gewinn bringt, inklusive Messung und Monitoring.

Resolver-Audit

Query-Zähler und N+1-Analyse für bestehende GraphQL-Schemas

DataLoader-Implementierung

Batching, Request-Cache und Fehlerbehandlung nach Best Practices

Performance-Monitoring

p95-Latenz und Query-Zahl kontinuierlich im APM-Tool im Blick

10. Zusammenfassung

Das DataLoader Pattern löst das N+1 Problem in GraphQL PHP, ohne die saubere, isolierte Struktur einzelner Resolver aufzugeben. Der Kern ist eine Batch-Load-Funktion, die Keys innerhalb eines Ausführungs-Ticks sammelt, dedupliziert und in einer einzigen Query auflöst, kombiniert mit einem Request-scoped Cache, der wiederholte Anfragen für denselben Key ohne erneuten Datenbankzugriff beantwortet. Mit webonyx/graphql-php übernimmt der SyncPromiseAdapter das Triggern der Batch-Ausführung, wodurch sich das Pattern trotz fehlendem echten Event-Loop nahezu identisch zur JavaScript-Referenzimplementierung verhält.

Für n:m-Beziehungen liefert die Batch-Funktion Arrays statt Einzelwerte, für Fehlerfälle unterstützt das Pattern granulare Fehler pro Key statt eines kompletten Batch-Abbruchs. Wer den Effekt sichtbar machen will, misst die Anzahl ausgeführter Datenbankqueries vor und nach der Umstellung, typischerweise sinkt sie von linear wachsend auf eine konstante Anzahl pro Feldtyp. Damit ist das DataLoader Pattern die Standardlösung für performante GraphQL-Resolver in PHP, unabhängig davon, ob das Schema selbst gebaut oder Teil einer bestehenden Plattform-Integration ist.

DataLoader Pattern in GraphQL PHP — Das Wichtigste auf einen Blick

Batching

Keys werden in einer Warteschlange gesammelt und erst am Ende des Ticks in einer einzigen Query aufgelöst.

Request-Cache

Ergebnisse werden nur innerhalb desselben Requests gecacht, nie über Requests hinweg, sonst drohen veraltete Daten.

n:m-Beziehungen

Batch-Funktion liefert Arrays statt Einzelwerte, gruppiert per Key, mit leeren Arrays statt null.

Messbarkeit

Query-Zähler und p95-Latenz vor und nach der Umstellung vergleichen, um den Effekt zu belegen.

11. FAQ: DataLoader Pattern in GraphQL PHP

1Was ist das DataLoader Pattern in GraphQL?
Sammelt Ladeanfragen innerhalb eines Ticks in einer Warteschlange und löst sie gebündelt in einer einzigen Batch-Query auf, statt N einzelne Queries pro Liste auszuführen.
2Warum reicht ein JOIN nicht?
GraphQL erlaubt beliebig tiefe, clientgesteuerte Query-Bäume. Zur Bauzeit ist nicht bekannt, welche Felder kombiniert werden, ein fester JOIN deckt das nicht ab.
3Muss der DataLoader pro Request neu erzeugt werden?
Ja, unbedingt. Ein Singleton über mehrere Requests hinweg liefert veraltete Daten aus früheren Requests zurück.
4Wie funktioniert das ohne echten Event-Loop?
webonyx/graphql-php nutzt SyncPromise und einen SyncPromiseAdapter, der die Batch-Ausführung synchron beim Warten auf ein Promise triggert.
5Wie behandelt es n:m-Beziehungen?
Die Batch-Funktion liefert pro Key ein Array statt eines Einzelwerts, fehlende Zuordnungen als leeres Array statt null.
6Was passiert bei einem fehlgeschlagenen Key?
Nur das betroffene Promise schlägt fehl, alle anderen Keys im Batch werden normal aufgelöst. Im Ergebnis wird nur das eine Feld null.
7Wie misst man die Wirkung?
Query-Zähler als Middleware, Vergleich der Query-Anzahl vor und nach der Umstellung, ergänzend p95-Latenz im APM-Tool beobachten.
8DataLoader und Response-Cache gleichzeitig?
Ja, beide ergänzen sich sinnvoll. DataLoader wirkt innerhalb eines Requests, Response-Cache über Requests hinweg.
9Muss der Cache manuell invalidiert werden?
Bei Mutationen im selben Request sollte der betroffene Eintrag per clear()-Methode entfernt werden, sonst liest ein späterer load()-Aufruf den veralteten Wert.
10Ab welcher Listengröße lohnt es sich?
Praktisch ab jeder Liste mit mehr als einem Element und verschachtelten Feldern, der Gewinn skaliert linear mit der Listengröße.