Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Bagaimana cara mendesain sistem flash sale yang tidak oversell stok?
Jadikan pengecekan stok dan pengurangan stok satu operasi atomic, misalnya UPDATE dengan klausa WHERE yang mensyaratkan stok cukup dan klausa RETURNING, lalu anggap nol baris terdampak sebagai sold out. Tambahkan CHECK constraint agar jumlah tidak pernah negatif. Setelah itu tambahkan reservasi dengan TTL, waiting room, dan order idempotent sesuai kebutuhan traffic.
02Mengapa membaca stok lalu meng-update-nya menyebabkan oversell?
Karena request lain berjalan di antara pembacaan dan penulisan Anda, sehingga beberapa pembeli bisa melihat angka stok yang sama dan sama-sama lolos pengecekan. Dengan stok 1 dan dua pembeli, keduanya membaca 1, keduanya menulis 0, dan dua order terbuat untuk satu unit. Membungkus dua langkah itu dalam transaction tidak membantu di bawah Read Committed.
03Sebaiknya pakai Redis atau Postgres untuk decrement stok flash sale?
Pakai Postgres sebagai sumber kebenaran, karena ia punya CHECK constraint dan baris reservasi yang durable. Tambahkan Redis di depannya, dengan script Lua yang mengecek dan mengurangi dalam satu langkah atomic, bila Anda butuh penolakan sold out yang murah dan throughput lebih tinggi daripada yang diizinkan satu hot row. Hasil sukses dari Redis hanya berarti Anda boleh mencoba Postgres.
04Berapa lama reservasi stok flash sale sebaiknya berlaku?
Dasarkan pada lama alur pembayaran Anda yang sebenarnya, lalu tambahkan margin. Biayanya mudah dihitung: dengan 500 unit dan satu unit per order, hold 10 menit bisa membekukan seluruh 500 unit selama 600 detik bila semua pembeli meninggalkan checkout. Job sweeper harus mengkedaluwarsakan hold yang lewat dan mengembalikan unit ke Postgres dan counter Redis.
05Bagaimana menjaga stok Redis dan database tetap konsisten?
Tetapkan Postgres sebagai pemenang dan bandingkan keduanya secara terjadwal. Bila Redis menunjukkan stok lebih banyak daripada Postgres, koreksi segera dan kirim alert, karena ia akan meloloskan request yang harus ditolak database. Bila Redis menunjukkan lebih sedikit, ia hanya undersell, jadi koreksi ketika selisih yang sama bertahan pada run berikutnya.
Cara mendesain sistem flash sale yang tidak oversell stok: atomic decrement di Postgres dan Redis Lua, reservasi dengan TTL, waiting room, sharded counter, dan rekonsiliasi.
Agar flash sale tidak oversell, jangan pernah membaca stok lalu menulisnya kembali. Kurangi stok secara atomic dalam satu langkah, lewat UPDATE Postgres yang disertai pengecekan stok atau script Redis Lua, dan anggap nol baris terdampak sebagai sold out. Lalu tambahkan reservasi TTL, waiting room, sharded counter, order idempotent, dan rekonsiliasi.
Setiap desain flash sale berawal dari momen buruk yang sama: 500 unit mulai dijual, traffic datang serentak, dan tabel order berakhir dengan 503 baris. Tidak ada yang crash dan tidak ada query yang gagal. Setiap request membaca angka stok yang memang benar pada saat dibaca.
Ini adalah desain yang dikerjakan lewat contoh hitungan, bukan cerita pengalaman, jadi setiap angka di bawah berasal dari asumsi yang disebutkan dengan perhitungan yang ditunjukkan, atau dari dokumentasi PostgreSQL dan Redis. Stack-nya yang biasa saya pakai di satu VPS: Postgres, Redis, dan Node atau NestJS. Sisi ledger inventori, tempat stok adalah jumlah dari pergerakan stok, sudah dibahas di tulisan saya tentang stock movement dan modul inventory ERP. Tulisan ini membahas jam berikutnya: satu counter yang diserbu ribuan pembeli sekaligus.
Mengapa read-then-write membuat stok oversell?
Karena pengecekan dan penulisan adalah dua langkah terpisah, dan request lain berjalan di antara keduanya. Ini race klasik time-of-check to time-of-use. Ambil stok 1 dan dua pembeli. Keduanya menjalankan SELECT dan sama-sama melihat angka 1. Keduanya lolos if. Keduanya menulis 0 dan membuat order. Satu unit, dua order.
Lebih parah lagi bila aplikasi yang menghitung nilai barunya. Misalkan stok 5, pembeli A mengambil 2 dan pembeli B mengambil 1, dan keduanya membaca 5. A menulis 5 dikurangi 2, yaitu 3. B menulis 5 dikurangi 1, yaitu 4. Penulisan terakhir yang menang, sehingga tabel berisi 4 padahal 3 unit sudah terjual dan jawaban yang benar adalah 2. Dua unit muncul entah dari mana. Transaction saja tidak menyelesaikan ini: pada isolation level default, setiap SELECT hanya melihat data yang sudah di-commit, dan tidak ada yang mencegah dua transaction memegang angka basi yang sama.
// Wrong: the check and the write are two round trips. Anything can happen between them.
const { rows } = await pool.query("SELECT available FROM sku_stock WHERE sku = $1", [sku]);
if (rows[0].available >= qty) {
// Two buyers both got here holding the same stale number.
await pool.query("UPDATE sku_stock SET available = $2 WHERE sku = $1", [sku, rows[0].available - qty]);
await createOrder(sku, buyerId, qty);
}
// Right: one statement. The database does the check and the write under the row lock.
const res = await pool.query(
"UPDATE sku_stock SET available = available - $2 WHERE sku = $1 AND available >= $2 RETURNING available",
[sku, qty],
);
if (res.rowCount === 0) throw new SoldOutError(sku); // zero rows = sold out, not an error in the database
Membungkus SELECT dan UPDATE dalam satu transaction tidak membuat keduanya atomic di bawah Read Committed. Gabungkan jadi satu statement seperti di bawah, atau ambil row lock eksplisit dengan SELECT FOR UPDATE dan terima antrean yang menyertainya.
Bagaimana cara decrement stok secara atomic di Postgres?
Taruh pengecekan di dalam UPDATE. Klausa WHERE membawa kondisinya, RETURNING memberi tahu apakah Anda berhasil, dan CHECK constraint membuat jumlah negatif mustahil terjadi walaupun ada code path di masa depan yang lupa aturannya.
CREATE TABLE sku_stock (
sku text PRIMARY KEY,
initial integer NOT NULL, -- what we loaded for the sale, never changes
available integer NOT NULL CHECK (available >= 0) -- the backstop: Postgres refuses to go negative
);
INSERT INTO sku_stock VALUES ('SKU-FLASH-01', 500, 500);
-- The whole "check stock and take it" step. No SELECT first.
UPDATE sku_stock
SET available = available - 2
WHERE sku = 'SKU-FLASH-01'
AND available >= 2 -- re-evaluated after any concurrent writer commits
RETURNING available; -- 1 row back = you got the units. 0 rows = you did not.
Ini aman karena cara Read Committed memperlakukan row yang sedang diperebutkan. Bila UPDATE kedua bertemu row yang sedang diubah transaction lain, ia menunggu transaction itu commit atau rollback, lalu menurut dokumentasi PostgreSQL kondisi pencariannya dievaluasi ulang terhadap versi row yang sudah diperbarui. Telusuri dengan stok 1 dan dua pembeli yang masing-masing minta 1. Pembeli A mengambil row lock, mengubah available menjadi 0, lalu commit. Pembeli B yang tadi menunggu mengecek ulang apakah 0 paling sedikit 1, hasilnya salah, dan tidak ada row yang tersentuh. Kode Anda membaca rowCount 0 sebagai sold out.
Klausa RETURNING juga penting: ia mengembalikan nilai baru dari statement yang sama, jadi Anda tidak perlu SELECT lanjutan, yang berarti membaca ulang angka yang sudah basi. Untuk banyak penjualan, satu statement ini sudah seluruh desainnya. Semua yang ada setelah bagian ini muncul karena satu hot row punya batas throughput, bukan karena statement-nya salah.
Apakah flash sale sebaiknya memakai counter Redis atau script Lua?
Redis cocok sebagai gerbang depan, selama decrement-nya satu langkah atomic. DECRBY sendiri atomic, tetapi ia dengan senang hati membawa stok di bawah nol, dan ia menginisialisasi key yang tidak ada menjadi 0 sebelum berjalan, sehingga counter yang belum di-load menjadi negatif alih-alih gagal. Untuk mengecek dan mengurangi sekaligus, Anda butuh script Lua. Dokumentasi Redis menjamin eksekusi script secara atomic dan menyebut seluruh aktivitas server lain diblokir selama script berjalan, jadi tidak ada client lain yang bisa menyelip di antara GET dan DECRBY.
-- reserve.lua: take qty units and record a hold, as ONE atomic step.
-- KEYS[1] = stock:SKU-FLASH-01 (integer counter, loaded before the sale)
-- KEYS[2] = hold:SKU-FLASH-01:<orderKey>
-- ARGV[1] = qty ARGV[2] = hold TTL in seconds
-- Returns: units remaining (0 or more) | -1 sold out | -2 this order key already holds stock
if redis.call('EXISTS', KEYS[2]) == 1 then
return -2 -- a retry of the same order: do not take stock twice
end
-- GET on a missing key gives Lua false, so "or '0'" makes an unloaded counter read as sold out
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[1])
if stock < qty then
return -1
end
redis.call('DECRBY', KEYS[1], qty)
redis.call('SET', KEYS[2], qty, 'EX', ARGV[2]) -- the hold marker expires on its own
return stock - qty
import Redis from "ioredis";
import { readFileSync } from "node:fs";
const redis = new Redis();
const script = readFileSync("reserve.lua", "utf8");
// eval(script, numberOfKeys, ...keys, ...args). Both keys are passed in KEYS, never built inside the script.
const result = Number(
await redis.eval(script, 2, "stock:SKU-FLASH-01", "hold:SKU-FLASH-01:" + orderKey, 1, 600),
);
if (result === -1) return { status: "sold_out" }; // cheap rejection, Postgres never hears about it
if (result === -2) return { status: "already_held" };
// result >= 0: Redis let you through. Postgres still has the final say.
Dua detail di script itu disengaja. Setiap key yang disentuh dikirim lewat KEYS, karena dokumentasi menyebut script sebaiknya hanya mengakses key yang namanya diberikan sebagai input argument. Dan penanda hold dikunci dengan order key, sehingga retry order yang sama mengembalikan minus 2 alih-alih mengambil stok untuk kedua kalinya. Redis menjawab dari memori, jadi ia bisa bilang sold out ke sebagian besar kerumunan tanpa Postgres pernah melihat mereka. Tetapi ia hanya memberi saran: hasil sukses berarti Anda boleh mencoba Postgres, dan Postgres tetap yang memutuskan.
TTL pada penanda hold di Redis tidak mengembalikan stok. Saat penanda kedaluwarsa, counter tetap berkurang. Mengembalikan unit adalah tugas sweeper di bagian berikutnya, yang juga mendorong angka yang sudah dikoreksi kembali ke Redis. Ingat juga bahwa script cache bersifat volatile: setelah restart atau failover, load ulang script sebelum memanggil EVALSHA.
Bagaimana reservasi dengan TTL dan order idempotent bekerja?
Mengambil stok saat pembeli mengklik tidak sama dengan menjualnya. Pembayaran butuh waktu dan sebagian pembeli tidak pernah menyelesaikannya. Jadi klik membuat reservasi yang menahan unit selama jendela waktu tetap, pembayaran mengonfirmasinya, dan sweeper melepas hold yang sudah lewat. Reservasi punya tiga status: held, confirmed, expired. Tabel reservations juga membawa order_key yang unik, yaitu idempotency key yang dibuat client sekali untuk setiap percobaan checkout.
CREATE TABLE reservations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sku text NOT NULL REFERENCES sku_stock(sku),
buyer_id text NOT NULL,
qty integer NOT NULL CHECK (qty > 0),
order_key text NOT NULL UNIQUE, -- client-generated idempotency key
status text NOT NULL DEFAULT 'held', -- held | confirmed | expired
expires_at timestamptz NOT NULL
);
-- Confirm after payment. Fails (0 rows) if the hold already expired or was confirmed.
UPDATE reservations
SET status = 'confirmed'
WHERE id = $1 AND status = 'held' AND expires_at > now()
RETURNING sku, qty;
-- Release sweeper, every 30 s. Expire holds and give the units back in one statement.
WITH expired AS (
UPDATE reservations
SET status = 'expired'
WHERE status = 'held' AND expires_at <= now()
RETURNING sku, qty
), totals AS (
SELECT sku, sum(qty) AS qty FROM expired GROUP BY sku
)
UPDATE sku_stock s
SET available = s.available + t.qty
FROM totals t
WHERE s.sku = t.sku
RETURNING s.sku, s.available; -- push these numbers back to the Redis counter
Urutan operasi di dalam transaction adalah intinya. Insert reservasi dulu dengan ON CONFLICT DO NOTHING, baru decrement. Bila insert tidak menyentuh baris, order key ini sudah pernah diproses, jadi kembalikan hasil aslinya tanpa mengambil stok. Bila decrement tidak menyentuh baris, lakukan rollback, yang sekaligus menghapus baris reservasi. Pembeli yang double-click atau client yang retry setelah timeout hanya memakan satu unit, bukan dua.
// Idempotent reserve: insert the order key FIRST, decrement second, one transaction.
async function reserve(sku: string, buyerId: string, qty: number, orderKey: string) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const ins = await client.query(
`INSERT INTO reservations (sku, buyer_id, qty, order_key, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '10 minutes')
ON CONFLICT (order_key) DO NOTHING
RETURNING id`,
[sku, buyerId, qty, orderKey],
);
if (ins.rowCount === 0) { // same key seen before: a retry, not a new order
await client.query("ROLLBACK");
return findReservationByKey(orderKey); // hand back the original answer
}
const dec = await client.query(
"UPDATE sku_stock SET available = available - $2 WHERE sku = $1 AND available >= $2 RETURNING available",
[sku, qty],
);
if (dec.rowCount === 0) { // sold out: rolling back also removes the insert
await client.query("ROLLBACK");
return { status: "sold_out" as const };
}
await client.query("COMMIT");
return { status: "held" as const, id: ins.rows[0].id };
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
Tentukan lama hold dari waktu pembayaran yang sebenarnya, lalu tambahkan margin. Angka 10 menit di sini hanya pilihan untuk ilustrasi, dan ada harganya yang bisa dihitung: dengan 500 unit dan satu unit per order, paling banyak 500 hold terbuka, sehingga pada kasus terburuk, ketika semua pembeli pergi, stok beku selama 600 detik. Tampilkan unit yang sedang di-hold terpisah dari unit yang terjual di halaman produk agar pembeli yang melihat sold out tahu bahwa pelepasan stok masih mungkin terjadi.
Bagaimana waiting room meredam beban sebelum sampai ke database?
Database tidak boleh melihat kerumunan, hanya sejumlah pembeli yang sanggup ia layani. Misalkan 20.000 pembeli datang untuk 500 unit, dan asumsikan, sebagai angka yang perlu Anda ganti dengan hasil ukur sendiri, satu decrement menahan row lock selama 5 ms. Itu 1.000 dibagi 5, atau 200 decrement per detik pada satu row. Bila semua 20.000 diloloskan, kerja serialnya 20.000 kali 5 ms, yaitu 100 detik, dengan connection pool penuh oleh request yang menunggu. Waiting room mengubahnya menjadi aliran pendek yang terbatas.
Letakkan flag sold-out di Redis di samping counter. Begitu script Lua mengembalikan minus 1, setiap request berikutnya ditolak dengan respons murah dan tidak pernah sampai ke Postgres.
Sebelum penjualan dibuka, arahkan pembeli ke halaman antrean yang memegang nomor urut, bukan ke checkout. Undian acak pada detik pembukaan lebih adil daripada siapa cepat dia dapat, yang menguntungkan koneksi tercepat.
Loloskan pembeli dengan laju yang sanggup dilayani database Anda, misalnya 200 per detik menurut asumsi di atas, dengan memberi setiap pembeli yang diloloskan token bertanda tangan berumur pendek yang diwajibkan oleh checkout.
Batasi kuantitas per pembeli, dan terapkan rate limit per akun dan per IP, supaya satu orang tidak menghabiskan 500 unit dalam satu klik.
Dengan flag sold-out di depan, jumlah yang sampai ke Postgres kira-kira 500 hold yang berhasil ditambah apa pun yang sudah berjalan, bukan 20.000. Pada 5 ms per request, itu sekitar 2,5 detik waktu lock, bukan 100. Bentuknya sama dengan desain load shedding mana pun: murah dan cepat saat berkata tidak, dan hanya menghabiskan resource mahal untuk request yang masih bisa berhasil.
Bagaimana jika satu hot row menjadi bottleneck?
Ketika satu SKU adalah seluruh penjualan, setiap decrement mengantre pada satu row lock, dan batasnya ditentukan oleh lama lock ditahan. Solusinya memecah counter. Alih-alih satu row berisi 500, simpan 5 row masing-masing 100. Dengan asumsi 5 ms yang sama, setiap row melayani 200 per detik, sehingga lima row memberi batas 1.000 per detik. Request mulai dari shard acak dan maju ke berikutnya bila meleset, dan baru setelah lima kali meleset berturut-turut ia melaporkan sold out.
-- 500 units split across 5 rows of 100. Five rows = five independent locks.
CREATE TABLE sku_stock_shard (
sku text NOT NULL,
shard smallint NOT NULL,
available integer NOT NULL CHECK (available >= 0),
PRIMARY KEY (sku, shard)
);
INSERT INTO sku_stock_shard
SELECT 'SKU-FLASH-01', g, 100 FROM generate_series(0, 4) AS g;
-- Each request starts at a random shard (0-4) and walks forward on a miss.
UPDATE sku_stock_shard
SET available = available - $3
WHERE sku = $1 AND shard = $2 AND available >= $3
RETURNING shard;
-- 0 rows: try shard (n+1) mod 5. Five misses in a row = the SKU is sold out.
-- Real remaining stock is the sum, and only the sum:
SELECT sum(available) FROM sku_stock_shard WHERE sku = 'SKU-FLASH-01';
Sharding punya biaya, dan itu soal hitungan, bukan opini. Sisa stok hanya bermakna sebagai jumlah seluruh shard. Dan bagian ujungnya canggung: bila kelima shard masing-masing tinggal 1 unit, ada 5 unit tersisa, tetapi pembeli yang minta 2 gagal di setiap shard. Batasi kuantitas jadi 1 untuk tahap akhir, atau biarkan sweeper menyeimbangkan ulang unit antar shard. Pembeli kasus terburuk juga membayar hingga lima percobaan berurutan, sekitar 25 ms waktu lock menurut asumsi tadi, yang masih lebih baik daripada mengantre di belakang ribuan orang.
Jangan lakukan sharding sebelum Anda mengukur satu row. Satu UPDATE dengan CHECK constraint jauh lebih mudah dipikirkan, dan waiting room biasanya menghilangkan kebutuhan shard. Tambahkan shard ketika waktu tunggu lock pada satu row itu yang ditunjuk oleh monitoring Anda.
Bagaimana merekonsiliasi stok Redis dengan database?
Dua penyimpanan pasti akan menyimpang, jadi putuskan sejak awal siapa yang menang. Postgres adalah sumber kebenaran karena punya CHECK constraint dan baris reservasi, sedangkan Redis adalah cache di depannya. Query pertama menunjukkan Postgres bisa mengaudit dirinya sendiri: available harus sama dengan jumlah yang di-load dikurangi klaim yang masih hidup, dan setiap baris yang muncul berarti ada bug di salah satu jalur penulisan. Query kedua membandingkan counter Redis dengan Postgres, dan arah selisihnya menentukan tindakan.
-- What Postgres says should be available: what we loaded, minus live claims on it.
SELECT s.sku,
s.available AS db_available,
s.initial - COALESCE(sum(r.qty) FILTER (WHERE r.status IN ('held', 'confirmed')), 0) AS expected
FROM sku_stock s
LEFT JOIN reservations r ON r.sku = s.sku
GROUP BY s.sku, s.initial, s.available
HAVING s.available <> s.initial - COALESCE(sum(r.qty) FILTER (WHERE r.status IN ('held', 'confirmed')), 0);
-- Any row returned is a bug in Postgres itself. It should return nothing, ever.
// Redis is the cache, Postgres is the truth. Compare, then correct Redis, never the other way.
const dbAvailable = Number(
(await pool.query("SELECT available FROM sku_stock WHERE sku = $1", [sku])).rows[0].available,
);
const cached = Number((await redis.get("stock:" + sku)) ?? "0");
if (cached > dbAvailable) {
// Redis thinks there is more than Postgres does: it will let through requests Postgres must reject.
await redis.set("stock:" + sku, dbAvailable);
alertOps("redis oversold by " + (cached - dbAvailable));
} else if (cached < dbAvailable) {
// Redis thinks there is less: it undersells. Safe, and often just a hold in flight,
// so only correct it when the same gap is still there on the next run.
logGap(sku, dbAvailable - cached);
}
// Run it every minute.
Redis lebih tinggi dari Postgres adalah arah yang berbahaya, karena Redis akan meloloskan request yang harus ditolak Postgres, jadi koreksi segera dan kirim alert. Redis lebih rendah dari Postgres hanya berarti penjualan undersell, dan sering kali itu hanya hold yang sedang berjalan, jadi koreksi hanya bila selisih yang sama bertahan di run berikutnya. Dua aturan lagi menutup lingkarannya: sweeper pelepas menulis angka available barunya kembali ke Redis, dan counter selalu dibangun ulang dari Postgres, tidak pernah sebaliknya, bila Redis suatu saat dipulihkan dari kondisi yang lebih lama.
Pendekatan mana yang dipilih, dan apa checklist-nya?
Lapisan-lapisan ini ditumpuk, bukan bersaing. Postgres saja sudah benar dan cukup sampai satu row menjadi batas. Redis di depan membeli throughput dan penolakan murah. Reservasi membuat pembayaran aman. Shard menaikkan batas hanya ketika hasil ukur menunjukkan Anda membutuhkannya.
Pendekatan
Aman dari oversell
Batas throughput
Kegagalan yang perlu direncanakan
Read, lalu write
Tidak
Salah pada concurrency nyata apa pun
Oversell diam-diam dan unit hantu
UPDATE Postgres atomic
Ya
Satu row lock: 1.000 dibagi lama lock dalam ms per detik
Antrean lock di hot row, connection pool habis
Counter Redis Lua
Ya, di dalam Redis
Di memori, satu script pada satu waktu
Menyimpang dari Postgres setelah restart; kedaluwarsa tidak mengembalikan stok
Reservasi dengan TTL
Ya, bila decrement-nya aman
Sama dengan counter di bawahnya
Hold yang ditinggalkan membekukan stok sampai sweeper berjalan
Sharded counter
Ya
Jumlah shard dikali batas satu row
Unit terdampar ketika sisa terbagi tipis
Pakai ini sebagai urutan pembangunan, dan berhenti di langkah pertama yang sudah cukup untuk traffic Anda.
Tulis UPDATE atomic dengan RETURNING dan CHECK constraint, dan anggap nol baris sebagai sold out.
Tambahkan reservasi dengan order key unik, jendela hold yang berdasar waktu pembayaran nyata, dan sweeper pelepas.
Tambahkan gerbang Redis Lua dan flag sold-out ketika koneksi Postgres, bukan logika, yang menjadi batas.
Tambahkan waiting room dan batas per pembeli sebelum penjualan, bukan saat penjualan berlangsung.
Lakukan sharding counter hanya jika waktu tunggu lock pada satu row yang Anda ukur, dan jalankan job rekonsiliasi sejak hari pertama.
Aturan yang dibawa pulang: database yang memutuskan apakah stok ada, dalam statement yang sama yang mengambilnya, dan semua hal lain hanyalah cara menjauhkan kerumunan dari statement itu. Atomic decrement dulu, reservasi kedua, Redis dan antrean ketiga, dan Postgres menang di setiap perselisihan.