blog.dopana

Back

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ư:

  1. Ứ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.
  2. Ứ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_id hoặc conversation_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 }
text

5. 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:

6. 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:

  1. Mở giới hạn File Descriptors của Linux:
    # Trong /etc/security/limits.conf
    * soft nofile 1048576
    * hard nofile 1048576
    bash
  2. Tối ưu TCP Kernel buffers (sysctl.conf):
    fs.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
    ini
  3. 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.

7. Tài liệu tham khảo#