Czy podczas próby zaimportowania dużego pliku CSV lub JSON na serwerze pojawia się komunikat o treści Fatal error: Allowed memory size of 134217728 bytes exhausted? To klasyczny problem w aplikacjach napisanych w PHP, które próbują wczytać całą zawartość pliku bezpośrednio do pamięci operacyjnej RAM przed rozpoczęciem przetwarzania. Gdy plik ma rozmiar 500 MB, 2 GB lub 10 GB, domyślne funkcje pomocnicze zawodzą, a podnoszenie limitu memory_limit w pliku php.ini jest jedynie chwilowym, wysoce niebezpiecznym maskowaniem problemu.
Aby bezproblemowo przetwarzać pliki o dowolnym rozmiarze bez przekraczania minimalnego limitu pamięci (np. 16 MB czy 32 MB), należy zmienić paradygmat z operowania na pełnych obiektach w pamięci na przetwarzenie strumieniowe. Niniejszy poradnik krok po kroku przeprowadzi Cię przez proces konfiguracji, tworzenia potoków danych (data pipelines) oraz uniknięcia najczęstszych wycieków pamięci.
Diagnoza problemu: Dlaczego PHP zużywa całą pamięć przy odczycie plików?
Zanim przejdziemy do wdrażania rozwiązań, musimy dokładnie przeanalizować, co dzieje się wewnątrz silnika Zend Engine podczas standardowego odczytu danych. Większość początkujących deweloperów sięga po najprostsze funkcje języka, nie zdając sobie sprawy z ich wewnętrznego kosztu pamięciowego.
Mechanizm wczytywania całego pliku do pamięci
Funkcje takie jak file_get_contents() lub file() wykonują natychmiastowe zmapowanie całego pliku z dysku do pamięci RAM. Jeśli plik CSV ma 200 MB, sama zmienna tekstowa zajmie co najmniej 200 MB. Problem potęguje się w momencie parsowania.
Gdy wywołasz funkcję json_decode() na ciągu tekstowym o wielkości 200 MB, PHP musi utworzyć strukturę obiektów i tablic w pamięci. Ze względu na wewnętrzne struktury danych w C (struktura zval, tablice mieszające/hashtables), reprezentacja danych w postaci zdekodowanej tablicy PHP potrafi zająć od 5 do 10 razy więcej pamięci niż surowy plik tekstowy. Oznacza to, że plik JSON o rozmiarze 200 MB może wymagać od 1 do 2 GB pamięci RAM!
Jak PHP zarządza pamięcią RAM i buforem
PHP jest językiem zaprojektowanym do krótkiego cyklu życia skryptu (Shared-Nothing Architecture). Każda zmienna, zadeklarowana tablica czy wczytana struktura istnieje w pamięci do momentu zakończenia zapytania HTTP lub wywołania niszczyciela.
W przypadku procesów CLI (Command Line Interface) oraz długo działających skryptów tła (np. workerów kolejek) pamięć nie jest automatycznie zwalniana po przetworzeniu pojedynczego rekordu, chyba że kod zostanie
napisany w sposób jawnie zoptymalizowany pod kątem kontrolowanego zwalniania pamięci i wykorzystania strumieni.
Procedura krok po kroku: Bezpieczne przetwarzanie strumieniowe
Poniższa procedura pozwala na przetworzenie plików CSV oraz JSON dowolnej wielkości (nawet 20 GB i więcej) przy stałym zużyciu pamięci RAM na poziomie od 2 MB do 8 MB.
Krok 1: Wdrożenie strumieniowego odczytu plików CSV z użyciem generatorów
Zamiast wczytywać cały plik CSV do tablicy za pomocą file() lub str_getcsv(), skrypt powinien otwierać wskaźnik pliku i odczytywać go wiersz po wierszu, używając funkcji fgetcsv() połączonej z operatorem yield (Generatory w PHP).
function readLargeCsv(string $filePath, string $delimiter = ','): Generator
{
$handle = fopen($filePath, 'rb');
if ($handle === false) {
throw new RuntimeException("Nie można otworzyć pliku: {$filePath}");
}
try {
// Odczyt nagłówków (opcjonalnie)
$headers = fgetcsv($handle, 0, $delimiter);
while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
if ($headers !== false) {
// Łączenie nagłówków z wartościami w celu uzyskania tablicy asocjacyjnej
yield array_combine($headers, $row);
} else {
yield $row;
}
}
} finally {
fclose($handle);
}
}
// Użycie generatora - pamięć RAM pozostaje stała
foreach (readLargeCsv('sciezka/do/duzego_pliku.csv') as $row) {
// Przetwarzanie pojedynczego wiersza
processRow($row);
}
Krok 2: Wdrożenie strumieniowego parsowania plików JSON
Pliki JSON ze względu na swoją zagnieżdżoną strukturę są trudniejsze do parsowania wiersz po wierszu. Standardowe json_decode() wymaga pełnego drzewa w pamięci. Do bezpiecznego przetwarzania plików JSON należy zastosować bibliotekę typu *pull parser*, np. halaxa/json-machine lub JSONStreamingParser.
Instalacja biblioteki JSON Machine przez Composer:
composer require halaxa/json-machine
Przykład przetwarzania ogromnego pliku JSON zawierającego tablicę obiektów:
use JsonMachineItems;
// Pobieranie elementów z pliku JSON bezpośrednio ze strumienia
$rows = Items::fromFile('sciezka/do/duzego_pliku.json', ['pointer' => '/data']);
foreach ($rows as $id => $item) {
// $item zawiera sparsowany pojedynczy obiekt/rekord
processItem($item);
}
Dzięki temu rozwiązaniu biblioteka czyta plik w małych buforach (domyślnie 8 KB) i generuje obiekty PHP tylko dla aktualnie iterowanego elementu.
Krok 3: Optymalizacja zapisu danych (Batch Processing)
Przetwarzanie wiersz po wierszu rozwiązuje problem pamięci RAM, ale może spowolnić operacje I/O (np. zapytania do bazy danych). Aby połączyć niskie zużycie RAM z wysoką wydajnością zapisu, zastosuj przetwarzanie wsadowe (batching).
$batch = [];
$batchSize = 500; // Paczka 500 rekordów
foreach (readLargeCsv('duzy_plik.csv') as $row) {
$batch[] = prepareDataForInsert($row);
if (count($batch) >= $batchSize) {
insertBatchToDatabase($batch);
$batch = []; // Natychmiastowe zwolnienie tablicy bufora
}
}
// Zapis pozostałych rekordów (ostatnia niepełna paczka)
if (!empty($batch)) {
insertBatchToDatabase($batch);
unset($batch);
}
Krok 4: Weryfikacja i monitorowanie zużycia pamięci
Podczas wykonywania skryptu należy rejestrować szczytowe zużycie pamięci operacyjnej za pomocą funkcji memory_get_peak_usage().
$startTime = microtime(true);
// ... wykonanie pętli przetwarzającej ...
$peakMemory = memory_get_peak_usage(true) / 1024 / 1024; // MB
$executionTime = microtime(true) - $startTime;
echo sprintf("Maksymalne zużycie RAM: %.2f MBn", $peakMemory);
echo sprintf("Czas wykonania: %.2f sekundyn", $executionTime);
Kryterium sukcesu: Szczytowe zużycie pamięci (memory_get_peak_usage) nie powinno wzrastać w miarę postępu przetwarzania pliku. Wykres pamięci musi być płaski.
Najczęstsze pułapki i ostrzeżenia (Krytyczne błędy)
Nawet przy użyciu generatorów istnieją wzorce w kodzie, które mogą cicho prowadzić do wycieków pamięci RAM:
* Ostrzeżenie 1: Agregacja danych w tablicy głównej. Zapisywanie przetworzonych wyników do globalnej tablicy w celu późniejszego podsumowania (np. $results[] = $processedRow;) niweluje korzyści z użycia generatora. Dane zagregowane należy od razu zapisywać na dysku lub w bazie danych.
* Ostrzeżenie 2: Dziennik zapytań (Query Log) w ORM. Frameworki takie jak Laravel (Eloquent) lub Symfony (Doctrine) domyślnie rejestrują każde wykonane zapytanie SQL w pamięci RAM w celu debugowania. Przed uruchomieniem skryptu należy bezwzględnie wyłączyć logowanie zapytań:
DB::disableQueryLog(); (Laravel) lub $entityManager->getConnection()->getConfiguration()->setSQLLogger(null); (Doctrine).
* Ostrzeżenie 3: Tożsamość obiektów w ORM (Unit of Work). Wykorzystywanie ORM do tworzenia tysięcy obiektów w pętli powoduje, że ORM przetrzymuje ich referencje w pamięci. Po przetworzeniu paczki danych wywołaj wyczyszczenie tożsamości: $entityManager->clear();.
* Ostrzeżenie 4: Zapominanie o zamykaniu uchwytów (File Handlers). Otwieranie wielu strumieni bez ich jawnego zamykania (fclose()) prowadzi do wyczerpania zasobów systemowych.
Lista kontrolna (Checklist) bezpiecznego parsera
Przed uruchomieniem importu na środowisku produkcyjnym przejdź przez poniższą listę sprawdzającą:
Przed uruchomieniem (Konfiguracja i architektura):
* [ ] Skrypt NIE
używa file_get_contents(), file() ani json_decode() bezpośrednio na całym pliku.
DB::disableQueryLog() lub wyczyszczono logi Doctrine).try...finally przy otwieraniu strumienia, aby zagwarantować zamknięcie uchwytu pliku (fclose()).W trakcie parsowania (Pętla i przetwarzanie wsadowe):
- Dane są emitowane wiersz po wierszu za pomocą generatora z operatorem
yieldlub czytnika strumieniowego. - Zapis do bazy danych lub przetwarzanie zewnętrzne odbywa się w paczkach (batching, np. 500–1000 rekordów) zamiast pojedynczych zapytań SQL.
- Tablica bufora paczki jest jawnie czyszczona (
$batch = []) natychmiast po jej przetworzeniu. - W przypadku użycia ORM, po przetworzeniu każdej paczki wywoływane jest czyszczenie tożsamości obiektów (np.
$entityManager->clear()). - Żadne zagregowane dane wyjściowe nie są gromadzone w jednej wielkiej tablicy w pamięci RAM.
Po zakończeniu (Weryfikacja i audyt):
- Wszystkie używane strumienie i uchwyty zostały zamknięte.
- Wartość wywołania
memory_get_peak_usage(true)jest rejestrowana i mieści się w założonym limicie (np. poniżej 10–16 MB niezależnie od wielkości pliku). - Przetestowano działanie skryptu na pliku testowym o rozmiarze co najmniej 2–5 razy większym niż domyślny limit
memory_limitw PHP.
Praktyczny finał: Gotowy szablon produkcyjnego parsera
Poniższy skrypt scala wszystkie omówione zasady w jednolitą, gotową do wdrożenia strukturę produkcyjną. Działa przy stałym zużyciu pamięci RAM, obsługuje błędne rekordy oraz dba o bezpieczne zamykanie zasobów systemowych.
„`php
batchSize = $batchSize;
}
/**
* Generator strumieniujący plik CSV wiersz po wierszu.
*/
private function streamCsv(string $filePath, string $delimiter = ’,’): Generator
{
if (!file_exists($filePath) || !is_readable($filePath)) {
throw new InvalidArgumentException(„Plik nie istnieje lub jest niedostępny: {$filePath}”);
}
$handle = fopen($filePath, 'rb’);
if ($handle === false) {
throw new RuntimeException(„Błąd podczas otwierania pliku: {$filePath}”);
}
try {
$headers = fgetcsv($handle, 0, $delimiter);
while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
if ($headers !== false && count($headers) === count($row)) {
yield array_combine($headers, $row);
} else {
yield $row;
}
}
} finally {
fclose($handle);
}
}
/**
* Główna metoda przetwarzająca plik bez wycieków pamięci.
*/
public function process(string $filePath): void
{
$startTime = microtime(true);
$processedCount = 0;
$batch = [];
// Wyłączenie logów ORM jeśli używane w środowisku frameworka
// DB::disableQueryLog();
echo „Rozpoczynanie przetwarzania strumieniowego…n”;
foreach ($this->streamCsv($filePath) as $record) {
$batch[] = $record;
$processedCount++;
if (count($batch) >= $this->batchSize) {
$this->flushBatch($batch);
$batch = []; // Czyszczenie pamięci po paczce
}
}
// Zapis resztki danych
if (!empty($batch)) {
$this->flushBatch($batch);
unset($batch);
}
$executionTime = round(microtime(true) – $startTime, 2);
$peakMemoryMB = round(memory_get_peak_usage(true) / 1024 / 1024, 2);
echo „— Raport z wykonania —n”;
echo „Przetworzono rekordów: {$processedCount}n”;
echo „Czas wykonania: {$executionTime} sn”;
echo „Szczytowe zużycie RAM: {$peakMemoryMB} MBn”;
}
/**
* Zapis paczki danych do bazy lub zewnętrznego API.
*/
private function flushBatch(array $batch): void
{
// Tutaj wykonaj jedno zapytanie zbiorcze (np. INSERT INTO … VALUES (…), (…))
// Przykład: DB::table(’users’)->insert($batch);
// Jeśli używasz Doctrine ORM:
// $this->entityManager->flush();
// $this->entityManager->clear();
}
}
// Uruchomienie skryptu w CLI:
$processor = new StreamProcessor(batchSize: 500);
$processor->process(’/sciezka/do/twojego_pliku_10GB.csv’);
„`
Aby przetestować ten kod na swoim środowisku, zapisz go i wywołaj poleceniem CLI:
php processor.php.
Zwróć uwagę na komunikat dotyczący szczytowego zużycia RAM – niezależnie od tego, czy plik wejściowy ma 100 MB, czy 20 GB, zużycie pamięci pozostanie stałe na poziomie kilku megabajtów.
Przy planowaniu kolejnych kroków pomocny może być tekst: W jaki sposób sprawdzić, czy dany plik istnieje?.
Warto przeczytać również powiązany artykuł: Jak Usunąć z Tablicy Powtarzające Się Wartości w PHP?.
Przy planowaniu kolejnych kroków pomocny może być tekst: W jaki sposób zdefiniować wartość stałą w informatyce?.



