Masalah N+1 adalah penyakit paling mahal di aplikasi ber-ORM: kode terlihat benar, hasilnya benar, tetapi database mengerjakan ratusan query untuk satu halaman. Chapter Lazy Loading sudah menunjukkan bentuk dasarnya — enam post berarti tujuh query — dan sepanjang section ini kita membangun pertahanannya satu per satu. Chapter penutup ini menyusun semuanya menjadi satu alur kerja lengkap: ukur kerusakan dengan query log, perbaiki dengan eager loading , lalu pasang penjaga agar pola itu tidak kembali diam-diam. Sebagai bonus, kita bedah dua jebakan yang membuat N+1 lolos dari mata telanjang: relasi bersarang dan accessor.
Mengukur Masalahnya #
N+1 paling licik saat bersembunyi di belakang eager loading yang setengah jadi. Kode berikut sudah memakai with('comments') — namun di dalam komentar, $c->user->name tetap dimuat malas:
use App\Models\Post;
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
foreach (Post::with('comments')->orderBy('id')->get() as $post) {
$nama = $post->comments->map(fn ($c) => $c->user->name);
echo $post->judul, ': ', $nama->implode(', '), "\n";
}
$log = DB::getQueryLog();
echo 'total query: ', count($log), "\n";
foreach (array_slice($log, 0, 4) as $q) {
echo '[', $q['query'], "]\n";
}Output yang Dihasilkan:
Belajar Relasi 01: Siti, Anton, Siti
Belajar Relasi 02: Budi, Budi
Belajar Relasi 03:
Belajar Relasi 04: Anton, Budi, Siti
Belajar Relasi 05: Siti
Belajar Relasi 06:
total query: 11
[select * from "posts" order by "id" asc]
[select * from "comments" where "comments"."post_id" in (1, 2, 3, 4, 5, 6)]
[select * from "users" where "users"."id" = ? limit 1]
[select * from "users" where "users"."id" = ? limit 1]Empat baris pertama log memperlihatkan polanya: query posts, query comments, lalu query users yang berulang — sembilan komentar berarti sembilan query tambahan, total 11. Perbaikan setengah jadi seperti ini justru lebih berbahaya daripada tidak memuat sama sekali, karena rasanya sudah “aman” padahal tidak.
Perbaikannya: Eager Loading Bersarang #
Notasi titik pada with() sekaligus memuat relasi dari relasi:
DB::flushQueryLog();
foreach (Post::with('comments.user')->orderBy('id')->get() as $post) {
$nama = $post->comments->map(fn ($c) => $c->user->name);
echo $post->judul, ': ', $nama->implode(', '), "\n";
}
$log = DB::getQueryLog();
echo 'total query: ', count($log), "\n";
foreach ($log as $q) {
echo '[', $q['query'], "]\n";
}Output yang Dihasilkan:
Belajar Relasi 01: Siti, Anton, Siti
Belajar Relasi 02: Budi, Budi
Belajar Relasi 03:
Belajar Relasi 04: Anton, Budi, Siti
Belajar Relasi 05: Siti
Belajar Relasi 06:
total query: 3
[select * from "posts" order by "id" asc]
[select * from "comments" where "comments"."post_id" in (1, 2, 3, 4, 5, 6)]
[select * from "users" where "users"."id" in (1, 2, 3)]Output tampilan identik, biaya turun dari 11 query ke 3. Semua penulis dikumpulkan dalam satu where ... in (1, 2, 3) — dan jumlah ini tetap tiga berapa pun jumlah post maupun komentarnya. Sebelum memasang perbaikan, pastikan dulu polanya memang N+1: baca SQL dari query log, cari baris identik yang berulang dengan binding berbeda.
Deteksi Dini dengan preventLazyLoading #
Query log berguna saat menyelidiki, tetapi penjaga permanen bekerja sebaliknya: melarang lazy loading sejak awal. Dengan Model::preventLazyLoading() yang kita pasang di AppServiceProvider (lihat chapter Lazy Loading
), pelanggaran pertama langsung meledak:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\LazyLoadingViolationException;
Model::preventLazyLoading(true);
try {
foreach (Post::orderBy('id')->get() as $post) {
echo $post->user->name, "\n";
}
} catch (LazyLoadingViolationException $e) {
echo 'Exception: ', $e->getMessage(), "\n";
}Output yang Dihasilkan:
Exception: Attempted to lazy load [user] on model [App\Models\Post] but lazy loading is disabled.Pesan exception menunjuk persis lokasinya: relasi user pada App\Models\Post. Di aplikasi nyata, stack trace membawa kita ke baris kode yang menyentuh relasi tersebut tanpa with() — perbaikannya tinggal menambahkan eager loading di query terkait.
Menghitung Tanpa Menghentikan Eksekusi #
Melempar exception kadang terlalu keras — misalnya saat memindai proyek lama yang penuh pelanggaran. Callback handleLazyLoadingViolationUsing() mengubah reaksi default menjadi penghitung:
$hitung = 0;
Model::preventLazyLoading(true);
Model::handleLazyLoadingViolationUsing(function ($model, $relation) use (&$hitung) {
$hitung++;
});
DB::flushQueryLog();
foreach (Post::orderBy('id')->get() as $post) {
$post->user->name;
}
echo 'pelanggaran: ', $hitung, ' | query: ', count(DB::getQueryLog()), "\n";Output yang Dihasilkan:
pelanggaran: 6 | query: 7Eksekusi tetap berjalan (relasi tetap termuat malas), tetapi setiap pelanggaran tercatat — enam akses malas, tujuh query. Pola serupa bisa dipakai untuk menulis log peringatan alih-alih menghitung. Pengawasan level query juga tersedia lewat DB::listen(), yang memanggil callback untuk setiap query berjalan:
$jumlah = 0;
DB::listen(function ($query) use (&$jumlah) {
$jumlah++;
});
foreach (Post::orderBy('id')->get() as $post) {
$post->user->name;
}
echo 'query terekam listener: ', $jumlah, "\n";Output yang Dihasilkan:
query terekam listener: 7Paket seperti Laravel Debugbar dan Telescope membangun seluruh tampilan query-per-request di atas mekanisme yang sama.
Jebakan Tersembunyi: Accessor dan Serialisasi #
Sumber N+1 tidak selalu terlihat di kode controller. Accessor yang menyentuh relasi akan memicu lazy load kapan pun dipanggil — termasuk saat model di-serialisasi ke JSON bila accessor masuk daftar $appends:
class CommentWithAuthor extends Comment
{
protected $table = 'comments';
protected $appends = ['nama_penulis'];
public function getNamaPenulisAttribute()
{
return $this->user->name;
}
}
$comments = CommentWithAuthor::orderBy('id')->limit(3)->get();
foreach ($comments as $comment) {
echo 'komentar #', $comment->id, ' oleh ', $comment->nama_penulis, "\n";
}
echo 'total query: ', count(DB::getQueryLog()), "\n";Output yang Dihasilkan:
komentar #1 oleh Siti
komentar #2 oleh Anton
komentar #3 oleh Siti
total query: 4Tiga komentar, empat query — select * from "comments" ... limit 3 diikuti tiga query users yang identik. Efek sampingnya pun nyata: karena accessor memuat relasi user ke property relations, JSON yang dihasilkan toJson() ikut menyisipkan objek user lengkap — "nama_penulis":"Siti","user":{"id":2,...}. Untuk model seperti ini, eager load wajib dinyalakan sejak query: CommentWithAuthor::with('user')->... membuat semua akses accessor bebas query.
Kapan N+1 Masih Wajar #
| Kondisi | Contoh | Penilaian |
|---|---|---|
| N kecil dan tetap | Mengolah 3 post pilihan lewat Post::find([1, 3, 6]) | 4 query sekali jalan — tidak perlu rumit |
| Skrip admin sekali pakai | Rekap data yang berjalan semalam | Kejelasan kode lebih penting daripada milidetik |
| Halaman publik, data besar | Daftar 200 post beserta relasi | Wajib eager loading |
| Relasi di dalam accessor/serialisasi | $appends seperti di atas | Wajib eager loading + uji query log |
Bukti untuk kasus pertama — tiga post dari find([1, 3, 6]):
DB::flushQueryLog();
foreach (Post::find([1, 3, 6]) as $post) {
echo $post->judul, ' — ', $post->user->name, "\n";
}
echo 'total query: ', count(DB::getQueryLog()), "\n";Output yang Dihasilkan:
Belajar Relasi 01 — Budi
Belajar Relasi 03 — Budi
Belajar Relasi 06 — Siti
total query: 4Empat query untuk tiga model adalah harga yang pantas untuk kode yang ringkas. Aturan praktisnya: khawatirkan N yang tumbuh mengikuti data pengguna, bukan N kecil yang kita kendalikan.
Dengan pengukuran (getQueryLog(), DB::listen()), perbaikan (with() bersarang), dan penjaga (preventLazyLoading()), siklus lengkap melawan N+1 sudah di tangan kita. Section Eloquent Relationships pun tertutup: dari definisi relasi di awal section sampai operasi tulis dan pivot table, semua pola di sini dirancang agar model yang saling terhubung tetap cepat — dan tetap jujur terhadap query yang benar-benar dijalankannya.