Topik wawancara representatif

Wawancara Backend: Bagaimana Cara Mendiagnosis dan Memperbaiki Masalah N+1 Query?

BackendSulit
Tim Redaksi Offer.ccDipublikasikan Diperbarui

Pertanyaan

Sebuah endpoint daftar pesanan mengambil 50 pesanan dan kemudian membaca data pelanggan dari setiap pesanan saat melakukan serialisasi respons, menghasilkan 51 pernyataan SQL. Setiap pernyataan dieksekusi dengan cepat, tetapi latensi permintaan meningkat seiring bertambahnya ukuran halaman. Bagaimana Anda membuktikan penyebab N+1, memilih perbaikannya, dan mencegah masalah ini muncul kembali?

Masalah dan Konteks yang Berlaku

Sebuah endpoint daftar pesanan pertama-tama mengambil satu halaman berisi 50 pesanan. Selama serialisasi respons, ORM memuat order.customer secara terpisah untuk setiap pesanan. Sebuah production trace memuat satu kueri pesanan dan 50 kueri pelanggan. Tidak ada pernyataan individual yang terlihat lambat, tetapi satu permintaan melakukan 51 roundtrip ke basis data. Ketika ukuran halaman berlipat ganda, jumlah pernyataan dan latensi permintaan ikut bertambah seiring dengannya.

Asumsikan setiap pesanan dimiliki oleh satu pelanggan, respons hanya membutuhkan nama tampilan (display name) pelanggan, dan endpoint harus mempertahankan urutan saat ini, filter otorisasi, serta semantik paginasi. Angka-angka ini adalah asumsi wawancara yang membuat diagnosis dapat diuji. Pertanyaan intinya adalah bagaimana mendeteksi masalah pola akses aplikasi, bukan cara mengoptimalkan satu query plan SQL yang lambat.

Peran yang ditargetkan adalah backend engineer yang bekerja dengan basis data relasional dan ORM. Jawaban yang lengkap harus membandingkan kueri tunggal yang digabungkan (joined query), batch select-in dengan dua kueri, dan perubahan pada pengaturan default pemuatan (loading defaults). Jawaban tersebut juga harus mencakup relasi one-to-many, konsistensi transaksi, observabilitas, serta pemeriksaan regresi yang jumlah kuerinya tidak bergantung pada waktu eksekusi.

Apa yang Sedang Dinilai oleh Pewawancara

Sinyal pertama adalah apakah kandidat mengukur beban kerja pada batas permintaan (request boundary). Masalah N+1 dapat terdiri dari banyak kueri berindeks yang masing-masing berjalan efisien. Hanya melihat log slow-query atau menjalankan EXPLAIN pada satu pencarian pelanggan dapat melewatkan faktor pengali tersebut. Bukti yang berguna adalah trace atau log kueri yang mengelompokkan pernyataan berdasarkan permintaan dan menunjukkan pencarian ternormalisasi yang sama diulang dari call site yang sama.

Sinyal kedua adalah model pertumbuhan yang benar. Untuk N baris induk (parent), jalur naif mengeksekusi satu kueri induk ditambah satu kueri baris terkait per induk:

text
Q(N) = 1 + N
Q(50) = 51

Batch select-in biasanya mengubahnya menjadi satu kueri induk ditambah satu kueri baris terkait, sehingga jumlahnya tetap dua untuk ukuran halaman yang diuji. Jika daftar ID harus dipecah menjadi B batch, jumlahnya menjadi 1 + B; jumlah tersebut tetap tidak bertambah sekali per induk.

Sinyal ketiga adalah memilih bentuk pemuatan dari kardinalitas relasi dan kebutuhan respons. Pencarian pelanggan many-to-one dengan beberapa kolom sempit sering kali cocok untuk JOIN. Koleksi one-to-many yang besar dapat melipatgandakan baris hasil dan menduplikasi kolom induk, membuat kueri halaman induk yang diikuti oleh kueri anak (child) secara batch menjadi lebih aman. "Mengaktifkan eager loading di mana-mana" bukanlah sebuah desain; beberapa ORM masih dapat mengeluarkan select sekunder untuk relasi eager, dan eager loading global dapat mengambil data yang tidak pernah dikembalikan oleh endpoint ini.

