Socket.IO 从入门到原理:一份新手也能看懂的完整笔记


1.Socket.IO 是什么?

Socket.IO 是一个基于 WebSocket 的实时通信库 ,由 socket.io(服务端)和 socket.io-client(客户端)两部分组成。

跟原生 WebSocket 的区别

特性 原生 WebSocket Socket.IO
传输方式 只有 WebSocket polling + WebSocket,自动降级
断线重连 需自己实现 内置自动重连
心跳检测 需自己实现 内置 ping/pong
事件系统 需自己封装 内置 emit/on 事件模型
ack 确认 需自己实现 内置 ack + timeout
房间/命名空间 没有 内置
协议 标准 WebSocket 自定义协议(EIO=4)

一句话理解Socket.IO 不是 WebSocket 的替代品,而是在 WebSocket 之上封装了一层协议和应用层 API,解决了原生 WebSocket 的各种工程痛点。

最小示例:

服务端代码:

javascript 复制代码
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: "*",
    methods: ["GET", "POST"]
  }
});

io.on("connection", (socket) => {
  console.log("客户端连接,id =", socket.id);

  socket.on("disconnect", (reason) => {
    console.log("客户端断开,id =", socket.id, "reason =", reason);
  });
});

httpServer.listen(3000, () => {
  console.log("server open");
});

客户端代码:

javascript 复制代码
import { io } from "socket.io-client";

const socket = io("http://localhost:3000", {
  transports: ["websocket", "polling"]
});

socket.on("connect", () => {
  console.log("socket connected, id =", socket.id);
});

socket.on("connect_error", (err) => {
  console.error("socket connect_error:", err.message);
});

socket.on("disconnect", (reason) => {
  console.log("socket disconnected, reason =", reason);
});

2.传输方式(transports)与连接生命周期

transports 属性的作用

Socket.IO 不是纯 WebSocket,它是一个在 WebSocket 之上封装的协议,为了兼容性,底层支持两种传输方式:

  • polling(长轮询):客户端反复发 HTTP 请求去问服务端「有没有新消息」,是默认的兜底传输。优点是几乎所有网络环境(包括被代理/防火墙拦截 WebSocket 的场景)都能用;缺点是延迟高、开销大,每次请求都要带 cookie、走完整 HTTP 握手
  • websocket:真正的双向长连接,延迟低、开销小,但可能被某些企业防火墙/代理拦截

transports 就是告诉客户端「按什么顺序尝试这两种传输」,默认值是 ["polling", "websocket"]------先轮询,握手成功后再升级到 websocket。

客户端代码:

javascript 复制代码
// 显式指定传输顺序:先 websocket,失败再退回到 polling
const socket = io("http://localhost:3000", {
  transports: ["websocket", "polling"]
});

// 只用 websocket,不允许降级(网络不支持 websocket 时直接连不上)
const socket = io("http://localhost:3000", {
  transports: ["websocket"]
});

两种顺序的取舍

顺序 行为 适用场景
["polling", "websocket"](默认) 先轮询连上,再尝试升级 websocket 兼容性最好,几乎能连上所有环境,但首屏有一次轮询开销
["websocket", "polling"] 先直连 websocket,失败再退回轮询 确定环境支持 websocket 时延迟更低,失败有一次重试开销
["websocket"] 只走 websocket,不降级 对实时性要求极高且确定 websocket 可用(如内网、可控网络)

注意 :服务端也有对应的 transports 配置,且必须包含客户端使用的传输方式 ,否则会连接失败。比如客户端只传 ["websocket"],服务端必须允许 websocket(默认允许)。

连接生命周期事件

客户端 socket 实例有三个核心事件,分别对应连接成功、连接失败、连接断开:

客户端代码:

javascript 复制代码
const socket = io("http://localhost:3000", {
  transports: ["websocket", "polling"]
});

// 连接成功:拿到服务端分配的 socket.id
socket.on("connect", () => {
  console.log("socket connected, id =", socket.id);
});

// 连接失败:握手被拒、网络不通、CORS 等都会触发
socket.on("connect_error", (err) => {
  console.error("socket connect_error:", err.message);
});

// 连接断开:reason 告诉是为什么断的
socket.on("disconnect", (reason) => {
  console.log("socket disconnected, reason =", reason);
});

disconnect 的 reason 常见值

reason 含义
io server disconnect 服务端主动调用 socket.disconnect() 断开
io client disconnect 客户端主动调用 socket.disconnect() 断开
transport close 底层传输连接关闭(网络断开、刷新页面、服务端重启等),最常见
transport error 传输层出错(如 websocket 连接被中间件中断)
ping timeout 客户端在心跳超时时间内没收到服务端的 ping 响应,认为连接已死

connect 与 reconnect 的区别

  • connect:每次建立新连接(包括重连)都会触发
  • reconnect / reconnect_attempt:只有在「断线后自动重连」时才会触发,首次连接不会
  • 重连成功后会拿到一个新的 socket.id,所以业务上不能依赖 socket.id 作为用户的永久标识,需要自己维护 userId ↔ socketId 的映射(见后面 offset 模式的思路)

