Debugging N+1 query sering dimulai dari gejala yang terlihat sepele: endpoint API yang tadinya stabil tiba-tiba lambat saat volume data naik. Di produksi, dampaknya biasanya bukan hanya response time membesar, tetapi juga CPU database melonjak, jumlah query per request membengkak, dan timeout muncul secara intermiten.
Masalah ini jarang terlihat saat data masih sedikit. Endpoint tampak normal di lingkungan development atau staging, lalu mulai gagal memenuhi SLA ketika satu request harus memproses puluhan atau ratusan entitas terkait. Artikel ini membahas studi kasus investigasi backend secara step-by-step: mulai dari membaca log, mengukur query, mengaktifkan slow query log atau APM, menemukan loop pemicu query berulang, sampai mengonfirmasi root cause dan memverifikasi hasil perbaikan setelah deploy.
Gejala Nyata di Produksi
Pola N+1 query biasanya muncul pada endpoint yang mengembalikan daftar data beserta relasi turunannya, misalnya daftar order dengan customer dan item, daftar post dengan author dan comments, atau daftar invoice dengan payment status. Gejala yang umum terlihat:
- Latency naik seiring jumlah data, bukan sekadar karena traffic meningkat.
- CPU database meningkat walaupun CPU aplikasi tidak selalu ikut naik signifikan.
- Query count per request membengkak, misalnya dari belasan menjadi ratusan.
- Timeout intermiten, terutama saat page size besar atau ada tenant dengan data lebih padat.
- Slow query log penuh oleh query yang secara individual tidak selalu buruk, tetapi dieksekusi terlalu sering.
Ciri pentingnya: performa memburuk proporsional terhadap jumlah record yang diproses. Jika request untuk 10 data cepat, tetapi request untuk 100 data menjadi berkali-kali lebih lambat, itu sinyal kuat adanya query berulang di dalam loop.
Memahami N+1 Query dengan Singkat
N+1 query terjadi saat aplikasi menjalankan:
- 1 query untuk mengambil daftar utama, lalu
- N query tambahan untuk mengambil data relasi setiap item satu per satu.
Contoh sederhana: endpoint mengambil 100 order, lalu untuk setiap order menjalankan query terpisah untuk customer. Hasilnya bukan 2 query, melainkan 101 query.
Masalah utamanya bukan hanya jumlah query yang banyak. Setiap query tambahan menambah biaya jaringan, parsing SQL, locking internal, cache lookup, dan kerja planner database. Pada data kecil efeknya samar, pada data besar efeknya tajam.
Studi Kasus: Endpoint Orders yang Tiba-Tiba Melambat
Misalkan ada endpoint:
GET /api/orders?status=paid&limit=100Endpoint ini awalnya baik-baik saja. Saat jumlah order per tenant bertambah, tim mulai melihat:
- Response p95 membesar tajam.
- Database CPU naik pada jam sibuk.
- Ada request yang selesai normal, tetapi sebagian timeout.
- Alert dari API gateway muncul sporadis, sulit direproduksi di laptop developer.
Setelah dicek, endpoint mengembalikan struktur seperti ini:
- Order
- Customer
- Items
- Product summary untuk tiap item
Secara bentuk response tidak aneh. Masalahnya ada pada cara data diambil.
Langkah Investigasi Step-by-Step
1. Mulai dari log aplikasi dan access log
Jangan langsung menebak ORM sebagai penyebab. Mulai dari gejala yang benar-benar tercatat:
- Endpoint mana yang latency-nya naik?
- Kapan lonjakan terjadi?
- Apakah terkait tenant tertentu, parameter tertentu, atau page size tertentu?
- Apakah timeout terjadi di aplikasi, load balancer, atau upstream client?
Dari access log atau structured log, cari korelasi antara:
- path endpoint,
- durasi request,
- jumlah data yang diminta,
- error timeout,
- request ID untuk tracing.
Kalau pola latensi memburuk saat limit naik atau saat tenant dengan banyak relasi diakses, itu petunjuk awal yang kuat.
2. Ukur jumlah query per request
Setelah endpoint target ditemukan, langkah berikutnya adalah mengukur, bukan menebak. Di banyak stack backend, query database bisa dicatat melalui query logging, instrumentation ORM, middleware, atau APM.
Yang perlu dikumpulkan:
- Total query per request
- Total waktu yang dihabiskan di database
- Query yang sama dieksekusi berulang kali
- Parameter request yang memicu lonjakan
Jika satu request menghasilkan ratusan query dengan pola SQL yang mirip, kemungkinan besar ada N+1.
Catatan: aktifkan query log dengan hati-hati di produksi. Logging semua query secara permanen bisa menambah overhead dan menghasilkan log sangat besar. Lebih aman memakai sampling, APM, atau mengaktifkannya sementara pada request tertentu.
3. Aktifkan slow query log atau APM
Slow query log berguna untuk melihat query yang makan waktu lama, tetapi pada kasus N+1, sering kali tiap query individu tidak ekstrem lambat. Yang mahal adalah akumulasinya. Karena itu, APM atau tracing lebih membantu untuk melihat satu request end-to-end.
Cari pola seperti:
- satu transaction/request memicu query serupa puluhan kali,
- span database mendominasi total response time,
- query ke tabel relasi dieksekusi berulang dengan parameter ID berbeda.
Contoh pola yang mencurigakan:
SELECT * FROM customers WHERE id = ? LIMIT 1 -- dieksekusi 100 kali
SELECT * FROM order_items WHERE order_id = ? -- dieksekusi 100 kaliJika APM memperlihatkan waterfall query seperti itu, investigasi bisa difokuskan ke kode endpoint yang melakukan iterasi data.
4. Temukan loop yang memicu query berulang
Masalah N+1 hampir selalu tersembunyi di tempat seperti ini:
- serialisasi response,
- transformer/resource layer,
- mapper DTO,
- template rendering,
- hook lazy-loading relasi di ORM.
Kode yang terlihat bersih belum tentu efisien. Contoh pseudocode sebelum perbaikan:
$orders = Order::where('status', 'paid')
->latest()
->limit(100)
->get();
$result = [];
foreach ($orders as $order) {
$result[] = [
'id' => $order->id,
'customer_name' => $order->customer->name,
'items' => $order->items->map(function ($item) {
return [
'sku' => $item->product->sku,
'qty' => $item->qty,
];
}),
];
}Secara fungsi kode ini benar. Secara performa, ini berbahaya:
- 1 query untuk mengambil order,
- N query untuk customer,
- N query untuk items,
- dan bisa bertambah lagi untuk product di setiap item.
Jika ada 100 order dan masing-masing punya beberapa item, total query bisa melonjak sangat cepat.
5. Konfirmasi root cause
Jangan berhenti di asumsi. Konfirmasi dengan cara berikut:
- Jalankan endpoint dengan dataset yang cukup besar di staging atau environment aman.
- Catat query count sebelum perubahan.
- Perbaiki pengambilan relasi.
- Bandingkan query count dan waktu database sesudah perubahan.
Kalau query count turun drastis dan latency membaik, root cause telah terkonfirmasi.
Contoh Perbaikan: Eager Loading yang Tepat
Pendekatan paling umum adalah eager loading, yaitu mengambil relasi yang memang akan dipakai sejak awal, bukan membiarkan ORM melakukan lazy load di dalam loop.
$orders = Order::with([
'customer:id,name',
'items.product:id,sku'
])
->where('status', 'paid')
->latest()
->limit(100)
->get();
$result = $orders->map(function ($order) {
return [
'id' => $order->id,
'customer_name' => optional($order->customer)->name,
'items' => $order->items->map(function ($item) {
return [
'sku' => optional($item->product)->sku,
'qty' => $item->qty,
];
})->values(),
];
});Mengapa ini bekerja?
- ORM mengambil relasi dalam batch, bukan per item.
- Jumlah query menjadi jauh lebih kecil dan lebih stabil terhadap pertambahan jumlah data.
- Waktu round-trip ke database turun.
Selain itu, contoh di atas juga memilih kolom yang dibutuhkan saja. Ini membantu mengurangi transfer data dan penggunaan memori aplikasi.
Kapan eager loading cukup?
Eager loading cocok jika:
- relasinya jelas akan dipakai pada response,
- jumlah data masih masuk akal untuk diambil dalam satu batch,
- struktur object response memang membutuhkan relasi lengkap.
Namun eager loading bukan jawaban untuk semua kasus. Jika relasi sangat besar, Anda bisa memperbaiki N+1 tetapi malah memindahkan bottleneck ke memori aplikasi.
Alternatif Perbaikan: Batching dan Join
1. Batching manual untuk kebutuhan spesifik
Jika ORM sulit diatur atau response hanya butuh sebagian data relasi, batching manual kadang lebih efisien.
$orders = Order::where('status', 'paid')
->latest()
->limit(100)
->get(['id', 'customer_id']);
$customerIds = $orders->pluck('customer_id')->filter()->unique();
$customers = Customer::whereIn('id', $customerIds)
->get(['id', 'name'])
->keyBy('id');
$result = $orders->map(function ($order) use ($customers) {
return [
'id' => $order->id,
'customer_name' => optional($customers->get($order->customer_id))->name,
];
});Pendekatan ini berguna ketika Anda hanya perlu field tertentu dan ingin menghindari overhead object graph ORM yang terlalu besar.
2. Join untuk response datar atau agregasi
Jika endpoint hanya mengembalikan data datar atau ringkasan, join di level SQL sering lebih tepat daripada memuat relasi objek lengkap.
SELECT
o.id,
c.name AS customer_name,
o.created_at,
o.total_amount
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'paid'
ORDER BY o.created_at DESC
LIMIT 100;Join bekerja baik saat:
- response tidak butuh nesting kompleks,
- Anda ingin satu query yang eksplisit dan mudah dianalisis dengan
EXPLAIN, - Anda perlu agregasi di database, bukan di aplikasi.
Trade-off-nya, query join untuk relasi one-to-many dapat menghasilkan duplikasi baris dan perlu pengelompokan ulang di aplikasi jika response bersifat nested.
Trade-off dan Hal yang Sering Salah
Eager loading bukan berarti selalu terbaik
Kesalahan umum setelah menemukan N+1 adalah menambahkan semua relasi ke with tanpa seleksi. Hasilnya:
- payload query makin besar,
- memori aplikasi naik,
- waktu serialisasi response bertambah,
- beberapa relasi ternyata tidak dipakai sama sekali.
Prinsipnya: ambil relasi yang benar-benar diperlukan oleh endpoint itu.
Pagination yang buruk bisa menyamarkan masalah
Endpoint tanpa batasan data yang jelas akan memperburuk efek N+1. Sekalipun sudah memakai eager loading, meminta terlalu banyak record sekaligus tetap mahal. Pastikan ada pagination atau limit yang wajar.
Serializer atau resource layer sering jadi sumber masalah
Banyak tim memperbaiki query utama tetapi lupa bahwa akses relasi sebenarnya terjadi di layer presentasi. Misalnya, controller hanya mengembalikan collection, lalu resource transformer mengakses properti relasi satu per satu. Saat debugging, periksa seluruh jalur request sampai response final terbentuk.
Cache bukan perbaikan akar masalah
Cache bisa membantu meredam beban baca, tetapi tidak menghapus akar N+1. Jika cache miss tinggi atau invalidation sering terjadi, masalah akan muncul lagi. Perbaiki pola query dulu, lalu pertimbangkan cache jika memang diperlukan.
Checklist Verifikasi Setelah Deploy
Setelah perbaikan dirilis, jangan hanya melihat bahwa endpoint “terasa lebih cepat”. Verifikasi dengan metrik yang konkret:
- Bandingkan query count per request sebelum dan sesudah deploy.
- Pantau p50, p95, dan p99 latency pada endpoint target.
- Lihat total waktu database per request jika APM menyediakan metrik ini.
- Periksa CPU database dan connection pool saturation.
- Cek error rate, terutama timeout dan 5xx.
- Verifikasi ukuran response agar eager loading tidak membuat payload berlebihan.
- Uji tenant atau dataset terbesar, bukan hanya data kecil.
Jika memungkinkan, gunakan perbandingan request yang setara pada periode sebelum dan sesudah deploy agar hasilnya tidak bias oleh fluktuasi traffic.
Metrik yang Wajib Dipantau
Untuk kasus performa seperti ini, metrik yang paling berguna biasanya:
- Request latency: p50, p95, p99 per endpoint.
- Query count per request: ideal untuk mendeteksi regresi N+1.
- DB time per request: total waktu yang dihabiskan di database.
- Database CPU dan IOPS: melihat tekanan di layer database.
- Connection pool usage: apakah request lambat menahan koneksi lebih lama.
- Timeout rate: dari aplikasi, gateway, dan upstream.
- Row scanned / rows examined jika tersedia di observability database.
Tanpa metrik ini, tim sering hanya tahu endpoint lambat, tetapi tidak tahu apakah penyebabnya query count, query plan, atau ukuran data yang ditarik.
Tips Praktis Agar Bug Serupa Tidak Terulang
1. Tambahkan guardrail di development
Jika stack Anda mendukungnya, aktifkan mode yang memperingatkan saat lazy loading terjadi pada relasi yang seharusnya sudah dimuat. Ini sangat efektif untuk mencegah N+1 lolos ke produksi.
2. Uji dengan data realistis
Banyak bug performa tidak muncul karena database lokal terlalu kecil. Siapkan seed atau fixture yang mendekati distribusi data produksi, terutama untuk endpoint list dengan relasi bertingkat.
3. Review query pada endpoint kritis
Untuk endpoint yang sering dipanggil atau dipakai dashboard utama, lakukan code review dengan fokus khusus pada:
- akses relasi di dalam loop,
- resource/serializer yang menyentuh properti relasi,
- pemilihan kolom yang terlalu lebar,
- limit dan pagination.
4. Pertimbangkan tes performa ringan
Anda tidak selalu butuh benchmark besar. Bahkan tes sederhana yang memastikan query count tidak meledak saat jumlah record meningkat sudah sangat berguna untuk mencegah regresi.
5. Dokumentasikan pola akses data
Untuk endpoint penting, dokumentasikan relasi apa yang wajib di-eager load dan mengapa. Ini membantu developer lain memahami alasan desain query, bukan sekadar menyalin pola.
Pelajaran dari Kasus Ini
N+1 query adalah contoh klasik bug yang tidak langsung terlihat saat sistem masih kecil. Endpoint tampak normal, tes fungsional lolos, dan query individual mungkin tidak masuk kategori lambat. Tetapi ketika data tumbuh, akumulasi query menghantam latency, membebani database, dan memicu timeout yang terlihat acak.
Pendekatan yang efektif bukan menebak, melainkan investigasi sistematis: baca log, ukur query per request, gunakan slow query log atau APM, cari loop yang memicu lazy loading, lalu konfirmasi root cause dengan membandingkan sebelum dan sesudah perbaikan. Solusinya bisa berupa eager loading, batching, atau join, tergantung bentuk response dan kebutuhan endpoint.
Jika ada satu prinsip yang perlu diingat, itu adalah: jangan menilai performa endpoint hanya dari data kecil dan local environment. Untuk endpoint API yang memuat relasi, jumlah query per request seharusnya menjadi metrik yang dipantau, bukan sekadar detail implementasi internal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!