N+1 Problem — Cara Deteksi dan Solusinya

Ada jenis bug yang paling berbahaya bukan karena dia crash, tapi karena dia diam-diam memperlambat aplikasimu dan baru terasa ketika sudah terlambat.

N+1 Problem adalah salah satunya.

Kodenya terlihat benar. Hasilnya benar. Tapi di baliknya, database sedang dibombardir ratusan query yang tidak perlu — dan kamu tidak sadar sampai ada pengguna mengeluh halaman lambat, atau sampai server kehabisan koneksi.

Di artikel ini kita bahas: apa itu N+1, bagaimana mendeteksinya sebelum sampai production, dan semua cara memperbaikinya.


🤔 Apa Itu N+1 Problem?

Analoginya

Bayangin kamu punya daftar 100 nama murid, dan kamu mau tahu nilai ujian masing-masing.

Cara bodoh: pergi ke lemari arsip, ambil map murid pertama, baca nilainya, kembalikan. Pergi lagi, ambil map murid kedua, baca nilainya, kembalikan. Ulangi 100 kali.

Cara pintar: ambil semua 100 map sekaligus, baca semuanya dalam satu dudukan.

Cara bodoh itu yang terjadi di N+1 Problem — satu query untuk daftarnya, lalu satu query lagi untuk setiap item dalam daftar.

Dalam kode

$posts = Post::all(); // Query #1: SELECT * FROM posts

foreach ($posts as $post) {
    echo $post->user->name;
    // Query #2, #3, #4, ... #N+1: SELECT * FROM users WHERE id = ?
}

Kalau ada 100 post → 101 query. Kalau ada 1.000 post → 1.001 query.

Ini bukan teori. Di aplikasi nyata dengan data yang cukup besar, ini bisa membuat satu halaman membutuhkan ribuan query hanya untuk di-render.


🔍 Cara Deteksi N+1 Problem

Memperbaiki N+1 yang sudah ada di production adalah situasi yang mau kita hindari. Lebih baik deteksi saat development.

Cara 1 — Laravel Debugbar

Package paling populer untuk ini. Install sekali, langsung terlihat semua query yang dijalankan di setiap request — lengkap dengan berapa kali query yang sama diulang.

composer require barryvdh/laravel-debugbar --dev

Setelah terpasang, buka aplikasimu di browser. Di bagian bawah halaman muncul toolbar — klik tab Queries. Kalau kamu lihat query yang sama berulang puluhan kali dengan ID yang berbeda-beda, itu N+1.

# Tanda bahaya yang terlihat di Debugbar:
SELECT * FROM users WHERE id = 1    ← ini normal
SELECT * FROM users WHERE id = 2    ← mulai curiga
SELECT * FROM users WHERE id = 3    ← N+1
SELECT * FROM users WHERE id = 4    ← N+1
... (95 query lagi)

Cara 2 — preventLazyLoading()

Ini cara yang lebih agresif dan sangat disarankan untuk environment development. Laravel akan melempar exception setiap kali relasi diakses tanpa eager loading — jadi N+1 tidak bisa lolos tanpa disadari.

📁 app/Providers/AppServiceProvider.php

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Aktifkan hanya di environment development
    Model::preventLazyLoading(! app()->isProduction());
}

Sekarang kalau ada kode yang memicu N+1, kamu akan mendapat error yang jelas:

Illuminate\Database\LazyLoadingViolationException:
Attempted to lazy load [user] on model [App\Models\Post]
but lazy loading is disabled.

📌 Ini adalah net safety yang sangat berguna. Kamu tidak bisa tidak sadar — aplikasi langsung protes sebelum masalahnya sampai ke production.

Cara 3 — DB::listen()

Untuk debugging manual yang lebih granular, kamu bisa log semua query yang berjalan:

// Taruh sementara di controller atau tinker
\DB::listen(function ($query) {
    \Log::info($query->sql, $query->bindings);
});

Atau pakai toSql() untuk melihat query yang akan dihasilkan sebelum dieksekusi:

dd(Post::with('user')->toSql());
// "select * from `posts`"

💡 Solusi 1 — Eager Loading dengan with()

Ini solusi utama dan paling sering dipakai. Daripada memuat relasi saat diperlukan (lazy), kamu minta Laravel untuk memuat semuanya sekaligus di awal (eager).

// ❌ Sebelum — 101 query untuk 100 post
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->user->name;
}

// ✅ Sesudah — selalu 2 query, berapapun jumlah post
$posts = Post::with('user')->get();
foreach ($posts as $post) {
    echo $post->user->name;
}

Di balik layar, Laravel menjalankan dua query yang efisien:

-- Query 1: ambil semua post
SELECT * FROM posts;

-- Query 2: ambil semua user yang relevan sekaligus
SELECT * FROM users WHERE id IN (1, 2, 3, 4, ...);

Eager loading banyak relasi sekaligus

// Load beberapa relasi dalam satu panggilan
$posts = Post::with(['user', 'comments', 'tags'])->get();

