Chat: gửi tin nhắn real-time cho hàng triệu người

4 câu hỏi~30 phútTừ khóa: WebSocket, presence, connection registry, ordering per conversation, client message id, SENT/DELIVERED/READ, offline push, partition by conversation_id

Tiếp đi: thiết kế hệ thống chat kiểu Messenger.

Đề: thiết kế chat cơ bản kiểu WhatsApp/Messenger. Bài này gom lại rất nhiều thứ đã học: stateless, cache, queue, idempotency, object storage.

1. Requirements

2. Ước lượng

50M DAU, mỗi người gửi 40 tin/ngày
→ 2B tin/ngày ≈ 23,000 tin/giây, đỉnh ~70,000/giây
Mỗi tin ~200 bytes → 400 GB/ngày → ~150 TB/năm
10M kết nối đồng thời, ~50k kết nối / chat server → ~200 server

Kết luận: write-heavy, dữ liệu lớn và tăng mãi, nhiều kết nối lâu dài.

3. Vấn đề: HTTP polling

Client: GET /messages … GET /messages … mỗi vài giây
10M clients × polling → phần lớn request trả về rỗng, rất phí

Ta muốn kết nối lâu dài để server đẩy tin xuống ngay: WebSocket.

Client ⇅ WebSocket ⇅ Chat Server

4. API

WebSocket  wss://chat.example.com/ws   (auth bằng JWT lúc handshake)
  → client gửi: { "type":"SEND", "clientMsgId":"uuid", "conversationId":"c1", "content":"hi" }
  ← server ack:  { "type":"ACK", "clientMsgId":"uuid", "messageId":"m900", "seq":57 }
  ← server push: { "type":"MESSAGE", "conversationId":"c1", "seq":58, ... }
  → client gửi: { "type":"READ", "conversationId":"c1", "seq":58 }

REST (không cần real-time)
GET  /conversations/{id}/messages?afterSeq=40&limit=50
POST /attachments/upload-url   → pre-signed URL

5. Data model

MESSAGE (partition key = conversation_id, sort key = seq)
  conversation_id, seq, message_id, client_msg_id, sender_id,
  content, attachment_url, created_at
  UNIQUE (conversation_id, client_msg_id)

CONVERSATION_MEMBER
  conversation_id, user_id, last_read_seq, last_delivered_seq

USER_INBOX (để client đồng bộ nhiều hội thoại)
  user_id, conversation_id, last_seq

Truy vấn chính là "cho tôi tin của hội thoại X theo thứ tự". Vì vậy partition theo conversation_id là tự nhiên. Ở quy mô này thường chọn wide-column (Cassandra, DynamoDB) vì write nhiều và truy vấn theo khoá. Xem thêm Partition & sharding.

6. Kiến trúc

                      Clients
                         ⇅ WebSocket
                    Load Balancer (L4, sticky theo connection)
                  /      |       \
                C1       C2       C3      (Chat Server, giữ connection)
                  \      |       /
                   Presence / Connection Registry (Redis)
                         │
                   Message Service ── Message Store (partition by conversation_id)
                         │
                   Event Bus (Kafka, key = conversation_id)
                   ┌─────┴──────┐
                   ▼            ▼
             Chat Servers   Notification Service → APNs/FCM (offline push)

Media: Client → pre-signed URL → Object Storage → CDN

7. Gửi một tin từ A tới B

A ──ws──► C1
          1. Message Service: cấp seq, lưu DB (dedup clientMsgId)
          2. ACK cho A  → A thấy ✓ (SENT)
          3. publish event (key = conversationId)
          4. tra registry: B đang ở C3?
                ├─ online  → C3 push xuống B → B gửi DELIVERED
                └─ offline → Notification Service → push lên điện thoại
B mở lại app: GET messages?afterSeq=lastSeq → lấy tin bị lỡ

Thứ tự quan trọng: lưu trước, rồi mới ACK và đẩy. Tin chưa lưu thì không được báo SENT.

8. Trade-off

Lựa chọnĐượcMất
WebSocketPush tức thì, ít overheadServer có state (connection), scale và deploy khó hơn
Long polling / SSEĐơn giản, đi qua proxy dễMột chiều (SSE) hoặc tốn request hơn
Partition theo conversationĐọc một hội thoại chỉ chạm một partitionNhóm cực đông thành hot partition
Ordering per conversationRẻ, đủ cho người dùngKhông có thứ tự toàn cục (không cần)

