Doctrine Batch Processing großer Datenmengen
AI generated
SF
{ }
Symfony · Doctrine ORM · Performance · Batch Processing
Doctrine Batch Processing großer Datenmengen
Millionen Datensätze ohne Memory-Leak verarbeiten

Naive Entity-Iteration über Millionen Datensätze führt zuverlässig in ein Memory-Limit, weil der UnitOfWork jede geladene Entity im Speicher hält. EntityManager::clear(), toIterable() und Bulk-DQL-Updates lösen dieses Problem, jeweils mit eigenen Kompromissen.

19 Min. Lesezeit Batch Processing · toIterable · Bulk-Update · Messenger Symfony 7 · Doctrine ORM 3 · PHP 8.4

1. Warum naive Entity-Iteration bei Millionen Zeilen scheitert

Der Doctrine UnitOfWork verfolgt jede geladene Entity, um Änderungen zu erkennen und beim nächsten flush() die passenden SQL-Statements zu generieren. Diese Nachverfolgung ist der Kern der Change-Tracking-Funktionalität von Doctrine, hat aber einen Preis: Jede geladene Entity bleibt im Speicher, solange der EntityManager existiert. Bei einer Schleife über zehn Millionen Zeilen mit $query->getResult() versucht Doctrine, alle zehn Millionen Entities gleichzeitig zu hydratisieren und im UnitOfWork zu verfolgen, was das PHP-Memory-Limit unweigerlich sprengt.

Dieses Verhalten überrascht viele Entwickler, weil kleinere Datenmengen im Test problemlos funktionieren und das Problem erst in Produktion mit echten Datenvolumina sichtbar wird. Doctrine Batch Processing bezeichnet die Gesamtheit der Techniken, die dieses Grundproblem lösen: Daten in kontrollierten, kleineren Portionen verarbeiten, den UnitOfWork regelmäßig zurücksetzen, und wo möglich, die Entity-Hydration komplett umgehen.

Der Kernkonflikt bei Doctrine Batch Processing ist immer derselbe: Volle ORM-Funktionalität mit Events, Lifecycle-Callbacks und Change-Tracking kostet Speicher pro Entity. Wer diesen Speicher sparen will, muss auf einen Teil dieser Funktionalität verzichten. Die folgenden Abschnitte zeigen die verschiedenen Punkte auf dieser Skala, von vollem ORM-Komfort mit periodischem Reset bis zu reinem SQL ohne jede Hydration.

2. Das Grundmuster: EntityManager::clear() und Batch-Size

Das grundlegende Muster für Doctrine Batch Processing kombiniert eine feste Batch-Größe mit periodischen Aufrufen von $entityManager->clear(). Der Aufruf von clear() löst den UnitOfWork komplett von allen verfolgten Entities, wodurch der Garbage Collector von PHP den zuvor belegten Speicher freigeben kann. Ohne diesen periodischen Reset wächst der Speicherverbrauch linear mit der Anzahl verarbeiteter Zeilen, unabhängig davon, ob flush() zwischendurch aufgerufen wird oder nicht.


<?php

declare(strict_types=1);

namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;

final readonly class ProductPriceRecalculator
{
    private const int BATCH_SIZE = 500;

    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {
    }

    public function recalculateAll(): int
    {
        $query = $this->entityManager
            ->createQuery('SELECT p FROM App\Entity\Product p ORDER BY p.id ASC');

        $processed = 0;

        foreach ($query->toIterable() as $product) {
            $product->recalculatePrice();
            ++$processed;

            if ($processed % self::BATCH_SIZE === 0) {
                $this->entityManager->flush();
                // Detach all managed entities to free memory held by the UnitOfWork
                $this->entityManager->clear();
            }
        }

        // Flush the remainder that did not reach a full batch
        $this->entityManager->flush();

        return $processed;
    }
}

Ein wichtiger Fallstrick bei diesem Muster: Nach clear() sind alle zuvor geladenen Entities losgelöst (detached) und dürfen nicht mehr referenziert werden, ohne sie neu über find() zu laden. Wer eine Referenz auf eine bereits geclearte Entity außerhalb der Schleife hält und versucht, sie zu speichern, erhält eine ORMInvalidArgumentException. Bei Doctrine Batch Processing mit clear() muss der gesamte Verarbeitungszustand pro Batch in sich abgeschlossen sein.

3. Iterable Queries mit toIterable()

