Setiap proses PHP menjalankan skrip dengan anggaran memori yang terbatas — melampauinya berarti request berakhir dengan fatal error, bukan sekadar melambat. Kebanyakan masalah memori tidak lahir dari bahasanya, melainkan dari satu pola: materialisasi seluruh data sekaligus — memuat satu juta baris ke array, membaca berkas log sepuluh gigabyte ke string, menampung seluruh hasil query sebelum diproses. Pada chapter ini kita akan membalik pola tersebut menjadi pemrosesan bertahap dengan generator, fetch satu-per-satu, dan streaming berkas, lalu menutup dengan pemahaman tentang garbage collector dan batas memory_limit. Angka-angka pasti pemakaian memori berbeda antar lingkungan, sehingga contoh yang menyangkut memori sengaja hanya menampilkan hasil boolean dan struktur.

Generator untuk Data Besar #

Generator adalah function yang menghasilkan nilai satu per satu lewat yield tanpa pernah membangun array lengkap. Saat di-foreach, kode di dalam generator berjalan hanya sampai yield berikutnya, menghasilkan satu nilai, lalu berhenti sampai diminta lagi:

php
<?php
function angka(int $maks): Generator
{
    for ($i = 1; $i <= $maks; $i++) {
        yield $i;
    }
}

$jumlah = 0;
$total = 0;
foreach (angka(1000000) as $n) {
    $jumlah++;
    $total += $n;
}

echo "Elemen dilalui: $jumlah\n";
echo "Total nilai: $total\n";

Output yang Dihasilkan:

text
Elemen dilalui: 1000000
Total nilai: 500000500000

Function angka() memang terlihat seperti mengembalikan array, tetapi yang dikembalikan hanyalah object Generator. Setiap langkah foreach mengeksekusi loop di dalam generator tepat satu iterasi: $i naik, yield $i menyerahkan satu nilai, lalu eksekusi berhenti sampai foreach meminta nilai berikutnya. Satu juta elemen berhasil dilalui, tetapi pada satu waktu hanya satu nilai yang hidup di memori.

Seberapa Besar Hematnya #

Untuk merasakan bedanya, bandingkan generator dengan range() yang membangun array satu juta elemen sungguhan. Karena angka absolut memori berbeda-beda antar mesin, hasilnya kita cetak sebagai boolean terhadap patokan 4 MB:

php
<?php
function rentang(int $maks): Generator
{
    for ($i = 1; $i <= $maks; $i++) {
        yield $i;
    }
}

$batas = 4 * 1024 * 1024; // patokan 4 MB

$sebelum = memory_get_usage();
iterator_count(rentang(2000000));
$deltaGenerator = memory_get_usage() - $sebelum;

$sebelum = memory_get_usage();
$array = range(1, 2000000);
$deltaArray = memory_get_usage() - $sebelum;
unset($array);

echo "Generator 2 juta elemen tetap di bawah 4 MB: "
    . var_export($deltaGenerator < $batas, true) . "\n";
echo "Array 2 juta elemen melampaui 4 MB: "
    . var_export($deltaArray > $batas, true) . "\n";

Output yang Dihasilkan:

text
Generator 2 juta elemen tetap di bawah 4 MB: true
Array 2 juta elemen melampaui 4 MB: true

Kedua jalur menelusuri dua juta nilai. Jalur generator memakai iterator_count() untuk menghitung elemen tanpa materialisasi — pertumbuhan memorinya tetap di bawah 4 MB karena hanya satu nilai yang hidup pada satu waktu. Jalur range(1, 2000000) harus menyimpan dua juta elemen sekaligus, dan array sebesar itu melampaui patokan 4 MB dengan mudah. Bayangkan selisih ini pada data nyata: membaca baris berkas, hasil query, atau hasil API yang diproses satu per satu tidak perlu menumpuk semuanya di memori lebih dulu.

Query Satu Baris pada Satu Waktu #

Kebiasaan bawaan banyak pengembang adalah fetchAll() — seluruh hasil query ditumpuk ke satu array sebelum diproses. PDO menyediakan alternatif yang lebih hemat: memanggil fetch() berulang kali sehingga hanya satu baris yang berada di memori pada satu waktu:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE log_aktivitas (id INTEGER PRIMARY KEY, pesan TEXT)");
$stmt = $pdo->prepare("INSERT INTO log_aktivitas (pesan) VALUES (?)");
foreach (["Login berhasil", "Perbarui profil", "Logout"] as $pesan) {
    $stmt->execute([$pesan]);
}

