Bug stok minus saat checkout paralel biasanya terjadi ketika beberapa request membeli produk yang sama pada waktu hampir bersamaan, sementara kode backend melakukan pengecekan dan pengurangan stok sebagai operasi terpisah tanpa proteksi yang cukup. Akibatnya, beberapa checkout sama-sama membaca stok yang masih tersedia, lalu semuanya melanjutkan pengurangan stok.

Solusi utamanya bukan sekadar menambah validasi di aplikasi, melainkan memastikan pengurangan stok dilakukan secara aman di level database: gunakan transaksi, atomic conditional update seperti WHERE stock >= qty, idempotency key untuk mencegah order ganda, dan test concurrency agar bug tidak muncul lagi saat traffic tinggi.

Gejala Awal: Laporan User, Log, dan Metrik

Kasus ini biasanya tidak langsung terlihat di lingkungan development karena checkout manual dilakukan satu per satu. Bug baru muncul ketika ada flash sale, promo, produk populer, atau retry otomatis dari client.

Laporan dari user dan tim operasional

  • User berhasil checkout, tetapi kemudian order dibatalkan karena stok ternyata tidak cukup.
  • Beberapa user membeli item terakhir pada produk yang sama.
  • Dashboard admin menampilkan stok -1, -3, atau angka negatif lain.
  • Tim gudang melihat jumlah pesanan lebih banyak daripada stok fisik.

Petunjuk dari log aplikasi

Log yang berguna biasanya memperlihatkan banyak checkout untuk product_id yang sama dalam rentang waktu sangat pendek. Contoh pola log yang mencurigakan:

2026-09-01T10:00:01.120Z checkout_start user=41 product=123 qty=1 request_id=req-a
2026-09-01T10:00:01.121Z checkout_start user=52 product=123 qty=1 request_id=req-b
2026-09-01T10:00:01.125Z stock_read product=123 stock=1 request_id=req-a
2026-09-01T10:00:01.126Z stock_read product=123 stock=1 request_id=req-b
2026-09-01T10:00:01.140Z checkout_success order=9001 product=123 request_id=req-a
2026-09-01T10:00:01.142Z checkout_success order=9002 product=123 request_id=req-b

Dua request membaca stok yang sama, sama-sama merasa valid, lalu sama-sama membuat order. Jika pengurangan stok tidak bersifat kondisional dan atomik, stok dapat turun melewati nol.

Metrik yang biasanya ikut berubah

  • checkout_success_total naik tajam untuk produk tertentu.
  • checkout_failed_out_of_stock_total rendah, padahal stok hampir habis.
  • negative_stock_detected_total atau query monitoring menemukan produk dengan stok negatif.
  • Latency endpoint checkout meningkat karena banyak request bersaing di resource yang sama.
  • Jumlah retry dari client atau payment callback meningkat dan dapat memperparah duplikasi order jika tidak idempotent.

Cara Reproduksi dengan Concurrent Request

Untuk membuktikan race condition, buat skenario sederhana: satu produk memiliki stok kecil, lalu kirim banyak request checkout secara paralel. Tujuannya bukan melakukan benchmark, tetapi memaksa beberapa request melewati bagian kode yang sama pada waktu bersamaan.

Setup data uji

UPDATE products
SET stock = 5
WHERE id = 123;

DELETE FROM orders
WHERE product_id = 123;

Kirim request paralel

Contoh menggunakan shell dan curl. Setiap request memakai idempotency key berbeda agar mensimulasikan pembeli berbeda, bukan retry dari request yang sama.

seq 1 20 | xargs -n 1 -P 20 -I {} \
  curl -s -X POST http://localhost:8080/checkout \
    -H 'Content-Type: application/json' \
    -H 'Idempotency-Key: test-{}' \
    -d '{"product_id":123,"qty":1}'

Setelah itu cek stok dan jumlah order:

SELECT id, stock
FROM products
WHERE id = 123;

SELECT COUNT(*) AS order_count
FROM orders
WHERE product_id = 123
  AND status IN ('PENDING', 'PAID', 'CONFIRMED');

