Double-sell saat flash sale hampir selalu muncul ketika beberapa request memesan item yang sama dalam waktu sangat rapat, tetapi sistem stok tidak punya kontrol konkurensi yang benar. Gejalanya biasanya jelas: stok minus, order sukses lebih banyak dari inventory, log yang saling bertentangan, dan gelombang refund karena barang ternyata tidak tersedia.

Masalah ini mirip dengan pola di sistem lain ketika pengawasan lemah: perilaku tertentu bisa tampak sah dari luar, padahal secara internal ada celah yang memberi “keuntungan” pada request yang kebetulan menang balapan. Analogi dengan isu insentif dan manipulasi pasar pada berita telur berguna di sini: saat kontrol lemah, hasil akhirnya bisa menguntungkan pihak tertentu tanpa terlihat jelas pada pandangan pertama. Dalam backend stok, “pihak tertentu” itu bukan aktor bisnis, melainkan request yang berhasil lolos dari race condition.

Gejala Nyata di Produksi

Bug ini jarang pertama kali terdeteksi dari kode. Biasanya tim menemukannya dari efek samping operasional.

  • Stok minus di database atau panel admin.
  • Order sukses melebihi inventory, misalnya stok awal 100 tetapi order paid mencapai 117.
  • Log inkonsisten: ada log “stok tersedia” dari beberapa request untuk produk yang sama pada timestamp nyaris identik.
  • Refund meningkat karena order dibatalkan setelah pembayaran.
  • Perbedaan data antar sistem: database menyatakan stok habis, cache masih menampilkan tersedia.

Jika gejalanya muncul hanya saat lonjakan trafik, besar kemungkinan penyebabnya bukan bug logika sederhana, melainkan bug konkurensi.

Root Cause yang Paling Umum

1. Read-then-write yang tidak atomik

Pola paling klasik adalah:

  1. Request membaca stok saat ini.
  2. Jika stok lebih dari 0, request melanjutkan.
  3. Request mengurangi stok lalu menyimpan hasilnya.

Masalahnya, dua request bisa membaca stok yang sama sebelum salah satunya sempat menulis perubahan. Keduanya merasa stok masih tersedia, lalu sama-sama mengurangi. Inilah inti race condition.

// Contoh pola rawan race condition (pseudocode)
stock = SELECT available_stock FROM products WHERE id = :product_id
if stock >= qty:
  INSERT INTO orders (...)
  UPDATE products SET available_stock = stock - qty WHERE id = :product_id
  return success
else:
  return out_of_stock

Pola di atas terlihat benar pada pengujian tunggal, tetapi gagal saat request paralel.

2. Transaksi database ada, tetapi tidak benar-benar aman

Banyak tim merasa aman karena sudah memakai transaksi. Padahal transaksi saja tidak otomatis mencegah lost update. Jika Anda tetap melakukan read-then-write tanpa lock yang tepat, dua transaksi bisa tetap membaca nilai lama lalu menulis hasil yang saling menimpa, tergantung isolation level dan bentuk query.

Kesalahan umum:

  • Mengira BEGIN/COMMIT otomatis menyelesaikan race condition.
  • Tidak menggunakan SELECT ... FOR UPDATE saat memang butuh lock per baris.
  • Mengandalkan isolation level default tanpa memahami perilakunya.

3. Retry tanpa idempotency

Saat payment callback timeout, worker gagal sesaat, atau API gateway melakukan retry, request yang sama bisa diproses lebih dari sekali. Jika endpoint reserve stock atau create order tidak idempotent, stok dapat berkurang berkali-kali untuk satu intent bisnis yang sama.

Ini sering sulit dideteksi karena dari sisi log terlihat seperti request yang sah, apalagi jika request-id baru dibuat pada setiap retry.

4. Cache stale atau write-through yang tidak konsisten

Beberapa sistem membaca stok dari cache untuk kecepatan, tetapi menulis stok ke database terlebih dahulu dan melakukan invalidasi cache belakangan. Pada lonjakan trafik, jendela stale beberapa ratus milidetik saja sudah cukup untuk membuat banyak request melihat stok yang seolah masih tersedia.

Masalah makin parah jika:

  • Cache invalidation gagal diam-diam.
  • Ada beberapa jalur penulisan stok dengan perilaku berbeda.
  • Stock check dilakukan di cache, tetapi pengurangan stok dilakukan di database tanpa sinkronisasi ketat.

Studi Kasus Singkat: Bagaimana Double-Sell Terjadi

