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

Thiết kế chat realtime với SignalR trong .NET

Cách xây service chat realtime trong .NET với SignalR: fan-out WebSocket, Redis backplane để scale-out, tracking presence, và thứ tự message theo phòng.

Mục lục
  1. Khi nào case study chat 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 với SignalR + Redis backplane?
  5. Đảm bảo thứ tự message ra sao?
  6. Đường scale-out hỗ trợ?
  7. Tạo failure mode nào?
  8. Khi nào SignalR là hình sai?
  9. Đi tiếp đâu từ đây?

Hệ chat là case study mọi block trong series xuất hiện: WebSocket cho realtime, fan-out xuyên replica, lưu trữ bền cho lịch sử, tracking presence, và thứ tự message. Chương này thiết kế một trong .NET với SignalR, rồi dây hình production scale tới 100K user đồng thời.

Khi nào case study chat xuất hiện?

Ba bối cảnh. Chat hỗ trợ khách trong sản phẩm SaaS. Nhắn tin team (clone Slack, Teams). Lobby game và cộng tác realtime. Ý tưởng kiến trúc giống nhau; volume và yêu cầu bền khác.

Phỏng vấn thường khung là "thiết kế WhatsApp ở scale". Production thường là "thêm chat vào app hiện có mà không làm sập database".

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

User đồng thời       100K
Avg msg/user/giờ     20
Đỉnh msg/giây        100K * 20 / 3600 * 5 = 2,800
Avg size message     500 byte (text + meta)
Storage / ngày       100K * 100 msg * 500 B = 5 GB/ngày
Connection           100K WebSocket
Memory / connection  ~30 KB SignalR + Kestrel = 3 GB tổng

100K WebSocket thoải mái trên 4 instance ASP.NET Core sau LB sticky session. Postgres lo 2,800 write/giây không tune. Việc thú vị là fan-out xuyên replica, đó là việc của Redis backplane.

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

flowchart LR
    Client1[Browser/Mobile] -.WebSocket.-> LB[Sticky LB]
    Client2[Browser/Mobile] -.WebSocket.-> LB
    LB --> H1[SignalR Hub 1]
    LB --> H2[SignalR Hub 2]
    LB --> H3[SignalR Hub 3]
    H1 -.pub/sub.-> Redis[(Redis Backplane)]
    H2 -.pub/sub.-> Redis
    H3 -.pub/sub.-> Redis
    H1 --> PG[(Postgres<br/>messages)]
    H1 --> Pres[(Redis<br/>presence)]
    H1 -. publish event .-> Q[(Queue<br/>notifications)]

LB sticky giữ connection trên một hub instance. Message gửi tới phòng được persist Postgres, broadcast qua backplane Redis tới mọi hub, và forward tới connection local trên mỗi hub. Key presence sống trong Redis với TTL. Event notification qua queue sang email/push.

Cấu hình .NET 10 với SignalR + Redis backplane?

// Program.cs
builder.Services.AddSignalR()
    .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!,
        opt => opt.Configuration.ChannelPrefix = RedisChannel.Literal("chat"));

app.MapHub<ChatHub>("/hubs/chat");

// Hub
public class ChatHub(AppDbContext db, IConnectionMultiplexer redis) : Hub
{
    public override async Task OnConnectedAsync()
    {
        var userId = Context.User!.GetUserId();
        await redis.GetDatabase().StringSetAsync($"presence:user:{userId}",
            Context.ConnectionId, TimeSpan.FromSeconds(30));
        await base.OnConnectedAsync();
    }

    public async Task JoinRoom(Guid roomId)
        => await Groups.AddToGroupAsync(Context.ConnectionId, $"room:{roomId}");

    public async Task SendMessage(Guid roomId, string text)
    {
        var msg = new Message
        {
            Id = Guid.NewGuid(),
            RoomId = roomId,
            UserId = Context.User!.GetUserId(),
            Text = text,
            CreatedAt = DateTimeOffset.UtcNow,
            Sequence = await NextSequenceAsync(roomId)   // monotonic theo phòng
        };
        db.Messages.Add(msg);
        await db.SaveChangesAsync();

        // Cache message gần nhất
        await redis.GetDatabase().SortedSetAddAsync(
            $"room:{roomId}:recent", JsonSerializer.Serialize(msg), msg.Sequence);

        // Broadcast - backplane fan out xuyên replica
        await Clients.Group($"room:{roomId}").SendAsync("message", msg);
    }