Jika stok awal 5 tetapi order sukses lebih dari 5, atau stok menjadi negatif, bug berhasil direproduksi.

Catatan: pastikan test dilakukan di environment non-produksi dengan data terkontrol. Race condition sering bersifat timing-dependent; jika belum muncul, tambahkan jumlah paralelisme atau sisipkan delay kecil di antara operasi baca stok dan update pada build khusus debugging.

Root Cause: Pola Read-Modify-Write Tanpa Proteksi

Akar masalah paling umum adalah pola read-modify-write: aplikasi membaca stok, memutuskan apakah stok cukup, lalu mengurangi stok dalam query terpisah. Di antara langkah baca dan update, request lain bisa melakukan hal yang sama.

Contoh kode bermasalah

function checkout(userId, productId, qty, idempotencyKey) {
  product = db.queryOne(
    'SELECT id, stock FROM products WHERE id = ?',
    [productId]
  )

  if (product.stock < qty) {
    throw new Error('OUT_OF_STOCK')
  }

  order = db.insert('orders', {
    user_id: userId,
    product_id: productId,
    qty: qty,
    idempotency_key: idempotencyKey,
    status: 'CONFIRMED'
  })

  db.execute(
    'UPDATE products SET stock = stock - ? WHERE id = ?',
    [qty, productId]
  )

  return order
}

Kode di atas tampak benar saat request berjalan satu per satu. Namun pada checkout paralel, misalnya stok tersisa 1 dan ada dua request dengan qty = 1:

  1. Request A membaca stok 1.
  2. Request B membaca stok 1 sebelum A mengurangi stok.
  3. A membuat order dan mengurangi stok menjadi 0.
  4. B tetap melanjutkan karena sebelumnya sudah membaca stok 1.
  5. B membuat order dan mengurangi stok menjadi -1.

Masalahnya bukan hanya tidak ada transaksi. Transaksi yang hanya membungkus query tanpa locking atau conditional update juga belum tentu cukup. Yang harus dijamin adalah keputusan stok cukup dan operasi kurangi stok terjadi sebagai satu operasi yang aman terhadap concurrency.

Perbaikan Utama: Transaksi, Conditional Update, dan Idempotency Key

Perbaikan yang lebih aman adalah memindahkan validasi stok ke query update itu sendiri. Database akan mengeksekusi update pada row terkait secara konsisten, dan aplikasi cukup memeriksa jumlah row yang berhasil diupdate.

Atomic conditional update

UPDATE products
SET stock = stock - :qty
WHERE id = :product_id
  AND stock >= :qty;

Query ini hanya berhasil jika stok masih cukup pada saat update dieksekusi. Jika dua request bersaing untuk stok terakhir, hanya satu yang akan memodifikasi row. Request lain mendapatkan affected_rows = 0 dan harus dianggap gagal karena stok habis.

Gunakan transaksi untuk menjaga konsistensi order dan stok

Pengurangan stok biasanya bukan satu-satunya operasi checkout. Ada pembuatan order, order item, reservasi pembayaran, kupon, atau audit log. Operasi yang harus konsisten perlu berada dalam transaksi yang sama.

BEGIN;

-- 1. Buat order dengan idempotency key unik.
-- Jika constraint unique gagal, rollback lalu return order yang sudah ada.
INSERT INTO orders (user_id, product_id, qty, idempotency_key, status)
VALUES (:user_id, :product_id, :qty, :idempotency_key, 'PENDING');

-- 2. Kurangi stok secara atomik dan kondisional.
UPDATE products
SET stock = stock - :qty
WHERE id = :product_id
  AND stock >= :qty;

-- 3. Aplikasi wajib memeriksa affected_rows.
-- Jika affected_rows = 0: ROLLBACK dan return OUT_OF_STOCK.

UPDATE orders
SET status = 'CONFIRMED'
WHERE id = :order_id;

COMMIT;

Pola di atas memastikan order tidak terkonfirmasi jika stok gagal dikurangi. Jika update stok gagal, transaksi di-rollback sehingga order PENDING yang baru dibuat tidak tertinggal, kecuali desain sistem memang ingin menyimpan order gagal sebagai audit terpisah.