Misalkan produk A punya stok 1. Dua request checkout, R1 dan R2, masuk hampir bersamaan.

  1. R1 membaca stok = 1.
  2. R2 membaca stok = 1.
  3. R1 membuat order dan mengurangi stok menjadi 0.
  4. R2, yang sudah telanjur percaya stok = 1, juga membuat order dan menulis stok menjadi 0 atau -1, tergantung implementasi.

Dari sisi user, keduanya melihat order sukses. Dari sisi bisnis, satu order harus direfund. Dari sisi audit, bug ini tampak “masuk akal” karena tiap request sendiri-sendiri terlihat valid. Ini mirip sistem insentif yang buruk: tanpa kontrol yang memadai, hasil yang salah bisa muncul tanpa ada satu langkah pun yang tampak mencurigakan secara individual.

Langkah Investigasi di Produksi

1. Tetapkan timeline per order dan per produk

Jangan mulai dari asumsi. Ambil satu SKU bermasalah dan susun timeline:

  • stok awal,
  • waktu order dibuat,
  • waktu payment berhasil,
  • waktu stok dikurangi,
  • waktu cache diperbarui,
  • waktu refund dibuat.

Tujuannya adalah membuktikan urutan kejadian, bukan hanya mencari anomali akhir.

2. Periksa query yang benar-benar dipakai aplikasi

Lihat log SQL atau APM trace. Yang dicari:

  • Apakah aplikasi melakukan read-then-write?
  • Apakah update stok memakai operasi atomik?
  • Apakah ada SELECT ... FOR UPDATE atau lock lain?
  • Apakah satu order bisa memicu beberapa pengurangan stok?

Contoh query audit yang berguna:

-- Cari produk dengan stok negatif
SELECT id, sku, available_stock
FROM products
WHERE available_stock < 0;

-- Bandingkan inventory dengan order sukses untuk SKU tertentu
SELECT p.sku,
       p.available_stock,
       COUNT(o.id) AS paid_orders,
       SUM(oi.quantity) AS sold_qty
FROM products p
JOIN order_items oi ON oi.product_id = p.id
JOIN orders o ON o.id = oi.order_id
WHERE p.id = :product_id
  AND o.status IN ('PAID', 'PROCESSING', 'COMPLETED')
GROUP BY p.sku, p.available_stock;

-- Deteksi order duplikat dari idempotency key atau external reference
SELECT external_ref, COUNT(*)
FROM orders
WHERE created_at >= :start_time
GROUP BY external_ref
HAVING COUNT(*) > 1;

Struktur tabel tiap sistem berbeda, tetapi arah investigasinya sama: cocokkan inventory aktual dengan jumlah order yang dianggap sukses.

3. Periksa log aplikasi dengan korelasi yang benar

Minimal, log yang ingin Anda punya:

  • request_id
  • idempotency_key
  • product_id atau sku
  • order_id
  • payment_reference
  • old_stock dan new_stock jika memungkinkan

Contoh pola log yang sering mengungkap masalah:

[10:00:00.101] request_id=R1 sku=EGG-01 stock_check available=1
[10:00:00.103] request_id=R2 sku=EGG-01 stock_check available=1
[10:00:00.120] request_id=R1 sku=EGG-01 stock_update from=1 to=0 success=true
[10:00:00.121] request_id=R2 sku=EGG-01 stock_update from=1 to=0 success=true

Jika kedua request menulis berdasarkan nilai lama yang sama, Anda hampir pasti menghadapi lost update.

4. Cek retry dari gateway, worker, dan client

Double-sell tidak selalu murni dari race condition antar user. Kadang satu user memicu beberapa eksekusi karena:

  • mobile app retry saat koneksi buruk,
  • reverse proxy retry request upstream,
  • payment callback dikirim ulang,
  • consumer queue memproses pesan ulang setelah timeout.

Karena itu, audit semua jalur yang dapat mengeksekusi reserve stock atau create order lebih dari sekali.

5. Verifikasi hubungan cache dan database

Pertanyaan yang harus dijawab:

  • Sumber kebenaran stok ada di mana?
  • Request membaca dari cache atau database?
  • Kapan cache di-invalidasi?
  • Apa yang terjadi jika invalidasi gagal?

Jika jawaban atas pertanyaan-pertanyaan ini tidak konsisten antar tim, kemungkinan besar arsitektur stok memang rentan.

Cara Mereproduksi Bug dengan Beban Paralel

Bug konkurensi sulit dibuktikan jika hanya diuji manual. Anda perlu memaksa banyak request paralel ke SKU dengan stok kecil.

# Contoh sederhana dengan xargs + curl
seq 1 50 | xargs -I{} -P 20 curl -s -X POST \
  -H 'Content-Type: application/json' \
  -d '{"product_id":123,"qty":1}' \
  https://api.example.com/checkout

