19.565 Datensätze, 1 GB Memory: XLSX-Exporte in TYPO3 mit OpenSpout streamen
PhpSpreadsheet ist bequem, aber speicherhungrig. Wie wir den XLSX-Export in einem TYPO3-Backend-Modul auf OpenSpout umgestellt und damit Memory-Limits eliminiert haben.
Wenn der Export-Button stumm versagt
Im “Datenrecherche”-Backend-Modul von VINUM.eu können Redaktion und Datenpflege beliebige Treffermengen aus dem Wein- und Adressbestand als XLSX exportieren. Ein typischer Use-Case: alle Winzer aus der Schweiz, die in den letzten fünf Jahren mindestens einen 18-Punkte-Wein hatten – für einen redaktionellen Newsletter oder eine Eventeinladung.
Bisher lief das problemlos. Bis ein Redakteur eine Suche mit 19.565 Treffern abgesetzt hat, den Export-Button klickte – und nichts passierte.
Die Herausforderung
Aus Benutzersicht: kein Fehler, kein Download, kein Hinweis. Aus Sicht der Logs: ein PHP-Fatal, sechsmal hintereinander, weil der Redakteur den Button mehrfach gedrückt hatte:
PHP Fatal error: Allowed memory size of 1073741824 bytes exhausted
(tried to allocate 167772160 bytes)
in /var/www/html/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php
on line 395
Das memory_limit der Produktion liegt bei 1 GB – großzügig dimensioniert. PhpSpreadsheet hat es trotzdem gesprengt.
Die Ursache lag nicht in der Datenbankabfrage. Die war bereits gebatcht: BackendSearchHelper::exportXlsx() lädt die Treffer in Hunderter-Schritten über eine Pagination-Hilfsmethode. Das eigentliche Problem war die Spreadsheet-Erstellung:
$spreadsheet = new Spreadsheet();
$worksheet = $spreadsheet->getActiveSheet();
foreach ($paginatedResults as $row) {
$worksheet->fromArray($row, null, "A{$rowIndex}");
$rowIndex++;
}
$writer = new Xlsx($spreadsheet);
$writer->save($tmpFile);
PhpSpreadsheet hält intern die komplette Cells-Collection im Arbeitsspeicher, bis save() aufgerufen wird. Egal wie elegant man die Daten in 100er-Blöcken liest – die Mappe selbst wächst monoton mit. Bei rund 19.500 Zeilen × der vollständigen TCA-Spaltenliste war Schluss.
Im Frontend gab es zusätzlich ein UI-Problem: Der Export-Button wurde durch ein <f:if condition="{pagination.count} <= 35000"> ausgeblendet, sobald mehr als 35.000 Treffer in der Suche standen. Bei genau 19.565 war der Button sichtbar – und scheiterte trotzdem. Schlimmer noch: Beim Überschreiten der Grenze verschwand der Button kommentarlos, ohne dem Nutzer zu erklären, warum.
Unsere Lösung
1. Streaming-Writer statt In-Memory-Mappe
Wir haben den Export auf OpenSpout migriert – eine Library, die XLSX-Zeilen direkt auf die Festplatte streamt, statt sie im RAM zu sammeln. Der Speicherverbrauch bleibt damit konstant, unabhängig von der Zeilenzahl.
Die Migration war erstaunlich überschaubar, weil sich die API-Konzepte ähneln:
use OpenSpout\Common\Entity\Row;
use OpenSpout\Writer\XLSX\Writer;
$writer = new Writer();
$writer->openToFile($tmpFile);
$writer->addRow(Row::fromValues($headers));
foreach ($this->iterateResults($table, $repository, $search) as $row) {
$writer->addRow(Row::fromValues($row));
}
$writer->close();
Statt einer Spreadsheet-Instanz wird ein Writer geöffnet, der direkt in den Output-Stream schreibt. Jeder addRow()-Aufruf flusht die Zeile, der Speicher bleibt im einstelligen MB-Bereich – auch bei 100.000 Zeilen.
Wichtig: openspout/openspout haben wir explizit in der composer.json der backendsearch-Extension deklariert. Vorher griff die Extension auf PhpSpreadsheet zu, das nur transitive Abhängigkeit der Haupt-Extension vinum war. Solche impliziten Vendor-Abhängigkeiten sind eine tickende Zeitbombe: Wenn die übergeordnete Extension irgendwann auf eine andere Library wechselt, bricht der Export ohne sichtbaren Anlass.
2. Ehrliches UI-Gate
Das <f:if> blendet den Button nicht mehr stumm aus, sondern ersetzt ihn durch eine erklärende Zeile:
<f:if condition="{pagination.count} <= {settings.maxExportRows}">
<f:then>
<f:render section="ExportButton" arguments="{_all}" />
</f:then>
<f:else>
<p class="text-muted">
<f:translate
key="export.tooManyResults"
arguments="{0: '{settings.maxExportRows}'}" />
</p>
</f:else>
</f:if>
Auf Deutsch: “Export nur bis 35.000 Treffer möglich. Bitte Suche eingrenzen.” Der Schwellwert ist konfigurierbar, das tatsächliche Limit wird im Text genannt. Mit OpenSpout könnten wir die Obergrenze theoretisch deutlich höher legen – pragmatisch belassen wir sie aber bei 35.000, weil ein Export dieser Größenordnung in Excel ohnehin nicht mehr sinnvoll zu bearbeiten ist.
3. Diagnose-Pfad dokumentieren
Was bei diesem Fix unterschätzt wird: Der größere Aufwand steckte nicht in der Code-Änderung, sondern in der Diagnose. Ohne den Blick in fpm-stderr.log wäre die Ursache nicht eindeutig zuzuordnen gewesen – der Browser meldet keinen Fehler, das Backend liefert eine leere Antwort, und der Button “tut einfach nichts”.
Wir haben den Diagnose-Pfad daher in der Projektdokumentation festgehalten: Welche Logs für welche Symptome relevant sind, welche memory_limit- und max_execution_time-Werte produktiv gelten, und wie man PHP-Fatals einer konkreten Action im Backend zuordnet. Das spart bei der nächsten ähnlichen Meldung schlicht Zeit.
Das Ergebnis
- Der ursprünglich gescheiterte Export mit 19.565 Treffern läuft jetzt durch – ohne PHP-Fatal, mit konstantem Speicherverbrauch.
- Suchen oberhalb des konfigurierten Limits zeigen einen verständlichen Hinweistext statt eines verschwindenden Buttons.
- Die
backendsearch-Extension hat ihre Abhängigkeit zu Excel-Libraries jetzt sauber in der eigenencomposer.jsondeklariert. - Die öffentliche API des Helpers (
exportXlsx(string $table, object $repository, array $search): string) ist unverändert – der Refactoring lief vollständig innerhalb der Implementierung.
Wann lohnt sich der Wechsel?
PhpSpreadsheet ist eine ausgezeichnete Library, wenn man Formatierungen, Formeln, Diagramme oder mehrere Sheets benötigt. Für reine Datentabellen-Exporte mit größeren Treffermengen ist OpenSpout aber fast immer die bessere Wahl: schneller, schlanker, und ohne harte Obergrenze beim Datenvolumen.
Wenn Sie ähnliche Probleme mit Exporten, Imports oder generell speicherintensiven Backend-Modulen in TYPO3 haben, sprechen Sie uns an. Oft genügen punktuelle Eingriffe an den richtigen Stellen, um aus einem “geht nicht mehr” wieder ein “läuft zuverlässig” zu machen.
Über den Autor
Christopher Zechendorf
Christopher Zechendorf leitet die ext.dev GmbH und bringt über 25 Jahre Erfahrung in Webentwicklung, CMS-Systemen und Infrastruktur mit.