Case study Nâng cao 5 phút đọc

Thiết kế News Feed (timeline Twitter) trong .NET

Fan-out on write vs fan-out on read, bài toán celebrity, và cách xây service timeline kiểu Twitter trong .NET với Postgres + Redis + Kafka.

Mục lục
  1. Khi nào bài toán news feed xuất hiện?
  2. Số ước lượng nhanh nào định hình thiết kế?
  3. Kiến trúc trông thế nào?
  4. Cấu hình .NET 10 cho fan-out-on-write?
  5. Đường scale-out hỗ trợ?
  6. Failure mode tạo ra?
  7. Khi nào mô hình đơn giản đủ?
  8. Đi tiếp đâu từ đây?

News feed là bài thiết kế hệ kinh điển khó nhất vì ép trade-off giữa chi phí write và chi phí read ra rõ. Chương này thiết kế timeline kiểu Twitter trong .NET, với mô hình fan-out hybrid mà hệ production hội tụ về, và cấu hình .NET 10 làm nó cụ thể.

Khi nào bài toán news feed xuất hiện?

Ba bối cảnh. Clone Twitter / Mastodon, nơi timeline là sản phẩm. Activity feed trong app - "người bạn theo đăng X", "cập nhật gần đây của team". Notification feed - cùng hình fan-out, render khác.

Cả ba đều hỏi "cho graph quan hệ follow, làm sao show user X N post gần nhất từ người họ theo, nhanh?". Câu trả lời là một trong ba mô hình, cộng đường thoát celebrity.

Số ước lượng nhanh nào định hình thiết kế?

DAU                  100M
Avg follow           200 mỗi user
Post / ngày          50M (50% DAU đăng một lần)
Read timeline / ngày 5B (50 lần mở timeline mỗi DAU)
Đỉnh read timeline   5B / 100K * 5 = 250K req/s
Avg size timeline    1000 ID * 8 byte = 8 KB
Tổng memory timeline 100M * 8 KB = 800 GB cluster

800 GB tổng memory khả thi cho cluster Redis shard. 250K req/s read phải phục vụ từ cache - DB không theo kịp. Write 50M/ngày = ~600 write/s trung bình, đỉnh 3K; nhẹ với Postgres. Cổ chai là khuếch đại fan-out.

Kiến trúc trông thế nào?

Hybrid fan-out:

flowchart LR
    User[User đăng] -->|write| App[ASP.NET Core]
    App --> PG[(Postgres<br/>posts)]
    App --> OB[(Outbox)]
    OB --> Worker[Worker fan-out]
    Worker --> FollowSvc[(Follow graph)]
    Worker -->|fan-out nhỏ| Redis[(Redis ZSET<br/>mỗi timeline)]
    Worker -.bỏ celebrity.-> Redis
    Reader[Reader fetch] --> App2[ASP.NET Core]
    App2 -->|merge ZSET cá nhân| Redis
    App2 -->|merge post celebrity| PG

Hai write cho post user thường: row post cộng entry outbox. Worker đọc outbox, lookup follower, push vào ZSET mỗi follower (bỏ author celebrity; post họ merge lúc read). Khi read, fetch ZSET cá nhân, merge với post celebrity gần đây user theo.

Cấu hình .NET 10 cho fan-out-on-write?

// Handler post
public async Task<Guid> PostAsync(string text, CancellationToken ct)
{
    var post = new Post { Id = Guid.NewGuid(), AuthorId = userId, Text = text, CreatedAt = DateTime.UtcNow };
    db.Posts.Add(post);
    db.OutboxMessages.Add(new OutboxMessage
    {
        MessageType = nameof(PostCreated),
        Payload = JsonSerializer.Serialize(new PostCreated(post.Id, post.AuthorId, post.CreatedAt))
    });
    await db.SaveChangesAsync(ct);
    return post.Id;
}