服务端的断开连接事件

服务端也能监听断开,但事件挂在 connection 回调里的 socket 对象 上,而不是 io 上:

服务端代码:

javascript 复制代码
io.on("connection", (socket) => {
  console.log("客户端连接,id =", socket.id);

  // 断开连接事件挂在 socket 上,不是 io 上
  socket.on("disconnect", (reason) => {
    console.log("客户端断开,id =", socket.id, "reason =", reason);
  });
});

为什么是 socket.on 而不是 io.on

  • io 是整个服务端实例,只负责「全局级别」的事件,目前只有 connection(有新客户端连进来)这一个核心事件
  • socket单个客户端连接 的实例,断开是针对某一个具体连接的,所以必须挂在 socket
  • io.on("disconnect") 不会触发,因为 io 本身不会断开,只有具体的 socket 会断开

服务端 disconnect 的 reason 与客户端略有不同

reason 含义
transport close 客户端网络断开、关闭页面、主动 socket.disconnect() 等,最常见
transport error 传输层错误
ping timeout 客户端心跳超时未响应
io server disconnect 服务端自己调用 socket.disconnect() 断开

注意:服务端没有 io client disconnect 这个 reason,那是客户端侧才有的。

补充:服务端还有个 disconnecting 事件

如果想在客户端还没完全断开、仍能拿到它所在的房间 时做清理(比如从房间列表移除),可以用 disconnecting

服务端代码:

javascript 复制代码
socket.on("disconnecting", (reason) => {
  // 此时 socket.rooms 里还有这个客户端加入的所有房间
  console.log("即将断开,所在房间:", socket.rooms);
});

disconnecting 触发时连接还在,socket.rooms 可用;disconnect 触发时连接已关闭,socket.rooms 已清空。


3.Socket.IO 的完整请求路径

Socket.IO 的请求路径由 path + query 参数 组成,分为 polling 握手和 WebSocket 升级两个阶段。

默认 path(/socket.io/):

javascript 复制代码
// 客户端代码
const socket = io("http://localhost:3000");

握手阶段(polling):

bash 复制代码
GET http://localhost:3000/socket.io/?EIO=4&transport=polling&t=abc123

WebSocket 升级:

bash 复制代码
ws://localhost:3000/socket.io/?EIO=4&transport=websocket&sid=xxx&t=abc123

自定义 path(/ws/):

javascript 复制代码
// 客户端代码
const socket = io("http://localhost:3000", {
  path: "/ws/"
});

握手阶段(polling):

bash 复制代码
GET http://localhost:3000/ws/?EIO=4&transport=polling&t=abc123

WebSocket 升级:

bash 复制代码
ws://localhost:3000/ws/?EIO=4&transport=websocket&sid=xxx&t=abc123

路径结构拆解

ini 复制代码
http://localhost:3000/socket.io/?EIO=4&transport=polling&t=abc123
└─────────┬──────────┘└────┬────┘└───────────────┬───────────────┘
     host + path           │                  query 参数
                    固定前缀                EIO=协议版本
                                          transport=传输方式
                                          t=时间戳(防缓存)
                                          sid=会话ID(握手后才有)
部分 默认值 自定义后 说明
path /socket.io/ /ws/ 由客户端 path 选项控制
EIO 4 4 Socket.IO 协议版本(v4),固定值
transport polling / websocket 先 polling 握手,再升级 websocket
t 随机字符串 时间戳,防止浏览器/CDN 缓存
sid 握手后返回 服务端分配的会话 ID,握手阶段没有

实际请求流程

ini 复制代码
1. 客户端发 polling 请求(无 sid)
   GET /socket.io/?EIO=4&transport=polling&t=123

2. 服务端返回 sid
   → sid=abc456

3. 客户端用 sid 发 WebSocket 升级请求
   ws://host/socket.io/?EIO=4&transport=websocket&sid=abc456&t=124

4. 升级成功后,后续通信用 WebSocket 帧
   (不再走 HTTP polling)

与 Nginx 配置的关系 :Nginx 的 location /socket.io/ 匹配的就是上面请求路径的前缀。如果客户端改了 path: "/ws/",所有请求路径前缀变成 /ws/,Nginx 就必须配 location /ws/ 才能匹配到,否则走默认 HTTP 代理,WebSocket 握手失败。


4.监听事件(Listening to Events)

Socket.IO 的 Socket 实例(服务端和客户端都一样)提供了一套 EventEmitter 风格的 API 来监听事件。服务端 Socket 继承自 Node.js 的 EventEmitter,客户端 Socket 使用 component-emitter 库,API 基本一致。

EventEmitter 方法

socket.on(eventName, listener)

将监听器添加到指定事件的监听器数组末尾,每次该事件触发都会执行。

客户端 / 服务端通用:

javascript 复制代码
socket.on("details", (...args) => {
  // ...
});

socket.once(eventName, listener)