Yang ingin diamati:

  • Berapa request yang sukses?
  • Apakah sukses melebihi stok?
  • Apakah stok akhir menjadi negatif atau tidak cocok?
  • Apakah ada order ganda untuk user atau payment reference yang sama?

Untuk hasil yang lebih andal, gunakan alat load testing yang mendukung concurrency dan assertion, lalu reset data produk sebelum setiap skenario. Fokuskan test pada satu SKU agar race condition terlihat jelas.

Catatan: reproduksi sebaiknya dilakukan di staging dengan konfigurasi yang semirip mungkin dengan produksi, terutama database, queue, dan cache. Race condition sering tidak muncul di laptop karena latensi dan concurrency terlalu rendah.

Opsi Perbaikan yang Praktis

1. Atomic update di database

Pendekatan paling langsung adalah mengurangi stok dengan satu query atomik yang hanya berhasil jika stok masih cukup.

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

Setelah query dijalankan, periksa jumlah baris yang terpengaruh:

  • 1 baris: stok berhasil dipesan.
  • 0 baris: stok tidak cukup atau sudah direbut request lain.

Mengapa ini bekerja? Karena pengecekan ketersediaan dan pengurangan stok dilakukan dalam satu operasi database. Tidak ada celah antara membaca stok dan menulis stok.

Trade-off:

  • Sederhana dan cepat untuk banyak kasus.
  • Tetap perlu desain transaksi yang benar jika ada tabel lain yang harus konsisten bersama order.
  • Jika logika reservasi kompleks, query bisa menjadi lebih sulit dipelihara.

2. Row locking dengan transaksi

Jika alur bisnis menuntut beberapa langkah yang harus konsisten sekaligus, gunakan transaksi dan kunci baris produk saat membaca stok.

BEGIN;

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

-- validasi stok di level aplikasi
-- insert order
-- insert order_items
-- update products set available_stock = available_stock - :qty

COMMIT;

Mengapa ini bekerja? FOR UPDATE mencegah transaksi lain memodifikasi baris yang sama sampai transaksi selesai, sehingga request tidak bisa berlomba memakai stok yang sama.

Trade-off:

  • Lebih aman untuk alur multi-tabel.
  • Dapat meningkatkan lock contention saat flash sale.
  • Jika transaksi terlalu lama, throughput turun dan deadlock lebih mungkin terjadi.

Praktik penting:

  • Pastikan transaksi sesingkat mungkin.
  • Jangan melakukan panggilan jaringan eksternal di dalam transaksi.
  • Kunci resource dalam urutan konsisten untuk mengurangi deadlock.

3. Idempotency key untuk create order dan reserve stock

Setiap intent bisnis yang sama harus punya kunci unik yang sama di semua retry, misalnya dari client atau payment reference. Server menyimpan hasil proses berdasarkan key tersebut.

// Pseudocode
if exists(idempotency_key):
  return previous_response

BEGIN;
  reserve_stock_atomically()
  create_order()
  save_idempotency_result()
COMMIT;

Mengapa ini bekerja? Retry tidak lagi memicu pengurangan stok berulang. Request yang identik hanya mengembalikan hasil sebelumnya.

Trade-off:

  • Perlu skema penyimpanan dan expiry policy.
  • Harus hati-hati membedakan retry sah dengan request baru yang memang berbeda.

4. Pisahkan reservasi stok dari konfirmasi pembayaran

Pada trafik tinggi, pola yang lebih aman adalah:

  1. Checkout membuat reservation stok dengan TTL.
  2. Pembayaran yang berhasil mengonfirmasi reservation menjadi order final.
  3. Reservation kedaluwarsa mengembalikan stok.

Pendekatan ini menurunkan risiko oversell pada proses pembayaran yang lambat, tetapi menambah kompleksitas sinkronisasi dan proses kompensasi.

5. Gunakan outbox untuk event yang harus andal

Jika setelah order dibuat Anda perlu menerbitkan event seperti OrderCreated atau StockReserved ke queue, jangan bergantung pada pola “tulis ke DB lalu publish terpisah” tanpa jaminan. Gunakan transactional outbox: simpan perubahan bisnis dan event outbox dalam transaksi yang sama, lalu proses publisher secara terpisah.

Mengapa ini bekerja? Anda menghindari kondisi di mana order sudah tersimpan tetapi event tidak pernah terbit, atau sebaliknya. Ini penting agar sistem inventory, payment, dan notifikasi tetap sinkron.

Trade-off:

  • Menambah komponen worker/publisher.
  • Perlu deduplikasi di consumer karena pengiriman event umumnya minimal sekali.