Der zweite Baustein für effizientes Doctrine Batch Processing, bereits im vorherigen Beispiel verwendet, ist toIterable() statt getResult(). Während getResult() das komplette Ergebnis-Set in einem Rutsch aus der Datenbank abruft und alle Zeilen sofort hydratisiert, nutzt toIterable() einen serverseitigen Cursor und liefert Entities einzeln, während die Iteration fortschreitet. Der Speicherverbrauch für die reine Datenübertragung bleibt dadurch konstant, unabhängig von der Gesamtzahl der Zeilen.

Wichtig zu verstehen: toIterable() allein löst das Speicherproblem des UnitOfWork nicht, es löst nur das Problem des Result-Set-Buffers. Ohne die kombinierte Verwendung mit periodischem clear() wächst der UnitOfWork trotzdem linear, weil jede über den Cursor gelesene Entity weiterhin verfolgt wird, bis sie explizit entfernt wird. Effektives Doctrine Batch Processing braucht immer beide Techniken zusammen, den Cursor für die Datenübertragung und das periodische Clearing für den UnitOfWork.

4. Bulk-Updates mit DQL statt Entity-Hydration

Für reine Datenänderungen, die keine Lifecycle-Callbacks oder Entity-Events benötigen, ist ein DQL UPDATE- oder DELETE-Statement erheblich effizienter als jede Form von Entity-Hydration. Ein solches Bulk-Statement wird direkt in SQL übersetzt und auf der Datenbank ausgeführt, ohne eine einzige Entity in PHP zu laden.


<?php

declare(strict_types=1);

namespace App\Service;

use Doctrine\ORM\EntityManagerInterface;

final readonly class InactiveProductArchiver
{
    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {
    }

    public function archiveInactiveProducts(\DateTimeImmutable $cutoff): int
    {
        // Bulk DQL UPDATE — no entities are loaded into memory at all
        $query = $this->entityManager->createQuery(
            'UPDATE App\Entity\Product p
             SET p.status = :archived
             WHERE p.lastOrderedAt < :cutoff AND p.status != :archived'
        );

        $query->setParameter('archived', 'archived');
        $query->setParameter('cutoff', $cutoff);

        return $query->execute();
    }
}

Die entscheidende Einschränkung bei Bulk-DQL im Kontext von Doctrine Batch Processing: Doctrine-Events wie preUpdate und postUpdate werden bei Bulk-Statements nicht ausgelöst, weil keine Entity-Objekte durch den regulären Persistenz-Zyklus laufen. Wenn Anwendungslogik von diesen Events abhängt, etwa Audit-Logging oder Cache-Invalidierung über Lifecycle-Callbacks, muss diese Logik separat und explizit nach dem Bulk-Update ausgeführt werden.

5. Batch Processing mit dem Messenger auslagern

Für Datenmengen, bei denen selbst ein gebatchter Durchlauf das Zeitlimit eines einzelnen HTTP-Requests oder Cron-Jobs überschreitet, ist die richtige Antwort, das Doctrine Batch Processing in mehrere unabhängige, asynchrone Nachrichten über Symfonys Messenger-Komponente aufzuteilen. Ein initialer Handler ermittelt die Gesamtzahl der zu verarbeitenden Datensätze und verteilt die Arbeit in Chunks, jeder Chunk als eigene Message, die von einem Worker unabhängig verarbeitet wird.


<?php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Message\RecalculatePriceChunk;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final readonly class RecalculatePriceChunkHandler
{
    public function __construct(
        private EntityManagerInterface $entityManager,
    ) {
    }

    public function __invoke(RecalculatePriceChunk $message): void
    {
        $query = $this->entityManager->createQuery(
            'SELECT p FROM App\Entity\Product p WHERE p.id BETWEEN :from AND :to'
        );
        $query->setParameter('from', $message->fromId);
        $query->setParameter('to', $message->toId);

        foreach ($query->toIterable() as $product) {
            $product->recalculatePrice();
        }

        // One flush and clear per chunk, each chunk handled by an isolated worker call
        $this->entityManager->flush();
        $this->entityManager->clear();
    }
}

Diese Aufteilung bringt zwei Vorteile für Doctrine Batch Processing mit sich: Jeder Chunk läuft in einem eigenen, kurzlebigen Worker-Aufruf, wodurch selbst ein Speicherleck in einem Chunk keine Auswirkung auf die anderen hat. Zusätzlich lässt sich die Verarbeitung horizontal parallelisieren, indem mehrere Messenger-Worker gleichzeitig unterschiedliche Chunks abarbeiten, was die Gesamtlaufzeit bei ausreichend vorhandenen CPU-Kernen und Datenbankkapazität deutlich verkürzt.

