Pengantar dan Ringkasan Solusi

Setelah traffic spike, backend Go Fiber kami mengalami lonjakan latency p95 hingga 2,5 detik walaupun CPU dan throughput tetap stabil. Gejala ini langsung mempengaruhi user experience API internal. Artikel ini menjelaskan bagaimana kami mereproduksi, menganalisis, dan memperbaiki bug eksponensial yang tersembunyi di handler goroutine, hingga mencegahnya dari sisi observability dan arsitektur.

Solusi berfokus pada kombinasi observability (profiling, tracing, metrik), debugging analitik, dan refactor handler agar tidak membuat goroutine tak terkendali atau channel yang menunggu indefinitely I/O tanpa timeout.

1. Gejala dan Observability

Langsung setelah traffic spike (~500 req/s), latency API melonjak, sementara:

  • p95 latency meningkat dari 120ms menjadi >2s.
  • CPU Node tetap di bawah 70%.
  • Garbage collection frequency tidak berubah signifikan.
  • Connection pool ke database tampak tidak penuh.

Metrik observability yang digunakan:

  • Latency histogram dari Fiber middleware.
  • Thread/ goroutine count via runtime/pprof.
  • Channel queue length dari instrumentation internal.
  • Trace distribusi menggunakan OpenTelemetry untuk melihat durasi handler dan downstream.

Gejala utamanya: latency naik seiring jumlah goroutine aktif juga meningkat secara eksponensial tanpa turun meskipun request selesai.

2. Reproduksi Kasus

Untuk memastikan masalah bukan faktor eksternal, kami menghimpun traffic control dengan wrk:

wrk -t4 -c100 -d60s http://api.internal/service

Perhatikan bahwa latency naik drastis pada 15 detik pertama traffic spike, dan goroutine count dari pprof terus naik. Kami menambahkan endpoint debug sederhana yang mengembalikan jumlah goroutine agar terus dipantau.

Reproduksi juga mencakup pengiriman payload besar karena handler melakukan I/O ke service downstream tanpa timeout. Saat service downstream slow, goroutine menunggu indefinitely.

3. Analisis Root Cause

Melakukan pprof heap dan goroutine mengungkap pola:

  • Handler memulai goroutine baru per request dan mengirim data ke channel buffered kecil.
  • Channel tersebut diproses oleh worker loop tanpa timeout atau back-pressure.
  • Jika downstream tidak merespon cepat, goroutine tetap menunggu di channel select dengan blocking call.

Contoh simplifikasi handler yang bermasalah:

func handler(c *fiber.Ctx) error {
    data := c.Body()
    responseCh := make(chan result)
    go func() {
        res := callDownstream(data)
        responseCh <- res
    }()

    select {
    case res := <-responseCh:
        return c.JSON(res)
    case <-c.Context().Done():
        return fiber.ErrRequestTimeout
    }
}

Problema adalah goroutine launch per request tanpa batas dan callDownstream bisa blocking lama. Jika callDownstream tidak responsif, goroutine tetap hidup dan channel menunggu, menimbulkan latency cumulative. Selain itu, tidak ada batas waktu sebelum panggilan goroutine berlangsung, sehingga spike traffic memicu ribuan goroutine.

4. Langkah Perbaikan Praktis

4.1 Profiling dan Tracing

Kami menggunakan:

  • pprof goroutine untuk melihat stack goroutine yang tertahan.
  • pprof block profile untuk mengetahui channel blocking.
  • OpenTelemetry trace untuk melihat dependency tree per request.

Profiling mengkonfirmasi goroutine menumpuk pada select tanpa timeout, dan trace memastikan bottleneck ada di downstream call.

4.2 Refactor Handler

Perbaikan melibatkan:

  • Menghindari goroutine baru: cukup jalankan panggilan I/O di goroutine utama handler karena Fiber sudah asynchronous.
  • Menambahkan context dengan timeout dan membatalkan panggilan jika melebihi batas.
  • Memanfaatkan worker pool terbatas jika asynchronous tetap dibutuhkan.

Perubahan kode:

func handler(c *fiber.Ctx) error {
    ctx, cancel := context.WithTimeout(c.Context(), 2*time.Second)
    defer cancel()

    res, err := callDownstreamWithContext(ctx, c.Body())
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return fiber.ErrRequestTimeout
        }
        return fiber.ErrInternalServerError
    }
    return c.JSON(res)
}

Panggilan callDownstreamWithContext sekarang menghormati ctx sehingga jika downstream lambat, handler menghentikan operasi dan tidak menciptakan goroutine menumpuk.

4.3 Pengaturan Timeout dan Back-pressure

Selain timeout handler, kami memasang:

  • Rate limiter di Fiber middleware untuk mencegah spike langsung memicu worker pool.
  • Channel buffered dengan kapasitas terbatas jika strategi worker pool digunakan, agar request ditolak saat queue penuh.
  • Instrumentasi queue length ke Observability untuk alert ketika melebihi threshold.

5. Pencegahan dan Praktik Observability

Rekomendasi jangka panjang:

  1. Terapkan timeouts kaidan konteks di semua panggilan I/O, termasuk HTTP client dan database query.
  2. Gunakan profil goroutine secara berkala untuk mendeteksi goroutine leak.
  3. Pantau latency histogram dan queue depth untuk menandai pola eksponensial.
  4. Siapkan alert ketika goroutine count naik di luar baseline.
  5. Sediakan retry dengan jitter pada panggilan downstream yang bisa diulang.

Debugging kasual ternyata menunjukkan bahwa kenaikan latency bukan soal CPU atau DB, melainkan akumulasi goroutine yang menunggu. Dengan observability yang tepat, bug eksponensial ini dapat dideteksi lebih awal sebelum berdampak ke user.