N+1 query ORM adalah salah satu penyebab klasik API backend tiba-tiba melambat di produksi, padahal logika bisnis terlihat benar dan test fungsional tetap lolos. Polanya sederhana: satu query utama mengambil daftar data, lalu di dalam loop aplikasi menembakkan query tambahan untuk setiap item. Pada dataset kecil gejalanya sering tidak terlihat, tetapi pada data tertentu jumlah query melonjak, latensi naik tajam, CPU database tinggi, dan request bisa berakhir timeout.
Studi kasus di artikel ini membahas kronologi bug semacam itu pada sebuah endpoint API daftar pesanan. Masalahnya tidak muncul di semua request, hanya pada tenant dengan jumlah order dan relasi item yang besar. Kita akan melihat cara membaca gejala, mereproduksi masalah, membandingkan jumlah query per request, menemukan akar masalah di eager loading yang hilang, lalu menutupnya dengan langkah pencegahan yang realistis.
Gejala di Produksi: Latency Naik, Query Meledak, CPU Database Tinggi
Insiden dimulai dari alert latency API yang naik pada endpoint ringkasan order, misalnya GET /api/orders. Dari sisi aplikasi, error yang terlihat hanya timeout dari upstream atau gateway. Dari sisi database, grafik menunjukkan beban CPU meningkat dan jumlah query per detik melonjak. Yang membuat debugging lebih sulit, tidak semua request terdampak.
Pola gejala yang umum pada kasus N+1 query:
- P95/P99 latency endpoint naik, tetapi median tidak selalu ikut naik drastis.
- Jumlah query database per request meningkat seiring jumlah data yang dikembalikan.
- CPU database naik karena banyak query kecil berulang, bukan satu query besar saja.
- Hanya muncul pada data tertentu, misalnya customer besar, tenant lama, atau halaman tanpa pagination ketat.
- Aplikasi terlihat “baik-baik saja” di lokal karena seed data terlalu kecil.
Jika latency hanya buruk pada subset data tertentu, curigai masalah yang tumbuh linear terhadap jumlah baris: N+1 query, serialisasi berlebihan, loop transformasi mahal, atau akses jaringan per item.
Studi Kasus: Endpoint Order yang Lambat Hanya untuk Tenant Besar
Bayangkan endpoint ini mengembalikan daftar order beserta nama customer dan jumlah item. Di level API, responsnya tampak sederhana. Namun implementasinya mengambil order terlebih dahulu, lalu saat serialisasi mengakses relasi customer dan items untuk setiap order.
Secara konseptual, alurnya seperti ini:
- Ambil 100 order terbaru.
- Loop semua order.
- Untuk tiap order, ambil customer.
- Untuk tiap order, hitung atau ambil item terkait.
Kalau relasi tidak di-eager load, ORM akan melakukan:
- 1 query untuk daftar order.
- N query untuk customer tiap order.
- N query lagi untuk items tiap order atau perhitungan berbasis relasi.
Hasil akhirnya bukan 1 query, melainkan 1 + N + N. Dengan 100 order, itu bisa menjadi lebih dari 200 query per request, bahkan lebih jika ada relasi bertingkat.
Investigasi Step-by-Step
1. Mulai dari log aplikasi dan gejala timeout
Langkah pertama bukan langsung mengubah kode, tetapi memastikan pola kegagalannya. Dari access log atau structured log, cari:
- Endpoint mana yang paling sering timeout.
- Apakah timeout konsisten pada parameter tertentu, misalnya tenant tertentu, filter tertentu, atau page size tertentu.
- Durasi request dan correlation ID untuk ditelusuri ke log database atau APM.
Contoh log yang berguna:
request_id=8f2c endpoint=/api/orders tenant_id=acme status=504 duration_ms=30120 page=1 per_page=100Di tahap ini, kita belum tahu akar masalahnya. Tetapi sudah terlihat bahwa timeout terkait endpoint tertentu dan payload yang cukup besar.
2. Buka APM atau slow query log
Jika menggunakan APM, perhatikan breakdown waktu request:
- Waktu habis di database atau di application code?
- Apakah ada banyak query serupa dengan durasi kecil-menengah yang berulang?
- Apakah query yang sama dipanggil puluhan atau ratusan kali dalam satu trace?
Pada kasus N+1, APM biasanya menunjukkan pola query yang identik dengan parameter berbeda, misalnya:
SELECT * FROM customers WHERE id = ? LIMIT 1;
SELECT * FROM customers WHERE id = ? LIMIT 1;
SELECT * FROM customers WHERE id = ? LIMIT 1;
...Atau untuk relasi item:
SELECT * FROM order_items WHERE order_id = ?;
SELECT * FROM order_items WHERE order_id = ?;
SELECT * FROM order_items WHERE order_id = ?;
...Kalau belum ada APM, slow query log database tetap sangat membantu. Walaupun tiap query individual mungkin tidak terlalu lambat, volume query yang tinggi akan tetap terlihat dari banyaknya entri serupa.
3. Reproduksi lokal dengan seed data yang realistis
Banyak bug performa tidak muncul di laptop karena dataset lokal terlalu kecil. Jadi langkah berikutnya adalah membuat data yang menyerupai produksi:
- Jumlah order per tenant cukup besar.
- Setiap order punya relasi customer dan beberapa item.
- Gunakan ukuran halaman yang sama dengan request bermasalah.
Tujuannya bukan menyalin produksi secara penuh, tetapi membuat kondisi di mana pertumbuhan query bisa terlihat. Dengan seed data yang realistis, Anda dapat membandingkan request kecil versus request besar dan mengamati apakah jumlah query tumbuh linear.
4. Hitung jumlah query per request
Setelah masalah bisa direproduksi, ukur jumlah query dalam satu request. Hampir semua ORM atau layer database menyediakan cara untuk mencatat query yang dieksekusi, minimal di mode debug atau melalui hook logging.
Yang ingin dicari:
- Berapa query untuk 10 order?
- Berapa query untuk 100 order?
- Apakah pertumbuhannya mendekati konstan, atau mengikuti jumlah item?
Jika hasilnya seperti ini, indikasinya kuat:
- 10 order - 21 query
- 50 order - 101 query
- 100 order - 201 query
Pola linear seperti itu hampir selalu menunjuk ke N+1.
5. Audit bagian serialisasi atau transformasi respons
Kesalahan sering bukan pada query utama, tetapi pada tahap membangun respons JSON. Misalnya controller terlihat efisien, namun resource/serializer mengakses relasi secara malas (lazy loading) di dalam loop.
Contoh gejala yang sering terlewat:
- Mengakses
order.customer.namedi serializer tanpa eager loading. - Memanggil
order.items.count()di dalam loop. - Mengakses relasi bertingkat seperti
order.customer.company.name. - Menghitung agregasi per item dengan query terpisah.
Akar Masalah: Eager Loading Hilang dan Loop Memicu Query Berulang
Berikut contoh implementasi yang tampak rapi, tetapi memicu N+1. Contoh ini generik dan mudah dipetakan ke ORM yang mendukung relasi model.
Sebelum: kode yang memicu N+1 query
// Controller / service
orders = Order.query()
.where('tenant_id', tenantId)
.orderBy('created_at', 'desc')
.limit(100)
.get()
response = orders.map(order => ({
id: order.id,
customer_name: order.customer.name,
item_count: order.items.length,
total: order.total_amount
}))Masalahnya ada di dua akses relasi:
order.customermemicu query jika customer belum dimuat.order.itemsjuga memicu query per order.
SQL yang kemungkinan terjadi:
SELECT id, customer_id, total_amount, created_at
FROM orders
WHERE tenant_id = ?
ORDER BY created_at DESC
LIMIT 100;
SELECT * FROM customers WHERE id = ?;
SELECT * FROM customers WHERE id = ?;
SELECT * FROM customers WHERE id = ?;
...
SELECT * FROM order_items WHERE order_id = ?;
SELECT * FROM order_items WHERE order_id = ?;
SELECT * FROM order_items WHERE order_id = ?;
...Walaupun tiap query kecil, total round-trip menjadi besar. Di jaringan produksi, biaya akumulatifnya signifikan.
Sesudah: eager loading relasi yang memang dibutuhkan
orders = Order.query()
.where('tenant_id', tenantId)
.with(['customer', 'items'])
.orderBy('created_at', 'desc')
.limit(100)
.get()
response = orders.map(order => ({
id: order.id,
customer_name: order.customer.name,
item_count: order.items.length,
total: order.total_amount
}))Dengan eager loading, ORM akan mengambil relasi dalam jumlah query yang jauh lebih sedikit, umumnya satu query utama untuk order, lalu satu query untuk customer terkait, dan satu query untuk items terkait. Secara logis, query berubah dari ratusan menjadi beberapa query saja.
SQL yang lebih sehat biasanya terlihat seperti ini:
SELECT id, customer_id, total_amount, created_at
FROM orders
WHERE tenant_id = ?
ORDER BY created_at DESC
LIMIT 100;
SELECT * FROM customers
WHERE id IN (?, ?, ?, ...);
SELECT * FROM order_items
WHERE order_id IN (?, ?, ?, ...);Alternatif yang lebih efisien untuk agregasi sederhana
Jika yang dibutuhkan hanya jumlah item, memuat seluruh items bisa berlebihan. Lebih efisien memakai agregasi di query utama atau subquery terkontrol, tergantung ORM dan kebutuhan respons.
orders = Order.query()
.where('tenant_id', tenantId)
.with(['customer'])
.withCount('items')
.orderBy('created_at', 'desc')
.limit(100)
.get()
response = orders.map(order => ({
id: order.id,
customer_name: order.customer.name,
item_count: order.items_count,
total: order.total_amount
}))Pendekatan ini menghindari memuat semua item ke memori jika hanya butuh hitungan.
Trade-off: eager loading mengurangi jumlah query, tetapi bisa memperbesar jumlah data yang diambil. Karena itu, muat hanya relasi dan kolom yang benar-benar diperlukan.
Mengapa Bug Ini Hanya Muncul pada Data Tertentu?
Ini bagian yang sering membingungkan tim. Kodenya sama, tetapi hanya sebagian tenant yang terkena timeout. Penyebabnya biasanya kombinasi faktor berikut:
- Kardinalitas data berbeda: tenant besar punya jauh lebih banyak order.
- Relasi lebih padat: tiap order memiliki lebih banyak item.
- Pagination longgar: page size terlalu besar atau endpoint mengekspor terlalu banyak data sekaligus.
- Cache tidak membantu: parameter request bervariasi sehingga query tetap dieksekusi berulang.
- Dataset lokal kecil: di lingkungan pengembangan, 10 order terasa cepat sehingga masalah tersembunyi.
N+1 sangat sensitif terhadap ukuran data. Pada 5 baris mungkin tidak terasa. Pada 500 baris, beban query dan round-trip jaringan meningkat drastis.
Validasi Perbaikan: Jangan Berhenti Setelah Kode Diubah
Setelah eager loading ditambahkan atau loop diperbaiki, pastikan hasilnya diukur lagi. Tujuan debugging bukan hanya “kode terlihat lebih benar”, tetapi metrik memang membaik.
Apa yang dibandingkan sebelum dan sesudah
- Jumlah query per request.
- Durasi endpoint di lokal dan staging dengan seed data yang sama.
- Slow query log apakah pola query berulang menghilang.
- CPU database saat traffic serupa.
- Ukuran payload dan penggunaan memori jika eager loading menambah data.
Contoh hasil yang sehat setelah perbaikan:
- Jumlah query turun dari ratusan menjadi beberapa query tetap.
- Latency endpoint turun signifikan.
- Timeout hilang pada tenant besar.
- Beban CPU database menurun karena query repetitif tidak lagi dijalankan.
Tidak perlu mengklaim angka tertentu jika belum ada pengukuran presisi. Yang penting adalah membuktikan perubahan arah metrik dan mengonfirmasi tidak ada regresi baru.
Kesalahan Umum Saat Memperbaiki N+1 Query
1. Eager load semua relasi tanpa seleksi
Ini memang mengurangi jumlah query, tetapi bisa memindahkan masalah ke penggunaan memori dan ukuran respons. Muat relasi seperlunya.
2. Memperbaiki di controller, tetapi serializer tetap memicu lazy loading
Audit juga lapisan presenter, transformer, resource, dan helper. Banyak N+1 tersembunyi di sana.
3. Mengabaikan pagination
Walaupun N+1 sudah diperbaiki, endpoint daftar tetap sebaiknya dipaginate. Memproses terlalu banyak entitas dalam satu request tetap mahal.
4. Tidak memeriksa indeks pendukung
Eager loading menghasilkan query IN (...) atau join yang lebih besar. Pastikan kolom relasi dan filter utama memiliki indeks yang sesuai, misalnya:
orders.tenant_idorders.customer_idorder_items.order_id- kolom sort/filter yang sering dipakai seperti
created_atbila relevan
Tanpa indeks yang tepat, query yang lebih sedikit belum tentu cukup cepat.
Langkah Pencegahan agar N+1 Tidak Kembali
1. Aktifkan profiling query di lingkungan yang aman
Di development dan staging, tampilkan jumlah query per request atau setidaknya log query yang mencurigakan. Tujuannya agar masalah terdeteksi sebelum produksi.
2. Terapkan query budget per endpoint
Setiap endpoint penting sebaiknya punya ekspektasi kasar, misalnya daftar order tidak boleh menjalankan query yang tumbuh linear terhadap jumlah hasil. Ini bukan angka kaku, tetapi pagar agar review lebih disiplin.
3. Pagination sebagai default
Endpoint daftar sebaiknya selalu dipaginate. Batasi per_page maksimum agar satu request tidak memuat ribuan baris beserta relasinya.
4. Tambahkan indeks yang mendukung pola query nyata
Perbaikan ORM harus dibarengi dengan evaluasi indeks. Lihat query utama dan query eager loading yang muncul, lalu pastikan filter, foreign key, dan kolom sort penting memiliki indeks yang sesuai.
5. Masukkan checklist performa ke code review
Checklist sederhana sering sangat efektif. Contohnya:
- Apakah ada akses relasi di dalam loop?
- Apakah serializer/resource mengakses properti relasi secara implisit?
- Apakah endpoint daftar sudah paginated?
- Apakah agregasi sederhana bisa dihitung di query, bukan per item?
- Apakah relasi yang di-eager load memang dipakai?
- Apakah query utama dan foreign key didukung indeks?
6. Tambahkan test regresi performa ringan
Tidak semua tim butuh benchmark rumit. Test ringan yang memverifikasi jumlah query atau batas pertumbuhan query sering sudah cukup untuk mencegah N+1 kembali.
Contoh pendekatan:
- Seed 20 order dengan relasi customer dan items.
- Panggil endpoint atau service yang relevan.
- Verifikasi jumlah query tidak meledak di atas ambang yang wajar.
Jika framework mendukung assertion query count, manfaatkan itu. Jika tidak, pasang listener query pada test dan hitung secara manual. Yang penting bukan angka absolut universal, tetapi mendeteksi lonjakan tidak normal dari perubahan kode berikutnya.
Ringkasan Proses Debugging yang Bisa Diulang
- Identifikasi endpoint yang timeout dan data mana yang memicunya.
- Cek access log, APM, atau slow query log untuk pola query berulang.
- Reproduksi lokal dengan seed data realistis, bukan data mini.
- Hitung jumlah query per request dan bandingkan terhadap jumlah baris.
- Audit loop, serializer, dan akses relasi yang memicu lazy loading.
- Perbaiki dengan eager loading, agregasi yang tepat, atau restrukturisasi query.
- Ukur lagi jumlah query, latency, dan dampak ke database.
- Tambahkan guardrail: profiling, query budget, pagination, indeks, code review, dan test regresi.
Penutup
Debugging backend: N+1 query ORM yang memicu timeout API hampir selalu berawal dari gejala yang tampak acak, padahal akar masalahnya sangat sistematis: query kecil yang diulang di dalam loop. Kunci penyelesaiannya adalah disiplin observasi dan pengukuran, bukan tebakan. Begitu Anda melihat hubungan antara jumlah data dan jumlah query per request, jalur investigasinya biasanya menjadi jauh lebih jelas.
Jika sebuah endpoint lambat hanya pada tenant besar atau data tertentu, jangan berhenti di level aplikasi saja. Buka trace, hitung query, reproduksi dengan dataset realistis, lalu audit eager loading dan serializer. Dalam banyak kasus, satu perbaikan kecil di ORM dapat menghapus ratusan query berulang dan mengembalikan API ke kondisi normal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!