6. Natives SQL für maximale Bulk-Insert-Performance

Für das Einfügen sehr großer Datenmengen, etwa beim initialen Import einer Millionen Zeilen umfassenden CSV-Datei, ist selbst ein gebatchtes persist()/flush()-Muster über den EntityManager langsamer als ein natives Multi-Row-INSERT-Statement. Jede Doctrine-Entity durchläuft beim Persistieren Change-Tracking, Validierung und Event-Dispatching, Overhead, der beim reinen Massenimport nicht gebraucht wird.

Für solche Fälle nutzt effizientes Doctrine Batch Processing die native Connection-API von Doctrine DBAL direkt, mit vorbereiteten Multi-Row-INSERT-Statements in Batches von einigen hundert Zeilen pro Statement. Dieser Ansatz verzichtet vollständig auf Entity-Hydration und ORM-Overhead, erfordert aber, dass sämtliche Validierungslogik, die sonst im Entity-Konstruktor oder in Lifecycle-Callbacks steckt, vor dem Insert manuell in PHP nachgebildet wird, da die Datenbank selbst nur grundlegende Constraint-Prüfungen durchführt.

7. Progress-Tracking und Fehlerbehandlung

Ein langlaufender Batch-Prozess ohne Fortschrittsanzeige ist aus Betriebssicht ein Blackbox-Risiko: Niemand weiß, ob der Prozess noch läuft, hängt oder bereits fertig ist. Symfonys Console-Komponente bietet mit der ProgressBar-Klasse eine einfache Möglichkeit, den Fortschritt bei CLI-gesteuertem Doctrine Batch Processing sichtbar zu machen, inklusive geschätzter Restlaufzeit basierend auf der bisherigen Verarbeitungsgeschwindigkeit.

Fehlerbehandlung braucht bei Batch-Prozessen eine bewusste Entscheidung: Soll ein einzelner fehlerhafter Datensatz den gesamten Batch abbrechen, oder soll er übersprungen und protokolliert werden, während der Rest weiterläuft? Für die meisten produktiven Doctrine Batch Processing-Szenarien ist die zweite Option robuster, mit einem try-catch pro Einzeldatensatz innerhalb der Batch-Schleife und einer separaten Fehlerliste, die am Ende des Laufs ausgewertet wird, statt den kompletten Fortschritt bei einem einzelnen kaputten Datensatz zu verlieren.

8. Speicherprofiling mit memory_get_usage() und Blackfire

Ohne Messung bleibt jede Optimierung von Doctrine Batch Processing Vermutung. memory_get_usage(true) liefert den tatsächlich vom PHP-Prozess allokierten Speicher und lässt sich direkt in die Batch-Schleife einbauen, um den Speicherverlauf über die Verarbeitung hinweg zu protokollieren. Ein stetig steigender Speicherverbrauch trotz periodischem clear() deutet fast immer darauf hin, dass irgendwo im Code eine Referenz auf gecleartes Entity-Material bewahrt wird, etwa in einem Array außerhalb der Schleife.

Für tiefere Analysen liefert Blackfire mit seinem Memory-Profiling detaillierte Call-Graphen, die genau zeigen, welche Funktion wie viel Speicher alloziert. Bei Doctrine Batch Processing zeigt sich in solchen Profilen häufig, dass der Doctrine-eigene Second Level Cache, sofern aktiviert, ebenfalls Speicher über die gesamte Laufzeit des Batch-Prozesses akkumuliert, ein Effekt, der beim Aktivieren des Second Level Cache für Batch-Kommandos separat berücksichtigt werden sollte.

9. Batch-Ansätze im direkten Vergleich

Die folgende Tabelle stellt die vorgestellten Techniken für Doctrine Batch Processing gegenüber und zeigt, welcher Ansatz bei welchem Datenvolumen und welchen Anforderungen an ORM-Funktionalität die richtige Wahl ist.

Ansatz Speicherverbrauch ORM-Events Typischer Einsatz
getResult() ohne Batching Linear wachsend, unbegrenzt Vollständig Nur kleine Datenmengen
toIterable() + clear() Konstant pro Batch Vollständig Fachlogik pro Entity noetig
Bulk-DQL UPDATE/DELETE Minimal Keine Reine Datenänderung ohne Events
Messenger-Chunking Konstant pro Worker Vollständig, isoliert pro Chunk Sehr lange Laufzeiten, Parallelisierung
Native Bulk-Inserts Minimal Keine Initialer Massenimport

