
Um gerador lê um ficheiro de qualquer tamanho com cerca de 2 MB de memória, ali onde file() consome 229 MB num ficheiro de 73 MB. Em PHP 8.5, a relação medida é de 114 para 1, sem custo adicional em tempo. Para transformar um ficheiro sem o examinar, os filtros de fluxo evitam carregar tudo.
Um script PHP que trata um ficheiro de alguns megabytes funciona. O mesmo script num ficheiro de 70 MB pára em Allowed memory size exhausted. A causa é quase sempre a mesma: o ficheiro inteiro é carregado em memória quando bastava percorrê-lo. Este artigo mede as várias estratégias de leitura sobre um ficheiro real e mostra qual escolher consoante o tratamento.
O protocolo de medição
Ficheiro de teste: um CSV de 73,1 MB, 2 000 000 de linhas, quatro colunas.
<?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);Cada estratégia corre num processo separado, com memory_limit a 512 MB, num contentor limitado a 1 núcleo e 1 GB. É indispensável: memory_get_peak_usage() guarda o máximo atingido desde o início do script, portanto medir várias estratégias no mesmo processo dá números falsos, a primeira medição contamina todas as seguintes.
<?php
declare(strict_types=1);
$t0 = hrtime(true);
$resultat = /* … a estratégia a medir … */;
printf(
"%7.0f ms pic %7.1f Mo %s\n",
(hrtime(true) - $t0) / 1e6,
memory_get_peak_usage(true) / 1_048_576,
$resultat,
);Uma nota de método que nos custou uma hora: as primeiras medições foram feitas numa pasta montada a partir do macOS no Docker. O número de linhas lidas diminuía a cada execução, 1 990 083, depois 1 989 958, depois 1 989 132, enquanto o tamanho do ficheiro não mexia. A montagem truncava leituras sequenciais longas. Mover o ficheiro para um volume Docker tornou as medições reprodutíveis, com exatamente 2 000 000 de linhas em cada passagem. Uma medição que varia sem razão é uma medição falsa, não uma medição ruidosa.
Percorrer um ficheiro linha a linha
Quatro maneiras de contar as linhas, mesmo ficheiro, mesma máquina.
// 1. file(): todo o ficheiro num array
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);
// 2. fgets acumulado num array
$lignes = [];
$h = fopen('gros.csv', 'rb');
while (($ligne = fgets($h)) !== false) {
$lignes[] = rtrim($ligne, "\r\n");
}
fclose($h);
$n = count($lignes);
// 3. Gerador
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++;
}
}| Estratégia | Tempo mediano | Pico de memória |
|---|---|---|
file() | 612 ms | 229,1 MB |
fgets num array | 492 ms | 156,0 MB |
| Gerador | 652 ms | 2,0 MB |
SplFileObject | 594 ms | 2,0 MB |
A relação é de 114 para um entre file() e o gerador. Os tempos, esses, ficam todos muito próximos: entre 490 e 660 ms, com uma dispersão de uma passagem para a outra que ultrapassa a diferença entre estratégias. Por outras palavras, não carregar o ficheiro em memória não custa nada em velocidade e divide o consumo por cem.
Os 229 MB para um ficheiro de 73 MB costumam surpreender. Um array PHP com dois milhões de cadeias curtas não guarda apenas os caracteres: cada entrada carrega uma estrutura de array e cada cadeia um cabeçalho. O custo adicional ultrapassa largamente os próprios dados.
O que isto muda na prática
Com memory_limit = 128M, um valor corrente em alojamento partilhado:
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 lignesCom 192 MB, file() continua a falhar. Precisa de 256 MB para passar. O gerador funciona com 2 MB, e funcionaria da mesma forma num ficheiro de 700 MB: o seu consumo não depende do tamanho do ficheiro, apenas da linha mais longa.
Tratar as colunas de um CSV
A mesma comparação, desta vez a somar a terceira coluna.
// A. Carregar tudo e depois dividir
$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. Ler linha a linha e dividir
$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, que trata as aspas e os separadores escapados
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);| Estratégia | Tempo mediano | Pico de memória |
|---|---|---|
file_get_contents + explode | 583 ms | 229,1 MB |
fgets + explode | 704 ms | 2,0 MB |
fgetcsv | 4 729 ms | 2,0 MB |
As três dão a mesma soma, ao cêntimo. Mas fgetcsv é sete vezes mais lento do que explode. Essa diferença não é um defeito: fgetcsv aplica as regras do formato CSV, trata as aspas, os separadores dentro dos campos e as quebras de linha entre aspas. explode(',') não faz nada disso e parte-se assim que um campo contém uma vírgula.
A escolha faz-se portanto pela natureza do ficheiro, não pelo desempenho: fgetcsv para um CSV que vem de fora, explode apenas para um formato que garantes não ter aspas nem separadores escapados.
Desde o PHP 8.4, os três últimos parâmetros de fgetcsv() têm de ser passados explicitamente se quiseres evitar um aviso de obsolescência sobre o parâmetro de escape.
Transformar um ficheiro sem o ler
Outra situação: comprimir um ficheiro sem nunca precisar de olhar para o seu conteúdo. Carregar tudo para o passar a gzencode() funciona, mas os filtros de fluxo fazem o trabalho por blocos.
// Em memória
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);
// Em fluxo
$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 = cabeçalho gzip; 15 produziria zlib em bruto
]);
stream_copy_to_stream($entree, $sortie);
fclose($entree);
fclose($sortie);| Estratégia | Tempo mediano | Pico de memória | Saída |
|---|---|---|---|
gzencode em memória | 1 692 ms | 149,3 MB | 13,8 MB |
| Filtro de fluxo | 1 928 ms | 91,1 MB | 13,8 MB |
O ganho em memória é real, mas menos espetacular do que na leitura: stream_copy_to_stream usa um buffer interno considerável. Os dois produzem um ficheiro do mesmo tamanho. O parâmetro window a 31 é o que distingue um verdadeiro ficheiro gzip de um fluxo zlib em bruto que o gunzip se recusa a abrir.
Tratar por lotes
O caso mais frequente na prática: percorrer um ficheiro grande e enviar as linhas em pacotes para uma base de dados ou uma fila. O gerador combina bem com um acumulador.
<?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; // o resto
}
}$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();O yield final depois do ciclo é a linha que toda a gente esquece: sem ele, as últimas linhas do ficheiro, as que não completam um lote inteiro, perdem-se em silêncio.
Agrupar as inserções em transações muda tudo na duração. Uma transação por linha força uma escrita em disco de cada vez, um commit a cada 50 000 linhas faz uma por lote.
Os limites de tempo
A memória não é o único muro. Na linha de comandos, max_execution_time vale 0: o script corre sem limite. Atrás de um servidor web, está geralmente a 30 segundos, e o servidor frontal tem o seu próprio prazo.
// Em CLI, verificar antes de contar com isso
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}Um tratamento de ficheiro volumoso não tem lugar dentro de um pedido HTTP. O pedido regista o trabalho a fazer, um comando na linha de comandos ou uma fila trata dele.
A reter
- Um gerador lê um ficheiro de qualquer tamanho com 2 MB de memória.
file()efile_get_contents()carregam tudo. - O custo adicional em tempo é nulo, a medição mostra-o: a escolha não tem contrapartida.
fgetcsvé sete vezes mais lento do queexplode, e esse é o preço da correção. A pagar num ficheiro cuja produção não controlas.- Os filtros de fluxo comprimem sem carregar tudo.
- Medir cada estratégia num processo separado, senão
memory_get_peak_usage()mente.
O controlo da memória conta ainda mais num processo que nunca pára: um servidor WebSocket em PHP mede esse custo ligação a ligação. Para tratar colunas de datas, vê obter e formatar a data e a hora.
Vê também a ligação a uma base de dados em PHP para a parte da inserção, e o hub Desenvolvimento web.
Erros frequentes
memory_limit a 128M, dá Allowed memory size of 134217728 bytes exhausted.memory_get_peak_usage() guarda o máximo atingido desde o início do script: a primeira medição falseia todas as seguintes. Um processo por estratégia.fgetcsv é sete vezes mais lento, e esse é o preço da correção: trata as aspas e os separadores dentro dos campos. explode(',') parte-se assim que um campo contém uma vírgula.yield depois do ciclo, as linhas que não completam um lote inteiro desaparecem em silêncio.gunzip se recusa a abrir. É preciso 'window' => 31 para um verdadeiro ficheiro gzip.max_execution_time vale 0 na linha de comandos, mas 30 segundos atrás de um servidor web.