Terakhir, pewawancara mengharapkan bukti bahwa perilaku sistem tetap terjaga. Pengurangan jumlah kueri tidak membenarkan hilangnya filter tenant, batas halaman yang berubah, pengurutan yang tidak stabil, pembacaan yang tidak konsisten, atau baris tambahan yang disebabkan oleh join. Jawaban yang kuat memvalidasi beban kerja basis data dan kesetaraan respons.

Pertanyaan Klarifikasi Sebelum Menjawab

  • Di mana akses relasi terjadi? Jika serialisasi, template, logging, atau mapper menyentuh properti setelah repository mengembalikannya, sumber kueri berada di luar loop yang terlihat. Perbaikan harus mencakup jalur akses yang sebenarnya.
  • Berapa kardinalitas relasinya? Data many-to-one dapat digabungkan tanpa melipatgandakan satu pesanan menjadi beberapa baris. Koleksi anak one-to-many mengubah risiko paginasi dan ukuran payload.
  • Field terkait mana saja yang dibutuhkan? Sebuah nama tampilan mendukung proyeksi yang sempit. Memuat seluruh entitas pelanggan dan setiap asosiasinya menciptakan overfetching bahkan jika jumlah kueri menurun.
  • Apakah ID terkait berulang? Identity map berlingkup permintaan (request-scoped) dapat mengurangi pencarian duplikat, tetapi tidak membatasi jumlah kueri ketika sebagian besar ID bersifat unik. Lakukan pengukuran alih-alih berasumsi bahwa cache akan memperbaikinya.
  • Bagaimana paginasi diterapkan? Baris induk harus dipilih dengan pengurutan yang deterministik sebelum melakukan join one-to-many atau pemuatan anak; jika tidak, multiplikasi baris dapat mengubah induk mana yang muncul.
  • Haruskah kedua pembacaan berbagi satu snapshot? JOIN adalah satu pernyataan. Kueri induk yang diikuti oleh kueri anak dapat mengamati perubahan bersamaan (concurrent change) di bawah perilaku isolasi default. Jika konsistensi point-in-time penting, gunakan snapshot transaksi yang sesuai atau bentuk pernyataan tunggal.
  • Apa yang sebenarnya dihasilkan oleh ORM? Istilah seperti eager, include, prefetch, atau split query tidak menjamin jumlah pernyataan tertentu. Periksa SQL yang dihasilkan untuk versi yang diterapkan.

Kerangka Jawaban 30 Detik

"Saya akan mengelompokkan span basis data berdasarkan permintaan dan memverifikasi satu kueri halaman yang diikuti oleh pencarian pelanggan ternormalisasi yang sama sebanyak 50 kali. Kemudian saya akan memvariasikan ukuran halaman; jumlah 11, 21, dan 41 untuk halaman berukuran 10, 20, dan 40 membuktikan amplifikasi kueri linier meskipun setiap pencarian berlangsung cepat. Untuk field nama tampilan many-to-one ini, saya akan membandingkan JOIN sempit dengan batch select-in dua kueri. Saya akan menghindari eager default secara global karena dapat menyebabkan overfetching dan tidak menjamin satu pernyataan. Untuk relasi one-to-many yang besar, saya akan memaginasi induk terlebih dahulu dan mem-batch anak-anaknya guna menghindari multiplikasi baris. Terakhir, saya akan menetapkan anggaran kueri yang konstan, membandingkan ID serta pengurutan respons, dan memantau jumlah kueri tingkat permintaan beserta latensinya setelah peluncuran."

Pembahasan Mendalam Langkah demi Langkah

Langkah 1: Buktikan amplifikasi pada batas permintaan

Lampirkan ID permintaan atau trace pada span basis data, normalkan SQL dengan mengganti nilai parameter, dan kelompokkan berdasarkan call site. Trace yang mencurigakan secara struktural akan terlihat seperti ini:

text
1 × SELECT id, customer_id, created_at, total_cents FROM orders ... LIMIT ?
50 × SELECT id, display_name FROM customers WHERE id = ?

Fingerprint yang berulang dan penskalaan linier membedakan N+1 dari pernyataan tunggal yang mahal, lock wait, antrean connection pool, atau serializer yang lambat. Catat total durasi basis data dan jumlah roundtrip serta durasi pernyataan. Lima puluh kueri berdurasi satu milidetik tidak setara dengan satu kueri berdurasi lima puluh satu milidetik karena setiap roundtrip juga menghabiskan koneksi, beban protokol, dan waktu penjadwal (scheduler).

