Race Condition #

Race condition adalah salah satu bug paling berbahaya di dunia software engineering — bukan karena susah diperbaiki, tapi karena susah dideteksi. Ia hampir tidak pernah muncul di lingkungan development, lolos dari semua unit test, dan baru muncul di production saat traffic tinggi. Ketika muncul pun, efeknya bukan error yang jelas — melainkan data yang diam-diam korup: saldo jadi minus, stok barang terjual lebih dari yang tersedia, atau satu user mendapat akses yang seharusnya milik user lain. Artikel ini membahas race condition dari akar prinsipnya, di layer mana saja ia bisa terjadi, contoh konkret di HTTP, memory, database, dan distributed system, serta pola pencegahan yang bisa langsung diterapkan.

Apa Itu Race Condition? #

Race condition adalah kondisi ketika hasil akhir sebuah proses bergantung pada urutan eksekusi beberapa operasi yang berjalan bersamaan, dan urutan tersebut tidak terkontrol. Dua atau lebih proses “berlomba” mengakses atau memodifikasi resource yang sama — dan siapa yang “menang” menentukan state akhir sistem.

Yang membuat race condition berbahaya adalah sifatnya yang non-deterministik: kode yang persis sama bisa menghasilkan output berbeda tergantung timing eksekusi di CPU, beban sistem, atau jumlah koneksi yang masuk secara bersamaan. Ini bukan random bug — ini bug deterministik yang bergantung pada timing.

flowchart TD
    A[Dua Request Masuk Bersamaan] --> B[Request A: Baca saldo = 100]
    A --> C[Request B: Baca saldo = 100]
    B --> D[Request A: Kurangi 100]
    C --> E[Request B: Kurangi 100]
    D --> F[Request A: Simpan saldo = 0]
    E --> G[Request B: Simpan saldo = 0]
    F --> H[❌ Saldo harusnya -100\ntapi tersimpan 0]
    G --> H

Race condition terjadi ketika tiga kondisi hadir bersamaan: ada shared resource, ada lebih dari satu eksekutor (thread, goroutine, request, process), dan tidak ada mekanisme sinkronisasi yang benar.


Tiga Prinsip Pemicu Race Condition #

Memahami tiga prinsip ini adalah kunci untuk mengantisipasi race condition sejak fase desain, bukan setelah production rusak.

Shared Mutable State #

Race condition selalu melibatkan data bersama yang bisa berubah. Jika data hanya dibaca (read-only), tidak ada race. Jika data hanya diakses satu proses, tidak ada race. Masalah muncul ketika ada shared resource yang bisa di-write oleh lebih dari satu eksekutor.

Shared mutable state yang paling umum:

ResourceContoh Konkret
Variable di memoryCounter, flag, cache in-process
Row di databaseSaldo akun, stok barang, status invoice
Distributed cacheKey di Redis, Memcached
FileLog file, config file yang ditulis bersamaan
External stateKuota API, status third-party service

Concurrency atau Parallelism #

Race condition tidak mungkin terjadi di sistem single-thread yang benar-benar sequential. Ia muncul ketika ada lebih dari satu jalur eksekusi yang bisa berjalan “bersamaan” — baik secara fisik (parallelism di multi-core) maupun secara logis (concurrency lewat context switching).

flowchart LR
    subgraph Sequential["Sequential — Tidak Bisa Race"]
        S1[Request A] --> S2[Proses A] --> S3[Request B] --> S4[Proses B]
    end
    subgraph Concurrent["Concurrent — Bisa Race"]
        C1[Request A] --> C3[Proses A\n& Proses B\nberjalan overlap]
        C2[Request B] --> C3
        C3 --> C4[State akhir\ntidak terprediksi]
    end

Jalur eksekusi yang bisa memicu race: multi-thread, multi-goroutine, multiple HTTP request bersamaan, consumer queue paralel, dan multiple instance service di distributed system.

Non-Atomic Operation #

Ini akar teknis dari sebagian besar race condition. Operasi yang terlihat “satu langkah” di level bisnis sebenarnya terdiri dari beberapa langkah teknis yang bisa diinterupsi.