添加一次性监听器,事件触发一次后自动移除,后续不再执行。

javascript 复制代码
socket.once("details", (...args) => {
  // 只执行一次
});

socket.off(eventName, listener)

移除指定事件的某个监听器。注意:必须传入同一个函数引用 才能移除,匿名函数无法被 off

javascript 复制代码
const listener = (...args) => {
  console.log(args);
};

socket.on("details", listener);

// 之后移除
socket.off("details", listener);

socket.removeAllListeners(eventName)

移除所有监听器,或指定事件的所有监听器。

javascript 复制代码
// 移除指定事件的所有监听器
socket.removeAllListeners("details");

// 移除所有事件的监听器
socket.removeAllListeners();

Catch-all 监听器

Socket.IO v3 开始,支持「捕获所有事件」的监听器,客户端和服务端都可用。适合做日志、调试、统一拦截等场景。

socket.onAny(listener)

添加一个监听器,任何事件触发时都会执行。回调第一个参数是事件名,后面是事件参数。

javascript 复制代码
socket.onAny((eventName, ...args) => {
  console.log("收到事件:", eventName, args);
});

socket.prependAny(listener)

onAny 类似,但监听器被添加到数组开头 ,会比普通 onAny 先执行。

javascript 复制代码
socket.prependAny((eventName, ...args) => {
  // 在所有 onAny 监听器之前执行
});

socket.offAny(listener)

移除所有 catch-all 监听器,或指定的那个。

javascript 复制代码
const listener = (eventName, ...args) => {
  console.log(eventName, args);
};

socket.onAny(listener);

// 移除指定的
socket.offAny(listener);

// 移除所有
socket.offAny();

参数验证

Socket.IO 不内置 事件参数验证,需要用第三方库:joi / ajv / validatorjs

服务端代码(joi + ack 示例):

javascript 复制代码
const Joi = require("joi");

const userSchema = Joi.object({
  username: Joi.string().max(30).required(),
  email: Joi.string().email().required()
});

io.on("connection", (socket) => {
  socket.on("create user", (payload, callback) => {
    // 判断是否带 ack 回调,不带就断开(防止恶意请求)
    if (typeof callback !== "function") {
      return socket.disconnect();
    }

    const { error, value } = userSchema.validate(payload);
    if (error) {
      return callback({ status: "KO", error });
    }

    // 校验通过,处理业务
    callback({ status: "OK" });
  });
});

错误处理

Socket.IO 没有内置错误处理 ,监听器里抛出的异常需要自己捕获。特别是 async 监听器里的 rejected Promise 不会被自动处理。

服务端代码(try/catch):

javascript 复制代码
io.on("connection", (socket) => {
  socket.on("list items", async (callback) => {
    try {
      const items = await findItems();
      callback({ status: "OK", items });
    } catch (e) {
      callback({ status: "NOK" });
    }
  });
});

另一种方式是用 Node.js 的 EventEmitter.captureRejections(实验性):

javascript 复制代码
require("events").captureRejections = true;

io.on("connection", (socket) => {
  socket.on("list products", async () => {
    const products = await findProducts();
    socket.emit("products", products);
  });

  socket[Symbol.for("nodejs.rejection")] = (err) => {
    socket.emit("error", err);
  };
});

注意captureRejections 是 Node.js 实验性 API,生产环境慎用,推荐还是用 try/catch。


5.广播事件(Broadcasting Events)

广播是 Socket.IO仅服务端功能,用于向多个客户端同时发送事件。客户端没有广播能力。

给所有连接的客户端

服务端代码:

javascript 复制代码
// 给所有已连接的客户端发送事件
io.emit("hello", "world");

理解

  • io.emit 会向当前服务器上所有已连接的 socket 发送事件
  • 断开连接或正在重连的客户端收不到,服务端不会为它们缓存
  • 如果需要断线客户端重连后也能收到,必须自己把事件存到数据库,重连时补发(见后面 offset 模式)

补充示例------广播 + ack 的区别

javascript 复制代码
// 广播不支持 ack(没有回调参数)
io.emit("notification", { msg: "系统升级" });

// 如果要给每个客户端单独 ack,需要遍历
io.sockets.sockets.forEach((socket) => {
  socket.timeout(5000).emit("notification", { msg: "系统升级" }, (err, res) => {
    if (err) {
      console.log("客户端未应答:", socket.id);
    }
  });
});

除发送者外的所有连接的客户端

服务端代码:

javascript 复制代码
io.on("connection", (socket) => {
  // 给除自己以外的所有客户端广播
  socket.broadcast.emit("hello", "world");
});

理解

  • socket.broadcast.emit = 广播给所有人,但不包括当前 socket 自己
  • 这在聊天室场景很常用:用户发消息时,其他人都收到,但自己不需要收到(自己已经在本地显示了)

补充示例------聊天室广播

javascript 复制代码
io.on("connection", (socket) => {
  socket.on("chat message", (msg) => {
    socket.broadcast.emit("chat message", {
      user: socket.id,
      msg: msg
    });
  });
});

