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:
sids:Map<SocketId, Set<Room>>--- 每个 socket 加入了哪些房间rooms:Map<Room, Set<SocketId>>--- 每个房间有哪些 socket
广播时的查找逻辑:
io.emit()(广播所有):遍历sidsMap,给每个 socket 发io.to("room").emit()(广播到房间):遍历roomsMap 中该房间的 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", () => {});
});
各命名空间的独立性:
- 事件处理 :互不干扰,
/orders的order: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.emit 是 at-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(给所有连接),路径不同但事件结构一样
容易踩的坑:
- offset 存哪里 :示例里 offset 挂在
socket.auth.offset,是纯内存属性,刷新页面就没了。生产环境必须落到localStorage/IndexedDB - 补发也可能重复 :补发走
socket.emit,仍受 at-most-once 限制,客户端也要用 messageId 做幂等 - 先处理 data 再更新 offset vs 先更新 offset 再处理 data:示例选前者(处理失败则 offset 不更新,下次重连重新收到,at-least-once),通常更安全
- ID 必须单调递增:offset 比较的前提是 ID 有序,不能用 UUID 字符串直接比较,得用单独的递增序列号字段
- 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 会被直接丢弃。
关键点:
timeout(5000)是客户端的等待上限,跟服务端实际处理多久无关- 超时后即使服务端后来回了 ack,客户端也不会再触发回调(已经放弃了)
- 要避免超时,要么缩短服务端处理时间 ,要么加大客户端 timeout
- 如果业务需要「至少一次」语义,客户端应在
if (err)分支里重试 emit,但服务端必须做幂等去重 - 超时时间的设置必须大于服务端正常处理的最大耗时,否则正常请求也会被误判为超时,造成无意义的重试风暴
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 机制
关键细节:
- 缓冲的是「还没发出去」的事件 :客户端断线后调
emit,这些发不出去的才进 buffer。已经发出去但没收到 ack 的事件不在 buffer 里 - 只发一次,不重试:重连后 buffer flush 一次就清空,是 at-most-once,不保证到达
- 跟
volatile.emit的区别:默认 emit 断线缓冲、重连补发;volatile emit 断线直接丢、不补发 - 刷新浏览器会清空 buffer:buffer 是纯内存的,刷新页面 = buffer 清零
一句话总结 :客户端的默认 socket.emit 在断线时会缓冲,重连后补发;服务端的 emit 不缓冲,断线就丢。缓冲是客户端上行的特性,服务端下行没有。