// Terlihat seperti satu operasi:
counter++

// Sebenarnya tiga langkah yang bisa diinterupsi di antara ketiganya:
// 1. LOAD  — baca nilai counter dari memory
// 2. ADD   — tambahkan 1
// 3. STORE — tulis nilai baru ke memory

// Goroutine B bisa baca nilai lama di antara LOAD dan STORE goroutine A

Begitu pula dengan pola read-check-write yang sangat umum di kode bisnis:

1. Baca saldo dari database  ← Goroutine B juga baca di sini
2. Periksa saldo >= jumlah
3. Kurangi saldo
4. Simpan saldo baru         ← Dua goroutine menyimpan hasil berbeda

Jika langkah-langkah ini tidak atomic — artinya tidak terjamin berjalan tanpa interupsi — race condition hampir pasti muncul.


Race Condition di Level HTTP / API #

Banyak engineer mengira race condition hanya terjadi di level thread atau goroutine. Padahal race condition bisa muncul di level HTTP meskipun backend kamu single-thread, selama ada lebih dari satu request yang diproses bersamaan.

Kasus: Double Submit Payment #

Skenario paling klasik: user mengklik tombol “Bayar” dua kali karena respons lambat, atau mobile client melakukan retry otomatis karena timeout jaringan. Dua HTTP request masuk hampir bersamaan ke endpoint yang sama.

sequenceDiagram
    participant ClientA as Request A\n(klik pertama)
    participant ClientB as Request B\n(klik kedua / retry)
    participant Server
    participant DB

    ClientA->>Server: POST /payments
    ClientB->>Server: POST /payments
    Server->>DB: SELECT status WHERE invoice_id=1
    Server->>DB: SELECT status WHERE invoice_id=1
    DB-->>Server: status = UNPAID
    DB-->>Server: status = UNPAID
    Note over Server: Kedua request melihat UNPAID
    Server->>DB: Proses pembayaran A
    Server->>DB: Proses pembayaran B ❌
    DB-->>Server: OK
    DB-->>Server: OK
    Note over DB: Invoice dibayar dua kali!
// ANTI-PATTERN: check-then-act tanpa proteksi concurrency
func processPayment(invoiceID string, amount int) error {
    invoice, _ := db.FindInvoice(invoiceID)
    
    if invoice.Status == "PAID" {
        return errors.New("already paid")
    }
    
    // GAP DI SINI — request lain bisa lolos check di atas
    
    db.CreatePayment(invoiceID, amount)
    db.UpdateInvoiceStatus(invoiceID, "PAID")
    return nil
}

// BENAR: gunakan atomic update dengan WHERE clause yang jadi guard
func processPayment(invoiceID string, amount int) error {
    // UPDATE hanya berhasil jika status masih UNPAID — atomic di DB level
    result := db.Exec(`
        UPDATE invoices 
        SET status = 'PAID' 
        WHERE id = ? AND status = 'UNPAID'
    `, invoiceID)
    
    if result.RowsAffected == 0 {
        return errors.New("invoice already paid or not found")
    }
    
    return db.CreatePayment(invoiceID, amount)
}

Penyebab Umum di HTTP Layer #

PenyebabMekanismeSolusi
Double clickUI tidak disable tombol setelah klikDisable tombol + idempotency key
Client retryHTTP client timeout dan retry otomatisIdempotency key dari client
Gateway retryLoad balancer retry ke instance lainIdempotency key + locking
Slow consumerRequest masuk lebih cepat dari processingRate limiting + queue

Race Condition di Application / Memory Level #

Di level aplikasi, race condition terjadi ketika goroutine atau thread berbagi state di memory tanpa sinkronisasi yang benar.

Kasus: Counter yang Tidak Akurat #

// ANTI-PATTERN: variable counter diakses oleh banyak goroutine tanpa mutex
var requestCount int

func handleRequest(w http.ResponseWriter, r *http.Request) {
    requestCount++ // TIDAK AMAN — bukan operasi atomic
    // proses request...
}

