Mengelola inventaris aset unik (kuantitas = 1)—seperti lelang barang bekas industri, unit surplus operasional perkeretaapian layaknya model sbbresale.ch, atau reservasi nomor plat kendaraan—memiliki tantangan konkurensi yang berbeda dibandingkan e-commerce retail konvensional. Pendekatan decrement stok sederhana (stock = stock - 1) tidak memadai karena status unit bersifat biner: tersedia atau tidak.
Ketika dua klien melakukan checkout pada milidetik yang sama, kegagalan menangani konkurensi menyebabkan double claim. Di sisi lain, retry otomatis akibat jaringan terputus dapat memicu duplikasi reservasi, dan pengguna yang meninggalkan halaman pembayaran memicu stale hold (aset terkunci tanpa pernah dibayar). Artikel ini membedah perancangan kontrak API, manajemen state machine atomik pada basis data relasional tanpa beban distributed lock berlebih, serta siklus hidup pembersihan lock kedaluwarsa.
State Machine dan Pola Hold-and-Commit
Pola Hold-and-Commit memisahkan fase penguncian sementara (temporary reservation) dari finalisasi pembayaran (commit). Model ini mencegah unit terjual ke pihak lain selama jendela checkout, sekaligus menjamin unit kembali tersedia jika transaksi dibatalkan.
Siklus hidup status aset didefinisikan ke dalam tiga status utama:
- AVAILABLE: Aset bebas diklaim oleh pembeli mana pun.
- HELD: Aset dikunci secara eksklusif oleh satu pemesan untuk periode waktu terbatas (misal: 10 menit). Tidak ada transaksi lain yang boleh mengambil aset ini selama periode aktif.
- SOLD: Aset telah dibayar penuh. Status ini bersifat terminal dan tidak dapat diubah kembali.
Desain Kontrak API dan Semantik Idempotensi
Semua mutasi reservasi wajib menyertakan header Idempotency-Key. Header ini mencegah duplikasi alokasi hold saat klien melakukan retry akibat network timeout.
1. Endpoint Pembuatan Reservasi (Hold)
Klien mengirimkan request pembuatan reservasi dengan menyertakan durasi atau token unik pengguna.
POST /v1/assets/EQ-88392/reservations
Idempotency-Key: 7b84c798-289b-4b2a-a929-873d6b0bf5d2
Content-Type: application/json
{
"buyer_id": "usr_01HZX48K7N2A"
}Respon Berhasil (Reservasi Baru Terbentuk): 201 Created
{
"reservation_id": "res_01HZX49R8T39",
"asset_id": "EQ-88392",
"buyer_id": "usr_01HZX48K7N2A",
"status": "HELD",
"expires_at": "2023-10-25T14:30:00Z",
"created_at": "2023-10-25T14:20:00Z"
}Respon Idempotent Replay: 200 OK
Jika request dengan Idempotency-Key yang sama dikirim ulang dan reservasi yang dibuat sebelumnya masih valid, server mengembalikan status 200 OK dengan payload data reservasi yang tersimpan di cache/database, bukan membuat reservasi baru atau melempar error konflik.
Respon Kegagalan Konkurensi: 409 Conflict
Jika aset telah berada pada status HELD oleh pembeli lain atau sudah SOLD, server menolak permintaan secara instan.
{
"error_code": "ASSET_UNAVAILABLE",
"message": "Asset is currently reserved by another buyer or already sold."
}2. Endpoint Commit dan Release
POST /v1/reservations/{reservation_id}/commit: Dipanggil setelah gateway pembayaran memverifikasi transaksi. Mengubah status aset menjadiSOLD. Mengembalikan200 OK.POST /v1/reservations/{reservation_id}/release: Dipanggil jika pembeli membatalkan pesanan secara eksplisit. Mengembalikan status aset menjadiAVAILABLEsecara instan tanpa menunggu waktu TTL habis.
Transisi State Atomik Menggunakan Conditional Update
Penggunaan distributed locking seperti Redis Redlock sering kali menimbulkan kompleksitas operasional yang tidak sebanding untuk aset inventaris tunggal. Penanganan konkurensi dapat diselesaikan pada level database engine menggunakan Atomic Conditional Update berstandar ACID.
Struktur tabel minimal untuk aset dan reservasi:
CREATE TABLE assets (
id VARCHAR(64) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
hold_expires_at TIMESTAMPTZ NULL,
current_reservation_id VARCHAR(64) NULL,
version INT NOT NULL DEFAULT 1
);
CREATE TABLE reservations (
id VARCHAR(64) PRIMARY KEY,
asset_id VARCHAR(64) NOT NULL REFERENCES assets(id),
buyer_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) UNIQUE NOT NULL,
status VARCHAR(20) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Eksekusi Hold Atomik dalam Satu Query
Query berikut menangani dua kondisi sekaligus: mengambil aset yang berstatus AVAILABLE, atau mengambil aset yang berstatus HELD namun masa berlakunya (TTL) telah kedaluwarsa (stale hold recovery).
-- ponytail: conditional update via RDBMS single row lock, upgrade ke pg_advisory_lock jika logic checkout multi-item.
UPDATE assets
SET
status = 'HELD',
hold_expires_at = NOW() + INTERVAL '10 minutes',
current_reservation_id = :new_reservation_id,
version = version + 1
WHERE id = :asset_id
AND (
status = 'AVAILABLE'
OR (status = 'HELD' AND hold_expires_at < NOW())
);Database secara internal memanfaatkan row-level write lock saat mengeksekusi klausa WHERE. Jika ada 100 request paralel yang mencoba mengeksekusi query ini pada baris yang sama, hanya 1 transaksi yang mengubah affected rows = 1. Sisanya akan mendapatkan affected rows = 0.
Jika baris terpengaruh bernilai 0, aplikasi langsung mengembalikan respons 409 Conflict ke pemanggil tanpa memerlukan pessimistic lock (SELECT ... FOR UPDATE) yang berpotensi menyebabkan connection pool starvation.
Mitigasi Stale Hold: Passive Reclamation vs Active Janitor
Stale hold terjadi ketika pembeli mengunci barang tetapi menutup browser sebelum menyelesaikan pembayaran. Terdapat dua strategi pemulihan:
- Passive Reclamation (Inline Expiration): Ditangani langsung oleh klausa
OR (status = 'HELD' AND hold_expires_at < NOW())pada query pemesanan baru di atas. Keunggulannya: beban komputasi rendah, barang otomatis ter-unlock saat ada peminat baru yang masuk. - Active Janitor (Background Sweeper): Diperlukan agar listing publik tetap menampilkan status terkini tanpa menunggu user berikutnya mengklik tombol reservasi. Jalankan scheduled task (cron job atau background worker) setiap 30–60 detik untuk memperbarui status aset yang kedaluwarsa.
UPDATE assets
SET
status = 'AVAILABLE',
hold_expires_at = NULL,
current_reservation_id = NULL
WHERE status = 'HELD'
AND hold_expires_at < NOW();Alur Transaksi Commit Saat Pembayaran Berhasil
Saat webhook gateway pembayaran diterima, lakukan verifikasi bahwa reservasi belum kedaluwarsa sebelum mengubah status ke SOLD:
UPDATE assets
SET
status = 'SOLD',
hold_expires_at = NULL
WHERE id = :asset_id
AND status = 'HELD'
AND current_reservation_id = :reservation_id
AND hold_expires_at >= NOW();Penting: Jika update commit menghasilkan
affected rows = 0karena TTL terlanjur habis dan aset telah diambil oleh pembeli lain, sistem pembayaran wajib segera mengeksekusi auto-refund kepada pemesan pertama.
Catatan Implementasi dan Trade-off
- Sinkronisasi Waktu Server: Query harus selalu mengandalkan fungsi waktu database server (
NOW()atauCURRENT_TIMESTAMP), bukan waktu lokal instance aplikasi, untuk mencegah ketidaksinkronan akibat clock drift antar instance worker. - Penyimpanan Idempotensi: Tabel atau cache idempotensi harus menyimpan response payload lengkap selama 24 jam. Klien yang melakukan network retry harus menerima payload yang identik dengan status kode
200 OK. - Beban Kontensi Tinggi: Jika satu aset diperebutkan oleh ribuan pengguna dalam event flash sale aset unik, menolak langsung dengan
409 Conflictadalah strategi paling aman bagi database performa tinggi, daripada menumpuk antrean hold yang akhirnya terbuang.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!