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.
Inhaltsverzeichnis
- 1. Warum naive Entity-Iteration bei Millionen Zeilen scheitert
- 2. Das Grundmuster: EntityManager::clear() und Batch-Size
- 3. Iterable Queries mit toIterable()
- 4. Bulk-Updates mit DQL statt Entity-Hydration
- 5. Batch Processing mit dem Messenger auslagern
- 6. Natives SQL für maximale Bulk-Insert-Performance
- 7. Progress-Tracking und Fehlerbehandlung
- 8. Speicherprofiling mit memory_get_usage() und Blackfire
- 9. Batch-Ansätze im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
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.