4 Oktober 2026•baca 5 menit

Jawabannya: Dua Perbaikan di Dua Tempat Berbeda

Query-nya sudah 2, kodenya sudah benar, dan latency-nya masih 1 detik. Jawabannya ada di dua tempat berbeda, dan cuma satu yang kelihatan di dalam kode.

Jawabannya: Dua Perbaikan di Dua Tempat Berbeda

Ini artikel jawabannya. Kalau kamu belum mengerjakan lab-nya, mendingan tutup dulu halaman ini dan mulai dari Query-nya Tinggal 2, Tapi Response-nya Masih 1 Detik. Lab-nya ada di inva-lab — make up, make check, dan targetnya <= 3 query dengan p95 <= 30 ms.

Sisanya di bawah ini adalah jawabannya.

Poin Penting

  • Query yang sedikit belum tentu cepat: 2 query bisa lebih lambat daripada 41 query.
  • Menghitung dengan len() berarti memindahkan semua barisnya ke memori aplikasi. Minta angkanya lewat agregat.
  • Setelah kodenya benar, lapisan kedua ada di skema: foreign key tanpa index bikin database memindai seluruh tabel lalu membuang hampir semua hasilnya.
  • Index mempercepat cara database mencari, bukan mengurangi jumlah data yang harus menyeberang.

Angka lengkapnya

Enam varian dari endpoint yang sama, di dataset yang sama (400 tulisan, 750.000 komentar, 20 tulisan per halaman). Semua diukur di mesin yang sama, dan setiap varian mengembalikan body response yang identik — yang berubah cuma performanya.

versi query p50 p95
apa adanya 41 950 ms 1.152 ms
setelah penulis di-eager load 21 915 ms 981 ms
jebakan: komentar di-eager load, dihitung len() 2 983 ms 1.026 ms
jebakan + index 2 949 ms 1.002 ms
komentar dihitung sebagai satu agregat 2 60 ms 62 ms
agregat + index 2 7 ms 8 ms

Tiga baris pertama sudah kamu lihat di artikel sebelumnya. Tiga baris terakhir itu jawabannya, dan perhatikan baris keempat — itu bagian yang paling gampang dilewatkan.

Perbaikan pertama: hitung, jangan pindahkan

Kesalahannya bukan memakai eager loading. Silakan pakai eager loading sampai kapan pun untuk data yang memang mau kamu tampilkan.

Kesalahannya adalah memakai len() untuk menghitung. Supaya bisa memanggil len(), semua barisnya harus ada dulu di memori aplikasi. Padahal kamu nggak butuh barisnya sama sekali — kamu butuh satu angka per tulisan.

Jadi jangan ambil barisnya. Minta angkanya:

PYTHON
post_ids = [post.id for post in posts]

counts = dict(
    session.execute(
        select(Comment.post_id, func.count())
        .where(Comment.post_id.in_(post_ids))
        .group_by(Comment.post_id)
    ).all()
)

for post in posts:
    post.comment_count = counts.get(post.id, 0)

Query-nya tetap 2. Latency-nya turun dari 1.026 ms ke 62 ms.

Perhatikan juga: latestArticles di bawah, readNextPosts di halaman artikel, daftar di halaman kategori — semua pola yang sama. Kalau kamu pernah menampilkan "5 komentar terakhir" dengan cara memuat semua komentar lalu memotongnya di kode, itu kesalahan yang sama.

Kenapa masih 62 ms

Di titik ini query-nya sudah benar dan hasilnya sudah benar. Yang belum benar adalah skemanya, dan itu nggak kelihatan dari membaca kode sama sekali.

Ini yang sebenarnya terjadi. Rata-rata tiap tulisan punya ~1.875 komentar, dan satu halaman menampilkan 20 tulisan. Jadi query agregat di atas harus memeriksa 37.500 baris. Jumlah itu masuk akal untuk database.

Yang nggak masuk akal adalah cara dia mencarinya. Ini EXPLAIN ANALYZE dari query yang sama, dijalankan langsung di database, di luar aplikasi:

SQL
EXPLAIN ANALYZE
SELECT post_id, count(*)
FROM comments
WHERE post_id IN (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20)
GROUP BY post_id;
TEXT
 Finalize GroupAggregate  (actual time=64.299..65.905 rows=20 loops=1)
   ->  Gather Merge  (actual time=64.292..65.886 rows=60 loops=1)
         Workers Launched: 2
         ->  Partial HashAggregate  (actual time=57.802..57.807 rows=20 loops=3)
               ->  Parallel Seq Scan on comments  (actual time=0.046..54.035 rows=12500 loops=3)
                     Filter: (post_id = ANY ('{1,2,3,...}'::integer[]))
                     Rows Removed by Filter: 237500
 Execution Time: 66.049 ms

Hasilnya 20 angka. Baris yang dibaca lalu dibuang: 237.500 × 3 worker = 712.500.

Hampir seluruh isi tabel dipindai, dan 95% hasilnya langsung dibuang, cuma untuk tahu ada berapa komentar di 20 tulisan. Kalau tabelnya tumbuh 10× lipat, angka itu ikut tumbuh 10× lipat — dan make check di laptopmu nggak akan kelihatan bedanya, karena dataset-nya tetap.

