Race Condition #

Race condition adalah jenis bug yang paling licin di dunia backend. Dia tidak muncul di local development karena di sana hanya ada satu request pada satu waktu. Dia tidak muncul di staging karena traffic-nya terlalu kecil. Dia baru muncul di produksi, ketika puluhan atau ratusan request menabrak titik yang sama secara bersamaan — dan bahkan ketika muncul pun, dia tidak selalu meninggalkan jejak yang jelas di log. Yang tertinggal hanya data yang salah: saldo yang tidak berkurang dua kali padahal transaksi terjadi dua kali, stok yang menjadi negatif padahal ada validasi, atau dua user terdaftar dengan email yang sama padahal ada pengecekan duplikat di kode.

Inilah yang membuat race condition berbahaya: bukan karena sulit diperbaiki, tapi karena sulit ditemukan. Sistem terlihat berjalan normal. Tidak ada error di log. Tidak ada exception yang terlempar. Hanya data yang pelan-pelan tidak bisa dipercaya — dan kepercayaan yang hilang itu jauh lebih mahal dari bug apapun yang melempar exception.

Artikel ini membahas race condition dari sudut pandang database — di mana ia paling sering terjadi, bagaimana bentuk-bentuknya yang konkret dengan timeline visualisasi, dan apa teknik yang benar untuk mengatasinya.

Mengapa Database Adalah Medan Utama Race Condition #

Banyak developer mengira database aman dari race condition karena bersifat transactional. Anggapan ini sebagian benar — tapi hanya sebagian. Database memang menyediakan mekanisme untuk mencegah race condition, tapi mekanisme itu tidak aktif secara default untuk semua skenario. Kamu harus tahu kapan dan bagaimana menggunakannya.

Race condition di database hampir selalu mengikuti pola yang sama: read, lalu act berdasarkan hasil read itu, lalu write. Di antara read dan write itulah jendela waktu yang rawan. Jika proses lain membaca data yang sama di jendela waktu itu, keduanya akan bertindak berdasarkan data yang identik — dan salah satu dari tindakan itu akan menghasilkan state yang tidak valid.

Pola umum yang rentan race condition:

  Thread A:  READ data ─────→ [jendela rawan] ─────→ WRITE hasil kalkulasi
  Thread B:               READ data ─────────────────────────→ WRITE hasil kalkulasi

  Jika Thread B membaca sebelum Thread A selesai menulis,
  keduanya bertindak berdasarkan data yang sama → salah satu update hilang.

Yang memperparah situasi ini di era modern: hampir semua backend berjalan dengan banyak instance secara bersamaan, banyak worker yang memproses job queue, dan ribuan user yang mengirim request di detik yang sama. Race condition yang sebelumnya sangat jarang terpicu bisa terjadi puluhan kali per menit seiring traffic naik — dan seiring itulah data yang rusak terakumulasi.


Tiga Bentuk Race Condition yang Paling Umum #

Check-Then-Act: Jebakan yang Terlihat Aman #

Bentuk race condition paling klasik. Kode memeriksa kondisi tertentu, lalu bertindak berdasarkan hasil pemeriksaan itu. Masalahnya: antara memeriksa dan bertindak, kondisi bisa sudah berubah oleh proses lain yang berjalan paralel.

Contoh yang paling umum ditemukan: validasi duplikat di application layer sebelum insert.

-- ANTI-PATTERN: check-then-insert tanpa proteksi database

-- Step 1: cek apakah email sudah ada
SELECT COUNT(*) FROM users WHERE email = '[email protected]';
-- Hasil: 0 → "aman" untuk insert

-- Step 2: insert user baru
INSERT INTO users (email, name) VALUES ('[email protected]', 'Ali');

Terlihat aman jika hanya ada satu request. Tapi ini yang terjadi ketika dua request masuk hampir bersamaan:

Timeline dua request concurrent — check-then-act:

  Waktu   Request A                           Request B
  ──────  ──────────────────────────────────  ──────────────────────────────────
  T=0ms   SELECT COUNT(*) → hasil: 0
  T=1ms                                       SELECT COUNT(*) → hasil: 0
  T=2ms   INSERT ([email protected]) ✓
  T=3ms                                       INSERT ([email protected]) ✓
  T=4ms   → dua baris dengan email yang sama di database
            padahal logika aplikasi sudah "mengecek"

