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
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ợ?
- Bảng posts: partition theo tháng, archive tháng cũ sang cold storage.
- Cache timeline: cluster Redis, shard theo user ID; một ZSET mỗi user partition tự nhiên.
- Follow graph: service riêng với database riêng, thường hình graph (Neo4j, recursive CTE Postgres ở quy mô nhỏ).
- Worker fan-out: scale ngang với outbox
SKIP LOCKED; partition theo hash author ID.
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?
- Lag fan-out - post mới mất vài giây xuất hiện ở timeline
follower. Phòng: alert
outbox_age_p99; scale worker. - Lag phát hiện hot author - user thành celebrity qua đêm nhưng vẫn bị fan-out. Phòng: refresh follower count realtime trong consumer; cap kích thước batch fan-out.
- Drift timeline - cache và DB không khớp về feed của user.
Phòng: rebuild đầy đủ định kỳ từ bảng
poststheo yêu cầu hoặc job lập lịch. - Cache lạnh trên user mới - mở feed lần đầu có ZSET rỗng. Phòng: populate lười - khi miss, chạy fan-out-on-read một lần và populate ZSET.
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
- WebSocket, presence, thứ tự message. Hình khác nhưng từ vựng fan-out chuyển trực tiếp sang broadcast.