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 OK untuk 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:

  1. Goroutine A membaca skor pemain dari basis data: 100.
  2. Goroutine B membaca skor pemain dari basis data sebelum A menyimpan data baru: 100.
  3. Goroutine A menghitung 100 + 50 = 150, lalu menulis nilai 150 ke database.
  4. Goroutine B menghitung 100 + 30 = 130, lalu menulis nilai 130 ke 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(&currentScore)
    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.