9. Khi nào đơn giản hơn được

Chat hỗ trợ khách hàng vài nghìn người: một Spring Boot + STOMP over WebSocket + PostgreSQL + Redis pub/sub là đủ. Kiến trúc trên chỉ cần khi có hàng triệu kết nối.

Có nhiều chat server thì C1 làm sao biết B đang kết nối ở server nào? Mình tưởng load balancer lo việc đó?

Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: load balancer chỉ quyết định connection mới đi vào server nào. Nó không biết "user B đang ở đâu" để chuyển tin giữa các server. Phần đó cần một connection registry.

1. Connection registry (presence)

Redis:
  conn:userA = C1   (TTL 60s, heartbeat gia hạn mỗi 30s)
  conn:userB = C3
  conn:userC = C2

Khi connect: ghi key. Khi disconnect: xoá key. Server chết đột ngột: key tự hết TTL, B coi như offline.

2. Hai cách chuyển tin giữa server

CáchHoạt độngHợp khi
Tra registry + gọi thẳngC1 đọc conn:userB = C3, gọi gRPC tới C3Chat 1–1, nhiều server
Pub/sub theo serverMỗi server subscribe topic riêng server.C3; C1 publish vào đóĐơn giản, dùng Redis pub/sub hoặc Kafka

3. Code: Spring WebSocket handler

@Component
@RequiredArgsConstructor
public class ChatSocketHandler extends TextWebSocketHandler {
    private final StringRedisTemplate redis;
    private final LocalSessions sessions;          // Map<userId, WebSocketSession> trong JVM
    @Value("${chat.server-id}") private String serverId;

    @Override
    public void afterConnectionEstablished(WebSocketSession s) {
        String userId = (String) s.getAttributes().get("userId");   // set bởi HandshakeInterceptor sau khi verify JWT
        sessions.put(userId, s);
        redis.opsForValue().set("conn:" + userId, serverId, Duration.ofSeconds(60));
    }

    @Override
    public void afterConnectionClosed(WebSocketSession s, CloseStatus status) {
        String userId = (String) s.getAttributes().get("userId");
        sessions.remove(userId);
        redis.delete("conn:" + userId);
    }
}

4. Presence cho bạn bè ("đang online")

Đừng broadcast mỗi lần ai đó online cho toàn bộ bạn bè: 1 người có 1.000 bạn thì một lần mở app sinh 1.000 event. Cách thực tế: client hỏi trạng thái của những người đang hiển thị trên màn hình, và "last seen" được cập nhật thưa (mỗi 30–60 giây).

Điện thoại mất mạng rồi gửi lại tin thì có bị trùng không? Và làm sao đảm bảo tin hiện ra đúng thứ tự?

Đây là hai câu interviewer rất hay hỏi. Cả hai đều giải bằng ID.

1. Trùng tin: client message id

Client gửi M1 → server lưu thành công → ACK bị mất
Client retry M1 → nếu không chặn: lưu lần 2 ❌

Client sinh clientMsgId (UUID) một lần khi người dùng bấm gửi, giữ nguyên khi retry. Server chặn bằng unique constraint:

public MessageAck send(SendCommand cmd) {
    return repo.findByConversationIdAndClientMsgId(cmd.conversationId(), cmd.clientMsgId())
            .map(MessageAck::of)                         // đã có → trả lại ACK cũ
            .orElseGet(() -> {
                long seq = sequencer.next(cmd.conversationId());
                try {
                    return MessageAck.of(repo.save(Message.from(cmd, seq)));
                } catch (DataIntegrityViolationException dup) {   // 2 retry đến cùng lúc
                    return MessageAck.of(repo.findByConversationIdAndClientMsgId(
                            cmd.conversationId(), cmd.clientMsgId()).orElseThrow());
                }
            });
}

Giống hệt tư duy idempotency key ở Payment.

2. Thứ tự: sequence per conversation

Global ordering cho cả nền tảng rất đắt và không cần. Người dùng chỉ cần thứ tự trong một hội thoại.

seq = INCR conv:c1:seq      (Redis, hoặc cột counter trong DB)
M1 → seq 56, M2 → seq 57, M3 → seq 58
Kafka key = conversationId → mọi event của c1 vào cùng partition → consumer đọc đúng thứ tự
Client sắp xếp theo seq, thấy thiếu seq 57 thì GET afterSeq=56 để lấp lỗ