Keduanya melihat COUNT = 0 sebelum salah satu sempat commit. Keduanya melanjutkan ke INSERT. Hasilnya adalah duplikat data yang seharusnya tidak pernah ada.

Solusi yang benar bukan menambah lebih banyak validasi di application layer — itu hanya memperkecil jendela waktu, tidak menutupnya. Solusi yang benar adalah membuat database yang menjaga uniqueness, bukan kode aplikasi:

-- BENAR: uniqueness dijaga oleh database, bukan hanya application layer

-- Tambahkan UNIQUE constraint
ALTER TABLE users ADD UNIQUE KEY uk_users_email (email);

-- Sekarang jika dua insert terjadi bersamaan:
-- Insert pertama berhasil.
-- Insert kedua mendapat: Duplicate entry '[email protected]' for key 'uk_users_email'
-- Tangkap error ini di aplikasi dan kembalikan response "email sudah terdaftar".

-- Atau gunakan INSERT ... ON DUPLICATE KEY untuk menangani dengan lebih elegan:
INSERT INTO users (email, name)
VALUES ('[email protected]', 'Ali')
ON DUPLICATE KEY UPDATE name = name; -- no-op jika duplikat
-- rows_affected = 1 → insert baru berhasil
-- rows_affected = 0 → sudah ada, tidak ada yang berubah

Pola yang sama berlaku untuk banyak konteks lain: pengecekan stok sebelum order, validasi voucher sebelum digunakan, pengecekan kapasitas event sebelum registrasi. Semua pola “cek dulu baru act” rentan terhadap race condition jika tidak ada pengaman di level database.

Lost Update: Perubahan yang Hilang Begitu Saja #

Lost update terjadi ketika dua proses membaca data yang sama, masing-masing menghitung perubahan berdasarkan nilai yang dibaca, lalu keduanya menulis hasil perhitungan mereka. Salah satu update akan menimpa yang lain — dan update yang ditimpa itu hilang seolah tidak pernah terjadi.

Skenario paling mudah dipahami adalah pengurangan saldo:

-- ANTI-PATTERN: read-modify-write di application layer tanpa locking

-- Transaksi A (tarik tunai Rp 10.000):
SELECT balance FROM wallets WHERE user_id = 1;  -- balance = 100.000
-- [hitung di application: 100.000 - 10.000 = 90.000]
UPDATE wallets SET balance = 90000 WHERE user_id = 1;

-- Transaksi B (pembayaran Rp 30.000) — berjalan hampir bersamaan:
SELECT balance FROM wallets WHERE user_id = 1;  -- balance = 100.000
-- [hitung di application: 100.000 - 30.000 = 70.000]
UPDATE wallets SET balance = 70000 WHERE user_id = 1;
Timeline lost update:

  Waktu   Transaksi A                         Transaksi B
  ──────  ───────────────────────────────────  ───────────────────────────────────
  T=0ms   SELECT balance → 100.000
  T=1ms                                        SELECT balance → 100.000
  T=2ms   UPDATE balance = 90.000 ✓
  T=3ms                                        UPDATE balance = 70.000 ✓
  T=4ms   → saldo akhir di database: 70.000
            seharusnya:              60.000  (100.000 - 10.000 - 30.000)
            selisih:                 10.000  → uang "tercipta" dari udara

Keduanya berhasil. Tidak ada error. Tidak ada exception. Tapi saldo akhir salah — dan dalam konteks sistem finansial, selisih ini adalah kerugian nyata yang muncul entah di sisi pengguna atau di sisi bisnis.

Double Processing: Satu Pekerjaan Dikerjakan Dua Kali #

Bentuk ketiga ini khususnya umum di sistem yang menggunakan job queue atau message broker. Dua worker mengambil job yang sama secara bersamaan, keduanya melihat status job sebagai “belum diproses”, keduanya memprosesnya — dan hasilnya adalah double processing: email terkirim dua kali, pembayaran diproses dua kali, notifikasi muncul ganda.

-- ANTI-PATTERN: worker mengambil job tanpa atomic claim

-- Worker A:
SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1;
-- mendapat job_id = 42, status = 'pending'
UPDATE jobs SET status = 'processing' WHERE id = 42;

-- Worker B (berjalan hampir di waktu yang sama):
SELECT * FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1;
-- mendapat job_id = 42 juga — karena Worker A belum commit saat B membaca
UPDATE jobs SET status = 'processing' WHERE id = 42;

-- Hasil: dua worker memproses job yang sama

Isolation Level: Bukan Perisai Lengkap #

Salah satu kesalahpahaman yang paling umum: “kita sudah pakai transaction, pasti aman dari race condition.” Transaction dengan isolation level tertentu memang mencegah beberapa jenis anomali — tapi tidak semuanya.

Empat isolation level dan anomali yang masih bisa terjadi:

┌──────────────────────┬─────────────┬──────────────────┬──────────────┬─────────────┐
│ Isolation Level      │ Dirty Read  │ Non-Repeatable   │ Phantom Read │ Lost Update │
│                      │             │ Read             │              │             │
├──────────────────────┼─────────────┼──────────────────┼──────────────┼─────────────┤
│ READ UNCOMMITTED     │ Bisa terjadi│ Bisa terjadi     │ Bisa terjadi │ Bisa terjadi│
│ READ COMMITTED       │ Dicegah     │ Bisa terjadi     │ Bisa terjadi │ Bisa terjadi│
│ REPEATABLE READ      │ Dicegah     │ Dicegah          │ Bisa terjadi │ Bisa terjadi│
│ SERIALIZABLE         │ Dicegah     │ Dicegah          │ Dicegah      │ Dicegah     │
└──────────────────────┴─────────────┴──────────────────┴──────────────┴─────────────┘

Catatan: MySQL InnoDB REPEATABLE READ mencegah beberapa kasus lost update
untuk operasi UPDATE murni, tapi tidak untuk pola read-compute-write
di application layer seperti contoh saldo di atas.

SERIALIZABLE memang mencegah semua anomali — tapi dengan biaya yang sangat tinggi. Database harus memastikan setiap transaction berjalan seolah tidak ada transaction lain yang berjalan bersamaan. Di sistem dengan high concurrency, ini bisa menurunkan throughput secara dramatis.

Kesimpulannya: isolation level adalah satu lapis pertahanan, bukan satu-satunya. Kamu perlu teknik yang lebih presisi sesuai dengan jenis race condition yang dihadapi.


Empat Teknik untuk Mengatasi Race Condition #

1. Atomic Operation — Hilangkan Jendela Waktu #

Cara paling elegan untuk mengatasi race condition adalah menghilangkan jendela waktunya sama sekali. Jika read dan write bisa dilakukan dalam satu query tunggal yang atomic, tidak ada celah bagi proses lain untuk masuk di antara keduanya.

-- ANTI-PATTERN: read di application → hitung → write terpisah
SELECT balance FROM wallets WHERE user_id = 1;     -- jendela waktu terbuka di sini
-- [kalkulasi di application layer]
UPDATE wallets SET balance = [hasil] WHERE user_id = 1;

-- BENAR: kalkulasi langsung di database dalam satu query atomic

-- Pengurangan saldo dengan validasi:
UPDATE wallets
SET balance = balance - 10000
WHERE user_id = 1
  AND balance >= 10000;
-- rows_affected = 1 → berhasil, saldo cukup
-- rows_affected = 0 → saldo tidak cukup, tolak transaksi

-- Claim job dari queue secara atomic — hanya satu worker yang berhasil:
UPDATE jobs
SET status = 'processing',
    worker_id = 'worker-A',
    started_at = NOW()
WHERE id = 42
  AND status = 'pending';
-- rows_affected = 1 → worker ini yang memproses job
-- rows_affected = 0 → job sudah diambil worker lain, cari job berikutnya

