II — Praktik
Artikel 3 dari 9

Praktik: Mengukur Dulu, Baru Optimasi

Membangun skrip load test sederhana, mengukur baseline `task-tracker-api`, lalu mendorongnya sampai titik patah untuk menemukan bottleneck nyata lewat log — bukan tebakan.

10 Juli 2026

Di dua artikel sebelumnya kita sudah bahas konsepnya. Sekarang saatnya praktik — tapi belum ke caching atau database scaling dulu. Sebelum itu, kita perlu jawab pertanyaan yang sering dilewatkan: seberapa cepat/lambat sebenarnya task-tracker-api kita sekarang? Tanpa angka ini, klaim "sudah lebih cepat" di artikel-artikel berikutnya cuma perasaan, bukan fakta.

Kenapa Mengukur Dulu Itu Wajib

Optimasi tanpa data itu seperti benerin mobil dengan mata tertutup — bisa saja kebetulan benar, tapi lebih sering salah sasaran. Tim yang buru-buru menambah cache, upgrade server, atau pecah jadi microservice tanpa tahu di mana bottleneck sebenarnya, sering berakhir dengan sistem yang lebih kompleks TAPI TIDAK LEBIH CEPAT — karena masalah aslinya ada di tempat lain yang tidak pernah tersentuh.

Prinsipnya sederhana: ukur, baru optimasi, ukur lagi untuk validasi. Tiga langkah ini akan kita ulang di setiap artikel praktik selanjutnya.

Skrip Load Test Sederhana

Kita akan pakai satu skrip Python kecil, benchmark/load_test.py, sepanjang sisa seri ini. Tidak perlu install tool eksternal (locust, hey, dst.) — cukup httpx yang sudah ada di requirements.txt.

async def run_load_test(url: str, total_requests: int, concurrency: int) -> None:
    latencies: list[float] = []
    errors: list[str] = []

    semaphore = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient(timeout=30.0) as client:

        async def bounded_worker() -> None:
            async with semaphore:
                await _worker(client, url, latencies, errors)

        start = time.perf_counter()
        await asyncio.gather(*(bounded_worker() for _ in range(total_requests)))
        total_duration = time.perf_counter() - start
    ...

Cara kerjanya sederhana: kirim total_requests request ke url, tapi dibatasi maksimum concurrency request yang berjalan bersamaan di satu waktu (lewat asyncio.Semaphore) — ini mensimulasikan banyak user yang mengakses API secara paralel, bukan satu per satu berurutan. Skrip mencatat latency tiap request, lalu di akhir menghitung throughput (request per detik) dan distribusi latency (rata-rata, median, p95, max).

Kenapa p95, bukan cuma rata-rata? Rata-rata gampang menyembunyikan masalah — kalau 95 dari 100 request selesai dalam 50ms tapi 5 sisanya butuh 5 detik, rata-ratanya masih kelihatan "oke" padahal 1 dari 20 user kamu mengalami pengalaman buruk. P95 (95th percentile) memberi tahu: "95% request selesai dalam waktu SEKIAN atau lebih cepat" — jauh lebih jujur menggambarkan pengalaman user di kondisi terburuk yang masih umum terjadi.

Baseline: Mengukur task-tracker-api Apa Adanya

Kita mulai dari titik akhir seri FastAPI for Beginners: satu instance, SQLite, tanpa cache. Jalankan aplikasinya seperti biasa:

cd fastapi-for-beginner/demo-app
uvicorn app.main:app --port 8000

Isi beberapa data dulu supaya endpoint list punya sesuatu untuk dikerjakan:

for i in $(seq 1 50); do
  curl -s -X POST http://localhost:8000/api/tasks \
    -H "Content-Type: application/json" \
    -d "{\"title\": \"Task seed $i\"}" > /dev/null
done

Lalu jalankan load test dengan beban yang wajar — 200 request, 20 di antaranya berjalan bersamaan:

python benchmark/load_test.py --url http://localhost:8000/api/tasks --requests 200 --concurrency 20

Hasilnya, di mesin yang dipakai menulis seri ini:

