前两天一个做运营系统的朋友找我,说他们基于 Egg.js 的后台项目要加“实时推送 + 即时通信”,并且服务器用的是宝塔面板,部署层也要一起搞定。这个需求听起来不复杂,但真做起来会踩不少暗坑。我这篇文章就围绕 eggjs 集成 socket.io 这条主线,把推送、即时通信、鉴权、消息落库、宝塔反向代理整个链路完整跑一遍,把我实测过的步骤和踩过的坑都写清楚,给正在做类似实时功能的人一份可以直接参考的实操方案。
先说结论:如果你要在 Egg.js 项目里做服务端主动推送、群聊/单聊这类功能,socket.io 比你在ws基础上自己封装要省太多事。再加上 Egg.js 本身有egg-socket.io这个插件,目录约定、中间件、controller 都能融进框架原有体系里,写起来很顺。宝塔那边只需要把 Nginx 反代的 Upgrade 头补上,WebSocket 就能穿透。下面我按“选型 → 接入 → 生产化改造 → 宝塔反代 → 线上排坑”的顺序展开。
1. 为什么我最后选了 Socket.io + Egg.js,而不是自己封装 WebSocket
1.1 接到需求后先摸清楚要解决什么问题
朋友的需求其实可以拆成两块:一块是“服务端主动推送”,比如订单状态变化、用户收到新消息时,后端要主动把数据送到某个或某批客户端;另一块是“用户之间即时通信”,比如群聊里的消息广播、单聊里的点对点消息。
这两块看起来都能用原生 WebSocket 实现,但真正落地时你会发现,要上线一套可靠的实时通信,不止是“建立连接、收发消息”这么简单。你得处理断线重连、心跳保活、连接状态管理、房间/分组广播,还有多进程部署下消息怎么互通的问题。如果全部自己写,两周打底,而且每个环节都可能藏着边界 bug。
Socket.io 的价值在于它把这些基础能力都内置了。它不是一个简单的传输层库,而是一套基于 WebSocket 的事件通信框架。你不需要关心底层是 WebSocket 还是长轮询,也不需要自己维护房间列表,更不用手动实现重连和心跳策略。开箱即用,这在业务开发场景下非常划算。
1.2 原生 WebSocket 与 Socket.io 的真实差距
为了给朋友解释,我列了一张对比表,也贴在下面。如果你正在犹豫选型,可以先看这张表:
| 能力 | 原生 WebSocket(ws 库) | Socket.io |
|---|---|---|
| 连接握手 | 需要自己解析协议、处理 Upgrade | 内置握手,兼容 HTTP 端口 |
| 心跳保活 | 自己实现 ping/pong 定时器 | 内置 pingInterval / pingTimeout |
| 断线重连 | 自己写重试逻辑 | 客户端自动重连,支持退避 |
| 房间/广播 | 自己维护 Map 并遍历发送 | 原生join/to().emit() |
| 消息事件 | 需要自定义消息类型和 JSON 协议 | 事件驱动,天然支持 emit/on |
| 网络降级 | WebSocket 不通就直接失败 | 自动降级到 HTTP 长轮询 |
| 多进程广播 | 需要自己接 Redis pub/sub | 配 Redis adapter 即可解决 |
| 中间件能力 | 无 | 有 connectionMiddleware / packetMiddleware |
对大多数业务系统来说,“自动降级”和“断线重连”这两个特性就已经值回选型成本了。尤其是国内网络环境比较复杂,某些用户网络下 WebSocket 握手会被拦截,Socket.io 会自动退回 HTTP 长轮询,业务不会因此直接挂掉。
1.3 Egg.js 的约定式目录在这里体现的优势
我能选egg-socket.io还有一层原因:Egg.js 是一个强约定框架,所有 controller、middleware、service 都有固定目录。egg-socket.io插件把 socket.io 的初始化、命名空间、中间件、路由都按 Egg 的风格重新组织了一遍。
同样一套逻辑,如果我用原生 Socket.io,要在app.js里手动初始化、手动挂中间件、手动定义路由;用了插件之后,代码直接落在app/io/controller、app/io/middleware这些目录里,跟 HTTP 层的代码结构统一,团队协作时不需要多学一套范式。后面接鉴权、接 service 落库,都是顺理成章的事。
提示:这里的 “io” 目录不是随便建的,
egg-socket.io默认会扫描app/io下的 controller、middleware 和 router 配置。如果你放在别的位置,插件不会自动加载。
2. 接入过程全记录:从安装 egg-socket.io 到第一个房间消息跑通
2.1 安装依赖与开启插件
接入的第一步非常简单,先把插件装进项目:
npm i egg-socket.io然后在config/plugin.js里开启:
'use strict'; module.exports = { io: { enable: true, package: 'egg-socket.io' } };这里要注意一个点:egg-socket.io基于的是 Socket.io v2.x 协议。如果你想用最新的 Socket.io v4,直接靠这个插件是接不上的,这个坑后面第 5 部分我会专门讲。如果你是第一次接入,先按 v2 的生态来,稳定性没什么问题。
2.2 基础配置:命名空间、中间件与 Redis
接着在config/config.default.js里加上io配置。下面是完整的基础配置,包括命名空间、连接中间件、心跳参数、跨域以及 Redis adapter:
'use strict'; module.exports = (appInfo) => { const config = {}; config.io = { init: { pingTimeout: 20000, pingInterval: 25000, transports: ['websocket', 'polling'], cors: { origin: ['http://localhost:3000'], credentials: true } }, namespace: { '/chat': { connectionMiddleware: ['connection'], packetMiddleware: [] } }, redis: { host: '127.0.0.1', port: 6379, db: 0 } }; config.security = { csrf: { enable: false }, domainWhiteList: ['http://localhost:3000'] }; return config; };一眼看过去可能有几个概念比较绕,我解释一下:
init里的参数会直接传给底层 Socket.io 服务实例,比如心跳间隔、心跳超时时间、允许的传输方式。namespace是命名空间,你可以理解为不同的“房间分组”或者“聊天频道”。我这边定义了一个/chat空间,专门处理聊天消息。connectionMiddleware是连接中间件,在客户端连接握手完成之后执行,适合做鉴权。这里我挂了一个叫connection的中间件。redis配置是可选的,但线上多进程部署必须配。它的作用是让多个 Egg worker 进程之间共享房间和广播数据。现在项目早期就可以加上,后面省得返工。
2.3 目录结构、路由与第一个 chat controller
开启配置后,开始写代码。目录结构大概长这样:
app/ ├── io/ │ ├── controller/ │ │ └── chat.js │ └── middleware/ │ └── connection.js ├── service/ │ └── message.js └── router.js在app/router.js里注册 io 路由:
'use strict'; module.exports = (app) => { app.io.of('/chat').route('message', app.io.controller.chat.message); app.io.of('/chat').route('join', app.io.controller.chat.join); };注意这里和 HTTP 路由不一样,app.io.of('/chat')是拿到/chat命名空间下的路由注册器,.route('message', ...)表示客户端往message事件发数据时,交给chatcontroller 的message方法处理。
然后在app/io/controller/chat.js里写处理逻辑:
'use strict'; const Controller = require('egg').Controller; class ChatController extends Controller { // 客户端加入某个房间 async join() { const { ctx } = this; const { roomId } = ctx.args[0] || {}; if (!roomId) { ctx.socket.emit('error', { message: 'roomId is required' }); return; } await ctx.socket.join(roomId); ctx.socket.emit('joined', { roomId }); } // 往房间发消息 async message() { const { ctx, app } = this; const { roomId, content } = ctx.args[0] || {}; if (!roomId || !content) { ctx.socket.emit('error', { message: 'roomId and content are required' }); return; } const user = ctx.socket.user || { id: 'anonymous' }; const msg = { roomId, content, user, time: Date.now() }; // 广播给房间内所有人 app.io.of('/chat').to(roomId).emit('message', msg); // 顺手落库 await ctx.service.message.save(msg); } } module.exports = ChatController;这里出现了一个关键对象ctx.socket,它对应 Socket.io 的 socket 实例,所有跟当前连接相关的操作都挂在这上面。ctx.args[0]是客户端 emit 时带过来的参数。因为egg-socket.io把 socket 实例挂到了 Egg 的 ctx 上,所以你在 controller 里可以直接用ctx.service.message.save做消息持久化,完全复用框架能力。
客户端那边,连接/chat并发送消息的示例:
import io from 'socket.io-client'; const socket = io('http://localhost:7001/chat', { transports: ['websocket', 'polling'] }); socket.on('connect', () => { socket.emit('join', { roomId: 'room_1' }); socket.emit('message', { roomId: 'room_1', content: 'hello everyone' }); }); socket.on('message', (data) => { console.log('收到消息:', data); });2.4 本地验证连接是否真正跑通
代码写完,启动服务,本地验证怎么确认真的跑通了?
第一个方法,看服务端日志。egg-socket.io在被访问时会打对应日志,连接建立、消息发送都会留下记录。
第二个方法,用命令行客户端模拟。在项目目录外再开一个 Node 脚本:
const io = require('socket.io-client'); const socket = io('http://127.0.0.1:7001/chat', { transports: ['websocket'], query: { token: 'test-token' } }); socket.on('connect', () => { console.log('connected, socket.id =', socket.id); socket.emit('join', { roomId: 'room_1' }); socket.emit('message', { roomId: 'room_1', content: 'from script' }); }); socket.on('message', (data) => { console.log('message:', data); });第三个方法,浏览器里直接看。Chrome 打开项目页面,F12 → Network → WS,能看到 WebSocket 连接,切到 Messages 标签页能看到收发消息。
如果前面几步都正常,说明 Socket.io 服务已经起来了,基础链路没问题。从这一步再往业务方向扩展,就比较踏实了。
3. 把 Demo 改成生产可用:鉴权、房间广播、服务端推送与 Redis 适配
3.1 连接中间件里做握手鉴权
Demo 能跑通和能上线是两回事。第一个必须补的就是鉴权。Socket.io 的握手阶段会传query或auth字段,在连接中间件里做校验,校验失败直接断开连接。
在app/io/middleware/connection.js写:
'use strict'; module.exports = () => { return async (ctx, next) => { const { socket } = ctx; const token = socket.handshake.query.token || socket.handshake.auth?.token; if (!token) { socket.emit('error', { message: 'token required' }); socket.disconnect(true); return; } const user = await ctx.service.user.findByToken(token); if (!user) { socket.emit('error', { message: 'invalid token' }); socket.disconnect(true); return; } // 把 user 挂到 socket 上,后续事件处理里直接取 ctx.socket.user = user; await next(); }; };这个中间件的执行时机在连接建立之后、其他事件到达之前。你可以在里面做 token 校验、黑名单拦截、IP 限制等任何前置逻辑。有一点和 HTTP 中间件不太一样:这里失败时不能直接返回 HTTP 错误,而是要先emit一个错误事件再disconnect,让客户端知道为什么被踢。
提示:Socket.io v2 的握手信息里没有
auth字段,只有query。上面对socket.handshake.auth的处理是为了兼容新版客户端。如果你的客户端固定是 v2,只留query那一行就够了。
3.2 房间机制:群聊和单聊的实现思路
房间是 Socket.io 里一个很好用的抽象。socket.join(roomId)把当前连接加进某个房间,io.to(roomId).emit(event, data)给房间内所有连接广播消息。
在即时通信场景里:
- 群聊:直接拿群 ID 当房间 ID,群里所有人
join进来,发消息就往这个房间广播。 - 单聊:可以用
user_${userId}作为房间名,把每个用户连接加入自己的专属房间。用户 A 要给用户 B 发消息,就调用io.to('user_B').emit('private_message', data),只有 B 的连接能收到。
单聊这种设计我建议一开始就用,因为这样服务端主动推送也会变得非常自然。比如某个用户下单成功,后端直接往user_${userId}房间推一条通知,用户在任意设备上都能收到。
3.3 服务端主动推送:在普通 Controller 里触发
推送不一定来自app/io/controller,很多时候是业务逻辑触发的。比如订单状态变更、审核结果出来、新消息提醒,这些场景都发生在普通 HTTP controller 或 service 里。
假设你现在在一个普通的app/controller/order.js里:
'use strict'; const Controller = require('egg').Controller; class OrderController extends Controller { async update(ctx) { const orderId = ctx.params.id; const userId = ctx.params.userId; // 业务逻辑:更新订单状态 await ctx.service.order.updateStatus(orderId, 'paid'); // 推送给这个用户 ctx.app.io.emit('order:updated', { orderId, status: 'paid' }); // 如果只想推给某个人,用房间定向推送 ctx.app.io.of('/chat').to(`user_${userId}`).emit('order:updated', { orderId, status: 'paid' }); ctx.body = { success: true }; } } module.exports = OrderController;这里要注意一个重要细节:ctx.app.io是整个 Socket.io 服务实例,而 HTTP controller 里的ctx.app就是 Egg 的 app 对象。所以你可以直接用ctx.app.io.emit或ctx.app.io.to(room).emit做全局广播或定向推送。这样推送能力和 HTTP 业务逻辑就彻底打通了。
3.4 多进程部署下必须解决的 Redis adapter
这个问题非常容易在线上翻车。Egg.js 默认会按 CPU 核数启动多个 worker 进程,每个 worker 各自跑一个 Socket.io 服务实例。如果不共享数据,会出现一个尴尬的场景:
用户 A 连到了 worker 1,用户 B 连到了 worker 2。A 在群里发消息,消息由 worker 1 处理并广播给 worker 1 上的所有连接,但 worker 2 上的 B 完全收不到。
本地开发时往往只有一个 worker,所以测试一切正常;一到线上多个 worker 启动,广播就“丢消息”。解决办法就是给 Socket.io 配 Redis adapter,让所有 worker 通过 Redis 共享房间和广播数据。
egg-socket.io里只需要配置io.redis,插件会自动把它转成 Redis adapter:
config.io = { redis: { host: '127.0.0.1', port: 6379, password: undefined, db: 0 } };如果启动时提示找不到适配器相关依赖,先补装:
npm i socket.io-redis配置完成后,建议测试方法:把服务启动成 cluster 模式,开两个端口或两个 worker,分别连上之后互相发消息,确认广播能互通。
注意:这里 Redis 不只是做存储,它承担的是跨进程消息转发。Redis 挂掉,Socket.io 的多进程广播就会失效,所以生产环境 Redis 本身要做好主从和持久化。
3.5 消息落库与拉取历史消息
实时消息如果只停留在 Socket.io 转发层面,用户一刷新页面就什么都没了。所以在广播的同时落库是必须的。
我在上面的chat.message里已经调用了await ctx.service.message.save(msg)。对应的 service 非常简单:
'use strict'; const Service = require('egg').Service; class MessageService extends Service { async save(msg) { const { ctx } = this; // 存到数据库,表结构自行设计 return ctx.model.Message.create(msg); } async getRecentByRoom(roomId, limit = 50) { const { ctx } = this; return ctx.model.Message.findAll({ where: { roomId }, order: [['time', 'DESC']], limit }); } } module.exports = MessageService;用户进入房间时,可以先调一个 HTTP 接口拉取最近 50 条历史消息,展示完后再建立 Socket.io 连接接收增量。这样既保证了消息的完整性,也避免新用户进入房间后一片空白。
4. 宝塔面板配置反向代理的完整操作:从默认反代到 WebSocket 真正可用
4.1 前置检查:先确认本机端口能直连
在配置任何反代之前,先确认 Node 服务本身没有问题。在服务器上执行:
curl -i http://127.0.0.1:7001/socket.io/?EIO=3&transport=polling如果返回{"type":0...}或者至少能看到 HTTP 200,说明服务正常。如果这一步被防火墙拦住,先去检查安全组和宝塔安全页面的端口放行情况。
一个常见的坑是:很多人在宝塔里只放行了 80/443,没放行 7001,然后反代配完 Node 服务访问不到。实际上反代场景下 7001 并不需要对外网开放,只要保证 127.0.0.1:7001 能被 Nginx 访问即可。
4.2 宝塔面板的反向代理入口
宝塔面板里,找到站点对应的域名,进入“设置”,找到“反向代理”,点击“添加反向代理”。
- 代理名称:随意,比如
node-socket - 目标 URL:填
http://127.0.0.1:7001 - 发送域名:默认
$host即可
保存后,宝塔会生成一段 Nginx 配置。但这里有一个关键点:默认生成的反向代理配置只能用于普通 HTTP,不能直接用于 WebSocket 升级。它缺少Upgrade和Connection头,浏览器发起 WebSocket 握手时,Nginx 不会把 101 Switching Protocols 响应正确传回给客户端,表现就是连接一直失败或被拒绝。
4.3 手动修改 Nginx 配置:补全 WebSocket 升级头
回到宝塔的“网站 → 设置 → 配置文件”(或者叫“Nginx 配置”),找到反代对应的location块,改成下面这样:
location / { proxy_pass http://127.0.0.1:7001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 60s; proxy_send_timeout 60s; }其中这几行最关键:
proxy_http_version 1.1;:WebSocket 升级依赖 HTTP/1.1,HTTP/1.0 不支持 Upgrade 头。proxy_set_header Upgrade $http_upgrade;:把客户端发来的 Upgrade 头透传给后端。proxy_set_header Connection "upgrade";:固定设置 Connection 为 upgrade,这是 WebSocket 协议升级的必需条件。proxy_read_timeout 60s;:防止 Nginx 因空闲时间过长主动断开连接,这个值后面还可以根据 Socket.io 心跳间隔调整。
如果你的服务器上还有别的 Web 应用,比如原有 PHP 站点或静态页面,不希望整个域名都被 Node 接管,可以让 Node 服务只接管/socket.io这一个路径:
location /socket.io/ { proxy_pass http://127.0.0.1:7001; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 60s; proxy_send_timeout 60s; }这里有个细节要注意:如果使用/socket.io/前缀并且proxy_pass后面不带 URI,会保留原始 URI 一起转发,这样/socket.io/socket.io.js就能正确打到127.0.0.1:7001/socket.io/socket.io.js。如果你在proxy_pass后面多写了一个/,前缀会被丢掉,路径就错了。
改完配置后,保存并重载 Nginx:
nginx -t && nginx -s reload提示:在宝塔的“反向代理”页面可以直接点“修改配置”进入对应的 Nginx 配置代码块,没必要去手撸整个
nginx.conf。改完先在宝塔里做一次配置检测,避免语法错误导致 Nginx 无法重启。
4.4 验证 WebSocket 是否真正穿透了反代
配置完成后的验证方式,我在本地测试时会这么做:
打开浏览器控制台,建立连接:
const socket = io('https://你的域名/chat', { transports: ['websocket'], path: '/socket.io' }); socket.on('connect', () => { console.log('connect success, id:', socket.id); });然后到 Chrome 的 Network 面板,过滤 WS,点击连接名,看 Status 是不是101 Switching Protocols。如果能看到 101,说明 Nginx 反代已经正确透传了 WebSocket 握手。
再看响应头里应该有:
Upgrade: websocket Connection: Upgrade如果 Status 是别的东西,比如 400 或 502,大概率是proxy_set_header少了某个头,或者后端服务没有启动。
另一种验证方式是看 Nginx 错误日志路径,通常宝塔默认的日志路径在/www/wwwlogs/。出现upstream prematurely closed connection之类的日志,基本可以确定是超时或后端主动断开了连接,往心跳参数那方面排查。
5. 线上跑一段时间后遇到的坑与排查思路
5.1 一直 HTTP 长轮询,不升级 WebSocket
表现是客户端能连接、能收发消息,但 Network 面板里全是transport=polling的请求,根本看不到 WebSocket。这时连接是通的但性能和实时性都打了折扣。
排查链路基本是固定的:
- 确认前后端传输配置一致。客户端设置了
transports: ['websocket'],服务端transports也包含websocket。 - 检查 Nginx 的
Upgrade和Connection头是否配置正确。漏了任何一个头,Socket.io 都不会升级到 WebSocket。 - 确认网络环境里是否有中间设备拦截了 WebSocket 握手。如果办公室网络或防火墙有策略限制,会一直走 polling。
其中第一个原因最常见:服务端init.transports里写了['websocket', 'polling'],客户端也写了,但中间 Nginx 没有透传 Upgrade,Socket.io 检测到升级失败后就会自动退回到 polling。我后来排查过几次,都是 Nginx 那段配置被其他同事改回去导致的。
5.2 多人连接在线后频繁闪断
闪断问题要分两层看:一层是 Socket.io 自身的断开,一层是 Nginx 或系统层面把连接杀掉了。
Socket.io 默认的心跳配置是pingInterval: 25000、pingTimeout: 20000,意思是每 25 秒发一次 ping,如果 20 秒内没收到 pong 就判断连接断开。问题出在 Nginx 的proxy_read_timeout上,默认 60 秒。如果某段时间内没有数据交互,Nginx 会先断开空闲连接,客户端和服务端都感知不到,直到下一次心跳失败才被发现。
我的调整思路是:
- 服务端心跳周期保持默认或略微调大,比如
pingInterval: 30000。 - Nginx 的
proxy_read_timeout和proxy_send_timeout统一调到 120s 以上,保证 Nginx 的空闲断开阈值大于 Socket.io 的心跳周期。 - 客户端开启自动重连并设置退避策略,避免服务端重启时客户端集中冲击。
如果调整完还是频繁闪断,要去看部署机器上的连接数限制,执行:
ulimit -nNginx 默认的worker_connections也可能成为瓶颈,如果在线并发高,要同步调大。
5.3 多进程下广播消息丢失
这是我在真实项目里踩过最深的坑。本地怎么测都正常,部署到线上后,部分用户收不到消息,而且毫无规律。后来查清楚原因:线上 Egg 启动了 8 个 worker,默认的 Socket.io adapter 是内存级的,worker 之间互相看不到对方的连接和房间。
解决办法就是第 3 部分说的 Redis adapter。配置完 Redis 后再测试,广播就能跨 worker 正常传递了。
建议:项目一开始就规划好 Redis adapter,别等线上出问题再补。前期不配,后面出了问题,排查成本比配置成本高得多。
5.4 403 连接被拒绝
连接直接返回 403,通常有三个来源:
第一是鉴权中间件拒绝。token校验失败、token 过期、query 参数没传都会触发disconnect(true),客户端看到的就是连接被拒。这类问题先看服务端日志里有没有打印 token 相关错误。
第二是 CORS。Socket.io 本身有跨域策略,init.cors如果没配置或者不允许当前域名,握手请求会被拦截。在config.default.js里把cors.origin配成前端实际域名即可。
第三是 Nginx 层把非法的Upgrade请求挡了。有些安全模块会拦截带Upgrade头的请求,表现为状态 403。排查时可以关掉安全模块临时测试,确认后再调整规则。
5.5 Socket.io 版本不匹配
最后提一个最容易忽略的版本问题。egg-socket.io目前大部分版本还是基于 Socket.io v2 协议,而前端如果用了最新版socket.io-client(v4),连接时会因为协议版本不兼容而失败,经常表现为连不上、狂刷新、或者握手一直失败。
解决办法有三种:
- 前端把
socket.io-client固定在 v2 版本,和服务端保持同协议。 - 如果坚持用 v4,后端换成支持 v4 的方案,或者适配对应插件。
- 升级迁移时,新旧客户端不能混用同一套后端,最好保持全量切换。
判断方法也很简单:看握手请求里EIO参数。v2 是EIO=3,v4 是EIO=4。后端和前端如果不一致,基本就是版本没对上。
这套eggjs 集成 socket.io + 宝塔反向代理的方案,我已经在两个项目上完整跑通过。一次是给运营后台加实时通知推送,一次是给客服系统做在线聊天模块,两边的需求不同,但底层链路完全一致。每次跑通之后,我都会跟同事强调同一个建议:项目初期只要确定会有推送和实时通信,就把 Redis adapter 和鉴权中间件一起规划进去,别等线上出现广播丢失再回来补。先本地把服务跑起来,用 socket.io-client 模拟两个客户端,分别验证连接、房间、广播,再去改宝塔反代,最后看线上日志。顺序对了,整个接入过程会顺很多。