Endpoint daftar order yang semula terasa normal bisa tiba-tiba melambat saat trafik naik, meski tidak ada perubahan arsitektur besar. Dalam banyak kasus, penyebabnya bukan query yang terlihat rumit, melainkan N+1 query yang tersembunyi di loop, serializer, atau helper generik. Artikel ini membahas studi kasus debugging backend ketika kode hasil AI membuat endpoint daftar order tampak rapi, tetapi diam-diam memicu ratusan query tambahan per request.
Konteksnya relevan dengan tren yang sempat ramai dibahas lewat tulisan “We charge $10k a week to delete AI-generated code” di odra.dev. Poin pentingnya bukan bahwa semua kode AI buruk, melainkan bahwa kode yang terlihat masuk akal sering lolos review karena rapi secara struktur, namun lemah secara perilaku runtime. Di backend, masalah paling mahal biasanya baru terlihat saat trafik naik: p95 latency naik, query database meledak, dan CPU aplikasi ikut tinggi.
Gejala: endpoint daftar order melambat saat trafik naik
Kasusnya sederhana: endpoint GET /orders menampilkan daftar order beserta nama customer, jumlah item, dan status pengiriman. Secara fungsional endpoint ini benar. Di lingkungan development, data sedikit dan response tetap cepat. Namun saat beban meningkat, tim mulai melihat gejala berikut:
- p95 latency naik signifikan, sementara p50 masih tampak wajar.
- Jumlah query database per request melonjak jauh di atas ekspektasi.
- CPU aplikasi tinggi karena ORM terus membangun object dan menjalankan serialisasi.
- Koneksi database mulai sibuk, meski throughput API tidak naik drastis.
Pola ini penting. Jika p95 naik lebih dulu, sering kali masalahnya terkait jalur request tertentu yang memproses data lebih banyak, bukan bug total yang memukul semua request secara merata. Untuk endpoint daftar order, itu biasanya berarti ukuran halaman, relasi terkait, atau serializer memicu kerja tambahan per item.
Root cause: kode AI memicu lazy loading dan N+1 query
Akar masalahnya adalah kombinasi tiga hal:
- Loop di level aplikasi memicu lazy loading relasi.
- Helper generik atau accessor menambah query tersembunyi.
- Reviewer melewatkan masalah karena kode tampak bersih, ringkas, dan mudah dibaca.
Contoh pola yang terlihat rapi, tetapi berbahaya
Misalnya service mengambil data order seperti ini:
// Contoh generik dengan ORM style aktif-record / relation loading
$orders = Order::query()
->latest()
->limit(50)
->get();
return array_map(function ($order) {
return [
'id' => $order->id,
'customer_name' => $order->customer->name,
'item_count' => $order->items->count(),
'shipment_status' => formatShipmentStatus($order),
'total' => $order->total_amount,
];
}, $orders->all());Sekilas ini tampak normal. Namun secara runtime:
$order->customerbisa memicu satu query per order jika relasi belum dimuat.$order->itemsbisa memicu satu query lagi per order.formatShipmentStatus($order)mungkin memanggil relasi lain atau helper yang menanyakan tabel shipment.
Jika ada 50 order, sangat mungkin request berubah dari 1-3 query menjadi ratusan query. Inilah bentuk klasik N+1 query: satu query untuk mengambil daftar utama, lalu tambahan query untuk setiap item di hasil.
Helper generik sebagai sumber query tersembunyi
Masalah yang lebih sulit dideteksi adalah helper yang tampak “reusable”. Contoh:
function formatShipmentStatus($order) {
$latestShipment = $order->shipments()
->latest()
->first();
return $latestShipment ? $latestShipment->status : 'pending';
}Secara fungsi helper ini benar. Tetapi jika dipanggil di dalam loop untuk banyak order, ia menambah satu query per order. Karena helper dipisahkan dari controller atau serializer, reviewer sering tidak melihat bahwa helper tersebut mengandung akses database.
Mengapa reviewer melewatkannya?
Karena kode hasil AI sering unggul pada surface-level readability:
- Nama fungsi jelas.
- Struktur pemetaan data rapi.
- Tidak ada query SQL mentah yang mencurigakan.
- Test fungsional bisa saja lolos karena hanya memeriksa bentuk response.
Masalah performa bukan terletak pada kerapian sintaks, melainkan pada efek runtime dari akses relasi, accessor, helper, dan serializer.
Langkah investigasi yang benar
Ketika gejalanya muncul di produksi atau staging, jangan langsung mengubah kode berdasarkan firasat. Tujuannya adalah memastikan query mana yang meledak, pada request apa, dan bagian kode mana yang memicunya.
1. Aktifkan log SQL per request secara terukur
Langkah pertama adalah mencatat query yang dijalankan endpoint terkait, idealnya hanya untuk route tertentu atau pada sampling terbatas. Yang ingin dicari:
- Jumlah query per request.
- Query yang sama dieksekusi berulang dengan parameter berbeda.
- Durasi query paling mahal.
Tanda N+1 sangat khas: pola query identik berulang, misalnya select * from customers where id = ? berkali-kali, lalu select * from order_items where order_id = ? berkali-kali.
Jangan biarkan SQL logging aktif secara luas di produksi tanpa kontrol. Logging detail bisa menambah overhead dan menuliskan data sensitif. Gunakan sampling, redaksi parameter bila perlu, dan batasi pada endpoint yang sedang diinvestigasi.
2. Lihat tracing, bukan hanya log teks
Tracing per request membantu menghubungkan satu request API dengan semua operasi di bawahnya: handler, serializer, query database, dan panggilan eksternal. Saat endpoint daftar order lambat, tracing memudahkan Anda melihat:
- Apakah waktu habis di database atau di CPU aplikasi.
- Relasi mana yang diakses paling sering.
- Apakah serialisasi response ternyata lebih mahal dari query utama.
Jika tool observability Anda mendukung span per query, N+1 akan terlihat sebagai deretan query pendek tetapi banyak. Ini sering lebih informatif daripada satu query lambat.
3. Profiling per request di layer aplikasi
Bila CPU aplikasi tinggi, jangan hanya fokus ke database. Profiling per request dapat menunjukkan bahwa waktu juga habis untuk:
- Membangun object ORM berulang kali.
- Menjalankan accessor atau mutator pada tiap model.
- Menjalankan serializer generik yang menyentuh relasi tanpa sadar.
Ini penting karena setelah query dikurangi, bottleneck berikutnya sering berpindah ke serialisasi atau transformasi data.
4. Jalankan EXPLAIN pada query yang benar-benar penting
EXPLAIN tidak menyelesaikan N+1, tetapi tetap berguna untuk query utama atau query turunan yang mahal. Gunanya adalah memeriksa apakah query yang sudah diketahui sering dipanggil juga menggunakan indeks dengan baik, melakukan scan besar, atau melakukan join yang tidak efisien.
Urutannya sebaiknya seperti ini:
- Temukan query yang berulang.
- Kurangi jumlah query dulu.
- Setelah itu, optimalkan query yang tersisa dengan
EXPLAINbila memang masih lambat.
Kesalahan umum adalah terlalu cepat mengutak-atik indeks, padahal akar masalahnya adalah 200 query kecil yang seharusnya bisa menjadi 3 query besar yang wajar.
Contoh perubahan kode: dari N+1 ke eager loading yang terkontrol
Sebelum perbaikan
$orders = Order::query()
->latest()
->limit(50)
->get();
$result = [];
foreach ($orders as $order) {
$result[] = [
'id' => $order->id,
'customer_name' => $order->customer->name,
'item_count' => $order->items->count(),
'shipment_status' => formatShipmentStatus($order),
'total' => $order->total_amount,
];
}
return $result;Sesudah perbaikan
$orders = Order::query()
->select(['id', 'customer_id', 'total_amount', 'created_at'])
->with([
'customer:id,name',
'latestShipment:id,order_id,status',
])
->withCount('items')
->latest()
->limit(50)
->get();
$result = [];
foreach ($orders as $order) {
$result[] = [
'id' => $order->id,
'customer_name' => $order->customer ? $order->customer->name : null,
'item_count' => $order->items_count,
'shipment_status' => $order->latestShipment ? $order->latestShipment->status : 'pending',
'total' => $order->total_amount,
];
}
return $result;Perubahan ini bekerja karena:
- Eager loading memuat relasi yang memang diperlukan di muka, bukan saat loop berlangsung.
- Seleksi field membatasi kolom yang dibawa dari database sehingga payload ORM lebih kecil.
- withCount atau mekanisme agregasi serupa menghindari load penuh collection item hanya untuk menghitung jumlah.
- Status pengiriman diambil dari relasi yang sudah disiapkan, bukan helper yang menembak query baru.
Nama relasi seperti latestShipment bergantung pada desain model Anda, tetapi prinsipnya sama: siapkan data yang dibutuhkan saat query utama dibangun, bukan saat serialisasi berjalan.
Perbaiki serializer, bukan hanya query builder
Dalam banyak sistem, sumber masalah bukan controller, melainkan serializer/resource layer. Contoh pola yang berbahaya:
class OrderSerializer {
public function toArray($order) {
return [
'id' => $order->id,
'customer' => [
'name' => $order->customer->name,
],
'items' => $order->items->map(fn($item) => [
'sku' => $item->sku,
'qty' => $item->qty,
]),
];
}
}Jika serializer boleh mengakses relasi sesuka hati, ia menjadi sumber query tersembunyi. Solusi yang lebih aman:
- Definisikan kontrak data yang harus sudah dimuat sebelum serializer dipanggil.
- Jika relasi tidak dimuat, serializer bisa gagal cepat di non-production atau mencatat warning.
- Pisahkan serializer untuk list endpoint dan detail endpoint. Jangan pakai serializer “super generik” untuk semua kasus.
Ini penting karena endpoint list dan detail hampir selalu punya kebutuhan data yang berbeda. Memaksa satu serializer generik untuk semua jalur sering berujung overfetching atau lazy loading tersembunyi.
Trade-off dan batasan perbaikan
Eager loading bukan obat universal. Ada beberapa hal yang perlu dipertimbangkan:
1. Eager loading berlebihan juga bisa mahal
Jika Anda memuat terlalu banyak relasi untuk halaman list, ukuran object di memori dan biaya serialisasi ikut naik. Solusinya bukan “muat semua agar aman”, melainkan muat secukupnya sesuai kontrak response.
2. Join besar tidak selalu lebih baik
Beberapa tim mencoba menghapus N+1 dengan satu query join raksasa. Ini bisa cocok untuk kebutuhan tertentu, tetapi juga bisa menggandakan baris hasil, memperumit mapping, dan membuat maintenance lebih sulit. Untuk endpoint list, sering kali lebih sehat memakai kombinasi query utama + eager loading relasi penting + agregasi seperlunya.
3. Count dan agregasi perlu perhatian
Mengambil semua item lalu menghitung di aplikasi hampir selalu salah untuk list endpoint. Gunakan agregasi di database bila hanya butuh jumlah. Namun tetap cek rencana query dengan EXPLAIN jika data besar dan agregasi terasa mahal.
Guardrail review agar masalah serupa tidak terulang
Karena masalah ini lolos review akibat kode tampak rapi, perbaikannya tidak cukup hanya di level implementasi. Tim perlu menambahkan guardrail yang eksplisit.
Checklist review untuk kode hasil AI atau refactor backend
- Apakah ada loop yang mengakses relasi model?
- Apakah serializer/resource menyentuh relasi yang tidak dijamin sudah dimuat?
- Apakah ada helper, accessor, atau method model yang menjalankan query database?
- Apakah endpoint list memakai serializer yang seharusnya hanya cocok untuk detail endpoint?
- Apakah field yang diambil dari database sudah dibatasi sesuai kebutuhan?
- Apakah count, status, atau metadata dihitung di database, bukan lewat load collection penuh?
- Apakah jumlah query per request untuk endpoint ini diketahui dan masuk akal?
Buat query budget test
Test fungsional biasa tidak cukup. Tambahkan query budget test untuk endpoint penting, terutama list endpoint dengan relasi.
Prinsipnya sederhana:
- Siapkan fixture dengan jumlah order yang realistis.
- Panggil endpoint.
- Pastikan jumlah query tidak melewati ambang yang disepakati.
Ambang ini bergantung pada desain aplikasi. Yang penting bukan angka absolutnya, melainkan adanya alarm ketika perubahan kecil membuat query melonjak 5x atau 10x. Dengan cara ini, bug performa bisa tertangkap di CI sebelum sampai ke produksi.
Deteksi lazy loading saat development
Bila framework Anda mendukung mekanisme pencegahan lazy loading atau warning saat relasi diakses tanpa preload, aktifkan di development dan test. Ini sangat efektif untuk mendeteksi pola N+1 lebih awal, terutama pada serializer dan presenter layer.
Jika mekanisme otomatis tidak tersedia, buat aturan internal: method yang berpotensi query tidak boleh dipanggil dari serializer tanpa kontrak preload yang jelas.
Checklist deteksi dini N+1 query
Berikut checklist praktis yang bisa dipakai developer saat mengerjakan endpoint backend:
- Lihat p95, bukan hanya rata-rata. N+1 sering lebih cepat terlihat di tail latency.
- Bandingkan jumlah query untuk 5 data vs 50 data. Jika naik linear per item, curigai N+1.
- Cari helper yang menyentuh database. Nama fungsi yang polos tidak berarti aman.
- Audit serializer. Pastikan ia tidak diam-diam membuka relasi.
- Batasi kolom yang diambil. Jangan bawa seluruh row jika hanya butuh 3-4 field.
- Gunakan agregasi database untuk count/ringkasan.
- Trace per request. Query banyak yang kecil tetap bisa membunuh throughput.
- Tulis query budget test untuk endpoint kritikal.
Pelajaran yang bisa langsung diterapkan developer
Ada beberapa pelajaran penting dari kasus ini.
1. Kode yang rapi belum tentu murah saat runtime
Kode hasil AI sering membantu mempercepat penulisan boilerplate, tetapi evaluasi utamanya harus berpindah dari “enak dibaca” ke “bagaimana perilakunya saat request nyata berjalan”. Backend performance bug sering tersembunyi justru pada kode yang tampak paling bersih.
2. Endpoint list perlu desain data yang spesifik
Jangan perlakukan endpoint list seperti versi kecil dari endpoint detail. Kebutuhan datanya berbeda. Gunakan query, preload, dan serializer yang memang dirancang untuk skenario list.
3. Observability harus cukup rinci untuk menghubungkan gejala ke root cause
Tanpa log SQL, tracing, dan profiling per request, tim mudah terjebak menebak-nebak: apakah database lambat, CPU tinggi, atau jaringan bermasalah. Pada kasus N+1, gabungan ketiganya biasanya yang memberi jawaban paling cepat.
4. Guardrail lebih murah daripada debugging darurat
Review checklist, pencegahan lazy loading di development, dan query budget test mungkin terasa seperti tambahan proses. Namun biaya itu jauh lebih kecil dibanding memburu regression performa setelah trafik naik.
Penutup
Dalam studi kasus ini, endpoint daftar order lambat bukan karena satu query monster, melainkan karena kode AI memicu N+1 query lewat loop, lazy loading, dan helper generik yang tampak tidak berbahaya. Gejalanya muncul jelas saat trafik naik: p95 memburuk, query database meledak, dan CPU aplikasi ikut tinggi. Investigasi yang efektif dimulai dari log SQL, tracing, profiling per request, lalu dilanjutkan dengan EXPLAIN untuk query yang benar-benar penting.
Perbaikannya juga bukan sekadar “optimasi database”, tetapi mendesain ulang jalur data: eager loading yang tepat, pembatasan field, serializer yang tidak sembarang membuka relasi, serta guardrail review dan query budget test agar regression serupa tidak terulang. Jika Anda memakai AI untuk mempercepat implementasi backend, anggap kode hasilnya sebagai draft yang harus diuji perilaku runtime-nya, bukan sebagai final answer.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!