Satu hal lagi yang perlu kamu lihat: bentuk yang sama persis terjadi di versi satu-query-per- tulisan (50,9 ms, 748.125 baris dibuang) — dan juga di versi awal yang 41 query itu. Semua varian lambat, dari satu sebab yang sama. Jumlah query-nya beda-beda; sebabnya satu.

Perbaikan kedua: index, dan ini di luar kode

SQL
CREATE INDEX CONCURRENTLY idx_comments_post_id ON comments (post_id);

Setelah itu, plan-nya berubah jadi ini:

TEXT
 HashAggregate  (actual time=12.297..12.302 rows=20 loops=1)
   ->  Bitmap Heap Scan on comments  (actual time=1.685..7.374 rows=37500 loops=1)
         ->  Bitmap Index Scan on idx_comments_post_id  (rows=37500 loops=1)
               Index Cond: (post_id = ANY ('{1,2,3,...}'::integer[]))
 Execution Time: 12.427 ms

Baris yang dibaca sekarang 37.500 — persis yang dibutuhkan, nggak ada yang dibuang. Seq Scan hilang, diganti Bitmap Index Scan. Di level endpoint, hasilnya 8 ms.

Kenapa CONCURRENTLY? Di lab ini tabelnya milikmu sendiri dan nggak ada yang menulis, jadi CREATE INDEX biasa aman. Di produksi nggak: comments akan terus menerima tulisan, dan CREATE INDEX biasa mengambil kunci yang memblokir tulisan selama prosesnya. CONCURRENTLY nggak memblokir — harganya, dia mengerjakan dua kali pemindaian tabel dan butuh waktu lebih lama. Kalau pembuatannya gagal, yang tertinggal adalah index berstatus invalid yang harus kamu buang manual sebelum mencoba lagi.

Dua tempat berbeda, dan hanya satu yang kelihatan di kode

Ini bagian yang pantas kamu ingat.

Kamu bisa membaca repositories.py sampai hafal dan nggak akan pernah menemukan perbaikan keduanya, karena yang salah bukan cara kamu mengambil data — yang salah adalah datanya nggak punya jalan pintas untuk pertanyaan seperti itu.

Dan perhatikan baris keempat tabel di atas sekali lagi: jebakan di artikel sebelumnya tetap 1.002 ms walaupun sudah dipasangi index. Index mempercepat cara database mencari. Kalau masalahnya adalah jumlah data yang harus menyeberang, index bukan jawabannya.

Menghitung bukan butuh datanya. Butuh angkanya.

Kalau kamu berhenti sebelum index

Kalau kamu berhasil menyelesaikan perbaikan pertama lalu berhenti karena sudah 94% lebih cepat, kamu belum salah — kamu cuma belum selesai. Di dunia nyata, keputusan seperti itu justru sering benar: 62 ms mungkin sudah cukup, dan index punya harga (tulisan jadi lebih lambat, dan memakan tempat).

Yang bikin keputusan itu jadi keputusan adalah kalau kamu tahu ada lapisan kedua dan memutuskan dengan sadar untuk nggak mengerjakannya. Bukan kalau kamu nggak tahu.

Lab-nya, kalau kamu mau buktikan sendiri

👉 inva-lab

BASH
git clone https://github.com/izzudd/inva-lab.git
cd inva-lab/n-plus-one
make up
make check   # target: <= 3 query, p95 <= 30 ms

Branch solusi berisi dua perbaikan di atas, plus catatan pengukurannya. Kalau kamu mau membuktikan bahwa biaya ini tumbuh mengikuti jumlah baris, kalikan isi db/init/02_seed.sql jadi 3× lipat, jalankan make reset, lalu make bench lagi.

Pertanyaan untuk lab berikutnya: kalau tabel comments sudah punya 200 juta baris dan count(*) per tulisan sudah jadi bagian dari halaman yang dilihat jutaan orang, index saja mulai nggak cukup. Apa yang kamu ubah? (Ada tiga jawaban yang sering dipakai di produksi, dan semuanya punya kekurangan masing-masing.)

Biar makin jago ngoding.

Yuk gabung bareng temen-temen developer lainnya buat dapet update teknologi, tips, dan tutorial santai tiap minggu.

Biar Nggak Kudet

Update teknologi, tips ngoding, dan insight santai langsung ke email kamu tiap minggu.

Santai, anti spam. Bisa berhenti langganan kapan aja.

Baca Selanjutnya

Lihat semua
Query-nya Tinggal 2, Tapi Response-nya Masih 1 Detik
databaseQuery-nya Tinggal 2, Tapi Response-nya Masih 1 Detik

Endpoint feed yang cuma menampilkan 20 tulisan butuh satu detik, dan server database dihujani 41 query. Kamu kerjakan nasihat N+1 yang standar, query-nya turun jadi 2, dan latency-nya nggak bergerak. Ini versi "apa adanya" dari masalah itu, plus lab-nya.

© 2026 Inva.dev.