// Dengan 1000 goroutine concurrent:
// Hasil akhir requestCount bisa 847, 923, atau angka lain yang tidak terprediksi
// Bergantung pada timing CPU scheduling

// BENAR: gunakan sync/atomic untuk operasi counter
var requestCount int64

func handleRequest(w http.ResponseWriter, r *http.Request) {
    atomic.AddInt64(&requestCount, 1) // Benar-benar atomic, aman dari race
    // proses request...
}

// Atau gunakan mutex untuk blok kode yang lebih kompleks
var (
    mu           sync.Mutex
    activeUsers  = make(map[string]bool)
)

func addActiveUser(userID string) {
    mu.Lock()
    defer mu.Unlock()
    activeUsers[userID] = true // Aman — hanya satu goroutine di sini
}
sequenceDiagram
    participant G1 as Goroutine 1
    participant G2 as Goroutine 2
    participant Mem as Memory\ncounter=5

    G1->>Mem: LOAD counter (baca 5)
    G2->>Mem: LOAD counter (baca 5)
    G1->>G1: ADD 1 → hasil = 6
    G2->>G2: ADD 1 → hasil = 6
    G1->>Mem: STORE 6
    G2->>Mem: STORE 6
    Note over Mem: counter = 6, bukan 7!
    Note over G1,G2: Satu increment hilang

Contoh Nyata di Produksi #

Race condition di memory level sering muncul dalam skenario ini:

// ANTI-PATTERN: concurrent write ke map tanpa mutex — crash di Go runtime
var cache = make(map[string]string)

func getFromCache(key string) string {
    // Concurrent read sebenarnya aman, tapi jika ada write bersamaan: FATAL
    return cache[key]
}

func setCache(key, value string) {
    cache[key] = value // Concurrent write ke map = panic
}

// BENAR: gunakan sync.RWMutex untuk read-heavy cache
var (
    cacheMu sync.RWMutex
    cache   = make(map[string]string)
)

func getFromCache(key string) string {
    cacheMu.RLock() // Multiple reader boleh bersamaan
    defer cacheMu.RUnlock()
    return cache[key]
}

func setCache(key, value string) {
    cacheMu.Lock() // Hanya satu writer, block semua reader
    defer cacheMu.Unlock()
    cache[key] = value
}
Di Go, concurrent write ke map tanpa mutex tidak hanya menghasilkan data korup — tapi langsung panic di runtime. Go race detector (go test -race) sangat dianjurkan dijalankan di CI pipeline untuk mendeteksi race condition sebelum ke production.

Race Condition di Database Level #

Ini yang paling sering diremehkan. Banyak engineer berasumsi bahwa karena sudah menggunakan database, data otomatis aman dari race condition. Asumsi ini keliru.

Kasus: Stok Barang Minus #

-- ANTI-PATTERN: read-check-write terpisah tanpa locking
-- Transaksi A dan B bisa keduanya baca stok = 1 sebelum salah satu update

-- Transaksi A
SELECT stock FROM products WHERE id = 1;  -- stock = 1
-- (Transaksi B juga SELECT di sini, mendapat stock = 1)

-- Aplikasi: if stock > 0 → boleh beli

-- Transaksi A
UPDATE products SET stock = stock - 1 WHERE id = 1;  -- stock = 0

-- Transaksi B (juga lolos check karena baca stock = 1 tadi)
UPDATE products SET stock = stock - 1 WHERE id = 1;  -- stock = -1 ❌
-- BENAR: atomic update dengan WHERE guard, tidak perlu SELECT terpisah
UPDATE products 
SET stock = stock - 1 
WHERE id = 1 AND stock > 0;

-- Cek rows_affected: jika 0, berarti stok habis atau race terjadi
-- Tidak ada celah di antara check dan update — satu operasi atomic
// Implementasi di Go dengan cek rows affected
func purchaseProduct(productID int) error {
    result := db.Exec(
        "UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0",
        productID,
    )
    
    if result.Error != nil {
        return result.Error
    }
    
    if result.RowsAffected == 0 {
        // ANTI-PATTERN: return generic error
        return errors.New("failed")
        
        // BENAR: return error yang informatif
        return errors.New("product out of stock")
    }
    
    return nil
}