// Consumer fan-out
public class PostCreatedConsumer(IConnectionMultiplexer redis, IFollowService follows, IUserService users)
    : IConsumer<PostCreated>
{
    private const int CelebrityThreshold = 100_000;

    public async Task Consume(ConsumeContext<PostCreated> ctx)
    {
        var msg = ctx.Message;
        var author = await users.GetAsync(msg.AuthorId);
        if (author.FollowerCount > CelebrityThreshold) return;  // celebrity, không push

        var followers = await follows.GetFollowersAsync(msg.AuthorId);
        var db = redis.GetDatabase();
        var batch = db.CreateBatch();
        foreach (var f in followers)
        {
            batch.SortedSetAddAsync($"tl:{f}", msg.PostId.ToString(), msg.CreatedAt.Ticks);
            batch.SortedSetRemoveRangeByRankAsync($"tl:{f}", 0, -1001);  // giữ top 1000
        }
        batch.Execute();
    }
}

// Endpoint read - merge feed cá nhân với post celebrity
public async Task<List<Post>> GetTimelineAsync(Guid userId, int take = 50, CancellationToken ct = default)
{
    var personalIds = (await redis.GetDatabase().SortedSetRangeByRankAsync(
        $"tl:{userId}", 0, take - 1, Order.Descending))
        .Select(v => Guid.Parse(v!)).ToArray();

    var celebrityFollows = await follows.GetCelebrityFolloweesAsync(userId);
    var celebrityPosts = await db.Posts
        .Where(p => celebrityFollows.Contains(p.AuthorId) && p.CreatedAt > DateTime.UtcNow.AddHours(-24))
        .OrderByDescending(p => p.CreatedAt)
        .Take(take)
        .ToListAsync(ct);

    var personal = await db.Posts.Where(p => personalIds.Contains(p.Id)).ToListAsync(ct);
    return personal.Concat(celebrityPosts)
                   .OrderByDescending(p => p.CreatedAt)
                   .Take(take).ToList();
}

Ba chi tiết. Outbox đảm bảo fan-out chạy ngay cả khi worker crash giữa write. Batch pipelined Redis làm fan-out đến hàng nghìn follower vừa vài round-trip mạng. Merge celebrity lúc read giữ hệ khỏi sập trước user viral.

Đường scale-out hỗ trợ?

Kiến trúc lo 100M DAU thoải mái; đỉnh thật của Twitter cùng bậc cường độ.

Failure mode tạo ra?

Khi nào mô hình đơn giản đủ?

Ba ca.

Chỉ pull (fan-out-on-read) ổn đến ~1M user với tốc độ post thấp - query read "post từ followee 7 ngày qua" là một SQL có index với JOIN follow.

Chỉ push (fan-out-on-write) ổn đến ~10K follower-per-user - khuếch đại write có giới hạn.

Hybrid là câu trả lời khi không mô hình đơn giản nào giữ. Với quy mô Twitter, chỉ hybrid chạy.

Đi tiếp đâu từ đây?

Case study kế tiếp: chat realtime với SignalR

Câu hỏi thường gặp

Push hay pull - mô hình fan-out nào?
Hybrid. Fan-out-on-write (push) cho read timeline O(1) đổi bằng write tỉ lệ với follower. Cho user 1000 follower, post ghi vào 1000 cache timeline. Fan-out-on-read (pull) đọc K query lúc fetch. Push cho user thường (write chịu nổi), pull cho celebrity (user triệu follower không fan out mỗi post).
Bài toán 'celebrity' là gì?
User 100M follower không fan out vào 100M timeline realtime - bão write làm sập hệ. Cách giải: phát hiện celebrity theo follower count, loại khỏi fan-out-on-write, rồi merge post họ lúc read khi follower fetch feed. Twitter, Instagram, Mastodon đều dùng hybrid này.
Timeline lưu ở đâu?
Redis sorted set - một ZSET mỗi user với score = timestamp post, member = post ID. Trim còn 1000 entry cuối. Read O(log n), pagination một call ZRANGE. Bản thân post body lưu Postgres hoặc Cassandra; timeline chỉ là ID. Chương cache giải thích trade-off Redis.
Sync cache với database ra sao?
Outbox pattern chương 10. Tạo post ghi Postgres + outbox trong một transaction; worker đọc outbox và fan out vào ZSET timeline follower. Worker crash thì outbox đảm bảo retry. Eventually consistent trong vài giây - có thể chấp nhận cho feed.