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
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ợ?
- Hub instance: scale ngang với backplane; session sticky qua LB.
- Backplane: Redis Cluster shard kênh theo hash room ID; một kênh mỗi phòng giảm chi phí broadcast.
- Storage: partition bảng message theo room ID + tháng; read truy vấn một partition.
- Presence: Redis cluster, một shard mỗi hash user ID.
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?
- Fail-over session sticky - LB drop một hub; client reconnect hub mới. Phòng: auto-reconnect SignalR + replay N message cuối từ cache recent-messages.
- Outage backplane - Redis pub/sub chết; hub không fan-out xuyên instance. Phòng: handler resilience chương 11 áp dụng; xuống cấp single-hub mode nếu backplane không khoẻ.
- Bão message một phòng - 100K user một phòng, mỗi người gửi một msg/giây. Phòng: rate-limit theo user theo phòng; backpressure khi queue broadcast dài.
- Drift presence - user đóng laptop, presence dây tới TTL. Phòng: TTL 15-30 s giữ drift nhỏ; client heartbeat mỗi 10 s.
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?
Backplane Redis chạy ra sao?
Message lưu ở đâu?
Track presence ra sao?
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).