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 --> HRace 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:
| Resource | Contoh Konkret |
|---|---|
| Variable di memory | Counter, flag, cache in-process |
| Row di database | Saldo akun, stok barang, status invoice |
| Distributed cache | Key di Redis, Memcached |
| File | Log file, config file yang ditulis bersamaan |
| External state | Kuota 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]
endJalur 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 #
| Penyebab | Mekanisme | Solusi |
|---|---|---|
| Double click | UI tidak disable tombol setelah klik | Disable tombol + idempotency key |
| Client retry | HTTP client timeout dan retry otomatis | Idempotency key dari client |
| Gateway retry | Load balancer retry ke instance lain | Idempotency key + locking |
| Slow consumer | Request masuk lebih cepat dari processing | Rate 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 hilangContoh 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 kemaptanpa 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 Level | Dirty Read | Non-Repeatable Read | Phantom Read | Performa |
|---|---|---|---|---|
| READ UNCOMMITTED | Bisa | Bisa | Bisa | Tertinggi |
| READ COMMITTED | Aman | Bisa | Bisa | Tinggi |
| REPEATABLE READ | Aman | Aman | Bisa | Sedang |
| SERIALIZABLE | Aman | Aman | Aman | Terendah |
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:
| Skenario | Penyebab | Solusi |
|---|---|---|
| Dua instance proses job yang sama | Tidak ada koordinasi | Distributed lock (Redis SetNX) |
| Consumer queue paralel | At-least-once delivery | Idempotency + event_id check |
| Leader election gagal | Network partition | Consensus algorithm (Raft, etcd) |
| Cache stampede | Banyak request saat cache expired bersamaan | Probabilistic 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 DB —
UPDATE ... WHERE stock > 0jauh lebih aman dan lebih simpel daripada read-check-write terpisah.- Distributed lock untuk lintas instance —
Redis SetNXadalah 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 CI —
go test -racemendeteksi 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.