kasus /

Race oversell: locking, version, dan idempotency di checkout

memeriksa

9 Oktober 2026 · go · sqlite · buka demo ↗

Demo langsung

Layar penuh ↗

Demo berjalan sebagai aplikasinya sendiri. Kalau gagal dimuat di sini, berarti memang sedang mati — buka layar penuh untuk memastikan, atau kembali lagi nanti. Penjelasan di bawah tetap berdiri sendiri.

buka demo ↗

Problem

Flash sale. Tersisa lima unit. Dua puluh lima pembeli menghantam checkout di detik yang sama, dan setiap request mereka melakukan tiga hal yang sama: baca stok, putuskan stoknya cukup, tulis penjualan. Dua dari request itu membaca "5" yang sama — dan keduanya diberi jawaban ya. Oversell itu bukan bug yang ditulis siapa pun; ia adalah celah antara memeriksa dan bertindak yang melekat pada setiap alur check-then-act. Sistem pembayaran, peta kursi, kode promo, terraform state lock, leader Kubernetes: bentuk sama, lobang sama.

Context

Demo ini menjual satu SKU (5 unit) di bawah tiga strategi concurrency — schema identik, trafik identik (25 pembeli konkuren per rush), kode Go identik kecuali cara purchase menjaga jendela check-then-act-nya. Setiap percobaan dijurnal beserta strategi dan hasilnya, sehingga setelah setiap rush Anda bisa membaca persis siapa yang diberi jawaban "confirmed" untuk unit yang tidak pernah ada.

Satu catatan implementasi yang sekaligus adalah pelajarannya: handle SQLite di demo ini sengaja membuka beberapa koneksi untuk strategi no-lock. Dengan satu koneksi, database/sql menyeragamkan urutan setiap statement dan race-nya menghilang — bug itu disembunyikan oleh konfigurasi deployment, bukan diperbaiki. Jalankan kode yang sama di connection pool sungguhan (atau RDS dengan 200 koneksi), bug itu kembali. Run test pertama saya "lolos" justru karena alasan ini: centang hijau, rasa aman palsu.

Alternatives Considered

Optimistic concurrency (kolom version). Setiap baris SKU membawa version. Purchase membaca stok+version, menulis UPDATE ... WHERE version = ?, dan — ini bagian yang sering dilupakan orang — memutuskan apa artinya write yang gagal: retry, atau menyerah. Di bawah contention, sebagian besar percobaan kalah, jadi Anda memilih antara badai retry dan pembeli yang kesal. Pendekatan ini benar, tapi kebenarannya datang dari konflik, dan konflik itu latensi.

Pessimistic locking (BEGIN IMMEDIATE). Ambil write lock dulu, lalu baca, putuskan, dan tulis di dalam satu transaksi. Pembeli mengantre; kebenaran datang dari penjadwalan. Biayanya adalah throughput di hot row dan, pada database berbasis jaringan, lock wait yang muncul sebagai p99 Anda.

Reservasi Redis / masukkan semuanya ke queue. Pindahkan penghitung stok keluar dari database, atau terima order ke dalam queue lalu selesaikan satu per satu. Keduanya sah pada skala besar; keduanya menambah satu batas infrastruktur — dan satu pertanyaan konsistensi baru ("bagaimana kalau reservation service mati setelah pembayaran?") — bahkan sebelum race-nya selesai dijawab.

Demo menjalankan ketiga strategi in-process terhadap satu file SQLite sungguhan, sehingga satu-satunya variabel adalah strateginya.

Solution

Ketiga kode purchase-nya:

// no-lock: the bug. Check and act are two separate moments.
var stock int
_ = db.QueryRow(`SELECT stock FROM skus WHERE sku = ?`, sku).Scan(&stock)
if stock <= 0 { return reject() }
_, _ = db.Exec(`UPDATE skus SET stock = stock - 1 WHERE sku = ?`, sku)

// optimistic: the check and the act share a guard.
res, _ := db.Exec(`UPDATE skus
    SET stock = stock - 1, version = version + 1
    WHERE sku = ? AND version = ?`, sku, version)

// pessimistic: everyone waits their turn.
tx, _ := db.Begin()            // first statement grabs the write lock
_, _ = tx.Exec(`UPDATE _lock_anchor SET x = x + 1`)
// ...read, decide, deduct inside the tx...
_ = tx.Commit()