发送方式的完整对照

方式 范围 发送者是否收到
io.emit("event", data) 所有已连接客户端
socket.broadcast.emit("event", data) 除发送者外的所有客户端
socket.emit("event", data) 仅当前客户端
io.local.emit("event", data) 仅当前服务器实例上的客户端
io.to(room).emit("event", data) 指定房间内的客户端 看是否在房间里

多服务器部署(Redis 适配器)

广播默认只在单个服务器实例内传播。多实例部署需要 Redis 适配器让广播跨实例传播。

服务端代码(Redis 适配器):

javascript 复制代码
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 的广播通过 Redis 在多实例间传播
io.adapter(createAdapter(pubClient, subClient));

local 标志

只想广播到当前服务器实例上的客户端,不通过适配器传播到其他实例:

javascript 复制代码
// 只发给当前实例上的客户端,不跨实例
io.local.emit("hello", "world");

6.房间(Rooms)

房间是 Socket.IO仅服务端概念,用于将 socket 分组,实现向特定子集客户端广播事件。客户端无权访问自己加入了哪些房间。

加入和离开

服务端代码:

javascript 复制代码
io.on("connection", (socket) => {
  // 加入房间
  socket.join("some room");

  // 离开房间
  socket.leave("some room");
});

向房间广播:

javascript 复制代码
// 给房间内所有客户端发(包括发送者,如果它在房间里)
io.to("some room").emit("some event");

// to 和 in 是等价的
io.in("some room").emit("some event");

// 给房间内除发送者外的客户端发
socket.to("some room").emit("some event");

多房间联合广播:

javascript 复制代码
// 同时给多个房间发,执行的是联合(Union)操作
// 即使 socket 同时在 room1 和 room2 中,也只收到一次事件
io.to("room1").to("room2").to("room3").emit("some event");

补充示例------聊天室场景:

javascript 复制代码
io.on("connection", (socket) => {
  socket.on("join room", (roomName) => {
    socket.join(roomName);
    socket.to(roomName).emit("user joined", socket.id);
  });

  socket.on("chat message", ({ room, msg }) => {
    socket.to(room).emit("chat message", {
      user: socket.id,
      msg: msg
    });
  });
});

默认房间

每个 socket 连接后都会自动加入一个以自己 socket.id 命名的房间。利用这个特性实现私聊:

javascript 复制代码
io.on("connection", (socket) => {
  socket.on("private message", (anotherSocketId, msg) => {
    // 发给指定 socket.id 对应的默认房间 = 私聊
    socket.to(anotherSocketId).emit("private message", socket.id, msg);
  });
});

示例用例

多设备同步------同一用户的多个标签页/设备:

javascript 复制代码
io.on("connection", async (socket) => {
  const userId = await computeUserIdFromHeaders(socket);
  socket.join(userId);

  // 给该用户所有设备推送
  io.to(userId).emit("hi");
});

实体通知------项目更新通知:

javascript 复制代码
io.on("connection", async (socket) => {
  const projects = await fetchProjects(socket);
  projects.forEach(project => socket.join("project:" + project.id));

  // 项目更新时通知所有订阅者
  io.to("project:4321").emit("project updated");
});

断开连接时的房间清理

断开后 Socket.IO自动 让 socket 离开所有房间。disconnecting 事件触发时 socket.rooms 仍可用:

服务端代码:

javascript 复制代码
io.on("connection", (socket) => {
  socket.on("disconnecting", () => {
    // 此时 socket.rooms 还有数据,至少包含自己的 socket.id
    console.log("所在房间:", socket.rooms);
  });

  socket.on("disconnect", () => {
    // 此时 socket.rooms.size === 0,已清空
  });
});

实现细节(适配器内部数据结构)

房间功能由**适配器(Adapter)**实现,内部维护两个 ES6 Map:

  • sidsMap<SocketId, Set<Room>> --- 每个 socket 加入了哪些房间
  • roomsMap<Room, Set<SocketId>> --- 每个房间有哪些 socket

广播时的查找逻辑:

  • io.emit()(广播所有):遍历 sids Map,给每个 socket 发
  • io.to("room").emit()(广播到房间):遍历 rooms Map 中该房间的 Set,给里面的每个 socket 发

房间事件

socket.io@3.1.0 开始,适配器会发出以下事件:

服务端代码:

javascript 复制代码
io.of("/").adapter.on("create-room", (room) => {
  console.log(`房间 ${room} 被创建`);
});

io.of("/").adapter.on("join-room", (room, id) => {
  console.log(`socket ${id} 加入了房间 ${room}`);
});

io.of("/").adapter.on("leave-room", (room, id) => {
  console.log(`socket ${id} 离开了房间 ${room}`);
});

io.of("/").adapter.on("delete-room", (room) => {
  console.log(`房间 ${room} 被删除`);
});

7.命名空间(Namespaces)

命名空间是一种通信通道,允许在单个共享连接上拆分应用逻辑(多路复用)。每个命名空间有独立的事件处理、房间、中间件,互不干扰。

