Series duthaho đi phỏng vấn: bài toán "Ngày này năm xưa"
Dưới đây là mô phỏng một buổi phỏng vấn System Design tại một công ty công nghệ lớn giữa Interviewer (Anh Tùng - Principal Engineer) và Ứng viên (Duthaho). Like và subscrible bài viết gốc https://duthaho.substack.com/p/toi-i-phong-van-bai-toan-ngay-nay của mình để nhận bài viết mới nhất nhé!
Anh Tùng: Chào Duthaho. Anh thấy CV của em có kinh nghiệm sâu về System Design. Hôm nay chúng ta sẽ giải bài toán thiết kế tính năng “On This Day” của Facebook. Em hãy hình dung hệ thống phải phục vụ lượng user khổng lồ. Em bắt đầu đi.
Duthaho: Chào anh Tùng. Bài toán rất thú vị. Trước khi đi vào kiến trúc, em muốn làm rõ một số requirements để khoanh vùng phạm vi:
-
Scale: Chúng ta đang nói về quy mô cỡ Facebook, tức là khoảng 2-3 tỷ users?
-
Tính năng: Chỉ hiển thị bài viết (Posts), ảnh (Photos) hay cả quan hệ bạn bè (Friends)?
-
Latency: Yêu cầu độ trễ thế nào khi user load feed này?
Anh Tùng: Tốt. Giả sử 2 tỷ user active. Tập trung vào Posts và Photos. Độ trễ phải cực thấp, dưới 200ms user phải thấy nội dung. Và quan trọng nhất: Hệ thống này không được làm sập Core Feed chính.
Duthaho: Các hướng tiếp cận & trade-offs:
Cách 1: Query trực tiếp (Real-time)
Khi user login, thực hiện câu query SQL kiểu: SELECT * FROM posts WHERE user_id = X AND MONTH(created_at) = M AND DAY(created_at) = D
-
Ưu điểm: Dữ liệu luôn tươi mới (real-time). Không tốn storage lưu cache.
-
Nhược điểm:
-
Hiệu năng thảm họa: Với bảng posts chứa hàng nghìn tỷ bản ghi, việc query không theo index cluster (thường là Time-series tăng dần) mà query theo “ngày/tháng” bỏ qua “năm” là cực nặng.
-
DB Load: 2 tỷ queries phức tạp mỗi sáng sẽ đánh sập bất kỳ database nào.
-
Cách 2: Pre-computation (Batch Processing - Offline)
Chạy một job hàng ngày quét qua dữ liệu, tính toán trước kết quả cho tất cả user và lưu vào một bảng riêng hoặc Cache.
-
Ưu điểm: Read cực nhanh (O(1)). Không ảnh hưởng DB chính vào giờ cao điểm.
-
Nhược điểm:
-
Lãng phí tài nguyên: Rất nhiều user không online ngày hôm đó nhưng vẫn tốn công tính toán và lưu trữ.
-
Stale Data: Nếu user vừa xóa bài viết 5 phút trước, job chạy từ đêm qua vẫn hiện bài đó -> Trải nghiệm tệ (và rủi ro privacy).
-
Cách 3: Hybrid (Lazy Loading + Caching)
Không tính trước cho tất cả. Chỉ khi user có dấu hiệu active (login) hoặc gần đến giờ gửi noti, hệ thống mới tính toán và cache lại với TTL (Time To Live) là 24h.
Với yêu cầu không ảnh hưởng Core Feed và latency thấp, em sẽ loại bỏ ngay phương án query trực tiếp (Real-time Query) vào bảng Posts chính. Query kiểu WHERE user_id = ? AND MONTH(date) = ? trên bảng hàng tỷ dòng là thảm họa về hiệu năng.
Em đề xuất kiến trúc Hybrid với việc tính toán trước (Pre-computation):
-
Memory Service: Microservice chịu trách nhiệm logic nghiệp vụ “Ngày này năm xưa”.
-
Activity Log DB (Source of Truth): Database chứa tất cả activities (Posts, Likes, Photos).
-
Search Index / Secondary Index: Hệ thống đánh index đặc biệt để query theo (User_ID, Month, Day).
-
Cache Layer (Redis/Memcached): Lưu kết quả đã tính toán (”Memories Feed”) của user trong ngày.
-
Notification Service: Quản lý việc đẩy thông báo.
Anh Tùng: Hợp lý. Vậy em sẽ lưu trữ dữ liệu “lookup” này như thế nào để query nhanh nhất?
Duthaho: Vấn đề khó nhất ở đây là: Làm sao query nhanh dữ liệu cũ?
Dữ liệu Facebook thường lưu theo Time-series (Append only). Dữ liệu mới nằm trên RAM/Hot storage, dữ liệu cũ (Cold data) nằm ở đĩa cứng chậm hơn. Query random access vào dữ liệu 5 năm trước là rất tốn kém (Disk I/O).
Giải pháp: Thay vì scan bảng posts, ta cần một bảng Index riêng (Lookup table):
-
Key: User_ID
-
Columns: List các Post_ID kèm Timestamp.
-
Tuy nhiên, cái này vẫn chưa tối ưu cho query “Ngày này”.
-
Ta cần một cấu trúc index dạng:
Bucket_Map<User_ID, List<Post_Metadata>>. -
Facebook thực tế sử dụng hệ thống graph database của họ (TAO) và lớp caching cực mạnh. Nhưng với thiết kế tổng quát, ta có thể dùng Cassandra hoặc HBase với RowKey:
UserID#Month#Day.- Khi query
UserID=123, Month=12, Day=06, ta lấy được ngay lập tức tất cả post ID của các năm.
- Khi query
Quy trình lọc (Filtering Pipeline):
-
Fetch: Lấy list Post IDs.
-
Hydrate: Gọi sang Post Service để lấy nội dung chi tiết.
-
Privacy Check (Quan trọng): Kiểm tra ACL hiện tại. (User A post lên tường User B 5 năm trước, nay A block B -> B không được thấy).
-
Rank: Dùng ML model nhẹ để chọn ra post nhiều like/comment nhất để hiển thị đầu tiên.
Nếu dùng MySQL, em sẽ tạo một bảng riêng là memories_lookup.
Schema sẽ trông như thế này:
CREATE TABLE memories_lookup (
user_id BIGINT,
mm_dd SMALLINT, -- Ví dụ: 1206 (Ngày 6 tháng 12)
post_id BIGINT,
post_year SMALLINT,
PRIMARY KEY (user_id, mm_dd, post_id)
);
Em chọn Primary Key là (user_id, mm_dd, post_id) vì nó là Clustered Index.
Khi query SELECT * FROM memories_lookup WHERE user_id = X AND mm_dd = 1206, dữ liệu nằm liền nhau vật lý trên đĩa cứng. Ổ cứng chỉ cần seek 1 lần và đọc tuần tự (Sequential Read). Tốc độ sẽ cực nhanh.
Anh Tùng: Tại sao em không dùng Index trên bảng Posts có sẵn mà phải đẻ thêm bảng này? Tốn storage lắm.
Duthaho: Vì bảng Posts thường được tối ưu cho Time-series (mới nhất hiện trước). Index thường là (user_id, created_at).
Nếu em query tìm ngày 06/12 trên bảng đó, DB engine phải scan qua toàn bộ lịch sử của user, nhảy cóc qua các năm, tạo ra Random I/O rất lớn.
Việc tách bảng lookup giúp cô lập tải đọc (Read Load) khỏi bảng chính. Storage rẻ hơn Compute I/O nhiều, nên đây là trade-off đáng giá.
Anh Tùng: OK, đồng ý về Schema. Giờ sang vấn đề vận hành.
8:00 sáng, hệ thống bắn Notification cho 500 triệu người dùng. 50 triệu người cũng lúc bấm vào xem. Database của em chịu nổi không?
Duthaho: Để giải quyết vấn đề này, chúng ta cần chiến lược khác nhau:
1. Kỹ thuật “Jitter” (Phân tán ngẫu nhiên): Đừng bao giờ gửi 100 triệu thông báo vào đúng 08:00:00.
-
Thiết kế: Notification Service sẽ chia user thành các lô (batches).
-
Thực thi: Gửi rải rác trong khung giờ từ 08:00 đến 09:00. Ví dụ: User A nhận lúc 8:05, User B nhận lúc 8:42.
-
Hiệu quả: Giảm tải đỉnh (peak load) xuống 60 lần (nếu rải đều trong 60 phút).
2. Cache Warming (Làm nóng Cache):
-
Logic: Khi hệ thống chạy job ban đêm để tìm ra “Ai có kỷ niệm hôm nay” để đưa vào danh sách gửi Notification, hệ thống đã có dữ liệu trong tay.
-
Hành động: Ngay lúc tính toán xong (ví dụ 4:00 AM), hãy đẩy luôn dữ liệu đó vào Redis với TTL là 24h.
-
Kết quả: Khi User bấm vào thông báo lúc 8:00 AM, request đi thẳng vào Redis lấy dữ liệu ra. Database gần như không chịu tải gì cả (Zero DB Hit cho luồng này).
3. Circuit Breaker & Fallback: Giả sử Redis chết hoặc quá tải?
-
Đừng để request chọc thẳng xuống Database (sẽ làm sập luôn DB).
-
Kích hoạt Circuit Breaker: Trả về một thông báo nhẹ nhàng “Hệ thống đang bận, hãy quay lại sau” hoặc hiển thị một phiên bản cached cũ hơn, thay vì để user chờ đợi gây tắc nghẽn thêm.
Anh Tùng: Khá lắm. Cache Warming là key ở đây.
Anh Tùng: Giờ anh làm khó em một chút.
Em lưu dữ liệu vào bảng memories_lookup lúc 3:00 sáng.
Lúc 7:00 sáng, User A xóa bài viết gốc ở bảng Posts.
Lúc 8:00 sáng, User A vào xem “On This Day”.
Làm sao em đảm bảo bài viết đó biến mất? Đồng bộ dữ liệu kiểu gì?
Duthaho: Em có 2 lựa chọn: Dual Write (Ghi đồng bộ) hoặc CDC (Bất đồng bộ).
Em từ chối Dual Write (tức là code App vừa xóa Post vừa xóa Lookup trong 1 transaction) vì nó làm chậm thao tác xóa của user và ghép chặt 2 hệ thống (Tight Coupling).
Em chọn CDC (Change Data Capture) kết hợp với chiến lược Check-on-Read:
-
Async deletion: Khi Post bị xóa, DB bắn log update vào Kafka. Một Worker sẽ đọc Kafka để xóa dòng tương ứng trong memories_lookup. Sẽ có độ trễ vài giây.
-
Safety Net (Lưới bảo vệ): Đây là mấu chốt.
-
Khi User A load “On This Day”, em lấy được list Post_ID từ lookup (có thể vẫn còn sót bài đã xóa).
-
Nhưng trước khi trả về cho Client, em sẽ có bước Hydration (lấy chi tiết nội dung). Em gọi sang Post Service với list ID đó.
-
Post Service check DB chính, thấy bài đã xóa -> Trả về null.
-
Service “On This Day” thấy null thì filter bỏ đi.
-
User sẽ không bao giờ thấy “Ghost Data” (dữ liệu ma), dù hệ thống lookup có chưa kịp cập nhật.
Anh Tùng: Cách giải quyết “Check-on-Read” rất thông minh. Nó đảm bảo Consistency mà không hy sinh Availability của luồng Write.
Câu hỏi cuối: Em ước tính hệ thống này cần bao nhiêu Storage cho 2 tỷ user?
Duthaho:
-
2 tỷ user. Trung bình 500 posts/user -> 1,000 tỷ bản ghi (records).
-
Mỗi record lookup (User 8B + Date 2B + PostID 8B + Year 2B + Overhead) ~ 30-40 Bytes.
-
Tổng raw data = 1,000 tỷ * 40 Bytes = 40 TB.
-
Với Replication Factor = 3 (để an toàn) -> 120 TB.
-
Con số này hoàn toàn khả thi để lưu trữ trên cụm Cassandra hoặc HBase, thậm chí Sharded MySQL (khoảng 60-100 instances).
Anh Tùng: Cảm ơn Duthaho. Anh rất ấn tượng về cách em đi từ Schema chi tiết lên đến Architecture tổng thể và xử lý các Edge cases. Em có tư duy của một SA thực thụ.
All rights reserved