Ulangi permintaan dengan ukuran halaman yang terkontrol yaitu 10, 20, dan 40. Jumlah kueri 11, 21, dan 41 adalah bukti kausal yang kuat. Menghapus field relasi untuk sementara seharusnya meruntuhkan kueri tambahan tersebut; hal itu mengonfirmasi akses properti mana yang memicu pemuatan. Eksperimen ini lebih berguna daripada menambahkan indeks ke pencarian primary key yang sudah berindeks.

Langkah 2: Tentukan hasil yang diperlukan sebelum mengubah pemuatan

Tulis kontrak respons: ID pesanan yang berurutan, batas kursor atau halaman, tenant yang diizinkan, dan field pelanggan yang tepat. Tentukan juga bagaimana pelanggan yang hilang atau dihapus ditampilkan. Ini mencegah optimasi kueri berubah secara diam-diam menjadi perubahan kontrak data.

Pertahankan predikat otorisasi dan soft-delete pada jalur batch. Jika loader relasi asli memberlakukan cakupan tenant, kueri WHERE id = ANY(...) manual yang mengabaikan cakupan tenant dapat menjadi kebocoran data. Jumlah kueri hanyalah salah satu kriteria penerimaan.

Langkah 3: Pilih antara JOIN sempit dan batch select-in

Untuk relasi many-to-one wajib dan respons yang sempit, satu pernyataan joined sangatlah sederhana:

sql
SELECT
  o.id,
  o.created_at,
  o.total_cents,
  c.id AS customer_id,
  c.display_name
FROM orders AS o
JOIN customers AS c
  ON c.id = o.customer_id
 AND c.tenant_id = o.tenant_id
WHERE o.tenant_id = $1
ORDER BY o.created_at DESC, o.id DESC
LIMIT $2;

Kondisi penentu (tie-breaker) pada o.id membuat pengurutan menjadi deterministik. Gunakan left join jika pesanan secara sah dapat tetap ada melebihi record pelanggannya dan kontrak yang ada mengembalikan pesanan tersebut.

Batch dua kueri memisahkan paginasi induk dan bekerja dengan baik ketika lebar join atau kardinalitas koleksi akan menggelembungkan hasil. TypeScript ilustratif berikut mendeduplikasi ID, hanya memuat kolom yang diperlukan, dan memetakannya di memori:

ts
interface OrderRow {
  id: string
  customerId: string
  createdAt: Date
  totalCents: number
}

interface CustomerRow {
  id: string
  displayName: string
}

const orders = await loadOrderPage(tenantId, limit)
const customerIds = [...new Set(orders.map((order) => order.customerId))]
const customers = await loadCustomersByIds(tenantId, customerIds)
const customerById = new Map(customers.map((customer) => [customer.id, customer]))

return orders.map((order) => ({
  ...order,
  customer: customerById.get(order.customerId) ?? null,
}))

loadCustomersByIds pada repository harus menggunakan satu predikat berbasis set untuk halaman normal dan membagi daftar ID yang sangat besar menjadi batch-batch terbatas. Deduplikasi mengurangi parameter yang ditransfer; ini bukan perbaikan utama. Perbaikan utamanya adalah memindahkan pemuatan terkait ke luar dari jalur akses per baris.

Langkah 4: Tangani relasi one-to-many tanpa merusak paginasi

Misalkan setiap pesanan juga mengembalikan banyak item baris (line items). Menggabungkan pesanan, pelanggan, dan item dapat menghasilkan satu baris per item dan menduplikasi kolom pesanan. Menerapkan LIMIT 50 setelah join tersebut dapat membatasi baris yang digabungkan, bukan 50 pesanan yang berbeda. Memuat beberapa koleksi dalam satu join dapat melipatgandakan data satu sama lain.

Pilih 50 pesanan induk terlebih dahulu dengan urutan yang stabil, lalu ambil semua item yang order_id-nya ada dalam kumpulan ID induk tersebut. Kelompokkan item berdasarkan order_id dan lampirkan dalam urutan induk aslinya. Ini adalah alasan praktis mengapa dokumentasi resmi ORM menawarkan strategi joined, subquery, select-in, dan split-query alih-alih satu sakelar eager-loading universal.

Langkah 5: Tolak perubahan pemuatan global sebagai jalan pintas

Mengubah setiap relasi dari lazy menjadi eager dapat memindahkan masalah daripada menyelesaikannya. Endpoint yang tidak membutuhkan pelanggan sekarang akan melakukan overfetching. Kueri yang tidak melakukan join-fetch pada relasi eager mungkin masih memicu select sekunder pada beberapa perilaku ORM. Graf objek yang luas juga dapat membuat join besar atau siklus yang sulit diprediksi.

