Cómo leer archivos grandes con PHP sin saturar la memoria

Cuatro formas de leer un archivo grande en PHP medidas sobre un CSV de 73,1 MB: generadores, SplFileObject, filtros de flujo y procesamiento por lotes, con las cifras de memoria de cada una.

Cómo leer archivos grandes con PHP sin saturar la memoria
Respuesta rápida

Un generador lee un archivo de cualquier tamaño con unos 2 MB de memoria, mientras que file() consume 229 MB con un archivo de 73 MB. En PHP 8.5 la relación medida es de 114 a uno, sin sobrecoste en tiempo. Para transformar un archivo sin examinarlo, los filtros de flujo evitan cargarlo entero.

Un script PHP que procesa un archivo de unos pocos megabytes funciona. El mismo script sobre un archivo de 70 MB se detiene con Allowed memory size exhausted. La causa es casi siempre la misma: se carga el archivo entero en memoria cuando bastaba con recorrerlo. Este artículo mide las distintas estrategias de lectura sobre un archivo real y muestra cuál elegir según el tratamiento.

El protocolo de medición

Archivo de prueba: un CSV de 73,1 MB, 2.000.000 de líneas, cuatro columnas.

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 estrategia se ejecuta en un proceso aparte, con memory_limit a 512 MB, en un contenedor limitado a 1 núcleo y 1 GB. Es imprescindible: memory_get_peak_usage() retiene el máximo alcanzado desde el inicio del script, así que medir varias estrategias en el mismo proceso da cifras falsas, la primera medición contamina todas las siguientes.

une-strategie.php
<?php

declare(strict_types=1);

