Pada arsitektur backend game trivia interaktif dengan ribuan klien mobile aktif secara bersamaan, endpoint pencatatan skor sangat rentan terhadap anomali data. Masalah umum yang sering muncul pada framework web performa tinggi seperti Go Fiber adalah hilangnya data pembaruan (lost updates) atau kalkulasi skor ganda ketika aplikasi mobile mengirim beberapa request penyelesaian kuis dalam jeda beberapa milidetik.
Gejala Masalah: Desinkronisasi Leaderboard dan Duplikasi Skor
Kasus ini bermula saat pengguna klien mobile (misalnya aplikasi iOS/SwiftUI) menyelesaikan sesi kuis bertema Alan Turing. Di bawah koneksi jaringan seluler yang fluktuatif, mekanisme retry otomatis pada klien atau klik berulang pada tombol submit memicu request HTTP POST paralel ke backend Go Fiber:
- Skor total pemain melompat tidak wajar atau justru berkurang dari akumulasi poin yang seharusnya diperoleh.
- Leaderboard menampilkan posisi yang tidak konsisten saat di-refresh oleh pemain lain.
- Log aplikasi mencatat respons
200 OKuntuk semua request, namun nilai akhir di basis data tidak mencerminkan jumlah total akumulasi dari payload yang masuk.
Root Cause: Siklus Read-Modify-Write Tanpa Isolasi Konkurensi
Fiber menjalankan setiap HTTP request di dalam goroutine terpisah. Ketika dua goroutine menangani request pembaruan skor untuk pengguna yang sama secara bersamaan, terjadi race condition tipe read-modify-write.
Misalkan skor awal pemain adalah 100. Request A bernilai +50 poin dan Request B bernilai +30 poin masuk secara paralel:
- Goroutine A membaca skor pemain dari basis data:
100. - Goroutine B membaca skor pemain dari basis data sebelum A menyimpan data baru:
100. - Goroutine A menghitung
100 + 50 = 150, lalu menulis nilai150ke database. - Goroutine B menghitung
100 + 30 = 130, lalu menulis nilai130ke database.
Nilai akhir di database adalah 130. Penambahan 50 poin dari Request A hilang sepenuhnya (lost update).
Kode Bermasalah (Penyebab Race Condition)
// Handler bermasalah: Read-Modify-Write non-atomik
func SubmitScoreHandler(c *fiber.Ctx) error {
type Request struct {
UserID string `json:"user_id"`
Score int64 `json:"score"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "payload tidak valid"})
}
var currentScore int64
// 1. READ
err := db.QueryRow(c.Context(), "SELECT total_score FROM player_scores WHERE user_id = $1", req.UserID).Scan(¤tScore)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db read error"})
}
// 2. MODIFY di memori Go
newScore := currentScore + req.Score
// 3. WRITE
_, err = db.Exec(c.Context(), "UPDATE player_scores SET total_score = $1, updated_at = NOW() WHERE user_id = $2", newScore, req.UserID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db write error"})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{"total_score": newScore})
}
Solusi Implementasi: Redis Atomic vs PostgreSQL Lock
Untuk menghilangkan celah race condition, kalkulasi harus didelegasikan ke layer database yang mendukung atomisitas murni atau menggunakan mekanisme locking eksplisit.
Opsi 1: Atomic Increment di Redis (Sangat Cocok untuk Real-Time Leaderboard)
Redis bersifat single-threaded dalam eksekusi instruksinya, sehingga perintah seperti HINCRBY atau ZINCRBY bersifat atomik tanpa memerlukan lock aplikasi tambahan.
func SubmitScoreRedisHandler(c *fiber.Ctx) error {
type Request struct {
UserID string `json:"user_id"`
Score float64 `json:"score"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "payload tidak valid"})
}
// Eksekusi atomik ZINCRBY langsung di Redis Sorted Set
newScore, err := rdb.ZIncrBy(c.Context(), "trivia:leaderboard", req.Score, req.UserID).Result()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "redis error"})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"user_id": req.UserID,
"total_score": newScore,
})
}
Opsi 2: PostgreSQL SELECT FOR UPDATE atau Single-Statement UPDATE
Jika state harus langsung presisten di PostgreSQL relasional, hindari read-modify-write di layer aplikasi Go. Gunakan single statement update atomik atau transaksi dengan pesimistic locking (FOR UPDATE).
// Solusi direct database update: atomic di SQL engine
func SubmitScoreSQLHandler(c *fiber.Ctx) error {
type Request struct {
UserID string `json:"user_id"`
Score int64 `json:"score"`
}
var req Request
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "payload tidak valid"})
}
var updatedScore int64
// Eksekusi single query atomic update dengan klausa RETURNING
query := `
UPDATE player_scores
SET total_score = total_score + $1, updated_at = NOW()
WHERE user_id = $2
RETURNING total_score;
`
err := db.QueryRow(c.Context(), query, req.Score, req.UserID).Scan(&updatedScore)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "update failed"})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"user_id": req.UserID,
"total_score": updatedScore,
})
}
Verifikasi Menggunakan k6
Gunakan pengujian beban konkurensi (concurrent stress test) untuk memverifikasi apakah race condition sudah terselesaikan. Skrip k6 berikut mengirimkan 100 request simultan untuk satu ID user dengan nilai masing-masing 10 poin. Hasil akhir skor harus tepat bernilai 1.000 poin.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
concurrent_score_submit: {
executor: 'per-vu-iterations',
vus: 50,
iterations: 2,
maxDuration: '10s',
},
},
};
export default function () {
const url = 'http://localhost:3000/api/v1/score/submit';
const payload = JSON.stringify({
user_id: 'usr-turing-42',
score: 10,
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
const res = http.post(url, payload, params);
check(res, {
'status is 200': (r) => r.status === 200,
});
}
Setelah k6 selesai dijalankan, lakukan validasi manual ke database atau periksa respons query terakhir:
SELECT user_id, total_score FROM player_scores WHERE user_id = 'usr-turing-42';
Jika implementasi non-atomik digunakan, nilai total_score akan bernilai jauh di bawah 1.000 karena benturan goroutine. Dengan penerapan single-query update atau Redis atomic commands, nilai total_score konsisten tepat di angka 1.000.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!