Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

WebSocket và Socket.io: kỹ thuật realtime cho chat, live dashboard và multiplayer web app

Ếch Trendy
Ếch Trendy

3 tháng trước · 9 phút đọc

Tại sao HTTP không đủ cho realtime?

Bạn có bao giờ build một trang dashboard cập nhật giá cổ phiếu, rồi nhận ra mình đang dùng setInterval gọi API mỗi 1 giây? Hoặc làm chat app mà tin nhắn phải F5 mới hiện?

Vấn đề nằm ở bản chất của HTTP: request-response. Client hỏi, server trả lời, kết nối đóng. Muốn data mới, client phải hỏi lại. Cứ thế lặp.

Polling (liên tục gọi API) hoạt động được, nhưng tốn tài nguyên khủng khiếp. Hàng chục đến hàng trăm request thừa trên mỗi kết nối mỗi phút khi dùng polling 1s/lần - trong khi 99% chúng là "không có gì mới". Với 1000 người dùng đồng thời, server của bạn đang xử lý lượng traffic vô nghĩa.

344780 HTTP polling vs WebSocket - cùng data, traffic khác nhau hoàn toàn

WebSocket ra đời để giải quyết đúng bài toán này. Thay vì mỗi lần cần data lại mở một kết nối mới, WebSocket duy trì một kết nối TCP duy nhất, hai chiều, từ khi client connect đến khi disconnect. Server push data xuống bất cứ lúc nào - không cần client hỏi.


WebSocket thuần: đơn giản hơn bạn nghĩ

WebSocket không phải thư viện hay framework - đó là giao thức chuẩn (RFC 6455), được hỗ trợ native trên mọi trình duyệt hiện đại và Node.js. Bạn không cần cài gì thêm để bắt đầu.

Phía client:

// Khởi tạo kết nối WebSocket
const ws = new WebSocket('ws://localhost:3000');

// Khi kết nối thành công
ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'join', room: 'general' }));
};

// Nhận message từ server
ws.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Nhận được:', data);
};

// Xử lý mất kết nối
ws.onclose = () => {
  console.log('Kết nối đã đóng - thử reconnect');
};

Phía server (Node.js với thư viện ws):

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 3000 });

wss.on('connection', (socket) => {
  console.log('Client mới kết nối');

  socket.on('message', (raw) => {
    const data = JSON.parse(raw);

    // Broadcast tới tất cả client đang kết nối
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(data));
      }
    });
  });

  socket.on('close', () => {
    console.log('Client ngắt kết nối');
  });
});

344781 WebSocket thuần đủ dùng cho nhiều use case - không cần thêm dependency

WebSocket thuần phù hợp khi:

  • Use case đơn giản: chat 1-1, notification đẩy xuống
  • Bạn muốn kiểm soát hoàn toàn protocol
  • Bundle size là ưu tiên (không muốn thêm 40KB cho Socket.io)
  • Môi trường không có Node.js (Go, Rust, Python server)

Giới hạn của WebSocket thuần: bạn phải tự xử lý reconnect logic, room management, event naming, fallback khi môi trường không hỗ trợ WebSocket (proxy cũ, corporate firewall). Đây là lúc Socket.io tỏa sáng.


Socket.io: khi nào đáng thêm dependency?

Socket.io không phải WebSocket - đây là điểm nhiều người hiểu nhầm. Socket.io là một abstraction layer chạy trên WebSocket (và fallback sang long-polling nếu cần). Nó thêm:

  • Event-based API: thay vì parse JSON thủ công, bạn emit/on theo tên event
  • Auto-reconnect: tự động thử lại khi mất kết nối, có backoff
  • Room & namespace: quản lý nhóm client dễ dàng
  • Acknowledgement: biết chính xác server đã nhận message chưa
  • Fallback transport: hoạt động ngay cả khi WebSocket bị block
// Server với Socket.io
const io = require('socket.io')(server);

