Đấu giá trực tuyến
Hiểu đề
Yêu cầu chức năng
- Người bán có thể tạo phiên đấu giá: ảnh, mô tả, giá khởi điểm, bước giá, giờ kết thúc
- Người dùng có thể xem phiên và thấy giá cao nhất cập nhật gần như real-time
- Người dùng có thể đặt giá, giá mới phải cao hơn giá hiện tại ít nhất một bước giá
- Hệ thống đóng phiên đúng giờ, chọn người thắng và báo cho người thắng, người bán
Yêu cầu phi chức năng
- Nhất quán mạnh ở bước đặt giá: mỗi phiên luôn có đúng 1 người dẫn đầu, 0 bid được nhận sau giờ kết thúc
- Đặt giá p95 < 300 ms, kể cả lúc 200 bid/s đổ vào một phiên ở phút cuối
- Giá mới đến người đang xem trong ≤ 1 s (p95)
- Đóng phiên và gửi kết quả trong ≤ 5 s sau giờ kết thúc
- Độ sẵn sàng 99,9%, chịu đỉnh ×10 lượt bid vào khung giờ tối
Ngoài phạm vi: Thanh toán, vận chuyển và tranh chấp sau khi thắng; Chi tiết thuật toán đấu giá ủy quyền (proxy bid), chỉ nhắc ở phần công bằng; Các kiểu đấu giá khác: đấu giá kín, đấu giá Hà Lan; Phát hiện bid ảo (shill bidding) và đánh giá người bán.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/auctions | Tạo phiên. Ảnh tải thẳng lên S3 bằng presigned URL, body chỉ chứa key ảnh. |
| GET | /v1/auctions/{id} | Chi tiết phiên kèm current_price, leader, end_at và server_time để client chỉnh đồng hồ đếm ngược. |
| POST | /v1/auctions/{id}/bids | Đặt giá: body {amount}, header Idempotency-Key. Trả 409 nếu giá không đủ cao, 410 nếu phiên đã đóng. |
| GET | /v1/auctions/{id}/stream | SSE: đẩy {price, leader, end_at, seq} mỗi khi giá đổi hoặc phiên được gia hạn. |
| GET | /v1/auctions?q=&category=&endingSoon= | Tìm và lọc phiên đang mở, sắp xếp theo giờ kết thúc. |
| GET | /v1/auctions/{id}/bids?cursor= | Lịch sử trả giá, phân trang theo cursor. |
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 bán có thể tạo phiên đấu giá
Tạo phiên là thao tác ít và đơn giản. Phần nặng là ảnh, nên ảnh đi thẳng lên S3 rồi phục vụ qua CDN, DB chỉ lưu metadata.
- 1Client xin presigned URL, tải ảnh lên S3
- 2Client gửi POST /v1/auctions với thông tin món hàng, giá khởi điểm, bước giá, end_at
- 3API kiểm tra end_at hợp lệ (ví dụ 1-10 ngày), ghi Auction status OPEN
- 4API đăng ký hạn kết thúc vào scheduler (hàng DB có index end_at, sau này thêm Redis ZSET)
Nguồn: Grokking Modern System Design · ch.20 Blob Store ↗ · mục "Detailed design" và "Stream a file" ; Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN"
2) Người dùng có thể xem phiên và thấy giá real-time
Trang phiên gồm phần tĩnh (ảnh, mô tả) cache được ở CDN và phần động nhỏ (giá, người dẫn đầu, giờ kết thúc). Phần động được đẩy qua SSE.
- 1Client tải trang và ảnh từ CDN
- 2Client gọi GET /v1/auctions/{id} lấy giá hiện tại, seq và server_time
- 3Client mở SSE /stream, nhận sự kiện bid_accepted, end_extended, auction_closed
- 4Mất kết nối thì gọi lại GET để lấy trạng thái mới nhất, bỏ qua sự kiện có seq cũ hơn
Nguồn: Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN" ; Redis in Action ↗ · ch.3 · §3.6 Publish/subscribe
3) Người dùng có thể đặt giá
Đây là chỗ tranh chấp duy nhất cần nhất quán mạnh. Một câu UPDATE có điều kiện trên hàng Auction quyết định ai thắng, kèm kiểm tra giờ kết thúc bằng đồng hồ DB.
- 1Client gửi POST /bids với amount và Idempotency-Key
- 2API mở transaction: UPDATE auctions SET current_price=:amt, leader_id=:u, version=version+1 WHERE id=:id AND status='OPEN' AND end_at > now() AND current_price + min_increment <= :amt
- 31 hàng được cập nhật: INSERT Bid ACCEPTED và AuctionEvent vào outbox trong cùng transaction
- 40 hàng: đọc lại phiên, trả 409 kèm giá hiện tại hoặc 410 nếu đã hết giờ
- 5Sau commit, publish giá mới cho người xem và báo người vừa bị vượt giá
Nguồn: Microservices Patterns ↗ (.zip) · ch.6 · §6.1.3 Handling concurrent updates using optimistic locking ; Chưa có nguồn trong thư viện: UPDATE có điều kiện là kỹ thuật DB chung
4) Hệ thống đóng phiên đúng giờ và báo kết quả
Việc chặn bid trễ đã nằm trong điều kiện end_at > now() của câu UPDATE, nên scheduler chỉ cần chốt kết quả nhanh. Đóng phiên phải idempotent vì có thể chạy hai lần.
- 1Scheduler lấy các phiên có end_at <= now() và status OPEN
- 2UPDATE auctions SET status='CLOSED' WHERE id=:id AND status='OPEN' AND end_at <= now()
- 3Cập nhật được 1 hàng thì ghi outbox auction_closed với leader_id là người thắng
- 4Worker gửi email/push cho người thắng và người bán, đẩy auction_closed cho người đang xem
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Task idempotency" ; Redis in Action ↗ · ch.6 · §6.4.2 Delayed tasks (tr. 137)
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.
Sàn đấu giá đồ sưu tầm của một cộng đồng nhỏ. Hai instance Node sau CDN, một Postgres managed giữ cả giá hiện tại lẫn lịch hẹn kết thúc; giá trên trang cập nhật bằng polling.
Ước lượng
- Lượt xem: 10.000 DAU × 30 req / 86.400 ≈ 3,5 req/s, đỉnh ×10 ≈ 35 req/s
- Polling giá: 500 người cùng xem phiên × 1 req / 5 s = 100 req/s
- API: ceil((35 + 100) / 600) = 1 → chạy 2 instance để dự phòng
- Bid: 10.000 × 2 bid / 86.400 ≈ 0,23 bid/s; phút cuối phiên hot ≈ 50 bid / 60 s ≈ 0,8 bid/s trên một hàng Auction
- Kết thúc phiên: 2.000 phiên / 7 ngày ≈ 286 phiên/ngày, quét DB mỗi 1 s là thừa sức
- Lưu trữ: bid 20.000 × 200 B × 365 ≈ 1,5 GB/năm; ảnh ≈ 290 phiên mới/ngày × 5 ảnh × 500 KB × 365 ≈ 265 GB/năm trên S3
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Standard numbers to remember" và "Requests estimation" ; Quy tắc ước lượng của khoá học · /hoc/chon-server
Bảng hạ tầng
| Thành phần | Chọn | Số lượng | Cấu hình | Vì sao |
|---|---|---|---|---|
| CDN | Cloudflare (gói free/pro) | 1 | Cache ảnh, JS/CSS, trang danh sách phiên | Ảnh chiếm gần hết băng thông, để CDN gánh là rẻ nhất. |
| API | Node.js (NestJS), Docker trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 135 req/s chỉ cần 1 instance, chạy 2 để không chết khi 1 cái lỗi. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | Dưới 1 bid/s mỗi phiên, UPDATE có điều kiện trên hàng Auction đảm bảo một người dẫn đầu; dữ liệu 1,5 GB/năm. |
| Job đóng phiên | Vòng lặp trong API, SKIP LOCKED | 1 | Chạy mỗi 1 s, index (status, end_at) | 286 phiên/ngày; SKIP LOCKED cho phép cả 2 instance cùng chạy mà không đóng trùng. |
| Lưu ảnh | S3 / GCS | 1 bucket | Presigned upload | 265 GB/năm, không bao giờ để ảnh trong DB. |
| Amazon SES / SendGrid | 1 | Dịch vụ bên ngoài | Vài trăm email kết quả mỗi ngày, gửi thẳng từ API là đủ. |
- Điểm xuất phát: một API, một Postgres, CDN và S3 cho ảnh
- Đặt giá bằng UPDATE có điều kiện, chưa cần Redis hay queue
- Giá trên trang cập nhật bằng polling 5 s, đóng phiên bằng vòng quét mỗi giây
Polling tăng tuyến tính theo số người xem: 10.000 người cùng xem là 2.000 req/s trống rỗng đổ vào API và DB. Cần đẩy giá chủ động và cache giá hiện tại ngoài DB.
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, 2.000 phiên đang mở | 1 triệu DAU, 500.000 phiên đang mở | 50 triệu DAU, 20 triệu phiên đang mở |
| Chi phí | ≈ 50–150 USD/tháng (ước lượng) | ≈ 3.000–7.000 USD/tháng (ước lượng, tăng dần theo dung lượng ảnh) | ≈ 100.000–250.000 USD/tháng (ước lượng, phần lớn là lưu ảnh và CDN) |
| CDN | Cloudflare (gói free/pro) 1 × Cache ảnh, JS/CSS, trang danh sách phiên | Cloudflare / CloudFront 1 × Cache ảnh, trang phiên, danh sách sắp kết thúc 5 s | Chưa cần |
| API | Node.js (NestJS), Docker trên Cloud Run 2 × 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 | PostgreSQL chia shard theo auction_id (Citus hoặc định tuyến ở app) 16 shard × (1 primary + 1 replica) × 8 vCPU · 32 GB · 1 TB SSD mỗi node |
| Job đóng phiên | Vòng lặp trong API, SKIP LOCKED 1 × Chạy mỗi 1 s, index (status, end_at) | Chưa cần | Chưa cần |
| Lưu ảnh | S3 / GCS 1 bucket × Presigned upload | Chưa cần | Chưa cần |
Amazon SES / SendGrid 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần | |
| API (gồm đặt giá) | Chưa cần | Node.js (NestJS), container autoscale 2–4 (autoscale) × 2 vCPU · 4 GB RAM | Chưa cần |
| SSE gateway | Chưa cần | Node.js SSE + Redis pub/sub 4 × 2 vCPU · 4 GB RAM | Chưa cần |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Scheduler | Chưa cần | Node.js worker đọc Redis ZSET 2 × 1 vCPU · 2 GB RAM | Node.js worker, mỗi pod giữ lease một bucket 16 pod × 1 vCPU · 2 GB mỗi pod |
| Queue | Chưa cần | Amazon SQS / Google Pub/Sub 1 × Managed, có dead-letter queue | Chưa cần |
| Worker thông báo | Chưa cần | Node.js consumer 2 × 1 vCPU · 2 GB RAM | Chưa cần |
| CDN + WAF | Chưa cần | Chưa cần | CloudFront / Cloudflare + bot management 1 × Cache ảnh, trang phiên, danh sách; chặn bot |
| API đọc | Chưa cần | Chưa cần | Node.js trên Kubernetes, 3 AZ 78 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Bid service | Chưa cần | Chưa cần | Node.js trên Kubernetes, 3 AZ 29 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Gateway real-time | Chưa cần | Chưa cần | Node.js SSE, Redis sharded pub/sub 125 pod × 2 vCPU · 4 GB mỗi pod |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis 7 Cluster 3 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 auction-events 24 partition, RF 3 |
| Search | Chưa cần | Chưa cần | OpenSearch 6 node × 8 vCPU · 32 GB mỗi node |
Đà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) Hàng trăm người trả giá cùng lúc vào một phiên. Làm sao giá cao nhất luôn đúng?
API SELECT current_price, so sánh trong Node.js rồi ghi bid mới. Hai request đọc cùng giá 1.000.000 đ, cả hai thấy mình hợp lệ và cùng được báo là dẫn đầu.
Đánh đổi: Code dễ viết nhưng sai ngay khi có hai bid đến gần nhau, đúng vào lúc quan trọng nhất của phiên.
⚠ Chưa có nguồn trong thư viện: đọc-rồi-ghi ở app là nhận định chung
Mở transaction, khóa hàng phiên, kiểm tra giá và giờ, cập nhật rồi commit. Các bid cùng phiên xếp hàng chờ khóa nên kết quả đúng. Optimistic locking theo cột version (Microservices Patterns §6.1.3) cũng cho kết quả tương tự, chỉ khác là bên thua phải thử lại.
Đánh đổi: Mỗi bid tốn 2-3 round-trip trong lúc giữ khóa, phiên hot ở phút cuối bị xếp hàng lâu và dễ cạn connection pool.
Nguồn: Microservices Patterns ↗ (.zip) · ch.6 · §6.1.3 Handling concurrent updates using optimistic locking ; Chưa có nguồn trong thư viện: SELECT FOR UPDATE là kỹ thuật DB chung
Đưa toàn bộ luật vào WHERE: status OPEN, end_at > now(), current_price + min_increment <= amount. DB tự khóa hàng trong vài ms và kiểm tra lại điều kiện sau khi chờ, nên chỉ một bid ở mỗi mức giá thắng. Bid thua trả về ngay với giá mới để client hiện cho người dùng. Idempotency-Key chặn việc bấm hai lần.
Đánh đổi: Một hàng chịu được vài trăm bid/s; vượt mức đó thì khóa hàng trở thành nút thắt và cần tuần tự hóa phía trước.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging ; Chưa có nguồn trong thư viện: UPDATE có điều kiện là kỹ thuật DB chung
Bid được đẩy vào partition Kafka chọn theo auction_id, mỗi partition có đúng một consumer xử lý tuần tự và giữ giá hiện tại trong bộ nhớ, loại ngay bid thấp trước khi ghi DB. Câu UPDATE có điều kiện vẫn giữ làm lớp an toàn cuối.
Đánh đổi: Đặt giá thành bất đồng bộ, client phải chờ kết quả qua SSE; thêm độ trễ và phức tạp nên chỉ đáng khi một phiên thật sự vượt vài trăm bid/s.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Ordering of messages" (Strict ordering) ; Chưa có nguồn trong thư viện: single writer theo auction_id là thiết kế của khoá học
2) Làm sao đóng phiên đúng giờ khi có hàng triệu phiên kết thúc rải rác trong ngày?
Cron tìm phiên hết hạn và đóng lại. Trong 5 phút trễ đó API vẫn nhận bid vì chỉ dựa vào status, nên người trả giá sau giờ kết thúc lại thắng.
Đánh đổi: Sai luật đấu giá và mất niềm tin của người dùng; quét cả bảng mỗi lần còn tốn tài nguyên.
⚠ Chưa có nguồn trong thư viện: cron quét là nhận định chung
Tính đúng đắn nằm ở bid path: dùng đồng hồ DB, không tin giờ của client hay của từng server. Job mỗi giây chạy SELECT ... WHERE status='OPEN' AND end_at <= now() ORDER BY end_at LIMIT 500 FOR UPDATE SKIP LOCKED, nhiều worker chạy song song không giẫm lên nhau.
Đánh đổi: Đủ tốt tới vài chục phiên/s; khi bảng lớn và nhiều phiên kết thúc vào phút tròn thì việc quét liên tục đè lên primary.
⚠ Chưa có nguồn trong thư viện: quét bằng index và SKIP LOCKED là kỹ thuật DB chung
Mỗi phiên là một phần tử ZSET với score = end_at (mẫu delayed tasks, Redis in Action §6.4.2). Worker gọi ZRANGEBYSCORE 0 now mỗi 200-500 ms trên bucket của mình, đọc lại DB rồi chạy UPDATE ... WHERE status='OPEN' AND end_at <= now(). Nếu phiên đã được gia hạn thì chỉ cần ZADD lại với end_at mới. ZSET mất dữ liệu thì dựng lại từ index end_at trong DB, đúng như cách Grokking ch.23 tách lưu trữ task và phần thực thi.
Đánh đổi: Thêm một thành phần phải theo dõi độ trễ (lag giữa end_at và lúc đóng); nhưng vì bid path tự chặn bid trễ nên scheduler chậm vài giây chỉ làm thông báo đến muộn, không làm sai kết quả.
Nguồn: Redis in Action ↗ · ch.6 · §6.4.2 Delayed tasks (tr. 137) ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Components" (tách lưu task và thực thi)
3) Làm sao đẩy giá mới đến hàng nghìn người đang xem một phiên trong dưới 1 giây?
3.000 người xem một phiên hot tạo 3.000 req/s chỉ cho một trang, phần lớn trả về dữ liệu không đổi. Nhân lên hàng chục nghìn phiên thì origin và DB gánh không nổi.
Đánh đổi: Dễ làm nhất, chấp nhận được ở quy mô nhỏ với chu kỳ 3-5 s.
⚠ Chưa có nguồn trong thư viện: polling là nhận định chung
Client gửi seq đang có, server trả 304 hoặc chỉ phần giá mới. Response của mỗi phiên cache 1 s ở CDN nên 3.000 người dùng chung khoảng 1 request/s tới origin.
Đánh đổi: Trễ 1-2 s và vẫn tốn request rỗng; khó đạt p95 ≤ 1 s ở phút cuối.
Nguồn: Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN"
Sau commit, Bid service publish {price, leader, end_at, seq} lên kênh auction:{id} (Grokking ch.18, Redis in Action §3.6). Gateway chỉ subscribe các phiên mà client của nó đang xem rồi đẩy xuống qua SSE. Phiên quá nóng thì gộp, mỗi 250 ms gửi một bản giá mới nhất. Client so seq để bỏ tin cũ và gọi lại GET khi kết nối lại.
Đánh đổi: Phải vận hành hàng loạt kết nối mở và kênh pub/sub; pub/sub không lưu lại tin nên client luôn cần đường GET để bù.
Nguồn: Grokking Modern System Design · ch.18 Pub-sub ↗ · mục "High-level design" ; Redis in Action ↗ · ch.3 · §3.6 Publish/subscribe
4) Chống "bắn tỉa" ở giây cuối (sniping) và giữ phiên công bằng thế nào?
Bot gửi bid ở giây cuối, người khác không kịp phản ứng. Lấy timestamp từ client hoặc từ từng API server có đồng hồ lệch nhau thì thứ tự bid còn có thể bị làm giả.
Đánh đổi: Người có bot luôn thắng, người dùng thật thấy bất công và bỏ sàn.
⚠ Chưa có nguồn trong thư viện: sniping là nghiệp vụ đấu giá
Người dùng nhập giá tối đa, hệ thống tự trả thay từng bước giá. Người đã đặt giá tối đa cao thì bị bắn tỉa cũng không thua. Client hiển thị đồng hồ theo server_time để ai cũng thấy cùng một mốc kết thúc.
Đánh đổi: Giảm lợi thế của sniper nhưng không loại bỏ; logic proxy làm câu điều kiện phức tạp hơn.
⚠ Chưa có nguồn trong thư viện: proxy bid là nghiệp vụ đấu giá
Câu UPDATE đặt giá thêm SET end_at = GREATEST(end_at, now() + interval '2 minutes'), extensions = extensions + 1 khi bid rơi vào 2 phút cuối, kèm giới hạn tổng thời gian gia hạn (ví dụ 30 phút). Ai cũng có 2 phút để đáp trả nên bid giây cuối hết tác dụng. Sự kiện end_extended được đẩy cho mọi người xem; scheduler tự ZADD lại khi thấy end_at đã đổi. Thêm rate limit theo user cho endpoint bid để chặn bot spam.
Đánh đổi: Giờ kết thúc không còn cố định nên người mua khó canh giờ; phải hiển thị rõ luật gia hạn và giới hạn tối đa để tránh phiên kéo dài vô tận.
Nguồn: Chưa có nguồn trong thư viện: soft close là nghiệp vụ đấu giá ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Where to place the rate limiter"
Trong phỏng vấn
- “Chỉ bước đặt giá cần nhất quán mạnh, tôi đưa toàn bộ luật vào WHERE của một câu UPDATE trên hàng Auction.”
- “Bid sau giờ bị chặn ngay trong câu UPDATE bằng end_at > now() theo đồng hồ DB, nên scheduler chậm chỉ làm thông báo đến muộn chứ không làm sai kết quả.”
- “Giá real-time đi qua SSE và pub/sub theo kênh của từng phiên, client luôn có đường GET để bù khi mất tin.”
- “Soft close gia hạn 2 phút khi có bid cuối, làm nguyên tử trong cùng câu UPDATE và có giới hạn tổng thời gian.”
- Đọc giá ở app rồi mới ghi, hai người cùng được báo là dẫn đầu.
- Chỉ dựa vào cron để đóng phiên mà không kiểm tra giờ ở bước đặt giá.
- Lấy timestamp từ client hoặc từ từng API server để quyết định thứ tự bid.
- Đặt giá qua WebSocket mà không có Idempotency-Key, mạng chập chờn sinh bid trùng.
Đọ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.