N+1 problem terjadi saat kode mengambil daftar data dengan 1 query, lalu mengirim 1 query lagi untuk setiap item di daftar itu. Ada N item, jadi totalnya N + 1 query. Mirip belanja 10 barang ke warung, tapi kamu bolak-balik 10 kali, satu barang per perjalanan. Barangnya gak berat, yang bikin capek perjalanannya. Di database juga begitu: tiap query adalah satu perjalanan pulang-pergi dari aplikasi ke database.
🧪 Tebakan cepat: halaman admin Kedai Senja menampilkan 10 pesanan terbaru beserta nama pelanggannya. Kodenya mengambil 10 pesanan, lalu di dalam loop menulis
$order->customer->name. Berapa query yang dikirim ke database: 1, 2, atau 11? Tebak dulu, jawabannya ada di bagian "Satu query per pesanan".
Tabel yang kita pakai
Kita lanjut dengan Kedai Senja. Pelanggannya Budi (id 1), Siti (2) dan Andi (3). Tabel orders sudah punya 20 pesanan, dan tiap pesanan menunjuk pelanggannya lewat customer_id:
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(id),
total INTEGER NOT NULL -- rupiah
);
Di Laravel, model Order punya relasi customer: satu pesanan milik satu pelanggan.
class Order extends Model
{
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}
Satu query per pesanan
$orders = Order::orderByDesc('id')->take(10)->get();
foreach ($orders as $order) {
echo $order->customer->name; // query baru di sini, tiap putaran
}
Ini query yang benar-benar sampai ke database, persis seperti yang dicatat query log Laravel:
SELECT * FROM orders ORDER BY id DESC LIMIT 10;
SELECT * FROM customers WHERE id = 2 LIMIT 1; -- pesanan #20, Siti
SELECT * FROM customers WHERE id = 1 LIMIT 1; -- pesanan #19, Budi
SELECT * FROM customers WHERE id = 2 LIMIT 1; -- pesanan #18, Siti lagi
SELECT * FROM customers WHERE id = 3 LIMIT 1; -- pesanan #17, Andi
-- ... 6 query lagi, satu per pesanan
Totalnya 11 query: 1 untuk daftar pesanan, ditambah 10 untuk pelanggan, satu per pesanan. Itu jawaban tebakannya.
Order::...->get()cuma mengambil tabelorders. Pelanggannya belum ikut.$order->customerdiakses pertama kali di dalam loop, dan saat itulah Eloquent mengirim query untuk pelanggan pesanan itu. Cara memuat relasi "nanti, saat diakses" ini namanya lazy loading, dan ini perilaku bawaan Eloquent.- Budi muncul di 4 dari 10 pesanan, jadi baris Budi yang sama diambil 4 kali.
Makin banyak pesanan di halaman, makin banyak query: 100 pesanan jadi 101 query, 1.000 pesanan jadi 1.001 query. Di lokal dengan 3 pesanan semuanya terasa cepat. Di production, database biasanya ada di server lain, jadi tiap query menempuh jaringan, sering 0,5 sampai 1 ms walau servernya satu data center. 1.001 query berarti setengah sampai satu detik habis cuma untuk bolak-balik.
Eager loading dengan with()
Solusinya: bilang ke Eloquent sejak awal bahwa relasi customer akan dipakai.
$orders = Order::with('customer')->orderByDesc('id')->take(10)->get();
foreach ($orders as $order) {
echo $order->customer->name; // sudah ada di memory, gak ada query
}
SELECT * FROM orders ORDER BY id DESC LIMIT 10;
SELECT * FROM customers WHERE id IN (1, 2, 3);
with('customer')berisi nama method relasinya,customer.- Setelah query pertama, Laravel mengumpulkan semua
customer_iddari 10 pesanan itu, membuang yang dobel, lalu mengambil semuanya sekaligus denganWHERE id IN (1, 2, 3). - Pelanggan dipasangkan ke pesanannya di PHP, jadi loop-nya gak mengirim query lagi.
Cara memuat relasi di awal, bersama query utamanya, ini namanya eager loading. Hasilnya selalu 2 query untuk satu relasi, berapa pun jumlah pesanannya:
| Jumlah pesanan | Lazy loading | Eager loading with() |
|---|---|---|
| 10 | 11 query | 2 query |
| 100 | 101 query | 2 query |
| 1.000 | 1.001 query | 2 query |
Dua relasi, misalnya with('customer', 'items'), berarti 3 query: 1 untuk pesanan, ditambah 1 per relasi.
Cara ketahuan ada N+1
- Lihat query log. Laravel Debugbar atau Telescope menampilkan jumlah query per request. Query yang bentuknya sama berulang dengan id berbeda adalah tanda N+1.
- Larang lazy loading saat development. Taruh ini di
AppServiceProvider:
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
Di lokal, kode tadi langsung berhenti dengan LazyLoadingViolationException: Attempted to lazy load [customer] on model [App\Models\Order] but lazy loading is disabled. Di production, aplikasinya tetap jalan seperti biasa.
N+1 bukan masalah Laravel saja. Di JavaScript bentuknya sama: for (const order of orders) { await db.query('SELECT * FROM customers WHERE id = ?', [order.customer_id]) } juga 1 + N query.
📌 withCount dan relasi bertingkat
- Cuma butuh jumlah?
Customer::withCount('orders')->get()menghitung lewat subquery di query yang sama, lalu hasilnya ada di$customer->orders_count. Menulis$customer->orders()->count()di dalam loop mengirim satu queryCOUNTper pelanggan, N+1 lagi. - Relasi dari relasi:
Order::with('items.product')memuat item tiap pesanan, lalu produk tiap item. Tiap tingkat menambah 1 query, bukan N. - Sudah terlanjur
get()?$orders->load('customer')memuat relasinya belakangan, tetap dengan satuWHERE id IN (...).
⚠️ Jebakan yang sering ketemu
- Eager load relasi yang salah.
with('customer')tapi view juga memakai$order->items. Pelanggannya aman, item-nya tetap N+1. - Tes dengan 3 baris data. 4 query terasa instan. Masalahnya baru kelihatan saat tabelnya berisi ribuan baris.
orders()pakai kurung di dalam loop.$customer->orders()membuat query baru setiap dipanggil. Pakaiwith('orders')atauwithCount('orders').- Di latihan, eager loading tetap 2 query untuk jumlah berapa pun.
hitungQuery(1000, true)hasilnya2, bukan1001. Tanpa eager loading, hasilnyajumlahItem + 1.
TL;DR: N+1 terjadi saat 1 query mengambil daftar, lalu setiap item mengirim 1 query lagi untuk relasinya: 10 pesanan jadi 11 query, 1.000 jadi 1.001. Itu karena Eloquent memakai lazy loading: relasi baru diambil saat diakses. Eager loading dengan Order::with('customer') memuat relasinya di awal lewat satu WHERE id IN (...), jadi totalnya selalu 2 query untuk satu relasi. Cek jumlah query di query log atau Debugbar, dan pasang Model::preventLazyLoading() di development supaya N+1 langsung ketahuan.