Race Condition #

Race condition adalah salah satu bug paling berbahaya di sistem software modern — bukan karena sulit dipahami secara teori, tapi karena sifatnya yang licin: hampir tidak pernah muncul di unit test, sering tidak muncul di environment development, dan baru menampakkan diri di production saat traffic tinggi. Ketika sudah muncul, efeknya bukan sekadar error yang bisa dilacak — tapi data yang sudah rusak, saldo yang sudah salah, atau resource yang sudah habis terjual padahal stok hanya satu. Panduan ini membahas race condition dari akarnya: apa prinsip yang membuatnya bisa terjadi, di layer mana saja ia bisa muncul dari HTTP hingga distributed system, contoh kode konkret dengan anti-pattern dan solusinya, serta pola-pola pencegahan yang benar-benar bekerja.

Apa Itu Race Condition? #

Race condition adalah kondisi ketika hasil akhir sebuah proses bergantung pada urutan eksekusi beberapa operasi yang berjalan secara concurrent, dan urutan tersebut tidak terkontrol. Dua atau lebih eksekutor “berlomba” mengakses atau memodifikasi resource yang sama — dan siapa yang sampai duluan menentukan hasilnya.

Yang membuat ini berbahaya: kodenya terlihat benar. Tidak ada syntax error, tidak ada null pointer, tidak ada exception. Ia hanya menghasilkan hasil yang salah ketika timing-nya tidak beruntung.

Race condition hampir selalu membutuhkan tiga kondisi sekaligus:

Kondisi 1: Shared mutable state
    Ada data bersama yang bisa dibaca DAN ditulis
    (variabel, row database, cache, file, counter)

Kondisi 2: Lebih dari satu eksekutor
    Thread, goroutine, request HTTP, process,
    atau instance service yang berbeda

Kondisi 3: Operasi non-atomic
    Apa yang terlihat "satu langkah" di level bisnis
    sebenarnya terdiri dari beberapa langkah teknis
    yang bisa diinterupsi di tengah jalan

Hilangkan salah satu dari tiga kondisi ini, dan race condition tidak bisa terjadi. Ini juga yang menjadi dasar semua teknik pencegahannya.


Race Condition di Level HTTP / API #

Banyak engineer berpikir race condition hanya masalah thread dan mutex. Kenyataannya, race condition bisa terjadi di level HTTP bahkan jika backend kamu berjalan single-thread — karena setiap request adalah eksekutor yang independen.

Kasus: Double Submit Payment #

Bayangkan flow pembayaran invoice yang terlihat normal:

1. Client kirim POST /payments
2. Server: SELECT status FROM invoices WHERE id = X → UNPAID
3. Server: proses pembayaran ke payment gateway
4. Server: UPDATE invoices SET status = 'PAID' WHERE id = X

Kodenya benar. Flownya logis. Masalahnya muncul ketika dua request masuk hampir bersamaan — karena user klik tombol dua kali, karena mobile app retry saat timeout, atau karena load balancer meneruskan request yang sama ke dua instance.

# Timeline race condition di HTTP layer

Request A: SELECT status → UNPAID  ← kedua request baca nilai yang sama
Request B: SELECT status → UNPAID

Request A: proses pembayaran ke gateway ✓
Request B: proses pembayaran ke gateway ✗ ← double charge

Request A: UPDATE status = PAID
Request B: UPDATE status = PAID  ← tidak ada efek, tapi kerusakan sudah terjadi

Hasil akhir: invoice “lunas” dua kali. User kehilangan uang dua kali lipat.

Pencegahan di HTTP Layer #

// ANTI-PATTERN: cek status di aplikasi, bukan di database — rentan race
func processPayment(invoiceID int) error {
    invoice, _ := db.GetInvoice(invoiceID)
    if invoice.Status == "UNPAID" {
        // ← jeda di sini cukup untuk request lain masuk
        gateway.Charge(invoice.Amount)
        db.UpdateStatus(invoiceID, "PAID")
    }
    return nil
}

// BENAR: gunakan atomic update dengan kondisi — hanya satu yang berhasil
func processPayment(invoiceID int) error {
    // UPDATE hanya terjadi jika status masih UNPAID
    // Dua request bersamaan: hanya satu yang rows_affected = 1
    result := db.Exec(`
        UPDATE invoices
        SET status = 'PROCESSING'
        WHERE id = ? AND status = 'UNPAID'
    `, invoiceID)

    if result.RowsAffected == 0 {
        return ErrInvoiceAlreadyProcessed
    }

    // Aman dieksekusi — kita sudah "mengunci" invoice ini
    if err := gateway.Charge(invoice.Amount); err != nil {
        db.Exec("UPDATE invoices SET status = 'UNPAID' WHERE id = ?", invoiceID)
        return err
    }

    db.Exec("UPDATE invoices SET status = 'PAID' WHERE id = ?", invoiceID)
    return nil
}