Diukur pada rush demo (25 pembeli, 5 unit — jalankan ulang, jurnalnya menyimpan setiap percobaan): no-lock mengonfirmasi 14 order untuk 5 unit dan selesai dengan stok −9; optimistic mengonfirmasi tepat 5 dan mengubah 20 sisanya menjadi penolakan yang bersih; pessimistic mencapai hal sama dengan membuat pembeli mengantre. Order yang oversold diklasifikasikan secara jujur: setelah setiap pemotongan, strategi membaca ulang stok aktual, dan order confirmed yang membuat stok negatif dicatat sebagai oversold — jurnal menampilkan penjualan hantu itu, bukan angka error yang samar.

Dua detail kontrak sama pentingnya dengan strategi. Idempotency adalah constraint di schema, bukan harapan: UNIQUE (sku, buyer) ditambah pemeriksaan fast-path sebelum strategi dijalankan, sehingga request yang di-retry tidak mungkin memotong stok dua kali — menaruh pemeriksaan itu setelah pemotongan adalah bug yang test saya sendiri tangkap saat pengembangan (stok 8 setelah satu pembelian dan satu replay). Klasifikasi menuntut pembacaan pasca-write: dari nilai lokal pembeli yang sudah basi, order oversell tidak bisa dibedakan dari order normal.

Trade-offs

  • Optimistic: pembaca tidak pernah memblokir siapa pun dan happy path

hanya satu round-trip — tapi di bawah contention, retry menggandakan beban justru pada saat Anda paling tidak mampu menanggungnya. Batasi jumlah percobaan (demo mengizinkan 8) dan siapkan jawaban nyata untuk percobaan ke-9. Salah untuk hot row, tepat untuk row yang jarang diperebutkan.

  • Pessimistic: terbatas, terprediksi, mudah dinalar — dan ia menjadikan

database sebagai choke point. Di SQLite, lock-nya adalah file; di Postgres, berupa row lock dan timer idle-in-transaction; pada apa pun yang berbasis jaringan, setiap pengantri memegang satu koneksi. Benar lewat penjadwalan, dibayar dengan tail latency.

  • Idempotency key bukan opsional. Strategi mana pun yang menang,

jaringan akan me-replay request: timeout, retry, double-click. Pembeli pertama yang menyentuh endpoint memiliki key itu; semua yang datang setelahnya menerima jawaban asli mereka. Hasil duplicate di demo adalah sistem yang bekerja dengan benar — tunjukkan saya checkout tanpa itu, saya tunjukkan Anda tagihan ganda.

  • Antrean adalah keputusan produk. "Stok habis" versus "Anda nomor 14

di antrean, restock segera datang" adalah dua bisnis yang berbeda. Pessimistic locking secara alami menghasilkan yang kedua; optimistic menghasilkan yang pertama. Tidak ada yang menjadi jawaban default.

Production Notes

  • Spesifik SQLite: modernc/sqlite tidak punya BEGIN IMMEDIATE lewat

database/sql, sehingga jalur pessimistic mengambil write lock dengan menjadikan sebuah write sebagai statement pertama transaksi. Handle multi-koneksi butuh busy_timeout, atau writer konkuren gagal dengan SQLITE_BUSY alih-alih menunggu — demo mengeset 5s. Dan MaxOpenConns(1) adalah pilihan production yang sah untuk service SQLite kecil, tapi pahami apa yang sedang dilakukannya: ia adalah mutex global yang juga menyembunyikan bug check-then-act.

  • Deadlock yang saya ship saat membangun ini: jalur penolakan

pessimistic mencatat penolakan ke jurnal masih sambil memegang transaksi — dan dengan satu koneksi di pool, write jurnal menunggu selamanya pada koneksi yang dipegang transaksi itu. Akhiri transaksi sebelum menyentuh apa pun lagi yang butuh pool. Test menemukannya sebagai hang timeout 15 detik; di production, ini akan menjadi tumpukan goroutine yang menggantung.

  • Terjemahan ke Postgres: BEGIN IMMEDIATE menjadi perilaku default

first-write dari INSERT/UPDATE atau SELECT ... FOR UPDATE; kolom version bekerja apa adanya; UNIQUE (sku, buyer) bekerja apa adanya. Strategi-strateginya storage-agnostic; hanya mekanika lock-nya yang berbeda.

  • Flash sale sungguhan melakukan satu hal lagi: mereka menahan

reservasi (dengan TTL) saat add-to-cart, bukan saat pembayaran. Race kemudian terjadi antar reservasi, yang murah, dan hanya pemenang yang pernah sampai ke pemeriksaan stok di sisi pembayaran. Strategi di sini adalah bahan bangunannya; reservasi ber-TTL adalah orkestrasi di sekelilingnya.