Eager loading relasi bersarang (nested)

// Load komentar, dan setiap komentar juga load usernya
$posts = Post::with('comments.user')->get();

Eager loading dengan kondisi

// Hanya load komentar yang sudah disetujui
$posts = Post::with(['comments' => function ($query) {
    $query->where('is_approved', true)->orderBy('created_at', 'desc');
}])->get();

💡 Solusi 2 — Lazy Eager Loading dengan load()

Kadang kamu sudah terlanjur punya koleksi model — mungkin datang dari method lain, atau kondisinya baru diketahui setelah data diambil. Di sini kamu tidak bisa pakai with() lagi, tapi masih bisa pakai load().

// Data sudah ada di tangan
$posts = Post::all();

// Baru sadar butuh relasi — load sekarang
// Tetap hanya 1 query tambahan, bukan N query
$posts->load('user');

// Atau banyak relasi sekaligus
$posts->load(['user', 'comments']);

📌 load() berbeda dari akses lazy biasa ($post->user). load() dipanggil pada koleksi — hasilnya satu query untuk semua. Sedangkan $post->user di dalam loop menghasilkan query per item.


💡 Solusi 3 — Chunking untuk Dataset Besar

Eager loading memang solusi N+1, tapi ada masalah lain yang muncul ketika datanya sangat besar: memori.

Post::with('user')->get() akan muat semua data ke memori sekaligus. Kalau ada 500.000 post, kamu bisa kehabisan memory limit PHP sebelum sempat memproses apapun.

Di sinilah chunk() masuk.

chunk() — Proses per batch

// Proses 200 post sekaligus, tidak pernah lebih dari itu di memori
Post::with('user')->chunk(200, function ($posts) {
    foreach ($posts as $post) {
        // proses tiap post
        // misalnya: kirim notifikasi, generate laporan, export CSV
    }
});

Di balik layar, Laravel menjalankan query dengan LIMIT dan OFFSET yang bergeser setiap batch:

SELECT * FROM posts LIMIT 200 OFFSET 0;
SELECT * FROM posts LIMIT 200 OFFSET 200;
SELECT * FROM posts LIMIT 200 OFFSET 400;
-- dan seterusnya sampai habis

chunkById() — Lebih aman untuk data yang berubah

chunk() punya satu kelemahan: kalau ada data yang dihapus atau diubah di tengah proses chunking, offset bisa meleset — beberapa record bisa terlewat.

chunkById() tidak pakai offset, tapi pakai WHERE id > ? — lebih aman:

Post::with('user')->chunkById(200, function ($posts) {
    foreach ($posts as $post) {
        // aman meski ada data yang berubah di tengah proses
    }
});

lazy() — Alternatif modern yang lebih elegan

Laravel juga punya lazy() dan lazyById() — bekerja seperti chunk tapi kamu bisa pakai sintaks foreach biasa tanpa callback:

// Terasa seperti iterasi biasa, tapi di baliknya tetap pakai chunking
foreach (Post::with('user')->lazy() as $post) {
    // proses tiap post
}

📌 lazy() menggunakan PHP Generator di baliknya — memori tetap efisien karena hanya satu batch yang ada di memori setiap saat.


🗺️ Gambaran Besar — Pilih Solusi yang Tepat

Data yang diambil
│
├── Ukuran normal (ratusan hingga ribuan record)
│    │
│    ├── Belum punya data → with('relasi')->get()
│    │
│    └── Sudah punya koleksi → $koleksi->load('relasi')
│
└── Dataset sangat besar (puluhan ribu ke atas)
     │
     ├── Butuh callback per batch → chunk() / chunkById()
     │
     └── Mau sintaks foreach biasa → lazy() / lazyById()

📊 Perbandingan Solusi

MetodeJumlah QueryMemoriCocok untuk
Lazy (default)N+1 ❌Rendah— hindari dalam loop
with()2 query ✅Tinggi (semua dimuat)Dataset normal
load()2 query ✅Tinggi (semua dimuat)Koleksi yang sudah ada
chunk()Per batch ✅Rendah ✅Dataset besar
chunkById()Per batch ✅Rendah ✅Dataset besar + data bisa berubah
lazy()Per batch ✅Rendah ✅Dataset besar, sintaks bersih

💡 Ingat!

N+1 Problem hampir tidak pernah terlihat di data kecil — itulah yang membuatnya berbahaya. Di local development dengan 10 record, semua terasa cepat. Di production dengan 10.000 record, halaman yang sama bisa timeout.

Dua kebiasaan yang kalau diterapkan dari awal akan menyelamatkan banyak waktu debugging:

Pertama, aktifkan Model::preventLazyLoading() di environment development. Biarkan Laravel yang berteriak duluan, bukan pengguna di production.

Kedua, biasakan selalu tanya pada diri sendiri ketika menulis loop Eloquent: “Apakah saya mengakses relasi di dalam loop ini?” Kalau iya — with() dulu sebelum loop dimulai.

Itu saja. Dua kebiasaan kecil, efeknya besar.