Race Condition di Application / Memory Level #

Di level aplikasi, race condition paling sering terjadi pada shared state yang diakses dari multiple goroutine atau thread tanpa sinkronisasi.

Kasus: Counter dengan Goroutine #

// ANTI-PATTERN: counter++ bukan operasi atomic
var counter int

func increment() {
    counter++ // ← ini sebenarnya 3 operasi: LOAD, ADD, STORE
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }
    wg.Wait()
    fmt.Println(counter) // ← hasilnya bukan 1000, bisa 847, 923, atau angka lain
}

Mengapa ini terjadi: counter++ di level mesin terdiri dari tiga instruksi terpisah — load nilai dari memory, tambahkan satu, store kembali ke memory. Dua goroutine bisa sama-sama membaca nilai 500, keduanya menghitung 501, keduanya menyimpan 501 — satu increment hilang.

// BENAR — pilihan 1: gunakan sync/atomic untuk operasi numerik
var counter int64

func increment() {
    atomic.AddInt64(&counter, 1) // ← satu instruksi atomic, tidak bisa diinterupsi
}

// BENAR — pilihan 2: gunakan mutex untuk critical section yang lebih kompleks
var (
    counter int
    mu      sync.Mutex
)

func increment() {
    mu.Lock()
    defer mu.Unlock()
    counter++ // ← aman karena hanya satu goroutine yang bisa masuk
}

// BENAR — pilihan 3: gunakan channel untuk komunikasi antar goroutine (Go idiom)
func counterWorker(ch <-chan struct{}, result chan<- int) {
    count := 0
    for range ch {
        count++
    }
    result <- count
}

Area yang Paling Sering Terkena #

Race condition di memory level sering muncul di area-area ini yang tidak selalu terlihat obvious:

Area Rawan Race Condition di Memory:
  ✗ In-memory cache yang diupdate dari goroutine berbeda
  ✗ Rate limiter berbasis counter tanpa atomic operation
  ✗ Session store yang diakses concurrent
  ✗ Config hot-reload yang membaca dan menulis map bersamaan
  ✗ Metrics counter (request count, error count, latency sum)
  ✗ Connection pool yang dimanage manual
Go menyediakan race detector bawaan yang sangat berguna: jalankan test dengan flag -race untuk mendeteksi race condition secara otomatis. Contoh: go test -race ./.... Ini tidak gratis dari sisi performa, tapi sangat berharga untuk dijalankan di CI pipeline.

Race Condition di Database Level #

Ini area yang paling sering diremehkan. Engineer yang sudah menggunakan database sering berasumsi bahwa data otomatis aman — padahal tanpa transaction isolation dan locking yang tepat, database pun bisa menjadi sumber race condition.

Kasus: Stok Barang #

-- Flow yang terlihat aman tapi tidak:
-- 1. Baca stok
SELECT stock FROM products WHERE id = 1;  -- stock = 1

-- 2. Cek di aplikasi
-- if stock > 0: lanjut

-- 3. Kurangi stok
UPDATE products SET stock = stock - 1 WHERE id = 1;
Timeline dua transaksi bersamaan:

Tx A: SELECT stock = 1  ─┐
Tx B: SELECT stock = 1  ─┘  ← keduanya baca nilai yang sama

Tx A: (di aplikasi) stock > 0 → lanjut
Tx B: (di aplikasi) stock > 0 → lanjut

Tx A: UPDATE stock = 0  ✓
Tx B: UPDATE stock = 0  ✗ ← barang terjual dua kali, stok jadi negatif secara logika bisnis

Solusinya: jangan pisahkan READ dan WRITE ke dua query berbeda untuk operasi yang perlu atomicity.

-- ANTI-PATTERN: READ di aplikasi, WRITE di query terpisah
-- (seperti contoh di atas — rentan race condition)

-- BENAR — opsi 1: atomic UPDATE dengan kondisi
UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock > 0;
-- Cek rows_affected: jika 0, stok habis

-- BENAR — opsi 2: SELECT FOR UPDATE (pessimistic locking)
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- Baris ini terkunci — Tx lain akan menunggu sampai COMMIT/ROLLBACK
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

Kasus: Lost Update #

Lost update adalah varian race condition yang lebih halus — update yang dilakukan satu transaksi “tertimpa” oleh transaksi lain yang berjalan bersamaan.

// ANTI-PATTERN: read-modify-write pattern tanpa locking
func updateUserProfile(userID int, newBio string) error {
    user, _ := db.GetUser(userID)     // ← Tx A dan Tx B sama-sama baca
    user.Bio = newBio                  // ← Tx A ubah Bio
    user.UpdatedAt = time.Now()
    return db.SaveUser(user)           // ← Tx B overwrite perubahan Tx A
}

