Satu halaman feed butuh satu detik untuk tampil. Datanya cuma 400 tulisan dan 750.000 komentar, dan yang dikerjakan endpoint-nya sederhana: ambil 20 tulisan terbaru, lalu tampilkan nama penulisnya dan jumlah komentarnya masing-masing.
Tidak ada error. Tidak ada log kuning. Tidak ada satu baris pun yang kelihatan salah kalau kamu membacanya. Sampai ada yang mengeluh, kamu mengukur, dan ternyata database dihujani 41 query hanya untuk melayani satu request itu.
Poin Penting
- N+1 itu 1 query untuk mengambil daftar, lalu N query tambahan untuk melengkapi tiap barisnya.
- Nasihat "pakai eager loading" nggak salah, tapi dia memperbaiki cara mengambil data - bukan banyaknya data yang harus menyeberang.
- Jumlah query itu metrik yang gampang dilihat, dan itu sebabnya dia jadi satu-satunya yang dilihat. Dua kelompok query bisa berbeda biaya ratusan kali lipat.
- Kasus lengkapnya bisa kamu kerjakan sendiri di lab: make up, lalu make check.
N+1, dan kenapa namanya begitu
Kalau kamu sudah tahu bagian ini, lewati saja satu paragraf.
Namanya N+1 karena jumlahnya: 1 query untuk mengambil daftar utamanya, lalu N query tambahan untuk melengkapi tiap baris di daftar itu. Untuk 20 tulisan, hasilnya 21 query.
posts = session.execute(select(Post).limit(20)).scalars().all()
for post in posts:
print(post.author.name) # tiap akses memicu query baru ke database
Penyebabnya lazy loading, perilaku bawaan hampir semua ORM: data relasi nggak diambil sampai kamu benar-benar mengaksesnya. Sekilas itu masuk akal — hemat memori, dan kamu cuma mengambil yang kamu butuh. Masalahnya "kamu butuh" ternyata terjadi di dalam loop, dan hasilnya bukan penghematan, tapi 20 kali bolak-balik ke database untuk sesuatu yang bisa diselesaikan sekali jalan.
Nasihat yang semua orang sudah tahu
Solusinya eager loading: beri tahu ORM supaya relasinya sekalian diambil, bukan satu-satu.
posts = session.execute(
select(Post).options(joinedload(Post.author)).limit(20)
).scalars().all()
Sampai di sini kamu sudah mengerjakan nasihat standarnya dengan benar. Nama penulis sekarang nggak lagi memicu query tambahan. Tapi cuma itu yang berubah:
| versi | query per request | p50 | p95 |
|---|---|---|---|
| apa adanya | 41 | 950 ms | 1.152 ms |
| setelah penulis di-eager load | 21 | 915 ms | 981 ms |
Dua puluh query berhasil kamu hapus, dan p50-nya turun 35 ms. Sekitar 4%. Kenapa begitu kecil? Karena 20 query yang baru kamu hapus itu mencari berdasarkan primary key — pencarian paling murah yang bisa dilakukan database. Bolak-baliknya memang boros, tapi yang bolak-balik nggak pernah jadi biaya utamanya.
Di sini kebanyakan orang berhenti dan menyimpulkan masalahnya ada di tempat lain. Padahal masalahnya masih di depan mata: ada N+1 yang kedua, dan dia menyumbang hampir semuanya.
N+1 yang kedua
20 query sisanya itu menghitung jumlah komentar untuk tiap tulisan. Ditulis seperti ini:
for post in posts:
post.comment_count = session.execute(
select(func.count())
.select_from(Comment)
.where(Comment.post_id == post.id)
).scalar_one()
Perhatikan bahwa kode ini ditulis dengan niat baik. Dia memakai COUNT(*) supaya nggak perlu
memuat ribuan baris komentar hanya untuk menghitung jumlahnya — itu memang lebih hemat
daripada len(post.comments). Yang tertinggal cuma satu hal: dia berjalan di dalam
loop, jadi tetap satu query per tulisan.
Dan begitu masalahnya kelihatan, perbaikannya jadi kelihatan sepele juga — ambil semuanya sekaligus, lalu hitung di memori:
posts = session.execute(
select(Post)
.options(joinedload(Post.author), selectinload(Post.comments))
.limit(20)
).scalars().all()
for post in posts:
post.comment_count = len(post.comments)
Query-nya sekarang 2. Satu untuk daftar tulisan beserta penulisnya, satu untuk seluruh komentar di halaman itu. Turun dari 41, dan itu penurunan yang pantas kamu banggakan.
Latency-nya 1.026 ms.
Dua query, satu detik
Itu bukan salah tulis. Di titik ini:
| versi | query per request | p50 | p95 |
|---|---|---|---|
| apa adanya | 41 | 950 ms | 1.152 ms |
| setelah penulis di-eager load | 21 | 915 ms | 981 ms |
setelah komentar di-eager load, dihitung dengan len() |
2 | 983 ms | 1.026 ms |
Jumlah query turun 95%, dan latency-nya naik.
Ini bagian yang bikin cerita N+1 hampir selalu nggak lengkap. Nasihat "pakai eager loading" itu nggak salah — tapi dia memperbaiki cara mengambil data, bukan banyaknya data yang harus menyeberang. Empat puluh satu query yang murah bisa jadi masalah yang jauh lebih kecil daripada dua query yang masing-masing memindahkan puluhan ribu baris dari database ke memori aplikasi.
Dan yang paling nggak nyaman: kode barusan terasa seperti kode yang benar. Dia memakai eager loading, persis yang diajarkan semua artikel N+1 — termasuk artikel ini, tiga bagian di atas. Kamu bisa membacanya tiga kali dan nggak akan menemukan kesalahannya, karena kesalahannya bukan di cara kamu menulisnya.
Yang belum dijawab
Kalau query-nya sudah 2 dan waktunya masih 1 detik, pertanyaannya harus diganti. Berhenti menghitung query, lalu tanya ini:
berapa banyak baris yang harus dibaca database untuk melayani satu request ini?
Jawabannya bukan 40-an baris. Jawabannya ratusan ribu, dan itu terjadi 20 kali. Sementara di sisi aplikasi, angka yang menyeberang ke memori juga bukan 20 — jauh lebih besar.
Dari mana angka-angka itu datang dan kenapa semahal itu, nggak bisa dijawab di sini. Bukan karena jawabannya panjang, tapi karena kamu nggak akan memercayainya sebelum melihatnya sendiri di query plan.
Lab-nya: kamu yang kerjakan
👉 inva-lab — folder n-plus-one/
git clone https://github.com/izzudd/inva-lab.git
cd inva-lab/n-plus-one
make up # 400 post, 750.000 komentar
make bench # lihat angkanya dulu, sebelum mengubah apa pun
make check # target: <= 3 query, p95 <= 30 ms
Yang ada di dalamnya adalah versi "apa adanya" dari tabel di atas — 41 query, satu detik, dan kode yang sengaja nggak punya kesalahan yang kelihatan. Empat puluh satu query itu bisa kamu hitung sendiri di setiap header response, jadi kamu bisa menebak-nebak sepuasnya sebelum mengubah satu baris pun.
Satu hal yang perlu kamu tahu dari awal: menghilangkan N+1-nya saja nggak akan cukup untuk
bikin make check hijau. Ada lapisan kedua, dan lapisan itu nggak ada di dalam kode.
Kalau nyangkut lebih dari 45 menit, ada HINTS.md dengan tiga tingkat petunjuk. Pakai urut,
jangan langsung ke yang ketiga.
Sudah mentok dan mau lihat jawabannya? Jawabannya: Dua Perbaikan di Dua Tempat Berbeda