
Ein Generator liest eine Datei beliebiger Größe mit rund 2 MB Speicher, während file() bei einer Datei von 73 MB 229 MB verbraucht. Auf PHP 8.5 beträgt das gemessene Verhältnis 114 zu 1, ohne Zeitaufschlag. Um eine Datei umzuwandeln, ohne sie anzusehen, ersparen Stream-Filter das vollständige Laden.
Ein PHP-Skript, das eine Datei von wenigen Megabyte verarbeitet, läuft problemlos. Dasselbe Skript bricht bei einer Datei von 70 MB mit Allowed memory size exhausted ab. Die Ursache ist fast immer dieselbe: Die komplette Datei landet im Speicher, obwohl es gereicht hätte, sie zu durchlaufen. Dieser Artikel misst die verschiedenen Lesestrategien an einer echten Datei und zeigt, welche sich für welche Aufgabe eignet.
Das Messprotokoll
Testdatei: eine CSV von 73,1 MB, 2.000.000 Zeilen, vier Spalten.
<?php
$h = fopen('gros.csv', 'wb');
$tampon = '';
for ($i = 1; $i <= 2_000_000; $i++) {
$tampon .= $i . ',client-' . str_pad((string) ($i % 999999), 6, '0', STR_PAD_LEFT)
. ',' . (($i % 977) + 0.5)
. ',2026-' . str_pad((string) (($i % 12) + 1), 2, '0', STR_PAD_LEFT) . "-15\n";
if (($i % 50_000) === 0) {
fwrite($h, $tampon);
$tampon = '';
}
}
fwrite($h, $tampon);
fclose($h);Jede Strategie läuft in einem eigenen Prozess, mit memory_limit auf 512 MB, in einem Container mit einem Kern und 1 GB. Das ist zwingend: memory_get_peak_usage() behält das seit Skriptbeginn erreichte Maximum, mehrere Strategien im selben Prozess zu messen liefert also falsche Zahlen, die erste Messung verfälscht alle folgenden.
<?php
declare(strict_types=1);
$t0 = hrtime(true);
$resultat = /* … die zu messende Strategie … */;
printf(
"%7.0f ms pic %7.1f Mo %s\n",
(hrtime(true) - $t0) / 1e6,
memory_get_peak_usage(true) / 1_048_576,
$resultat,
);Eine methodische Anmerkung, die uns eine Stunde gekostet hat: Die ersten Messungen liefen auf einem Verzeichnis, das aus macOS in Docker eingebunden war. Die Zahl der gelesenen Zeilen sank bei jedem Durchlauf, 1.990.083, dann 1.989.958, dann 1.989.132, , während die Dateigröße unverändert blieb. Das Mount schnitt lange sequenzielle Lesevorgänge ab. Nachdem die Datei in ein Docker-Volume umgezogen war, waren die Messungen reproduzierbar, mit exakt 2.000.000 Zeilen in jedem Durchlauf. Eine Messung, die grundlos schwankt, ist keine verrauschte Messung, sondern eine falsche.
Eine Datei Zeile für Zeile durchlaufen
Vier Arten, Zeilen zu zählen, gleiche Datei, gleiche Maschine.
// 1. file(): die ganze Datei in einem Array
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);
// 2. fgets in einem Array gesammelt
$lignes = [];
$h = fopen('gros.csv', 'rb');
while (($ligne = fgets($h)) !== false) {
$lignes[] = rtrim($ligne, "\r\n");
}
fclose($h);
$n = count($lignes);
// 3. Generator
function lireLignes(string $chemin): Generator
{
$h = fopen($chemin, 'rb');
try {
while (($ligne = fgets($h)) !== false) {
yield rtrim($ligne, "\r\n");
}
} finally {
fclose($h);
}
}
$n = 0;
foreach (lireLignes('gros.csv') as $ligne) {
$n++;
}
// 4. SplFileObject
$f = new SplFileObject('gros.csv', 'rb');
$f->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD);
$n = 0;
foreach ($f as $ligne) {
if ($ligne !== false && $ligne !== '') {
$n++;
}
}| Strategie | Mittlere Zeit | Speicherspitze |
|---|---|---|
file() | 612 ms | 229,1 MB |
fgets in ein Array | 492 ms | 156,0 MB |
| Generator | 652 ms | 2,0 MB |
SplFileObject | 594 ms | 2,0 MB |
Zwischen file() und dem Generator liegt ein Verhältnis von 114 zu 1. Die Zeiten dagegen liegen dicht beieinander: zwischen 490 und 660 ms, mit einer Streuung von Durchlauf zu Durchlauf, die größer ist als der Abstand zwischen den Strategien. Anders gesagt: Die Datei nicht in den Speicher zu laden kostet keine Geschwindigkeit und senkt den Verbrauch auf ein Hundertstel.
Die 229 MB für eine Datei von 73 MB überraschen oft. Ein PHP-Array mit zwei Millionen kurzen Strings speichert nicht nur die Zeichen: Jeder Eintrag bringt eine Array-Struktur mit und jeder String einen Header. Der Overhead übertrifft die Daten selbst deutlich.
Was das konkret ändert
Mit memory_limit = 128M, einem im Shared Hosting üblichen Wert:
file() -> Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 16777224 bytes)
générateur -> 659 ms, pic 2,0 Mo, 2 000 000 lignesBei 192 MB scheitert file() immer noch. Es braucht 256 MB, um durchzukommen. Der Generator kommt mit 2 MB aus und käme mit einer Datei von 700 MB genauso zurecht: Sein Verbrauch hängt nicht von der Dateigröße ab, sondern nur von der längsten Zeile.
Die Spalten einer CSV verarbeiten
Derselbe Vergleich, diesmal mit der Summe der dritten Spalte.
// A. Alles laden und dann zerlegen
$texte = file_get_contents('gros.csv');
$somme = 0.0;
foreach (explode("\n", $texte) as $ligne) {
if ($ligne === '') { continue; }
$colonnes = explode(',', $ligne);
$somme += (float) ($colonnes[2] ?? 0);
}
// B. Zeile für Zeile lesen und zerlegen
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($ligne = fgets($h)) !== false) {
$colonnes = explode(',', $ligne);
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);
// C. fgetcsv, das Anführungszeichen und maskierte Trennzeichen verarbeitet
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);| Strategie | Mittlere Zeit | Speicherspitze |
|---|---|---|
file_get_contents + explode | 583 ms | 229,1 MB |
fgets + explode | 704 ms | 2,0 MB |
fgetcsv | 4.729 ms | 2,0 MB |
Alle drei liefern dieselbe Summe, auf den Cent genau. Aber fgetcsv ist siebenmal langsamer als explode. Dieser Abstand ist kein Mangel: fgetcsv wendet die Regeln des CSV-Formats an, verarbeitet Anführungszeichen, Trennzeichen innerhalb von Feldern und eingeschlossene Zeilenumbrüche. explode(',') tut nichts davon und bricht, sobald ein Feld ein Komma enthält.
Die Wahl richtet sich also nach der Art der Datei, nicht nach der Performance: fgetcsv für eine CSV aus fremder Hand, explode nur für ein Format, bei dem du garantierst, dass es weder Anführungszeichen noch maskierte Trennzeichen enthält.
Seit PHP 8.4 müssen die letzten drei Parameter von fgetcsv() ausdrücklich übergeben werden, wenn du einen Deprecation-Hinweis zum Escape-Parameter vermeiden willst.
Eine Datei umwandeln, ohne sie zu lesen
Ein anderer Fall: eine Datei komprimieren, ohne ihren Inhalt je ansehen zu müssen. Alles zu laden und an gzencode() zu übergeben funktioniert, aber Stream-Filter erledigen die Arbeit blockweise.
// Im Speicher
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);
// Als Stream
$entree = fopen('gros.csv', 'rb');
$sortie = fopen('gros.csv.gz', 'wb');
stream_filter_append($sortie, 'zlib.deflate', STREAM_FILTER_WRITE, [
'level' => 6,
'window' => 31, // 31 = Gzip-Header; 15 würde rohes zlib erzeugen
]);
stream_copy_to_stream($entree, $sortie);
fclose($entree);
fclose($sortie);| Strategie | Mittlere Zeit | Speicherspitze | Ausgabe |
|---|---|---|---|
gzencode im Speicher | 1.692 ms | 149,3 MB | 13,8 MB |
| Stream-Filter | 1.928 ms | 91,1 MB | 13,8 MB |
Der Speichergewinn ist real, aber weniger spektakulär als beim Lesen: stream_copy_to_stream nutzt einen recht großen internen Puffer. Beide erzeugen eine Datei gleicher Größe. Der Parameter window auf 31 ist das, was eine echte Gzip-Datei von einem rohen zlib-Stream unterscheidet, den gunzip nicht öffnen will.
In Losen verarbeiten
Der in der Praxis häufigste Fall: eine große Datei durchlaufen und die Zeilen paketweise an eine Datenbank oder eine Queue schicken. Der Generator lässt sich gut mit einem Sammler kombinieren.
<?php
declare(strict_types=1);
/**
* @param iterable<mixed> $source
* @return Generator<int, array>
*/
function parLots(iterable $source, int $taille = 1000): Generator
{
$lot = [];
foreach ($source as $element) {
$lot[] = $element;
if (count($lot) === $taille) {
yield $lot;
$lot = [];
}
}
if ($lot !== []) {
yield $lot; // der Rest
}
}$pdo->beginTransaction();
$insertion = $pdo->prepare('INSERT INTO clients (reference, nom, montant) VALUES (?, ?, ?)');
foreach (parLots(lireLignes('gros.csv'), 1000) as $numero => $lot) {
foreach ($lot as $ligne) {
$colonnes = str_getcsv($ligne, ',', '"', '\\');
$insertion->execute([$colonnes[0], $colonnes[1], $colonnes[2]]);
}
if ($numero % 50 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();Das abschließende yield nach der Schleife ist die Zeile, die gern vergessen wird: Ohne sie gehen die letzten Zeilen der Datei, die kein vollständiges Los mehr füllen, stillschweigend verloren.
Die Inserts in Transaktionen zu bündeln macht bei der Laufzeit den entscheidenden Unterschied. Eine Transaktion pro Zeile erzwingt jedes Mal einen Schreibvorgang auf die Platte, ein Commit alle 50.000 Zeilen macht daraus einen pro Los.
Die Zeitlimits
Der Speicher ist nicht die einzige Wand. Auf der Kommandozeile steht max_execution_time auf 0: Das Skript läuft ohne Limit. Hinter einem Webserver sind es meist 30 Sekunden, und der vorgelagerte Server hat sein eigenes Timeout.
// Auf der CLI vorher prüfen, statt sich darauf zu verlassen
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}Die Verarbeitung einer großen Datei hat in einem HTTP-Request nichts zu suchen. Der Request merkt die anstehende Arbeit vor, ein Kommandozeilenbefehl oder eine Queue erledigt sie.
Das Wichtigste in Kürze
- Ein Generator liest eine Datei beliebiger Größe mit 2 MB Speicher.
file()undfile_get_contents()laden alles. - Der Zeitaufschlag ist null, die Messung zeigt es: Diese Entscheidung hat keinen Preis.
fgetcsvist siebenmal langsamer alsexplode, und das ist der Preis der Korrektheit. Bei einer Datei, deren Erzeugung du nicht kontrollierst, ist er fällig.- Stream-Filter komprimieren, ohne alles zu laden.
- Jede Strategie in einem eigenen Prozess messen, sonst lügt
memory_get_peak_usage().
Der Umgang mit dem Speicher zählt noch mehr in einem Prozess, der nie endet: ein WebSocket-Server in PHP misst diese Kosten Verbindung für Verbindung. Für Spalten mit Datumsangaben siehe Datum und Uhrzeit abrufen und formatieren.
Siehe auch die Verbindung zu einer Datenbank in PHP für den Insert-Teil und den Hub Webentwicklung.
Häufige Fehler
memory_limit auf 128M gibt es Allowed memory size of 134217728 bytes exhausted.memory_get_peak_usage() behält das seit Skriptbeginn erreichte Maximum: Die erste Messung verfälscht alle folgenden. Ein Prozess pro Strategie.fgetcsv ist siebenmal langsamer, und das ist der Preis der Korrektheit: Es verarbeitet Anführungszeichen und Trennzeichen innerhalb von Feldern. explode(',') bricht, sobald ein Feld ein Komma enthält.yield nach der Schleife verschwinden die Zeilen, die kein vollständiges Los füllen, stillschweigend.gunzip nicht öffnet. Für eine echte Gzip-Datei braucht es 'window' => 31.max_execution_time steht auf der Kommandozeile auf 0, hinter einem Webserver aber auf 30 Sekunden.

