Race Condition #
Race condition adalah salah satu masalah paling klasik sekaligus paling berbahaya dalam sistem backend dan database. Masalah ini sering tidak muncul di local, sulit direproduksi, dan baru terlihat saat traffic naik. Ketika sudah muncul, dampaknya bisa sangat serius: data korup, status tidak konsisten, double processing, hingga kerugian finansial.
Artikel ini akan membahas:
- Apa itu race condition
- Bagaimana race condition terjadi di level database
- Contoh kasus nyata
- Best practice untuk menangani race condition di database
Artikel ini difokuskan pada sudut pandang database dan backend engineer, bukan sekadar definisi teoritis.
Apa Itu Race Condition #
Race condition terjadi ketika:
Hasil akhir dari sebuah operasi bergantung pada urutan eksekusi beberapa proses/thread/request yang berjalan secara bersamaan (concurrent).
Jika urutan eksekusinya berubah, maka hasilnya juga bisa berubah — dan sering kali tidak sesuai ekspektasi.
Secara sederhana:
- Ada shared resource (database row, file, memory, counter)
- Ada lebih dari satu actor (thread, request, worker)
- Mereka mengakses atau memodifikasi resource yang sama
- Tidak ada mekanisme sinkronisasi yang benar
Race Condition di Level Database #
Banyak developer mengira database “aman” dari race condition karena sifatnya transactional. Kenyataannya, database justru salah satu tempat paling sering terjadi race condition.
Race condition di database biasanya muncul dalam bentuk:
- Double insert
- Double update
- Status yang meloncat (invalid state transition)
- Overwrite data tanpa disadari
- Idempotency failure
Kenapa Database Rentan Race Condition? #
Beberapa penyebab umum:
- Banyak request paralel (HTTP, job, worker)
- Logic bisnis dilakukan di luar transaction
- Query bersifat read → logic → write tanpa proteksi
- Salah memilih isolation level
- Tidak ada locking yang eksplisit
Contoh Kasus Race Condition di Database #
Check-Then-Insert (Classic Race Condition) #
SELECT COUNT(*) FROM orders WHERE order_id = 'X';
-- hasil = 0
INSERT INTO orders (order_id, status) VALUES ('X', 'NEW');
Jika dua request menjalankan logic ini secara bersamaan:
- Keduanya melihat
COUNT = 0 - Keduanya melakukan
INSERT - Terjadi duplicate data
UNIQUE constraint bisa membantu, tapi tanpa handling yang benar, tetap bisa jadi bug.
Lost Update #
SELECT balance FROM accounts WHERE id = 1; -- balance = 100
UPDATE accounts SET balance = 100 - 10 WHERE id = 1;
Jika dua transaksi berjalan paralel:
- Keduanya membaca balance = 100
- Keduanya mengurangi 10
- Balance akhir = 90 (seharusnya 80)
Ini disebut lost update.
Status Processing yang Tidak Idempotent #
UPDATE orders
SET status = 'PROCESSING'
WHERE id = 10;
Jika dua worker memproses order yang sama:
- Worker A update ke PROCESSING
- Worker B juga update ke PROCESSING
- Keduanya lanjut memproses
Akibatnya:
- Double charge
- Double email
- Double event
Race Condition vs Isolation Level #
Isolation level seperti:
- READ COMMITTED
- REPEATABLE READ
- SERIALIZABLE
Tidak otomatis menyelesaikan semua race condition.
Contoh:
- READ COMMITTED masih bisa lost update
- REPEATABLE READ tidak selalu mencegah double update
- SERIALIZABLE aman tapi sangat mahal untuk performa
Artinya: isolation level bukan solusi utama, hanya alat tambahan.
Best Practice Menangani Race Condition di Database #
Gunakan Atomic Operation (Single Query) #
Hindari pola:
SELECT → IF → UPDATE
Ganti dengan:
UPDATE orders
SET status = 'PROCESSING'
WHERE id = 10 AND status = 'NEW';
Lalu cek:
rows_affected > 0→ request pertamarows_affected = 0→ request kedua dan seterusnya
Ini adalah best practice utama.
Manfaatkan Pessimistic Locking (FOR UPDATE) #
BEGIN;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- logic
UPDATE orders SET status = 'DONE' WHERE id = 10;
COMMIT;
Gunakan jika:
- Logic kompleks
- Perlu konsistensi kuat
⚠️ Catatan penting:
- Jangan taruh logic berat di dalam transaction
- Transaction lama = locking lama = bottleneck
Gunakan Optimistic Locking (Versioning) #
Tambahkan kolom:
version INT
Update:
UPDATE orders
SET status = 'DONE', version = version + 1
WHERE id = 10 AND version = 3;
Jika rows_affected = 0:
- Data sudah berubah
- Client harus retry atau abort
Cocok untuk:
- High read, low write
- Minim conflict
Pastikan Idempotency di Level Database #
Gunakan:
- Unique key (idempotency_key)
- Conditional update
- Insert with constraint + proper error handling
Contoh:
INSERT INTO payments (idempotency_key, order_id)
VALUES ('abc', 10)
ON CONFLICT DO NOTHING;
Jangan Mengandalkan Application Lock Saja #
Lock di application (mutex, redis lock) tidak cukup jika:
- Ada lebih dari satu instance
- Ada retry dari client
- Ada multiple consumer
Database harus tetap menjadi last line of defense.
Gunakan Constraint sebagai Safety Net #
- UNIQUE constraint
- FOREIGN KEY
- CHECK constraint
Constraint:
- Murah
- Aman
- Dieksekusi langsung oleh database
Logic aplikasi boleh salah, constraint tidak boleh salah.
Anti-Pattern yang Harus Dihindari #
- ❌ Logic bisnis berat di dalam transaction
- ❌ SELECT tanpa lock lalu UPDATE
- ❌ Mengandalkan delay atau sleep
- ❌ Retry tanpa batas
- ❌ Mengunci table untuk masalah row-level
Kesimpulan #
Race condition di database adalah masalah:
- Nyata
- Sering terjadi
- Sulit dideteksi
Solusinya bukan satu teknik, tapi kombinasi:
- Atomic query
- Conditional update
- Locking yang tepat
- Constraint database
- Idempotency
Prinsip utamanya:
Lebih baik satu query yang deterministik daripada seribu baris logic yang terlihat aman.
Jika mau, artikel ini bisa dilanjutkan ke topik:
- Race condition di level HTTP
- Perbandingan optimistic vs pessimistic locking
- Studi kasus race condition di sistem pembayaran
- Implementasi konkret di Golang + PostgreSQL