// fetchAll(): seluruh hasil dimuat ke satu array sekaligus
$semua = $pdo->query("SELECT id, pesan FROM log_aktivitas")->fetchAll(PDO::FETCH_ASSOC);
echo "fetchAll memuat " . count($semua) . " baris dalam satu array\n";

// fetch(): hanya satu baris berada di memori pada satu waktu
$query = $pdo->query("SELECT id, pesan FROM log_aktivitas");
$jumlah = 0;
while ($query->fetch(PDO::FETCH_ASSOC) !== false) {
    $jumlah++;
}
echo "fetch bertahap melalui $jumlah baris\n";

Output yang Dihasilkan:

text
fetchAll memuat 3 baris dalam satu array
fetch bertahap melalui 3 baris

Kedua jalur membaca tabel yang sama dan melalui tiga baris yang sama; bedanya hanya penumpukan. fetchAll() cocok bila hasilnya memang akan dipakai sebagai array utuh (misalnya diberikan ke template yang menghitung total). Untuk pemrosesan berurutan — menulis ulang ke berkas lain, mengirim email, mengagregasi angka — loop fetch() menjaga memori tetap rata seberapa pun besar hasilnya. Pada MySQL, pola ini berpadu dengan unbuffered query (menonaktifkan PDO::MYSQL_ATTR_USE_BUFFERED_QUERY): hasil mengalir dari server baris demi baris alih-alih ditumpuk penuh di buffer klien lebih dulu. Konsekuensinya, koneksi tetap sibuk sampai penelusuran selesai, sehingga jangan menyalakan mode ini lalu melupakan statement-nya setengah jalan.

Streaming Berkas dengan fgets #

Logika yang sama berlaku untuk berkas. Fungsi file() dan file_get_contents() memuat seluruh berkas ke memori; fgets() membacanya baris demi baris:

php
<?php
$namaFile = tempnam(sys_get_temp_dir(), "latihan");
file_put_contents($namaFile, "2026-01-15 login budi\n2026-01-15 logout budi\n2026-01-16 login siti\n");

// file() memuat seluruh berkas ke memori sebagai array
$semuaBaris = file($namaFile);
echo "file() memuat " . count($semuaBaris) . " baris sekaligus\n";

// fgets() membaca satu baris pada satu waktu
$handle = fopen($namaFile, "r");
$jumlah = 0;
while (($baris = fgets($handle)) !== false) {
    $jumlah++;
}
fclose($handle);
echo "fgets() membaca $jumlah baris satu per satu\n";

unlink($namaFile);

Output yang Dihasilkan:

text
file() memuat 3 baris sekaligus
fgets() membaca 3 baris satu per satu

Contoh di atas membuat berkas kecil sementara lewat tempnam(), mengisinya tiga baris, lalu membersihkannya dengan unlink() — pada berkas kecil kedua cara sama-sama sah. Perbedaannya baru terasa ketika berkas berukuran ratusan megabyte: file() memuat seluruh isi ke array (memori = ukuran berkas), sedangkan loop fgets() memakai memori selebar satu baris berapa pun panjang berkasnya. Pola fopen() → loop → fclose() ini juga menjadi dasar streaming respons besar yang akan kita pakai di chapter berikutnya; untuk berkas CSV, fgetcsv() drop-in menggantikan fgets() dengan parsing kolom gratis.

Siklus Referensi dan Garbage Collector #

PHP membebaskan memori dengan reference counting: setiap nilai menghitung berapa nama yang menunjuk kepadanya, dan saat hitungan mencapai nol, memorinya dilepas. Masalah muncul ketika dua object saling menunjuk — hitungan keduanya tidak pernah mencapai nol meskipun tidak ada variabel luar yang memegangnya. PHP mengatasinya dengan pengumpul siklus (cycle collector) yang berjalan otomatis:

php
<?php
class Simpul
{
    public ?Simpul $berikutnya = null;

    public function __construct(public readonly string $nama) {}
}

$a = new Simpul("A");
$b = new Simpul("B");
$a->berikutnya = $b;
$b->berikutnya = $a; // siklus: kedua objek saling menunjuk

unset($a, $b); // refcount keduanya masih di atas nol karena siklus

// Pengumpul siklus membersihkan pasangan objek yang terjebak ini
echo "Objek yang dikumpulkan: " . gc_collect_cycles() . "\n";

