Duże pliki w PHP: jak je czytać bez zabijania serwera

Był czas, kiedy jedynym sposobem uwierzytelnienia się w aplikacji było podanie danych logowania (zwykle nazwy użytkownika albo adresu e-mail i hasła), a potem sesja utrzymywała stan użytkownika aż do wylogowania. Nieco później zaczęliśmy

Duże pliki w PHP: jak je czytać bez zabijania serwera
Szybka odpowiedź

Generator czyta plik dowolnej wielkości przy około 2 MB pamięci, podczas gdy file() zużywa 229 MB na pliku 73 MB. Na PHP 8.5 zmierzony stosunek to 114 do 1, bez narzutu czasowego. Do przekształcenia pliku bez zaglądania w jego treść filtry strumieni pozwalają uniknąć ładowania całości.

Skrypt PHP, który przetwarza plik o wielkości kilku megabajtów, działa. Ten sam skrypt na pliku 70 MB zatrzymuje się na Allowed memory size exhausted. Przyczyna jest prawie zawsze ta sama: cały plik ląduje w pamięci, choć wystarczyło przejść go wiersz po wierszu. Ten artykuł mierzy różne strategie odczytu na prawdziwym pliku i pokazuje, którą wybrać zależnie od rodzaju przetwarzania.

Protokół pomiaru

Plik testowy: CSV o wielkości 73,1 MB, 2 000 000 wierszy, cztery kolumny.

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

Każda strategia działa w osobnym procesie, z memory_limit ustawionym na 512 MB, w kontenerze ograniczonym do 1 rdzenia i 1 GB. To konieczne: memory_get_peak_usage() pamięta maksimum osiągnięte od początku skryptu, więc mierzenie kilku strategii w jednym procesie daje fałszywe liczby, pierwszy pomiar zatruwa wszystkie kolejne.

une-strategie.php
<?php

declare(strict_types=1);

$t0 = hrtime(true);
$resultat = /* … mierzona strategia … */;

printf(
    "%7.0f ms  pic %7.1f Mo  %s\n",
    (hrtime(true) - $t0) / 1e6,
    memory_get_peak_usage(true) / 1_048_576,
    $resultat,
);

Uwaga metodologiczna, która kosztowała nas godzinę: pierwsze pomiary szły na katalogu montowanym z macOS do Dockera. Liczba odczytanych wierszy spadała przy każdym uruchomieniu, 1 990 083, potem 1 989 958, potem 1 989 132, choć rozmiar pliku się nie zmieniał. Montowanie ucinało długie odczyty sekwencyjne. Przeniesienie pliku na wolumen Dockera sprawiło, że pomiary stały się powtarzalne, z dokładnie 2 000 000 wierszy w każdym przebiegu. Pomiar, który zmienia się bez powodu, jest pomiarem błędnym, a nie zaszumionym.

Przejście przez plik wiersz po wierszu

Cztery sposoby na policzenie wierszy, ten sam plik, ta sama maszyna.

php
// 1. file(): cały plik w tablicy
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);

// 2. fgets gromadzony w tablicy
$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++;
    }
}
Strategia Mediana czasu Szczyt pamięci
file() 612 ms 229,1 MB
fgets do tablicy 492 ms 156,0 MB
Generator 652 ms 2,0 MB
SplFileObject 594 ms 2,0 MB

Między file() a generatorem stosunek wynosi 114 do jednego. Czasy trzymają się blisko siebie: od 490 do 660 ms, a rozrzut między przebiegami przekracza różnicę między strategiami. Inaczej mówiąc, rezygnacja z ładowania pliku do pamięci nie kosztuje nic na szybkości i dzieli zużycie przez sto.

Liczba 229 MB przy pliku 73 MB zwykle zaskakuje. Tablica PHP z dwoma milionami krótkich ciągów nie przechowuje samych znaków: każdy wpis niesie strukturę tablicy, a każdy ciąg swój nagłówek. Narzut znacznie przewyższa same dane.

Co to zmienia w praktyce

Przy memory_limit = 128M, wartości typowej na hostingu współdzielonym:

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

Przy 192 MB file() nadal się wywraca. Żeby przeszło, potrzeba 256 MB. Generator działa na 2 MB i tak samo poradziłby sobie z plikiem 700 MB: jego zużycie nie zależy od rozmiaru pliku, tylko od najdłuższego wiersza.

Przetwarzanie kolumn pliku CSV

To samo porównanie, tym razem z sumowaniem trzeciej kolumny.

php
// A. Wczytanie całości, potem podział
$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. Odczyt wiersz po wierszu i podział
$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, który obsługuje cudzysłowy i ucieczkowane separatory
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
    $somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);
Strategia Mediana czasu Szczyt pamięci
file_get_contents + explode 583 ms 229,1 MB
fgets + explode 704 ms 2,0 MB
fgetcsv 4 729 ms 2,0 MB

Wszystkie trzy dają tę samą sumę, co do grosza. Ale fgetcsv jest siedem razy wolniejszy od explode. Ta różnica nie jest wadą: fgetcsv stosuje reguły formatu CSV, obsługuje cudzysłowy, separatory wewnątrz pól i znaki końca wiersza zamknięte w cudzysłowie. explode(',') nie robi z tego nic i psuje się, gdy tylko w polu pojawi się przecinek.