io.on('connection', (socket) => {
  // Tham gia room
  socket.on('join-room', (roomId) => {
    socket.join(roomId);
    socket.to(roomId).emit('user-joined', socket.id);
  });

  // Gửi tin nhắn trong room
  socket.on('chat-message', ({ roomId, message }) => {
    // Broadcast tới tất cả trong room TRỪ người gửi
    socket.to(roomId).emit('new-message', {
      from: socket.id,
      message,
      time: Date.now()
    });
  });

  // Acknowledgement - biết client đã nhận
  socket.on('confirm-read', (msgId, callback) => {
    markAsRead(msgId);
    callback({ status: 'ok' }); // trả về cho client
  });
});

344782 Socket.io giải quyết phần boilerplate mà WebSocket thuần bỏ ngỏ

So sánh thực tế:

WebSocket thuần Socket.io
Bundle size ~0KB ~40KB (client)
Reconnect Tự code Có sẵn
Room management Tự code socket.join()
Fallback Không Long-polling
Event naming Tự parse JSON emit/on
Acknowledgement Tự code Có sẵn

Kết luận thực tế: Nếu build production app với nhiều người dùng, cần room, reconnect, và team không muốn tự viết boilerplate - dùng Socket.io. Nếu build microservice internal, IoT, hoặc cần tối ưu tối đa - WebSocket thuần.


Kiến trúc room và broadcast trong thực tế

Room là khái niệm quan trọng nhất khi build chat hoặc multiplayer game. Hiểu đơn giản: room là một nhóm socket ID được gắn một cái tên. Server có thể broadcast tới toàn bộ nhóm chỉ bằng một lệnh.

Ba kiểu emit cần nhớ:

// 1. Gửi CHỈ cho client hiện tại
socket.emit('event', data);

// 2. Gửi cho TẤT CẢ trong room TRỪ người gửi
socket.to('room-id').emit('event', data);

// 3. Gửi cho TẤT CẢ trong room KỂ CẢ người gửi
io.to('room-id').emit('event', data);

Ví dụ thực tế - live dashboard cập nhật cho từng team:

// Mỗi team có dashboard riêng
socket.on('subscribe-dashboard', (teamId) => {
  socket.join(`dashboard:${teamId}`);
});

// Khi có data mới (từ DB change, webhook, batch job...)
function pushDashboardUpdate(teamId, metrics) {
  io.to(`dashboard:${teamId}`).emit('metrics-update', metrics);
}

// Gọi khi có thay đổi
setInterval(async () => {
  const teams = await getActiveTeams();
  for (const team of teams) {
    const metrics = await fetchMetrics(team.id);
    pushDashboardUpdate(team.id, metrics);
  }
}, 5000); // Push mỗi 5 giây, chỉ tới team đang xem

344783 Room giúp server chỉ push data tới đúng người cần - không broadcast toàn bộ

Namespace là tầng cao hơn room - dùng khi một server phục vụ nhiều ứng dụng khác nhau. Ví dụ /chat/dashboard là 2 namespace riêng, không ảnh hưởng nhau.

Một pattern hữu ích: presence tracking - biết ai đang online trong room:

// Lưu map user -> socket
const onlineUsers = new Map();

io.on('connection', (socket) => {
  const userId = socket.handshake.auth.userId;
  onlineUsers.set(userId, socket.id);

  // Thông báo cho room biết user X online
  socket.on('join-room', (roomId) => {
    socket.join(roomId);
    io.to(roomId).emit('presence-update', {
      userId,
      status: 'online'
    });
  });

  socket.on('disconnect', () => {
    onlineUsers.delete(userId);
    // Thông báo user offline
  });
});

Reconnect và xử lý mất kết nối

Mạng di động chập chờn. Laptop ngủ. Proxy timeout. Mất kết nối WebSocket là chuyện bình thường, không phải edge case. Ứng dụng production phải handle được điều này.