Lebih disukai proyeksi khusus endpoint atau rencana pemuatan eksplisit. Dalam pengembangan dan pengujian, gunakan opsi ORM yang memunculkan error (raise) pada lazy SQL yang tidak terduga jika tersedia. Hal itu mengubah akses basis data tersembunyi menjadi kegagalan yang terlihat pada batas di mana respons disusun.

Langkah 6: Verifikasi bentuk kueri, semantik, dan efek produksi

Bangun matriks regresi dengan hasil kosong, satu baris, ID pelanggan berulang, semua ID pelanggan unik, pelanggan opsional yang hilang, dan ukuran halaman maksimum yang diizinkan. Pastikan urutan respons, ID, perilaku null, isolasi tenant, dan anggaran kueri konstan. Untuk rencana dua kueri, halaman berukuran 10 dan 40 keduanya harus menggunakan dua pernyataan di bawah batas batch yang dipilih.

Kemudian bandingkan data representatif yang menyerupai data produksi untuk total latensi permintaan, span basis data per permintaan, baris dan byte yang dikembalikan, okupansi connection pool, dan beban basis data. JOIN yang mengurangi 51 pernyataan menjadi satu tetapi mengembalikan payload berulang yang sangat besar mungkin merupakan regresi di bawah metrik yang berbeda. Lakukan peluncuran per endpoint, pantau distribusi jumlah kueri, dan pertahankan sampel trace yang dapat mengidentifikasi call site jika lazy loading muncul kembali.

Contoh Jawaban Berkualitas Tinggi

"Bukti yang ada mengarah pada amplifikasi kueri daripada satu query plan yang lambat. Saya akan mulai dengan satu trace permintaan dan mengelompokkan SQL yang dinormalisasi berdasarkan call site. Jika satu halaman berisi 50 pesanan menunjukkan satu kueri pesanan dan 50 pencarian primary key pelanggan, kemudian halaman berukuran 10, 20, dan 40 menghasilkan 11, 21, dan 41 pernyataan, saya dapat menunjukkan bahwa beban kerja basis data bertambah sekali per baris induk.

Sebelum memperbaikinya, saya akan mempertahankan kontrak: predikat tenant, ID pesanan, pengurutan deterministik, batas halaman, field pelanggan yang diperlukan, dan perilaku saat pelanggan tidak ada. Karena ini adalah pencarian many-to-one yang sempit, JOIN adalah kandidat pertama yang baik. Pemuatan select-in dua kueri juga valid: ambil halaman pesanan, deduplikasi ID pelanggan, muat pelanggan tersebut dalam satu kueri set, dan petakan berdasarkan ID. Saya akan memilih di antara keduanya berdasarkan SQL yang dihasilkan, lebar payload, dan kebutuhan konsistensi.

Jika relasinya adalah koleksi one-to-many yang besar, saya akan memaginasi pesanan terlebih dahulu dan mem-batch item dalam kueri kedua. Hal ini menghindari multiplikasi baris yang digabungkan mengubah paginasi. Saya tidak akan membuat setiap asosiasi menjadi eager secara global; ini dapat menyebabkan overfetching dan beberapa bentuk kueri ORM masih mengeluarkan select sekunder.

Uji regresi akan menjalankan kasus kosong, ID berulang, ID unik, relasi hilang, dan halaman maksimum. Pengujian ini akan memastikan ID, pengurutan, otorisasi, dan perilaku null yang identik, ditambah anggaran pernyataan yang konstan. Setelah peluncuran, saya akan memantau span basis data per permintaan dan total latensi, tidak hanya pernyataan individual yang lambat. Ini membuktikan perbaikan performa sekaligus hasil yang tidak berubah."

