Race Condition

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 pertama
  • rows_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
About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact