Race condition pada update stok order adalah salah satu bug backend yang paling menjengkelkan karena gejalanya nyata di produksi, tetapi sering sulit direproduksi di lokal. Dampaknya tidak kecil: stok bisa menjadi minus, dua pelanggan bisa membeli unit yang sama, dan status order atau pembayaran menjadi tidak sinkron dengan inventori.
Dalam banyak kasus, akar masalahnya bukan sekadar "query lambat" atau "server sibuk", melainkan asumsi concurrency yang salah. Dua request checkout berjalan hampir bersamaan, sama-sama membaca stok yang masih tersedia, lalu sama-sama mengurangi stok tanpa koordinasi yang benar. Artikel ini membahas pola bug tersebut sebagai studi debugging backend: mulai dari gejala, investigasi, reproduksi, akar masalah teknis, sampai opsi perbaikan yang realistis beserta trade-off-nya.
Gejala yang Muncul di Produksi
Pola bug ini biasanya tidak muncul sebagai satu error yang jelas. Yang terlihat justru efek samping di beberapa tempat sekaligus.
- Stok minus: nilai inventori turun di bawah nol untuk produk tertentu.
- Oversell: dua atau lebih order berhasil membeli item yang stok nyatanya hanya cukup untuk satu order.
- Data order inkonsisten: order tercatat sukses, tetapi stok tidak cocok; atau stok sudah berkurang, tetapi order gagal di tengah proses.
- Sulit direproduksi di lokal: pengujian manual satu per satu terlihat normal karena tidak menimbulkan konkurensi nyata.
Gejala sering muncul saat ada promo, flash sale, traffic tinggi, atau integrasi dari beberapa kanal yang mengurangi stok pada waktu hampir sama. Dalam kondisi trafik rendah, bug bisa tersembunyi selama berminggu-minggu.
Pola Root Cause: Read-Modify-Write Tanpa Proteksi yang Memadai
Urutan masalah yang umum
Implementasi yang tampak benar secara logika bisnis sering kali salah secara konkurensi. Misalnya alurnya seperti ini:
- Request A membaca stok produk = 1.
- Request B membaca stok produk = 1.
- Request A memutuskan stok cukup, lalu menulis stok baru = 0.
- Request B juga memutuskan stok cukup, lalu menulis stok baru = 0 atau bahkan -1, tergantung implementasi.
Masalah utamanya adalah operasi read-modify-write dilakukan dalam beberapa langkah terpisah tanpa lock yang tepat atau tanpa query atomik yang menjamin konsistensi.
Contoh pseudo-code yang rentan
// rentan race condition
product = SELECT stock FROM products WHERE id = :product_id
if product.stock >= qty:
new_stock = product.stock - qty
UPDATE products SET stock = :new_stock WHERE id = :product_id
INSERT INTO orders (...)
else:
return "stok habis"Secara bisnis, kode di atas terlihat masuk akal. Secara concurrent access, ini berbahaya. Ada celah waktu antara operasi baca dan tulis, dan di celah itu request lain bisa masuk.
Mengapa transaksi saja belum tentu cukup
Banyak tim mengira cukup dengan membungkus kode dalam transaksi database. Kenyataannya, transaksi tidak otomatis menyelesaikan race condition jika:
- baris yang dibaca tidak di-lock,
- isolation level tidak mencegah anomali yang relevan,
- logika tetap melakukan keputusan bisnis berdasarkan data yang bisa berubah sebelum commit.
Contohnya, pada isolation level yang umum dipakai, dua transaksi masih bisa membaca nilai stok yang sama sebelum salah satunya mengubah data. Jika tidak ada mekanisme locking atau kondisi atomik pada query update, hasil akhirnya tetap bisa oversell.
Mengapa Bug Ini Sulit Direproduksi di Lokal
Di lingkungan lokal, developer biasanya menguji request secara serial: klik checkout sekali, tunggu respons, lalu coba lagi. Pola itu hampir tidak pernah memicu race condition. Bug baru muncul saat dua atau lebih request menembak endpoint yang sama dalam jendela waktu sangat sempit.
Beberapa faktor yang membuatnya sulit muncul di lokal:
- database lokal sangat cepat sehingga window race sangat kecil atau justru tidak realistis dibanding produksi,
- hanya ada satu instance aplikasi, tanpa paralelisme dari worker atau pod lain,
- tidak ada retry dari client, gateway, atau job queue yang menambah konkurensi,
- data uji terlalu sederhana dan volume request terlalu kecil.
Jika bug hanya muncul “sesekali” di produksi, jangan langsung menyimpulkan masalahnya acak. Pada sistem concurrent, bug semacam ini biasanya deterministik terhadap interleaving tertentu, hanya saja interleaving itu jarang tertangkap saat pengujian manual biasa.
Langkah Investigasi yang Praktis
1. Mulai dari gejala, bukan asumsi
Kumpulkan contoh konkret: produk mana yang stoknya minus, order mana yang saling bertabrakan, dan rentang waktu kejadiannya. Fokuskan investigasi pada satu produk atau satu insiden lebih dulu agar jejaknya jelas.
2. Analisis log aplikasi dan korelasikan request
Untuk debugging backend race condition pada update stok order, log tanpa korelasi request sering tidak cukup. Idealnya setiap request memiliki request_id, order_id, product_id, dan timestamp presisi tinggi.
Contoh log yang berguna:
2026-04-04T10:15:22.101Z request_id=req-A product_id=SKU-123 step=read_stock stock=1
2026-04-04T10:15:22.103Z request_id=req-B product_id=SKU-123 step=read_stock stock=1
2026-04-04T10:15:22.120Z request_id=req-A product_id=SKU-123 step=update_stock new_stock=0
2026-04-04T10:15:22.122Z request_id=req-B product_id=SKU-123 step=update_stock new_stock=0Dari pola seperti ini, kita bisa melihat dua request membaca stok yang sama sebelum update terjadi. Tanpa log berurutan seperti itu, masalah sering terlihat seperti “stok tiba-tiba salah”.
3. Cari korelasi antar tabel
Periksa apakah jumlah item terjual melebihi stok awal atau apakah ada order sukses lebih banyak daripada ketersediaan barang. Query inspeksi harus disesuaikan dengan skema Anda, tetapi polanya mirip berikut:
-- cek produk dengan stok negatif
SELECT id, sku, stock
FROM products
WHERE stock < 0;
-- cek order sukses untuk produk tertentu pada rentang waktu insiden
SELECT oi.product_id, o.id AS order_id, o.status, o.created_at, oi.quantity
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
WHERE oi.product_id = :product_id
AND o.created_at BETWEEN :start_time AND :end_time
ORDER BY o.created_at ASC;
-- bandingkan total kuantitas terjual dengan stok yang seharusnya
SELECT product_id, SUM(quantity) AS sold_qty
FROM order_items
WHERE product_id = :product_id
GROUP BY product_id;Tujuannya bukan hanya mencari data salah, tetapi memetakan urutan kejadian: kapan order dibuat, kapan stok berubah, dan apakah ada rollback parsial atau retry.
4. Audit boundary transaksi
Periksa titik-titik berikut:
- Apakah baca stok dan update stok berada dalam transaksi yang sama?
- Apakah row yang dibaca di-lock?
- Apakah insert order terjadi sebelum atau sesudah stok berhasil dikurangi?
- Apakah ada proses asinkron lain yang juga mengubah stok, misalnya worker pembayaran, sinkronisasi marketplace, atau kompensasi cancel?
- Apakah client atau gateway melakukan retry pada request timeout sehingga request identik dieksekusi dua kali?
Sering kali masalah bukan hanya satu endpoint checkout, melainkan kombinasi API sinkron dan job asinkron yang sama-sama memodifikasi inventori.
5. Reproduksi dengan concurrent test
Pengujian manual tidak cukup. Anda perlu membuat dua atau lebih request berjalan hampir bersamaan terhadap produk dengan stok sangat kecil, misalnya 1.
Contoh skenario uji:
- Set stok produk menjadi 1.
- Siapkan dua request checkout untuk produk yang sama dengan quantity 1.
- Kirim secara paralel, bukan berurutan.
- Amati apakah dua order sama-sama sukses atau stok menjadi salah.
Contoh pseudo-test sederhana:
parallel_run(
() => POST /checkout { product_id: "SKU-123", qty: 1, idempotency_key: "A" },
() => POST /checkout { product_id: "SKU-123", qty: 1, idempotency_key: "B" }
)
assert successful_orders <= 1
assert current_stock >= 0Jika perlu, tambahkan artificial delay di antara baca stok dan update stok pada environment test untuk memperlebar window race. Teknik ini sering membantu memunculkan bug yang tadinya sangat jarang.
Memahami Peran Isolation Level
Isolation level menentukan seberapa jauh satu transaksi dapat melihat perubahan transaksi lain. Namun penting dipahami bahwa isolation level bukan tombol ajaib untuk semua race condition.
- Isolation level lebih longgar dapat membiarkan dua transaksi membaca kondisi yang sama, lalu sama-sama mengambil keputusan update.
- Isolation level lebih ketat bisa mengurangi anomali tertentu, tetapi biasanya ada trade-off pada blocking, deadlock, atau throughput.
Dalam konteks update stok, pertanyaan yang lebih berguna adalah: bagaimana memastikan hanya satu transaksi yang boleh “memenangkan” pengurangan stok ketika stok terbatas? Jawabannya biasanya berupa salah satu dari: lock baris, update atomik bersyarat, atau versi/optimistic locking.
Opsi Perbaikan dan Trade-off
1. Row locking dengan transaksi
Pendekatan ini mengunci baris produk saat stok dibaca untuk diproses. Pola umumnya adalah membaca row inventori dengan lock, memeriksa stok, lalu mengurangi stok dalam transaksi yang sama.
BEGIN;
SELECT stock
FROM products
WHERE id = :product_id
FOR UPDATE;
-- aplikasi mengecek stock >= qty
UPDATE products
SET stock = stock - :qty
WHERE id = :product_id;
INSERT INTO orders (...);
COMMIT;Mengapa ini bekerja: request lain yang ingin memproses row yang sama harus menunggu sampai transaksi pertama selesai, sehingga keputusan stok tidak diambil dari snapshot yang sama secara bebas.
Trade-off:
- lebih aman untuk stok kritis,
- meningkatkan contention pada produk populer,
- bisa memicu lock wait atau deadlock jika urutan akses ke banyak row tidak konsisten,
- transaksi harus dibuat sesingkat mungkin.
Kapan cocok: jika konsistensi inventori lebih penting daripada throughput maksimal, dan jumlah row yang di-lock per transaksi relatif kecil.
2. Atomic update dengan kondisi
Ini sering menjadi solusi yang sederhana dan efektif. Daripada membaca stok lalu menghitung di aplikasi, lakukan pengurangan langsung di database dengan syarat stok mencukupi.
UPDATE products
SET stock = stock - :qty
WHERE id = :product_id
AND stock >= :qty;Setelah query dijalankan, aplikasi memeriksa jumlah row yang terpengaruh:
- jika 1 row ter-update, stok berhasil dikurangi,
- jika 0 row ter-update, stok tidak cukup atau row tidak ditemukan.
Mengapa ini bekerja: validasi stok dan pengurangan stok terjadi sebagai satu operasi atomik di database, sehingga celah race antara baca dan tulis berkurang drastis.
Trade-off:
- sangat baik untuk kasus pengurangan stok sederhana,
- lebih sulit jika logika bisnis butuh banyak validasi lintas tabel sebelum commit,
- tetap perlu desain transaksi yang benar untuk penulisan order dan pembayaran.
Kapan cocok: saat aturan utamanya adalah “kurangi stok hanya jika masih cukup” dan Anda ingin meminimalkan lock eksplisit di aplikasi.
3. Optimistic locking
Pendekatan ini menambahkan kolom versi atau timestamp yang diperiksa saat update. Jika versi berubah sejak data dibaca, berarti ada transaksi lain yang sudah memodifikasi row tersebut, dan update dibatalkan.
-- saat baca
SELECT stock, version
FROM products
WHERE id = :product_id;
-- saat update
UPDATE products
SET stock = :new_stock,
version = version + 1
WHERE id = :product_id
AND version = :old_version;Mengapa ini bekerja: aplikasi mendeteksi konflik tulis, bukan mencegahnya dengan lock di depan. Jika dua request membaca versi yang sama, hanya satu yang berhasil update.
Trade-off:
- bagus saat konflik jarang terjadi,
- jika traffic tinggi pada produk yang sama, konflik bisa sering dan menyebabkan banyak retry,
- implementasi lebih mudah salah jika tidak semua jalur update mematuhi aturan version check.
Kapan cocok: saat contention rendah sampai sedang, dan Anda ingin menghindari blocking yang lama.
4. Idempotency key untuk mencegah eksekusi ganda request yang sama
Idempotency key tidak menyelesaikan seluruh race condition inventori, tetapi sangat penting untuk mencegah request identik diproses dua kali akibat retry client, timeout, atau double-click.
Contoh alur:
- Client mengirim checkout dengan idempotency_key.
- Server menyimpan key tersebut bersama hasil request.
- Jika request yang sama datang lagi dengan key sama, server mengembalikan hasil sebelumnya, bukan memproses ulang pengurangan stok.
Trade-off:
- mencegah duplikasi dari sumber yang sama,
- tidak cukup untuk dua request berbeda yang memperebutkan stok yang sama,
- butuh storage dan kebijakan expiry yang jelas.
5. Retry terkontrol
Pada pendekatan optimistic locking atau saat terjadi deadlock/serialization failure, retry bisa diperlukan. Namun retry harus terkontrol, bukan loop tanpa batas.
Prinsipnya:
- retry hanya untuk error transient yang memang layak dicoba ulang,
- batasi jumlah percobaan,
- gunakan backoff kecil agar tidak memperparah contention,
- pastikan operasi aman untuk diulang, idealnya dengan idempotency key.
Kesalahan umum: menambahkan retry tanpa idempotency, lalu justru memperbesar peluang duplikasi order atau penurunan stok ganda.
Memilih Strategi yang Tepat
Tidak ada satu solusi yang selalu paling benar. Pilihan bergantung pada pola trafik, aturan bisnis, dan toleransi terhadap blocking.
- Pilih row locking jika konsistensi sangat kritis dan jumlah konflik pada produk populer masih bisa diterima.
- Pilih atomic conditional update jika alur stok relatif sederhana dan Anda ingin solusi yang efisien serta langsung di level database.
- Pilih optimistic locking jika konflik relatif jarang dan throughput lebih penting daripada blocking.
- Tambahkan idempotency key hampir di semua alur checkout atau create order yang bisa terkena retry.
- Gunakan retry terkontrol hanya untuk konflik transient, bukan sebagai penutup desain concurrency yang lemah.
Pada sistem nyata, kombinasi sering lebih baik daripada satu teknik tunggal. Contoh yang umum: atomic update untuk stok, transaksi untuk order, idempotency key untuk API checkout, dan retry terbatas untuk konflik transient.
Pola Implementasi yang Lebih Aman
Salah satu pola yang sering lebih aman adalah:
- Terima request checkout dengan idempotency key.
- Mulai transaksi.
- Lakukan pengurangan stok dengan query atomik bersyarat atau lock row inventori.
- Jika stok berhasil dikurangi, buat order dan item order dalam transaksi yang sama.
- Commit.
- Jika commit gagal, rollback dan jangan tinggalkan stok/order dalam keadaan setengah jadi.
Jika proses pembayaran berlangsung asinkron, pisahkan dengan jelas antara reserve stock dan finalize order. Banyak bug lahir karena stok langsung dianggap final padahal pembayaran belum pasti, atau kompensasi pembatalan tidak idempotent.
Kesalahan Umum Saat Memperbaiki Bug Ini
- Hanya menambah transaksi tanpa lock atau update atomik, lalu mengira masalah selesai.
- Mengandalkan cache sebagai source of truth stok tanpa sinkronisasi yang kuat dengan database.
- Mengabaikan jalur update lain, misalnya admin panel, sinkronisasi marketplace, worker cancel, atau proses refund.
- Retry tanpa batas yang memperbesar load dan konflik.
- Logging minim, sehingga sulit membuktikan interleaving antar request.
- Tidak menambahkan regression test concurrent setelah bug diperbaiki.
Monitoring Setelah Perbaikan
Perbaikan concurrency tidak cukup hanya di-merge. Anda perlu memastikan gejalanya benar-benar hilang dan tidak digantikan masalah baru seperti lock contention berlebihan.
Metrik yang layak dipantau
- jumlah stok negatif, harus nol,
- jumlah order gagal karena stok habis setelah payment flow dimulai,
- jumlah konflik optimistic locking atau retry,
- lock wait time atau deadlock di database,
- selisih antara stok teoritis dan stok aktual hasil audit.
Alert yang berguna
- alert jika ada
stock < 0, - alert jika jumlah update inventory gagal melonjak tajam,
- alert jika deadlock meningkat setelah penerapan lock.
Monitoring ini membantu memastikan bahwa solusi memang memperbaiki konsistensi, bukan sekadar memindahkan masalah ke latensi atau error rate.
Regression Test yang Wajib Ditambahkan
Setelah bug race condition ditemukan dan diperbaiki, tambahkan pengujian yang eksplisit menargetkan konkurensi.
Checklist regression test
- dua request checkout paralel untuk produk dengan stok 1, hasil sukses maksimal satu,
- request duplikat dengan idempotency key yang sama tidak membuat order kedua,
- konflik update menghasilkan respons yang konsisten dan tidak membuat stok negatif,
- rollback transaksi tidak meninggalkan order tanpa pengurangan stok atau sebaliknya,
- alur cancel/refund yang mengembalikan stok juga aman terhadap request paralel.
Jika memungkinkan, jalankan test concurrency ini di CI pada database yang perilakunya mendekati produksi. Test unit saja biasanya tidak cukup untuk menangkap interaksi transaksi dan lock.
Checklist Pencegahan Race Condition pada Update Stok Order
- Hindari pola read-modify-write terpisah tanpa proteksi.
- Gunakan row lock, optimistic locking, atau update atomik sesuai kebutuhan.
- Pastikan boundary transaksi jelas dan singkat.
- Terapkan idempotency key pada endpoint checkout/create order.
- Tambahkan retry hanya untuk error transient dan batasi jumlahnya.
- Log request_id, order_id, product_id, dan timestamp presisi tinggi.
- Audit semua jalur yang bisa mengubah stok, termasuk job asinkron dan admin tools.
- Pantau lock contention, deadlock, dan anomali inventori setelah deploy.
- Buat regression test paralel yang memverifikasi stok tidak minus dan oversell tidak terjadi.
Penutup
Bug race condition pada update stok order hampir selalu berakar pada asumsi bahwa request datang satu per satu atau bahwa transaksi otomatis menjamin konsistensi. Di sistem produksi, asumsi itu tidak aman. Ketika dua request checkout tiba hampir bersamaan, read-modify-write tanpa lock, tanpa query atomik, atau tanpa idempotency akan cepat berubah menjadi oversell dan data inkonsisten.
Pendekatan yang baik dimulai dari investigasi yang rapi: korelasikan log, reproduksi dengan concurrent test, dan periksa query serta boundary transaksi. Setelah itu, pilih strategi perbaikan yang sesuai—row locking, optimistic locking, atomic conditional update, idempotency key, dan retry terkontrol—dengan memahami trade-off-nya. Target akhirnya bukan hanya membuat bug hilang sekali, tetapi memastikan kelas bug ini tidak kembali lewat monitoring dan regression test yang tepat.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!