Kasus: Lost Update #

Lost update adalah bentuk race condition database yang lebih halus — dua transaksi membaca nilai yang sama, mengubahnya berdasarkan nilai tersebut, dan salah satu update “hilang” karena ditimpa.

sequenceDiagram
    participant TxA as Transaksi A
    participant TxB as Transaksi B
    participant DB

    TxA->>DB: SELECT login_count WHERE user_id=1 → 10
    TxB->>DB: SELECT login_count WHERE user_id=1 → 10
    TxA->>TxA: Hitung: 10 + 1 = 11
    TxB->>TxB: Hitung: 10 + 1 = 11
    TxA->>DB: UPDATE login_count = 11
    TxB->>DB: UPDATE login_count = 11
    Note over DB: login_count = 11, bukan 12!
    Note over TxA,TxB: Satu increment hilang — Lost Update
-- ANTI-PATTERN: read di aplikasi, hitung di aplikasi, write ke DB
-- Rentan terhadap lost update

-- BENAR: operasi increment langsung di database — atomic
UPDATE users SET login_count = login_count + 1 WHERE id = 1;

-- Atau untuk kasus yang butuh nilai sebelumnya:
-- Gunakan SELECT FOR UPDATE untuk lock row
BEGIN;
SELECT login_count FROM users WHERE id = 1 FOR UPDATE;
-- Sekarang row ini locked — transaksi lain akan wait
UPDATE users SET login_count = login_count + 1 WHERE id = 1;
COMMIT;

Isolation Level dan Dampaknya #

Database menyediakan isolation level yang mengontrol seberapa terlindungi transaksi dari efek transaksi lain yang berjalan bersamaan:

Isolation LevelDirty ReadNon-Repeatable ReadPhantom ReadPerforma
READ UNCOMMITTEDBisaBisaBisaTertinggi
READ COMMITTEDAmanBisaBisaTinggi
REPEATABLE READAmanAmanBisaSedang
SERIALIZABLEAmanAmanAmanTerendah

Default PostgreSQL adalah READ COMMITTED. Default MySQL/InnoDB adalah REPEATABLE READ. Pahami default database yang kamu gunakan dan sesuaikan per transaksi jika diperlukan.


Race Condition di Distributed System #

Di distributed system, race condition melampaui batas satu proses atau satu mesin. Ini level paling kompleks karena tidak ada shared memory — sinkronisasi harus dilakukan lewat network, yang inherently tidak reliable.

flowchart TD
    A[Job Scheduler] --> B[Instance 1\nMemproses Job X]
    A --> C[Instance 2\nMemproses Job X]
    B --> D{Distributed Lock\nTersedia?}
    C --> D
    D -- Lock berhasil → Instance 1 --> E[Instance 1 proses]
    D -- Lock gagal → Instance 2 --> F[Instance 2 tunggu / skip]
    E --> G[Release lock]
    G --> H[Instance 2 bisa proses job berikutnya]
// ANTI-PATTERN: tidak ada koordinasi antar instance
// Jika 3 instance berjalan, job yang sama bisa diproses 3 kali
func processScheduledJob(jobID string) {
    job := db.FindJob(jobID)
    if job.Status == "PENDING" {
        executeJob(job)
        db.UpdateJobStatus(jobID, "COMPLETED")
    }
}

// BENAR: gunakan distributed lock sebelum proses
func processScheduledJob(jobID string) error {
    lockKey := fmt.Sprintf("job-lock:%s", jobID)
    
    // Coba ambil lock dengan TTL — atomic di Redis
    acquired, err := redis.SetNX(ctx, lockKey, "locked", 30*time.Second)
    if err != nil || !acquired {
        // Instance lain sedang memproses job ini
        return nil
    }
    defer redis.Del(ctx, lockKey) // Lepas lock setelah selesai
    
    job := db.FindJob(jobID)
    if job.Status != "PENDING" {
        return nil // Sudah diproses instance lain
    }
    
    return executeJob(job)
}

Skenario distributed race condition yang paling umum:

