M7 · Flash sale

4 câu hỏi~3–4 ngày codeTừ khóa: token bucket, Redis Lua, stock decrement, async queue, 429, backpressure, k6

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:

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

3. Đề bài

Yêu cầu chức năng

API

MethodPathBodyResponse
POST/api/admin/flash-sales{eventId, quantity, perUserLimit, opensAt}201 {flashSaleId}
POST/api/flash-sales/{id}/reservations{quantity: 1} + header Idempotency-Key202 {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

  1. k6 10k VU, mỗi VU một user khác nhau, bắn trong 60s: tổng bookings của sale = đúng 1000 (không hơn, không kém sau khi worker xong).
  2. Không user nào có hơn perUserLimit booking (query GROUP BY user_id HAVING count(*) > 1 trả 0 dòng).
  3. Redis stock không bao giờ âm (ghi lại giá trị min trong suốt test).
  4. Postgres: active connection của booking-service ≤ pool size, CPU DB < 70%, không có lỗi connection timeout trong log.
  5. Request bị chặn nhận 429 + Retry-After trong < 50 ms (p95), không phải 500/504.
  6. Endpoint reservation p95 < 200 ms cho cả nhánh OK lẫn SOLD_OUT.
  7. 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_acquire hoặc log SQL).
  8. Gọi lại cùng Idempotency-Key trả cùng reservationId.
  9. 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

IDKịch bảnInput / điều kiệnKỳ vọng
FS-01Mua trước giờ mởopensAt = now + 1 phút425 NOT_OPEN, stock giữ nguyên
FS-02Mua bình thườngstock 10, 1 user202, stock 9, sau vài giây status HELD
FS-03Mua lần 2cùng user, key khác409 ALREADY_PURCHASED
FS-04Race Luastock 100, 1000 thread khác user cùng lúc (test không qua gateway)đúng 100 OK, 900 SOLD_OUT, stock = 0
FS-05Rate limit user1 user bắn 20 req/sphần vượt burst nhận 429 + Retry-After
FS-06Idempotencycùng key gửi 3 lần1 reservation, 3 response giống nhau
FS-07Worker gặp message trùngpublish lại cùng reservationIdchỉ 1 booking
FS-08Ghi DB lỗi vĩnh viễnghế bị khóa/không tồn tạireservation FAILED, compensate trả stock, user khác mua được
FS-09Load 10k VUk6 script dướiAC 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ạoHành vi mong đợi
Redis chậmToxiproxy latency 300ms trên kết nối booking → RedisTimeout Redis ngắn (vd 100ms) → trả 503 nhanh, không treo thread; không trừ stock nửa vời
Redis chếtdocker stop redisGateway: chọn fail-open hay fail-closed (ghi ADR). Reservation trả 503, không fallback xuống DB
Worker chếtkill container worker khi đã xử lý 400/1000Message chưa commit offset được xử lý lại, dedupe giữ đúng 1000
DB chậmToxiproxy latency 2s trên PostgresQueue lag tăng, API vẫn trả 202 nhanh, status PENDING lâu hơn; không mất reservation
Kafka không nhậnstop Kafka sau khi Lua trừ xongKhông được mất vé: dùng outbox hoặc compensate stock + trả lỗi; chứng minh bằng đếm
Bot 1 userk6 1 VU bắn 1000 req/sGầ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

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:

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ĩ:

Mình làm xong rồi, review và phỏng vấn mình đi.

Checklist review code AI sẽ soi

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.