Output yang Dihasilkan:

text
Objek yang dikumpulkan: 2

Dua object Simpul saling menunjuk lewat property berikutnya — struktur yang wajar untuk linked list. Setelah unset($a, $b) tidak ada variabel luar yang memegang keduanya, tetapi masing-masing masih dirujuk oleh pasangannya sehingga reference count tidak turun ke nol. Pemanggilan gc_collect_cycles() memaksa pengumpul siklus bekerja dan melaporkan dua object yang berhasil dibebaskan. Dalam praktik Anda hampir tidak pernah memanggilnya manual — pengumpul berjalan otomatis saat akumulasi tertentu tercapai. Yang perlu Anda lakukan justru lebih sederhana: unset() variabel besar (hasil query, isi berkas) sebelum memasuki tahap berat berikutnya, agar memorinya bisa dimanfaatkan ulang lebih cepat.

Copy-on-Write: PHP Sudah Cerdas #

Ketakutan akan “penyalinan array yang mahal” sering membuat orang menempel & di semua tempat yang salah. Kenyataannya, penugasan array di PHP tidak menyalin data — kedua nama berbagi struktur internal yang sama, dan penyalinan sungguhan baru terjadi ketika salah satunya diubah (copy-on-write):

php
<?php
$data = ["nama" => "Budi", "email" => "[email protected]", "kota" => "Bandung"];

// Penugasan array TIDAK menyalin isinya berkat copy-on-write
$salinan = $data;

// Baris inilah yang baru memicu penyalinan internal yang sebenarnya
$salinan["nama"] = "Siti";

echo $data["nama"] . "\n";
echo $salinan["nama"] . "\n";

Output yang Dihasilkan:

text
Budi
Siti

Baris $salinan = $data hanya menaikkan penghitung referensi struktur array — murah, seberapa pun besar arraynya. Baris $salinan["nama"] = "Siti" barulah memicu penyalinan struktur karena kedua nama kini harus berbeda isi; terbukti $data["nama"] tetap Budi. Artinya: mengoper array besar ke function tanpa & sudah murah secara default. Referensi tetap punya tempat yang tepat — modifikasi in-place yang memang diinginkan dan iterasi write-heavy besar — tetapi memasang & dengan alasan “biar hemat” justru memaksa penyalinan terjadi lebih dini dan merusak optimasi copy-on-write.

Realitas memory_limit #

Semua pola di atas bekerja di dalam pagar bernama memory_limit — anggaran memori per skrip yang nilainya tercetak dalam konfigurasi (128M pada banyak instalasi default, termasuk lingkungan chapter ini). Melewatinya menghasilkan fatal error Allowed memory size of ... bytes exhausted, dan satu-satunya penanganan yang benar adalah mencegahnya terjadi. Untuk tugas khusus yang terbukti membutuhkan ruang lebih — misalnya memproses berkas yang harus utuh di memori — batasnya bisa dinaikkan sementara:

php
<?php
// Tugas impor besar yang terbukti membutuhkan ruang lebih:
ini_set("memory_limit", "512M");

// ... proses impor di sini ...

// Setelah selesai, batas bisa dikembalikan seperti semula
ini_set("memory_limit", "128M");

Angka 512M di sini bukan resep — bacalah kebutuhan tugas Anda dari laporan puncak pemakaian (memory_get_peak_usage()) saat pengembangan, lalu tetapkan batas yang masuk akal di atasnya. Menaikkan memory_limit adalah solusi terakhir, bukan yang pertama: bila skrip biasa tiba-tiba membutuhkan ratusan megabyte, hampir selalu ada pola materialisasi massal yang lebih baik diganti dengan salah satu teknik bertahap di atas.

Ringkasan #

Memori PHP hemat atau boros ditentukan oleh pola akses data: generator menjaga iterasi jutaan elemen tetap ringan, loop fetch() menggantikan fetchAll() untuk pemrosesan berurutan, fgets() menstreaming berkas baris demi baris, sementara copy-on-write membuat penugasan array murah tanpa referensi. Siklus referensi ditangani pengumpul siklus otomatis, dan memory_limit menjadi pagar terakhir yang menaikkan alarm saat pola kita salah. Semua teknik ini mencapai bentuk paling bergunanya ketika data harus dikirim keluar sebagai respons besar — unduhan berkas dan ekspor CSV — yang menjadi fokus chapter Streaming Large Responses berikutnya.

Referensi #