N+1 query di Laravel sering terlihat sepele saat data masih sedikit, tetapi di production dampaknya cepat terasa: response time naik, jumlah query membengkak, CPU database tinggi, dan pagination menjadi lambat. Kasus ini umum terjadi pada halaman daftar order ketika setiap baris mencoba mengakses relasi customer dan items secara terpisah di dalam loop.
Pada artikel ini, kita bahas postmortem bug nyata di halaman daftar order: bagaimana gejalanya muncul, apa root cause teknisnya, cara mereproduksi dan mengidentifikasi masalah lewat log SQL, Laravel Debugbar atau Telescope, serta bagaimana memperbaikinya dengan eager loading, pemilihan kolom seperlunya, dan disiplin menghindari akses relasi di loop.
Studi kasus: gejala di production
Halaman yang bermasalah adalah daftar order dengan pagination. Setiap order menampilkan informasi berikut:
- Nomor order
- Nama customer
- Jumlah item
- Total harga
- Tanggal order
Secara fungsional, halaman bekerja normal. Namun setelah volume data bertambah, tim mulai melihat gejala berikut di production:
- Response time meningkat terutama pada endpoint daftar order.
- Jumlah query SQL melonjak saat membuka satu halaman pagination.
- CPU database tinggi walau traffic tidak naik drastis.
- Pagination terasa lambat, terutama saat pindah halaman atau memakai filter.
Gejala ini khas untuk kasus ketika aplikasi mengeksekusi banyak query kecil yang sebenarnya bisa digabung menjadi beberapa query saja.
Root cause teknis N+1 query di Laravel
N+1 query terjadi ketika aplikasi mengambil daftar data utama dengan 1 query, lalu untuk setiap baris data menjalankan query tambahan untuk mengambil relasi. Jika ada 50 order dalam satu halaman, dan setiap order memicu query customer dan query items, maka total query bisa bertambah sangat cepat.
Contoh pola masalah:
- 1 query untuk mengambil 50 order
- 50 query untuk mengambil customer tiap order
- 50 query untuk mengambil items tiap order
Totalnya menjadi 101 query hanya untuk merender satu halaman. Jika di dalam loop ada perhitungan lain berbasis relasi, jumlah query bisa lebih besar lagi.
Di Laravel, ini biasanya muncul karena lazy loading: relasi baru diambil saat properti relasi diakses, misalnya $order->customer atau $order->items. Lazy loading tidak salah dalam semua kasus, tetapi menjadi masalah pada halaman list dengan banyak baris.
Contoh kode sebelum perbaikan
Berikut contoh controller dan Blade yang tampak wajar, tetapi berpotensi menimbulkan N+1 query:
// Controller
public function index()
{
$orders = Order::query()
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}
<!-- Blade -->
@foreach ($orders as $order)
<tr>
<td>{{ $order->order_number }}</td>
<td>{{ $order->customer->name }}</td>
<td>{{ $order->items->count() }}</td>
<td>{{ number_format($order->items->sum('price')) }}</td>
<td>{{ $order->created_at }}</td>
</tr>
@endforeach
Masalah teknis di sini:
$order->customermemicu query jika relasi belum dimuat.$order->itemsjuga memicu query per order.- Pemanggilan
count()dansum()pada collection relasi yang baru dimuat memperbesar biaya memori dan CPU aplikasi. - Jika relasi tidak dibatasi kolomnya, aplikasi mengambil data lebih banyak dari yang dibutuhkan.
Cara mereproduksi bug secara lokal
Agar debugging akurat, reproduksi masalah dengan kondisi yang mendekati production:
- Buat data order dalam jumlah cukup, misalnya beberapa ratus atau ribuan baris.
- Pastikan setiap order punya relasi customer dan beberapa items.
- Buka halaman daftar order dengan pagination 50 atau 100 item per halaman.
- Amati query yang berjalan saat view dirender.
Jika data lokal terlalu kecil, N+1 kadang tidak terlihat signifikan. Masalahnya baru terasa ketika satu halaman berisi cukup banyak relasi yang diakses berulang.
Dataset yang perlu disiapkan
Untuk kasus ini, pola data yang relevan adalah:
- Satu order belongsTo satu customer.
- Satu order hasMany items.
- Halaman list menampilkan field dari order, customer, dan ringkasan items.
Fokusnya bukan pada ukuran tabel semata, melainkan pada banyaknya akses relasi per halaman.
Mengidentifikasi masalah: log SQL, Debugbar, Telescope, dan EXPLAIN
1. Log SQL untuk melihat pola query berulang
Cara paling langsung adalah merekam query SQL yang dijalankan saat endpoint dipanggil. Tujuannya bukan sekadar menghitung jumlah query, tetapi mencari pola query yang sama dieksekusi berulang dengan parameter berbeda.
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::listen(function ($query) {
Log::debug('SQL', [
'sql' => $query->sql,
'bindings' => $query->bindings,
'time_ms' => $query->time,
]);
});
Indikasi N+1 biasanya terlihat seperti ini:
- Satu query
select * from orders ... limit ... offset ... - Lalu berkali-kali query
select * from customers where id = ? limit 1 - Lalu berkali-kali query
select * from order_items where order_id = ?
Jika query yang sama muncul puluhan kali dengan binding berbeda, hampir pasti ada akses relasi di dalam loop.
2. Laravel Debugbar untuk debugging lokal
Laravel Debugbar berguna di local environment karena langsung menampilkan total query, durasi query, dan duplikasi query dalam satu request. Untuk halaman daftar order, Anda bisa membuka tab query dan melihat apakah ada query berulang terhadap tabel customers atau order_items.
Pilih Debugbar ketika:
- Masalah mudah direproduksi di lokal.
- Anda ingin melihat query per request dengan cepat.
- Anda butuh indikasi visual soal duplikasi query.
Gunakan Debugbar hanya di lingkungan pengembangan. Jangan mengaktifkannya di production.
3. Laravel Telescope untuk inspeksi request
Telescope cocok saat Anda ingin menelusuri request tertentu dan melihat query yang terkait secara lebih terstruktur. Ini berguna di environment non-production yang mirip staging, atau saat tim ingin membandingkan sebelum dan sesudah perbaikan tanpa menambahkan logging manual.
Pilih Telescope ketika:
- Anda perlu histori request dan query.
- Ingin memeriksa endpoint tertentu secara berulang.
- Butuh visibilitas debugging yang lebih lengkap daripada Debugbar.
4. EXPLAIN untuk memastikan query utama efisien
Setelah N+1 diatasi, jangan berhenti di sana. Query utama dan query eager loading tetap perlu dicek dengan EXPLAIN secara ringkas untuk memastikan database tidak melakukan full scan yang tidak perlu.
Misalnya, cek query order yang dipakai untuk pagination dan query relasi yang mengambil customer atau items berdasarkan foreign key. Hal yang dilihat secara umum:
- Apakah filter dan sorting memakai index yang sesuai.
- Apakah relasi menggunakan kolom foreign key yang terindeks.
- Apakah jumlah row yang dipindai masuk akal.
EXPLAIN tidak dipakai untuk mendeteksi N+1 secara langsung, melainkan untuk memastikan query hasil perbaikan tetap sehat di level database.
Perbaikan: eager loading, select kolom seperlunya, dan hindari akses relasi di loop
1. Eager loading relasi yang dipakai
Perbaikan pertama adalah memuat relasi yang memang dibutuhkan sejak awal dengan with(). Ini mengubah banyak query kecil menjadi beberapa query yang lebih terkontrol.
// Controller
public function index()
{
$orders = Order::query()
->select(['id', 'order_number', 'customer_id', 'created_at'])
->with([
'customer:id,name',
'items:id,order_id,price'
])
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}
Kenapa ini bekerja:
- Laravel mengambil order terlebih dahulu.
- Lalu mengambil semua customer yang relevan dalam satu query terpisah.
- Lalu mengambil semua items untuk order di halaman tersebut dalam satu query terpisah.
Dengan pola ini, jumlah query turun drastis dibanding memuat relasi satu per satu di dalam loop.
2. Pilih kolom seperlunya
select() pada query utama dan pembatasan kolom pada with() membantu mengurangi data yang ditransfer dan di-hydrate menjadi model Eloquent. Ini penting jika tabel punya banyak kolom yang tidak dipakai di halaman list.
Namun ada satu aturan penting: saat membatasi kolom relasi, jangan lupa menyertakan primary key dan foreign key yang dibutuhkan untuk memetakan relasi. Contohnya:
customer:id,nameperlu kolomiditems:id,order_id,priceperlu kolomiddanorder_id
Kesalahan umum adalah memilih kolom terlalu agresif hingga relasi gagal dipetakan dengan benar.
3. Hindari perhitungan relasi mahal di Blade
Meski relasi sudah di-eager load, tetap hindari logika berat di view. Untuk kasus jumlah item, lebih efisien memakai agregasi di query daripada menghitung dari collection di Blade.
Contoh perbaikan yang lebih baik:
// Controller
public function index()
{
$orders = Order::query()
->select(['id', 'order_number', 'customer_id', 'created_at', 'grand_total'])
->with(['customer:id,name'])
->withCount('items')
->latest()
->paginate(50);
return view('orders.index', compact('orders'));
}
<!-- Blade -->
@foreach ($orders as $order)
<tr>
<td>{{ $order->order_number }}</td>
<td>{{ $order->customer?->name ?? '-' }}</td>
<td>{{ $order->items_count }}</td>
<td>{{ number_format($order->grand_total) }}</td>
<td>{{ $order->created_at }}</td>
</tr>
@endforeach
Jika total harga sudah tersedia di kolom order, lebih baik gunakan kolom tersebut daripada menjumlahkan ulang item di setiap request list. Bila belum ada, pertimbangkan:
- Menghitung total saat order dibentuk atau diupdate, lalu menyimpannya di order.
- Menggunakan agregasi query yang eksplisit bila memang perlu ditampilkan.
Intinya, halaman list sebaiknya tidak melakukan komputasi berat berbasis relasi untuk setiap baris.
4. Cegah akses relasi tersembunyi di accessor atau serializer
Bug N+1 tidak selalu terlihat di Blade. Kadang sumbernya ada di accessor, resource, atau transformasi JSON yang diam-diam mengakses relasi.
public function getCustomerNameAttribute()
{
return $this->customer->name;
}
Accessor seperti ini tampak rapi, tetapi jika dipanggil untuk banyak order tanpa eager loading, hasilnya sama saja: query customer dieksekusi berulang. Karena itu, periksa juga:
- Accessor model
- API Resource
- Transformer
- Presenter atau helper view
Perbandingan sebelum dan sesudah perbaikan
Sebelum
- Query order dipanggil sekali.
- Customer di-load per order.
- Items di-load per order.
- View melakukan
count()dansum()dari relasi. - Pagination melambat seiring jumlah data di halaman.
Sesudah
- Order diambil dengan kolom yang memang dipakai.
- Customer dimuat lewat eager loading.
- Jumlah item diambil lewat
withCount(). - Total memakai nilai yang sudah tersedia atau agregasi yang lebih tepat.
- View hanya merender data, bukan memicu query tambahan.
Tujuan utama perbaikan bukan hanya mengurangi angka query, tetapi membuat pola akses data menjadi eksplisit dan terprediksi.
Langkah verifikasi setelah fix
Setelah perubahan dilakukan, verifikasi jangan hanya berdasarkan “terasa lebih cepat”. Gunakan checklist yang terukur:
- Hitung ulang jumlah query pada endpoint daftar order.
- Bandingkan pola SQL: query berulang terhadap customer dan items harus hilang.
- Cek waktu response di environment yang representatif.
- Amati CPU database dan metrik query jika ada monitoring.
- Uji pagination pada beberapa halaman, bukan hanya halaman pertama.
- Uji filter dan sorting karena kombinasi query bisa berubah.
Jika memungkinkan, tambahkan pengujian atau assertion sederhana untuk mencegah regresi. Minimal, dokumentasikan ekspektasi bahwa endpoint list tidak boleh mengakses relasi secara lazy di loop.
Apa yang perlu terlihat setelah perbaikan
- Jumlah query turun signifikan.
- Tidak ada lagi pola query relasi yang berulang per row.
- Waktu render halaman lebih stabil saat jumlah item per halaman bertambah.
- Beban database menurun karena jumlah round-trip berkurang.
Trade-off dan batasan eager loading
Eager loading adalah solusi utama untuk N+1, tetapi tetap punya trade-off:
- Memori aplikasi bisa naik jika terlalu banyak relasi dimuat sekaligus.
- Data berlebih tetap mungkin terjadi jika semua kolom diambil tanpa seleksi.
- Tidak semua kebutuhan cocok dengan Eloquent penuh; untuk list kompleks, query builder atau agregasi terpisah kadang lebih efisien.
Karena itu, gunakan eager loading secara terarah:
- Muat hanya relasi yang benar-benar dipakai.
- Batasi kolom seperlunya.
- Pakai
withCount()atau agregasi bila yang dibutuhkan hanya ringkasan. - Hindari memuat relasi besar yang tidak ditampilkan.
Untuk halaman daftar, kebutuhan umumnya adalah data ringkas. Jangan perlakukan halaman list seperti halaman detail.
Checklist debugging N+1 query di Laravel
- Apakah ada akses relasi di dalam
foreach? - Apakah accessor, resource, atau serializer mengakses relasi secara implisit?
- Apakah relasi yang dipakai sudah di-
with()? - Apakah hanya kolom yang dibutuhkan yang diambil?
- Apakah jumlah item atau agregat lain sebaiknya memakai
withCount()atau data terdenormalisasi? - Apakah query utama dan query relasi sudah dicek dengan
EXPLAIN? - Apakah foreign key yang dipakai join atau filter memiliki index yang sesuai?
- Apakah pagination diuji pada data yang cukup besar?
Pelajaran agar bug serupa tidak terulang
1. Perlakukan halaman list sebagai jalur kritis performa
Endpoint list biasanya paling sering dipanggil dan paling mudah terkena N+1. Saat menambah kolom baru ke tabel list, selalu cek apakah ada akses relasi tambahan.
2. Review query, bukan hanya kode PHP
Kode Eloquent bisa terlihat bersih, tetapi performanya ditentukan oleh query yang dihasilkan. Biasakan melihat SQL saat mengubah endpoint yang memuat relasi.
3. Pisahkan kebutuhan halaman detail dan halaman list
Halaman detail boleh memuat relasi lebih banyak. Halaman list sebaiknya ringkas, fokus pada kolom inti dan agregasi yang ringan.
4. Tambahkan guardrail di tim
Beberapa praktik yang membantu:
- Checklist review untuk akses relasi di loop
- Penggunaan tool observability untuk memantau query per request
- Konvensi bahwa accessor tidak boleh mengakses relasi tanpa alasan kuat
- Pengujian performa sederhana pada endpoint list penting
Penutup
Dalam postmortem ini, akar masalah bukan pada pagination itu sendiri, melainkan pada N+1 query di Laravel yang tersembunyi saat halaman daftar order mengakses relasi customer dan item per baris. Gejalanya muncul sebagai response time yang naik, query membengkak, CPU database tinggi, dan pagination yang terasa lambat.
Perbaikannya relatif jelas setelah akar masalah ditemukan: gunakan eager loading, ambil kolom seperlunya, hindari akses relasi di loop, dan pindahkan agregasi ke level query atau data yang sudah dipersiapkan sebelumnya. Yang paling penting, jadikan inspeksi query sebagai bagian dari kebiasaan engineering, terutama untuk endpoint list yang dipakai setiap hari.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!