Kesalahan Umum

  • Menambahkan indeks pada pencarian pelanggan yang berulang → Setiap pencarian mungkin sudah menggunakan indeks primary key, sementara permintaan tetap melakukan satu roundtrip per pesanan → Ukur dan ubah pola akses.
  • Mengaktifkan eager loading secara global → Endpoint yang tidak terkait melakukan overfetching, dan perilaku eager spesifik-ORM mungkin masih mengeluarkan pernyataan sekunder → Gunakan proyeksi khusus endpoint atau rencana pemuatan.
  • Melakukan join pada setiap relasi → Koleksi one-to-many melipatgandakan baris, mengulang data induk, dan dapat merusak batas halaman → Paginasi induk terlebih dahulu dan lakukan batching pada koleksi besar.
  • Menggunakan cache tingkat proses (process-wide) sebagai perbaikan → ID dingin (cold) atau unik tetap menghasilkan kueri linier, dan data basi (stale) atau lintas tenant menjadi risiko baru → Batasi jumlah kueri secara independen dari cache hit.
  • Hanya menghitung pernyataan yang lambat → Puluhan kueri cepat lolos dari log slow-query berbasis ambang batas sambil tetap menghabiskan roundtrip dan koneksi → Agregasikan span berdasarkan permintaan dan fingerprint.
  • Menghilangkan predikat keamanan dalam kueri batch → Optimasi dapat memuat baris terkait dari tenant lain → Pertahankan filter otorisasi dan soft-delete secara eksplisit.
  • Hanya memastikan latensi yang lebih rendah → Uji waktu bersifat noisy dan dapat lulus dengan cache yang hangat → Pastikan anggaran kueri konstan dan kesetaraan respons, lalu ukur latensi secara terpisah.
  • Mengasumsikan dua kueri sama dengan satu snapshot → Pembaruan bersamaan dapat muncul di antara pernyataan di bawah perilaku isolasi umum → Pilih snapshot transaksi atau satu pernyataan saat kontrak memerlukan konsistensi point-in-time.

Pertanyaan Lanjutan dan Tanggapan

Pertanyaan lanjutan 1: Kapan JOIN lebih baik daripada batch dua kueri?

JOIN menarik untuk relasi many-to-one atau one-to-one yang sempit, saat semantik snapshot satu pernyataan penting dan multiplikasi baris terbatas. Batch menarik ketika paginasi induk harus tetap terisolasi, data terkait adalah koleksi, atau join akan mengulang kolom induk yang lebar. Periksa SQL yang dihasilkan dan byte yang dikembalikan; jumlah pernyataan saja tidak menentukan.

Pertanyaan lanjutan 2: Bagaimana jika 50 pesanan hanya merujuk ke tiga pelanggan?

Identity map berlingkup permintaan mungkin mengurangi jalur naif menjadi empat pernyataan, tetapi hal itu tetap bergantung pada data. Deduplikasi ketiga ID tersebut dan jalankan satu kueri set sehingga jumlah yang direncanakan adalah dua. Jangan mengandalkan cache lintas permintaan untuk kebenaran data atau isolasi tenant.

Pertanyaan lanjutan 3: Bagaimana Anda menangkap N+1 dalam nested resolver bergaya GraphQL?

Kumpulkan kunci relasi selama satu eksekusi permintaan dan kirimkan batch berlingkup permintaan sebelum me-resolve field. Pertahankan urutan hasil dengan memetakan baris kembali ke urutan kunci asli dan representasikan kunci yang hilang secara eksplisit. Uji regresi harus meminta nested field untuk beberapa induk dan memastikan batas jumlah pernyataan; mengabaikan field tersebut seharusnya menghindari kueri terkait.

Pertanyaan lanjutan 4: Bagaimana jika batch berisi lebih banyak ID daripada yang seharusnya ditampung oleh satu kueri?

Bagi ID yang telah dideduplikasi menjadi bagian-bagian (chunks) terbatas yang dipilih berdasarkan batasan basis data dan driver. Modelnya menjadi 1 + B pernyataan untuk B chunks, sehingga pengujian harus memastikan batas yang diharapkan daripada angka dua tanpa syarat. Jika halaman endpoint biasa membutuhkan banyak chunk, kurangi ukuran halaman atau pertimbangkan kembali struktur datanya.

Pertanyaan lanjutan 5: Jumlah kueri sudah diperbaiki, tetapi latensi hampir tidak membaik. Apa selanjutnya?

Bandingkan waktu basis data, waktu jaringan, serialisasi, baris dan byte yang dikembalikan, lock wait, dan antrean pool sebelum mengusulkan perbaikan lain. Pernyataan joined atau batched yang baru mungkin memerlukan indeks tersendiri, mungkin mengembalikan terlalu banyak data, atau mungkin tidak mendominasi latensi end-to-end. Pertahankan perbaikan N+1 jika itu menghilangkan amplifikasi linier, tetapi diagnosis bottleneck yang tersisa dengan bukti baru.

Sumber publik

Pertanyaan terkait