-- Increment counter tanpa race condition:
UPDATE articles SET view_count = view_count + 1 WHERE id = 99;

Kuncinya: database mengeksekusi satu query ini secara atomic. Tidak ada jendela waktu antara baca dan tulis. Proses lain hanya bisa mengeksekusi query yang sama sebelum atau setelah — tidak bisa di tengah-tengah.

Pola conditional update — WHERE id = X AND status = 'kondisi_yang_diharapkan' — adalah teknik paling simpel dan paling efektif untuk mengatasi race condition di sebagian besar kasus. Selalu cek rows_affected setelah update: jika 0, artinya kondisi tidak terpenuhi dan proses lain sudah mengubah data lebih dulu.

2. Pessimistic Locking — Kunci Dulu, Proses Kemudian #

Pessimistic locking mengasumsikan bahwa conflict pasti akan terjadi, jadi lebih baik mengunci data sejak awal. Ini dilakukan dengan SELECT ... FOR UPDATE yang mengunci baris yang dipilih sampai transaction selesai. Proses lain yang mencoba mengunci baris yang sama akan menunggu.

-- BENAR: pessimistic locking dengan SELECT FOR UPDATE
START TRANSACTION;

-- Lock baris ini. Proses lain yang SELECT FOR UPDATE pada baris yang sama
-- akan diblokir sampai transaction ini COMMIT atau ROLLBACK.
SELECT balance FROM wallets WHERE user_id = 1 FOR UPDATE;

-- Sekarang aman untuk logika bisnis yang kompleks di application layer —
-- tidak ada proses lain yang bisa mengubah baris ini.
-- [validasi saldo, hitung fee, tentukan nilai transfer, dll]

UPDATE wallets SET balance = balance - 10000 WHERE user_id = 1;

COMMIT;
-- Lock dilepas. Proses yang menunggu baru bisa berjalan dengan data terbaru.
Visualisasi pessimistic locking:

  Waktu   Transaksi A                         Transaksi B
  ──────  ───────────────────────────────────  ───────────────────────────────────
  T=0ms   BEGIN
  T=1ms   SELECT ... FOR UPDATE → lock ✓
  T=2ms                                        BEGIN
  T=3ms                                        SELECT ... FOR UPDATE → [MENUNGGU]
  T=4ms   [proses bisnis]
  T=5ms   UPDATE balance
  T=6ms   COMMIT → lock dilepas
  T=7ms                                        SELECT ... FOR UPDATE → lock ✓
  T=8ms                                        [proses bisnis dengan data terbaru]
  T=9ms                                        UPDATE balance
  T=10ms                                       COMMIT
Jangan pernah melakukan HTTP call ke service eksternal, operasi file, atau komputasi berat di dalam transaction yang sedang memegang lock. Semakin lama transaction terbuka, semakin lama proses lain menunggu. Di sistem dengan high concurrency, lock yang terbuka 2-3 detik bisa menciptakan antrian panjang yang berujung pada timeout cascade.

3. Optimistic Locking — Deteksi Conflict Setelah Terjadi #

Optimistic locking mengambil pendekatan berlawanan: tidak ada yang dikunci di awal. Setiap proses bebas membaca dan memproses. Tapi sebelum menulis, proses harus membuktikan bahwa data belum berubah sejak ia membacanya. Jika sudah berubah, write ditolak.

Cara kerjanya: tambahkan kolom version ke tabel. Setiap update menaikkan version. Saat update, sertakan version yang dibaca sebagai kondisi — jika version sudah berubah, ada proses lain yang lebih dulu menulis.

-- Setup kolom version
ALTER TABLE wallets ADD COLUMN version INT NOT NULL DEFAULT 0;

-- Step 1: baca data beserta version-nya
SELECT balance, version FROM wallets WHERE user_id = 1;
-- balance = 100.000, version = 5

-- [proses bisnis di application layer]

-- Step 2: update dengan menyertakan version sebagai syarat
UPDATE wallets
SET balance    = 90000,
    version    = version + 1
WHERE user_id  = 1
  AND version  = 5;                   -- hanya berhasil jika version masih 5

