Flash sale: 12 giờ trưa, một triệu người tranh vài món hàng

6 câu hỏi~30 phútTừ khóa: race condition, atomic claim, SET NX, DECR, Lua, waiting room, rate limit, oversell, async order, source of truth

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)

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áchChọn winner bằngNhiều instanceKhi nào dùng
Atomic DB UPDATEAtomicity của DB✅Traffic vừa, đơn giản nhất
Redis SET NX / DECRLệnh atomic của Redis✅Traffic cực lớn, hot path
Queue rồi lấy message đầuThứ tự trong queue⚠️Không nên dùng để chọn winner
synchronizedLock trong 1 JVM❌Chỉ 1 instance

11. Khi nào nên / không nê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ớpChặn gì
Redis LuaChặn 99% traffic, đảm bảo không trừ quá stock ở hot path
DB: UPDATE inventory SET qty = qty - 1 WHERE id = ? AND qty > 0Lướ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ậtMục đíchĐổi lại
Rate limitCắt bot, spamUser thật bị 429 nếu ngưỡng chặt
Waiting roomSan phẳng đỉnh, UX công bằngThêm thành phần, thêm độ trễ
Sold-out flagBỏ round-trip sau khi hếtLệ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 > 0 và 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 > 0 and 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


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.