Tổng hợp tin tức
Hiểu đề
Yêu cầu chức năng
- Hệ thống thu thập bài mới từ hàng nghìn nguồn (RSS, sitemap tin tức) vài phút sau khi nguồn đăng
- Các bài cùng một sự kiện từ nhiều báo được gộp thành một cụm tin; bản đăng lại y hệt bị loại
- Người dùng xem dòng tin chính và theo chủ đề (Thời sự, Thể thao, Kinh doanh...)
- Người dùng đã đăng nhập có feed "Dành cho bạn" xếp theo chủ đề/nguồn họ theo dõi và lịch sử đọc
- Bấm vào tin thì chuyển sang bài gốc, hệ thống ghi nhận lượt click
Yêu cầu phi chức năng
- Bài mới xuất hiện trên app p95 ≤ 5 phút sau khi nguồn đăng (nguồn lớn ≤ 2 phút)
- Tải feed p95 < 200 ms, độ sẵn sàng 99,9%
- Bài trùng lọt ra feed thành hai tin riêng < 1% số cụm
- Chịu đỉnh ×5 khi có tin nóng (bão lớn, chung kết bóng đá)
- Lưu tiêu đề, tóm tắt, link của bài trong 1 năm
Ngoài phạm vi: Lưu và hiển thị toàn văn bài báo (chỉ giữ tiêu đề, sapo, ảnh đại diện, link); Bình luận, chia sẻ, mạng xã hội; Quảng cáo và đo lường quảng cáo; Tóm tắt tin bằng AI, kiểm chứng thông tin.
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| GET | /v1/feed?topic=thoi-su&cursor= | Feed chung theo chủ đề, giống nhau cho mọi người nên cache ở CDN 30 s. |
| GET | /v1/feed/for-you?cursor= | Feed cá nhân, cần đăng nhập, chỉ trả danh sách cluster_id + dữ liệu cụm đã cache. |
| GET | /v1/stories/{clusterId} | Chi tiết một cụm: các bài từ nhiều báo, sắp theo thời gian và nguồn. |
| PUT | /v1/me/follows | Theo dõi / bỏ theo dõi chủ đề hoặc nguồn. |
| POST | /v1/events | Client gửi lô sự kiện xem/click, trả 202 ngay. |
| GET | /r/{articleId} | Ghi click rồi trả 302 sang bài gốc. |
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) Hệ thống thu thập bài mới từ hàng nghìn nguồn
Mỗi feed có lịch lấy riêng. Crawler gửi request có điều kiện nên phần lớn lần lấy chỉ nhận 304, rồi đẩy bài mới vào queue để khâu xử lý chạy độc lập.
- 1Scheduler chọn các Source có next_fetch_at <= now()
- 2Crawler GET feed kèm If-None-Match / If-Modified-Since; 304 thì chỉ dời lịch
- 3Parse RSS, chuẩn hóa URL (bỏ utm_*, chuyển về canonical), tính url_hash
- 4url_hash đã có thì bỏ qua; bài mới thì đẩy vào queue
- 5Cập nhật next_fetch_at theo tần suất đăng của nguồn
Nguồn: Grokking Modern System Design · ch.35 Design a Web Crawler ↗ · mục "Design" và "Scheduling" ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Token bucket algorithm"
2) Bài cùng sự kiện được gộp thành cụm
Processor so bài mới với các bài trong 48 giờ gần nhất. Dùng MinHash + LSH để chỉ so với vài ứng viên giống nhau, rồi xác nhận bằng độ tương đồng chi tiết hơn.
- 1Bỏ dấu, tách từ tiếng Việt cho tiêu đề + sapo, tạo shingle 3 từ
- 2Tính chữ ký MinHash 128 giá trị, tra bảng LSH để lấy các bài ứng viên
- 3Tính Jaccard / cosine với ứng viên; vượt ngưỡng thì gán cluster_id của ứng viên
- 4Không có ứng viên đủ giống thì tạo cụm mới
- 5Cập nhật số bài, số nguồn, bài đại diện và điểm hot của cụm
⚠ Chưa có nguồn trong thư viện: MinHash + LSH không có trong sách; Grokking ch.26 YouTube chỉ nhắc locality-sensitive hashing để loại trùng
3) Người dùng xem dòng tin chính và theo chủ đề
Feed chung giống nhau với mọi người nên được tính sẵn và phục vụ từ CDN. Origin chỉ dựng lại danh sách mỗi 30 giây.
- 1Job mỗi 30 s lấy top cụm theo điểm hot cho từng chủ đề, ghi vào Redis ZSET
- 2Client gọi GET /v1/feed?topic=... qua CDN (s-maxage=30, stale-while-revalidate)
- 3CDN miss thì API đọc ZSET + dữ liệu cụm từ Redis, trả JSON
Nguồn: Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN" ; Grokking Modern System Design · ch.32 Design Newsfeed System ↗ · mục "The newsfeed ranking service"
4) Người dùng có feed cá nhân hóa
Xếp hạng hai bước: lấy vài trăm ứng viên từ các danh sách đã tính sẵn, rồi chấm điểm theo hồ sơ của người dùng ngay trong request.
- 1Lấy ứng viên: top cụm của các chủ đề người dùng theo dõi + tin nóng chung + tin địa phương
- 2Loại cụm đã đọc (Bloom filter theo user trong Redis)
- 3Điểm = mức quan tâm chủ đề × độ mới (giảm theo thời gian) × độ lớn cụm, thêm luật đa dạng nguồn
- 4Cache kết quả theo user 5 phút; client gửi sự kiện đọc về /v1/events để cập nhật hồ sơ
Nguồn: Grokking Modern System Design · ch.32 Design Newsfeed System ↗ · mục "The newsfeed ranking service" và "Posts ranking and newsfeed construction"
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.
Trang tổng hợp tin nhỏ. Hai instance Node sau CDN, một Postgres, crawler chạy theo lịch trong một worker; gộp cụm bằng TF-IDF trong bộ nhớ là đủ.
Ước lượng
- Crawl: 200 feed × 1 lần / 300 s ≈ 0,67 request/s
- Bài mới: 200 nguồn × 50 bài/ngày = 10.000 bài/ngày / 86.400 ≈ 0,12 bài/s
- Đọc: 10.000 DAU × 30 req / 86.400 ≈ 3,5 req/s, đỉnh ×5 ≈ 17 req/s
- API: ceil(17 / 600) = 1 → chạy 2 để dự phòng
- Lưu trữ: 10.000 bài × 2 KB × 365 ≈ 7,3 GB/năm
- Gộp cụm: cửa sổ 48 h = 20.000 bài, so TF-IDF trong bộ nhớ vẫn dưới 1 s mỗi bài
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 feed chủ đề 30 s, ảnh, JS/CSS | Phần lớn lượt đọc không chạm origin. |
| API | Node.js (NestJS) trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 17 req/s lúc đỉnh, 2 instance để không chết khi 1 cái lỗi. |
| Crawler + xử lý | Node.js worker, rss-parser | 1 | 1 vCPU · 2 GB RAM | 0,67 request/s và 0,12 bài/s; chết vài phút chỉ làm tin trễ, không mất dữ liệu. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | 7,3 GB/năm; lưu bài, cụm, nguồn, người dùng trong một chỗ. |
- Điểm xuất phát: một API, một worker crawl, một Postgres, CDN phía trước
- Mỗi feed lấy 5 phút một lần với ETag
- Gộp cụm bằng TF-IDF trong bộ nhớ, xếp hạng theo điểm hot giảm dần theo thời gian
Khi lên hàng nghìn nguồn, so từng bài với toàn bộ cửa sổ 48 giờ trở nên chậm, và feed cá nhân query thẳng Postgres sẽ quá tải lúc tin nóng.
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, 200 nguồn | 1 triệu DAU, 2.000 nguồn | 50 triệu DAU, 20.000 nguồn trong và ngoài nước |
| Chi phí | ≈ 40–120 USD/tháng (ước lượng) | ≈ 1.500–4.000 USD/tháng (ước lượng) | ≈ 30.000–80.000 USD/tháng (ước lượng) |
| CDN | Cloudflare (gói free/pro) 1 × Cache feed chủ đề 30 s, ảnh, JS/CSS | Cloudflare / CloudFront 1 × Feed chủ đề s-maxage 30 s + stale-while-revalidate, ảnh resize | CloudFront / Cloudflare, nhiều PoP 1 × Feed chủ đề, trang cụm tin, ảnh resize |
| 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 | Chưa cần |
| Crawler + xử lý | Node.js worker, rss-parser 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 managed (HA) 1 primary + 1 read replica × 4 vCPU · 16 GB · 200 GB SSD | PostgreSQL HA (Aurora), partition theo tháng 1 primary + 2 replica × 8 vCPU · 32 GB · 1 TB |
| Crawler | Chưa cần | Node.js worker pool 2 × 1 vCPU · 2 GB RAM | Node.js trên Kubernetes 6 pod × 1 vCPU · 2 GB mỗi pod |
| Queue | Chưa cần | Amazon SQS / Google Pub/Sub 2 (bài mới, sự kiện) × Managed, có dead-letter queue | Chưa cần |
| Gộp cụm | Chưa cần | Node.js / Python worker, MinHash + LSH 2 × 2 vCPU · 4 GB RAM | Worker MinHash + LSH (tùy chọn embedding) 3 pod × 4 vCPU · 8 GB mỗi pod |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 8 GB | Chưa cần |
| Kho sự kiện | Chưa cần | ClickHouse / BigQuery 1 × Managed | Chưa cần |
| Feed API | Chưa cần | Chưa cần | Node.js trên Kubernetes, 3 AZ 63 pod (HPA) × 2 vCPU · 4 GB mỗi pod |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 6 broker × Topic articles 12 partition, events 24 partition, RF 3 |
| Stream processing | Chưa cần | Chưa cần | Apache Flink 6 TaskManager × 4 vCPU · 16 GB mỗi node |
| Redis Cluster | Chưa cần | Chưa cần | ElastiCache Redis Cluster 6 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) Lấy bài từ hàng nghìn nguồn thế nào để nhanh mà không làm phiền các trang báo?
Một vòng lặp tuần tự gọi tất cả feed, mỗi lần tải lại toàn bộ file RSS.
Đánh đổi: Vài nghìn feed thì một vòng đã dài hơn một phút; báo ít đăng bị gọi thừa, báo lớn lại bị lấy chậm.
Nguồn: Grokking Modern System Design · ch.35 Design a Web Crawler ↗ · mục "Scheduling" (tần suất theo domain)
Mỗi Source có next_fetch_at; worker pool lấy feed đến hạn. Gửi ETag/Last-Modified để nhận 304 khi không có gì mới. Giới hạn số request đồng thời theo domain (token bucket, Grokking ch.19).
Đánh đổi: Chu kỳ cố định cho mỗi nguồn vẫn phải chỉnh tay; nguồn đăng dồn dập lúc có tin nóng vẫn bị trễ.
Nguồn: Grokking Modern System Design · ch.35 ↗ · mục "Self-throttle at a domain while crawling" ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · mục "Token bucket algorithm" ; Chưa có nguồn trong thư viện: ETag/Last-Modified không có trong sách
Ước lượng tốc độ đăng của nguồn theo khung giờ: nguồn đăng 10 bài/giờ được hỏi mỗi 1-2 phút, nguồn đăng 2 bài/ngày thì 30 phút; khi phát hiện bài mới thì rút ngắn chu kỳ tạm thời. Nguồn hỗ trợ WebSub hoặc sitemap tin tức thì nhận đẩy và dùng polling làm dự phòng. Fetch và xử lý tách bằng queue nên đợt tin nóng không làm crawler nghẽn.
Đánh đổi: Thêm logic ước lượng và theo dõi tình trạng từng nguồn; vẫn cần bộ parse HTML cho các báo không có RSS chuẩn.
Nguồn: Grokking Modern System Design · ch.35 ↗ · mục "Scheduling" ; Chưa có nguồn trong thư viện: WebSub và chu kỳ thích ứng là thiết kế của khoá học
2) Làm sao biết 20 bài của 20 báo khác nhau là cùng một sự kiện?
Chỉ gộp khi URL hoặc tiêu đề giống hệt. Bắt được bài đăng lại nhưng các báo luôn đặt tiêu đề khác nhau.
Đánh đổi: Feed đầy tin trùng: cùng một trận bóng xuất hiện 15 lần.
⚠ Chưa có nguồn trong thư viện: so trùng URL là nhận định chung
Biểu diễn tiêu đề + sapo bằng vector TF-IDF đã bỏ dấu và tách từ, so cosine với từng bài gần đây cùng chủ đề, vượt ngưỡng thì gộp.
Đánh đổi: Mỗi bài mới so với hàng chục nghìn bài nên chi phí tăng tuyến tính; chỉ ổn ở quy mô vài trăm nguồn.
Nguồn: Grokking Modern System Design · ch.21 Distributed Search ↗ · mục "Inverted index" ; Chưa có nguồn trong thư viện: TF-IDF và cosine không có trong sách
Chữ ký MinHash chia thành các band; bài nào trùng ít nhất một band là ứng viên, nên mỗi bài chỉ so chi tiết với vài bài. Xác nhận bằng Jaccard trên shingle cộng với trùng thực thể (tên người, địa danh, tỉ số). Cụm chỉ nhận bài trong 48 giờ để tin cũ không hút tin mới. Một job offline mỗi giờ gộp hoặc tách lại cụm bị sai. Có thể thay MinHash bằng embedding và tìm láng giềng gần khi cần hiểu nghĩa hơn.
Đánh đổi: Ngưỡng phải tinh chỉnh bằng dữ liệu gán nhãn; tách từ tiếng Việt sai làm giảm chất lượng; gộp nhầm hai sự kiện khác nhau tệ hơn để sót trùng.
⚠ Chưa có nguồn trong thư viện: MinHash + LSH không có trong sách; Grokking ch.26 chỉ nhắc locality-sensitive hashing
3) Xếp hạng feed "Dành cho bạn" thế nào cho nhanh và hợp sở thích?
Ai cũng thấy cùng một danh sách mới nhất. Không cần mô hình gì.
Đánh đổi: Nguồn đăng nhiều chiếm hết feed, tin lớn bị đẩy xuống sau vài phút, không cá nhân hóa.
⚠ Chưa có nguồn trong thư viện: sắp theo thời gian là nhận định chung
Điểm cụm = thời điểm xuất hiện + hằng số × (số nguồn + lượt click), giống cách cộng 432 điểm mỗi phiếu trong Redis in Action §1.3.1, nên tin cũ tự tụt hạng. Lưu ZSET theo chủ đề, feed cá nhân là trộn các ZSET của chủ đề người dùng theo dõi.
Đánh đổi: Cá nhân hóa còn thô, chỉ ở mức chủ đề; người dùng mới chưa theo dõi gì thì feed giống feed chung.
Nguồn: Redis in Action ↗ · ch.1 · §1.3 Hello Redis, mục "Voting on articles" (tr. 15) ; Redis in Action ↗ · ch.3 · §3.5 Sorted sets
Ứng viên là ~500 cụm từ ZSET các chủ đề quan tâm, tin nóng và tin địa phương. Bước xếp hạng nhẹ (dưới 50 ms) kết hợp vector sở thích tính từ click và thời gian đọc, độ mới, độ lớn cụm, loại tin đã đọc, rồi áp luật đa dạng (không quá 2 tin liền nhau cùng nguồn hoặc chủ đề). Hồ sơ cập nhật bằng stream sự kiện, giống ranking service của Grokking ch.32. Người dùng mới bắt đầu từ tin phổ biến theo vùng.
Đánh đổi: Cần pipeline sự kiện và thêm một dịch vụ phải đo A/B; dễ tạo bong bóng lọc nếu không giữ một phần tin chung.
Nguồn: Grokking Modern System Design · ch.32 Design Newsfeed System ↗ · mục "The newsfeed ranking service"
4) Lúc có tin nóng, lượng đọc tăng ×5. Phục vụ feed nhanh thế nào?
Truy vấn sắp xếp theo điểm, join bảng bài và cụm cho từng request.
Đánh đổi: Hàng nghìn query nặng mỗi giây đè sập DB đúng lúc nhiều người đọc nhất.
⚠ Chưa có nguồn trong thư viện: query Postgres mỗi request là nhận định chung
API đọc danh sách đã tính sẵn trong Redis, DB chỉ bị chạm khi dựng lại cache.
Đánh đổi: Mọi request vẫn đi tới origin; nhiều request cùng lúc khi cache vừa hết hạn có thể dội xuống DB (cache stampede).
Nguồn: Grokking Modern System Design · ch.16 Distributed Cache ↗ · mục "Writing policies" và "Cache invalidation" (TTL)
Feed theo chủ đề giống nhau cho mọi người nên CDN (Grokking ch.11) giữ 30 s với stale-while-revalidate, origin chỉ nhận vài request mỗi chủ đề mỗi 30 s. Feed cá nhân chỉ chứa danh sách cluster_id; nội dung cụm lấy từ cache dùng chung (Redis, Grokking ch.16) nên một cụm hot chỉ nằm một chỗ. Khi dựng lại cache thì gộp các request trùng (request coalescing). Ảnh đại diện resize và cache qua CDN.
Đánh đổi: Feed chung trễ tới 30 s; tin cực nóng cần purge CDN theo tag chủ đề.
Nguồn: Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN" ; Grokking Modern System Design · ch.16 Distributed Cache ↗ · mục "Cache client" ; Chưa có nguồn trong thư viện: stale-while-revalidate không có trong sách
Trong phỏng vấn
- “Hệ thống chia làm hai đường: đường ghi (crawl, gộp cụm) ít tải nhưng cần đúng, đường đọc (feed) rất nhiều tải nên phục vụ từ cache và CDN.”
- “Tôi lấy feed theo chu kỳ thích ứng với request có điều kiện, nên phần lớn lần lấy chỉ nhận 304.”
- “Gộp tin dùng MinHash + LSH để tìm ứng viên trong cửa sổ 48 giờ, không so mỗi bài với toàn bộ kho.”
- “Feed chung giống nhau cho mọi người nên nằm trên CDN; feed cá nhân chỉ là danh sách ID trỏ vào dữ liệu cụm dùng chung.”
- Gộp tin bằng so trùng tiêu đề hoặc URL.
- Gọi lại mọi feed với cùng chu kỳ cố định, không dùng ETag/Last-Modified.
- Lưu toàn văn bài báo khi chỉ cần tiêu đề, sapo và link.
- Tính feed cá nhân bằng query DB cho từng request.
Đọ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.