-- Cek rows_affected:
-- = 1 → berhasil, tidak ada conflict
-- = 0 → version sudah berubah, proses lain lebih cepat → retry atau error
Visualisasi optimistic locking:

  Waktu   Proses A                            Proses B
  ──────  ───────────────────────────────────  ───────────────────────────────────
  T=0ms   SELECT balance=100k, version=5
  T=1ms                                        SELECT balance=100k, version=5
  T=2ms   UPDATE ... WHERE version=5 ✓         (version jadi 6)
  T=3ms                                        UPDATE ... WHERE version=5 ✗
                                               rows_affected = 0
                                               → conflict terdeteksi → retry
Pilih teknik locking yang tepat berdasarkan situasi:

  Situasi                                          Teknik yang Tepat
  ─────────────────────────────────────────────    ──────────────────────────
  Logika bisa diringkas dalam satu query           Atomic Operation (terbaik)
  Logika kompleks, conflict rate tinggi            Pessimistic Locking
  Logika kompleks, conflict rate rendah            Optimistic Locking
  Operasi tidak boleh terjadi dua kali             Idempotency Key

4. Idempotency Key — Proteksi dari Double Processing #

Untuk operasi yang tidak boleh terjadi dua kali — pembayaran, pengiriman email, pembuatan order — idempotency key adalah mekanisme yang paling andal. Client mengirimkan unique key bersama setiap request. Server menyimpan key ini setelah operasi berhasil. Request duplikat yang membawa key yang sama akan mendapat respons yang sama tanpa operasi dieksekusi ulang.

-- Setup tabel idempotency key
CREATE TABLE idempotency_keys (
    key_value    VARCHAR(255)    NOT NULL,
    response     JSON            NOT NULL,
    created_at   TIMESTAMP       NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY  (key_value)
);

-- Alur di application layer untuk setiap request:

-- Step 1: coba "klaim" idempotency key
INSERT INTO idempotency_keys (key_value, response)
VALUES ('checkout-req-abc123', '{}')
ON DUPLICATE KEY UPDATE key_value = key_value;
-- rows_affected = 1 → key baru, lanjutkan proses
-- rows_affected = 0 → key sudah ada, ambil hasil sebelumnya dan kembalikan

-- Step 2: jika key baru (rows_affected = 1), proses operasi dalam transaction
START TRANSACTION;

INSERT INTO orders (user_id, total, status)
VALUES (1, 150000, 'pending');

-- Step 3: simpan hasil ke idempotency_keys
UPDATE idempotency_keys
SET response = JSON_OBJECT('order_id', LAST_INSERT_ID(), 'status', 'created')
WHERE key_value = 'checkout-req-abc123';

COMMIT;

-- Step 4: untuk request duplikat, kembalikan hasil tersimpan tanpa proses ulang
SELECT response FROM idempotency_keys WHERE key_value = 'checkout-req-abc123';

Dengan idempotency key, tidak peduli berapa kali request yang sama dikirimkan — hasilnya selalu sama dan operasi hanya dieksekusi sekali.


Deadlock: Efek Samping Locking yang Tidak Hati-Hati #

Deadlock adalah kondisi yang bisa muncul sebagai efek samping dari pessimistic locking yang urutan aksesnya tidak konsisten. Dua transaction saling menunggu lock yang dipegang oleh yang lain — dan tidak ada yang bisa maju.

Skenario deadlock klasik:

  Waktu   Transaksi A                         Transaksi B
  ──────  ───────────────────────────────────  ───────────────────────────────────
  T=0ms   BEGIN
  T=1ms   SELECT wallets id=1 FOR UPDATE ✓    BEGIN
  T=2ms                                        SELECT orders id=10 FOR UPDATE ✓
  T=3ms   SELECT orders id=10 FOR UPDATE
          → [menunggu Transaksi B]
  T=4ms                                        SELECT wallets id=1 FOR UPDATE
                                               → [menunggu Transaksi A]
  T=5ms   [DEADLOCK terdeteksi database]
          → salah satu di-rollback sebagai korban