Socket.io có auto-reconnect mặc định, nhưng bạn vẫn cần cấu hình đúng:

// Client - cấu hình reconnect hợp lý
const socket = io('http://localhost:3000', {
  // Thử reconnect tối đa 5 lần
  reconnectionAttempts: 5,
  // Delay tăng dần: 1s, 2s, 4s, 8s... (exponential backoff)
  reconnectionDelay: 1000,
  reconnectionDelayMax: 10000,
  // Timeout mỗi lần thử
  timeout: 5000
});

socket.on('connect', () => {
  console.log('Đã kết nối:', socket.id);
  // QUAN TRỌNG: rejoin rooms sau khi reconnect
  // Vì socket.id mới = server mất track room cũ
  rejoinRooms();
});

socket.on('disconnect', (reason) => {
  if (reason === 'io server disconnect') {
    // Server chủ động đá ra - không tự reconnect
    socket.connect();
  }
  // Các lý do khác: Socket.io tự reconnect
});

socket.on('reconnect_failed', () => {
  // Sau N lần thất bại - hiện thông báo cho user
  showOfflineNotice();
});

344784 Rejoin room sau reconnect là bước hay bị quên, gây bug khó tìm

Với WebSocket thuần, bạn phải tự code reconnect logic:

function createWebSocket(url) {
  let ws;
  let retries = 0;
  const MAX_RETRIES = 5;

  function connect() {
    ws = new WebSocket(url);

    ws.onopen = () => {
      retries = 0; // Reset khi thành công
    };

    ws.onclose = () => {
      if (retries < MAX_RETRIES) {
        // Exponential backoff: 1s, 2s, 4s...
        const delay = Math.min(1000 * 2 ** retries, 30000);
        retries++;
        setTimeout(connect, delay);
      }
    };
  }

  connect();
  return () => ws; // Trả về getter để component dùng
}

Một điểm hay bị bỏ qua: message queue khi offline. Nếu user gõ tin nhắn lúc mất mạng, ứng dụng cần lưu tạm và gửi lại sau khi reconnect - không phải drop luôn.


Scaling: khi một server không đủ

WebSocket giữ kết nối liên tục, nghĩa là scaling horizontal không đơn giản như REST API. Vấn đề cổ điển: user A kết nối vào server 1, user B kết nối vào server 2 - khi A emit, server 1 không biết B đang ở đâu.

344785 Không có adapter, 2 server WebSocket không nói chuyện được với nhau

Giải pháp phổ biến nhất: Redis Adapter cho Socket.io.

const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const pubClient = createClient({ url: 'redis://localhost:6379' });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));

// Sau đây, io.to('room').emit() hoạt động
// dù client ở server nào trong cluster

Với Redis Adapter, tất cả server instance dùng Redis như message bus trung tâm. Server 1 broadcast → Redis → Server 2 nhận và forward tới client. Vài mili giây latency thêm vào mỗi message - chấp nhận được cho hầu hết use case.

Kiến trúc tổng thể cho production:

[Client] ──→ [Load Balancer (sticky sessions)] ──→ [Server 1]
                                               ──→ [Server 2]  ──→ [Redis]
                                               ──→ [Server 3]

Sticky sessions là bắt buộc - load balancer phải đảm bảo cùng một client luôn hit cùng một server (ít nhất trong quá trình handshake).

Các option thay thế Redis:

  • Socket.io with MongoDB Adapter: dùng MongoDB Change Streams
  • Ably / Pusher: managed WebSocket service, không cần tự quản lý infrastructure
  • Cloudflare Durable Objects: kiến trúc edge, phù hợp app toàn cầu

Nếu bạn đang học Node.js và muốn đi sâu hơn về backend architecture, khóa Node & ExpressJS của F8 cover các khái niệm liên quan như event loop, async/await và cách build RESTful API - nền tảng tốt trước khi tackle WebSocket scaling.