
Un générateur lit un fichier de n'importe quelle taille avec environ 2 Mo de mémoire, là où file() en consomme 229 Mo sur un fichier de 73 Mo. Sur PHP 8.5, le rapport mesuré est de 114 pour 1, sans surcoût en temps. Pour transformer un fichier sans l'examiner, les filtres de flux évitent de tout charger.
Un script PHP qui traite un fichier de quelques mégaoctets fonctionne. Le même script sur un fichier de 70 Mo s’arrête sur Allowed memory size exhausted. La cause est presque toujours la même : le fichier entier est chargé en mémoire alors qu’il suffisait de le parcourir. Cet article mesure les différentes stratégies de lecture sur un fichier réel et montre laquelle choisir selon le traitement.
Le protocole de mesure
Fichier de test : un CSV de 73,1 Mo, 2 000 000 de lignes, quatre colonnes.
<?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);Chaque stratégie tourne dans un processus séparé, avec memory_limit à 512 Mo, dans un conteneur limité à 1 cœur et 1 Go. C’est indispensable : memory_get_peak_usage() retient le maximum atteint depuis le début du script, donc mesurer plusieurs stratégies dans le même processus donne des chiffres faux, la première mesure contamine toutes les suivantes.
<?php
declare(strict_types=1);
$t0 = hrtime(true);
$resultat = /* … la stratégie à mesurer … */;
printf(
"%7.0f ms pic %7.1f Mo %s\n",
(hrtime(true) - $t0) / 1e6,
memory_get_peak_usage(true) / 1_048_576,
$resultat,
);Une remarque de méthode qui nous a coûté une heure : les premières mesures étaient prises sur un dossier monté depuis macOS dans Docker. Le nombre de lignes lues diminuait à chaque exécution, 1 990 083, puis 1 989 958, puis 1 989 132, alors que la taille du fichier ne bougeait pas. Le montage tronquait des lectures séquentielles longues. Déplacer le fichier dans un volume Docker a rendu les mesures reproductibles, avec exactement 2 000 000 de lignes à chaque passe. Une mesure qui varie sans raison est une mesure fausse, pas une mesure bruitée.
Parcourir un fichier ligne par ligne
Quatre façons de compter les lignes, même fichier, même machine.
// 1. file() : tout le fichier dans un tableau
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);
// 2. fgets accumulé dans un tableau
$lignes = [];
$h = fopen('gros.csv', 'rb');
while (($ligne = fgets($h)) !== false) {
$lignes[] = rtrim($ligne, "\r\n");
}
fclose($h);
$n = count($lignes);
// 3. Générateur
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++;
}
}| Stratégie | Temps médian | Pic mémoire |
|---|---|---|
file() | 612 ms | 229,1 Mo |
fgets dans un tableau | 492 ms | 156,0 Mo |
| Générateur | 652 ms | 2,0 Mo |
SplFileObject | 594 ms | 2,0 Mo |
Le rapport est de 114 pour un entre file() et le générateur. Les temps, eux, se tiennent dans un mouchoir : entre 490 et 660 ms, avec une dispersion d’une passe à l’autre qui dépasse l’écart entre les stratégies. Autrement dit, ne pas charger le fichier en mémoire ne coûte rien en vitesse et divise la consommation par cent.
Le chiffre de 229 Mo pour un fichier de 73 Mo surprend souvent. Un tableau PHP de deux millions de chaînes courtes ne stocke pas seulement les caractères : chaque entrée porte une structure de tableau et chaque chaîne un en-tête. Le surcoût dépasse largement la donnée elle-même.
Ce que ça change concrètement
Avec memory_limit = 128M, valeur courante en hébergement mutualisé :
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À 192 Mo, file() échoue encore. Il lui faut 256 Mo pour passer. Le générateur fonctionne avec 2 Mo, et fonctionnerait pareillement sur un fichier de 700 Mo : sa consommation ne dépend pas de la taille du fichier, seulement de la plus longue ligne.
Traiter les colonnes d’un CSV
Même comparaison, cette fois en additionnant la troisième colonne.
// A. Tout charger puis découper
$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. Lire ligne à ligne et découper
$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, qui gère les guillemets et les séparateurs échappés
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);| Stratégie | Temps médian | Pic mémoire |
|---|---|---|
file_get_contents + explode | 583 ms | 229,1 Mo |
fgets + explode | 704 ms | 2,0 Mo |
fgetcsv | 4 729 ms | 2,0 Mo |
Les trois donnent la même somme, au centime près. Mais fgetcsv est sept fois plus lent que explode. Cet écart n’est pas un défaut : fgetcsv applique les règles du format CSV, gère les guillemets, les séparateurs à l’intérieur des champs et les retours à la ligne encadrés. explode(',') ne fait rien de tout ça et casse dès qu’un champ contient une virgule.
Le choix se fait donc sur la nature du fichier, pas sur la performance : fgetcsv pour un CSV venu de l’extérieur, explode uniquement pour un format dont vous garantissez qu’il n’a ni guillemets ni séparateurs échappés.
Depuis PHP 8.4, les trois derniers paramètres de fgetcsv() doivent être passés explicitement si l’on veut éviter un avis de dépréciation sur le paramètre d’échappement.
Transformer un fichier sans le lire
Autre situation : compresser un fichier sans jamais avoir besoin d’examiner son contenu. Charger la totalité pour la passer à gzencode() fonctionne, mais les filtres de flux font le travail par blocs.
// En mémoire
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);
// En flux
$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 = en-tête gzip ; 15 produirait du zlib brut
]);
stream_copy_to_stream($entree, $sortie);
fclose($entree);
fclose($sortie);| Stratégie | Temps médian | Pic mémoire | Sortie |
|---|---|---|---|
gzencode en mémoire | 1 692 ms | 149,3 Mo | 13,8 Mo |
| Filtre de flux | 1 928 ms | 91,1 Mo | 13,8 Mo |
Le gain en mémoire est réel mais moins spectaculaire que sur la lecture : stream_copy_to_stream utilise un tampon interne conséquent. Les deux produisent un fichier de taille identique. Le paramètre window à 31 est ce qui distingue un vrai fichier gzip d’un flux zlib brut que gunzip refusera d’ouvrir.
Traiter par lots
Le cas le plus fréquent en pratique : parcourir un gros fichier et envoyer les lignes par paquets vers une base ou une file. Le générateur se combine bien avec un accumulateur.
<?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; // le reste
}
}$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();Le yield final après la boucle est la ligne qu’on oublie : sans lui, les dernières lignes du fichier, celles qui ne complètent pas un lot entier, sont perdues en silence.
Regrouper les insertions dans des transactions change tout sur la durée. Une transaction par ligne force une écriture disque à chaque fois, un commit toutes les 50 000 lignes en fait une par lot.
Les limites de temps
La mémoire n’est pas le seul mur. En ligne de commande, max_execution_time vaut 0 : le script tourne sans limite. Derrière un serveur web, il est généralement à 30 secondes, et le serveur frontal a son propre délai.
// En CLI, vérifier avant de compter dessus
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}Un traitement de fichier volumineux n’a pas sa place dans une requête HTTP. La requête enregistre le travail à faire, une commande en ligne de commande ou une file s’en charge.
À retenir
- Un générateur lit un fichier de n’importe quelle taille avec 2 Mo de mémoire.
file()etfile_get_contents()chargent tout. - Le surcoût en temps est nul, la mesure le montre : le choix n’a pas de contrepartie.
fgetcsvest sept fois plus lent qu’explode, et c’est le prix de la correction. À payer sur un fichier dont vous ne maîtrisez pas la production.- Les filtres de flux compressent sans tout charger.
- Mesurer chaque stratégie dans un processus séparé, sinon
memory_get_peak_usage()ment.
La maîtrise de la mémoire compte encore davantage dans un processus qui ne s’arrête jamais : un serveur WebSocket en PHP mesure ce coût connexion par connexion. Pour traiter des colonnes de dates, voir obtenir et formater la date et l’heure.
Voir aussi la connexion à une base de données en PHP pour la partie insertion, et le hub Développement web.
Erreurs fréquentes
memory_limit à 128M, c'est Allowed memory size of 134217728 bytes exhausted.memory_get_peak_usage() retient le maximum atteint depuis le début du script : la première mesure fausse toutes les suivantes. Un processus par stratégie.fgetcsv est sept fois plus lent, et c'est le prix de la correction : il gère les guillemets et les séparateurs à l'intérieur des champs. explode(',') casse dès qu'un champ contient une virgule.yield après la boucle, les lignes qui ne complètent pas un lot entier disparaissent en silence.gunzip refuse d'ouvrir. Il faut 'window' => 31 pour un vrai fichier gzip.max_execution_time vaut 0 en ligne de commande mais 30 secondes derrière un serveur web.

