
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.
<?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.
<?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.
// 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:
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 lignesPrzy 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.
// 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.
// 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.
<?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
}
}$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.
// 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()ifile_get_contents()ładują wszystko. - Narzut czasowy jest zerowy, pomiar to pokazuje: ten wybór nie ma drugiej strony medalu.
fgetcsvjest siedem razy wolniejszy odexplodei 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
memory_limit równym 128M kończy się to na Allowed memory size of 134217728 bytes exhausted.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 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.yield po pętli wiersze, które nie dopełniają całej paczki, znikają po cichu.gunzip nie otworzy. Do prawdziwego pliku gzip potrzeba 'window' => 31.max_execution_time wynosi 0 w wierszu poleceń, ale 30 sekund za serwerem WWW.