Database modern mendeteksi deadlock secara otomatis dan me-rollback salah satu transaction. Tapi setiap deadlock berarti ada transaction yang gagal dan harus di-retry — dan frekuensi deadlock yang tinggi adalah tanda masalah arsitektur.

Cara mencegahnya sederhana: selalu akses tabel dan baris dalam urutan yang konsisten di seluruh codebase.

-- ANTI-PATTERN: urutan lock berbeda di dua tempat → risiko deadlock

-- Di service A:
SELECT * FROM wallets WHERE id = 1 FOR UPDATE;   -- kunci wallets dulu
SELECT * FROM orders WHERE id = 10 FOR UPDATE;   -- baru orders

-- Di service B (urutan terbalik):
SELECT * FROM orders WHERE id = 10 FOR UPDATE;   -- kunci orders dulu
SELECT * FROM wallets WHERE id = 1 FOR UPDATE;   -- baru wallets ← DEADLOCK

-- BENAR: urutan yang sama di semua tempat
-- Tetapkan konvensi: selalu kunci wallets sebelum orders
-- Tidak pernah sebaliknya, di manapun di codebase.

-- Di service A:
SELECT * FROM wallets WHERE id = 1 FOR UPDATE;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;

-- Di service B (urutan SAMA):
SELECT * FROM wallets WHERE id = 1 FOR UPDATE;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;

Anti-Pattern yang Harus Dihindari #

-- ✗ Anti-pattern 1: validasi uniqueness hanya di application layer
SELECT COUNT(*) FROM registrations WHERE event_id = 5;
-- Jika 0 atau di bawah kapasitas, lanjut insert.
-- Race condition bisa membuat banyak request lolos bersamaan.

-- ✓ Solusi: UNIQUE constraint di database + tangkap duplicate error di aplikasi

────────────────────────────────────────────────────────────────────────────────

-- ✗ Anti-pattern 2: HTTP call di dalam transaction yang memegang lock
START TRANSACTION;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- [HTTP call ke payment gateway — bisa 1-5 detik]
-- Lock ini menahan SEMUA proses lain yang butuh order id=10 selama 1-5 detik
UPDATE orders SET status = 'paid' WHERE id = 10;
COMMIT;

-- ✓ Solusi: lakukan operasi eksternal di luar transaction
payment_result = payment_gateway.charge(amount)   -- di luar transaction
START TRANSACTION;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
UPDATE orders SET status = payment_result.status WHERE id = 10;
COMMIT;

────────────────────────────────────────────────────────────────────────────────

-- ✗ Anti-pattern 3: mengandalkan Redis lock saja tanpa safety net di database
-- Redis lock bisa gagal karena: Redis down, lock expire terlalu cepat,
-- network partition, atau proses crash setelah lock dilepas tapi sebelum
-- operasi selesai. Tanpa constraint di database, race condition bisa lolos.

-- ✓ Solusi: Redis lock sebagai optimasi untuk mengurangi contention,
-- database constraint sebagai jaminan akhir yang tidak bisa dibypass.

────────────────────────────────────────────────────────────────────────────────

-- ✗ Anti-pattern 4: retry tanpa batas dan tanpa backoff
-- Jika banyak proses mengalami conflict bersamaan dan semuanya langsung retry,
-- mereka akan terus bertabrakan → retry storm yang memperparah situasi.

-- ✓ Solusi: batasi jumlah retry (misalnya maksimal 3 kali),
-- tambahkan jitter (delay acak) di antara retry untuk mengurangi collision,
-- dan kembalikan error yang jelas ke user jika semua retry gagal.

────────────────────────────────────────────────────────────────────────────────

-- ✗ Anti-pattern 5: mengunci seluruh tabel untuk masalah yang cukup row-level lock
LOCK TABLES wallets WRITE;   -- mengunci SEMUA baris di seluruh tabel
-- [proses satu transaksi]
UNLOCK TABLES;
-- Tidak ada query lain ke tabel wallets yang bisa berjalan selama ini.

-- ✓ Solusi: gunakan row-level lock yang presisi
SELECT * FROM wallets WHERE user_id = 1 FOR UPDATE;
-- Hanya mengunci baris user_id = 1, baris lain tetap bisa diakses.

