
2026-09-15 · 阅读 17
webscoket---实时通信方案
一、为什么需要 WebSocket:HTTP 的天生局限
1.1 HTTP 是"一问一答"
HTTP 是请求-响应模型:客户端问一次,服务器答一次,答完连接就没了。服务器无法主动推送消息——这是它天生的设计,不是 bug。
类比:HTTP 像发微信等回复——你不问,对方永远不会主动说话。
1.2 服务器想"主动通知你",以前只能这么干
| 旧方案 | 怎么做 | 两大致命伤 |
|---|---|---|
| 轮询 Polling | 客户端每 2 秒问一次"有新消息吗?" | 90% 的请求是白问的;消息最多延迟 2 秒才到 |
| 长轮询 Long Polling | 服务器扣住请求不放,有消息才返回,客户端收到立刻再发下一个 | 每条消息仍是一次完整 HTTP 开销;服务器要挂大量等待连接 |
1.3 算一笔账:轮询到底浪费了什么
服务器资源不抽象,云服务器的账单是按量计费的,跟水电表一样:
| 抽象资源 | 现实中对应 | 怎么扣费 |
|---|---|---|
| 带宽 | 快递费,按包裹重量收费 | 按 GB 计费 |
| CPU | 后厨厨师的劳动力 | 跑满 → 买更贵的实例 |
| 内存 | 餐厅座位 | 不够 → 崩溃/升配 |
| 数据库 QPS | 银行柜员每笔业务的手续费 | 撞顶 → 排队变慢 → 升档 |
假设 1000 人在线,每秒轮询一次:
- 每次请求头 + 响应头 ≈ 1KB(Cookie、JWT 每次都原样重发)
- 1000 人 × 1KB/秒 = 1MB/秒 ≈ 每月 2.6TB 纯垃圾流量(内容全是"没有新消息")
- 服务器每秒解析 1000 个 HTTP 请求,其中 90% 白干
- 数据库白挨 1000 QPS,而且登录、下单这些正经业务要跟垃圾查询抢同一个数据库
恶性链条:垃圾请求占满 CPU/数据库 → 正经请求排队 → 接口从 50ms 变 3s → 请求积压 → 崩溃 → 花四倍钱升配置。
换成 WebSocket:握手 1 次,之后每条消息帧头只有 2~14 字节(约 HTTP 头的 1/50),只有真的有消息才发。
二、WebSocket 是什么
类比:WebSocket 就是打电话——接通之后,双方随时都能开口,不用等对方先问。
2.1 建立连接:借 HTTP 的门进场,然后掀桌子换规则
① 客户端发起一个普通 HTTP 请求(复用 80/443 端口,防火墙不拦)
GET /chat HTTP/1.1
Upgrade: websocket ← "我想升级协议"
Connection: Upgrade
Sec-WebSocket-Key: xxx ← 随机串,防老缓存代理瞎转发
② 服务器同意,返回 101
HTTP/1.1 101 Switching Protocols
③ 从这一刻起,这条 TCP 连接不再是 HTTP——
双方改用 WebSocket 帧格式收发,任意一方随时可发
核心设计点:
- 握手用 HTTP,通信不用:天然过防火墙、过 Nginx
- 全双工(full-duplex):一条 TCP 连接上双向同时收发,服务器可以主动推了
- 帧协议:数据切成一帧帧发,帧有类型标记(文本/二进制/ping-pong 心跳/close)——TCP 只是根水管,WebSocket 规定了水流的格式
- 事件驱动:编程模型是
on('message')监听回调,和 Node 的 EventEmitter 一回事
2.2 连接的一生
TCP 三次握手 → HTTP Upgrade 握手 → 双方随时收发帧(互发 ping/pong 心跳)→ close 帧关闭
心跳的两个作用:① 防止中间的 Nginx/路由把"太久不说话"的连接掐掉;② 探测对方死没死——pong 不回 = 断开了,触发重连。
2.3 原生 WebSocket 代码(Node ws 库)
服务端:
// npm i ws
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 3000 });
wss.on('connection', (socket) => {
console.log('有人上线了,当前在线:', wss.clients.size);
socket.on('message', (data) => {
// 广播给所有人 —— 服务器主动推,这就是 HTTP 做不到的
for (const client of wss.clients) {
client.send(data.toString());
}
});
});
浏览器客户端:
const ws = new WebSocket('ws://localhost:3000');
ws.onopen = () => ws.send('大家好'); // 客户端 → 服务器
ws.onmessage = (e) => console.log(e.data); // 服务器 → 客户端(主动推)
一句话总结:轮询是客户端定时拉,WebSocket 是服务器有货直接推。
三、socket.io:把裸手机变成微信
裸 WebSocket 是一部裸手机,socket.io 是微信——微信也靠手机通信,但额外白送四样东西。
3.1 协议 vs 库
WebSocket 是协议(规范),socket.io 是基于它封装的库(工具箱)。类比:TCP 是规范,微信是产品。
⚠️ 注意:socket.io 有自己的握手协议,客户端和服务端必须配套——浏览器原生 new WebSocket() 连不上 socket.io 服务器。
3.2 socket.io 比 WebSocket 多给你的东西
| 裸 WebSocket 的坑 | socket.io 白送的 |
|---|---|
| 断线了怎么办?自己写重连逻辑 | 自动重连(指数退避:失败后隔 1s、2s、4s…再试) |
| Nginx 把空闲连接掐了?自己写 ping/pong | 内置心跳(默认每 25 秒 ping 一次,不回就判死) |
| 想只给某些人发?自己维护 socketId 列表 | room 房间:socket.join() + io.to(room).emit() 一行搞定 |
| 发出去对方收到没?不知道 | ack 回执:emit('event', data, callback) |
| 防火墙禁了 WebSocket?完了 | 自动降级:退回 HTTP 长轮询,保证能用 |
另外一个重要升级:通信单位从"字符串消息"变成了事件——socket.emit('事件名', 数据),事件名随便自定义。
3.3 三个关键机制
① 房间 room——本质是服务器内存里的一张表
room "对战-9527" → [socketA, socketB]
room "直播间-3306" → [socketC, socketD, socketE, ...]
socket.join('对战-9527'):把自己登记进房间io.to('对战-9527').emit(...):给这个房间所有人发socket.broadcast.to('对战-9527').emit(...):给房间里除自己以外的人发
⚠️ room 存在单台服务器内存里 → 多实例部署时,连在 A 机的用户和连在 B 机的用户互相看不见,必须上 Redis adapter(见第五节扩展层)。
② 心跳——保活 + 探活:服务器定期 ping,对端必须回 pong;没回 → 触发 disconnect 清理房间。
③ 重连——断线自动复活:客户端自动重连,但重连后是新 socket(新 id),服务端要把它重新 join 回原来的房间。
3.4 socket.io 基本代码
服务端:
// npm i socket.io
const { Server } = require('socket.io');
const io = new Server(3000);
io.on('connection', (socket) => {
console.log('用户上线:', socket.id);
// 1. 收自定义事件(不是裸消息,是"事件名 + 数据")
socket.on('chat', (msg, ack) => {
ack && ack('服务器已收到'); // 2. ack 回执:回调证明送达
socket.broadcast.emit('chat', msg); // 3. 广播给除自己以外的所有人
});
socket.on('joinRoom', (roomId) => {
socket.join(roomId); // 进房间
io.to(roomId).emit('system', `${socket.id} 进来了`); // 只发给这个房间
});
socket.on('disconnect', () => {
console.log('用户掉线:', socket.id); // 心跳超时/主动关闭都会走到这
});
});
浏览器客户端:
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io(); // 断线自动重连是它自带的
socket.emit('chat', '你好', res => console.log(res)); // 第三个参数传函数 = 要回执
socket.on('system', msg => console.log(msg));
</script>
四、实战:写一个双人聊天室
4.1 项目结构
D4-demo/
├── package.json
├── server.js
└── public/
└── index.html
4.2 完整代码
package.json
{
"name": "socketio-chat-demo",
"version": "1.0.0",
"description": "socket.io 双人聊天室",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"socket.io": "^4.8.0"
}
}
server.js(覆盖 4 个考点:连接 / 自定义事件 / room / broadcast / ack)
import { createServer } from 'node:http'; // 注意拼写:createServer 不是 creatServer
import { readFileSync } from 'node:fs';
import { Server } from 'socket.io';
// 1. 原生 http 托管聊天页面(不装 express,少个依赖)
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(readFileSync(new URL('./public/index.html', import.meta.url)));
// ↑ res 负责"结束响应",readFileSync 负责"读文件"——一个读一个还
});
const io = new Server(server);
io.on('connection', (socket) => {
console.log('[上线]', socket.id);
// 2. 自定义事件 join:进房间(事件名末尾不能有空格,按字符串精确匹配!)
socket.on('join', ({ room, name }) => {
socket.join(room); // 登记进房间(服务器内存里的一张表)
socket.data = { room, name }; // 记住自己是谁、在哪个房
io.to(room).emit('system', `"${name}" 加入了房间 ${room}`);
});
// 3. 自定义事件 chat:说话(第二个参数 ack 是回执回调)
socket.on('chat', (text, ack) => {
ack?.('✓ 服务器已收到');
// 发对象不发裸字符串——对手要靠 name 才知道是谁说的
socket.broadcast.to(socket.data.room) // broadcast = 同房间、除自己以外
.emit('chat', { name: socket.data.name, text });
});
// 4. 断开:关页面 / 断网心跳超时都会走到这(socket.io 自动探活)
socket.on('disconnect', () => {
console.log('[下线]', socket.id);
});
});
// 5. 开机——没有这行,写再好也访问不了
server.listen(3000, () => console.log('聊天室已启动 → http://localhost:3000'));
public/index.html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>socket.io 双人聊天室</title>
<style>
body { font-family: sans-serif; max-width: 460px; margin: 24px auto; }
#msgs { border: 1px solid #ccc; height: 280px; overflow-y: auto; padding: 8px; margin-bottom: 8px; }
p { margin: 4px 0; }
.sys { color: #999; }
</style>
</head>
<body>
<h3>socket.io 双人聊天室</h3>
<!-- 登录区:房间号 + 昵称 -->
<div id="login">
房间号 <input id="room" value="battle-9527">
昵称 <input id="name" placeholder="你的昵称">
<button onclick="join()">加入</button>
</div>
<!-- 聊天区:加入后显示 -->
<div id="chat" style="display:none">
<div id="msgs"></div>
<input id="input" placeholder="说点什么..." style="width:320px">
<button onclick="send()">发送</button>
</div>
<!-- socket.io 客户端由服务器自动提供,不用下载 -->
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io(); // 连接 + 断线自动重连,都自带
const $ = id => document.getElementById(id);
const log = html => { $('msgs').insertAdjacentHTML('beforeend', html); $('msgs').scrollTop = 1e9; };
function join() {
socket.emit('join', { room: $('room').value, name: $('name').value || '路人' });
$('login').style.display = 'none';
$('chat').style.display = 'block';
}
function send() {
const text = $('input').value.trim();
if (!text) return;
// emit 第 3 个参数传函数 = 要 ack 回执
socket.emit('chat', text, res => log(`<p class="sys">${res}</p>`));
log(`<p><b>我</b>:${text}</p>`); // 自己的消息本地直接显示
$('input').value = '';
}
// 对手的消息:服务器主动推过来的(这就是 HTTP 做不到的事)
socket.on('chat', d => log(`<p><b>${d.name}</b>:${d.text}</p>`));
socket.on('system', m => log(`<p class="sys">—— ${m} ——</p>`));
</script>
</body>
</html>
4.3 运行 + 四个实验
npm install
npm start
# 打开 http://localhost:3000,开两个窗口(第二个建议无痕窗口)
| # | 操作 | 现象 | 验证知识点 |
|---|---|---|---|
| 1 | 两窗口相同房间号、不同昵称,互发消息 | 对方窗口不刷新就蹦出消息 | 服务器主动推——HTTP 做不到 |
| 2 | 观察发送方 | 每条消息下出现「✓ 服务器已收到」 | ack 回执 |
| 3 | 第三个窗口换房间号再进 | 收不到上一个房间的消息 | room 隔离 |
| 4 | 关掉一个窗口 | 终端打出 [下线] + socket.id | disconnect(心跳探活) |
留意一个细节:自己发的消息不是服务器推给自己的——服务器用的是 broadcast(排除自己),屏幕上"我:xxx"是本地直接渲染的。
4.4 我手写时踩过的 8 个坑(手写面试代码必看)
第一次不看参考手写这个 demo,错了 8 处,每一处都是"手写代码"场景的典型翻车点:
creatServer拼写错误(少个 e),和下面用的createServer对不上 → ReferenceErrorres.readFileSync(...)——把两个 API 揉一起。正确:res.end(readFileSync(...)),res 负责结束响应,readFileSync 负责读文件- 忘写
server.listen(3000)——服务器写好了但没"开机" - 把
console.log手滑写成socket.join——效果变成"把人拉进名叫[上线]的房间" - 事件名
'join '末尾多一个空格——socket.io 按字符串精确匹配,多一个空格 = 永远监听不到 = 点按钮毫无反应,而且控制台一行错都不报。手写代码最阴的坑 emit('chat', text)只发字符串——客户端要读d.name,显示undefined:你好。跨端要约定数据结构,发对象- IDE 自动补进来的垃圾 import(打参数名
text时被自动导入了node:stream/consumers)——凡是自己没主动写的 import 一律警惕 - 漏了
disconnect处理——第四个考点直接丢了
五、综合设计:实时匹配对战系统(四层架构)
总纲:socket.io 管通信,Redis 队列管匹配,服务端权威管对战,Redis pub/sub 管多实例扩展。
5.1 第一层:通信层(socket.io)
- 玩家连上后拿到一个 socket(带唯一 id)
- 配对成功的两人
join进同一个 room(如battle:9527) - 所有对战事件通过这个 room 定向广播:推题、对手进度、比分、结算
- 心跳保活 + 断线自动重连(socket.io 自带)
5.2 第二层:匹配层(Redis 匹配队列)
用户点"开始匹配"
↓
把 userId + 积分塞进 Redis zset(积分当分数)
↓
服务端撮合循环:ZRANGEBYSCORE 找积分相近的两人(ELO 机制,防大佬虐新手)
↓
配对成功 → 生成 roomId → 两人 join 同一个 room → 广播 matched 事件
为什么用 Redis 不用数据库:匹配是秒级高频操作,撮合完就删,天然适合内存队列;写库太慢还脏数据。进队列前用 SETNX 保证同一人不能重复排队。
5.3 第三层:对战层(核心原则:服务端权威)
服务端选 5 道题 → io.to(room).emit('question', 题目) // 只推题干,不推答案!
玩家 A 提交答案 → socket.on('answer') 服务端判定对错、记分
A 答完 → socket.broadcast.to(room).emit('opponentDone') // 通知对手(不含自己)
超时没答 → 服务端 setTimeout 自动判错、跳下一题 // 计时以服务端为准
5 题结束 → 判胜负 → 更新积分表 → emit('result') → 解散 room
防作弊三件套(面试官最爱追问):
- 答案永不下发——客户端只拿到题干,判定在服务端做(答案发到前端,F12 就能看到)
- 计时以服务端为准——不信客户端传的时间戳(改本地时间就能作弊)
- 得分由服务端计算——客户端只提交选项,不提交"我得了 10 分"
5.4 第四层:扩展层(Redis pub/sub 跨实例)
问题:room 存在单机内存里。用户 A 连服务器 1、用户 B 连服务器 2 → 服务器 1 的 io.to(room) 够不到 B。
A 在服务器 1 emit 消息
↓
消息发到 Redis 频道 battle:9527
↓
每台服务器都订阅着频道,各自转发给"自己机器上"在这个 room 里的 socket
↓
B 在服务器 2 收到
socket.io 官方提供 @socket.io/redis-adapter,两行接入。
5.5 表设计
用户表、积分表(含 ELO 分)、房间表(状态机:匹配中 → 对战中 → 已结束)、题目表、对战记录表。
加分句:"房间状态用状态机管理,每次变更只允许合法流转,防止'已结束的房间还能答题'这类脏状态。"
六、面试问答速查
Q1:WebSocket 和 HTTP 的区别?
HTTP 是请求-响应模型,一问一答,服务器不能主动推,每次请求还带完整头部开销。WebSocket 是全双工长连接:借 HTTP 握手(Upgrade 头 + 101 状态码)完成升级,之后用自己的帧协议通信,双方随时发消息,头部开销极小,自带 ping/pong 心跳保活。适用:聊天、实时对战、行情推送、协同编辑。
Q2:WebSocket 怎么建立连接?
TCP 三次握手 → HTTP 请求带
Upgrade: websocket→ 服务器返回101 Switching Protocols→ 协议切换为 WebSocket 帧,连接建立。
Q3:SSE 和 WebSocket 怎么选?
SSE 基于 HTTP,只能服务器单向推,自带断线重连,简单够用(AI 流式输出常用)。需要客户端也随时发消息(对战、聊天)才上 WebSocket。
Q4:socket.io 和 WebSocket 什么关系?
WebSocket 是全双工长连接协议,socket.io 是基于它的库,额外封装了自动重连、心跳保活、room 房间、ack 回执、降级轮询。注意客户端和服务端必须配套(它有自己的握手协议)。
Q5:为什么选 WebSocket 而不是前端每秒拉一次接口?长轮询不行吗?
对战场景里,"匹配成功""对手已答题""比分更新"这些事件必须由服务器主动推,HTTP 一问一答做不到。轮询每秒拉一次,90% 空查询还带 1 秒延迟;1000 人在线每秒轮询,光重复 HTTP 头就是每秒 1MB 垃圾流量、数据库白挨 1000 QPS。长轮询虽能等来消息,但每条消息都是一次完整 HTTP 开销,服务器还得挂大量等待连接。WebSocket 借 HTTP 握手升级后变全双工长连接,一条连接复用,帧头开销极小,具体实现用 socket.io,还能白拿 room、自动重连。
Q6:两个人对战,怎么保证 A 的操作只发给 B?
配对成功后把两个 socket join 进同一个 room,
io.to('roomId').emit定向广播;通知对手但不通知自己,用socket.broadcast.to('roomId').emit。
Q7:用户断网 10 秒再连回来,怎么让他回到对局?
客户端 socket.io 自动重连;服务端在 connection 时按 userId 从 Redis 查到他的 roomId,重新 join 进去,并推当前题目和比分补状态。
七、写在最后
三个类比串起全文:
- HTTP vs WebSocket:发微信等回复 vs 打电话——接通后双方随时开口
- WebSocket vs socket.io:裸手机 vs 微信——多送群聊(room)、回执(ack)、断线重连、心跳降级
- 对战四层:socket.io 是对讲机,Redis 队列是媒人,服务端权威是裁判,Redis pub/sub 是广播站
技术选型的判断标准一句话:只要"服务器有情况要主动告诉你",就是 WebSocket 的地盘;低频场景(如一周一更的博客)别为了炫技上长连接,SSE 或轮询的优雅版(SWR)更合适。
评论 (0)
还没有评论,来抢沙发吧~