Die Tabelle macht den zentralen Kompromiss bei Doctrine Batch Processing sichtbar: Je mehr ORM-Komfort erhalten bleibt, desto mehr Speicher und Zeit kostet die Verarbeitung. Die richtige Wahl hängt davon ab, ob Fachlogik pro Datensatz benötigt wird oder ob eine reine, event-lose Datenänderung ausreicht.

Mironsoft

Symfony-Performance, Doctrine-Batchverarbeitung und Datenmigrationen

Große Datenmengen ohne Memory-Leak verarbeiten?

Wir analysieren eure Batch-Prozesse, beheben Memory-Leaks im UnitOfWork und richten Messenger-Chunking oder native Bulk-Operationen ein, damit auch Millionen Datensätze zuverlässig verarbeitet werden.

Memory-Profiling

Speicherlecks in bestehenden Batch-Kommandos identifizieren

Batch-Refactoring

toIterable, Bulk-DQL und native Inserts richtig kombinieren

Messenger-Architektur

Chunking und Parallelisierung für sehr große Datenmengen

10. Zusammenfassung

Doctrine Batch Processing löst ein strukturelles Problem des UnitOfWork: Ohne periodisches Zurücksetzen verfolgt Doctrine jede geladene Entity im Speicher, was bei großen Datenmengen unweigerlich das Memory-Limit sprengt. Die Kombination aus toIterable() für konstanten Result-Set-Speicher und periodischem flush() plus clear() für den UnitOfWork ist das Grundmuster für jede Fachlogik, die pro Entity ausgeführt werden muss.

Wo keine Entity-Events benötigt werden, sind Bulk-DQL-Statements oder native SQL-Inserts erheblich effizienter, weil sie die Hydration komplett umgehen. Für Datenmengen, die selbst gebatcht zu lange für ein einzelnes Zeitfenster brauchen, verteilt Messenger-Chunking die Arbeit auf mehrere unabhängige, parallelisierbare Worker-Aufrufe. Speicherprofiling mit memory_get_usage() und Blackfire macht sichtbar, ob die gewählte Strategie tatsächlich konstanten Speicherverbrauch erzeugt, bevor der Prozess in Produktion auf echten Datenvolumina läuft.

Doctrine Batch Processing großer Datenmengen — Das Wichtigste auf einen Blick

Grundmuster

toIterable() statt getResult(), kombiniert mit periodischem flush() plus clear().

Ohne Entity-Events

Bulk-DQL UPDATE/DELETE oder native SQL-Inserts umgehen Hydration komplett.

Sehr große Datenmengen

Messenger-Chunking verteilt Arbeit auf isolierte, parallelisierbare Worker-Aufrufe.

Messen statt raten

memory_get_usage() und Blackfire zeigen, ob der Speicherverbrauch tatsächlich konstant bleibt.

11. FAQ: Doctrine Batch Processing großer Datenmengen

1Warum entsteht ein Memory-Leak bei einer einfachen Schleife?
Der UnitOfWork verfolgt jede geladene Entity im Speicher, solange der EntityManager existiert.
2Was macht EntityManager::clear()?
Loest den UnitOfWork von allen verfolgten Entities, damit der Garbage Collector Speicher freigeben kann.
3Loest toIterable() alles allein?
Nein, nur den Result-Set-Buffer. Der UnitOfWork braucht zusaetzlich periodisches clear().
4Wann Bulk-DQL verwenden?
Wenn keine Lifecycle-Callbacks oder Events benoetigt werden, spart Bulk-DQL Hydration komplett.
5Werden Events bei Bulk-DQL ausgeloest?
Nein, preUpdate und postUpdate werden bei Bulk-Statements nicht ausgefuehrt.
6Wann Messenger-Chunking einsetzen?
Bei zu langer Laufzeit fuer ein einzelnes Zeitfenster oder zur Parallelisierung ueber mehrere Worker.
7Warum sind native Bulk-Inserts schneller?
Sie umgehen Change-Tracking, Validierung und Event-Dispatching des ORM komplett.
8Umgang mit fehlerhaften Einzeldatensaetzen?
try-catch pro Datensatz mit separater Fehlerliste statt kompletten Abbruch.
9Wie Speicherverbrauch messen?
memory_get_usage(true) in der Schleife, Blackfire fuer detaillierte Call-Graphen.
10Beeinflusst der Second Level Cache Batch Processing?
Ja, er akkumuliert ebenfalls Speicher ueber die Laufzeit, falls aktiviert.