Scale Real-Time Chat với WebSockets & Redis/Kafka
Hướng dẫn thiết kế kiến trúc hệ thống Chat thời gian thực mở rộng cho hàng triệu người dùng đồng thời: WebSocket Gateways, Redis Pub/Sub và Kafka buffer.
Xây dựng ứng dụng chat cho 100 người cùng lúc với WebSocket rất đơn giản: chỉ cần một server Node.js hoặc Go nhỏ là đủ. Nhưng khi hệ thống đạt 100.000 hoặc hàng triệu người dùng đồng thời (CCU), server đơn lẻ sẽ sụp đổ vì quá tải kết nối TCP (Stateful connection) và tắc nghẽn I/O.
Làm thế nào để hệ thống nhận tin nhắn từ Người dùng A (kết nối ở Server 1) và chuyển tức thời đến Người dùng B (kết nối ở Server 2)? Bài viết này sẽ phân tích từng bước thiết kế kiến trúc phân tán cho hệ thống chat quy mô lớn.
1. Mô hình tư duy (ELI5): Bưu điện và các nhân viên bưu tá#
Hãy tưởng tượng bạn chuyển thư trong một toà nhà chung cư:
- Ứng dụng chat cơ bản (1 server): Có 1 nhân viên quản lý một phòng chat chung. Mọi người đều vào phòng đó và nghe nhau nói. Rất nhanh, nhưng khi phòng có 10.000 người, người quản lý không thể nói chuyện cùng lúc với tất cả mọi người được.
- Ứng dụng chat phân tán (Nhiều servers): Chúng ta mở nhiều quầy tiếp nhận (WebSocket Gateways). Khi bạn gửi thư cho bạn mình ở quầy khác:
- Quầy của bạn đẩy thư vào hệ thống băng chuyền trung tâm (Kafka để lưu trữ tin cậy, không làm mất thư).
- Hệ thống phát loa nội bộ (Redis Pub/Sub) báo tin: “Có thư mới cho phòng B!”.
- Quầy số 2 (nơi bạn của bạn đang đứng) nhận tín hiệu và trao thư tận tay bạn của bạn qua kết nối trực tiếp (WebSocket).
2. Thách thức cốt lõi của WebSocket khi Scale#
Khác với giao thức HTTP truyền thống (Stateless - gửi xong ngắt kết nối):
- Stateful Connection: Mỗi kết nối WebSocket giữ một phiên TCP liên tục giữa client và một server cụ thể.
- Resource Limits: Mỗi kết nối tiêu tốn File Descriptors (FD) và bộ nhớ RAM (khoảng 10KB - 50KB/socket). Một server tiêu chuẩn chỉ chịu được khoảng 50k - 100k kết nối trước khi cạn tài nguyên.
- Cross-Node Routing: Khi mở rộng thành cụm (Cluster), client A và client B có thể ở 2 máy chủ vật lý khác nhau. Làm sao Server 1 biết gửi tin nhắn cho Server 2?
3. Kiến trúc tổng quan cho Hệ thống Chat quy mô lớn#
Dưới đây là sơ đồ luồng kiến trúc phân tán chuẩn production:
flowchart TD
subgraph Clients["Tầng Thiết bị (Clients)"]
UserA["Client A (Người gửi)"]
UserB["Client B (Người nhận)"]
end
subgraph Edge["Tầng Cân bằng tải & Gateway"]
LB["Layer 7 Load Balancer (Nginx / HAProxy / Envoy)"]
WS1["WebSocket Gateway #1"]
WS2["WebSocket Gateway #2"]
WSS["WebSocket Gateway #N"]
end
subgraph RealTime["Tầng Điều phối Real-Time (Backplane)"]
Redis[(Redis Cluster / PubSub / Dragonfly)]
Presence[(Presence & Routing Cache)]
end
subgraph EventStream["Tầng Xử lý Bền vững & Message Queue"]
Kafka[(Apache Kafka Topic: chat-messages)]
Worker["Chat Worker / Storage Consumer"]
DB[(Primary DB: PostgreSQL / ScyllaDB)]
end
UserA -->|1. WSS Connection| LB
UserB -->|1. WSS Connection| LB
LB -->|Consistent Hash / Sticky| WS1
LB -->|Consistent Hash / Sticky| WS2
WS1 -->|2. Ingest message| Kafka
Kafka -->|3. Consume & Persist| Worker
Worker -->|Write history| DB
WS1 -->|4. Publish event| Redis
Redis -->|5. Broadcast / Route| WS2
WS2 -->|6. Push to client| UserB
WS1 -.->|Track session| Presence
WS2 -.->|Track session| Presence
4. Chi tiết các thành phần chính#
4.1. Tầng Gateway (WebSocket Cluster)#
- Nhiệm vụ duy nhất: Duy trì kết nối TCP/TLS với thiết bị người dùng, giải mã/mã hóa gói tin (JSON/Protobuf), và chuyển tiếp tin nhắn.
- Tách biệt logic kinh doanh (Business Logic) ra khỏi Gateway để server này nhẹ nhất có thể.
4.2. Tầng Message Backplane: Redis Pub/Sub vs Redis Streams#
- Redis Pub/Sub: Cực kỳ nhanh (sub-millisecond latency), dùng để phát thông báo tới tất cả các WebSocket Gateway node xem node nào đang giữ kết nối của người nhận.
- Nhược điểm: Fire-and-forget (nếu node nhận bị sập đúng lúc đó, tin nhắn broadcast sẽ mất). Vì vậy ta cần kết hợp với Kafka.
4.3. Tầng Bền vững & Hàng đợi: Apache Kafka#
- Đảm bảo Zero Message Loss (không bao giờ mất tin nhắn).
- Đảm bảo thứ tự tin nhắn theo phòng/người nhận (Message Ordering theo Partition Key =
room_idhoặcconversation_id). - Workers tiêu thụ (consume) tin nhắn từ Kafka và lưu trữ lịch sử chat vào Database không đồng bộ (Asynchronous Persistence) mà không làm nghẽn luồng gửi tin trực tiếp.
4.4. Quản lý trạng thái kết nối (Presence & Session Mapping)#
Lưu trữ trên Redis Hash:
Key: user:session:<user_id>
Value: { "server_id": "ws-node-02", "status": "online", "last_heartbeat": 1772183200 }text5. Ví dụ triển khai: WebSocket Gateway + Redis Pub/Sub (Node.js & TypeScript)#
Dưới đây là mã nguồn tối giản mô phỏng một WebSocket Node trong cụm phân tán:
import { WebSocketServer, WebSocket } from 'ws';
import Redis from 'ioredis';
import { v4 as uuidv4 } from 'uuid';
const SERVER_ID = `node-${process.env.NODE_ID || uuidv4().slice(0, 8)}`;
const PORT = Number(process.env.PORT) || 8080;
const redisPub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
const redisSub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
// Map lưu trữ các kết nối cục bộ trên server này
const localConnections = new Map<string, WebSocket>();
const wss = new WebSocketServer({ port: PORT });
wss.on('connection', (ws: WebSocket, req) => {
const userId = new URL(req.url || '', `http://${req.headers.host}`).searchParams.get('userId');
if (!userId) {
ws.close(4001, 'UserId required');
return;
}
localConnections.set(userId, ws);
console.log(`[${SERVER_ID}] User ${userId} connected.`);
// Cập nhật vị trí kết nối lên Redis Presence
redisPub.set(`presence:${userId}`, SERVER_ID, 'EX', 60);
ws.on('message', async (data: string) => {
try {
const payload = JSON.parse(data.toString());
const { recipientId, content } = payload;
const chatEvent = {
senderId: userId,
recipientId,
content,
timestamp: Date.now(),
};
// Đẩy vào kênh phân tán để các server khác nhận được
await redisPub.publish('chat:messages', JSON.stringify(chatEvent));
} catch (err) {
console.error('Failed to process message:', err);
}
});
ws.on('close', () => {
localConnections.delete(userId);
redisPub.del(`presence:${userId}`);
console.log(`[${SERVER_ID}] User ${userId} disconnected.`);
});
});
// Lắng nghe tin nhắn từ các node khác qua Redis
redisSub.subscribe('chat:messages');
redisSub.on('message', (_channel, messageStr) => {
const message = JSON.parse(messageStr);
const targetWs = localConnections.get(message.recipientId);
// Nếu người nhận đang kết nối ở server này, gửi tin nhắn đi
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(JSON.stringify(message));
}
});
console.log(`WebSocket Gateway [${SERVER_ID}] running on port ${PORT}`);typescript6. Tối ưu hệ điều hành & Hạ tầng khi scale hàng triệu kết nối#
Khi triển khai trên thực tế ở mức hàng trăm ngàn kết nối đồng thời:
- Mở giới hạn File Descriptors của Linux:
bash# Trong /etc/security/limits.conf * soft nofile 1048576 * hard nofile 1048576 - Tối ưu TCP Kernel buffers (sysctl.conf):
inifs.file-max = 2097152 net.ipv4.tcp_max_syn_backlog = 65536 net.core.somaxconn = 65536 net.ipv4.tcp_rmem = 4096 87380 4194304 net.ipv4.tcp_wmem = 4096 65536 4194304 - Heartbeat (Ping/Pong): Client và server phải gửi ping/pong định kỳ (ví dụ mỗi 30s) để phát hiện Dead Connection (mất mạng đột ngột do 4G/Wifi) và giải phóng RAM kịp thời.
[!TIP] Không nên tải toàn bộ lịch sử chat qua WebSocket! Hãy dùng REST API / GraphQL thông thường kết hợp phân trang (Cursor-based pagination) để tải tin nhắn cũ. Giữ kênh WebSocket thật nhẹ chỉ dành cho luồng dữ liệu thời gian thực.