io.of("/orders") 就是创建或获取名为 /orders 的命名空间。同一服务器里可以划出多个独立频道,各自独立运作,但共用同一条 WebSocket 连接。

类比:命名空间就像大楼里的不同楼层------订单部和用户部,每个部门的员工(socket)只能跟本部门的人通信。

服务端代码:

javascript 复制代码
// "/orders" 命名空间,处理订单相关事件
io.of("/orders").on("connection", (socket) => {
  socket.on("order:list", () => {});
  socket.on("order:create", () => {});
});

// "/users" 命名空间,处理用户相关事件
io.of("/users").on("connection", (socket) => {
  socket.on("user:list", () => {});
});

各命名空间的独立性

  • 事件处理 :互不干扰,/ordersorder:list 不会触发 /users 的监听器
  • 房间 :同名房间互不影响,/orders 的 room1 和 /users 的 room1 是完全不同的两个房间
  • 中间件:各自鉴权

主命名空间

默认的 io 实例就是主命名空间 / 的快捷方式:

javascript 复制代码
// 以下三种写法完全等价
io.on("connection", (socket) => {});
io.of("/").on("connection", (socket) => {});
io.sockets.on("connection", (socket) => {}); // io.sockets === io.of("/")

客户端初始化:

javascript 复制代码
// 客户端代码
const socket = io();             // 连主命名空间 "/"
const orderSocket = io("/orders"); // 连 "/orders" 命名空间
const userSocket = io("/users");   // 连 "/users" 命名空间
// 只建立一条 WebSocket 连接,数据包自动路由到正确的命名空间

常见用例

  • 权限隔离:只有授权用户才能访问 /admin 命名空间
  • 多租户:动态为每个租户创建命名空间

动态命名空间

javascript 复制代码
// 正则匹配
io.of(/^\/dynamic-\d+$/).on("connection", (socket) => {
  const namespace = socket.nsp;
});

命名空间 vs 房间

特性 命名空间 房间
创建方式 服务端 io.of("/name") 服务端 socket.join("room")
客户端选择 连接时指定 io("/orders") 客户端不能直接选房间
隔离级别 事件、房间、中间件全部隔离 只有事件隔离
典型场景 权限隔离、多租户 聊天室、私聊、分组广播

8.中间件(Middlewares)

中间件函数在每个新连接建立之前执行,用于拦截、鉴权、日志、限流等。机制类似 Express 中间件,但只执行一次(每个连接),不是每个请求都执行。

服务端代码:

javascript 复制代码
io.use((socket, next) => {
  if (isValid(socket.request)) {
    next(); // 放行
  } else {
    next(new Error("invalid")); // 拒绝连接
  }
});

可以注册多个中间件,按顺序执行

javascript 复制代码
io.use((socket, next) => {
  console.log("中间件1");
  next();
});

io.use((socket, next) => {
  console.log("中间件2");
  next(new Error("thou shall not pass"));
});

io.use((socket, next) => {
  // 不会执行,上一个中间件返回了错误
  console.log("中间件3");
  next();
});

注意 :任何情况下都必须调用 next(),否则连接会一直挂起直到超时。

执行时机 :中间件执行时 socket 尚未正式连接connection 事件还没触发,disconnect 事件也不会触发(如果连接在中间件阶段失败)。

发送凭据

客户端通过 auth 选项在握手时发送凭据:

javascript 复制代码
// 客户端代码
const socket = io({
  auth: {
    token: "abc"
  }
});

// 或用函数(适合需要异步获取 token 的场景)
const socket = io({
  auth: (cb) => {
    cb({ token: getTokenFromStorage() });
  }
});

服务端在中间件中读取:

javascript 复制代码
io.use((socket, next) => {
  const token = socket.handshake.auth.token;
  if (!token) {
    return next(new Error("未提供 token"));
  }
  next();
});

处理中间件错误

next(err) 传了 Error,连接被拒绝,客户端收到 connect_error 事件:

javascript 复制代码
// 服务端代码
io.use((socket, next) => {
  const err = new Error("not authorized");
  err.data = { content: "Please retry later" }; // 附加额外信息
  next(err);
});

// 客户端代码
socket.on("connect_error", (err) => {
  console.log(err.message); // "not authorized"
  console.log(err.data);    // { content: "Please retry later" }
});

与 Express 中间件的兼容性

大多数 Express 中间件可以复用,只需要一个包装函数适配签名:

javascript 复制代码
// 包装函数
const wrap = middleware => (socket, next) => middleware(socket.request, {}, next);

// express-session + Passport 完整鉴权示例
io.use(wrap(session({ secret: "cats" })));
io.use(wrap(passport.initialize()));
io.use(wrap(passport.session()));

io.use((socket, next) => {
  if (socket.request.user) {
    next();
  } else {
    next(new Error("unauthorized"));
  }
});
特性 Express 中间件 Socket.IO 中间件
参数 (req, res, next) (socket, next)
执行频率 每个 HTTP 请求 每个连接(一次)
拒绝方式 res.status(401).send() next(new Error())

