Reservasi stok terbatas
Modelkan hold, kedaluwarsa, konfirmasi, pembatalan, dan klaim bersamaan supaya barang terakhir nggak terjual dua kali.
Pertanyaan
Bagaimana menahan barang terakhir untuk satu pembeli tanpa menjualnya dua kali atau menguncinya selamanya?
Setelah baca ini, kamu bisa menggambar siklus hidup sebuah reservasi, menulis klaim atomik untuk stok terbatas, dan menjelaskan apa yang terjadi saat hold kedaluwarsa, dibatalkan, atau dikonfirmasi dua kali.
Prasyarat dan contoh
Kamu sudah paham invarians (M05.01), pembaruan atomik dan retry (M05.03), dan identifier (M05.04).
Flash sale: 500 unit, 20.000 pembeli dalam 10 detik pertama, kira-kira 2.000 klaim/detik pada satu produk. Pembeli butuh waktu hingga 10 menit untuk membayar. Dua kegagalan klasik: menjual unit ke-501 (oversell), atau menahan semua unit oleh pembeli yang nggak pernah membayar sehingga produk "habis" padahal nggak terjual.
Mekanisme
Model dengan tiga hitungan. Per produk simpan total, ditahan (hold aktif), dan terjual.
Yang tersedia: total - ditahan - terjual. Invarians: ditahan + terjual <= total.
Siklus hidup reservasi.
DITAHAN --bayar--> DIKONFIRMASI
| \--batal---> DIBATALKAN (stok kembali)
\---lewat batas waktu---> KEDALUWARSA (stok kembali)
Tiap reservasi punya id, produk, pembeli, status, dan kedaluwarsa_pada.
Klaim atomik. Satu pernyataan menahan stok bila tersedia:
UPDATE stok SET ditahan = ditahan + :n
WHERE produk = 7 AND total - ditahan - terjual >= :n;
-- 1 baris = berhasil, lanjut INSERT reservasi dalam transaksi yang sama
-- 0 baris = habis
Pengecekan dan penambahan terjadi dalam satu langkah di bawah kunci baris, jadi 2.000 klaim/detik berurutan di baris itu tanpa oversell. Inilah biaya yang diterima: satu baris produk menjadi titik serial. Kalau satu baris nggak sanggup, pecah stok jadi beberapa "keranjang" (misalnya 10 baris masing-masing 50 unit) dan klaim ke satu keranjang yang masih punya sisa. Invarians tetap dijaga per keranjang.
Konfirmasi dengan transisi bersyarat.
UPDATE reservasi SET status = 'DIKONFIRMASI'
WHERE id = :rid AND status = 'DITAHAN' AND kedaluwarsa_pada > now();
Lalu pindahkan hitungan: ditahan - 1, terjual + 1, di transaksi yang sama. Jika 0 baris
terpengaruh, hold sudah kedaluwarsa atau dibatalkan, dan pembayaran harus dikembalikan atau
ditangani.
Kedaluwarsa. Dua pendekatan yang bisa digabung:
- Lazy: hitung hold yang
kedaluwarsa_padasudah lewat sebagai tersedia saat menghitung stok. Nggak butuh pekerjaan latar, tapi hitungan jadi query agregat. - Sweeper: pekerjaan periodik (misalnya tiap 10 detik) mengubah reservasi lewat batas jadi
KEDALUWARSAdan mengurangiditahan, per batch (misalnya 500 baris) dengan transisi bersyaratWHERE status = 'DITAHAN'agar aman dijalankan dua kali.
Retry dan idempotensi. Klien yang mengulang "tahan 1 unit" karena timeout jangan menahan dua
unit. Kirim kunci_idempoten (misalnya UUID per percobaan checkout) dengan UNIQUE pada
(pembeli, kunci_idempoten). Ulangan mengembalikan reservasi yang sama.
Pembatalan. Mirip kedaluwarsa: transisi DITAHAN -> DIBATALKAN bersyarat, lalu
ditahan - 1. Pembatalan setelah DIKONFIRMASI adalah proses lain (refund), bukan pelepasan hold.
Saat rancangan ini gagal
- Cek dulu, tahan kemudian.
SELECTstok laluUPDATEtanpa syarat diWHEREmenjual melebihi stok. - Hold tanpa batas waktu. Bot yang menahan 500 unit dan nggak pernah membayar menghabiskan stok. Batasi waktu hold (misalnya 5 sampai 10 menit) dan jumlah hold per pembeli.
- Sweeper tidak aman diulang. Dua instance sweeper yang mengurangi
ditahanuntuk reservasi yang sama membuat hitungan jadi tidak benar. Selalu transisi bersyarat. - Konfirmasi setelah hold lewat. Pembeli membayar pada menit ke-11. Kebijakan harus eksplisit: tolak dan refund, atau terima bila stok masih ada. Jangan dibiarkan sebagai kebetulan.
- Satu baris panas. Pada beban jauh di atas ribuan klaim/detik per produk, antrean di kunci baris menaikkan latensi. Ukur lalu pecah ke keranjang atau pakai antrean di depan.
- Menganggap cache stok sebagai kebenaran. Tampilan "sisa 3" boleh usang. Keputusan klaim tidak boleh.
Latihan
Konser punya 1 kursi tersisa. Pembeli A menekan "pesan" pada detik 0 dan memegang hold 5 menit. Pembeli B menekan "pesan" pada detik 2. Pada menit ke-5 lewat 3 detik, A membayar dan di saat yang sama sweeper berjalan. B mencoba lagi pada menit ke-5 lewat 2 detik.
Jelaskan apa yang harus terjadi dan transisi mana yang menjamin hasilnya.
Lihat pembahasan
Detik 0: klaim A berhasil (total - ditahan - terjual = 1 >= 1), ditahan = 1. Detik 2: klaim B
mendapat 0 baris (tersedia 0), B diberi "habis, kami kabari kalau dilepas".
Menit ke-5 lewat 2 detik: hold A sudah lewat batas tapi sweeper belum jalan. Dengan pendekatan
lazy, kursi dihitung tersedia sehingga klaim B berhasil, dan ditahan dikoreksi oleh sweeper
untuk reservasi A. Dengan hanya sweeper, B ditolak sampai sweeper berjalan.
Menit ke-5 lewat 3 detik: A mencoba konfirmasi dan sweeper menandai A kedaluwarsa bersamaan.
Keduanya UPDATE reservasi ... WHERE status = 'DITAHAN'. Yang menang menentukan hasil:
- Konfirmasi A menang hanya bila
kedaluwarsa_pada > now()masih benar. Karena sudah lewat, konfirmasi mendapat 0 baris, A ditolak dan dananya dikembalikan. - Sweeper menang: status
KEDALUWARSA,ditahan - 1.
Kursi tidak pernah dimiliki dua orang karena kursi hanya berpindah lewat klaim atomik dan setiap transisi status bersyarat. Kebijakan yang perlu ditulis eksplisit: A yang membayar terlambat ditolak, bukan "mungkin diterima".
Terkait
- M05.03 Koordinasikan perubahan konkuren adalah alat dasar untuk klaim atomik.
- M05.06 Koordinasikan pekerjaan bertahap membahas apa yang terjadi saat pembayaran ada di layanan lain.
- Lab L05 (kursi terakhir yang tersedia) dan rancangan D04 melatih skenario ini. Keduanya belum tersedia.
- M10 membahas antrean untuk meredam lonjakan di depan klaim.
Catatan
- Asumsi: satu database relasional sebagai sumber kebenaran stok. Replikasi dan failover (M08) mengubah jaminannya dan nggak dibahas di sini.
- Angka 2.000 klaim/detik dan 10 menit adalah contoh. Ukur kapasitas baris panas di sistemmu.
- Yang nggak dibahas: ruang tunggu virtual, anti-bot, dan penetapan harga.
- Terakhir ditinjau: 26 Oktober 2026.