Không dùng created_at để sắp xếp: đồng hồ các server lệch nhau (cùng lý do như bài Flash sale).

3. SENT, DELIVERED, READ

A gửi → server lưu         → SENT       ✓
B device nhận được         → DELIVERED  ✓✓
B mở hội thoại             → READ       ✓✓ (xanh)

Không lưu trạng thái cho từng tin. Chỉ lưu con trỏ last_delivered_seq, last_read_seq cho mỗi thành viên. "Mọi tin có seq ≤ 58 đã đọc". Nhóm 500 người cũng chỉ 500 dòng, không phải 500 × số tin.

Phỏng vấn hỏi "thiết kế hệ thống chat" thì trả lời sao?

Q1. Thiết kế chat real-time cho nhiều server?

🇻🇳 Tiếng Việt:

Client giữ WebSocket tới chat server. Vì có nhiều server, em lưu connection registry trong Redis (userId → serverId, có TTL và heartbeat). Khi có tin, message service lưu trước, ACK cho người gửi, rồi tra registry để đẩy tới server của người nhận. Người nhận offline thì gửi push notification, và khi họ quay lại thì đồng bộ theo afterSeq.

🇬🇧 English:

Clients hold a WebSocket to a chat server. Because there are many servers, I keep a connection registry in Redis mapping userId to serverId, with a TTL and heartbeats. On a new message, the message service persists it first, acknowledges the sender, then looks up the registry to push it to the recipient's server. If the recipient is offline, we send a push notification, and they sync with afterSeq when they reconnect.

Q2. Làm sao tránh trùng tin và giữ thứ tự?

🇻🇳 Tiếng Việt:

Client sinh client message id một lần và giữ nguyên khi retry; server có unique constraint (conversation_id, client_msg_id). Thứ tự em chỉ đảm bảo trong một hội thoại bằng sequence tăng dần, và dùng conversationId làm partition key của Kafka. Global ordering vừa đắt vừa không cần.

🇬🇧 English:

The client generates a message id once and reuses it on retries, and the server enforces a unique constraint on (conversation_id, client_msg_id). I only guarantee ordering within a conversation, using a per-conversation sequence and the conversation id as the Kafka partition key. Global ordering is expensive and unnecessary.

Q3. Lưu tin nhắn ở đâu, partition thế nào?

🇻🇳 Tiếng Việt:

Truy vấn chính là lấy tin của một hội thoại theo thứ tự, nên em partition theo conversation_id và sort theo seq, thường trên wide-column store như Cassandra hoặc DynamoDB vì write rất nhiều. File đính kèm để ở object storage qua pre-signed URL, tin nhắn chỉ giữ link. Nhóm quá đông có thể thành hot partition, khi đó chia thêm theo bucket thời gian.

🇬🇧 English:

The main query is "messages of one conversation in order", so I partition by conversation_id and sort by sequence, usually on a wide-column store like Cassandra or DynamoDB because the workload is write-heavy. Attachments go to object storage via pre-signed URLs and messages only keep the link. Very large groups can become hot partitions, so I would add a time bucket to the key.

Cách nhớ ngắn gọn


Tự kiểm tra

Vì sao không dùng HTTP polling cho 10 triệu client?

Phần lớn request trả về rỗng, tốn CPU và băng thông. WebSocket giữ kết nối và server chủ động đẩy.

Server chat chết đột ngột, registry trong Redis xử lý sao?

Key conn:user có TTL, không được heartbeat gia hạn thì tự hết hạn. Client reconnect sang server khác và ghi key mới.

Vì sao clientMsgId phải do client sinh?

Chỉ client biết hai lần gửi là cùng một tin. ID sinh một lần khi bấm gửi, giữ nguyên khi retry, server dedup bằng unique constraint.

Làm sao giữ thứ tự tin nhắn khi đi qua Kafka?

Dùng conversationId làm message key, mọi event của một hội thoại vào cùng partition. Kèm seq tăng dần để client tự sắp xếp và phát hiện lỗ.

Lưu trạng thái "đã đọc" thế nào cho nhóm 500 người?

Mỗi thành viên một con trỏ last_read_seq. Không lưu trạng thái riêng cho từng tin.