Wybór zależy więc od natury pliku, a nie od wydajności: fgetcsv do pliku CSV z zewnątrz, explode wyłącznie do formatu, o którym wiesz na pewno, że nie ma w nim ani cudzysłowów, ani ucieczkowanych separatorów.

Od PHP 8.4 trzy ostatnie parametry fgetcsv() trzeba podać wprost, jeśli chcesz uniknąć ostrzeżenia o wycofaniu parametru ucieczki.

Przekształcić plik bez czytania go

Inna sytuacja: skompresować plik, nie zaglądając nigdy do jego zawartości. Wczytanie całości i podanie jej do gzencode() działa, ale filtry strumieni robią tę samą robotę blokami.

php
// W pamięci
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);

// Strumieniowo
$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 = nagłówek gzip; 15 dałoby surowy zlib
]);

stream_copy_to_stream($entree, $sortie);

fclose($entree);
fclose($sortie);
Strategia Mediana czasu Szczyt pamięci Wynik
gzencode w pamięci 1 692 ms 149,3 MB 13,8 MB
Filtr strumienia 1 928 ms 91,1 MB 13,8 MB

Zysk na pamięci jest realny, ale mniej spektakularny niż przy odczycie: stream_copy_to_stream używa sporego bufora wewnętrznego. Oba warianty dają plik tej samej wielkości. To parametr window równy 31 odróżnia prawdziwy plik gzip od surowego strumienia zlib, którego gunzip odmówi otwarcia.

Przetwarzanie partiami

Najczęstszy przypadek w praktyce: przejść przez duży plik i wysyłać wiersze paczkami do bazy albo do kolejki. Generator dobrze łączy się z akumulatorem.

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

Końcowy yield po pętli to ten wiersz, o którym się zapomina: bez niego ostatnie wiersze pliku, te, które nie dopełniają całej paczki, giną po cichu.

Grupowanie wstawek w transakcje zmienia wszystko, jeśli chodzi o czas. Jedna transakcja na wiersz wymusza zapis na dysk za każdym razem, commit co 50 000 wierszy robi jeden zapis na paczkę.

Limity czasu

Pamięć to nie jedyna ściana. W wierszu poleceń max_execution_time wynosi 0: skrypt działa bez limitu. Za serwerem WWW to zwykle 30 sekund, a serwer frontowy ma jeszcze własny limit.

php
// W CLI sprawdź to, zamiast na tym polegać
if (PHP_SAPI !== 'cli') {
    throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}

Przetwarzanie dużego pliku nie ma czego szukać w żądaniu HTTP. Żądanie zapisuje pracę do wykonania, a zajmuje się nią polecenie uruchomione z wiersza poleceń albo kolejka.

Do zapamiętania

  • Generator czyta plik dowolnej wielkości przy 2 MB pamięci. file() i file_get_contents() ładują wszystko.
  • Narzut czasowy jest zerowy, pomiar to pokazuje: ten wybór nie ma drugiej strony medalu.
  • fgetcsv jest siedem razy wolniejszy od explode i to jest cena poprawności. Warto ją zapłacić przy pliku, nad którego powstawaniem nie panujesz.
  • Filtry strumieni kompresują bez ładowania całości.
  • Mierz każdą strategię w osobnym procesie, inaczej memory_get_peak_usage() kłamie.

Panowanie nad pamięcią liczy się jeszcze bardziej w procesie, który nigdy się nie kończy: serwer WebSocket w PHP mierzy ten koszt połączenie po połączeniu. Do obsługi kolumn z datami zobacz pobieranie i formatowanie daty i godziny.

Zobacz też połączenie z bazą danych w PHP przy części wstawiającej dane oraz hub Programowanie webowe.

Częste błędy

file() i file_get_contents() ładują wszystko 229 MB pamięci na plik 73 MB. Przy memory_limit równym 128M kończy się to na Allowed memory size of 134217728 bytes exhausted.
Pomiar kilku strategii w jednym procesie memory_get_peak_usage() pamięta maksimum osiągnięte od początku skryptu: pierwszy pomiar fałszuje wszystkie kolejne. Jeden proces na strategię.
fgetcsv mylony z explode fgetcsv jest siedem razy wolniejszy i to jest cena poprawności: obsługuje cudzysłowy i separatory wewnątrz pól. explode(',') psuje się, gdy tylko w polu pojawi się przecinek.
Zapomniana ostatnia paczka Bez yield po pętli wiersze, które nie dopełniają całej paczki, znikają po cichu.
Filtr zlib bez window równego 31 Daje surowy strumień zlib, którego gunzip nie otworzy. Do prawdziwego pliku gzip potrzeba 'window' => 31.
Ciężkie przetwarzanie w żądaniu HTTP max_execution_time wynosi 0 w wierszu poleceń, ale 30 sekund za serwerem WWW.
Pomiary na montowaniu bind z macOS Długie odczyty sekwencyjne były ucinane: liczba wierszy spadała przy każdym przebiegu, choć rozmiar pliku się nie zmieniał. Wolumen Docker przywrócił powtarzalność pomiarów.

CSVFichiersGénérateursPerformancePHP

Damien Flandrin Web developer od 2010 roku, twórca Gekkode i Email Impact. Każdy artykuł jest sprawdzany na prawdziwym projekcie przed publikacją. Kontakt
Newsletter

Nowe testy, poradniki i projekty, e-mailem.

Powtarzalne testy, wersjonowany kod, datowane wyniki. Nigdy spamu.