    public async Task Heartbeat()
    {
        var userId = Context.User!.GetUserId();
        await redis.GetDatabase().KeyExpireAsync(
            $"presence:user:{userId}", TimeSpan.FromSeconds(30));
    }
}

Ba chi tiết. Backplane Redis là một extension method - không code fan-out tuỳ. Sequence mỗi phòng cho thứ tự đơn điệu ngay cả khi hai message đến hai hub khác nhau trong cùng mili giây. Presence chỉ là key TTL; user disconnect thì key hết hạn.

Đảm bảo thứ tự message ra sao?

sequenceDiagram
    participant A as User A (Hub1)
    participant H1 as Hub 1
    participant DB as Postgres
    participant H2 as Hub 2
    participant B as User B (Hub2)
    A->>H1: SendMessage(room, "hello")
    H1->>DB: INSERT Sequence=42
    H1->>H1: Broadcast room
    H1-->>H2: Backplane pub/sub Sequence=42
    H2->>B: deliver Sequence=42
    Note over A,B: Mọi client thấy Sequence 42 cùng thứ tự toàn cục theo phòng.

Sequence đến từ counter mỗi phòng (Postgres sequence hoặc Redis INCR). Client sắp view local theo sequence, nên message đến muộn sắp lại đúng. Không có cái này, hai message gửi cách nhau mili giây ở hai hub có thể hiện sai thứ tự ở client khác nhau.

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

Cho >1M connection đồng thời, thay SignalR bằng gateway WebSocket thuần và tách logic app thành microservice riêng. Đến đó, SignalR scale ổn.

Tạo failure mode nào?

Khi nào SignalR là hình sai?

Khi cần latency đầu cuối rất thấp (dưới 50 ms), connection đồng thời rất cao (>1M), hoặc bạn cần SDK client nhiều ngôn ngữ (SignalR client là .NET, JS, Java, Python, Swift). Vượt đó, WebSocket trần sau Nginx + fabric Redis pub/sub chạy cho mọi ngôn ngữ và cho control nhiều hơn - đổi bằng tự viết xử lý vòng đời.

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

Case study kế tiếp: notification system - fan-out từ chat mở rộng sang đa kênh (email, SMS, push) ở chương kế. Nhiều pattern (queue, idempotency, kho preference) chuyển trực tiếp.

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

Sao SignalR thay vì WebSocket trần?
SignalR xử lý bốn vấn đề khó: vòng đời connection (reconnect, heartbeat), fallback transport (WebSocket → Server-Sent Events → long polling), grouping (phòng), và backplane Redis cho fan-out xuyên replica. Bạn tự viết được tất cả, nhưng cho team .NET thư viện sẵn tiết kiệm hàng tuần sửa edge case.
Backplane Redis chạy ra sao?
Khi hub SignalR ở instance A gửi message tới group, message được publish lên kênh Redis pub/sub. Mọi instance khác subscribe kênh đó nhận message và forward tới các connection nó sở hữu trong group. Backplane tách 'instance nào sở hữu connection' khỏi 'ai cần message'.
Message lưu ở đâu?
Hai store. Postgres giữ transcript bền (mỗi message một row, index theo room và timestamp). Cache giữ 1000 message gần nhất mỗi phòng (Redis sorted set) để paginate nhanh. Fan-out gửi đi dùng backplane; storage và pagination lịch sử đọc từ Postgres + cache.
Track presence ra sao?
Mỗi connection của user ghi key Redis presence:user:{id} với TTL 30 s khi heartbeat. Để liệt kê bạn online, làm MGET trên các key kỳ vọng; thiếu = offline. Rẻ, không tốn fan-out, và tự sửa khi disconnect (TTL hết).