Thiết kế news feed: fan-out on write, fan-out on read và điều một tài khoản 5 triệu follower làm vỡ Nâng cao
Thời gian đọc ước tính: 11 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một mạng xã hội chung: người dùng follow nhau, đăng bài ngắn và mở feed trang chủ để xem bài mới nhất của mọi người mình follow. Query ngây thơ join follows với posts, sort theo thời gian, lấy 20 dòng. Ở 10.000 người dùng, nó chạy tốt.
Ở 20 triệu người dùng mỗi ngày, cùng query đó chạy hàng nghìn lần mỗi giây, mỗi lần qua hàng trăm followee. Tính sẵn mọi feed thì chi phí dồn sang lúc ghi, cho đến khi một tài khoản 5 triệu follower đăng bài.
Việc nên làm ở đâu: lúc ghi bài, lúc đọc feed, hay cả hai?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Tác giả thông thường (dưới ngưỡng) | Fan-out on write vào timeline Redis có giới hạn | Số lần đọc feed lớn hơn nhiều số bài; timeline vừa RAM |
| Tác giả vượt ngưỡng follower (ví dụ 10.000) | Fan-out on read cho bài của họ, trộn lúc đọc | Mỗi người đọc chỉ follow vài tài khoản như vậy |
| App nhỏ, hoặc màn hình admin theo thời gian | Kéo thẳng từ database | Index (author_id, created_at) giữ query nhanh |
| Follower không hoạt động 30+ ngày | Không giữ timeline; dựng lại khi đăng nhập | Tiết kiệm lượt fan-out và RAM |
| Trang vượt quá giới hạn timeline | Kéo từ kho bài viết, hoặc kết thúc feed | Ít người cuộn quá ~800 bài |
Ước lượng tải (giả định nêu rõ)
Phần tiêu đề “Ước lượng tải (giả định nêu rõ)”Các số dưới đây là giả định minh hoạ, không phải dữ liệu của sản phẩm thật.
| Giả định | Giá trị |
|---|---|
| Người dùng đăng ký / hoạt động mỗi ngày | 200 triệu / 20 triệu |
| Bài mới mỗi ngày | 4 triệu |
| Số follower trung bình mỗi bài | 200 (median thấp hơn nhiều; vài tài khoản có hàng triệu) |
| Số lần mở feed mỗi người mỗi ngày | 10 |
| Hệ số đỉnh so với trung bình | 3 lần |
| Một dòng bài viết (text, metadata, index; media để chỗ khác) | ~1 KB |
| Đại lượng | Phép tính | Kết quả |
|---|---|---|
| Bài viết | 4.000.000 / 86.400 | ~46/s, đỉnh ~140/s |
| Ghi timeline (fan-out on write) | 4 triệu × 200 = 800 triệu/ngày | ~9.300/s, đỉnh ~27.800/s |
| Đọc feed | 200 triệu / 86.400 | ~2.300/s, đỉnh ~6.900/s |
| Dung lượng bài viết | 4 triệu × 1 KB | ~4 GB/ngày, ~1,5 TB/năm |
| Cạnh follow | 200 triệu × 100 followee (giả định) | 20 tỷ cạnh |
Con số quyết định là write amplification (khuếch đại ghi): một bài trở thành trung bình 200 lần ghi timeline. Từng lần ghi rẻ; vấn đề nằm ở đuôi phân phối follower.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”| Fan-out on write (đẩy) | Fan-out on read (kéo) | |
|---|---|---|
| Việc lúc đăng bài | Một lần ghi cho mỗi follower | Một lần insert |
| Việc lúc đọc | Đọc một danh sách tính sẵn | Query mọi followee, trộn, sort |
| Độ trễ đọc | Thấp và ổn định | Tăng theo số followee |
| Chi phí một bài của tài khoản 5 triệu follower | 5 triệu lần ghi | Không thêm gì |
| Độ tươi | Trễ theo lag của queue | Tức thì |
Mô hình dữ liệu có ba phần:
posts(post_id, author_id, created_at, body, deleted_at): nguồn sự thật.follows(follower_id, followee_id), index cả hai chiều: “tôi follow ai” và “ai follow tôi”.tl:{user_id}: mỗi người một sorted set trong Redis. Member =post_id, score =created_attính bằng mili giây. Chỉ lưu id, không lưu nội dung.
Cách hoạt động
Phần tiêu đề “Cách hoạt động”INSERT bàiTác giả A đăng bài. Post API insert bài 7390…999 vào kho bài viết; đây là lần ghi bền vững duy nhất.
Trạng thái sau từng bước:
- Bài chỉ có trong kho bài viết. Chưa follower nào thấy.
- Event vào queue sau commit (xem transactional outbox). Độ trễ fan-out bắt đầu tính từ đây.
- Worker giữ một trang follower id. Tài khoản lớn cần hàng nghìn trang.
- 300 timeline đã có id mới, mỗi cái cắt còn 800 phần tử. Write amplification nằm ở bước này.
- B nhận id đúng thứ tự trong một lệnh:
ZRANGE tl:B 0 19 REV. - B nhận nội dung. Sửa và xoá bài có hiệu lực ở đây vì timeline chỉ giữ id.
Chi phí Redis theo tài liệu chính thức: ZADD O(log N) cho mỗi member, ZRANGE và ZREMRANGEBYRANK O(log N + M), với M là số member trả về hoặc bị xoá. ZREVRANGE vẫn chạy nhưng deprecated từ 6.2; dùng ZRANGE ... REV.
Bài toán người nổi tiếng và cách hybrid
Phần tiêu đề “Bài toán người nổi tiếng và cách hybrid”Với ngưỡng 10.000 follower, luồng đăng bài kiểm tra tác giả:
Đang tải diagram…
- Tác giả thông thường trả 200 lần ghi mỗi bài; người đọc không trả thêm gì.
- Tác giả 5 triệu follower trả một lần ghi. Mỗi người đọc follow họ trả thêm một lần đọc
posts:{author}. posts:{author}của tài khoản lớn thành hot key: hàng triệu người đọc dồn vào một shard. Cache trong process 1–5 giây.
Đặt hysteresis cho ngưỡng (chuyển sang kéo ở 10.000, quay lại đẩy khi dưới 8.000) để tài khoản sát ngưỡng không đổi chế độ liên tục.
Queue và worker fan-out
Phần tiêu đề “Queue và worker fan-out”Không fan-out ngay trong request. Tách làm hai tầng:
PostCreateddùng keyauthor_id(giữ thứ tự theo tác giả, xem message key).- Một splitter đọc follower theo trang và phát
FanoutBatch {post_id, 1.000 follower id}với key theo số batch, rải một bài lớn ra mọi partition.
Tác giả lớn có làn riêng, để batch của họ không chặn trước bài thường. Theo dõi consumer lag bằng thời gian, theo từng làn (xem giám sát lag).
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- Phân trang: dùng cursor
(score, post_id)của phần tử cuối, không dùng rank. Rank dịch chuyển mỗi khi có bài mới. Xem keyset pagination. - Tie-break: query
ZRANGE tl:B <score> -inf BYSCORE REV LIMIT 0 21(bao gồm biên), rồi bỏ các member ≥post_idcủa cursor. Score bằng nhau trả về theo thứ tự từ điển giảm dần, nên id cần cùng độ dài. - Độ chính xác score: score là double, chỉ chính xác tới 2^53. Dùng snowflake id 64-bit làm score sẽ mất chính xác; dùng mili giây.
- Xoá bài: đánh dấu
deleted_attrong kho bài viết. Hydrate sẽ bỏ qua; không fan-out lệnh xoá ra 5 triệu timeline. - Sửa bài: chỉ invalidate cache nội dung bài; timeline không đổi.
- Unfollow: một job async
ZREMcác post id gần đây của tác giả khỏi timeline của follower. Chặn (block) phải lọc lúc đọc, không được để “rồi sẽ nhất quán”. - Follow: backfill 20 bài gần nhất của tác giả vào timeline của follower.
- Ranking nằm ngoài phạm vi bài. Nó cắm vào sau bước lấy ứng viên: timeline cung cấp ~800 ứng viên, ranker sắp lại, và cursor khi đó phải trỏ vào một snapshot đã xếp hạng.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Các lệnh cho ra những con số trên (chạy với examples/docker-compose.yml, Redis ở cổng 6380):
> ZCARD tl:test # sau 1.000 lần ZADD1000> ZREMRANGEBYRANK tl:test 0 -801 # giữ 800 phần tử mới nhất200> OBJECT ENCODING tl:testskiplist> MEMORY USAGE tl:test SAMPLES 084048> ZADD p 9007199254740993 a # 2^53 + 11> ZSCORE p a9007199254740992MEMORY USAGE mặc định chỉ lấy mẫu 5 giá trị con và báo 79.392 cho cùng key; khi tính dung lượng, dùng SAMPLES 0.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Đọc feed là một
ZRANGEcộng hydrate 20 id, không phụ thuộc số followee. -
Batch fan-out idempotent: chạy lại
ZADDvới cùng member và score không đổi gì. -
Hybrid chặn bài tệ nhất ở mức ngưỡng × 1 lần ghi.
< slot="cons"> >
-
Write amplification: 800 triệu lần ghi timeline mỗi ngày cho 4 triệu bài.
-
Độ tươi phụ thuộc lag của queue; tồn đọng làm trễ mọi bài phía sau.
-
Hai luồng đọc phải bảo trì, cộng chuyển ngưỡng và backfill.
-
Timeline tốn RAM (~0,3–1,7 TB ở đây); phải loại người dùng không hoạt động.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Ghi lại phân phối follower (median, p99, max) trước khi chọn đẩy, kéo hay hybrid.
- Đặt ngưỡng hybrid có hysteresis, và lưu chế độ theo từng tác giả.
- Tách fan-out thành event theo tác giả và batch follower; tác giả lớn có làn riêng.
- Cảnh báo lag fan-out bằng giây theo từng làn, không bằng số message.
- Chỉ fan-out tới follower hoạt động trong 30 ngày; timeline khác dựng lại khi đăng nhập.
- Giới hạn timeline (
ZREMRANGEBYRANK 0 -801) trong cùng pipeline vớiZADD. - Phân trang bằng cursor
(score, post_id); không bao giờ trả rank cho client. - Xử lý xoá, sửa, unfollow và chặn ở bước hydrate, kèm dọn dẹp async.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Hai sai lầm xuất hiện trong gần như mọi bản thiết kế đầu tiên.
Fan-out ngay trong request
Hay gặp
for (const f of await followersOf(authorId)) await redis.zadd(`tl:${f}`, ts, postId);return res.status(201).end();Nên viết
await db.tx(async (t) => { await insertPost(t, post); await outbox(t, 'PostCreated', post); });return res.status(201).end();Vì sao
Bản đầu bắt tác giả chờ 5 triệu round trip và mất phần còn lại khi crash. Outbox commit event cùng với bài viết.
Sai lầm thứ hai trông vô hại khi test vì dữ liệu test không có người nổi tiếng.
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| Timeline lưu nội dung bài | Sửa và xoá phải chạm hàng triệu key | Lưu id, hydrate lúc đọc |
Phân trang theo rank (ZRANGE 20 39) |
Trùng bài khi có bài mới | Cursor (score, post_id) |
| Một queue cho mọi tác giả | Một bài của người nổi tiếng làm trễ tất cả | Làn riêng cộng hybrid |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Với trung bình 200 follower và 4 triệu bài mỗi ngày, lúc đỉnh (3 lần) có bao nhiêu lần ghi timeline mỗi giây?
- Vì sao xoá bài không cần fan-out, nhưng chặn phải kiểm tra lúc đọc?
- Một tài khoản đi từ 9.999 lên 10.000 follower. Bài cũ đã nằm trong timeline của follower ra sao, bài mới ra sao?
- Vì sao
ZRANGE ... (score -inf BYSCORE REV(loại trừ biên) làm mất bài?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- ZADD — Redis Docs: O(log N) mỗi member, ngữ nghĩa cập nhật, độ chính xác score 2^53.
- ZRANGE — Redis Docs:
REV,BYSCORE,LIMIT, thứ tự khi score bằng nhau. - ZREVRANGE — Redis Docs: deprecated từ 6.2.0.
- ZREMRANGEBYRANK — Redis Docs: rank âm để cắt còn N phần tử mới nhất.
- MEMORY USAGE — Redis Docs: vì sao
SAMPLES 0quan trọng với key lớn. - redis.conf của Redis 7.4: ngưỡng listpack cho sorted set.