Anatomi satu request — dari klien sampai baris database
Materi ini menyatukan semua yang sudah dibahas jadi satu jalur. Setelahnya, setiap keputusan di sisa roadmap — timeout, pool, cache, deploy — punya tempat yang jelas di peta.
Intisari
- Permintaan melewati tujuh batas; tiap batas punya timeout dan tiap timeout harus mengecil ke dalam.
- Sebagian besar permintaan pada situs bertrafik baca tinggi seharusnya berhenti di CDN dan tidak pernah menyentuh Go.
- Di dalam proses, satu permintaan = satu goroutine + satu
context.Context+ satu koneksi database yang dipinjam sebentar. - Koneksi database dipinjam dari pool per-kueri, bukan per-permintaan. Menahannya lebih lama dari perlu adalah penyebab kemacetan nomor satu.
- Kalau
ctxtidak diteruskan sampai keQueryContext, rantai pembatalannya putus dan seluruh anggaran waktu jadi hiasan.
Peta lengkapnya
Browser
│ 1. DNS → Route 53
▼
CloudFront (CDN) ← cache hit: SELESAI di sini
│ 2. cache miss / konten personal
▼
ALB (terminasi TLS, health check)
│ 3. pilih target sehat
▼
ECS Fargate task ── proses Go
│
├─ net/http.Server menerima koneksi
│ └─ goroutine BARU per permintaan
│
├─ Middleware (recover → request ID → log → timeout → CORS → rate limit → auth)
│ └─ r.Context() kini punya: tenggat, request ID, identitas pengguna
│
├─ ServeMux mencocokkan "GET /produk/{id}"
│
├─ Handler: parse & validasi input ──► 400 kalau gagal
│
├─ Service (domain): aturan bisnis ──► 403 / 409 kalau melanggar
│
├─ Repository: pinjam koneksi dari pool
│ ├─ pool penuh? TUNGGU (sampai ctx habis)
│ ▼
│ PostgreSQL (RDS) ── kueri berjalan, index dipakai, baris kembali
│ ▲
│ └─ koneksi DIKEMBALIKAN ke pool ← secepat mungkin
│
├─ Service: susun hasil jadi objek domain
├─ Handler: petakan ke DTO respons, tulis header + status + body
│
└─ Middleware selesai dari dalam ke luar: log dicatat, panic dipulihkan
▼
ALB ► CloudFront (menyimpan sesuai Cache-Control) ► Browser
Batas 1–3: sebelum kodemu jalan
| Batas | Yang terjadi | Yang kamu kendalikan |
|---|---|---|
| CDN | Cache dicek dengan kunci: host + path + header/query terpilih | Header Cache-Control yang kodemu kirim |
| Load balancer | Memilih target yang lolos health check | Endpoint /sehat, idle timeout, algoritma |
| Container | Menerima koneksi TCP | ReadHeaderTimeout, jumlah task |
Pengungkit terbesar ada di baris pertama tabel ini. Untuk halaman yang sama bagi semua pembaca,
satu header Cache-Control: public, s-maxage=60, stale-while-revalidate=600 memindahkan
sebagian besar lalu lintas ke CloudFront — permintaan yang tidak pernah menyentuh Go, tidak
pernah menyentuh database, dan tidak menambah satu pun kelas bug. Dibahas di Fase 9.
Batas 4: masuk ke proses Go
// Yang sudah terjadi sebelum baris pertama handler-mu:
// 1. Koneksi diterima, header dibaca (dibatasi ReadHeaderTimeout)
// 2. Goroutine baru dibuat khusus untuk permintaan ini
// 3. *http.Request dibangun; r.Context() dibatalkan otomatis kalau klien putus
// 4. Rantai middleware dijalankan dari luar ke dalam
func (h *Handler) AmbilProduk(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // ← bawa ini ke MANA-MANA. Ini benang merahnya.
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
h.tulisError(w, r, &ErrValidasi{Field: "id", Pesan: "harus angka positif"})
return
}
p, err := h.layanan.Ambil(ctx, id) // ctx diteruskan
if err != nil {
h.tulisError(w, r, err) // errors.Is memetakan ke status
return
}
h.tulisJSON(w, http.StatusOK, keProdukResp(p))
}
Batas 5: handler ke domain
// internal/produk/layanan.go — tidak tahu apa itu HTTP
func (s *Layanan) Ambil(ctx context.Context, id int64) (Produk, error) {
// Cache dulu: hit di sini menghemat perjalanan ke database.
if p, ok := s.cache.Ambil(ctx, id); ok {
return p, nil
}
// singleflight: seribu permintaan bersamaan untuk id yang sama
// menghasilkan SATU kueri (Fase 2).
v, err, _ := s.grup.Do(strconv.FormatInt(id, 10), func() (any, error) {
return s.penyimpan.Ambil(ctx, id)
})
if err != nil {
return Produk{}, err
}
p := v.(Produk)
s.cache.Simpan(ctx, p, 60*time.Second)
return p, nil
}
Batas 6: domain ke database
// internal/produk/postgres.go
func (r *Postgres) Ambil(ctx context.Context, id int64) (Produk, error) {
// Tenggat khusus kueri — lebih ketat dari tenggat permintaan.
ctx, batal := context.WithTimeout(ctx, 2*time.Second)
defer batal()
var p Produk
err := r.db.QueryRowContext(ctx, `
SELECT id, nama, harga, dibuat
FROM produk
WHERE id = $1 AND dihapus_pada IS NULL`, id,
).Scan(&p.ID, &p.Nama, &p.Harga, &p.Dibuat)
switch {
case errors.Is(err, sql.ErrNoRows):
return Produk{}, fmt.Errorf("produk %d: %w", id, ErrTidakDitemukan)
case err != nil:
return Produk{}, fmt.Errorf("ambil produk %d: %w", id, err)
}
return p, nil
}
Apa yang sebenarnya terjadi di baris QueryRowContext
database/sqlmeminta koneksi dari pool. Kalau semua sedang dipakai danMaxOpenConnstercapai, ia menunggu — dan penungguan itu dibatasictx.- Kueri dikirim sebagai prepared statement dengan parameter terpisah. Inilah yang membuat SQL injection tidak mungkin (Fase 4 dan 6).
- PostgreSQL merencanakan kueri, memakai index kalau ada, dan mengirim barisnya.
Scanmenyalin nilai ke variabel Go, dan koneksi dikembalikan ke pool.- Kalau
ctxkedaluwarsa di tengah, driver mengirim pembatalan ke server dan kueri dihentikan di sisi database juga.
Poin 4 adalah yang paling sering dirusak. Pada QueryContext (banyak baris),
koneksi tidak dikembalikan sampai rows.Close() dipanggil. Sebuah
rows yang lupa ditutup menahan satu koneksi selamanya — dan setelah MaxOpenConns
permintaan seperti itu, seluruh aplikasi berhenti melayani sambil menunggu pool yang tidak akan pernah
kosong. Selalu defer rows.Close().
Perjalanan pulang
Repository → objek domain
Service → aturan diterapkan, cache diisi
Handler → domain dipetakan ke DTO, header + status + body ditulis
Middleware → selesai dari dalam ke luar:
CORS menambah header
Timeout membatalkan context (defer batal())
Log mencatat status + durasi
Recover memastikan tidak ada panic yang lolos
Server → koneksi di-keep-alive untuk permintaan berikutnya
ALB → meneruskan
CloudFront → menyimpan kalau Cache-Control mengizinkan
Anggaran waktu di sepanjang jalur
| Batas | Setelan | Nilai contoh |
|---|---|---|
| CloudFront | Origin response timeout | 30 dtk |
| ALB | Idle timeout | 60 dtk |
| Server Go | ReadTimeout / WriteTimeout | 15 dtk |
| Middleware | context.WithTimeout | 10 dtk |
| Service → API luar | timeout klien HTTP | 3 dtk + retry |
| Repository → database | context.WithTimeout | 2 dtk |
Aturannya: setiap lapisan lebih ketat daripada yang membungkusnya. Kalau timeout database lebih longgar daripada timeout permintaan, klien sudah menyerah sementara kueri tetap berjalan dan tetap memegang koneksi. Di bawah beban, itu berubah jadi lingkaran setan: makin banyak yang menyerah, makin penuh pool, makin lambat semuanya.
Di mana waktunya habis — dan di mana memperbaikinya
| Gejala | Kemungkinan besar | Dibahas di |
|---|---|---|
| p95 tinggi, p50 normal | Antrean pool database, atau GC | Fase 4, 9 |
| Semua endpoint melambat bersamaan | Pool habis, atau satu kueri lambat memblokir | Fase 4 |
| Lambat hanya pada daftar | Index hilang, atau N+1 kueri | Fase 4 |
| Cepat lokal, lambat di AWS | Latensi jaringan × jumlah kueri per permintaan | Fase 4, 10 |
| CPU tinggi tanpa trafik tinggi | Serialisasi JSON, kompresi, atau alokasi berlebihan | Fase 9 |
| Rasio hit CDN rendah | Kunci cache mengandung cookie atau query pelacakan | Fase 9, 10 |
Latihan: gambar ulang peta di atas untuk satu endpoint di aplikasimu sendiri, lalu tulis angka timeout aktual di setiap batas. Tandai batas mana yang belum punya timeout sama sekali — itu daftar pekerjaanmu untuk sisa roadmap ini.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.