Flash sale: 12 giờ trưa, một triệu người tranh vài món hàng
Nếu có người hỏi: 12 giờ có 1 slot sale, khách tranh nhau vào mua, để chọn được người nhanh nhất thì thiết kế hệ thống thế nào?
Đúng kiểu câu hỏi System Design rất hay. Điểm quan trọng nhất là định nghĩa "người nhanh nhất" ở đâu. Mình sẽ không để nhiều request cùng update DB rồi hy vọng DB chọn đúng người. Mọi request phải đi qua một atomic gate: chỉ một request claim được slot.
1. Bài toán (requirements)
- Lúc 12:00 mở bán. Biến thể A: 1 slot. Biến thể B: 10.000 sản phẩm.
- Không được oversell (bán quá số lượng). Một user chỉ mua được 1 lần.
- Người thua phải nhận phản hồi nhanh ("hết hàng"), không treo.
- Order cuối cùng phải nằm trong DB. DB là source of truth.
2. Ước lượng
1,000,000 user bấm trong ~5 giây
→ ~200,000 request/giây ở đỉnh
→ nhưng chỉ 1 (hoặc 10,000) request thực sự "thắng"
→ 99% traffic là người thua, phải loại càng sớm càng rẻ
Đây là insight chính: hot path phải loại người thua rẻ nhất có thể, chỉ người thắng mới chạm DB.
3. Vấn đề: race condition
if (slot.available()) {
slot.setOwner(userId);
}
Thread A: available = true
Thread B: available = true
A → buy
B → buy
→ cả hai đều tưởng mình thắng.
Check rồi mới set là hai bước. Giữa hai bước, request khác chen vào. Cần gộp check + set thành một thao tác atomic.
4. Giải pháp đơn giản nhất: atomic UPDATE trong DB
Nếu scale chưa quá lớn, chưa cần Redis hay Kafka gì cả.
UPDATE sale_slot
SET status = 'SOLD', winner = :userId
WHERE id = 1
AND status = 'AVAILABLE';
affected rows = 1 → WINNER 🎉
affected rows = 0 → LOSER
DB đảm bảo atomicity. 10.000 request cùng tới thì chỉ một request có affectedRows = 1. Đây đã là câu trả lời phỏng vấn khá tốt. Chi tiết vì sao không dùng synchronized, id tự tăng hay create_date có ở bài Distributed lock.
5. Interviewer hỏi tiếp: "1 triệu người cùng bấm thì sao?"
1,000,000 users → Load Balancer → App instances → DATABASE 💥
một row → hot row / lock contention / connection pool cạn
Lúc này mới đưa Redis vào làm atomic gate ở hot path:
SET sale:12h:winner <userId> NX
User A → SET ... A NX → OK ← WINNER
User B → SET ... B NX → nil
User C → SET ... C NX → nil
6. Kiến trúc tổng thể
1M users (12:00)
│
▼
CDN (trang tĩnh, countdown)
│
▼
API Gateway: rate limit / chống bot
│
▼
Waiting room (token vào cửa)
│
▼
Sale Service (stateless, N instance)
│
▼
Redis atomic gate ── loser → "hết hàng" (trả ngay)
(SET NX / Lua)
│ winner
▼
Queue (Kafka/RabbitMQ)
│
▼
Order Worker → Order DB (UNIQUE constraint)
│
▼
Payment (hold 10 phút) → hết hạn thì trả slot
7. API & data model
POST /sales/{saleId}/claim Idempotency-Key: <uuid>
→ 202 { "status": "PENDING", "claimId": "..." } // thắng gate, order đang tạo
→ 409 { "status": "SOLD_OUT" }
GET /sales/{saleId}/claims/{claimId}
→ { "status": "ORDER_CREATED", "orderId": "..." }
CREATE TABLE sale_order (
id BIGSERIAL PRIMARY KEY,
sale_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL, -- PENDING_PAYMENT, PAID, EXPIRED
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (sale_id, user_id) -- 1 user 1 lần, chặn duplicate cuối cùng
);
8. Tại sao không để Kafka chọn người đầu tiên?
1M request → Kafka → message đầu tiên → winner ?
Được về lý thuyết, nhưng định nghĩa "first" lại phức tạp hơn: nhiều partition thì không có thứ tự toàn cục, user phải chờ consumer xử lý xong mới biết thắng hay thua. Kafka hợp với phần sau khi đã có winner:
Redis → tranh tài nguyên (nhanh, trả lời ngay)
Kafka → xử lý async sau đó (create order, payment, notification)
9. "Nhanh nhất" theo đồng hồ nào?
A click 12:00:00.001, latency 200ms → server nhận 12:00:00.201
B click 12:00:00.050, latency 20ms → server nhận 12:00:00.070
Winner = request đầu tiên được hệ thống authoritative chấp nhận, không phải timestamp client gửi lên. Client sửa được clickTime, nên không bao giờ tin nó.
10. So sánh
| Cách | Chọn winner bằng | Nhiều instance | Khi nào dùng |
|---|---|---|---|
| Atomic DB UPDATE | Atomicity của DB | ✅ | Traffic vừa, đơn giản nhất |
| Redis SET NX / DECR | Lệnh atomic của Redis | ✅ | Traffic cực lớn, hot path |
| Queue rồi lấy message đầu | Thứ tự trong queue | ⚠️ | Không nên dùng để chọn winner |
synchronized | Lock trong 1 JVM | ❌ | Chỉ 1 instance |
11. Khi nào nên / không nên
- Traffic vài trăm request/giây: atomic UPDATE là đủ, đừng thêm Redis cho "đẹp".
- Traffic hàng trăm nghìn/giây vào một món hàng: Redis gate + waiting room + async order.
- Không dùng Redis làm nơi lưu order. Redis chỉ chặn cửa; DB giữ sự thật.
SET ... NX chính xác làm gì, sao nó không bị race như code Java ở trên?SET sale:12h:winner <userId> NX
NX ở đây là cú pháp của Redis và không cho ghi đè à?
Đúng. NX là option của lệnh SET: chỉ SET nếu key chưa tồn tại.
SET sale:12h:winner userA NX → OK (key chưa có, ghi A)
SET sale:12h:winner userB NX → (nil) (key đã có, không ghi đè)
GET sale:12h:winner → "userA"
Điểm quan trọng: Redis làm "kiểm tra key tồn tại + set" như một thao tác atomic. Nếu application tự tách hai bước thì race ngay:
if (!redis.exists(key)) { redis.set(key, userId); } // ❌
Thread A → EXISTS → false
Thread B → EXISTS → false
Thread A → SET A
Thread B → SET B ← ghi đè
Ngược với NX là XX (chỉ SET nếu key đã tồn tại). Bài 1 slot thì NX vừa khít. Thêm EX để key tự hết hạn sau đợt sale:
Boolean won = redis.opsForValue()
.setIfAbsent("sale:12h:winner", userId, Duration.ofHours(1)); // SET NX EX
if (Boolean.TRUE.equals(won)) { publishWinner(userId); } else { throw new SoldOutException(); }
Nếu không phải 1 slot mà là 10.000 sản phẩm thì sao? Vẫn dùng SET NX được không?
Không trực tiếp, vì SET NX chỉ có một chỗ. Với N sản phẩm, ý tưởng vẫn là atomic claim, chỉ đổi từ "set nếu chưa có" thành "trừ nếu còn".
1. Cách ngây thơ: DECR rồi kiểm tra
SET sale:stock 10000
DECR sale:stock → 9999 (≥ 0 → thắng)
...
DECR sale:stock → -1 (< 0 → thua, nhưng số đã âm)
DECR atomic nên không oversell. Nhưng còn hai lỗ: stock bị âm (phải INCR trả lại), và một user bấm 5 lần lấy 5 suất.
2. Lua script: check stock + check user + trừ trong một bước
Redis chạy cả Lua script như một lệnh atomic, không có lệnh nào chen giữa.
-- KEYS[1] = sale:{42}:stock KEYS[2] = sale:{42}:buyers ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2 -- đã mua rồi
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
return -1 -- hết hàng
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1 -- còn lại
@Service
@RequiredArgsConstructor
public class FlashSaleGate {
private final StringRedisTemplate redis;
private static final RedisScript<Long> CLAIM =
RedisScript.of(new ClassPathResource("lua/claim.lua"), Long.class);
public ClaimResult claim(long saleId, String userId) {
String tag = "{" + saleId + "}"; // hash tag: 2 key cùng slot Redis Cluster
Long r = redis.execute(CLAIM,
List.of("sale:" + tag + ":stock", "sale:" + tag + ":buyers"), userId);
if (r == null || r == -1) return ClaimResult.SOLD_OUT;
if (r == -2) return ClaimResult.ALREADY_BOUGHT;
return ClaimResult.WON;
}
}
Hash tag {42} là bắt buộc nếu chạy Redis Cluster: script chỉ được đụng key nằm cùng một slot.
3. Oversell: ba lớp phòng thủ
| Lớp | Chặn gì |
|---|---|
| Redis Lua | Chặn 99% traffic, đảm bảo không trừ quá stock ở hot path |
DB: UPDATE inventory SET qty = qty - 1 WHERE id = ? AND qty > 0 | Lưới an toàn cuối nếu Redis lệch |
DB: UNIQUE(sale_id, user_id) | Chặn trùng order do retry/duplicate message |
Redis có thể mất dữ liệu khi failover (replication async). Vì vậy DB mới là trọng tài cuối cùng; Redis chỉ là bộ lọc nhanh.
4. Hot key
Một key sale:stock nằm trên một node Redis. ~100k ops/giây một node thường chịu được. Nếu vượt, chia stock thành nhiều bucket (stock:0..9, mỗi bucket 1.000), request hash vào một bucket; bucket hết thì thử bucket khác. Đổi lại: logic phức tạp hơn, có thể "hết" sớm ở một bucket.
Một triệu người bấm cùng lúc thì app và Redis có chịu nổi không? Có cần cho người dùng xếp hàng không?
Đúng hướng. Atomic gate giải quyết đúng, nhưng chưa giải quyết tải. Phải giảm traffic trước khi nó tới gate.
1. Chặn từ xa nhất có thể
Client : nút "Mua" disable tới 12:00, chống double click
CDN : trang sale, ảnh, countdown là static
Gateway : rate limit theo user/IP, chặn bot (xem bài 14.4)
Waiting room : chỉ cho X người/giây đi tiếp
Local flag : app biết "SOLD_OUT" thì trả luôn, không gọi Redis
2. Waiting room (virtual queue)
User → GET /sale/42/enter
→ vào hàng chờ, nhận position
→ poll mỗi vài giây (hoặc SSE)
→ tới lượt: nhận admission token (JWT ngắn hạn, 60s)
→ POST /claim kèm token → Gateway verify token
Hàng chờ thường là Redis Sorted Set (ZADD queue score=thứ tự), một job "mở cửa" lấy N người đầu mỗi giây. Người dùng thấy "bạn đang ở vị trí 35.120" thay vì lỗi 503.
3. Sold-out flag
Khi Lua trả về hết hàng, set sale:42:soldout = 1. Mỗi instance cache cờ này local vài giây. Sau khi hết hàng, hầu hết request được trả 409 ngay tại app, không tốn round-trip Redis.
4. So sánh
| Kỹ thuật | Mục đích | Đổi lại |
|---|---|---|
| Rate limit | Cắt bot, spam | User thật bị 429 nếu ngưỡng chặt |
| Waiting room | San phẳng đỉnh, UX công bằng | Thêm thành phần, thêm độ trễ |
| Sold-out flag | Bỏ round-trip sau khi hết | Lệch vài giây (chấp nhận được) |
Redis đã báo thắng nhưng app chết trước khi gửi message tạo order thì sao? Suất đó mất luôn à?
Bạn bắt đúng chỗ khó. Lúc này bài toán chuyển từ concurrency sang reliability.
Redis: stock 9999, buyers có A ✓
App 💥 crash
Kafka: không có event
DB: không có order A
→ suất bị "treo"
1. Ghi nhận winner bền vững ngay trong gate
Thay vì Lua chỉ SADD, cho Lua XADD luôn vào một Redis Stream sale:{42}:claims trong cùng script. Claim và "event cần tạo order" sinh ra cùng lúc, atomic. Worker đọc stream bằng consumer group, tạo order, rồi ACK.
Nếu dùng Kafka, gửi event sau khi gate OK với retry; kèm job đối soát ở bước 3.
2. Worker idempotent
INSERT INTO sale_order(sale_id, user_id, status)
VALUES (?, ?, 'PENDING_PAYMENT')
ON CONFLICT (sale_id, user_id) DO NOTHING;
Message lặp lại không tạo order thứ hai (xem Outbox, Inbox).
3. Hết hạn và đối soát
Order PENDING_PAYMENT quá 10 phút → EXPIRED
→ UPDATE inventory qty + 1 → INCR Redis stock, SREM buyer
Job đối soát mỗi phút:
buyers trong Redis vs sale_order trong DB
có trong Redis, không có trong DB quá 2 phút → tạo lại / trả suất
Câu chốt: Redis quyết định nhanh, DB quyết định cuối cùng, job đối soát kéo hai bên về khớp nhau. Cùng tư duy hold/expire như Ticket Booking.
Phỏng vấn hỏi "thiết kế flash sale" thì trả lời sao?
Q1. Thiết kế hệ thống flash sale 1 slot lúc 12 giờ?
🇻🇳 Tiếng Việt:
Đầu tiên em làm rõ "nhanh nhất" là request đầu tiên được server chấp nhận, không tin timestamp của client. Traffic nhỏ thì em dùng atomic conditional UPDATE trong DB, kiểm tra affected rows. Traffic lớn thì em đặt rate limit và waiting room phía trước, dùng Redis SET NX làm atomic gate để DB không phải tranh một hot row. Winner được publish event để tạo order và payment async. DB vẫn là source of truth.
🇬🇧 English:
First I clarify that "fastest" means the first request accepted by our authoritative server, never a client timestamp. At small scale I use an atomic conditional UPDATE and check affected rows. At large scale I put rate limiting and a waiting room in front, and use Redis SET NX as an atomic gate so the database doesn't fight over one hot row. The winner triggers an event to create the order and payment asynchronously. The database remains the source of truth.
Q2. Bán 10.000 sản phẩm, làm sao không oversell?
🇻🇳 Tiếng Việt:
Em dùng Lua script trong Redis để kiểm tra user đã mua chưa, kiểm tra stock và trừ stock trong một bước atomic. Ở DB em vẫn có conditional UPDATE
qty > 0và unique constraint(sale_id, user_id)làm lưới an toàn cuối, vì Redis có thể mất dữ liệu khi failover.
🇬🇧 English:
I use a Redis Lua script that checks whether the user already bought, checks stock and decrements it in one atomic step. The database still has a conditional UPDATE with
qty > 0and a unique constraint on(sale_id, user_id)as the final safety net, because Redis can lose writes during failover.
Q3. Redis trả về thắng nhưng app crash trước khi tạo order?
🇻🇳 Tiếng Việt:
Em ghi claim vào Redis Stream ngay trong cùng Lua script, worker tạo order idempotent bằng unique constraint. Order chưa thanh toán sẽ hết hạn và trả suất lại. Một job đối soát so Redis với DB để sửa phần lệch.
🇬🇧 English:
I append the claim to a Redis Stream inside the same Lua script, and an idempotent worker creates the order protected by a unique constraint. Unpaid orders expire and release the unit. A reconciliation job compares Redis with the database and fixes any drift.
Cách nhớ ngắn gọn
- Bài toán thật là race condition → cần atomic claim, không phải "sort ai đến trước".
- Nhỏ: DB conditional UPDATE. Lớn: rate limit → waiting room → Redis gate.
- 1 slot: SET NX. N slot: Lua (check user + check stock + DECR).
- Redis chọn nhanh, queue xử lý sau, DB phán quyết cuối, job đối soát dọn lệch.
Tự kiểm tra
Vì sao không tin clickTime client gửi lên?
Client sửa được, và đồng hồ các máy lệch nhau. Winner là request đầu tiên server authoritative chấp nhận.
EXISTS rồi SET khác SET NX thế nào?
Hai lệnh tách rời nên request khác chen giữa được. SET NX làm check + set atomic trong một lệnh.
Vì sao bán N sản phẩm nên dùng Lua thay vì chỉ DECR?
Để gộp kiểm tra user đã mua, kiểm tra stock và trừ stock thành một bước atomic, tránh stock âm và một người mua nhiều suất.
Waiting room giải quyết vấn đề gì?
San phẳng đỉnh traffic: chỉ cho X người/giây đi tiếp bằng admission token, bảo vệ app và Redis, UX công bằng hơn lỗi 503.
Nếu Redis failover mất vài lệnh DECR thì có oversell không?
Không ở mức order, vì DB còn conditional UPDATE qty > 0 và unique constraint làm lưới an toàn cuối.