Total request     : 200
Concurrency       : 20
Sukses / Gagal    : 200 / 0
Durasi total      : 1.09 detik
Throughput        : 182.7 req/detik
Latency rata-rata : 104.9 ms
Latency median    : 100.9 ms
Latency p95       : 158.6 ms
Latency max       : 203.3 ms

Tidak buruk — semua request sukses, latency masih di bawah 200ms bahkan di kondisi terburuknya (p95). Simpan angka ini — ini baseline kita, patokan pembanding untuk semua optimasi di artikel-artikel selanjutnya.

Mendorong Lebih Jauh: Mencari Titik Patah

Angka di atas terlihat sehat, tapi itu baru satu titik data di satu level beban. Pertanyaan yang lebih penting: di titik berapa sistem ini mulai retak? Mari naikkan levelnya jauh lebih tinggi — 500 request, 100 di antaranya bersamaan:

python benchmark/load_test.py --url http://localhost:8000/api/tasks --requests 500 --concurrency 100

Hasilnya, di mesin yang sama:

Total request     : 500
Concurrency       : 100
Sukses / Gagal    : 3 / 497
Durasi total      : 150.77 detik
Throughput        : 3.3 req/detik
Latency rata-rata : 29943.2 ms
Latency median    : 30129.1 ms

Dari 182.7 request/detik jadi 3.3 request per detik. Dari latency ~100ms jadi ~30 DETIK (dan itu pun kebanyakan berakhir timeout, bukan benar-benar selesai). Ini bukan degradasi bertahap — ini collapse total. Sesuatu yang jauh lebih dalam dari sekadar "server-nya agak lambat" sedang terjadi.

Membaca Log untuk Tahu Apa yang Sebenarnya Terjadi

Cek log server yang jalan di terminal lain, dan di sana kita menemukan error yang berulang-ulang:

sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached,
connection timed out, timeout 30.00

Ini persis contoh nyata dari poin "resource yang dikira bisa dibagi, ternyata terkunci" yang dibahas di Bagian 1. Database connection pool kita (default: 5 koneksi + 10 overflow = maksimum 15 koneksi bersamaan) kehabisan slot begitu 100 request datang bersamaan — request ke-16 dan seterusnya harus antre menunggu koneksi kosong, dan banyak yang keburu timeout sebelum kebagian giliran.

Ini bukan masalah CPU. Bukan juga masalah "server-nya kurang kuat". ini murni masalah konfigurasi resource — jumlah koneksi database yang tersedia tidak cukup untuk menampung beban konkuren yang datang. Kalau kita cuma asal upgrade server (vertical scaling) tanpa tahu ini, masalahnya TIDAK akan hilang — CPU dan RAM yang lebih besar tidak menambah slot di connection pool. Ini akan kita perbaiki tuntas di Bagian 5.

Kenapa Contoh Ini Penting

Perhatikan urutan yang baru saja kita lakukan: ukur di beban wajar (sehat), ukur di beban ekstrem (collapse), BARU baca log untuk diagnosis akar masalahnya. Kalau kita langsung lompat ke "solusi" begitu dengar kata "scalability" — misalnya langsung containerize dan bikin 5 instance — masalah connection pool ini akan tetap ada, cuma sekarang muncul di 5 tempat berbeda alih-alih satu. Menambah instance TIDAK menyelesaikan masalah yang akar penyebabnya adalah konfigurasi resource yang salah di satu instance.

Rangkuman

  • Ukur dulu, baru optimasi — tanpa baseline, klaim "lebih cepat" tidak bisa dibuktikan.
  • Skrip load test sederhana (benchmark/load_test.py) sudah cukup untuk dapat angka throughput dan latency (termasuk p95, yang lebih jujur dari rata-rata).
  • Baseline task-tracker-api di beban wajar (concurrency 20): ~183 req/detik, p95 ~159ms — sehat.
  • Di beban ekstrem (concurrency 100): sistem collapse total karena connection pool exhaustion — bukan soal CPU/RAM.
  • Selalu baca log untuk diagnosis akar masalah, sebelum memutuskan solusi apa yang mau diterapkan.
  • Topik

    ScalabilityPerformance