命名空间级别的中间件

javascript 复制代码
const adminNamespace = io.of("/admin");
adminNamespace.use((socket, next) => {
  if (socket.handshake.auth.role === "admin") {
    next();
  } else {
    next(new Error("需要 admin 权限"));
  }
});

9.Nginx 反向代理 WebSocket 的配置

为什么需要 /socket.io//ws/ 两个 location

  • /socket.io/Socket.IO默认路径,握手和通信都走这个路径
  • /ws/:可能是另一个原生 WebSocket 服务的路径,也可能是 Socket.IO 配置了自定义 path 后的路径

如果只配了一个,另一个路径的请求会走 Nginx 默认的普通 HTTP 代理,缺少 WebSocket 升级所需的头部,握手失败。

逐行分析配置

nginx 复制代码
location /socket.io/ {
    # 1. 代理到上游服务
    proxy_pass http://claim-backend;

    # 2. WebSocket 必须用 HTTP/1.1(默认 1.0 不支持 Upgrade)
    proxy_http_version 1.1;

    # 3. 把客户端的 Upgrade 头传给后端(websocket)
    proxy_set_header Upgrade $http_upgrade;

    # 4. 告诉后端「这是升级请求」,不是普通 HTTP
    proxy_set_header Connection "upgrade";

    # 5. 保留原始 Host 头
    proxy_set_header Host $host;

    # 6. 保留客户端真实 IP
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;

    # 7. WebSocket 是长连接,不能让 Nginx 默认 60s 超时断开
    proxy_read_timeout 86400s;  # 24 小时
    proxy_send_timeout 86400s;
}

为什么每行都不可少

配置项 不加的后果
proxy_http_version 1.1 用 HTTP/1.0,不支持 Upgrade,握手失败
Upgrade $http_upgrade 后端收不到升级请求,不知道客户端要 WebSocket
Connection "upgrade" Nginx 当普通 HTTP 处理,连接用完即关
proxy_read_timeout 86400s 空闲 60 秒被 Nginx 断开,WebSocket 频繁掉线
X-Real-IP / X-Forwarded-For 后端只能看到 Nginx IP,拿不到客户端真实 IP
Host $host 后端无法区分虚拟主机

一句话总结:WebSocket 握手是一个 HTTP 升级请求,Nginx 默认按普通 HTTP 代理处理,必须显式配置 1.1 + Upgrade + Connection + 长超时,WebSocket 才能正常建立和保持。


进阶:消息送达保证

前面学习了 Socket.IO 的基本用法,现在进入原理层面。Socket.IO 默认的消息送达保证是「最多一次」(at-most-once),这在实时聊天、通知推送等场景足够用,但如果你需要更强的送达保证(比如订单、支付),就需要自己实现。

什么是送达保证(QoS 等级)

在分布式通信中,消息送达有三个保证等级(类似 MQTT 的 QoS):

等级 含义 可能结果 适用场景
at-most-once(最多一次) 发出去就不管了 可能丢,不会重复 实时位置、心跳、通知推送
at-least-once(至少一次) 发了要确认,没确认就重发 不会丢,可能重复 订单、支付、重要消息
exactly-once(恰好一次) 发了确认 + 去重 不丢不重 最难实现,通常用 at-least-once + 幂等代替

Socket.IO 默认就是 at-most-once,下面讲解如何实现 at-least-once 和断线续传。


10.最多一次(at-most-once):Socket.IO 的默认行为

Socket.IO 默认的 socket.emitat-most-once------发出去了就算完成,不保证到达。

原因Socket.IO 在网络层使用 TCP,TCP 本身保证有序到达,但 Socket.IO 的 emit 发出去后:

  • 如果事件在网络传输中丢了(TCP 层面不太可能,但应用层面 socket 断开就丢了)
  • 如果服务端收到了但还没处理完就崩了
  • 如果 ack 回了但客户端没收到

客户端都不知道,因为 Socket.IO 不会自动重发

没有传送带缓冲

服务端给已断开的客户端发 io.to(sid).emit("xxx"),事件直接丢弃,不存任何地方。客户端重连后也收不到这些事件。

刷新浏览器 = 全丢

emit 的事件、未收到 ack 的重试闭包、socket.io 客户端的内部 buffer 数组,全部存在当前页面的 JS 内存里。刷新浏览器 = 整个 JS 运行时被销毁,所有变量、闭包、buffer 全部清零。socket.io 的客户端 buffer 是纯内存的,没有任何 localStorage / IndexedDB / sessionStorage 兜底。

如果要扛住刷新,得在 emit 之前先把事件写进 localStorage / IndexedDB,收到 ack 后再删除,类似发件箱模式(outbox pattern);或者直接走 HTTP 接口提交,socket 只用来推。


11.从客户端到服务端:至少一次(at-least-once)

要实现 at-least-once,需要客户端在超时后重发 ,服务端做幂等去重

客户端代码(递归重试 + ack):