Checklist Review Race Condition #

IDENTIFIKASI RISIKO:
  □ Semua endpoint yang bisa dipanggil secara concurrent sudah dipetakan
  □ Semua operasi dengan pola read-then-write sudah diidentifikasi
  □ Operasi finansial dan state machine sudah di-review secara khusus
  □ Job queue dan background worker sudah di-review untuk double processing

ATOMIC OPERATION:
  □ Operasi sederhana (increment, decrement, klaim status) menggunakan
    satu query atomic dengan conditional WHERE
  □ rows_affected selalu dicek setelah conditional update
  □ Tidak ada pola SELECT → compute di app → UPDATE untuk data kritikal

PESSIMISTIC LOCKING:
  □ SELECT FOR UPDATE selalu di dalam explicit transaction
  □ Tidak ada HTTP call, file I/O, atau sleep di dalam transaction yang locking
  □ Lock duration sesingkat mungkin — hanya selama operasi database
  □ Urutan penguncian tabel/baris konsisten di seluruh codebase

OPTIMISTIC LOCKING:
  □ Kolom version ada dan di-increment setiap update
  □ Kondisi version disertakan di setiap UPDATE yang kritis
  □ Ada mekanisme retry dengan batas maksimal dan backoff
  □ Error conflict dikomunikasikan dengan jelas ke user atau caller

IDEMPOTENCY:
  □ Operasi yang tidak boleh terjadi dua kali menggunakan idempotency key
  □ Idempotency key disimpan di database (bukan hanya cache)
  □ Key yang sama selalu mengembalikan hasil yang sama tanpa eksekusi ulang

DATABASE CONSTRAINT:
  □ Uniqueness yang merupakan aturan bisnis dijaga oleh UNIQUE constraint
  □ Database adalah last line of defense, bukan application layer
  □ Duplicate error dari database ditangani dengan benar di aplikasi

MONITORING:
  □ Deadlock dimonitor — frekuensi yang naik adalah early warning sign
  □ Conflict rate pada optimistic locking dimonitor
  □ rows_affected = 0 pada operasi kritis di-log untuk investigasi

Ringkasan #

  • Race condition hidup di jendela waktu antara read dan write — semakin pendek jendela itu, semakin kecil risikonya. Atomic operation menghilangkan jendela itu sepenuhnya.
  • Pola check-then-act adalah sumber race condition paling umum — validasi di application layer tidak cukup karena ada jeda antara cek dan tindakan. Database constraint adalah pengaman yang tidak bisa dibypass oleh request concurrent manapun.
  • Lost update terjadi ketika dua proses membaca data yang sama lalu keduanya menulis — hasilnya salah satu update hilang tanpa jejak error. Gunakan atomic update atau locking untuk mencegahnya.
  • Isolation level bukan solusi lengkap — bahkan REPEATABLE READ masih rentan lost update pada pola read-compute-write di application layer. Pilih teknik yang sesuai dengan jenis masalahnya.
  • Atomic operation adalah teknik terbaik untuk kasus sederhana — satu query yang menggabungkan validasi dan perubahan, tanpa jendela waktu.
  • Pessimistic locking cocok untuk logika kompleks dengan conflict rate tinggi — tapi jangan taruh operasi lambat di dalam transaction yang sedang locking.
  • Optimistic locking ringan dan non-blocking — cocok untuk conflict rate rendah, pastikan ada batas retry yang wajar dengan backoff.
  • Idempotency key adalah satu-satunya cara andal mencegah double processing pada operasi seperti pembayaran dan pengiriman email.
  • Deadlock terjadi ketika urutan lock tidak konsisten — tetapkan konvensi urutan akses tabel/baris di seluruh codebase dan patuhi tanpa pengecualian.
  • Database adalah last line of defense — application lock (Redis, mutex) boleh digunakan sebagai optimasi untuk mengurangi contention, tapi tidak bisa menggantikan constraint dan locking di level database.

← Sebelumnya: Locking   Berikutnya: Index →

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