Gejala Nyata: Payload Tertukar Antar-User

Pada aplikasi Go Fiber dengan beban lalu lintas tinggi, bug konkurensi sering kali muncul bukan sebagai panic langsung, melainkan sebagai korupsi data yang tersembunyi (silent corruption). Gejala umum yang sering dilaporkan:

  • User A menerima respon atau notifikasi berisi data milik User B.
  • Payload yang disimpan ke database melalui background task terpotong atau berisi fragmen payload request lain.
  • Log aplikasi menampilkan header Authorization atau field JSON yang tidak konsisten secara acak saat traffic melonjak.

Penyebab utama dari anomali ini adalah passing pointer fiber.Ctx atau memory slice bawaannya langsung ke dalam background goroutine tanpa isolasi memori.

Root Cause: Arsitektur Zero-Allocation fasthttp

Go Fiber dibangun di atas engine fasthttp, bukan standar net/http. Perbedaan arsitektur fundamental keduanya terletak pada alokasi memori:

net/http mengalokasikan struct http.Request baru untuk setiap request masuk dan membiarkan Garbage Collector (GC) membersihkannya. Sebaliknya, fasthttp mengejar performa zero-allocation dengan memanfaatkan sync.Pool secara agresif untuk me-recycle instance context dan buffer byte pendukungnya.

Siklus hidup memori pada Go Fiber berjalan sebagai berikut:

  1. Koneksi TCP masuk, fasthttp mengambil objek RequestCtx dan buffer memori dari sync.Pool.
  2. Handler Fiber mengeksekusi request. Semua operasi pembacaan seperti c.Body(), c.Params(), dan c.Query() mengembalikan slice byte yang merujuk langsung ke buffer memori internal milik fasthttp, bukan salinan baru.
  3. Segera setelah fungsi handler utama me-return nilai (misalnya return c.SendStatus(200)), Fiber menganggap siklus request selesai.
  4. Instance context dan buffer memori terkait di-reset (di-zero out) dan dikembalikan ke sync.Pool.
  5. Buffer yang sama dialokasikan kembali untuk melayani request HTTP berikutnya dari koneksi atau client lain.

Jika handler mengeksekusi background goroutine yang masih memegang pointer *fiber.Ctx atau slice dari c.Body(), goroutine tersebut akan membaca area memori yang sedang ditulis ulang oleh request baru. Hal ini memicu race condition dan data cross-contamination antar-request.

Contoh Kasus Buggy Code

Kode di bawah ini menunjukkan kesalahan umum: memproses data di background goroutine dengan mempertahankan referensi ke c *fiber.Ctx.

package main

import (
	"log"
	"time"

	"github.com/gofiber/fiber/v2"
)

func RegisterRoutes(app *fiber.App) {
	app.Post("/api/audit", func(c *fiber.Ctx) error {
		// BUG: c.Body() mengembalikan slice langsung ke buffer fasthttp.
		// c *fiber.Ctx juga di-recycle segera setelah handler return.
		go func() {
			time.Sleep(50 * time.Millisecond) // Mensimulasikan I/O / DB latency
			payload := c.Body()
			log.Printf("Processed payload: %s\n", string(payload))
		}()

		return c.SendStatus(fiber.StatusAccepted)
	})
}

Reproduksi Bug Menggunakan go test -race

Bug ini dapat dibuktikan secara deterministik menggunakan pengujian konkurensi dengan Go race detector bawaan runtime.

package main

import (
	"bytes"
	"net/http/httptest"
	"sync"
	"testing"

	"github.com/gofiber/fiber/v2"
)

func TestDataRaceContext(t *testing.T) {
	app := fiber.New()
	RegisterRoutes(app)

	var wg sync.WaitGroup
	concurrentRequests := 10

	for i := 0; i < concurrentRequests; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			req := httptest.NewRequest("POST", "/api/audit", bytes.NewBufferString("payload-user-"+string(rune(id))))
			resp, err := app.Test(req, -1)
			if err != nil {
				t.Errorf("Request failed: %v", err)
			}
			resp.Body.Close()
		}(i)
	}

	wg.Wait()
}

Jalankan pengujian menggunakan flag -race:

go test -race -v -run TestDataRaceContext

Runtime Go akan langsung mendeteksi pelanggaran akses memori dan mencetak trace data race:

WARNING: DATA RACE
Write at 0x00c0001a4000 by goroutine 8:
  github.com/valyala/fasthttp.(*RequestCtx).Reset()
...
Previous Read at 0x00c0001a4000 by goroutine 12:
  main.RegisterRoutes.func1.1()
...
Found 1 data race(s)
FAIL

Solusi dan Pola Perbaikan

1. Menggunakan c.Copy() untuk Context Duplication

Fiber menyediakan method c.Copy() yang mengembalikan kloningan context independen yang aman dari siklus recycle sync.Pool.

app.Post("/api/audit", func(c *fiber.Ctx) error {
	// Deep copy context sebelum masuk ke goroutine
	cSafe := c.Copy()

	go func() {
		time.Sleep(50 * time.Millisecond)
		log.Printf("Processed payload: %s\n", string(cSafe.Body()))
	}()

	return c.SendStatus(fiber.StatusAccepted)
})
Peringatan performa: c.Copy() menduplikasi seluruh context. Menggunakan cara ini secara masif membatalkan keuntungan zero-allocation dari Fiber dan menambah beban kerja Garbage Collector.

2. Deep Copy Byte Slice dan String Utility

Jika background process hanya membutuhkan payload atau query string tertentu, salin hanya data yang diperlukan sebelum return. Hindari menyalin seluruh objek context.

import (
	"bytes"
	"github.com/gofiber/fiber/v2/utils"
)

app.Post("/api/audit", func(c *fiber.Ctx) error {
	// Kloning byte slice (Go 1.20+)
	bodyPayload := bytes.Clone(c.Body())
	
	// Salin string immutable jika mengambil dari c.Params atau c.Query
	actionType := utils.CopyString(c.Query("action"))

	go func(payload []byte, action string) {
		time.Sleep(50 * time.Millisecond)
		log.Printf("Action: %s, Payload: %s\n", action, string(payload))
	}(bodyPayload, actionType)

	return c.SendStatus(fiber.StatusAccepted)
})

3. Best Practice: Worker Pool Decoupled dari Lifecycle HTTP

Pendekatan arsitektur yang paling bersih dan efisien adalah memisahkan HTTP layer dengan asynchronous processing menggunakan domain DTO dan buffered channel / worker pool. Request langsung diurai ke struct Go sebelum dilempar ke worker.

type AuditJob struct {
	UserID  string
	Payload []byte
}

type JobQueue struct {
	queue chan AuditJob
}

func (jq *JobQueue) Submit(job AuditJob) {
	jq.queue <- job
}

func StartWorker(queue chan AuditJob) {
	go func() {
		for job := range queue {
			// Processing logic berjalan terisolasi dari memori fasthttp
			log.Printf("Processing job user: %s, payload: %s\n", job.UserID, string(job.Payload))
		}
	}()
}

func SetupSecureRoute(app *fiber.App, jobs *JobQueue) {
	app.Post("/api/audit", func(c *fiber.Ctx) error {
		// 1. Ekstraksi data dan lepas dari buffer fasthttp
		job := AuditJob{
			UserID:  utils.CopyString(c.Get("X-User-ID")),
			Payload: bytes.Clone(c.Body()),
		}

		// 2. Kirim ke decoupled worker
		jobs.Submit(job)

		return c.SendStatus(fiber.StatusAccepted)
	})
}

Panduan Pemilihan Solusi

  • Gunakan bytes.Clone() / utils.CopyString() jika payload kecil dan pemrosesan async bersifat lokal pada satu fungsi.
  • Gunakan Decoupled Worker (Channel/Queue) jika pemrosesan async merupakan bagian dari core pipeline bisnis (logging, database write, event dispatcher). Ini adalah standar produksi yang paling aman.
  • Gunakan c.Copy() hanya untuk backward compatibility atau debugging cepat ketika refactoring signature fungsi belum memungkinkan.