Thiết kế news feed kiểu Facebook: fan-out on write hay on read

1. Tổng quan

News feed là trang chủ cho bạn xem bài mới nhất từ những người bạn theo dõi. Câu hỏi cốt lõi khi thiết kế nó là: ghép feed lúc ghi (fan-out on write) hay lúc đọc (fan-out on read)?

Bài này đi qua một case study hoàn chỉnh. Ta sẽ nêu yêu cầu và ước lượng quy mô, rồi dựng mô hình dữ liệu và API. Sau đó ta so sánh hai chiến lược fan-out và chọn mô hình lai. Cuối cùng là các điểm khó như celebrity, nhất quán và tải đột biến. Đọc xong, bạn có thể tự dựng một bản feed chạy được bằng Spring Boot, PostgreSQL, Redis và Kafka.

2. Yêu cầu bài toán

2.1. Yêu cầu chức năng

  • Người dùng đăng bài (text, có thể kèm ảnh).
  • Người dùng follow/unfollow người khác.
  • Người dùng xem feed: bài của người mình follow, mới nhất trước, có phân trang (infinite scroll).

2.2. Quy mô và ràng buộc

Ta giả định các con số sau:

DAU (daily active users)        : 300 triệu
Mở feed mỗi người / ngày        : 10 lần  -> 3 tỷ lượt đọc/ngày ≈ 35K QPS, peak ~100K QPS
Bài mới mỗi người / ngày        : 0.5     -> 150 triệu bài/ngày ≈ 1.7K QPS, peak ~5K QPS
Số follower trung bình          : 300
Tài khoản lớn nhất              : > 50 triệu follower

Từ đó rút ra vài ràng buộc:

  • Đọc nhiều hơn ghi rất nhiều (khoảng 20 lần). Đọc feed phải nhanh, p99 dưới 200 ms.
  • Chấp nhận eventual consistency: bài mới xuất hiện trong feed của follower sau vài giây là được.
  • Phân phối follower lệch rất mạnh. Đa số có vài trăm follower, một số ít có hàng chục triệu.

3. Mô hình dữ liệu

Nguồn sự thật (source of truth) nằm trong PostgreSQL:

CREATE TABLE users (
    id              BIGINT PRIMARY KEY,
    name            TEXT   NOT NULL,
    follower_count  BIGINT NOT NULL DEFAULT 0
);

