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:
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:
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;
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
CREATE INDEX CONCURRENTLY idx_comments_post_id ON comments (post_id);
Setelah itu, plan-nya berubah jadi ini:
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
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.)