Constraint unik untuk idempotency key

Race condition stok sering muncul bersamaan dengan masalah retry. Client bisa mengirim request yang sama dua kali karena timeout, user menekan tombol checkout berulang, atau gateway mengirim callback ulang. Tanpa idempotency, satu niat checkout bisa berubah menjadi beberapa order.

Tambahkan kolom idempotency key dan constraint unik pada scope yang tepat, misalnya per user:

ALTER TABLE orders
ADD COLUMN idempotency_key VARCHAR(128);

CREATE UNIQUE INDEX ux_orders_user_idempotency
ON orders (user_id, idempotency_key);

Alur yang disarankan:

  1. Client mengirim Idempotency-Key unik untuk satu percobaan checkout.
  2. Server mengecek apakah order dengan user_id dan key tersebut sudah ada.
  3. Jika sudah ada, server mengembalikan hasil order yang sama, bukan membuat order baru.
  4. Jika belum ada, server membuat order dalam transaksi dan constraint unik menjadi pagar terakhir terhadap request duplikat paralel.

Idempotency key tidak menggantikan atomic update stok. Keduanya menyelesaikan masalah berbeda: idempotency mencegah duplikasi request yang sama, sedangkan conditional update mencegah overselling antar request yang berbeda.

Alternatif: SELECT FOR UPDATE

Alternatif yang valid adalah mengunci row produk sebelum membaca stok, misalnya dengan SELECT ... FOR UPDATE di dalam transaksi, lalu melakukan validasi dan update. Pola ini berguna jika logika pengecekan stok lebih kompleks daripada satu decrement sederhana.

BEGIN;

SELECT stock
FROM products
WHERE id = :product_id
FOR UPDATE;

-- Validasi stock di aplikasi.
-- Row produk terkunci sampai transaksi selesai.

UPDATE products
SET stock = stock - :qty
WHERE id = :product_id;

COMMIT;

Trade-off-nya, lock dapat menambah waktu tunggu ketika banyak checkout terjadi pada produk yang sama. Untuk kasus pengurangan stok sederhana, conditional update biasanya lebih ringkas dan mengurangi peluang kesalahan karena validasi stok berada langsung di query update.

Test Concurrency agar Bug Tidak Kembali

Unit test biasa sering tidak cukup karena race condition muncul dari interleaving antar request. Tambahkan integration test yang benar-benar menjalankan beberapa worker atau request paralel terhadap database test.

Contoh pseudocode test

test('checkout paralel tidak membuat stok minus') {
  productId = createProduct(stock: 5)

  results = runConcurrently(20, function (i) {
    return api.post('/checkout', {
      product_id: productId,
      qty: 1
    }, headers: {
      'Idempotency-Key': 'concurrency-test-' + i
    })
  })

  successCount = count(results, statusCode == 200)
  outOfStockCount = count(results, statusCode == 409)

  product = db.queryOne('SELECT stock FROM products WHERE id = ?', [productId])
  orderCount = db.queryValue(
    'SELECT COUNT(*) FROM orders WHERE product_id = ? AND status = ?',
    [productId, 'CONFIRMED']
  )

  assert(successCount == 5)
  assert(outOfStockCount == 15)
  assert(product.stock == 0)
  assert(orderCount == 5)
}

Test seperti ini sebaiknya masuk pipeline CI jika infrastrukturnya mendukung database nyata. Mock database tidak akan menangkap perilaku lock, transaksi, dan atomic update secara akurat.

Kesalahan umum saat menulis test concurrency

  • Menggunakan idempotency key yang sama untuk semua request, sehingga yang dites adalah retry, bukan checkout paralel dari banyak pembeli.
  • Menjalankan test di SQLite in-memory atau mock yang perilaku concurrency-nya berbeda dari database produksi.
  • Tidak memeriksa affected_rows setelah update stok.
  • Hanya mengecek response API tanpa memverifikasi stok akhir dan jumlah order di database.
  • Tidak menguji skenario timeout atau retry client.

Mitigasi Sementara di Produksi

