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 IMMEDIATElewat
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 IMMEDIATEmenjadi 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.