javascript 复制代码
function sendEventWithRetry(data, retries = 3) {
  const messageId = generateUniqueId(); // 客户端生成唯一 ID

  function attempt(remaining) {
    if (remaining <= 0) {
      console.log("重试次数用完,放弃");
      return;
    }

    socket.timeout(2000).emit("foo", { id: messageId, data }, (err, res) => {
      if (err) {
        // 2 秒内没收到 ack,重试
        attempt(remaining - 1);
      } else {
        // 收到 ack,成功
        console.log("服务端确认收到", res);
      }
    });
  }

  attempt(retries);
}

服务端代码(幂等处理 + ack):

javascript 复制代码
const processedIds = new Set();

socket.on("foo", (payload, callback) => {
  // 幂等去重:用 messageId 判断是否处理过
  if (processedIds.has(payload.id)) {
    return callback("already processed"); // 已处理过,直接回 ack
  }

  processedIds.add(payload.id);
  // 处理业务...
  callback("ok");
});

四种情形分析------为什么可能重复

情形 服务端是否收到 服务端是否回 ack 客户端行为 服务端最终收到次数
事件本身丢包 超时重发 1 次(重发后)
事件到了,ack 丢失 是,但丢了 超时重发 2 次(重复)
事件到了,服务端处理慢,ack 迟到 是,但 >2s 超时重发 2 次(重复)
事件到了,ack 也发了,但 ack 在路上 >2s 超时重发 2 次(重复)

只有第一种是「真没收到」,后三种服务端其实都收到过了。客户端没法分辨,只能一律重发 → 服务端必须幂等去重。

客户端缓冲 vs 递归重试,会不会冲突

机制 触发条件 行为 可靠性
客户端缓冲(socket.io 内置) socket.connected === false(断线) emit 进 buffer,重连后 flush 一次 at-most-once
递归重试(自己写的) 已连接,2 秒没 ack 递归再调 emit at-least-once

两者不会同时重复发:emit 后断线,递归会触发,但 socket.connected === false,emit 进 buffer,递归终止。buffer 实际上抑制了递归在断线场景下的重复发送。


12.从服务端到客户端:offset 断线续传

上面讲的是客户端→服务端方向的可送达保证。反方向(服务端→客户端)要实现断线不丢消息,需要用 offset 模式,类似 Kafka consumer 的 offset 机制。

客户端代码:

javascript 复制代码
const socket = io({
  auth: {
    offset: undefined
  }
});

socket.on("my-event", ({ id, data }) => {
  // 先处理 data,再更新 offset
  // do something with the data
  socket.auth.offset = id;
});

服务端代码:

javascript 复制代码
io.on("connection", async (socket) => {
  const offset = socket.handshake.auth.offset;
  if (offset) {
    // 重连:从数据库补发该 offset 之后的事件
    for (const event of await fetchMissedEventsFromDatabase(offset)) {
      socket.emit("my-event", event);
    }
  } else {
    // 首次连接
  }
});

setInterval(async () => {
  const event = {
    id: generateUniqueId(),
    data: new Date().toISOString()
  };

  await persistEventToDatabase(event); // 先入库
  io.emit("my-event", event);          // 再广播
}, 1000);

机制理解

  • offset 是个游标,客户端告诉服务端「我上次收到这里了,从这之后开始补」
  • 写入顺序是 persistEventToDatabase 在前、io.emit 在后(outbox 模式),保证「只要事件能发出去,数据库里就一定有」
  • 补发走 socket.emit(只给当前 socket),正常广播走 io.emit(给所有连接),路径不同但事件结构一样

容易踩的坑

  1. offset 存哪里 :示例里 offset 挂在 socket.auth.offset,是纯内存属性,刷新页面就没了。生产环境必须落到 localStorage / IndexedDB
  2. 补发也可能重复 :补发走 socket.emit,仍受 at-most-once 限制,客户端也要用 messageId 做幂等
  3. 先处理 data 再更新 offset vs 先更新 offset 再处理 data:示例选前者(处理失败则 offset 不更新,下次重连重新收到,at-least-once),通常更安全
  4. ID 必须单调递增:offset 比较的前提是 ID 有序,不能用 UUID 字符串直接比较,得用单独的递增序列号字段
  5. persistEventToDatabase 失败await 失败会抛异常,setInterval 回调里的异步异常不会被外层捕获,要 try-catch + 告警

13.ack + timeout 超时场景实战

socket.timeout().emit() 配合服务端延迟回调,演示「超时」的完整例子。

服务端代码:

javascript 复制代码
// 服务端收到 my-event 后,延迟 6 秒才回 ack
socket.on("my-event", (callback) => {
  setTimeout(() => {
    callback({
      status: "ok"
    });
  }, 6000);
});

客户端代码:

javascript 复制代码
// 客户端只等 5 秒的 ack
socket.timeout(5000).emit("my-event", (err, res) => {
  if (err) {
    console.log(err);
    // 服务端没有在给定时间内应答,走超时分支
  } else {
    console.log(res);
  }
});