6. Hati-hati dengan cache: jangan jadikan cache sebagai sumber kebenaran stok tanpa kontrol kuat

Cache cocok untuk menampilkan estimasi ketersediaan atau mengurangi beban baca, tetapi pengurangan stok final sebaiknya tetap terjadi pada sumber data yang memberi jaminan konkurensi. Jika ingin menggunakan Redis atau sejenisnya untuk reservasi sangat cepat, desainnya harus eksplisit, atomik, dan konsisten dengan database, bukan sekadar “cek cache lalu update DB”.

Contoh Alur yang Lebih Aman

Berikut alur backend yang umum dan cukup kuat untuk banyak kasus:

  1. Terima request checkout dengan idempotency_key.
  2. Mulai transaksi database.
  3. Lakukan atomic update stok atau SELECT ... FOR UPDATE.
  4. Buat order dalam status PENDING_PAYMENT atau RESERVED.
  5. Simpan record idempotency dan outbox event dalam transaksi yang sama.
  6. Commit transaksi.
  7. Publisher outbox menerbitkan event ke broker/queue.
  8. Callback pembayaran mengonfirmasi order menggunakan idempotency yang sama.

Poin pentingnya: operasi kritis berada dalam batas konsistensi yang jelas, sementara komunikasi antarsistem dilakukan secara andal dan dapat diulang.

Kesalahan Umum saat Memperbaiki

  • Hanya menambah mutex di level aplikasi pada satu instance, padahal layanan berjalan multi-instance.
  • Mengandalkan cache lock tanpa memahami failure mode saat lock hilang, timeout, atau split-brain.
  • Memperpanjang transaksi dengan logika non-kritis di dalamnya.
  • Tidak memikirkan retry dari queue dan callback eksternal.
  • Memperbaiki oversell tetapi membiarkan event order dan inventory tetap bisa tidak sinkron.

Metrik dan Observability yang Wajib Dipasang

Jika Anda ingin menangkap masalah lebih cepat pada flash sale berikutnya, observability harus spesifik ke domain stok, bukan hanya CPU dan latency.

Metrik inti

  • stock_reservation_success_total
  • stock_reservation_conflict_total
  • orders_paid_total
  • refund_due_to_oversell_total
  • inventory_negative_total
  • idempotency_replay_total
  • cache_stale_hit_total jika dapat diukur
  • db_lock_wait_duration dan deadlock_total

Log dan tracing

  • Trace satu request dari checkout sampai payment callback.
  • Korelasi berdasarkan request_id, order_id, product_id, payment_reference, dan idempotency_key.
  • Catat hasil atomic update: sukses atau gagal karena stok habis.

Alert yang berguna

  • Stok negatif lebih dari 0.
  • Refund karena stok habis melampaui baseline normal.
  • Lonjakan konflik reservasi pada SKU tertentu.
  • Peningkatan lock wait atau deadlock saat event promo dimulai.

Checklist Pencegahan Sebelum Flash Sale

  1. Pastikan pengurangan stok final memakai operasi atomik atau row locking.
  2. Tambahkan idempotency key pada endpoint kritis dan callback payment.
  3. Audit semua retry dari client, gateway, queue, dan provider eksternal.
  4. Jadikan satu sistem sebagai sumber kebenaran inventory.
  5. Hindari pembacaan dari cache untuk keputusan final tanpa sinkronisasi kuat.
  6. Pasang outbox untuk event penting yang harus konsisten dengan transaksi DB.
  7. Lakukan load test paralel pada SKU dengan stok rendah.
  8. Monitor stok minus, oversell, deadlock, lock wait, refund, dan replay idempotency.
  9. Siapkan runbook incident: cara menonaktifkan SKU, membekukan checkout, dan melakukan rekonsiliasi order.
  10. Uji skenario gagal: callback ganda, queue redelivery, timeout DB, dan invalidasi cache gagal.

Penutup

Debug race condition stok tidak selesai dengan satu patch cepat, karena akar masalahnya sering gabungan dari konkurensi database, retry, dan cache yang stale. Cara paling efektif adalah membuktikan race lewat timeline, log, dan reproduksi paralel; lalu memperbaiki titik kritis dengan operasi atomik, transaksi ber-lock yang tepat, idempotency, dan event delivery yang andal.

Saat flash sale, sistem yang kontrolnya lemah bisa menghasilkan outcome yang tampak valid per request tetapi salah secara keseluruhan. Itulah inti double-sell: bukan karena satu langkah besar yang salah, melainkan banyak langkah kecil yang tidak dikunci dengan benar.