Grote bestanden lezen in PHP zonder je server te slopen

Vier manieren om een groot bestand te lezen in PHP, gemeten op een CSV van 73,1 MB: generatoren, SplFileObject, streamfilters en verwerking in batches, met de geheugencijfers van elke aanpak.

Grote bestanden lezen in PHP zonder je server te slopen
Kort antwoord

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.

generer.php
<?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.

une-strategie.php
<?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.

php
// 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:

code
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 lignes

Op 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.

php
// 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.

php
// 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.

src/lots.php
<?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
    }
}
php
$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.

php
// 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() en file_get_contents() laden alles in.
  • Het kost geen extra tijd, de meting laat het zien: die keuze heeft geen keerzijde.
  • fgetcsv is zeven keer trager dan explode, 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

file() en file_get_contents() laden alles in 229 MB geheugen voor een bestand van 73 MB. Met memory_limit op 128M is het Allowed memory size of 134217728 bytes exhausted.
Meerdere strategieën meten in hetzelfde proces 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 verwarren met explode 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.
De laatste batch vergeten Zonder een yield na de lus verdwijnen de regels die geen volledige batch vullen geruisloos.
zlib-filter zonder window op 31 Levert een ruwe zlib-stream op die gunzip weigert te openen. Voor een echt gzip-bestand heb je 'window' => 31 nodig.
Zware verwerking in een HTTP-request max_execution_time staat op 0 op de commandoregel, maar op 30 seconden achter een webserver.
Metingen op een bind mount van macOS Lange sequentiële leesbewerkingen werden afgekapt: het aantal regels daalde bij elke passage terwijl het bestand even groot bleef. Een Docker-volume maakte de metingen reproduceerbaar.

CSVFichiersGénérateursPerformancePHP

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.