
Een generator leest een bestand van elke omvang met ongeveer 2 MB geheugen, terwijl file() er 229 MB voor gebruikt op een bestand van 73 MB. Op PHP 8.5 is de gemeten verhouding 114 op 1, zonder extra tijd. Wil je een bestand omzetten zonder de inhoud te bekijken, dan voorkomen streamfilters dat je alles inlaadt.
Een PHP-script dat een bestand van een paar megabyte verwerkt, werkt prima. Hetzelfde script op een bestand van 70 MB stopt op Allowed memory size exhausted. De oorzaak is bijna altijd dezelfde: het hele bestand wordt in het geheugen geladen terwijl je er alleen doorheen hoefde te lopen. Dit artikel meet de verschillende leesstrategieën op een echt bestand en laat zien welke je kiest bij welke verwerking.
Het meetprotocol
Testbestand: een CSV van 73,1 MB, 2.000.000 regels, vier kolommen.
<?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);Elke strategie draait in een apart proces, met memory_limit op 512 MB, in een container met 1 core en 1 GB. Dat is noodzakelijk: memory_get_peak_usage() onthoudt het maximum dat sinds het begin van het script is bereikt, dus meerdere strategieën in hetzelfde proces meten levert verkeerde cijfers op, de eerste meting besmet alle volgende.
<?php
declare(strict_types=1);
$t0 = hrtime(true);
$resultat = /* … de te meten strategie … */;
printf(
"%7.0f ms pic %7.1f Mo %s\n",
(hrtime(true) - $t0) / 1e6,
memory_get_peak_usage(true) / 1_048_576,
$resultat,
);Een methodologische noot die ons een uur heeft gekost: de eerste metingen liepen op een map die vanuit macOS in Docker was gemount. Het aantal gelezen regels daalde bij elke uitvoering, 1.990.083, daarna 1.989.958, daarna 1.989.132, terwijl het bestand even groot bleef. De mount kapte lange sequentiële leesbewerkingen af. Het bestand naar een Docker-volume verplaatsen maakte de metingen reproduceerbaar, met precies 2.000.000 regels bij elke passage. Een meting die zonder reden varieert is een foute meting, geen meting met ruis.
Een bestand regel voor regel doorlopen
Vier manieren om de regels te tellen, hetzelfde bestand, dezelfde machine.
// 1. file(): het hele bestand in een array
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);
// 2. fgets opgestapeld in een array
$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 | Mediane tijd | Piekgeheugen |
|---|---|---|
file() | 612 ms | 229,1 MB |
fgets in een array | 492 ms | 156,0 MB |
| Generator | 652 ms | 2,0 MB |
SplFileObject | 594 ms | 2,0 MB |
De verhouding tussen file() en de generator is 114 op één. De tijden liggen juist dicht bij elkaar: tussen 490 en 660 ms, met een spreiding van passage tot passage die groter is dan het verschil tussen de strategieën. Anders gezegd: het bestand niet in het geheugen laden kost je geen snelheid en deelt het verbruik door honderd.
Dat cijfer van 229 MB voor een bestand van 73 MB verrast vaak. Een PHP-array met twee miljoen korte strings bewaart niet alleen de tekens: elk element draagt een arraystructuur mee en elke string een header. De overhead is een stuk groter dan de data zelf.
Wat dat concreet betekent
Met memory_limit = 128M, een gangbare waarde bij shared hosting:
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 lignesOp 192 MB faalt file() nog steeds. Er is 256 MB nodig om het te halen. De generator werkt met 2 MB, en zou net zo goed werken op een bestand van 700 MB: zijn verbruik hangt niet af van de bestandsgrootte, alleen van de langste regel.
De kolommen van een CSV verwerken
Dezelfde vergelijking, deze keer door de derde kolom op te tellen.
// A. Alles laden en daarna opsplitsen
$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. Regel voor regel lezen en opsplitsen
$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, dat aanhalingstekens en ge-escapete scheidingstekens afhandelt
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);| Strategie | Mediane tijd | Piekgeheugen |
|---|---|---|
file_get_contents + explode | 583 ms | 229,1 MB |
fgets + explode | 704 ms | 2,0 MB |
fgetcsv | 4.729 ms | 2,0 MB |
Alle drie geven dezelfde som, tot op de cent. Maar fgetcsv is zeven keer trager dan explode. Dat verschil is geen tekortkoming: fgetcsv past de regels van het CSV-formaat toe en gaat om met aanhalingstekens, scheidingstekens binnen een veld en regeleindes tussen aanhalingstekens. explode(',') doet niets van dat alles en breekt zodra een veld een komma bevat.
De keuze hangt dus af van de aard van het bestand, niet van de prestaties: fgetcsv voor een CSV die van buiten komt, explode alleen voor een formaat waarvan jij garandeert dat het geen aanhalingstekens en geen ge-escapete scheidingstekens bevat.
Sinds PHP 8.4 moet je de laatste drie parameters van fgetcsv() expliciet meegeven om een deprecation-melding over de escape-parameter te vermijden.
Een bestand omzetten zonder het te lezen
Een ander geval: een bestand comprimeren zonder ooit de inhoud te hoeven bekijken. Alles inladen om het aan gzencode() door te geven werkt, maar streamfilters doen het werk blok voor blok.
// In het geheugen
$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 zou ruwe zlib opleveren
]);
stream_copy_to_stream($entree, $sortie);
fclose($entree);
fclose($sortie);| Strategie | Mediane tijd | Piekgeheugen | Uitvoer |
|---|---|---|---|
gzencode in het geheugen | 1.692 ms | 149,3 MB | 13,8 MB |
| Streamfilter | 1.928 ms | 91,1 MB | 13,8 MB |
De geheugenwinst is echt, maar minder spectaculair dan bij het lezen: stream_copy_to_stream gebruikt een flinke interne buffer. Beide leveren een bestand van dezelfde grootte op. Het is de parameter window op 31 die een echt gzip-bestand onderscheidt van een ruwe zlib-stream die gunzip weigert te openen.
In batches verwerken
Het geval dat je in de praktijk het vaakst tegenkomt: een groot bestand doorlopen en de regels in pakketten naar een database of een queue sturen. De generator combineert goed met een accumulator.
<?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; // de 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();De laatste yield na de lus is de regel die je vergeet: zonder die regel verdwijnen de laatste regels van het bestand, de regels die geen volledige batch meer vullen, geruisloos.
Inserts groeperen in transacties scheelt enorm in doorlooptijd. Eén transactie per regel dwingt elke keer een schrijfactie naar schijf af, een commit per 50.000 regels maakt er één per batch van.
De tijdslimieten
Geheugen is niet de enige muur. Op de commandoregel staat max_execution_time op 0: het script draait zonder limiet. Achter een webserver staat hij meestal op 30 seconden, en de frontserver heeft daarnaast zijn eigen timeout.
// In CLI: controleren voordat je erop rekent
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}Een zware bestandsverwerking hoort niet thuis in een HTTP-request. Het request legt vast wat er moet gebeuren, een commando op de commandoregel of een queue voert het uit.
Wat je onthoudt
- Een generator leest een bestand van elke omvang met 2 MB geheugen.
file()enfile_get_contents()laden alles in. - Het kost geen extra tijd, de meting laat het zien: die keuze heeft geen keerzijde.
fgetcsvis zeven keer trager danexplode, en dat is de prijs van correctheid. Betaal die zodra je de productie van het bestand niet zelf in de hand hebt.- Streamfilters comprimeren zonder alles in te laden.
- Meet elke strategie in een apart proces, anders liegt
memory_get_peak_usage().
Grip op het geheugen telt nog zwaarder in een proces dat nooit stopt: een WebSocket-server in PHP meet die kost verbinding per verbinding. Voor kolommen met datums, zie datum en tijd ophalen en formatteren.
Zie ook de verbinding met een database in PHP voor het invoegen, en de hub Webontwikkeling.
Veelgemaakte fouten
memory_limit op 128M is het Allowed memory size of 134217728 bytes exhausted.memory_get_peak_usage() onthoudt het maximum sinds het begin van het script: de eerste meting vervalst alle volgende. Eén proces per strategie.fgetcsv is zeven keer trager, en dat is de prijs van correctheid: het gaat om met aanhalingstekens en met scheidingstekens binnen een veld. explode(',') breekt zodra een veld een komma bevat.yield na de lus verdwijnen de regels die geen volledige batch vullen geruisloos.gunzip weigert te openen. Voor een echt gzip-bestand heb je 'window' => 31 nodig.max_execution_time staat op 0 op de commandoregel, maar op 30 seconden achter een webserver.