CREATE TABLE posts (
    id          BIGINT PRIMARY KEY,           -- Snowflake ID
    author_id   BIGINT      NOT NULL,
    content     TEXT        NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted     BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX idx_posts_author_time ON posts (author_id, created_at DESC);

CREATE TABLE follows (
    follower_id BIGINT NOT NULL,
    followee_id BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (follower_id, followee_id)
);
CREATE INDEX idx_follows_followee ON follows (followee_id, follower_id);
  • Index idx_posts_author_time phục vụ câu “lấy N bài mới nhất của tác giả X”.
  • Khóa chính của follows trả lời “tôi follow ai”. Index idx_follows_followee trả lời “ai follow tôi”, và fan-out cần chính câu này.

Feed đã ghép sẵn nằm trong Redis. Mỗi người dùng có một sorted set:

Key    : feed:{userId}
Member : postId  (dạng chuỗi)
Score  : createdAt tính bằng epoch millis

Lưu ý: Đừng dùng Snowflake ID làm score. Score của Redis là double, chỉ biểu diễn chính xác số nguyên tới 2^53. Snowflake 64-bit sẽ bị làm tròn, và hai bài khác nhau có thể trùng score. Hãy dùng epoch millis làm score và giữ ID ở member.

Feed chỉ lưu ID, không lưu nội dung bài. Nội dung được “hydrate” từ cache bài viết lúc đọc. Nhờ vậy, khi sửa hay xóa bài, bạn chỉ cần sửa một chỗ.

4. API

@RestController
@RequestMapping("/api")
public class FeedController {

    private final PostService postService;
    private final FeedService feedService;

    public FeedController(PostService postService, FeedService feedService) {
        this.postService = postService;
        this.feedService = feedService;
    }

    @PostMapping("/posts")
    public ResponseEntity<PostDto> create(@RequestHeader("X-User-Id") long userId,
                                          @RequestBody CreatePostRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(postService.create(userId, req.content()));
    }

    @GetMapping("/feed")
    public FeedPage feed(@RequestHeader("X-User-Id") long userId,
                         @RequestParam(required = false) String cursor,
                         @RequestParam(defaultValue = "20") int limit) {
        return feedService.getFeed(userId, Cursor.parse(cursor), Math.min(limit, 50));
    }
}

public record CreatePostRequest(String content) {}
public record FeedPage(List<PostDto> items, String nextCursor) {}

Ví dụ một phản hồi:

GET /api/feed?limit=2
{
  "items": [ { "id": 7253..., "authorId": 42, "content": "Hello" }, { ... } ],
  "nextCursor": "1760245800123:7253..."
}

Ta dùng cursor-based pagination (createdAtMillis:postId), không dùng offset. Feed thay đổi liên tục. Với offset, khi có bài mới chen lên đầu, trang sau sẽ lặp lại bài đã xem.

5. Fan-out on write (push)

Ý tưởng: khi tác giả đăng bài, hệ thống ghi ngay ID bài vào feed của từng follower. Lúc đọc, chỉ cần một lệnh ZREVRANGEBYSCORE.

Thêm dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

PostService lưu bài vào DB rồi phát event PostCreated lên Kafka. Một consumer riêng làm fan-out:

public record PostCreatedEvent(long postId, long authorId, long createdAtMillis, long followerCount) {}

@Component
public class FanoutConsumer {

    static final int FEED_MAX = 500;
    static final long CELEBRITY_THRESHOLD = 10_000;
    private static final int BATCH = 1_000;

    private final FollowRepository follows;
    private final StringRedisTemplate redis;

    public FanoutConsumer(FollowRepository follows, StringRedisTemplate redis) {
        this.follows = follows;
        this.redis = redis;
    }

    @KafkaListener(topics = "post-created", groupId = "feed-fanout")
    public void onPostCreated(PostCreatedEvent e) {
        if (e.followerCount() >= CELEBRITY_THRESHOLD) {
            return; // celebrity: để đường đọc tự kéo (xem mục 7)
        }
        long lastId = 0;
        List<Long> batch;
        do {
            batch = follows.findActiveFollowerIds(e.authorId(), lastId, BATCH);
            pushToFeeds(batch, e);
            if (!batch.isEmpty()) lastId = batch.getLast();
        } while (batch.size() == BATCH);
    }

    private void pushToFeeds(List<Long> followerIds, PostCreatedEvent e) {
        redis.executePipelined((RedisCallback<Object>) conn -> {
            StringRedisConnection c = (StringRedisConnection) conn;
            for (Long followerId : followerIds) {
                String key = "feed:" + followerId;
                c.zAdd(key, e.createdAtMillis(), String.valueOf(e.postId()));
                c.zRemRange(key, 0, -(FEED_MAX + 1)); // chỉ giữ 500 bài mới nhất
            }
            return null;
        });
    }
}

Giải thích:

  • findActiveFollowerIds duyệt follower theo keyset pagination (follower_id > lastId), mỗi lần 1.000 người. Nó chỉ lấy người hoạt động trong 30 ngày gần đây.
  • executePipelined gom 2.000 lệnh vào một round-trip, thay vì 2.000 round-trip riêng lẻ.
  • zRemRange(key, 0, -501) cắt đuôi, giữ feed tối đa 500 phần tử. Bộ nhớ nhờ vậy không phình theo thời gian.
  • ZADD với cùng member và score là idempotent. Nếu Kafka giao lại event (at-least-once), feed không bị trùng.

Câu SQL tương ứng:

SELECT f.follower_id
FROM follows f
JOIN user_activity a ON a.user_id = f.follower_id
WHERE f.followee_id = :authorId
  AND f.follower_id > :lastId
  AND a.last_seen_at > now() - interval '30 days'
ORDER BY f.follower_id
LIMIT :batch;

Ưu điểm: đọc cực nhanh, O(log N) trên một key. Nhược điểm: ghi bị khuếch đại. 150 triệu bài × 300 follower ≈ 45 tỷ ZADD/ngày. Một celebrity 50 triệu follower đăng một bài sẽ sinh ra 50 triệu lệnh ghi.

6. Fan-out on read (pull)

Ý tưởng ngược lại: không ghép trước gì cả. Khi đọc, hệ thống lấy danh sách người mình follow rồi truy vấn bài mới nhất của họ:

SELECT p.id, p.created_at
FROM posts p
WHERE p.author_id IN (SELECT followee_id FROM follows WHERE follower_id = :userId)
  AND p.created_at < :before
  AND p.deleted = false
ORDER BY p.created_at DESC
LIMIT 20;

Ưu điểm: ghi rẻ (chỉ một INSERT), bài mới hiển thị ngay, không tốn RAM cho feed. Nhược điểm: mỗi lượt đọc phải chạm tới hàng trăm tác giả, và việc này xảy ra ở 100K QPS. Khi posts được shard theo author_id, một lượt đọc phải scatter-gather qua nhiều shard. p99 sẽ rất khó giữ dưới 200 ms.

Tóm tắt:

                    Fan-out on write          Fan-out on read
Chi phí ghi         Cao (× số follower)       Thấp
Chi phí đọc         Thấp (1 key Redis)        Cao (× số followee)
Độ trễ hiển thị     Vài giây (async)          Tức thì
Celebrity           Thảm họa                  Ổn
Người follow nhiều  Ổn                        Chậm

7. Mô hình lai (hybrid)

Facebook, Twitter và hầu hết hệ thống lớn đều đi theo hướng lai:

  • Tác giả thường (< 10K follower): push vào feed của follower.
  • Celebrity (≥ 10K follower): không push. Lúc đọc, hệ thống pull bài của các celebrity mà người dùng follow, rồi trộn với feed đã push.

Mỗi người dùng chỉ follow vài chục celebrity, nên phần pull nhỏ. Bài gần đây của celebrity lại được hàng triệu người đọc chung, nên cache rất hiệu quả.

Phần cốt lõi là logic trộn. Ta tách nó thành lớp thuần Java để dễ test:

public record FeedEntry(long postId, long createdAtMillis) {}

public record Cursor(long createdAtMillis, long postId) {
    static final Cursor START = new Cursor(Long.MAX_VALUE, Long.MAX_VALUE);

    public static Cursor parse(String raw) {
        if (raw == null || raw.isBlank()) return START;
        String[] p = raw.split(":");
        return new Cursor(Long.parseLong(p[0]), Long.parseLong(p[1]));
    }

    boolean isAfter(FeedEntry e) { // e có cũ hơn cursor không?
        return e.createdAtMillis() < createdAtMillis
            || (e.createdAtMillis() == createdAtMillis && e.postId() < postId);
    }
}

public final class FeedMerger {

    private static final Comparator<FeedEntry> NEWEST_FIRST =
        Comparator.comparingLong(FeedEntry::createdAtMillis)
                  .thenComparingLong(FeedEntry::postId)
                  .reversed();

    public static List<FeedEntry> merge(List<FeedEntry> pushed, List<FeedEntry> pulled,
                                        Cursor cursor, int limit) {
        return Stream.concat(pushed.stream(), pulled.stream())
            .filter(cursor::isAfter)
            .collect(Collectors.toMap(FeedEntry::postId, e -> e, (a, b) -> a))
            .values().stream()
            .sorted(NEWEST_FIRST)
            .limit(limit)
            .toList();
    }
}
  • filter(cursor::isAfter) chỉ giữ bài cũ hơn cursor. Khi hai bài cùng millisecond, postId được dùng để phân định thứ tự.
  • toMap(..., (a, b) -> a) khử trùng. Một bài có thể có mặt ở cả hai nguồn, chẳng hạn khi tác giả vừa vượt ngưỡng celebrity.
  • Sau đó danh sách được sắp xếp mới nhất trước và cắt theo limit.

FeedService ghép các mảnh lại:

public FeedPage getFeed(long userId, Cursor cursor, int limit) {
    double max = cursor == Cursor.START ? Double.POSITIVE_INFINITY : cursor.createdAtMillis();

    List<FeedEntry> pushed = redis.opsForZSet()
        .reverseRangeByScoreWithScores("feed:" + userId, 0, max, 0, limit + 1)
        .stream()
        .map(t -> new FeedEntry(Long.parseLong(t.getValue()), t.getScore().longValue()))
        .toList();

    List<Long> celebs = followRepository.findCelebrityFolloweeIds(userId);
    List<FeedEntry> pulled = celebrityTimelineCache.recentPosts(celebs, cursor, limit);

    List<FeedEntry> page = FeedMerger.merge(pushed, pulled, cursor, limit);
    List<PostDto> items = postCache.hydrate(page); // multi-get, bỏ bài đã xóa
    String next = page.isEmpty() ? null
        : page.getLast().createdAtMillis() + ":" + page.getLast().postId();
    return new FeedPage(items, next);
}

Truy vấn Redis dùng max bao gồm biên, vì có thể còn bài trùng millisecond với cursor. FeedMerger sẽ lọc chính xác lần nữa.

8. Kiểm thử logic trộn

class FeedMergerTest {

    @Test
    void mergesSortsDedupesAndRespectsCursor() {
        List<FeedEntry> pushed = List.of(
            new FeedEntry(5, 5_000), new FeedEntry(3, 3_000), new FeedEntry(1, 1_000));
        List<FeedEntry> pulled = List.of(
            new FeedEntry(4, 4_000), new FeedEntry(3, 3_000), new FeedEntry(2, 2_000));

        List<FeedEntry> firstPage = FeedMerger.merge(pushed, pulled, Cursor.START, 3);

        assertThat(firstPage).extracting(FeedEntry::postId).containsExactly(5L, 4L, 3L);

        Cursor next = new Cursor(3_000, 3);
        List<FeedEntry> secondPage = FeedMerger.merge(pushed, pulled, next, 3);

        assertThat(secondPage).extracting(FeedEntry::postId).containsExactly(2L, 1L);
    }
}

Bài 3 có ở cả hai nguồn nhưng chỉ xuất hiện một lần. Trang thứ hai bắt đầu đúng sau cursor, không lặp, không sót.

9. Các điểm khó và cách giải

9.1. Hot key của celebrity

Mọi người cùng đọc celeb:{id}:recent sẽ dồn tải vào một node Redis. Có ba cách xử lý:

  • Thêm local cache (Caffeine) ở từng instance với TTL 2–5 giây. 100K QPS khi đó chỉ còn vài trăm request/giây xuống Redis.
  • Bật Redis read replica cho các key này.
  • Khi celebrity đăng bài, ghi chủ động vào timeline cache, không đợi cache miss. Cách này tránh được thundering herd.

9.2. Nhất quán khi xóa bài, unfollow, follow mới

  • Xóa bài: không cần quét hàng triệu feed. Khi hydrate, postCache bỏ qua bài có deleted = true. Một job nền sẽ dọn ID dần sau đó.
  • Unfollow: lọc khi đọc bằng tập followee của người dùng, đồng thời phát event để xóa bất đồng bộ các bài của người đó khỏi feed:{userId}.
  • Follow mới: chạy backfill, kéo khoảng 20 bài gần nhất của người mới follow rồi ZADD vào feed.

Vì feed chỉ chứa ID, mọi sai lệch đều nằm ở “danh sách tham chiếu”. Nội dung luôn đúng theo nguồn sự thật.

9.3. Mất event giữa DB và Kafka

Nếu bạn INSERT bài rồi gọi kafkaTemplate.send(), việc send có thể thất bại sau khi transaction đã commit. Bài khi đó tồn tại nhưng không bao giờ được fan-out.

Lưu ý: Dùng transactional outbox. Ghi bài và một dòng vào bảng outbox trong cùng transaction. Một relay (hoặc CDC như Debezium) đọc outbox rồi đẩy lên Kafka. Phía consumer phải idempotent, và ZADD đã đáp ứng điều này sẵn.

9.4. Tải đột biến

Sự kiện lớn như bóng đá hay giao thừa có thể khiến lượng bài tăng gấp 10 lần. Các biện pháp:

  • Kafka làm bộ đệm. Consumer xử lý theo năng lực, lag tăng thì feed trễ thêm vài giây. Điều này chấp nhận được theo ràng buộc ở mục 2.
  • Partition theo authorId để giữ thứ tự bài của cùng tác giả. Scale consumer bằng cách tăng số partition.
  • Bỏ qua người không hoạt động. Họ không được push. Khi quay lại và gặp cache miss, feed được rebuild bằng fan-out on read rồi ghi vào Redis.
  • Hạ ngưỡng celebrity động. Khi lag vượt ngưỡng, giảm CELEBRITY_THRESHOLD để chuyển thêm tác giả sang chế độ pull.

9.5. Ước lượng bộ nhớ Redis

~200 triệu user hoạt động × 500 entry × ~60 byte ≈ 6 TB

Con số này đòi hỏi Redis Cluster. Bạn có thể giảm FEED_MAX xuống 200–300, vì rất ít người cuộn quá vài trăm bài. Phần cuộn sâu hơn thì fallback sang pull.

10. Khi nào không nên dùng mô hình này

  • Ứng dụng nhỏ (vài chục nghìn user): fan-out on read với một câu SQL có index là đủ. Thêm Kafka và Redis chỉ tăng độ phức tạp.
  • Feed xếp theo thuật toán (ranking bằng ML): feed push chỉ đóng vai trò “tập ứng viên”. Bước ranking chạy riêng lúc đọc, và khi đó score theo thời gian không còn là thứ tự cuối cùng.
  • Yêu cầu nhất quán mạnh (bài phải hiện ngay cho mọi follower): fan-out bất đồng bộ không đảm bảo được điều này. Bạn phải pull, hoặc chèn bài của chính tác giả vào phản hồi ngay sau khi đăng (read-your-writes).

11. Kết luận

Fan-out on write tối ưu cho đọc nhưng khuếch đại ghi. Fan-out on read làm điều ngược lại. Với phân phối follower lệch như mạng xã hội, câu trả lời thực tế là mô hình lai: push cho tác giả thường, pull cho celebrity, rồi trộn lúc đọc.

Những điểm bạn cần nhớ khi triển khai:

  • Feed chỉ lưu ID, score là epoch millis chứ không phải Snowflake ID.
  • Dùng cursor (createdAt, postId) để phân trang ổn định.
  • Consumer phải idempotent, kết hợp outbox để không mất event.
  • Xử lý xóa bài và unfollow bằng cách lọc khi đọc.
  • Dùng Kafka cùng ngưỡng celebrity động để hấp thụ tải đột biến.
system-designrediskafkaspring-bootcase-study