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-raceuntuk 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/atomicuntuk counter numerik dansync.Mutex/sync.RWMutexuntuk 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.