$t0 = hrtime(true);
$resultat = /* … la estrategia que se va 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,
);

Un apunte de método que nos costó una hora: las primeras mediciones se tomaron sobre una carpeta montada desde macOS en Docker. El número de líneas leídas bajaba en cada ejecución, 1.990.083, luego 1.989.958, luego 1.989.132, mientras que el tamaño del archivo no se movía. El montaje truncaba las lecturas secuenciales largas. Mover el archivo a un volumen Docker hizo reproducibles las mediciones, con exactamente 2.000.000 de líneas en cada pasada. Una medición que varía sin motivo es una medición falsa, no una medición con ruido.

Recorrer un archivo línea a línea

Cuatro formas de contar las líneas, mismo archivo, misma máquina.

php
// 1. file(): todo el archivo en un array
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);

// 2. fgets acumulado en un array
$lignes = [];
$h = fopen('gros.csv', 'rb');
while (($ligne = fgets($h)) !== false) {
    $lignes[] = rtrim($ligne, "\r\n");
}
fclose($h);
$n = count($lignes);

// 3. Generador
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++;
    }
}
Estrategia Tiempo mediano Pico de memoria
file() 612 ms 229,1 MB
fgets en un array 492 ms 156,0 MB
Generador 652 ms 2,0 MB
SplFileObject 594 ms 2,0 MB

La relación es de 114 a uno entre file() y el generador. Los tiempos, en cambio, están muy juntos: entre 490 y 660 ms, con una dispersión de una pasada a otra que supera la diferencia entre estrategias. Dicho de otro modo, no cargar el archivo en memoria no cuesta nada en velocidad y divide el consumo por cien.

La cifra de 229 MB para un archivo de 73 MB sorprende a menudo. Un array PHP de dos millones de cadenas cortas no almacena solo los caracteres: cada entrada lleva una estructura de array y cada cadena una cabecera. La sobrecarga supera con creces al propio dato.

Lo que cambia en la práctica

Con memory_limit = 128M, un valor habitual en alojamiento compartido:

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

Con 192 MB, file() sigue fallando. Necesita 256 MB para pasar. El generador funciona con 2 MB, y funcionaría igual sobre un archivo de 700 MB: su consumo no depende del tamaño del archivo, sino solo de la línea más larga.

Procesar las columnas de un CSV

La misma comparación, esta vez sumando la tercera columna.

php
// A. Cargarlo todo y luego trocear
$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. Leer línea a línea y trocear
$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 gestiona las comillas y los separadores escapados
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
    $somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);
Estrategia Tiempo mediano Pico de memoria
file_get_contents + explode 583 ms 229,1 MB
fgets + explode 704 ms 2,0 MB
fgetcsv 4.729 ms 2,0 MB

Las tres dan la misma suma, hasta el céntimo. Pero fgetcsv es siete veces más lento que explode. Esa diferencia no es un defecto: fgetcsv aplica las reglas del formato CSV, gestiona las comillas, los separadores dentro de los campos y los saltos de línea entrecomillados. explode(',') no hace nada de eso y se rompe en cuanto un campo contiene una coma.

La elección se hace, por tanto, según la naturaleza del archivo y no según el rendimiento: fgetcsv para un CSV que viene de fuera, explode solo para un formato del que garantizas que no tiene comillas ni separadores escapados.

Desde PHP 8.4, los tres últimos parámetros de fgetcsv() deben pasarse de forma explícita si quieres evitar un aviso de obsolescencia sobre el parámetro de escape.

Transformar un archivo sin leerlo

Otra situación: comprimir un archivo sin necesitar en ningún momento examinar su contenido. Cargarlo entero para pasárselo a gzencode() funciona, pero los filtros de flujo hacen el trabajo por bloques.

php
// En memoria
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);

// En flujo
$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 = cabecera gzip; 15 produciría zlib en bruto
]);

stream_copy_to_stream($entree, $sortie);

fclose($entree);
fclose($sortie);
Estrategia Tiempo mediano Pico de memoria Salida
gzencode en memoria 1.692 ms 149,3 MB 13,8 MB
Filtro de flujo 1.928 ms 91,1 MB 13,8 MB

La ganancia de memoria es real pero menos espectacular que en la lectura: stream_copy_to_stream usa un búfer interno considerable. Ambas producen un archivo del mismo tamaño. El parámetro window a 31 es lo que distingue un archivo gzip de verdad de un flujo zlib en bruto que gunzip se negará a abrir.

Procesar por lotes

El caso más frecuente en la práctica: recorrer un archivo grande y enviar las líneas por paquetes a una base de datos o a una cola. El generador se combina bien con un 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;   // el 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();

El yield final tras el bucle es la línea que se olvida: sin él, las últimas líneas del archivo, las que no completan un lote entero, se pierden en silencio.

Agrupar las inserciones en transacciones lo cambia todo en la duración. Una transacción por línea fuerza una escritura en disco cada vez, un commit cada 50.000 líneas deja una por lote.

Los límites de tiempo

La memoria no es el único muro. En línea de comandos, max_execution_time vale 0: el script se ejecuta sin límite. Detrás de un servidor web suele estar en 30 segundos, y el servidor frontal tiene su propio plazo.

php
// En CLI, comprobarlo antes de darlo por hecho
if (PHP_SAPI !== 'cli') {
    throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}

El tratamiento de un archivo voluminoso no tiene cabida en una petición HTTP. La petición registra el trabajo pendiente, y de él se encarga un comando en línea de comandos o una cola.

Para recordar

  • Un generador lee un archivo de cualquier tamaño con 2 MB de memoria. file() y file_get_contents() lo cargan todo.
  • El sobrecoste en tiempo es nulo, y la medición lo demuestra: la elección no tiene contrapartida.
  • fgetcsv es siete veces más lento que explode, y ese es el precio de la corrección. Hay que pagarlo con un archivo cuya generación no controlas.
  • Los filtros de flujo comprimen sin cargarlo todo.
  • Mide cada estrategia en un proceso aparte, si no, memory_get_peak_usage() miente.

Controlar la memoria importa aún más en un proceso que nunca se detiene: un servidor WebSocket en PHP mide ese coste conexión por conexión. Para tratar columnas de fechas, consulta obtener y formatear la fecha y la hora.

Consulta también la conexión a una base de datos en PHP para la parte de inserción, y el hub de Desarrollo web.

Errores frecuentes

file() y file_get_contents() lo cargan todo 229 MB de memoria para un archivo de 73 MB. Con memory_limit a 128M, sale Allowed memory size of 134217728 bytes exhausted.
Medir varias estrategias en un mismo proceso memory_get_peak_usage() retiene el máximo alcanzado desde el inicio del script: la primera medición falsea todas las siguientes. Un proceso por estrategia.
Confundir fgetcsv con explode fgetcsv es siete veces más lento, y ese es el precio de la corrección: gestiona las comillas y los separadores dentro de los campos. explode(',') se rompe en cuanto un campo contiene una coma.
El último lote olvidado Sin un yield después del bucle, las líneas que no completan un lote entero desaparecen en silencio.
Filtro zlib sin window a 31 Produce un flujo zlib en bruto que gunzip se niega a abrir. Hace falta 'window' => 31 para un archivo gzip de verdad.
Procesamiento pesado dentro de una petición HTTP max_execution_time vale 0 en línea de comandos, pero 30 segundos detrás de un servidor web.
Mediciones sobre un montaje bind de macOS Las lecturas secuenciales largas se truncaban: el número de líneas bajaba en cada pasada aunque el tamaño del archivo no cambiaba. Un volumen Docker hizo reproducibles las mediciones.

CSVFichiersGénérateursPerformancePHP

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.