Flash sale thương mại điện tử
Hiểu đề
Yêu cầu chức năng
- Người dùng có thể xem danh sách deal flash sale và số lượng còn lại
- Người dùng có thể đặt mua một sản phẩm flash sale (giới hạn số lượng mỗi người)
- Người dùng có thể thanh toán đơn trong 15 phút, quá hạn thì trả hàng về kho
- Người bán có thể cấu hình deal: SKU, giá, số lượng, giờ bắt đầu
Yêu cầu phi chức năng
- Không bán vượt: số đơn thành công ≤ số lượng mở bán, sai lệch 0
- Chịu đột biến ×100 so với tải trung bình trong 1-2 phút đầu
- Phản hồi bấm mua p95 < 500 ms (nhận đơn hoặc báo hết hàng)
- Mỗi lần bấm mua và mỗi callback thanh toán chỉ tạo đúng 1 đơn / 1 lần ghi nhận tiền
- Phần còn lại của sàn (tìm kiếm, giỏ hàng thường) vẫn chạy, độ sẵn sàng 99,95%
Ngoài phạm vi: Gợi ý sản phẩm, tìm kiếm toàn sàn; Vận chuyển và kho vật lý; Voucher chồng nhiều tầng; Đối soát tài chính với người bán.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| GET | /v1/flash-deals?slot=2026-12-12T00:00 | Danh sách deal của khung giờ. Phần tĩnh cache ở CDN, số lượng còn lại là ước lượng. |
| POST | /v1/flash-deals/{id}/purchase | Bấm mua. Header Idempotency-Key. Trả 202 + orderToken, hoặc 410 hết hàng, 429 quá tải. |
| GET | /v1/orders/by-token/{orderToken} | Client poll trạng thái: QUEUED / CREATED / SOLD_OUT. |
| POST | /v1/orders/{id}/pay | Tạo phiên thanh toán (ShopeePay, MoMo, thẻ), Idempotency-Key theo order. |
| POST | /v1/payments/callback | Cổng thanh toán gọi về, có thể gọi lặp. Xử lý idempotent theo provider_txn_id. |
Thiết kế tổng thể
Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.
1) Người dùng có thể xem deal và số lượng còn lại
Trang deal gồm phần tĩnh (ảnh, tên, giá) và phần động (phần trăm đã bán). Phần tĩnh render sẵn và đẩy lên CDN trước giờ G, phần động đọc từ cache với độ trễ 1-2 s.
- 1Trước giờ sale, job render JSON danh sách deal và làm nóng CDN
- 2Client tải trang từ CDN, gần như không chạm origin
- 3Client gọi API lấy số lượng còn lại, API trả từ cache trong process (TTL 1 s) hoặc Redis
- 4Thanh tiến độ hiển thị ước lượng, con số chính xác chỉ quyết định ở bước mua
Nguồn: Grokking Modern System Design · ch.11 CDN ↗ · bài "In-depth Investigation of CDN: Part 1", mục "Push CDN" (đẩy nội dung lên CDN trước) ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "High-level Design of a Distributed Cache" ; Chưa có nguồn trong thư viện: độ trễ 1–2 s của số lượng còn lại là quyết định thiết kế của khoá
2) Người dùng có thể đặt mua sản phẩm flash sale
Trừ kho trước trong Redis bằng một script nguyên tử, sau đó đưa yêu cầu tạo đơn vào queue. Order worker ghi đơn vào DB theo tốc độ DB chịu được, client poll kết quả bằng orderToken.
- 1Gateway kiểm tra rate limit theo user/IP, trả 429 nếu vượt
- 2Purchase API chạy Lua script trên Redis: user đã mua chưa? stock > 0? nếu được thì DECR và ghi user vào set đã mua
- 3Script trả hết hàng thì trả 410 ngay, không chạm DB
- 4Thành công thì đẩy message {orderToken, userId, dealId, idempotencyKey} vào queue và trả 202
- 5Order worker: trong 1 transaction UPDATE deal_stock SET remaining = remaining - 1 WHERE deal_id=? AND remaining > 0, INSERT order, INSERT outbox
- 6Client poll /orders/by-token cho tới khi CREATED rồi chuyển sang thanh toán
Nguồn: Redis in Action ↗ · ch.11 · §11.3 Doing away with WATCH/MULTI/EXEC (Lua, tr. 260–265) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Motivation" (Rate limiting: hấp thụ đột biến) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (trạng thái *_PENDING, tr. 128–132) ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Design of a Rate Limiter", mục "High-level design" (429)
3) Người dùng có thể thanh toán trong 15 phút, quá hạn trả hàng về kho
Thanh toán là một saga nhỏ: đơn PENDING_PAYMENT, chờ callback, thành công thì PAID, quá hạn thì huỷ đơn và cộng lại kho. Callback được khử trùng lặp bằng ràng buộc unique.
- 1Client gọi /pay, nhận URL hoặc deeplink của cổng thanh toán
- 2Cổng gọi /payments/callback (có thể nhiều lần)
- 3INSERT payment với UNIQUE(provider, provider_txn_id); trùng thì trả 200 và bỏ qua
- 4Cùng transaction: đổi order sang PAID nếu đang PENDING_PAYMENT, ghi outbox OrderPaid
- 5Job hẹn giờ huỷ đơn quá 15 phút chưa trả: order CANCELLED, cộng lại DB và INCR Redis
Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.1.3 Using the Saga pattern to maintain data consistency (huỷ đơn là bước bù, tr. 114–117) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Redis in Action ↗ · ch.6 · §6.4.2 Delayed tasks (tr. 137–140)
Hạ tầng theo quy mô
Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.
Shop độc lập tự chạy flash sale. Ba instance Node và một Postgres; trừ kho bằng UPDATE có điều kiện là đủ an toàn ở mức tải này.
Ước lượng
- Bình thường: 10.000 × 20 req / 86.400 ≈ 2,3 req/s
- Mở sale: 5.000 người × 3 req trong 10 s = 15.000 / 10 = 1.500 req/s
- API: ceil(1.500 / (1.000 × 0,6)) = ceil(2,5) = 3 instance
- Trừ kho: 5.000 lượt bấm mua / 10 s = 500 UPDATE/s vào 1 hàng; mỗi lần giữ khóa ~1 ms → trần ~1.000/s, vẫn đủ
- Lưu trữ: 500 đơn × 2 KB = 1.000 KB ≈ 1 MB mỗi đợt sale
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Storage requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3; hệ số đột biến ×100, CDN đỡ 80%, 100 đơn/s mỗi worker là giả định của khoá học)
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| CDN | Cloudflare | 1 | Cache ảnh, trang deal, JS/CSS | Phần lớn 1.500 req/s là tải trang, CDN đỡ gần hết. |
| API | Node.js (NestJS) trên Cloud Run | 3 (đặt min 3 trong giờ sale) | 2 vCPU · 4 GB RAM | 1.500 / 600 = 2,5 → 3 instance. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | 500 UPDATE/s có điều kiện trên một hàng vẫn dưới trần, không bán vượt. |
| Redis | Redis managed nhỏ | 1 | 1 GB | Rate limit theo user/IP và cache trang deal; chưa dùng cho kho. |
| Thanh toán | VNPay / MoMo | 1 | Dịch vụ bên ngoài | Callback lưu với UNIQUE(provider_txn_id) để không ghi nhận trùng. |
- Điểm xuất phát: API + Postgres + CDN
- Trừ kho bằng một câu UPDATE có điều kiện, không dùng SELECT FOR UPDATE
- Đơn tạo đồng bộ trong request, chưa cần queue
Khi số người tranh một SKU vượt vài nghìn lượt/s, hàng tồn kho trong Postgres thành điểm nghẽn và connection pool cạn. Cần đưa việc trừ kho ra Redis và tạo đơn qua queue.
Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.
So sánh nhanh ba mức
| Thành phần | Mức 1 | Mức 2 | Mức 3 |
|---|---|---|---|
| Người dùng | 10.000 DAU, sale 500 sản phẩm lúc 20h | 1 triệu DAU, sale 20.000 sản phẩm lúc 0h | 50 triệu DAU, sự kiện 12.12 |
| Chi phí | ≈ 60–150 USD/tháng (ước lượng) | ≈ 3.000–8.000 USD/tháng (ước lượng, cao hơn trong tuần sale) | ≈ 60.000–150.000 USD/tháng (ước lượng, phần lớn tăng vọt quanh ngày sale) |
| CDN | Cloudflare 1 × Cache ảnh, trang deal, JS/CSS | CloudFront / Cloudflare 1 × Làm nóng trước giờ sale | Chưa cần |
| API | Node.js (NestJS) trên Cloud Run 3 (đặt min 3 trong giờ sale) × 2 vCPU · 4 GB RAM | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL managed (HA) 1 primary + 1 read replica × 4 vCPU · 16 GB · 500 GB SSD | Chưa cần |
| Redis | Redis managed nhỏ 1 × 1 GB | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Thanh toán | VNPay / MoMo 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần |
| API gateway | Chưa cần | Kong / API Gateway managed 2 × Rate limit token bucket theo user/IP | Envoy / Kong trên Kubernetes 12 pod × 4 vCPU · 8 GB mỗi pod |
| Purchase API | Chưa cần | Node.js container, scale theo lịch 12 (scale sẵn lúc 23h30) × 2 vCPU · 4 GB RAM | Node.js trên Kubernetes, 3 AZ 167 pod (scale sẵn) × 2 vCPU · 4 GB mỗi pod |
| Queue | Chưa cần | Amazon SQS / Google Pub/Sub 1 × Có dead-letter queue | Chưa cần |
| Order worker | Chưa cần | Node.js consumer 4 × 1 vCPU · 2 GB RAM | Node.js consumer trên Kubernetes 34 pod × 1 vCPU · 2 GB mỗi pod |
| Job huỷ đơn / đối soát | Chưa cần | Worker theo lịch 1 × Chạy mỗi phút | Chưa cần |
| CDN + WAF + phòng chờ edge | Chưa cần | Chưa cần | Akamai / CloudFront + bot management 1 × Làm nóng trước, phòng chờ bằng edge function |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis Cluster 6 shard × (1 primary + 1 replica) × 4 vCPU · 16 GB mỗi node |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 3 broker × Topic orders 12 partition, RF 3 |
| DB đơn hàng | Chưa cần | Chưa cần | MySQL (Vitess) hoặc PostgreSQL (Citus), shard theo user_id 4 shard × (1 primary + 1 replica) × 8 vCPU · 32 GB · 2 TB SSD mỗi primary |
| DB kho / deal | Chưa cần | Chưa cần | PostgreSQL HA, multi-AZ 1 primary + 2 replica × 8 vCPU · 32 GB |
| Relay outbox + đối soát | Chưa cần | Chưa cần | Debezium CDC + job đối soát 3 pod × 2 vCPU · 4 GB mỗi pod |
Đào sâu
Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.
1) Làm sao không bán vượt kho khi hàng chục nghìn người cùng mua một SKU?
Đọc tồn kho có khóa, kiểm tra trong app, rồi UPDATE và INSERT đơn. Đúng về số lượng nhưng khóa giữ suốt transaction gồm cả round-trip mạng giữa app và DB.
Đánh đổi: Mọi request xếp hàng sau một khóa trên một hàng; vài nghìn request/s là connection pool cạn và timeout dây chuyền.
Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.3.1 Overview of anomalies (lost updates, tr. 127–128) ; Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" › "Concurrency"
Một câu lệnh duy nhất, DB tự kiểm tra và trừ nguyên tử, khóa hàng chỉ trong thời gian chạy câu lệnh. Số hàng bị ảnh hưởng bằng 0 nghĩa là hết hàng.
Đánh đổi: Không bao giờ bán vượt, nhưng mọi request vẫn dồn vào một hàng nóng, trần khoảng vài trăm đến 1.000 lần/s mỗi SKU.
Nguồn: Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" › "Concurrency" ; Chưa có nguồn trong thư viện: UPDATE ... WHERE remaining > 0 và trần vài trăm đến 1.000 lần/s mỗi hàng là kiến thức vận hành RDBMS, ước lượng cần load test
Redis đơn luồng nên một Lua script kiểm tra giới hạn mỗi người và DECR là nguyên tử, xử lý hàng chục nghìn lần/s. Chỉ người thắng mới được đẩy vào queue; worker ghi đơn bằng UPDATE có điều kiện nên DB vẫn là chốt chặn cuối. Đơn huỷ hoặc quá hạn thì cộng lại cả hai nơi; job đối soát so sánh Redis với DB mỗi phút.
Đánh đổi: Redis và DB có thể lệch (Redis failover mất vài lần DECR, worker lỗi giữa chừng), nên cần đối soát; người dùng thấy 'đang xử lý' thay vì kết quả tức thì.
Nguồn: Redis in Action ↗ · ch.11 · §11.3 Doing away with WATCH/MULTI/EXEC (Lua, tr. 260–265) ; Redis in Action ↗ · ch.6 · §6.2 Distributed locking (tr. 116–127) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Motivation" (Rate limiting: hấp thụ đột biến) ; Redis in Action ↗ · ch.4 · §4.3 Handling system failures (tr. 75–78: Redis có thể mất dữ liệu khi failover)
2) Lúc 0h, lượng request tăng ×100 trong vài giây. Hấp thụ thế nào?
Đột biến đến trong vài giây còn pod mới mất 1-3 phút để sẵn sàng. DB và Redis cũng không scale kịp theo kiểu này.
Đánh đổi: Đơn giản nhưng hệ thống sập đúng phút đầu, khi mọi đơn hàng được tạo.
Nguồn: Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Autoscaling" (tr. 230–231: bật máy mới mất thời gian, cần giữ dư năng lực)
Biết trước giờ sale nên scale sẵn từ 23h30. Rate limit bằng token bucket theo user và IP (Grokking ch.19), request vượt ngưỡng nhận 429 với Retry-After, client hiện nút chờ thay vì cho bấm liên tục.
Đánh đổi: Bảo vệ được hệ thống, nhưng request thừa vẫn đi tới gateway và người thật cũng có thể bị cắt.
Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Rate Limiter Algorithms", mục "Token bucket algorithm" ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "Design of a Rate Limiter" (trả 429 khi vượt hạn mức) ; Building Microservices ↗ (.zip) · ch.11 · mục "Autoscaling" (tr. 230–231: dùng lịch khi biết trước tải)
Purchase API chỉ làm việc nhẹ (Redis + đẩy message) và trả 202; queue (Grokking ch.17) giữ phần còn lại để worker ghi DB theo tốc độ ổn định. Khi số người vượt mức, đưa vào phòng chờ có số thứ tự. Khi SKU đã hết trong Redis, gateway trả 410 ngay từ cờ sold_out mà không gọi vào trong. Ưu tiên luồng thanh toán hơn luồng xem khi phải cắt tải.
Đánh đổi: Kết quả trả về bất đồng bộ, client phải poll hoặc nghe push; thêm queue và phòng chờ để vận hành.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · bài "System Design: The Distributed Messaging Queue", mục "Motivation" (Rate limiting: hấp thụ đột biến) ; Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Degrading Functionality" (tr. 207–208) ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design Considerations", mục "Prioritization" ; Chưa có nguồn trong thư viện: phòng chờ (waiting room) ở edge không có trong sách
3) Làm sao checkout và callback thanh toán không tạo đơn hoặc trừ tiền hai lần?
App retry khi mạng chập chờn, người dùng bấm liên tục, cổng thanh toán gọi callback lặp khi không nhận được 200. Mỗi lần lại tạo thêm đơn hoặc ghi nhận tiền.
Đánh đổi: Không tốn công làm, nhưng gây đơn trùng, trừ kho sai và khiếu nại.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) ; Building Microservices ↗ (.zip) · ch.11 · mục "Idempotency" (tr. 215)
Client sinh UUID cho mỗi lần bấm mua và gửi lại đúng key khi retry. Bảng orders có UNIQUE(idempotency_key); trùng thì trả lại đơn cũ. Callback dùng UNIQUE(provider, provider_txn_id).
Đánh đổi: Chặn được trùng lặp ở DB, nhưng nếu việc phát sự kiện nằm ngoài transaction thì vẫn có thể mất hoặc trùng sự kiện.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Writing idempotent message handlers" (tạo order với ID do client cấp là idempotent, tr. 96)
Ghi payment, đổi trạng thái order có điều kiện (WHERE status='PENDING_PAYMENT') và ghi outbox trong cùng một transaction (Microservices Patterns §3.3.7). Relay đẩy outbox lên Kafka với at-least-once, consumer lưu id sự kiện đã xử lý để bỏ bản trùng (§3.3.6). Huỷ đơn quá hạn là bước bù của saga (ch.4).
Đánh đổi: Thêm bảng outbox, relay và bảng processed_messages; đổi lại không mất, không trùng tác dụng.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Tracking messages and discarding duplicates" ; Microservices Patterns ↗ (.zip) · ch.4 · §4.1.3 và §4.2 Coordinating sagas (tr. 114–126)
4) Cache trang sản phẩm và xử lý hot key thế nào khi hàng triệu người xem cùng một deal?
Trang deal truy vấn bảng sản phẩm và tồn kho cho từng request. Hàng trăm nghìn req/s đổ vào DB đúng lúc DB đang phải ghi đơn.
Đánh đổi: Đơn giản, chỉ ổn ở vài chục req/s.
Nguồn: Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Why distributed cache?" (giảm tải DB)
Ảnh, mô tả, giá sale đẩy lên CDN và làm nóng trước giờ G. API đọc chi tiết từ Redis, miss thì đọc DB rồi ghi cache (Grokking ch.16).
Đánh đổi: Giảm tải DB, nhưng một deal hot dồn mọi lượt đọc vào một key trên một shard Redis.
Nguồn: Grokking Modern System Design · ch.11 CDN ↗ · bài "In-depth Investigation of CDN: Part 1", mục "Push CDN" ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Background of Distributed Cache", mục "Writing policies" (cache-aside) ; Redis in Action ↗ · ch.2 · §2.4 Database row caching (tr. 31–34)
Phần tĩnh hoàn toàn trên CDN. Phần động (phần trăm đã bán) cache trong bộ nhớ mỗi pod 1 s, nên 167 pod chỉ gửi khoảng 167 lần đọc/s xuống Redis. Với key bị ghi nóng như stock thì chia thành N sub-key (stock:{deal}:0..9) nằm trên nhiều shard, mỗi request chọn ngẫu nhiên một sub-key, hết thì thử sub-key khác. Grokking ch.16 nêu hot key và gợi ý sharding tiếp trong dải key đó hoặc thêm replica; ý tưởng chia sub-key giống sharded counters ở Grokking ch.24.
Đánh đổi: Số lượng hiển thị trễ 1-2 s; sub-key làm logic hết hàng phức tạp hơn (có sub-key hết trước trong khi tổng vẫn còn).
Nguồn: Grokking Modern System Design · ch.16 Distributed Cache ↗ · bài "Evaluation of a Distributed Cache's Design", mục "Scalability" (replica cho hot shard, sharding tiếp trong dải key nóng) ; Grokking Modern System Design · ch.24 Sharded Counters ↗ · bài "Detailed Design of Sharded Counters" (chia một counter nóng thành nhiều shard, chọn ngẫu nhiên khi ghi) ; Redis in Action ↗ · ch.2 · §2.3 Web page caching (tr. 30–31)
Trong phỏng vấn
- “Tôi tách đường đọc (CDN, cache, số lượng ước lượng) khỏi đường ghi (trừ kho nguyên tử), chỉ đường ghi cần chính xác tuyệt đối.”
- “Kho được trừ trước trong Redis bằng Lua script, đơn tạo qua queue, DB dùng UPDATE có điều kiện làm chốt chặn cuối và có job đối soát.”
- “Mọi điểm có thể retry đều có idempotency key và ràng buộc unique; sự kiện đi qua outbox trong cùng transaction.”
- “Giờ sale biết trước nên tôi scale sẵn theo lịch, thêm rate limit và phòng chờ; autoscale chỉ là lớp phụ.”
- Đọc tồn kho rồi trừ trong code, không có kiểm tra nguyên tử.
- Coi Redis là nguồn sự thật duy nhất và không đối soát với DB.
- Quên huỷ đơn chưa thanh toán và trả hàng về kho.
- Để một SKU nóng nằm trên một key Redis duy nhất.
Đọc thêm
Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.