Mạng chia sẻ ảnh
Hiểu đề
Yêu cầu chức năng
- Người dùng có thể đăng bài gồm 1-10 ảnh kèm chú thích
- Người dùng có thể theo dõi và bỏ theo dõi người khác
- Người dùng có thể xem feed ảnh từ những người mình theo dõi, mới nhất trước, cuộn vô hạn
- Người dùng có thể like, bỏ like và bình luận; mọi người thấy số like và số bình luận của bài
Yêu cầu phi chức năng
- Mở feed p95 < 300 ms; ảnh đầu tiên hiện trong < 1 s trên mạng 4G
- Ảnh đã tải lên không bao giờ mất (độ bền 11 số 9 của S3)
- Bài mới xuất hiện trong feed người theo dõi sau ≤ 10 s; số like được phép trễ vài giây
- Chịu được bài của tài khoản 10 triệu người theo dõi nhận 1 triệu like trong 10 phút
- Độ sẵn sàng 99,95% cho xem feed; đọc gấp khoảng 100 lần ghi
Ngoài phạm vi: Video, Reels, Stories, livestream; Tin nhắn trực tiếp; Feed khám phá bằng ML và quảng cáo; Tìm kiếm theo hashtag và địa điểm (xem bài Tìm kiếm bài viết).
Thực thể và API
| Method | Endpoint | Ghi chú |
|---|---|---|
| POST | /v1/media/uploads | Xin presigned URL: body {contentType, size}. Trả media_id và URL PUT thẳng lên S3, hết hạn sau 10 phút. |
| POST | /v1/posts | Tạo bài {caption, mediaIds[]} sau khi ảnh đã lên S3. Header Idempotency-Key, trả post ở trạng thái PENDING. |
| GET | /v1/feed?cursor= | Trả 20 bài kèm URL ảnh CDN theo kích thước phù hợp thiết bị và cursor là post_id cuối. |
| PUT | /v1/posts/{id}/like | Like idempotent; DELETE cùng đường dẫn để bỏ like. |
| POST | /v1/posts/{id}/comments | Bình luận; GET /v1/posts/{id}/comments?cursor= để đọc. |
| PUT | /v1/users/{id}/follow | Theo dõi; DELETE để bỏ theo dõi. |
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ể đăng bài ảnh
File ảnh không đi qua API server. Client xin presigned URL, tải thẳng lên S3, rồi tạo bài. Worker resize ảnh thành nhiều kích thước; chỉ khi xong thì bài chuyển sang READY và được phát cho người theo dõi.
- 1Client nén ảnh về cạnh dài 1080 px rồi gọi POST /v1/media/uploads, nhận media_id + presigned URL
- 2Client PUT ảnh thẳng lên bucket uploads/ của S3
- 3S3 phát sự kiện ObjectCreated vào queue, worker kiểm tra định dạng thật, bỏ EXIF GPS, sinh 4 kích thước WebP và blurhash
- 4Client gọi POST /v1/posts {caption, mediaIds}; bài ở trạng thái PENDING
- 5Khi mọi media đã xử lý xong, bài chuyển READY và phát sự kiện post_ready cho fan-out
Nguồn: Grokking Modern System Design · ch.20 Blob Store ↗ · mục "Detailed design" và "Stream a file" ; Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "Upload, view, and search a photo"
2) Người dùng có thể xem feed
Feed đọc rất nhiều nên được dựng sẵn khi có bài mới (push) cho phần lớn tác giả. Bài của người nổi tiếng được kéo lúc đọc (pull) rồi trộn vào. Feed chỉ chứa post_id, nội dung và URL ảnh lấy từ cache.
- 1Client gọi GET /v1/feed
- 2Feed service đọc 500 post_id gần nhất từ Redis timeline:{userId}
- 3Lấy thêm bài mới của những người nổi tiếng mà user theo dõi (thường vài chục người) từ cache bài theo tác giả
- 4Trộn theo post_id giảm dần, lấy 20 bài, lấy nội dung và số like theo lô
- 5Trả URL CDN dạng /m/{media_id}/640.webp, client chọn kích thước theo màn hình
Nguồn: Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "Generate a timeline" (The pull approach, The push approach, Hybrid approach)
3) Người dùng có thể like và bình luận
Bảng like là nguồn sự thật và đảm bảo mỗi người chỉ like một lần. Số like hiển thị lấy từ bộ đếm riêng, cập nhật bất đồng bộ, nên một bài nóng không biến thành một hàng bị khóa liên tục.
- 1Client gọi PUT /v1/posts/{id}/like
- 2Like service ghi (post_id, user_id) vào bảng like; trùng thì trả 200 không làm gì
- 3Phát sự kiện like vào Kafka
- 4Bộ đếm cộng dồn vào Redis (bài nóng chia nhiều shard đếm), định kỳ ghi số tổng xuống DB
- 5Feed đọc số like từ Redis, hiển thị dạng rút gọn (1,2 N; 3,4 Tr)
Nguồn: Grokking Modern System Design · ch.24 Sharded Counters ↗ · mục "Detailed design" (Burst of writes requests, Manage read requests) ; Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "Like or dislike posts"
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.
Ứng dụng ảnh cho một cộng đồng nhỏ. Hai instance Node, một Postgres, ảnh lên S3 bằng presigned URL và phát qua CDN. Feed dựng lúc đọc bằng một câu SQL.
Ước lượng
- Request: 10.000 × 60 (feed, like, profile...) / 86.400 ≈ 6,9 req/s, đỉnh ×10 ≈ 69 req/s
- API: ceil(69 / (1.000 × 0,6)) = 1 → lấy tối thiểu 2 instance để dự phòng
- Ảnh mới: 10.000 × 0,1 = 1.000 ảnh/ngày × 2 MB (gốc + 4 bản resize) = 2 GB/ngày ≈ 730 GB/năm
- Ảnh xem: 10.000 × 200 ảnh × 100 KB = 200 GB/ngày ≈ 6 TB/tháng qua CDN
- Like: 10.000 × 20 / 86.400 ≈ 2,3/s, đỉnh ×10 ≈ 23/s; UPDATE cộng dồn trên Postgres vẫn nhẹ
Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · mục "Standard numbers to remember" và "Requests estimation" ; Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "Number of servers estimation" (500 triệu DAU, 1.157 server) ; 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), Docker trên Cloud Run | 2 | 2 vCPU · 4 GB RAM | 69 req/s đỉnh chỉ cần 1 instance; API không nhận byte ảnh nào nên rất nhẹ. |
| Database | PostgreSQL managed | 1 | 2 vCPU · 8 GB · 50 GB SSD | User, follow, post, like, comment; feed dựng bằng WHERE author_id IN (...) ORDER BY id DESC LIMIT 20. |
| Lưu ảnh | S3 / GCS, presigned URL | 1 bucket | ≈ 730 GB/năm | Client tải thẳng lên bucket; ảnh không bao giờ nằm trong DB. |
| Resize ảnh | Lambda / Cloud Function dùng sharp | theo sự kiện | 1-2 GB RAM | 1.000 ảnh/ngày × khoảng 1 s CPU, không cần server thường trực. |
| CDN | Cloudflare / CloudFront | 1 | ≈ 6 TB/tháng | Ảnh là phần băng thông lớn nhất ngay từ ngày đầu. |
| Push notification | Firebase Cloud Messaging | 1 | Dịch vụ bên ngoài | Báo like, bình luận, người theo dõi mới; miễn phí. |
- Điểm xuất phát: một API, một Postgres, ảnh trên S3 qua CDN
- Ảnh tải thẳng lên S3 bằng presigned URL ngay từ đầu
- Feed dựng lúc đọc (pull) vì mỗi người chỉ theo dõi vài trăm tài khoản ít bài
Ở 1 triệu DAU, câu SQL gộp bài của hàng trăm người theo dõi chạy hơn 1.000 lần/giây lúc đỉnh và chiếm hết CPU của Postgres, còn resize bằng function bắt đầu đắt và chậm khởi độ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 | 1 triệu DAU | 50 triệu DAU |
| Chi phí | ≈ 150–600 USD/tháng (ước lượng, chủ yếu là CDN) | ≈ 10.000–25.000 USD/tháng (ước lượng, CDN chiếm hơn một nửa) | ≈ 300.000–700.000 USD/tháng (ước lượng, CDN và lưu trữ ảnh chiếm phần lớn) |
| API | Node.js (NestJS), Docker trên Cloud Run 2 × 2 vCPU · 4 GB RAM | Node.js (NestJS), container autoscale 2–6 (autoscale) × 2 vCPU · 4 GB RAM | Chưa cần |
| Database | PostgreSQL managed 1 × 2 vCPU · 8 GB · 50 GB SSD | PostgreSQL managed (HA) 1 primary + 2 read replica × 8 vCPU · 32 GB · 1 TB SSD | Chưa cần |
| Lưu ảnh | S3 / GCS, presigned URL 1 bucket × ≈ 730 GB/năm | S3, lifecycle sang lớp IA sau 90 ngày 1 bucket × ≈ 73 TB/năm | S3 nhân bản 2 region, lifecycle IA/Glacier multi-region × ≈ 3,65 PB/năm |
| Resize ảnh | Lambda / Cloud Function dùng sharp theo sự kiện × 1-2 GB RAM | Chưa cần | Chưa cần |
| CDN | Cloudflare / CloudFront 1 × ≈ 6 TB/tháng | CloudFront / Cloudflare, URL bất biến 1 × ≈ 600 TB/tháng | Chưa cần |
| Push notification | Firebase Cloud Messaging 1 × Dịch vụ bên ngoài | Chưa cần | Chưa cần |
| Queue | Chưa cần | SQS / Pub/Sub 2 hàng đợi × media_uploaded, post_ready; có dead-letter queue | Chưa cần |
| Media worker | Chưa cần | Node.js + sharp 5 × 2 vCPU · 4 GB RAM | Node.js/Go + libvips trên Kubernetes 37 pod × 8 vCPU · 16 GB mỗi pod |
| Fan-out worker | Chưa cần | Node.js, ghi Redis bằng pipeline 2 × 2 vCPU · 4 GB RAM | Consumer Kafka, pipeline Redis 11 pod × 2 vCPU · 4 GB mỗi pod |
| Redis | Chưa cần | ElastiCache / Memorystore 1 primary + 1 replica × 2 vCPU · 16 GB | Chưa cần |
| Edge | Chưa cần | Chưa cần | GeoDNS + nhiều CDN + origin shield 3 region × ≈ 30 PB/tháng |
| API (feed, post, like) | Chưa cần | Chưa cần | Node.js trên Kubernetes, HPA 174 pod × 2 vCPU · 4 GB mỗi pod |
| Redis Cluster | Chưa cần | Chưa cần | Timeline + cache bài + sharded counter 12 shard × (1 primary + 1 replica) × 4 vCPU · 32 GB mỗi node |
| Like/Comment store | Chưa cần | Chưa cần | ScyllaDB, partition theo post_id 9 node (3/AZ) × 8 vCPU · 64 GB · 4 TB NVMe |
| User/Post/Follow DB | Chưa cần | Chưa cần | PostgreSQL shard theo user_id (Citus/Vitess) 8 shard × (1 primary + 1 replica) × 16 vCPU · 64 GB · 2 TB SSD |
| Kafka | Chưa cần | Chưa cần | MSK / Confluent 6 broker (2/AZ) × Topic media, post_ready, likes, comments; RF 3 |
Đà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) Nhận và xử lý 5 triệu ảnh mỗi ngày thế nào mà API server không bị nghẽn?
Mỗi ảnh vài MB chạy qua Node.js, chiếm băng thông và bộ nhớ của API, rồi resize 4 kích thước ngay trong request mất 1-2 s CPU. Mạng 4G chập chờn làm request treo lâu và giữ kết nối.
Đánh đổi: Dễ viết nhất, nhưng API vừa tốn băng thông vừa bị CPU resize chặn event loop.
Nguồn: Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "Upload, view, and search a photo"
API chỉ ký URL, client PUT thẳng lên S3 (Grokking ch.20 Blob Store). S3 phát sự kiện, worker hoặc Lambda dùng sharp sinh các kích thước. API không chạm vào byte ảnh nào.
Đánh đổi: Phải xử lý trường hợp client tạo bài trước khi resize xong và ảnh tải lên nhưng không bao giờ được gắn vào bài.
Nguồn: Grokking Modern System Design · ch.20 Blob Store ↗ · mục "Detailed design" và "Stream a file" ; Chưa có nguồn trong thư viện: presigned URL và Lambda/sharp không có trong sách
Client nén về 1080 px trước khi tải (giảm 3 MB xuống khoảng 300-500 KB). Presigned URL giới hạn content-type và kích thước tối đa, key theo media_id; mạng yếu thì dùng multipart upload để tải tiếp phần còn thiếu. Worker kiểm tra magic bytes, bỏ EXIF GPS, sinh 1080/640/320/150 px WebP, blurhash và chạy kiểm duyệt nội dung. Post giữ PENDING tới khi mọi media READY rồi mới fan-out. Bucket uploads/ có lifecycle xóa file không được gắn vào bài sau 24 h.
Đánh đổi: Nhiều trạng thái và nhiều bước bất đồng bộ hơn; người đăng thấy bài ngay trên máy mình nhưng người khác thấy sau vài giây.
Nguồn: Grokking Modern System Design · ch.20 Blob Store ↗ · mục "Detailed design" và "Stream a file" ; Chưa có nguồn trong thư viện: chi tiết nén phía client, EXIF, lifecycle là thực hành chung
2) Dựng feed thế nào khi có cả người dùng thường và tài khoản 10 triệu người theo dõi?
Theo dõi 500 người là truy vấn bài của 500 tác giả, gộp và sắp xếp cho mỗi lần mở feed. Grokking ch.33 chỉ ra phần lớn tác giả không có bài mới nên đa số truy vấn trả về rỗng.
Đánh đổi: Ghi rẻ, nhưng đọc chậm và tốn kém trong một hệ thống đọc gấp 100 lần ghi.
Nguồn: Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "The pull approach"
Khi bài READY, worker thêm post_id vào danh sách Redis của từng người theo dõi. Mở feed chỉ còn là một lệnh đọc danh sách.
Đánh đổi: Một bài của tài khoản 10 triệu người theo dõi tạo 10 triệu lần ghi, feed của mọi người bị trễ theo hàng đợi fan-out.
Nguồn: Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "The push approach"
Tác giả dưới 10.000 người theo dõi thì push. Người nổi tiếng không push; lúc đọc, feed service lấy bài mới của vài chục người nổi tiếng mà user theo dõi từ cache bài theo tác giả rồi trộn vào (Grokking ch.33). Chỉ push tới người đã mở app trong 30 ngày; người lâu không vào thì dựng lại feed bằng pull khi họ quay lại. Timeline chỉ chứa post_id 8 B, cắt ở 500 phần tử.
Đánh đổi: Đường đọc phức tạp hơn và phải chọn ngưỡng người nổi tiếng; người vừa vượt ngưỡng cần chuyển chế độ mà không làm mất bài.
Nguồn: Grokking Modern System Design · ch.33 Design Instagram ↗ · mục "Hybrid approach" ; Redis in Action ↗ · ch.8 · §8.2 Home timeline
3) Phát 1 PB ảnh mỗi ngày tới người dùng nhanh và không phá sản thế nào?
Mọi lượt xem đều chạy tới một region, điện thoại tải ảnh 1080 px dù màn hình chỉ cần 320 px. Phí egress S3 cao hơn CDN nhiều lần.
Đánh đổi: Không phải cấu hình gì, nhưng chậm với người ở xa và đắt.
⚠ Chưa có nguồn trong thư viện: trả ảnh trực tiếp từ origin là nhận định chung
URL chứa media_id và kích thước, nội dung không bao giờ đổi nên đặt Cache-Control immutable, max-age 1 năm (Grokking ch.11 CDN). Client chọn 320/640/1080 theo màn hình. Tỷ lệ trúng cache trên 95%.
Đánh đổi: Ảnh của tài khoản riêng tư nằm trên URL công khai, ai có link là xem được.
Nguồn: Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN"
Thêm origin shield để mỗi ảnh chỉ kéo từ S3 một lần cho cả mạng CDN, đẩy tỷ lệ trúng lên khoảng 98%. Edge trả AVIF/WebP theo header Accept. Ảnh tài khoản riêng tư dùng URL ký hết hạn sau vài giờ. Client hiện blurhash ngay, tải trước ảnh của 3-5 bài kế tiếp khi đang cuộn. Ở quy mô lớn dùng nhiều CDN và đặt cache tại ISP; ảnh gốc ít xem sau 90 ngày chuyển sang lớp lưu trữ rẻ.
Đánh đổi: Cấu hình và chi phí vận hành phức tạp; URL ký làm giảm tỷ lệ trúng cache cho ảnh riêng tư.
Nguồn: Grokking Modern System Design · ch.11 Content Delivery Network (CDN) ↗ · mục "Content caching strategies in CDN" ; Grokking Modern System Design · ch.11 ↗ · mục "Multi-tier CDN architecture"
4) Đếm like và bình luận ở quy mô lớn, kể cả bài nhận 1.667 like/s?
COUNT trên bài 1 triệu like là quét 1 triệu hàng cho mỗi lượt xem feed. UPDATE cộng dồn thì mọi lượt like tranh khóa trên cùng một hàng, bài viral làm nghẽn cả DB.
Đánh đổi: Đúng tuyệt đối nhưng sập đúng lúc bài đang nóng.
⚠ Chưa có nguồn trong thư viện: COUNT và UPDATE cộng dồn là nhận định chung
Bảng like (khóa chính post_id, user_id) đảm bảo không like trùng; số đếm INCR trong Redis, đọc rất nhanh, một job ghi tổng xuống DB mỗi vài giây.
Đánh đổi: Một key của bài cực nóng vẫn dồn vào một shard Redis; Redis mất dữ liệu thì số đếm lệch tới khi dựng lại.
Nguồn: Redis in Action ↗ · ch.5 · §5.2 Counters and statistics
Bảng like nằm trên ScyllaDB/Cassandra, partition theo post_id, ghi idempotent. Sự kiện like qua Kafka tới bộ đếm. Bài bình thường dùng một key; bài vượt ngưỡng tốc độ thì chia thành 16 key con like:{post}:{0..15}, mỗi lượt cộng vào một key ngẫu nhiên, đọc thì cộng 16 key (Grokking ch.24 Sharded Counters). Số tổng được ghi định kỳ xuống DB và đối soát với bảng like. Hiển thị dạng rút gọn nên lệch vài giây không ai thấy. "Tôi đã like chưa" tra theo (post_id, user_id).
Đánh đổi: Số like là gần đúng trong vài giây và cần job đối soát; nhiều thành phần hơn cho một con số.
Nguồn: Grokking Modern System Design · ch.24 Sharded Counters ↗ · mục "Detailed design" (Burst of writes requests, Manage read requests) ; Chưa có nguồn trong thư viện: ScyllaDB không có trong sách
Trong phỏng vấn
- “File ảnh không bao giờ đi qua API: client lấy presigned URL và tải thẳng lên S3, worker resize bất đồng bộ.”
- “Tôi dùng fan-out hybrid: push cho tác giả thường, pull cho người nổi tiếng, và chỉ push tới người dùng còn hoạt động.”
- “URL ảnh là bất biến nên CDN cache được 1 năm; băng thông ảnh mới là chi phí lớn nhất của hệ thống.”
- “Bảng like là nguồn sự thật để chống trùng, còn số like hiển thị là bộ đếm bất đồng bộ, chia shard cho bài nóng.”
- Upload ảnh qua API server và resize trong request.
- Push bài của người nổi tiếng tới hàng triệu timeline.
- UPDATE like_count trên hàng của bài cho mỗi lượt like.
- Quên tính băng thông CDN: ở quy mô lớn nó lớn hơn tiền server nhiều lần.
Đọ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.