执行结果

由于服务端 6 秒才回 ack,而客户端只等 5 秒,前端会在第 5 秒触发超时,err 被赋值(TimeoutError),走 if (err) 分支。即使服务端在第 6 秒真的调用了 callback,前端也已经放弃等待,那个迟到的 ack 会被直接丢弃。

关键点

  1. timeout(5000)客户端的等待上限,跟服务端实际处理多久无关
  2. 超时后即使服务端后来回了 ack,客户端也不会再触发回调(已经放弃了)
  3. 要避免超时,要么缩短服务端处理时间 ,要么加大客户端 timeout
  4. 如果业务需要「至少一次」语义,客户端应在 if (err) 分支里重试 emit,但服务端必须做幂等去重
  5. 超时时间的设置必须大于服务端正常处理的最大耗时,否则正常请求也会被误判为超时,造成无意义的重试风暴

14.volatile 事件:断线时丢弃而非缓冲

socket.volatile.emit 是「易失事件」,标记为 volatile 的事件在客户端未连接时会被直接丢弃,不进入缓冲区。

服务端代码:

javascript 复制代码
io.on("connection", (socket) => {
  console.log("connect");

  socket.on("ping", (count) => {
    console.log(count);
  });
});

客户端代码:

javascript 复制代码
let count = 0;
setInterval(() => {
  socket.volatile.emit("ping", ++count);
}, 1000);

运行结果对比

重启服务端(客户端自动重连)后,控制台输出:

使用 volatile.emit

perl 复制代码
connect
1
2
3
4
# the server is restarted, the client automatically reconnects
connect
9
10
11

使用普通 emit

perl 复制代码
connect
1
2
3
4
# the server is restarted, the client automatically reconnects and sends its buffered events
connect
5
6
7
8
9
10
11

关键区别

  • 普通 emit:断线期间的事件进 buffer → 重连补发(5、6、7、8 也会到)
  • volatile.emit:断线期间的事件直接丢 → 重连后只发新的(5、6、7、8 丢了)

volatile 不是服务端重发机制的反面,而是客户端缓冲的反面开关。服务端下行本来就不缓冲,也没有 volatile 的对应概念。


15.默认 socket.emit 的缓冲行为

核心结论:缓冲只在客户端侧上行方向(客户端→服务端),服务端不缓冲。

客户端侧会缓冲

当客户端处于断线状态(socket.connected === false)时调用 socket.emit('xxx', data)

  • 事件不会丢,会被推进客户端的内部 buffer 数组
  • 重连成功后,buffer 里的事件会批量 flush 到服务端

服务端侧不会缓冲

服务端调 io.to(sid).emit('xxx', data)socket.emit('xxx', data) 时:

  • 如果目标 socket 已断开,事件直接丢弃,不存任何地方
  • 服务端没有 buffer 机制

关键细节

  1. 缓冲的是「还没发出去」的事件 :客户端断线后调 emit,这些发不出去的才进 buffer。已经发出去但没收到 ack 的事件不在 buffer 里
  2. 只发一次,不重试:重连后 buffer flush 一次就清空,是 at-most-once,不保证到达
  3. volatile.emit 的区别:默认 emit 断线缓冲、重连补发;volatile emit 断线直接丢、不补发
  4. 刷新浏览器会清空 buffer:buffer 是纯内存的,刷新页面 = buffer 清零

一句话总结 :客户端的默认 socket.emit 在断线时会缓冲,重连后补发;服务端的 emit 不缓冲,断线就丢。缓冲是客户端上行的特性,服务端下行没有。

相关推荐
TickDB8 小时前
Python 接入 A 股盘前集合竞价数据:AkShare 报错到 TickDB 实测的完整解法
python·websocket·行情数据 api
幸运小圣9 小时前
SSE 与 WebSocket 新手入门:前端实时通信完全指南【JavaScript】
前端·javascript·websocket
orient.lu1 天前
第 24 章《WebSocket 通道》· nanobot WebSocket 通道源码深度解析:多路复用 + 重连空闲 + 媒体接入
websocket·源码解析·nanobot
sugar__salt3 天前
从跨域到 WebSocket:一篇讲透浏览器的通信边界
网络·websocket·网络协议
ocean21033 天前
2025-2026年计算机网络大厂面试高频问题示例
websocket·计算机网络·秋招·tcp·后端面试·大厂面经·面试真题
Darling噜啦啦3 天前
WebSocket 双工通信实战:从协议握手到跨域原理,彻底搞懂实时通信的"另一条路"
websocket
艾莉丝努力练剑4 天前
【AI大模型接入SDK】ChatGPT API
网络·c++·人工智能·websocket·网络协议·学习·chatgpt
wjcroom4 天前
一个可以在线多人玩的五子棋开发与布署方法-WebRTC和WebSocket的测试方法
websocket·网络协议·webrtc
Sylvia33.5 天前
LOL实时数据接入深度解析:从WebSocket到完整数据模型
java·网络·python·websocket·网络协议