SkenarioPenyebabSolusi
Dua instance proses job yang samaTidak ada koordinasiDistributed lock (Redis SetNX)
Consumer queue paralelAt-least-once deliveryIdempotency + event_id check
Leader election gagalNetwork partitionConsensus algorithm (Raft, etcd)
Cache stampedeBanyak request saat cache expired bersamaanProbabilistic early expiration / mutex per key

Pola Pencegahan Race Condition #

Tidak ada silver bullet untuk race condition. Pilih solusi berdasarkan layer tempat race terjadi.

1. Atomic Operation #

Jadikan operasi yang rentan race sebagai satu langkah atomic yang tidak bisa diinterupsi.

// Memory level: gunakan sync/atomic
atomic.AddInt64(&counter, 1)
atomic.CompareAndSwapInt64(&value, old, new)

// Database level: biarkan DB yang hitung
db.Exec("UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0", id)
db.Exec("UPDATE users SET login_count = login_count + 1 WHERE id = ?", id)

2. Locking #

Gunakan lock untuk melindungi critical section — blok kode yang mengakses shared resource.

// In-process: sync.Mutex atau sync.RWMutex
var mu sync.Mutex
mu.Lock()
// critical section — hanya satu goroutine di sini
mu.Unlock()

// Database: SELECT FOR UPDATE
// BEGIN; SELECT ... FOR UPDATE; UPDATE ...; COMMIT;

// Distributed: Redis SetNX
redis.SetNX(ctx, lockKey, "locked", ttl)
Lock adalah solusi, bukan jaminan keamanan tanpa biaya. Lock yang tidak dilepas menyebabkan deadlock. Lock yang terlalu lebar membunuh throughput. Lock yang terlalu sempit tidak melindungi semua path. Selalu ukur dampak performa setelah menambahkan lock di hot path.

3. Optimistic Locking #

Alih-alih lock di awal, deteksi konflik saat akan menyimpan. Efektif untuk kasus read-heavy dengan write conflict yang jarang.

// ANTI-PATTERN: tanpa version check — lost update bisa terjadi
func updateUserProfile(userID string, data ProfileData) error {
    return db.Exec("UPDATE users SET name=?, bio=? WHERE id=?",
        data.Name, data.Bio, userID)
}

// BENAR: optimistic locking dengan version column
func updateUserProfile(userID string, data ProfileData, currentVersion int) error {
    result := db.Exec(`
        UPDATE users 
        SET name=?, bio=?, version=version+1 
        WHERE id=? AND version=?
    `, data.Name, data.Bio, userID, currentVersion)
    
    if result.RowsAffected == 0 {
        // Version tidak cocok — ada yang update duluan
        return errors.New("conflict: data was modified by another process, please refresh")
    }
    return nil
}

4. Transaction dan Isolation Level yang Tepat #

Pilih isolation level sesuai kebutuhan, bukan selalu default.

-- Untuk operasi yang butuh konsistensi tinggi (transfer saldo)
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT balance FROM accounts WHERE id = ? FOR UPDATE;
UPDATE accounts SET balance = balance - ? WHERE id = ?;
UPDATE accounts SET balance = balance + ? WHERE id = ?;
COMMIT;

-- Untuk operasi read-heavy yang toleran terhadap stale data
-- READ COMMITTED sudah cukup dan lebih performant

5. Desain yang Menghindari Shared State #

Solusi terbaik adalah tidak punya masalah dari awal. Desain sistem yang meminimalkan shared mutable state.

// ANTI-PATTERN: satu counter global yang diakses semua
var globalOrderCounter int

// BENAR: gunakan database sequence atau UUID — tidak perlu shared state
orderID := uuid.New().String()
// atau
orderNumber, _ := db.QueryRow("SELECT nextval('order_seq')").Scan(&orderNumber)

Anti-Pattern yang Harus Dihindari #

// ✗ Check-then-act tanpa atomicity
stock := db.GetStock(productID)  // baca
if stock > 0 {                   // check
    db.DecrementStock(productID) // act — race window di sini
}
// ✓ Atomic update dengan WHERE guard
db.Exec("UPDATE products SET stock = stock - 1 WHERE id = ? AND stock > 0", productID)

