Como ler ficheiros grandes em PHP sem matar o servidor

Quatro formas de ler um ficheiro grande em PHP medidas sobre um CSV de 73,1 MB: geradores, SplFileObject, filtros de fluxo e tratamento por lotes, com os números de memória de cada uma.

Como ler ficheiros grandes em PHP sem matar o servidor
Resposta rápida

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.

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);

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.

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

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

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

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

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

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

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;   // o resto
    }
}
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();

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.

php
// 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() e file_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 que explode, 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

file() e file_get_contents() carregam tudo 229 MB de memória para um ficheiro de 73 MB. Com memory_limit a 128M, dá Allowed memory size of 134217728 bytes exhausted.
Medir várias estratégias no mesmo processo 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 confundido com explode 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.
O último lote esquecido Sem um yield depois do ciclo, as linhas que não completam um lote inteiro desaparecem em silêncio.
Filtro zlib sem window a 31 Produz um fluxo zlib em bruto que o gunzip se recusa a abrir. É preciso 'window' => 31 para um verdadeiro ficheiro gzip.
Tratamento pesado dentro de um pedido HTTP max_execution_time vale 0 na linha de comandos, mas 30 segundos atrás de um servidor web.
Medições num bind mount de macOS As leituras sequenciais longas ficavam truncadas: o número de linhas baixava a cada passagem enquanto o tamanho do ficheiro não mexia. Um volume Docker tornou as medições reprodutíveis.

CSVFichiersGénérateursPerformancePHP

Damien Flandrin Programador web desde 2010, criador da Gekkode e do Email Impact. Cada artigo é testado num projeto real antes de ser publicado. Contacto
Newsletter

Os novos testes, tutoriais e projetos, por e-mail.

Testes reproduzíveis, código versionado, resultados datados. Nunca spam.