Đ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: Xây dựng app realtime, multiplayer game và live dashboard

Ếch Trendy
Ếch Trendy

2 tháng trước · 12 phút đọc

WebSocket là gì và tại sao HTTP polling không đủ dùng

Mình nhớ hồi mới làm tính năng live notification, mình dùng setInterval gọi API mỗi 2 giây. Nó chạy được. Nhưng khi lên production với vài trăm người dùng cùng lúc, server bắt đầu kêu.

Vấn đề của HTTP polling rất đơn giản: client hỏi, server trả lời - dù có dữ liệu mới hay không. Mỗi request đều kèm HTTP headers (~500 bytes đến vài KB), TCP handshake, và thời gian chờ. Với 1000 users polling mỗi 2 giây, bạn đang xử lý 500 request/giây chỉ để trả về "chưa có gì mới".

345930 Polling tạo request liên tục dù server không có gì mới - lãng phí tài nguyên rõ ràng

WebSocket giải quyết vấn đề này bằng cách giữ một kết nối TCP duy nhất mở liên tục. Luồng hoạt động:

  1. Client gửi HTTP request với header Upgrade: websocket
  2. Server trả về 101 Switching Protocols
  3. Từ đây, cả hai bên trao đổi frame nhị phân hai chiều trên cùng kết nối - không cần request mới

Kết quả? Server chủ động đẩy dữ liệu tới client ngay khi có. Latency giảm từ "chu kỳ polling" xuống còn vài milliseconds. Overhead từ headers HTTP biến mất.

Cái quan trọng hơn: mô hình tư duy thay đổi hoàn toàn. Thay vì "client hỏi → server trả lời", bạn làm việc với event stream hai chiều. Server có thể notify client bất cứ lúc nào mà không cần client hỏi trước.


Socket.IO là gì - và tại sao nó không phải WebSocket

Đây là nhầm lẫn mình thấy khá thường xuyên: dùng "WebSocket" và "Socket.IO" như thể chúng là một. Thực ra chúng khác nhau cơ bản.

WebSocket là giao thức chuẩn (RFC 6455) được browser và server hỗ trợ native. Socket.IO là thư viện với protocol riêng chạy trên Engine.IO - nó dùng WebSocket khi có thể, nhưng cũng có thêm nhiều thứ khác.

Một điểm nhiều bạn bỏ qua: kết nối đầu tiên của Socket.IO thường bắt đầu bằng HTTP long-polling, rồi mới upgrade lên WebSocket. Tức là message đầu tiên chậm hơn một round trip so với WebSocket thuần.

345931 Socket.IO bắt đầu bằng HTTP polling rồi mới upgrade - đây là lý do đầu tiên kết nối hơi chậm

Nhưng đổi lại, Socket.IO cho bạn rất nhiều thứ miễn phí:

  • Automatic reconnection với exponential backoff
  • Fallback transport: nếu WebSocket bị chặn (firewall công ty, proxy), tự chuyển sang long-polling
  • Rooms: broadcast cho nhóm người dùng cụ thể
  • Namespaces: tách logic thành các kênh riêng
  • Event model: gửi nhận theo tên event, không cần parse JSON thô thủ công

Bảng so sánh nhanh:

WebSocket Socket.IO
Bản chất Giao thức chuẩn Thư viện + protocol riêng
Kết nối đầu WebSocket ngay HTTP polling → upgrade
Fallback Tự xử lý Có sẵn
Reconnect Tự xử lý Có sẵn
Rooms/Namespaces Tự xây Có sẵn
Overhead Thấp Cao hơn

Chọn WebSocket thuần khi bạn cần throughput tối đa, kiểm soát chặt protocol, và không cần fallback hay room abstraction. Chọn Socket.IO khi bạn muốn development nhanh, có fallback, và cần tính năng như rooms/namespaces mà không muốn tự build.

Một điều cần nhớ: client WebSocket chuẩn không nói chuyện được với Socket.IO server vì họ nói hai protocol khác nhau. Nếu bạn dùng Socket.IO server thì client cũng phải dùng socket.io-client.


Setup Node.js + React với Socket.IO

Mình sẽ đi thẳng vào code. Ví dụ dưới đây build một live chat đơn giản - cấu trúc này dùng được cho cả multiplayer game lẫn live dashboard, chỉ thay đổi event names và payload.

Server side (Node.js + Express)

Cài package:

npm install express socket.io cors
// server.js
const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
const httpServer = createServer(app);

const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
  // Giảm polling transport, ưu tiên WebSocket
  transports: ['websocket', 'polling'],
});

// Namespace riêng cho chat
const chat = io.of('/chat');

chat.on('connection', (socket) => {
  console.log(`User connected: ${socket.id}`);

  // Vào room theo tên phòng chat
  socket.on('join_room', (roomId) => {
    socket.join(roomId);
    // Thông báo cho những người trong room
    socket.to(roomId).emit('user_joined', { userId: socket.id });
  });

  // Nhận message và broadcast cho room
  socket.on('send_message', ({ roomId, message }) => {
    chat.to(roomId).emit('receive_message', {
      userId: socket.id,
      message,
      timestamp: Date.now(),
    });
  });

  socket.on('disconnect', () => {
    console.log(`User disconnected: ${socket.id}`);
  });
});

httpServer.listen(4000, () => {
  console.log('Server chạy tại port 4000');
});

Client side (React)

npm install socket.io-client
// hooks/useSocket.js
import { useEffect, useRef } from 'react';
import { io } from 'socket.io-client';

export function useSocket(namespace = '/chat') {
  const socketRef = useRef(null);

  useEffect(() => {
    // Khởi tạo kết nối tới namespace
    socketRef.current = io(`http://localhost:4000${namespace}`, {
      // Ưu tiên WebSocket, fallback polling nếu cần
      transports: ['websocket', 'polling'],
      // Reconnect tự động với backoff
      reconnectionDelay: 1000,
      reconnectionDelayMax: 5000,
    });

    // Cleanup khi component unmount - BẮT BUỘC
    return () => {
      socketRef.current?.disconnect();
    };
  }, [namespace]);

  return socketRef.current;
}
// ChatRoom.jsx
import { useEffect, useState, useCallback } from 'react';
import { useSocket } from '../hooks/useSocket';

export function ChatRoom({ roomId }) {
  const socket = useSocket('/chat');
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    if (!socket) return;

    // Vào room khi component mount
    socket.emit('join_room', roomId);

    // Lắng nghe message mới
    const handleMessage = (data) => {
      setMessages((prev) => [...prev, data]);
    };

    socket.on('receive_message', handleMessage);

    // Cleanup listener - quan trọng để tránh duplicate handlers
    return () => {
      socket.off('receive_message', handleMessage);
    };
  }, [socket, roomId]);

  const sendMessage = useCallback(
    (text) => {
      socket?.emit('send_message', { roomId, message: text });
    },
    [socket, roomId]
  );

  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>{msg.message}</div>
      ))}
      {/* Input component */}
    </div>
  );
}

345932 Pattern hook tách socket logic ra khỏi UI component - dễ test và tái sử dụng hơn nhiều

Một vài điểm quan trọng trong code trên:

  • socket.off('receive_message', handleMessage) trong cleanup - không làm điều này là nguyên nhân số 1 gây duplicate message handlers
  • Dùng namespace riêng (/chat) thay vì default namespace để tách logic
  • transports: ['websocket', 'polling'] - đặt WebSocket trước để socket cố dùng WebSocket ngay từ đầu thay vì bắt đầu bằng polling

Nếu bạn mới làm quen với React và muốn có nền tảng vững trước khi đụng vào Socket.IO, khóa Xây Dựng Website với ReactJS của F8 cover khá đầy đủ từ hooks đến useEffect - những thứ bạn cần để dùng socket đúng cách.


Reconnection, throttling và xử lý mạng kém

Socket.IO có reconnect tự động - nhưng nếu bạn chỉ bật nó lên và không làm gì thêm, bạn sẽ gặp vấn đề.

Reconnection storm

Khi server restart hoặc mạng bị ngắt, tất cả client cố reconnect cùng lúc. Với 10k users, đây là 10k request đập vào server ngay cùng thời điểm. Server có thể chết ngay từ bước reconnect trước khi kịp serve traffic thật.

Fix bằng jitter - thêm độ ngẫu nhiên vào delay reconnect:

// client config
const socket = io('http://localhost:4000/chat', {
  reconnectionDelay: 1000,
  reconnectionDelayMax: 30000,
  // Socket.IO tự thêm jitter nhưng bạn có thể tune thêm
  randomizationFactor: 0.5,
});

Throttle event tần suất cao

Với multiplayer game hoặc collaborative tool, bạn có thể emit mousemove, cursor position, hoặc typing state rất thường xuyên. Gửi mỗi pixel di chuyển là không cần thiết.

// utils/throttle.js
export function throttle(fn, delay) {
  let lastCall = 0;
  return (...args) => {
    const now = Date.now();
    if (now - lastCall >= delay) {
      lastCall = now;
      fn(...args);
    }
  };
}

// Trong component
const emitCursorMove = useMemo(
  () =>
    throttle((x, y) => {
      socket?.emit('cursor_move', { x, y });
    }, 50), // 20fps là đủ cho cursor
  [socket]
);

345933 Throttle cursor ở 50ms (20fps) thay vì mỗi pixel - giảm đáng kể số events gửi lên server

Xử lý duplicate events sau reconnect

Khi client reconnect, có thể server đã emit event trong lúc client offline. Cách đơn giản nhất là dùng sequence ID:

// Server: gắn sequence ID cho mỗi message
let seq = 0;
function emitWithSeq(socket, event, data) {
  socket.emit(event, { ...data, seq: ++seq });
}

// Client: bỏ qua message đã nhận
const lastSeq = useRef(0);
socket.on('game_state', ({ seq, ...data }) => {
  if (seq <= lastSeq.current) return; // Đã xử lý rồi, bỏ qua
  lastSeq.current = seq;
  // Xử lý data
});

Batching updates

Thay vì emit từng thay đổi nhỏ, gom lại và gửi theo batch:

// Server: gom updates trong 100ms rồi flush
const pendingUpdates = new Map();

function queueUpdate(roomId, update) {
  if (!pendingUpdates.has(roomId)) {
    pendingUpdates.set(roomId, []);
    // Flush sau 100ms
    setTimeout(() => {
      const updates = pendingUpdates.get(roomId);
      io.to(roomId).emit('batch_update', updates);
      pendingUpdates.delete(roomId);
    }, 100);
  }
  pendingUpdates.get(roomId).push(update);
}

Kỹ thuật này đặc biệt hiệu quả cho live dashboard - thay vì emit mỗi metric thay đổi, batch lại 200-500ms rồi gửi một lần.


Scaling lên 100k+ users với Socket.IO

Một instance Node.js với Socket.IO có thể handle khoảng 10,000-50,000 concurrent WebSocket connections per Node.js Socket.IO instance (2023-2024 benchmark), với điều kiện tối ưu hóa OS (ulimit, ephemeral port range) và tải nhẹ; mức an toàn thường là 10,000-30,000 connections (Socket.IO Official Documentation & Community Benchmarks 2023-2024) concurrent connections tùy hardware và workload. Lên 100k+ bạn cần scale horizontal.

Vấn đề của nhiều instance

Socket.IO là stateful - mỗi kết nối gắn với một instance cụ thể. Khi bạn chạy 3 instance Node.js phía sau load balancer, user A kết nối instance 1, user B kết nối instance 2. Nếu server emit broadcast, chỉ users trên cùng instance mới nhận được.

Sticky sessions: load balancer phải đảm bảo mỗi client luôn route về cùng một instance. Với nginx:

upstream socketio_nodes {
  ip_hash; # Sticky sessions theo IP
  server node1:4000;
  server node2:4000;
  server node3:4000;
}

Redis Adapter

Sticky sessions chưa đủ - bạn vẫn cần broadcast cross-instance. Redis Adapter giải quyết điều này:

npm install @socket.io/redis-adapter ioredis
// server.js
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('ioredis');

const pubClient = createClient({ host: 'redis', port: 6379 });
const subClient = pubClient.duplicate();

Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  // Mọi instance dùng chung Redis để pub/sub
  io.adapter(createAdapter(pubClient, subClient));
  httpServer.listen(4000);
});

Với Redis Adapter, khi instance 1 emit tới room game-123, Redis pub/sub sẽ forward message đó tới tất cả instance khác đang có users trong room đó.

345934 Redis adapter làm cầu nối giữa các Node.js instances - broadcast giờ hoạt động đúng kể cả khi scale

Chiến lược giảm tải

Scale không chỉ là thêm instance. Một số kỹ thuật giảm fan-out:

  • Room partitioning: đừng có room với 100k users nhận cùng event. Chia nhỏ thành rooms 100-1000 users.
  • Load shedding: khi server quá tải, giảm tần suất update (từ 100ms xuống 500ms) thay vì drop connection.
  • Backpressure: nếu client xử lý chậm (slow consumer), giới hạn tốc độ emit cho client đó thay vì để queue phình to.
// Kiểm tra buffer của socket trước khi emit
function safeEmit(socket, event, data) {
  // Nếu buffer đang đầy, bỏ qua update này
  if (socket.conn.transport.writable && 
      socket.conn.bufferedAmount < 1024 * 64) { // 64KB threshold
    socket.emit(event, data);
  }
}

Một kiến trúc quan trọng cần nhớ: với quy mô lớn, kiến trúc và fan-out strategy quan trọng không kém code ứng dụng. Room quá lớn + broadcast quá thường = bottleneck cho dù server có mạnh đến đâu.


Performance tips và common pitfalls

Tips thực chiến

Gửi delta, không gửi full state. Đây là nguyên tắc quan trọng nhất. Thay vì emit toàn bộ game state mỗi frame:

// Xấu: gửi toàn bộ state
io.to(roomId).emit('game_state', entireGameState); // ~10KB mỗi 100ms

// Tốt: chỉ gửi phần thay đổi
io.to(roomId).emit('game_delta', {
  playerId: 'abc',
  x: 120, // Chỉ position mới
  y: 340,
});

Tách namespaces theo chức năng. Mixin tất cả vào default namespace là cách nhanh nhất để tạo chaos:

const presence = io.of('/presence'); // Online users, typing...
const gameplay = io.of('/game');     // Game events
const dashboard = io.of('/admin');   // Metrics, monitoring

Live dashboard: update theo chu kỳ 200-1000ms thay vì mỗi metric thay đổi. Người dùng không đọc được số thay đổi nhanh hơn 500ms.

345935 Namespace riêng cho từng chức năng giúp debug dễ hơn và giảm noise event

Common pitfalls

1. Quên cleanup listener trong React

// Nguy hiểm: mỗi lần re-render thêm một listener mới
useEffect(() => {
  socket.on('message', handler); // KHÔNG có cleanup
});

// Đúng:
useEffect(() => {
  socket.on('message', handler);
  return () => socket.off('message', handler); // Cleanup
}, []);

2. Broadcast quá rộng

io.emit(event, data) gửi tới tất cả clients kết nối. Với 50k users, đây là 50k message đi ra cùng lúc. Luôn dùng io.to(roomId).emit() hoặc socket.to(userId).emit().

3. Nhầm Socket.IO với WebSocket chuẩn

Client WebSocket của browser không kết nối được với Socket.IO server. Nếu bạn muốn dùng WebSocket native:

// WebSocket native - KHÔNG dùng được với Socket.IO server
const ws = new WebSocket('ws://localhost:4000');

// Socket.IO client - dùng được với Socket.IO server
import { io } from 'socket.io-client';
const socket = io('http://localhost:4000');

4. Kỳ vọng connection state recovery bền vững

Socket.IO có tính năng connection state recovery, nhưng buffer này nằm trong memory server. Server restart = mất buffer. Đừng dùng nó như database.

5. Không dùng sticky sessions khi scale

Dùng nhiều instance mà không có sticky sessions + adapter: users thỉnh thoảng miss event hoặc không nhận được broadcast. Bug này cực khó reproduce vì chỉ xảy ra ở một tỷ lệ request nhất định.

Bạn đang build backend với Node.js lần đầu? Khóa Node & ExpressJS trên F8 cover phần nền tảng Node.js khá tốt trước khi bạn đi sâu vào Socket.IO.