// BENAR — optimistic locking: gunakan version/timestamp untuk deteksi konflik
func updateUserProfile(userID int, newBio string, currentVersion int) error {
    result := db.Exec(`
        UPDATE users
        SET bio = ?, updated_at = NOW(), version = version + 1
        WHERE id = ? AND version = ?
    `, newBio, userID, currentVersion)

    if result.RowsAffected == 0 {
        // ✓ Versi tidak cocok — ada update lain yang lebih duluan
        return ErrConflict // client perlu refresh dan coba lagi
    }
    return nil
}

Transaction Isolation Level #

Pemilihan isolation level yang tepat adalah salah satu cara mengontrol race condition di database. Setiap level memberikan trade-off antara konsistensi dan performa:

Isolation Level    Dirty Read   Non-Repeatable   Phantom Read   Performa
─────────────────  ───────────  ───────────────  ─────────────  ────────
READ UNCOMMITTED   Mungkin      Mungkin          Mungkin        Paling cepat
READ COMMITTED     Tidak        Mungkin          Mungkin        Cepat (default di PG)
REPEATABLE READ    Tidak        Tidak            Mungkin        Sedang
SERIALIZABLE       Tidak        Tidak            Tidak          Paling lambat

Jangan selalu mengandalkan default — pilih isolation level berdasarkan kebutuhan operasi, bukan sekadar menggunakan apa yang terpasang secara default di database.


Race Condition di Distributed System #

Di level distributed system, race condition menjadi jauh lebih kompleks karena ada banyak mesin yang beroperasi secara independen dengan clock yang tidak tersinkronisasi sempurna.

Skenario umum yang memicu race condition di level ini:

Skenario 1: Multiple instance memproses job yang sama
    Scheduler di node A dan node B sama-sama melihat job "belum diproses"
    dan keduanya mulai eksekusi → pekerjaan dikerjakan dua kali

Skenario 2: Consumer queue ganda
    Dua consumer mengambil message yang sama (redelivery)
    dan keduanya melakukan write ke resource yang sama

Skenario 3: Caching yang tidak konsisten
    Instance A update data di DB, update cache
    Instance B baca data dari DB (sebelum cache propagate)
    Instance B overwrite cache dengan data lama

Skenario 4: Leader election gagal
    Dua node sama-sama mengklaim diri sebagai leader
    dan keduanya mulai menulis ke sumber data yang sama

Distributed Lock #

Untuk kasus di mana hanya satu eksekutor boleh berjalan dalam satu waktu lintas instance, distributed lock adalah solusinya:

// BENAR: distributed lock dengan Redis untuk job scheduling
func (s *Scheduler) RunDailyReport(jobID string) error {
    lockKey := "lock:daily_report:" + jobID
    lockTTL := 10 * time.Minute

    // Coba acquire lock — hanya satu instance yang berhasil
    acquired, err := s.redis.SetNX(ctx, lockKey, s.instanceID, lockTTL).Result()
    if err != nil {
        return fmt.Errorf("failed to acquire lock: %w", err)
    }
    if !acquired {
        // ✓ Instance lain sudah menjalankan job ini — skip
        return nil
    }

    // Pastikan lock dilepas setelah selesai
    defer s.redis.Del(ctx, lockKey)

    return s.generateDailyReport(jobID)
}
Distributed lock dengan Redis menggunakan SET NX sederhana punya celah: jika instance yang memegang lock crash setelah TTL habis tapi sebelum pekerjaan selesai, lock akan diambil instance lain dan pekerjaan berjalan dua kali. Untuk use case kritis (finansial, inventory), pertimbangkan Redlock algorithm atau solusi seperti etcd/ZooKeeper yang menawarkan stronger consistency guarantees.

Pola Pencegahan Race Condition #

Tidak ada satu solusi universal untuk race condition — pola yang tepat bergantung pada layer di mana race terjadi.

1. Atomic Operation #

Gunakan operasi yang tidak bisa diinterupsi di tengah jalan. Ini adalah solusi paling efisien karena tidak membutuhkan koordinasi eksternal.

// ✓ Atomic di level database
UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0

// ✓ Atomic di level Go
atomic.AddInt64(&counter, 1)
atomic.CompareAndSwapInt32(&state, oldVal, newVal)

// ✓ Atomic di level Redis
INCR counter
SETNX key value

2. Locking #

Untuk operasi yang terlalu kompleks untuk dijadikan atomic, gunakan lock untuk memastikan hanya satu eksekutor yang masuk ke critical section.

// ✓ Mutex untuk critical section di memory
var mu sync.RWMutex