Jika bug sudah terjadi di produksi, perbaikan permanen tetap perlu dilakukan di kode dan database. Namun ada beberapa mitigasi sementara untuk mengurangi kerusakan sambil menyiapkan patch.

  • Matikan checkout untuk SKU bermasalah atau produk dengan stok sangat rendah, terutama saat promo.
  • Batasi concurrency per produk melalui queue atau lock sementara di service layer. Ini dapat membantu, tetapi jangan dijadikan satu-satunya proteksi karena lock aplikasi bisa gagal saat multi-instance atau restart.
  • Tambahkan guard database cepat dengan conditional update jika memungkinkan tanpa migrasi besar.
  • Jalankan reconciliation job untuk menemukan stok negatif, order yang melebihi stok, dan order duplikat berdasarkan idempotency key atau pola request.
  • Tingkatkan logging checkout untuk mencatat product_id, qty, idempotency_key, affected_rows, status transaksi, dan request_id.
  • Siapkan prosedur operasional untuk membatalkan atau menahan order yang tidak dapat dipenuhi secara fisik.

Hindari solusi palsu: validasi tambahan di frontend, cache stok di Redis tanpa atomic operation yang benar, atau pengecekan ulang setelah order dibuat tidak cukup untuk menjamin konsistensi stok.

Checklist Observability untuk Deteksi Cepat

Race condition lebih mudah ditangani jika sistem memberi sinyal sebelum banyak order terdampak. Minimal, endpoint checkout perlu memiliki log terstruktur, metrik bisnis, dan alert yang terkait langsung dengan stok.

Log terstruktur

  • request_id atau trace id untuk menghubungkan semua log dalam satu checkout.
  • user_id, product_id, qty, dan idempotency_key.
  • Hasil update stok: affected_rows, status sukses/gagal, dan alasan gagal.
  • Status transaksi: commit, rollback, atau exception.
  • Durasi operasi database yang kritis.

Metrik dan alert

  • Jumlah produk dengan stock < 0. Alert ini harus bernilai nol dalam kondisi normal.
  • Counter checkout_out_of_stock_total untuk melihat apakah penolakan stok habis berjalan.
  • Counter stock_update_affected_rows_zero_total sebagai sinyal request kalah berebut stok.
  • Rasio order sukses terhadap stok awal untuk SKU tertentu selama event promo.
  • Jumlah pelanggaran unique constraint idempotency sebagai indikator retry atau double submit.
  • Latency dan error rate endpoint checkout, dipisah berdasarkan produk atau kategori jika memungkinkan.

Query audit sederhana

SELECT id, stock
FROM products
WHERE stock < 0;

SELECT product_id, SUM(qty) AS confirmed_qty
FROM orders
WHERE status = 'CONFIRMED'
GROUP BY product_id;

SELECT user_id, idempotency_key, COUNT(*) AS total
FROM orders
WHERE idempotency_key IS NOT NULL
GROUP BY user_id, idempotency_key
HAVING COUNT(*) > 1;

Query audit dapat dijalankan sebagai job berkala atau dashboard internal. Untuk sistem dengan volume besar, gunakan agregasi yang sesuai agar tidak membebani database utama.

Ringkasan Praktis

Bug stok minus saat checkout paralel terjadi karena beberapa request membaca stok yang sama lalu mengurangi stok tanpa operasi atomik. Perbaikannya harus diletakkan pada titik yang paling dekat dengan data: database.

  • Hindari pola read-modify-write tanpa lock atau conditional update.
  • Gunakan UPDATE products SET stock = stock - qty WHERE stock >= qty dan wajib cek affected_rows.
  • Bungkus pembuatan order dan pengurangan stok dalam transaksi.
  • Tambahkan idempotency key dengan unique constraint untuk mencegah order ganda akibat retry.
  • Buat test concurrency yang memverifikasi jumlah order sukses, order gagal, dan stok akhir.
  • Lengkapi observability agar stok negatif, retry, dan kegagalan update cepat terdeteksi.

Dengan kombinasi transaksi yang tepat, update kondisional, idempotency, dan observability, checkout paralel dapat ditangani secara deterministik tanpa membuat stok produk turun di bawah nol.