M7 · Flash sale
Notification xong rồi. Giờ mình muốn làm flash sale: concert 1000 ghế, 12:00 mở bán, 100k người bấm cùng lúc. Bắt đầu thế nào?
Đây là milestone "đông người nhất". Mục tiêu không phải bán nhanh, mà là bán đúng 1000 vé, không sập, và từ chối 99k người còn lại một cách lịch sự.
1. Bối cảnh & bài toán
Với luồng M1–M5, mỗi lượt giữ ghế đi thẳng xuống Postgres (UPDATE seats ... WHERE status='AVAILABLE'). 100k request trong 5 giây nghĩa là ~20k QPS dồn vào cùng vài nghìn dòng của một event_id:
- Connection pool (mặc định 10) cạn trong vài ms, request xếp hàng, timeout dây chuyền về gateway.
- Row lock tranh chấp nặng, p99 tăng lên hàng chục giây, CPU DB 100%.
- Các event khác (đang bán bình thường) cũng chết theo vì dùng chung DB.
- Nếu ai đó "tối ưu" bằng cách đọc tồn kho rồi trừ ở app → oversell.
Cách giải: lọc ở cửa (rate limit), quyết định ở bộ nhớ (Redis atomic), ghi DB sau (queue). DB chỉ thấy tối đa ~1000 lần ghi thành công cho sự kiện này.
100k user ─▶ gateway (token bucket, Redis) ──429──▶ phần lớn bị chặn
│ ~2k req/s lọt qua
▼
booking-service ─▶ Redis Lua: DECR stock + SADD buyer
│ ├─ 0 còn → SOLD_OUT (410/409)
│ └─ OK → trả 202 + reservationId
▼
queue (Kafka topic flash.reservations) ─▶ worker ghi bookings/seats
(batch, tốc độ cố định)
2. Ôn lại trước khi code
- Flash sale: luồng lọc nhiều tầng, vì sao Redis-first, cách xử lý "trừ Redis nhưng ghi DB lỗi".
- Rate limiting: token bucket vs sliding window, rate limit phân tán bằng Redis.
- Spring Cloud Gateway:
RequestRateLimiter,KeyResolver, filter order. - Redis kiến trúc: single-thread, vì sao script Lua chạy atomic.
- Delivery semantics: worker ghi DB phải idempotent vì at-least-once.
- Connection pool: vì sao giới hạn concurrency vào DB bảo vệ được DB.
- Race condition: check-then-act là nguồn gốc oversell.
3. Đề bài
Yêu cầu chức năng
- Admin "nạp" một flash sale:
event_id, số lượng, giờ mở. Lúc nạp, stock được ghi vào Redis. - Trước giờ mở: trả
425 Too Early(hoặc 403) kèmopensAt. - Gateway rate limit theo user (từ JWT
sub, M10 chưa có thì dùng headerX-User-Id) và một bucket toàn cục cho route flash sale. - Mỗi user mua tối đa 1 vé (limit có thể cấu hình).
- Booking-service trừ stock trong Redis bằng 1 script Lua (kiểm tra user đã mua chưa + còn hàng + trừ + ghi nhận, tất cả atomic).
- Thành công: publish message vào queue, trả
202vớireservationId, statusPENDING. Worker ghibookings+ gán ghế (seats.status = HELD) rồi chuyển sang luồng thanh toán M3/M5 như bình thường. - Client poll trạng thái. Hết hàng: trả
SOLD_OUT, không chạm DB.
API
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/admin/flash-sales | {eventId, quantity, perUserLimit, opensAt} | 201 {flashSaleId} |
| POST | /api/flash-sales/{id}/reservations | {quantity: 1} + header Idempotency-Key | 202 {reservationId, status:"PENDING"} · 409 SOLD_OUT · 409 ALREADY_PURCHASED · 425 NOT_OPEN · 429 (gateway) |
| GET | /api/flash-sales/{id}/reservations/{rid} | – | 200 {status: PENDING|HELD|FAILED, bookingId?, seatCode?} |
| GET | /api/flash-sales/{id}/stock | – | 200 {remaining} (đọc Redis, có thể lệch vài ms) |
429 phải có header Retry-After và body theo error model chung: {"code":"RATE_LIMITED","message":"...","retryAfterSeconds":2}.
Redis keys
flash:{fsId}:stock STRING 1000
flash:{fsId}:buyers SET userId đã đặt
flash:{fsId}:open_at STRING epoch millis
rl:user:{userId} (do RequestRateLimiter quản lý)
rl:global:flash (bucket toàn cục)
Dùng hash tag {fsId} để mọi key của một sale nằm cùng slot nếu sau này chạy Redis Cluster (M11).
Message vào queue (topic flash.reservations, key = flashSaleId hoặc userId — tự chọn và ghi ADR):
{
"reservationId": "uuid",
"flashSaleId": "uuid",
"eventId": "uuid",
"userId": "kc-sub",
"quantity": 1,
"reservedAt": "2026-10-14T12:00:00.123Z"
}
DB (thêm vào booking-service):
CREATE TABLE flash_sales (
id UUID PRIMARY KEY, event_id UUID NOT NULL,
quantity INT NOT NULL, per_user_limit INT NOT NULL DEFAULT 1,
opens_at TIMESTAMPTZ NOT NULL, status TEXT NOT NULL
);
CREATE TABLE flash_reservations (
id UUID PRIMARY KEY, -- reservationId, dùng để dedupe
flash_sale_id UUID NOT NULL REFERENCES flash_sales(id),
user_id TEXT NOT NULL,
status TEXT NOT NULL, -- PENDING / HELD / FAILED
booking_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (flash_sale_id, user_id) -- lưới an toàn cuối cùng
);
Skeleton (chỉ chữ ký, tự viết thân):
// booking-service
public enum ReserveResult { OK, SOLD_OUT, ALREADY_PURCHASED, NOT_OPEN }
public interface FlashStockGateway {
/** Chạy script Lua: kiểm tra giờ mở, user đã mua, còn stock; trừ + SADD atomic. */
ReserveResult tryReserve(UUID flashSaleId, String userId, int qty, long nowMillis);
/** Bù lại khi worker ghi DB thất bại vĩnh viễn. */
void compensate(UUID flashSaleId, String userId, int qty);
}
// resources/lua/reserve.lua — KEYS[1]=stock, KEYS[2]=buyers, KEYS[3]=open_at
// ARGV[1]=userId, ARGV[2]=qty, ARGV[3]=now
// return 1=OK, 0=SOLD_OUT, -1=ALREADY_PURCHASED, -2=NOT_OPEN
@Component
class FlashReservationWorker {
// consume flash.reservations, batch N message, ghi bookings + seats trong 1 tx,
// dedupe theo reservationId (inbox hoặc UNIQUE), ack sau commit.
void onBatch(List<FlashReservationMessage> batch) { /* TODO */ }
}
// gateway: application.yml
spring.cloud.gateway.routes[?].filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: ${FLASH_RL_RATE:2}
redis-rate-limiter.burstCapacity: ${FLASH_RL_BURST:4}
key-resolver: "#{@userKeyResolver}"
Cấu hình cần có: FLASH_RL_RATE, FLASH_RL_BURST, FLASH_GLOBAL_RATE, FLASH_WORKER_CONCURRENCY, FLASH_WORKER_BATCH_SIZE, SPRING_DATA_REDIS_HOST; Hikari pool của worker tách riêng và nhỏ (vd 5).
4. Acceptance criteria
- k6 10k VU, mỗi VU một user khác nhau, bắn trong 60s: tổng
bookingscủa sale = đúng 1000 (không hơn, không kém sau khi worker xong). - Không user nào có hơn
perUserLimitbooking (queryGROUP BY user_id HAVING count(*) > 1trả 0 dòng). - Redis
stockkhông bao giờ âm (ghi lại giá trị min trong suốt test). - Postgres: active connection của booking-service ≤ pool size, CPU DB < 70%, không có lỗi
connection timeouttrong log. - Request bị chặn nhận 429 +
Retry-Aftertrong < 50 ms (p95), không phải 500/504. - Endpoint reservation p95 < 200 ms cho cả nhánh OK lẫn SOLD_OUT.
- Sau khi hết hàng, 100% request tiếp theo nhận SOLD_OUT mà không có query DB nào (kiểm tra bằng metric
hikaricp_connections_acquirehoặc log SQL). - Gọi lại cùng
Idempotency-Keytrả cùngreservationId. - Kill worker giữa chừng rồi bật lại: cuối cùng vẫn đủ 1000 booking, không trùng.
5. Test cases
| ID | Kịch bản | Input / điều kiện | Kỳ vọng |
|---|---|---|---|
| FS-01 | Mua trước giờ mở | opensAt = now + 1 phút | 425 NOT_OPEN, stock giữ nguyên |
| FS-02 | Mua bình thường | stock 10, 1 user | 202, stock 9, sau vài giây status HELD |
| FS-03 | Mua lần 2 | cùng user, key khác | 409 ALREADY_PURCHASED |
| FS-04 | Race Lua | stock 100, 1000 thread khác user cùng lúc (test không qua gateway) | đúng 100 OK, 900 SOLD_OUT, stock = 0 |
| FS-05 | Rate limit user | 1 user bắn 20 req/s | phần vượt burst nhận 429 + Retry-After |
| FS-06 | Idempotency | cùng key gửi 3 lần | 1 reservation, 3 response giống nhau |
| FS-07 | Worker gặp message trùng | publish lại cùng reservationId | chỉ 1 booking |
| FS-08 | Ghi DB lỗi vĩnh viễn | ghế bị khóa/không tồn tại | reservation FAILED, compensate trả stock, user khác mua được |
| FS-09 | Load 10k VU | k6 script dưới | AC 1–7 đạt |
@Testcontainers
class FlashStockLuaIT {
@Container static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Test
void neverOversellsUnderConcurrency() throws Exception {
// arrange: set flash:{fs}:stock = 100, open_at = now - 1000
// act: 1000 virtual threads, mỗi thread userId khác, gọi tryReserve
// assert: count(OK) == 100, GET stock == "0", SCARD buyers == 100
}
}
// tests/k6/m07-flash-sale.js
import http from 'k6/http';
import { check } from 'k6';
import { Counter } from 'k6/metrics';
const ok = new Counter('reserve_ok'), sold = new Counter('sold_out'), limited = new Counter('rate_limited');
export const options = {
scenarios: { rush: { executor: 'ramping-vus', startVUs: 0,
stages: [{ duration: '10s', target: 10000 }, { duration: '50s', target: 10000 }] } },
thresholds: {
'http_req_duration{status:429}': ['p(95)<50'],
'http_req_failed{status:500}': ['rate==0'], // TODO: tự định nghĩa "failed" đúng
},
};
export default function () {
const user = `u-${__VU}`;
const res = http.post(`${__ENV.BASE}/api/flash-sales/${__ENV.FS_ID}/reservations`,
JSON.stringify({ quantity: 1 }),
{ headers: { 'Content-Type': 'application/json', 'X-User-Id': user,
'Idempotency-Key': `${user}-${__ITER}` } });
// TODO: đếm ok/sold/limited theo status + code, check có Retry-After khi 429
}
6. Failure scenarios
| Sự cố | Cách tạo | Hành vi mong đợi |
|---|---|---|
| Redis chậm | Toxiproxy latency 300ms trên kết nối booking → Redis | Timeout Redis ngắn (vd 100ms) → trả 503 nhanh, không treo thread; không trừ stock nửa vời |
| Redis chết | docker stop redis | Gateway: chọn fail-open hay fail-closed (ghi ADR). Reservation trả 503, không fallback xuống DB |
| Worker chết | kill container worker khi đã xử lý 400/1000 | Message chưa commit offset được xử lý lại, dedupe giữ đúng 1000 |
| DB chậm | Toxiproxy latency 2s trên Postgres | Queue lag tăng, API vẫn trả 202 nhanh, status PENDING lâu hơn; không mất reservation |
| Kafka không nhận | stop Kafka sau khi Lua trừ xong | Không được mất vé: dùng outbox hoặc compensate stock + trả lỗi; chứng minh bằng đếm |
| Bot 1 user | k6 1 VU bắn 1000 req/s | Gần như toàn bộ 429, không ảnh hưởng user khác |
7. Benchmark / số liệu cần thu
Lưu vào docs/benchmarks/m07-flash-sale.md + ảnh Grafana:
| Chỉ số | Không có M7 (đi thẳng DB) | Có M7 |
|---|---|---|
| Số booking cuối | ||
| Tỷ lệ 200/202 · 409 · 429 · 5xx | ||
| p50/p95/p99 reservation | ||
| Postgres CPU max, active conn max | ||
| Redis ops/s, CPU | ||
| Queue lag max, thời gian drain 1000 booking |
8. Checklist hoàn thành
- ☐ Acceptance criteria 1–9 có test tự động / k6 threshold, xanh.
- ☐ Mọi failure scenario chạy thật, ghi kết quả.
- ☐ Benchmark trước/sau trong
docs/benchmarks/. - ☐ ADR: fail-open vs fail-closed khi Redis chết; chọn queue (Kafka hay RabbitMQ) và message key.
- ☐ Script Lua nằm trong file riêng, load bằng
EVALSHA, có unit test. - ☐ Có cơ chế đối soát:
stock Redis + số reservation = quantity, job cảnh báo khi lệch. - ☐ Không hardcode, mọi ngưỡng qua
${ENV}. - ☐ Qua lượt review và phỏng vấn AI.
Lua trừ stock xong mà publish Kafka lỗi thì sao? Mình đang bị lệch: Redis còn 0 nhưng DB chỉ có 997 booking.
Đúng chỗ khó nhất của Redis-first: Redis và Kafka là dual write, không có transaction chung. Mình không giải hộ, chỉ đưa 3 hướng để bạn chọn và viết ADR:
- Bù ngay: publish lỗi (sau retry ngắn) → gọi
compensate(INCR stock, SREM buyer), trả 503. Câu hỏi tự trả lời: nếu chính app chết giữa Lua và publish thì sao? - Ghi "nhật ký" trong Redis: script Lua ngoài trừ stock còn
XADDvào Redis Stream trong cùng script. Worker đọc stream. Atomic vì cùng một script. Đánh đổi: phụ thuộc độ bền của Redis (xem lại AOF). - Đối soát định kỳ: job so
quantity - stockvới số reservation trong DB, phát hiện "vé mồ côi" quá X phút thì trả lại stock.
Gợi ý: hướng 2 + 3 thường đủ cho đề bài. Dù chọn gì, hãy viết test tái hiện đúng con số 997 trước, rồi mới sửa.
Mình không biết chọn replenishRate và bucket toàn cục bao nhiêu. Để thấp quá thì user than, cao quá thì DB chết.
Đừng đoán, hãy tính ngược từ thứ yếu nhất. Gợi ý cách nghĩ:
- Sau M7, DB không còn là cổ chai của request (chỉ worker ghi, tốc độ cố định). Cổ chai mới là booking-service + Redis. Đo throughput tối đa của 1 instance khi stock = 0 (nhánh rẻ nhất) và khi còn stock.
- Bucket toàn cục ≈ 70% throughput đo được × số instance. Phần dư để dành cho traffic sự kiện khác.
- Bucket per-user: người thật bấm vài lần/giây.
rate=1–2, burst=3–5là điểm khởi đầu; xem log 429 theo user để chỉnh. - Câu hỏi để tự kiểm tra: 1000 vé thì chỉ cần ~1000 request thành công. Cho 100k request vào Redis có thật sự cần không? Một "phòng chờ" (cho N người/giây vào) có hợp lý hơn không?
Mình làm xong rồi, review và phỏng vấn mình đi.
Checklist review code AI sẽ soi
- Có đoạn nào
GET stockrồi mớiDECRở Java không? (check-then-act → oversell.) - Script Lua có trả về đủ mã lỗi, có kiểm tra
qty > stocktrước khi trừ, không để stock âm. - Mọi key trong script có truyền qua
KEYS[](bắt buộc cho Cluster), không ghép chuỗi trong Lua. - Timeout Redis client có cấu hình, không để mặc định 60s.
- Worker: ack/commit offset sau commit DB; dedupe theo
reservationId; batch không quá lớn gây lock lâu. - 429 có
Retry-After, error model thống nhất; SOLD_OUT không chạm DB. - KeyResolver không tin header client khi đã có JWT (chống giả user).
- Không hardcode stock/rate trong code.
Câu hỏi phỏng vấn
1. Vì sao không dùng SELECT ... FOR UPDATE như M1 cho flash sale?
🇻🇳 Đúng nhưng không chịu tải: mọi request tranh một nhóm dòng, pool DB cạn, p99 nổ và kéo sập event khác. Redis trừ atomic trong bộ nhớ ~µs, DB chỉ nhận đúng số booking thành công với tốc độ do worker quyết định.
🇬🇧 It's correct but doesn't scale: every request contends on the same rows, the pool is exhausted and other events suffer. Redis decrements atomically in memory, and the DB only sees successful bookings at a rate the worker controls.
2. Làm sao đảm bảo không oversell khi có nhiều instance booking-service?
🇻🇳 Quyết định bán nằm trong một script Lua chạy atomic trên Redis (single-thread), không ở JVM. DB có UNIQUE(flash_sale_id, user_id) và dedupe theo reservationId làm lưới cuối.
🇬🇧 The sell decision is a single atomic Lua script on Redis, not in the JVM. The DB has a unique constraint and reservation-id dedupe as the last safety net.
3. Redis chết lúc 12:00 thì sao?
🇻🇳 Reservation fail-closed, trả 503 và thông báo thử lại, không fallback xuống DB vì sẽ kéo DB chết theo. Stock được dựng lại từ DB (quantity - count(reservations)) trước khi mở lại. Dài hạn: Sentinel/AOF ở M11.
🇬🇧 Fail closed with 503, never fall back to the DB. Rebuild stock from the DB before reopening; long-term, Sentinel and AOF in M11.
4. Tải x10 (1M user) thì đổi gì?
🇻🇳 Thêm phòng chờ/virtual queue trước gateway, phát token vào cửa; cache trang tĩnh ở CDN; chia stock thành nhiều key (bucket) để tránh hot key; rate limit ở edge.
🇬🇧 Add a virtual waiting room issuing admission tokens, push static content to the CDN, split stock across several keys to avoid a hot key, and rate-limit at the edge.
Cách nhớ ngắn gọn
Chặn ở cửa (429) → Quyết ở Redis (Lua atomic) → Ghi DB sau (queue, idempotent)
Không bao giờ: đọc rồi trừ, fallback xuống DB, ack trước commit.
Tự kiểm tra
Vì sao script Lua trên Redis chống được oversell?
Redis thực thi lệnh trên một thread, cả script chạy liền một mạch không bị xen ngang, nên kiểm tra và trừ là một thao tác atomic.
Request bị rate limit nên trả gì?
429 Too Many Requests kèm Retry-After và body theo error model chung, trả nhanh tại gateway.
Vì sao worker ghi DB phải idempotent?
Queue giao at-least-once; worker chết trước khi commit offset sẽ nhận lại message. Dedupe theo reservationId/UNIQUE tránh booking trùng.
Khi hết hàng, có nên query DB để xác nhận không?
Không. SOLD_OUT quyết định từ Redis; mọi query DB lúc đó là tải thừa đúng lúc đông nhất.
Làm sao phát hiện Redis và DB bị lệch?
Job đối soát so quantity - stock với số reservation trong DB, cảnh báo và trả lại vé mồ côi quá hạn.