func readData() Data {
    mu.RLock()         // multiple reader boleh masuk bersamaan
    defer mu.RUnlock()
    return sharedData
}

func writeData(d Data) {
    mu.Lock()          // hanya satu writer, semua reader diblokir
    defer mu.Unlock()
    sharedData = d
}

Perlu diingat: lock bukan solusi gratis. Ada tiga risiko yang harus dikelola — penurunan performa karena kontention, deadlock jika urutan lock tidak konsisten, dan starvation jika ada eksekutor yang selalu kalah dalam memperebutkan lock.

3. Optimistic Locking #

Untuk kasus di mana konflik jarang terjadi, optimistic locking lebih efisien karena tidak memblokir siapapun — hanya mendeteksi konflik saat terjadi dan meminta retry.

// ✓ Optimistic locking dengan version column
func transferBalance(fromID, toID int, amount float64, version int) error {
    result := db.Exec(`
        UPDATE accounts
        SET balance = balance - ?, version = version + 1
        WHERE id = ? AND version = ? AND balance >= ?
    `, amount, fromID, version, amount)

    if result.RowsAffected == 0 {
        return ErrConflictOrInsufficientBalance
    }
    // lanjutkan credit ke toID
    return nil
}

4. Design for Concurrency #

Solusi paling elegant adalah mendesain sistem agar race condition tidak bisa terjadi secara struktural:

Strategi desain yang menghilangkan race condition secara fundamental:

  Immutable data
    Jika data tidak bisa diubah setelah dibuat,
    tidak ada yang perlu dilindungi dari concurrent access.

  Append-only log (Event Sourcing)
    State direkonstruksi dari urutan event yang immutable.
    Write selalu append, tidak pernah update-in-place.

  Single writer per resource
    Hanya satu service/goroutine yang boleh menulis ke resource tertentu.
    Semua write diproses secara sequential oleh satu aktor.
    (Pola Actor model, e.g. Erlang/Akka)

  Channel-based communication (Go)
    "Don't communicate by sharing memory;
     share memory by communicating."

Checklist: Deteksi Race Condition Sejak Desain #

Gunakan pertanyaan ini saat merancang fitur baru yang melibatkan data yang bisa diubah:

IDENTIFIKASI RISIKO:
  □ Apakah ada data yang bisa dibaca DAN ditulis dari lebih dari satu request?
  □ Apakah ada scheduler atau job yang bisa berjalan di lebih dari satu instance?
  □ Apakah ada consumer queue yang berjalan paralel?
  □ Apakah operasi ini terdiri dari lebih dari satu query database?

PENCEGAHAN:
  □ Operasi numerik kritis (stok, saldo) menggunakan atomic UPDATE di DB
  □ SELECT FOR UPDATE digunakan sebelum modifikasi row yang dikompetisikan
  □ Shared state di memory dilindungi dengan mutex atau atomic
  □ Job/scheduler menggunakan distributed lock jika multi-instance
  □ Consumer queue didesain idempotent untuk handle redelivery

TESTING:
  □ Jalankan go test -race (atau equivalent) di CI pipeline
  □ Load test dengan concurrent request untuk operasi kritis
  □ Test skenario retry dan double-submit secara eksplisit

Ringkasan #

  • Race condition terjadi saat hasil akhir bergantung pada urutan eksekusi yang tidak terkontrol — butuh tiga kondisi: shared mutable state, lebih dari satu eksekutor, dan operasi non-atomic.
  • Race bukan hanya masalah thread — ia bisa terjadi di level HTTP request, goroutine, database transaction, hingga lintas instance di distributed system.
  • Di HTTP layer: gunakan atomic UPDATE dengan kondisi (WHERE status = 'UNPAID') daripada read-then-write dua langkah.
  • Di memory: gunakan sync/atomic untuk counter numerik dan sync.Mutex/sync.RWMutex untuk critical section yang lebih kompleks.
  • Di database: jangan percaya bahwa database otomatis aman — gunakan SELECT FOR UPDATE, atomic UPDATE, atau optimistic locking sesuai kebutuhan.
  • Di distributed system: gunakan distributed lock (Redis SET NX, etcd, ZooKeeper) untuk operasi yang hanya boleh dijalankan satu instance dalam satu waktu.
  • Optimistic locking efisien untuk kasus konflik jarang — tandai dengan version column, tolak jika versi tidak cocok.
  • Design for concurrency: immutable data, append-only log, dan single-writer pattern menghilangkan race condition secara struktural.
  • Race condition adalah bug deterministik yang tergantung timing — bukan “random bug”. Antisipasi sejak desain, bukan setelah production mati.

← Sebelumnya: Idempotency   Berikutnya: Replay Strategy →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact