Đ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

Socket.IO mất mạng rồi kết nối lại: xử lý event bị đệm và tránh thao tác lặp

Ếch Trendy
Ếch Trendy

5 giờ tới · 11 phút đọc

Vấn đề: Socket.IO reconnect và event bị gửi lại

Khi client mất mạng giữa chừng, Socket.IO không chỉ đơn giản "chờ" rồi tiếp tục. Thư viện sẽ cố tự kết nối lại (reconnect), và trong quá trình đó, các event mà client đã emit nhưng chưa được server nhận có thể bị gửi lại - hoặc ngược lại, bị mất hoàn toàn tùy cấu hình.

Vấn đề cụ thể có hai phía:

  • Event bị mất: client emit khi đang offline, server không nhận được.
  • Event bị lặp (duplicate): client emit lại sau khi reconnect, server xử lý hai lần.

Cả hai đều gây ra bug nghiêm trọng - đặc biệt với thao tác có tác dụng phụ như tạo đơn hàng, trừ điểm, hoặc ghi log.

348070 Một event emit khi offline có thể bị buffer hoặc bị mất tùy cấu hình client

Bài này tập trung vào Socket.IO v4 (client và server đều dùng socket.io / socket.io-client phiên bản 4.x). Hành vi có thể khác ở v2/v3 - kiểm tra tài liệu chính thức tương ứng nếu bạn dùng phiên bản cũ hơn.


Cơ chế offline buffer của Socket.IO v4

Socket.IO v4 có tính năng offline buffering phía client theo mặc định. Khi bạn gọi socket.emit(...) mà kết nối chưa sẵn sàng, event đó được đưa vào hàng đợi nội bộ. Ngay khi reconnect thành công, toàn bộ hàng đợi được flush - tức là server nhận tất cả các event đó cùng lúc.

Đây là hành vi được thiết kế có chủ ý để đảm bảo không mất event. Tài liệu chính thức mô tả rõ tại tài liệu offline behavior của Socket.IO v4.

// Ví dụ giả định minh họa - không chạy thật
const socket = io('http://localhost:3000');

// Gọi emit khi đang offline
socket.emit('place_order', { item: 'Book', qty: 1 });
// -> event bị đưa vào buffer, chưa tới server

// Khi reconnect thành công
// -> socket tự flush buffer, server nhận 'place_order'

Vấn đề xảy ra khi người dùng bấm nút nhiều lần trong lúc offline - hoặc khi code tự retry. Lúc reconnect, server nhận được 3-5 event place_order cho cùng một hành động của người dùng.

348071 Buffer flush sau reconnect: server nhận cùng lúc nhiều event nếu client emit nhiều lần khi offline

Tắt offline buffer nếu không cần

Nếu ứng dụng của bạn không cần behavior này, có thể tắt bằng option retries hoặc bỏ qua emit khi offline:

// Kiểm tra trạng thái trước khi emit
if (socket.connected) {
  socket.emit('place_order', payload);
} else {
  console.log('Offline - bỏ qua hoặc xử lý riêng');
}

Cách này đơn giản nhưng có đánh đổi: event bị mất hoàn toàn nếu mạng đứt. Phù hợp với các thao tác không quan trọng như gửi vị trí chuột real-time, nhưng không phù hợp với thao tác có trạng thái.


Idempotency key: cách đúng để tránh xử lý lặp

Idempotency (tính bất biến khi lặp) là nguyên tắc: thực thi cùng một thao tác nhiều lần phải cho kết quả giống như thực thi đúng một lần. Đây là giải pháp phù hợp nhất khi bạn không thể đảm bảo event chỉ đến server đúng một lần.

Cách triển khai cụ thể:

  1. Client tạo một ID duy nhất cho mỗi hành động của người dùng (không phải mỗi lần emit).
  2. Client gửi kèm ID đó trong payload của event.
  3. Server kiểm tra ID trước khi xử lý - nếu đã xử lý rồi thì bỏ qua.
// --- CLIENT (ví dụ giả định minh họa) ---
import { v4 as uuidv4 } from 'uuid';

function placeOrder(item, qty) {
  // ID được tạo một lần khi user bấm nút
  const idempotencyKey = uuidv4();

  socket.emit('place_order', {
    idempotencyKey,
    item,
    qty
  });
}

// Nếu socket tự retry hoặc buffer flush nhiều lần,
// cùng idempotencyKey được gửi đi -> server nhận dạng được
// --- SERVER (Node.js + Express, ví dụ giả định minh họa) ---
const processedKeys = new Set(); // Dùng Redis trong production

io.on('connection', (socket) => {
  socket.on('place_order', (data) => {
    const { idempotencyKey, item, qty } = data;

    if (processedKeys.has(idempotencyKey)) {
      // Đã xử lý rồi, bỏ qua
      socket.emit('order_ack', { status: 'already_processed', idempotencyKey });
      return;
    }

    // Xử lý đơn hàng
    processedKeys.add(idempotencyKey);
    // ... logic tạo đơn hàng ...
    socket.emit('order_ack', { status: 'created', idempotencyKey });
  });
});

348072 Server dùng Set hoặc Redis để kiểm tra key - xử lý một lần dù nhận nhiều lần

Lưu ý quan trọng: new Set() trong ví dụ trên chỉ phù hợp để minh họa. Trong production, dùng Redis hoặc database để lưu key vì:

  • Set bị reset khi server restart.
  • Nhiều server instance không chia sẻ Set.
  • Cần TTL (time-to-live) để tránh key tồn tại mãi mãi.

Key nên được tạo ở đâu?

Key phải được tạo một lần duy nhất cho mỗi intent của người dùng - không phải mỗi lần emit. Ví dụ: khi user bấm nút "Đặt hàng", tạo key và lưu vào biến. Nếu socket tự emit lại (do buffer), key vẫn là cũ.


Lỗi thường gặp khi xử lý reconnect

1. Tạo idempotency key trong hàm emit thay vì hàm xử lý intent

Đây là lỗi phổ biến nhất. Nếu key được tạo bên trong hàm emit, mỗi lần buffer flush sẽ tạo ra key mới - server không thể nhận dạng đó là cùng một thao tác.

// SAI - key mới mỗi lần emit
socket.emit('place_order', {
  idempotencyKey: uuidv4(), // tạo mới mỗi lần gọi emit
  ...
});

// ĐÚNG - key được tạo một lần khi user hành động
const key = uuidv4();
function handleOrderClick() {
  socket.emit('place_order', { idempotencyKey: key, ... });
}

2. Không xử lý acknowledgement

Socket.IO v4 có tính năng acknowledgement (ack): server có thể gửi callback confirm sau khi nhận event. Nếu client không dùng ack, không có cách nào biết server đã xử lý thành công chưa.

// Dùng ack để biết server đã nhận và xử lý
socket.emit('place_order', payload, (response) => {
  if (response.status === 'created') {
    // Thành công, không emit lại
  } else if (response.status === 'already_processed') {
    // Server đã xử lý rồi, OK
  }
});

3. Re-register event listener sau mỗi reconnect

Nếu bạn gọi socket.on('event', handler) bên trong handler của socket.on('connect', ...), mỗi lần reconnect sẽ thêm một listener mới. Kết quả: handler chạy 2, 3, 4... lần cho cùng một event.

// SAI - thêm listener mỗi lần connect
socket.on('connect', () => {
  socket.on('order_update', handleUpdate); // duplicate listener!
});

// ĐÚNG - register một lần bên ngoài
socket.on('order_update', handleUpdate);
socket.on('connect', () => {
  // chỉ xử lý UI reconnect, không re-register
});

348073 Listener đăng ký nhiều lần là nguyên nhân phổ biến của duplicate handler - khó debug hơn duplicate event

4. Nhầm lẫn giữa socket.connect() và io()

Gọi io() nhiều lần tạo nhiều socket instance riêng biệt, không phải reconnect. Mỗi instance có buffer riêng, event riêng. Chỉ tạo socket một lần và dùng socket.connect() / socket.disconnect() để điều khiển trạng thái.


Cấu hình reconnect và giới hạn cần biết

Socket.IO v4 cho phép tùy chỉnh hành vi reconnect qua options khi khởi tạo:

const socket = io('http://localhost:3000', {
  reconnection: true,          // bật/tắt auto reconnect (mặc định: true)
  reconnectionAttempts: 5,     // số lần thử tối đa (mặc định: Infinity)
  reconnectionDelay: 1000,     // delay lần đầu (ms)
  reconnectionDelayMax: 5000,  // delay tối đa (ms)
  randomizationFactor: 0.5,    // jitter để tránh thundering herd
});

Thundering herd là tình huống nhiều client cùng mất mạng và cùng reconnect đúng một lúc, gây quá tải server. randomizationFactor thêm jitter (độ ngẫu nhiên nhỏ) vào delay để dàn đều các lần reconnect.

348074 Jitter trong reconnect delay giúp tránh tình trạng ngàn client cùng kết nối lại đúng một thời điểm

Sự kiện liên quan đến reconnect

Socket.IO emit các sự kiện để bạn xử lý UI và logic tương ứng:

Sự kiện Khi nào Dùng để
disconnect Mất kết nối Hiện thông báo offline
connect_error Kết nối thất bại Log lỗi, hiện retry UI
reconnect_attempt Mỗi lần thử lại Hiện số lần đang thử
reconnect Reconnect thành công Refresh dữ liệu nếu cần
reconnect_failed Hết số lần thử Yêu cầu user reload

Giới hạn của offline buffer

Buffer phía client là bộ nhớ (in-memory). Nếu người dùng đóng tab, reload, hoặc thiết bị crash, buffer bị mất hoàn toàn. Socket.IO không có cơ chế persist buffer sang localStorage hay IndexedDB - đó là trách nhiệm của ứng dụng nếu cần.

Nếu ứng dụng cần đảm bảo event không bao giờ mất dù reload trang, cần kết hợp với một message queue hoặc gửi HTTP fallback song song với WebSocket.


Ví dụ tổng hợp: thao tác đặt hàng an toàn khi mất mạng

Dưới đây là ví dụ giả định minh họa kết hợp các kỹ thuật đã nêu. Đây là bài tập minh họa, không phải code production sẵn.

Điều kiện bài toán (giả định):

  • Client có thể mất mạng trong khi đặt hàng.
  • Server phải đảm bảo mỗi đơn hàng chỉ được tạo một lần.
  • UI cần phản hồi ngay cho người dùng.
// === CLIENT ===
import { io } from 'socket.io-client';
import { v4 as uuidv4 } from 'uuid';

const socket = io('http://localhost:3000', {
  reconnectionAttempts: 10,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 5000,
  randomizationFactor: 0.5,
});

// Register listener MỘT LẦN, bên ngoài handler connect
socket.on('order_ack', (response) => {
  if (response.status === 'created') {
    showSuccess('Đơn hàng đã được tạo!');
  } else if (response.status === 'already_processed') {
    showSuccess('Đơn hàng đã được ghi nhận trước đó.');
  }
});

socket.on('disconnect', () => {
  showBanner('Đang mất kết nối...');
});

socket.on('reconnect', () => {
  hideBanner();
});

// Hàm xử lý khi user bấm nút "Đặt hàng"
function handlePlaceOrder(item, qty) {
  // Key tạo MỘT LẦN tại đây
  const idempotencyKey = uuidv4();

  socket.emit(
    'place_order',
    { idempotencyKey, item, qty },
    (ack) => {
      // Ack nhận được nghĩa là server đã xử lý
      console.log('Server ack:', ack);
    }
  );
}
// === SERVER (Node.js) ===
import { createServer } from 'http';
import { Server } from 'socket.io';

const httpServer = createServer();
const io = new Server(httpServer);

// Trong production: dùng Redis với TTL thay cho Set
const processedKeys = new Set();

io.on('connection', (socket) => {
  socket.on('place_order', (data, callback) => {
    const { idempotencyKey, item, qty } = data;

    if (!idempotencyKey) {
      return callback({ status: 'error', message: 'Thiếu idempotencyKey' });
    }

    if (processedKeys.has(idempotencyKey)) {
      return callback({ status: 'already_processed', idempotencyKey });
    }

    // Xử lý đơn hàng (giả định)
    processedKeys.add(idempotencyKey);
    console.log(`Tạo đơn: ${item} x${qty}`);

    callback({ status: 'created', idempotencyKey });
  });
});

httpServer.listen(3000);

Kết quả mong đợi (giả định):

  • User bấm "Đặt hàng" khi offline -> event vào buffer.
  • Reconnect -> server nhận event, kiểm tra key, tạo đơn, trả về created.
  • Nếu buffer flush 3 lần -> lần 2 và 3 trả về already_processed, không tạo thêm đơn.

348075 Toàn bộ flow: từ offline → buffer → reconnect → server dedup → ack về client

Cách tự kiểm tra (thủ công):

  1. Chạy server: node server.js
  2. Mở client, bấm nút đặt hàng.
  3. Dùng DevTools -> Network -> offline mode.
  4. Bấm nút đặt hàng thêm 2-3 lần.
  5. Chuyển lại online.
  6. Quan sát console server: chỉ nên thấy một dòng "Tạo đơn".

Nếu muốn nắm vững nền tảng JavaScript trước khi đi vào Socket.IO, khóa Lập Trình JavaScript Cơ Bản trên F8 cover các khái niệm như async, callback và event loop cần thiết để hiểu phần này.

Trang web dùng cookie để hoạt động và đo hiệu quả quảng cáo. Xem chính sách bảo mật