News feed, timeline, fan-out, phân trangNews feeds, timelines, fan-out, pagination
Đường ghi và đường đọc tách riêng. Post Service lưu bài, CDC đẩy sự kiện vào Kafka, fan-out worker chèn id bài vào feed của từng follower trong Redis. Feed Service đọc feed đã tính sẵn, trộn thêm bài của người nổi tiếng, xếp hạng rồi lấy nội dung bài. Bật “Người nổi tiếng đăng bài” và “Cuộn bằng cursor” để xem hai luồng còn lại.The write path and the read path are separate. The Post Service stores the post, CDC pushes an event to Kafka, and fan-out workers insert the post id into each follower's feed in Redis. The Feed Service reads the precomputed feed, merges in celebrity posts, ranks, then loads post bodies. Turn on “Celebrity posts” and “Cursor scroll” to see the other flows.
1User u-42, có 300 follower, đăng một bài ảnh.User u-42, who has 300 followers, posts a photo.
Đẩy lúc ghi cho 2 triệu follower là 2 triệu ZADD và vài phút trễ. Mô hình lai: dưới ngưỡng (ví dụ 100 000 follower) thì đẩy; trên ngưỡng thì chỉ lưu vào timeline của tác giả và trộn lúc đọc. Người đọc theo dõi thường chỉ vài chục người nổi tiếng, nên trộn rẻ.Pushing to 2 million followers is 2 million ZADDs and minutes of delay. Use a hybrid: below a threshold (say 100,000 followers) push; above it store only in the author's timeline and merge on read. A reader usually follows only a few dozen celebrities, so the merge is cheap.
Post Store và Follow Graph là nguồn; feed trong Redis là bản chiếu dựng từ sự kiện, mất thì dựng lại được. Ghi qua Post Service, đọc qua Feed Service, hai đường mở rộng độc lập.The Post Store and Follow Graph are the source; the Redis feed is a projection built from events and can be rebuilt if lost. Writes go through the Post Service and reads through the Feed Service, and each scales on its own.
Bài mới chen vào đầu làm mọi vị trí dịch xuống. Dùng cursor (score, id): đọc score ≤ cursor, cùng score thì id nhỏ hơn. Với feed xếp hạng, chụp lại danh sách id đã xếp cho phiên (feed_session:{sid}, TTL 30 phút) rồi phân trang trên bản chụp.New posts at the top shift every position down. Use a (score, id) cursor: read score ≤ cursor and, on an equal score, smaller ids. For a ranked feed, snapshot the ranked id list per session (feed_session:{sid}, TTL 30 min) and page over the snapshot.
Feed chỉ chứa id và score, khoảng 50 byte mỗi phần tử; giới hạn 800 phần tử là khoảng 40 KB mỗi người. 50 triệu người hoạt động là khoảng 2 TB, nên bỏ feed của người không hoạt động 30 ngày và dựng lại khi họ quay lại. Nội dung bài lưu một lần, mọi feed trỏ tới cùng một bản.A feed holds only ids and scores, about 50 bytes an entry; an 800-entry cap is about 40 KB per user. 50 million active users is about 2 TB, so drop feeds of users inactive for 30 days and rebuild them on return. Post bodies are stored once and every feed points at the same copy.
Không xoá ngay khỏi hàng triệu feed. Đánh dấu tombstone trong Post Store, lọc khi lấy nội dung bài; job nền ZREM dần khỏi feed. Bài bị ẩn hoặc tác giả bị chặn được lọc theo danh sách riêng của từng người đọc.Never delete from millions of feeds synchronously. Mark a tombstone in the Post Store and filter at hydration time; a background job ZREMs it from feeds over time. Hidden posts and blocked authors are filtered per reader from their own lists.
Theo dõi mới: lấy 20 bài gần nhất của người đó từ Post Store và ZADD vào feed (backfill). Bỏ theo dõi: không quét feed ngay, lọc theo danh sách following khi đọc và để các phần tử cũ tự bị đẩy ra theo giới hạn 800.New follow: take the followee's 20 latest posts from the Post Store and ZADD them into the feed (backfill). Unfollow: do not scan the feed at once; filter by the following list on read and let old entries fall off with the 800 cap.
Lấy khoảng 3 lần số bài cần hiển thị làm ứng viên, chấm điểm theo độ mới, mức thân thiết với tác giả, tương tác dự đoán; cache điểm 5 phút. Ranking chậm hoặc lỗi thì trả theo thời gian, không làm hỏng trang.Take about three times the page size as candidates and score them on recency, affinity with the author and predicted engagement, caching scores for 5 minutes. If ranking is slow or fails, fall back to time order rather than breaking the page.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
posts_by_author (Cassandra) | author_id, post_id (Snowflake, clustering DESC), body, media, visibility, deleted | Partition theo tác giả; đọc 20 bài mới nhất của một người là một lần đọc partitionPartitioned by author; the 20 latest posts of one person are a single partition read |
feed:{uid} (Redis ZSET) | member = post_id, score = created_ms | Giới hạn 800 bằng ZREMRANGEBYRANK, bỏ sau 30 ngày không hoạt độngCapped at 800 with ZREMRANGEBYRANK, dropped after 30 inactive days |
posts:{celebrity_id} (Redis ZSET) | member = post_id, score = created_ms | Timeline của người nổi tiếng, 200 bài; đọc rất nhiều nên có replica và cache cục bộ vài giâyA celebrity's timeline of 200 posts; read heavily, so it has replicas and a local cache of a few seconds |
follow | follower_id, followee_id, created_at · follower_count trên user | Hai chỉ mục: theo follower (đang theo dõi ai) và theo followee (để fan-out)Two indexes: by follower (who I follow) and by followee (for fan-out) |
Worker chọn đẩy hay không theo số follower. Đường đọc lấy feed đã tính sẵn cộng timeline của người nổi tiếng, trộn theo (score, id) và trả cursor của bài cuối trang.The worker decides whether to push by follower count. The read path takes the precomputed feed plus celebrity timelines, merges by (score, id) and returns the cursor of the last post on the page.
@Service
@RequiredArgsConstructor
public class FeedService {
private static final long CELEBRITY = 100_000;
private static final int CAP = 800;
private final StringRedisTemplate redis;
private final FollowGraph graph;
@KafkaListener(topics = "post.created")
public void fanOut(PostCreated e) {
if (graph.followerCount(e.authorId()) >= CELEBRITY) {
redis.opsForZSet().add("posts:" + e.authorId(), e.postId(), e.ts()); // merged on read
return;
}
for (List<Long> batch : graph.activeFollowers(e.authorId(), 1_000)) {
redis.executePipelined((RedisCallback<Object>) c -> {
for (Long f : batch) {
byte[] key = ("feed:" + f).getBytes();
c.zSetCommands().zAdd(key, e.ts(), e.postId().getBytes());
c.zSetCommands().zRemRange(key, 0, -CAP - 1); // keep newest 800
}
return null;
});
}
}
public FeedPage page(long userId, Cursor cur, int limit) {
List<Entry> merged = new ArrayList<>(read("feed:" + userId, cur, limit));
for (long celeb : graph.followedCelebrities(userId)) // usually < 20
merged.addAll(read("posts:" + celeb, cur, limit));
List<Entry> page = merged.stream()
.sorted(Entry.NEWEST_FIRST) // score desc, id desc
.distinct().limit(limit).toList();
return new FeedPage(page, page.isEmpty() ? null : Cursor.of(page.get(page.size() - 1)));
}
private List<Entry> read(String key, Cursor cur, int limit) {
double max = cur == null ? Double.POSITIVE_INFINITY : cur.score();
return redis.opsForZSet()
.reverseRangeByScoreWithScores(key, Double.NEGATIVE_INFINITY, max, 0, limit + 20)
.stream().map(Entry::of)
.filter(en -> cur == null || en.isBefore(cur)) // same score: smaller id only
.limit(limit).toList();
}
}type Cursor struct {
Score float64
ID string
}
func (s *FeedService) Page(ctx context.Context, uid int64, cur *Cursor, limit int) ([]Entry, *Cursor, error) {
celebs, err := s.graph.FollowedCelebrities(ctx, uid)
if err != nil {
return nil, nil, err
}
keys := []string{fmt.Sprintf("feed:%d", uid)}
for _, c := range celebs {
keys = append(keys, fmt.Sprintf("posts:%d", c))
}
parts := make([][]Entry, len(keys))
g, gctx := errgroup.WithContext(ctx)
for i, k := range keys {
g.Go(func() error { // fan-out: one read per source
var err error
parts[i], err = s.readAfter(gctx, k, cur, limit)
return err
})
}
if err := g.Wait(); err != nil {
return nil, nil, err
}
page := mergeNewest(parts, limit) // fan-in: k-way merge by (score, id), dedup
if len(page) == 0 {
return page, nil, nil
}
last := page[len(page)-1]
return page, &Cursor{Score: last.Score, ID: last.ID}, nil
}
func (s *FeedService) readAfter(ctx context.Context, key string, cur *Cursor, limit int) ([]Entry, error) {
max := "+inf"
if cur != nil {
max = strconv.FormatFloat(cur.Score, 'f', -1, 64)
}
zs, err := s.rdb.ZRevRangeByScoreWithScores(ctx, key, &redis.ZRangeBy{
Max: max, Min: "-inf", Count: int64(limit + 20),
}).Result()
if err != nil {
return nil, err
}
out := make([]Entry, 0, limit)
for _, z := range zs {
e := Entry{ID: z.Member.(string), Score: z.Score}
if cur == nil || e.Before(*cur) { // same score: smaller id only
out = append(out, e)
}
}
return out, nil
}| Fan-out on write (push)Fan-out on write (push) | Fan-out on read (pull)Fan-out on read (pull) | |
|---|---|---|
| Chi phí đọcRead cost | Một ZREVRANGE, khoảng 1 msOne ZREVRANGE, about 1 ms | Đọc và trộn timeline của mọi người đang theo dõiRead and merge the timeline of everyone followed |
| Chi phí ghiWrite cost | Tỉ lệ với số follower, bùng nổ với người nổi tiếngProportional to followers; explodes for celebrities | Một lần ghi vào timeline tác giảOne write to the author's timeline |
| Bộ nhớMemory | Một feed cho mỗi người đọc, kể cả người ít mở appOne feed per reader, even for those who rarely open the app | Chỉ timeline tác giả, không có feed tính sẵnOnly author timelines, no precomputed feeds |
| Chọn khiPick it when | Tác giả có ít follower, người đọc mở app thường xuyênAuthors have few followers and readers open the app often | Tác giả có hàng triệu follower; kết hợp với push thành mô hình laiAuthors have millions of followers; combine with push as a hybrid |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Cân giữa thời gian fan-out và chi phí trộn lúc đọc. Với 1 000 ZADD mỗi lô mất vài mili giây, 100 000 follower là vài trăm mili giây, chấp nhận được. Trên ngưỡng đó thì pull. Ngưỡng là cấu hình, đo bằng độ trễ fan-out p99 và số timeline trung bình phải trộn.Balance fan-out time against read-time merge cost. At a few milliseconds per 1,000-ZADD batch, 100,000 followers take a few hundred milliseconds, which is acceptable. Above that, pull. The threshold is configuration, tuned on p99 fan-out delay and the average number of timelines to merge.
Dựng lại theo kiểu pull: lấy danh sách following, đọc vài bài mới nhất của mỗi người từ Post Store, trộn và ghi vào feed:{uid}. Trang đầu có thể chậm hơn (200–500 ms); các bài sau đó lại được đẩy như bình thường.Rebuild by pulling: get the following list, read a few latest posts per followee from the Post Store, merge them and write feed:{uid}. The first page may be slower (200–500 ms); later posts are pushed as usual.
Một bài nằm trong hàng nghìn feed; lưu nội dung sẽ nhân bộ nhớ lên hàng nghìn lần và sửa hoặc xoá bài phải cập nhật hàng nghìn bản. Lưu id thì nội dung có một bản, cache theo post_id, lấy bằng multi-get khi hiển thị.One post sits in thousands of feeds; storing bodies would multiply memory by thousands and make every edit or delete touch thousands of copies. With ids, the body exists once, is cached by post_id and is loaded with a multi-get at display time.
Điểm xếp hạng thay đổi giữa các lần gọi nên cursor (score, id) không còn ổn định. Xếp hạng một lần cho khoảng 200 ứng viên, lưu danh sách id vào feed_session:{sid} với TTL 30 phút; cursor là vị trí trong bản chụp đó. Hết bản chụp thì xếp hạng tiếp phần cũ hơn.Ranking scores change between calls, so a (score, id) cursor is no longer stable. Rank about 200 candidates once and store the id list in feed_session:{sid} with a 30-minute TTL; the cursor is a position in that snapshot. When it runs out, rank the next older slice.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.