// ✗ Asumsi bahwa DB transaction otomatis mencegah race
db.Transaction(func(tx *DB) error {
    user := tx.FindUser(id)           // baca
    user.Balance -= amount            // ubah di memory
    return tx.Save(user)              // simpan — masih bisa lost update!
})
// ✓ Gunakan locking eksplisit di dalam transaction
db.Transaction(func(tx *DB) error {
    var balance int
    tx.QueryRow("SELECT balance FROM users WHERE id = ? FOR UPDATE", id).Scan(&balance)
    if balance < amount { return errors.New("insufficient balance") }
    return tx.Exec("UPDATE users SET balance = balance - ? WHERE id = ?", amount, id)
})

// ✗ Distributed scheduler tanpa koordinasi
func runEvery5Minutes() {
    job()  // Semua instance jalan bersamaan
}
// ✓ Distributed lock sebelum eksekusi
func runEvery5Minutes() {
    if acquireDistributedLock("scheduler-job", 5*time.Minute) {
        defer releaseLock("scheduler-job")
        job()
    }
}

Checklist Review Race Condition #

IDENTIFIKASI:
  □ Semua shared resource (DB, cache, memory) sudah teridentifikasi
  □ Semua write operation sudah diperiksa apakah bisa diakses concurrent
  □ Pola read-check-write sudah diidentifikasi di seluruh codebase

APPLICATION LEVEL:
  □ Semua access ke shared map menggunakan sync.RWMutex
  □ Counter menggunakan sync/atomic atau mutex
  □ Tidak ada global variable yang bisa dimodifikasi concurrent tanpa lock
  □ Go: CI pipeline menjalankan go test -race

DATABASE LEVEL:
  □ Operasi increment/decrement langsung di SQL (tidak via aplikasi)
  □ UPDATE kritikal menggunakan WHERE guard (AND stock > 0, AND status = ?)
  □ Row locking (SELECT FOR UPDATE) digunakan di transaksi yang butuh konsistensi
  □ Isolation level dipilih berdasarkan kebutuhan, bukan hanya default

DISTRIBUTED SYSTEM:
  □ Scheduled job menggunakan distributed lock
  □ Consumer queue bersifat idempotent dengan event_id check
  □ Tidak ada asumsi "hanya satu instance yang jalan"

TESTING:
  □ Ada test concurrent (multiple goroutine) untuk operasi kritikal
  □ Race detector diaktifkan di CI
  □ Load test dijalankan untuk memicu race condition yang timing-dependent

Ringkasan #

  • Race condition adalah bug timing — terjadi ketika dua proses mengakses shared resource bersamaan tanpa koordinasi, dan hasilnya bergantung pada siapa yang “menang”.
  • Tiga syarat pemicu — shared mutable state + lebih dari satu eksekutor + operasi non-atomic. Hilangkan salah satu, race condition tidak bisa terjadi.
  • Tidak hanya soal thread — race condition bisa terjadi di level HTTP (double submit), database (lost update, phantom read), dan distributed system (job duplikat).
  • Database bukan peluru perak — transaction tidak otomatis mencegah race condition. Isolation level, SELECT FOR UPDATE, dan atomic SQL tetap diperlukan.
  • Atomic SQL adalah solusi terbaik untuk DBUPDATE ... WHERE stock > 0 jauh lebih aman dan lebih simpel daripada read-check-write terpisah.
  • Distributed lock untuk lintas instanceRedis SetNX adalah cara paling umum untuk koordinasi di distributed system.
  • Optimistic locking untuk konflik jarang — version column di database efektif untuk kasus read-heavy di mana konflik jarang terjadi.
  • Go race detector wajib di CIgo test -race mendeteksi race condition yang tidak terlihat di test biasa.
  • Desain tanpa shared state adalah solusi terbaik — UUID, DB sequence, dan event sourcing menghilangkan kebutuhan koordinasi dari awal.

← Sebelumnya: Idempotency   Berikutnya: Replay Strategy →

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