Theo dõi giá sản phẩm
Hiểu đề
Yêu cầu chức năng
- Người dùng thêm sản phẩm bằng link (hoặc qua tiện ích trình duyệt) và xem biểu đồ lịch sử giá
- Người dùng đặt cảnh báo: giá ≤ X đồng hoặc giảm ≥ Y%
- Hệ thống cập nhật giá định kỳ cho mọi sản phẩm đang được theo dõi
- Người dùng nhận push/email khi điều kiện thỏa, mỗi đợt giảm giá chỉ báo một lần
Yêu cầu phi chức năng
- Sản phẩm hot (≥ 10 người theo dõi) cập nhật ≤ 15 phút/lần, sản phẩm ít người theo dõi ≤ 24 giờ/lần
- Thông báo gửi đi ≤ 5 phút sau khi phát hiện giá giảm
- Biểu đồ lịch sử 1 năm tải p95 < 300 ms
- 0 thông báo sai do lỗi đọc giá: giá giảm bất thường (> 50%) phải được kiểm tra lại trước khi báo
- Tốc độ gọi tới mỗi sàn không vượt trần cấu hình (ví dụ 200 req/s mỗi sàn ở mức 2)
Ngoài phạm vi: Mua hộ, thanh toán, link affiliate trong luồng mua; So sánh cùng một sản phẩm giữa nhiều sàn (ghép sản phẩm); Giá sau voucher cá nhân, phí vận chuyển; Dự đoán giá tương lai.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/products | Body {url}. Chuẩn hóa link về (site, item_id); sản phẩm mới thì xếp lịch lấy giá ngay. Trả sản phẩm + giá hiện tại. |
| GET | /v1/products/{id}/history?range=1y&step=1d | Lịch sử giá đã gom theo ngày (min/max/cuối ngày). Cache ở CDN đến lần đổi giá tiếp theo. |
| POST | /v1/watches | Body {productId, targetPrice | dropPercent, channels}. Header Idempotency-Key. |
| GET | /v1/me/watches | Danh sách cảnh báo kèm giá hiện tại. |
| DELETE | /v1/watches/{id} | Xóa cảnh báo; giảm số người theo dõi của sản phẩm. |
| POST | /v1/observations | Tiện ích trình duyệt gửi giá người dùng đang thấy trên trang sản phẩm; chỉ dùng khi nhiều quan sát khớp nhau. |
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 thêm sản phẩm và xem lịch sử giá
Nhiều link khác nhau (có tham số theo dõi, link rút gọn, link app) trỏ về cùng một sản phẩm, nên phải chuẩn hóa về (site, item_id) để mọi người dùng chung một dòng lịch sử.
- 1Client gửi POST /v1/products với link
- 2API mở link rút gọn, tách site và item_id, upsert Product
- 3Sản phẩm mới thì đặt next_check_at = now() để lấy giá ngay
- 4GET /history đọc bảng giá đã gom theo ngày, trả JSON nhỏ (365 điểm)
⚠ Chưa có nguồn trong thư viện: chuẩn hóa URL về (site, item_id) là thiết kế riêng của khoá học
2) Hệ thống cập nhật giá định kỳ
Scheduler chọn sản phẩm đến hạn theo độ ưu tiên và còn ngân sách của từng sàn. Chỉ ghi điểm giá khi giá hoặc tình trạng còn hàng thay đổi.
- 1Scheduler lấy Product có next_check_at <= now(), ưu tiên sản phẩm nhiều người theo dõi
- 2Token bucket theo sàn quyết định được gửi bao nhiêu task mỗi giây
- 3Crawler gọi trang/endpoint sản phẩm, parser lấy giá, giá gốc, còn hàng
- 4Trang trả về là captcha hoặc lỗi thì coi là lần lấy thất bại, không ghi giá
- 5Giá đổi thì ghi PricePoint và phát sự kiện PriceChanged; luôn cập nhật next_check_at theo priority
Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Token bucket algorithm" ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Prioritization" (delay tolerance)
3) Người dùng nhận thông báo khi giá giảm
Chỉ những lần giá giảm mới kích hoạt việc tìm watch. Watch được đánh index theo (product_id, target_price) nên một lần giảm giá chỉ đọc đúng những watch thỏa điều kiện.
- 1Matcher nhận PriceChanged(product, old, new)
- 2Giảm > 50% thì xếp lấy lại giá ngay để xác nhận trước khi báo
- 3Query watch WHERE product_id=? AND target_price >= new AND state='ARMED'
- 4UPDATE watch SET state='TRIGGERED' có điều kiện state='ARMED'; chỉ hàng đổi thành công mới tạo Notification
- 5Notifier gửi push (FCM/APNs) hoặc email; khi giá lên lại trên ngưỡng + 5% thì watch về ARMED
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95)
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.
Dự án cá nhân hoặc startup nhỏ. Một API, một worker vừa crawl vừa kiểm tra cảnh báo, một Postgres lưu cả lịch sử giá.
Ước lượng
- Lấy giá: 5.000 sp hot / 900 s ≈ 5,6 + 45.000 sp thường / 21.600 s (6 giờ) ≈ 2,1 → ≈ 7,6 lượt/s
- Mỗi ngày: 5.000 × 96 + 45.000 × 4 = 480.000 + 180.000 = 660.000 lượt
- Lịch sử: giá đổi ở 10% số lượt → 66.000 điểm × 100 B ≈ 6,6 MB/ngày × 365 ≈ 2,4 GB/năm
- Worker: 50 request song song × 1 s mỗi request = 50 lượt/s, giữ 60% → 30; ceil(7,6 / 30) = 1
- API: 10.000 × 20 req / 86.400 ≈ 2,3 req/s, đỉnh ×10 ≈ 23 req/s → 1 instance, chạy 2
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 |
|---|---|---|---|---|
| API | Node.js (NestJS) trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 23 req/s lúc đỉnh, 2 instance để dự phòng. |
| Crawler + cảnh báo | Node.js worker (fetch + cheerio) | 1 | 1 vCPU · 2 GB RAM | 7,6 lượt/s; chết vài phút chỉ làm giá trễ, lịch nằm trong DB nên không mất. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | 2,4 GB lịch sử/năm; bảng giá partition theo tháng, index (product_id, ts). |
| Thông báo | Firebase Cloud Messaging + Amazon SES | 1 | Dịch vụ bên ngoài | Vài nghìn thông báo/ngày, gửi thẳng từ worker. |
- Điểm xuất phát: một API, một worker, một Postgres
- Hai tầng tần suất: 15 phút cho sản phẩm hot, 6 giờ cho phần còn lại
- Chỉ ghi điểm giá khi giá đổi
Lên hàng triệu sản phẩm, một worker không lấy kịp và gọi dồn vào một sàn sẽ bị chặn. Bảng giá trong Postgres thường cũng bắt đầu chậm khi vẽ biểu đồ.
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, 50.000 sản phẩm được theo dõi, 100.000 watch | 1 triệu DAU, 5 triệu sản phẩm, 20 triệu watch | 10 triệu DAU, 100 triệu sản phẩm, 500 triệu watch (thị trường theo dõi giá nhỏ hơn mạng xã hội; tải do số sản phẩm và ngày sale quyết định) |
| Chi phí | ≈ 40–120 USD/tháng (ước lượng) | ≈ 1.500–4.000 USD/tháng (ước lượng, chưa tính chi phí đối tác dữ liệu) | ≈ 25.000–60.000 USD/tháng (ước lượng) |
| API | Node.js (NestJS) trên Cloud Run 2 × 2 vCPU · 4 GB RAM | Node.js (NestJS), container autoscale 2–4 (autoscale) × 2 vCPU · 4 GB RAM | Node.js trên Kubernetes, 3 AZ 20 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Crawler + cảnh báo | Node.js worker (fetch + cheerio) 1 × 1 vCPU · 2 GB RAM | Chưa cần | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL + TimescaleDB (managed) 1 primary + 1 read replica × 4 vCPU · 16 GB · 500 GB SSD | Chưa cần |
| Thông báo | Firebase Cloud Messaging + Amazon SES 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần |
| CDN | Chưa cần | Cloudflare / CloudFront 1 × Cache JSON biểu đồ, ảnh, web app | CloudFront / Cloudflare 1 × JSON biểu đồ, ảnh, web app |
| Crawl scheduler | Chưa cần | Node.js, khóa leader trong Postgres 2 (1 chạy, 1 chờ) × 1 vCPU · 2 GB RAM | Service chia theo sàn, khóa leader qua Kubernetes lease 5 pod (1 mỗi sàn) × 2 vCPU · 4 GB mỗi pod |
| Queue | Chưa cần | Amazon SQS 3 (task crawl, giá đổi, thông báo) × Managed, có dead-letter queue | Chưa cần |
| Crawler | Chưa cần | Node.js worker 13 (autoscale) × 1 vCPU · 2 GB RAM, 50 request song song | Chưa cần |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 4 GB | Chưa cần |
| Notifier | Chưa cần | Node.js consumer + FCM/APNs + SES 2 × 1 vCPU · 2 GB RAM | Node.js consumer + FCM/APNs + SES 4–31 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Crawler + feed đối tác | Chưa cần | Chưa cần | Node.js trên Kubernetes 123 pod (HPA) × 1 vCPU · 2 GB mỗi pod |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 3 broker × Topic observations, price-changed 48 partition theo product_id, RF 3 |
| Lịch sử giá | Chưa cần | Chưa cần | ClickHouse (ReplicatedMergeTree) 3 shard × 2 replica × 8 vCPU · 32 GB · 1 TB mỗi node |
| Sản phẩm + watch | Chưa cần | Chưa cần | DynamoDB (on-demand) 1 bảng sản phẩm + 1 bảng watch × Watch: partition key product_id, sort key target_price |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis Cluster 3 shard × (1 primary + 1 replica) × 2 vCPU · 13 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) Có hàng triệu sản phẩm nhưng chỉ được gọi mỗi sàn vài trăm lần mỗi giây. Lấy giá cái nào trước?
Cron chạy mỗi giờ, đẩy hết sản phẩm vào queue. Sản phẩm không ai xem cũng được lấy dày như sản phẩm đang sale.
Đánh đổi: 5 triệu sản phẩm mỗi giờ là gần 1.400 req/s, vượt xa mức sàn chấp nhận; sản phẩm quan trọng phải xếp hàng sau sản phẩm vô dụng.
⚠ Chưa có nguồn trong thư viện: lấy giá toàn bộ là nhận định chung
Sản phẩm hot 15 phút, ấm 2 giờ, lạnh 24 giờ. Scheduler đọc index next_check_at, mỗi sàn có token bucket riêng (Grokking ch.19) nên không sàn nào bị gọi quá trần.
Đánh đổi: Tầng cố định bỏ lỡ sản phẩm ít người theo dõi nhưng giá đổi liên tục, và không biết sản phẩm nào sắp chạm ngưỡng của ai đó.
Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Token bucket algorithm" ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Prioritization" (delay tolerance)
Điểm = số người theo dõi × độ biến động giá gần đây × độ gần ngưỡng (giá chỉ còn cách target ≤ 5% thì lấy dày hơn), cộng thêm cho các ngày sale đã biết lịch (9.9, 11.11, 12.12). Ngân sách mỗi sàn chia theo điểm, sản phẩm lạnh vẫn có suất tối thiểu 1 lần/ngày. Trước khi crawl, kiểm tra xem đã có giá mới từ feed đối tác hoặc từ tiện ích trình duyệt chưa để bỏ qua.
Đánh đổi: Scheduler phức tạp và cần dữ liệu để tinh chỉnh trọng số; phải giám sát để sản phẩm lạnh không bị bỏ quên.
Nguồn: Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · mục "Prioritization" (delay tolerance) ; Chưa có nguồn trong thư viện: điểm ưu tiên động là thiết kế của khoá học
2) Lưu lịch sử giá của hàng chục triệu sản phẩm thế nào để biểu đồ vẫn nhanh?
Ghi cả khi giá không đổi, bảng không partition. Vẽ biểu đồ 1 năm là quét hàng nghìn hàng mỗi sản phẩm.
Đánh đổi: Hàng tỉ hàng mỗi năm, phần lớn trùng nhau; index phình to, xóa dữ liệu cũ khóa bảng.
⚠ Chưa có nguồn trong thư viện: ghi mỗi lần crawl là nhận định chung
Giá đổi khoảng 10% số lần lấy nên dữ liệu giảm 10 lần. Partition theo tháng để drop dữ liệu cũ; biểu đồ đọc theo index một khoảng thời gian.
Đánh đổi: Ổn tới vài trăm GB; biểu đồ vẫn phải gom theo ngày mỗi lần đọc và một Postgres sẽ chật khi dữ liệu lên TB.
⚠ Chưa có nguồn trong thư viện: partition theo tháng là kỹ thuật DB chung
Dùng TimescaleDB hoặc ClickHouse: dữ liệu theo (product_id, ts) nén theo cột, thường nhỏ đi khoảng 10 lần. Continuous aggregate gom min/max/giá cuối theo ngày nên biểu đồ 1 năm chỉ đọc 365 hàng. JSON biểu đồ của sản phẩm hot cache ở Redis/CDN và bị xóa khi có giá mới. Dữ liệu thô quá 2 năm chuyển ra S3.
Đánh đổi: Thêm một loại database phải vận hành; dữ liệu gom theo ngày trễ vài phút so với điểm giá mới nhất.
⚠ Chưa có nguồn trong thư viện: TimescaleDB/ClickHouse không có trong sách; xem /hoc/time-series-database
3) Một sản phẩm có 200.000 người đặt cảnh báo vừa giảm giá. Báo thế nào cho nhanh mà không trùng?
Đọc mọi watch rồi so với giá mới trong code.
Đánh đổi: Chi phí tỉ lệ với tổng số watch cho mỗi lần đổi giá; giá dao động quanh ngưỡng thì người dùng nhận thông báo liên tục.
⚠ Chưa có nguồn trong thư viện: duyệt toàn bảng là nhận định chung
Khi giá giảm, query đúng các watch có target_price >= giá mới của sản phẩm đó, đẩy từng thông báo vào queue để notifier gửi.
Đánh đổi: Message bị giao lại (at-least-once) hoặc hai matcher cùng xử lý thì vẫn gửi trùng; sản phẩm 200.000 watch tạo một lô khổng lồ trong một lần.
Nguồn: Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Message deletion" (visibility_timeout, dead-letter queue) ; Chưa có nguồn trong thư viện: index (product_id, target_price) là kỹ thuật DB chung
PriceChanged đi qua Kafka phân vùng theo product_id nên một sản phẩm chỉ do một matcher xử lý theo thứ tự. Watch chuyển ARMED sang TRIGGERED bằng UPDATE có điều kiện, chỉ về lại ARMED khi giá lên trên ngưỡng + 5% (tránh báo liên tục khi giá dao động). Notification có khóa (watch_id, episode) nên gửi lại vẫn chỉ ra một thông báo (Microservices Patterns §3.3.6). Sản phẩm nhiều watch thì chia thành lô 500 và rải qua queue, giới hạn tốc độ theo kênh gửi.
Đánh đổi: Thêm trạng thái trên mỗi watch; người ở lô cuối của sản phẩm cực hot nhận chậm hơn vài phút.
Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95) ; Grokking Modern System Design · ch.17 Distributed Messaging Queue ↗ · mục "Ordering of messages"
4) Sàn bắt đầu trả 403, captcha hoặc giá giả khi phát hiện crawler. Xử lý thế nào?
Tăng tốc độ khi bị chặn, giả trình duyệt và thuê dàn proxy để lách.
Đánh đổi: Vi phạm điều khoản của sàn, rủi ro pháp lý, bị chặn mạnh hơn; dữ liệu lẫn cả trang captcha nên ra giá sai.
⚠ Chưa có nguồn trong thư viện: né chặn không phải cách làm nên dùng, không có trong sách
Mỗi sàn có trần request/s; gặp 429 hoặc 403 thì giảm tốc và lùi thời gian theo cấp số nhân. Phát hiện trang captcha thì đánh dấu lần lấy thất bại, không ghi giá. Không lấy lại sản phẩm vừa có giá mới.
Đánh đổi: Khi bị chặn thì độ tươi của dữ liệu giảm cho mọi người; vẫn phụ thuộc hoàn toàn vào việc crawl.
Nguồn: Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Token bucket algorithm" ; Grokking Modern System Design · ch.35 Design a Web Crawler ↗ · mục "Self-throttle at a domain while crawling" và "High priority to robots.txt"
Dùng feed sản phẩm của chương trình đối tác/affiliate do sàn cung cấp cho phần lớn sản phẩm phổ biến. Tiện ích trình duyệt gửi giá người dùng đang xem; giá chỉ được chấp nhận khi nhiều quan sát độc lập khớp nhau. Crawl lấp phần còn thiếu với ngân sách tự động co lại theo tỉ lệ lỗi (circuit breaker theo sàn). Parser có kiểm tra sức khỏe: tỉ lệ đọc giá thất bại tăng đột ngột thì báo động vì trang có thể vừa đổi giao diện.
Đánh đổi: Phụ thuộc vào điều khoản của chương trình đối tác; dữ liệu từ tiện ích có thể bị gian lận nên cần kiểm tra chéo.
Nguồn: Building Microservices ↗ (.zip) · ch.11 · mục "Circuit Breakers" ; Chưa có nguồn trong thư viện: feed đối tác và tiện ích trình duyệt là thiết kế của khoá học
Trong phỏng vấn
- “Ngân sách gọi tới mỗi sàn là tài nguyên khan hiếm nhất, nên tôi xếp hạng sản phẩm theo số người theo dõi, độ biến động và độ gần ngưỡng.”
- “Tôi chỉ ghi điểm giá khi giá đổi và gom sẵn theo ngày, nên biểu đồ 1 năm chỉ đọc 365 điểm.”
- “Cảnh báo chạy theo sự kiện giá giảm, watch có trạng thái ARMED/TRIGGERED và khóa chống trùng cho mỗi đợt giảm.”
- “Tôi ưu tiên feed đối tác và dữ liệu từ tiện ích, crawl chỉ là phương án bù và luôn tôn trọng giới hạn của sàn.”
- Lấy giá mọi sản phẩm với cùng tần suất.
- Ghi một hàng cho mỗi lần crawl kể cả khi giá không đổi.
- Gửi lại thông báo mỗi lần crawl khi giá vẫn dưới ngưỡng.
- Tin ngay giá giảm 90% mà không kiểm tra lại (thường là lỗi parse hoặc trang captcha).
Đọ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.