Bỏ qua để đến nội dung
Knowledge Hub

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

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?

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á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.

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_at tính bằng mili giây. Chỉ lưu id, không lưu nội dung.
Một bài thành 300 lần ghi timeline; một lần đọc feed là một ZRANGE cộng hydrate 20 id
Một bài thành 300 lần ghi timeline; một lần đọc feed là một ZRANGE cộng hydrate 20 idBước 1/6: Tá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.Tác giả A300 followerPost APIQueue fan-outPostCreatedWorker fan-outKho bài viếtpost_id → nội dungđã commitKho follow(followee_id, follower_id)Người đọc BFeed APIRedis timelinetl:{user}, giới hạn 800INSERT bài
Post API → Kho bài viếtINSERT bài
Bước 1/6

Tá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:

  1. Bài chỉ có trong kho bài viết. Chưa follower nào thấy.
  2. Event vào queue sau commit (xem transactional outbox). Độ trễ fan-out bắt đầu tính từ đây.
  3. Worker giữ một trang follower id. Tài khoản lớn cần hàng nghìn trang.
  4. 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.
  5. B nhận id đúng thứ tự trong một lệnh: ZRANGE tl:B 0 19 REV.
  6. 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…

  1. 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ì.
  2. 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}.
  3. 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.

Không fan-out ngay trong request. Tách làm hai tầng:

  1. PostCreated dùng key author_id (giữ thứ tự theo tác giả, xem message key).
  2. 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).

  • 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_id củ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_at trong 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 ZREM cá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.

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 ZADD
1000
> ZREMRANGEBYRANK tl:test 0 -801 # giữ 800 phần tử mới nhất
200
> OBJECT ENCODING tl:test
skiplist
> MEMORY USAGE tl:test SAMPLES 0
84048
> ZADD p 9007199254740993 a # 2^53 + 1
1
> ZSCORE p a
9007199254740992

MEMORY 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.

Ưu điểm

  • Đọc feed là một ZRANGE cộng hydrate 20 id, không phụ thuộc số followee.

  • Batch fan-out idempotent: chạy lại ZADD vớ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

  1. Ghi lại phân phối follower (median, p99, max) trước khi chọn đẩy, kéo hay hybrid.
  2. Đặt ngưỡng hybrid có hysteresis, và lưu chế độ theo từng tác giả.
  3. Tách fan-out thành event theo tác giả và batch follower; tác giả lớn có làn riêng.
  4. Cảnh báo lag fan-out bằng giây theo từng làn, không bằng số message.
  5. Chỉ fan-out tới follower hoạt động trong 30 ngày; timeline khác dựng lại khi đăng nhập.
  6. Giới hạn timeline (ZREMRANGEBYRANK 0 -801) trong cùng pipeline với ZADD.
  7. Phân trang bằng cursor (score, post_id); không bao giờ trả rank cho client.
  8. Xử lý xoá, sửa, unfollow và chặn ở bước hydrate, kèm dọn dẹp async.

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
  1. 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?
  2. Vì sao xoá bài không cần fan-out, nhưng chặn phải kiểm tra lúc đọc?
  3. 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?
  4. Vì sao ZRANGE ... (score -inf BYSCORE REV (loại trừ biên) làm mất bài?