WebSocket实时通信
# 16.WebSocket实时通信
# 目录介绍
- 01.工作案例引入
- 1.1 一个"消息已读"功能的技术选型之路
- 1.2 选型背后的实时通信知识图谱
- 02.为什么需要WebSocket
- 2.1 HTTP的单向限制
- 2.2 短轮询的代价
- 2.3 长轮询的改进
- 2.4 SSE单向推送
- 2.5 四种方案对比
- 03.WebSocket握手与协议升级
- 3.1 HTTP升级机制
- 3.2 握手的完整流程
- 3.3 Sec-WebSocket-Key/Accept的计算
- 3.4 扩展协商
- 04.WebSocket帧格式深度解析
- 4.1 帧结构概览
- 4.2 FIN/RSV/Opcode/MASK/Payload
- 4.3 控制帧:Close/Ping/Pong
- 4.4 掩码设计的深意
- 4.5 分片传输
- 05.心跳与保活机制
- 5.1 为什么需要心跳
- 5.2 Ping/Pong帧的使用
- 5.3 中间代理的超时
- 5.4 重连策略设计
- 06.服务端架构设计
- 6.1 连接管理
- 6.2 消息广播
- 6.3 水平扩展
- 6.4 背压处理
- 07.安全与优化
- 7.1 WebSocket安全风险
- 7.2 消息压缩
- 7.3 二进制传输
- 7.4 连接数优化
- 08.综合案例:从0到1搭建实时聊天系统
- 8.1 案例背景与目标
- 8.2 第一代:短轮询(能跑就行)
- 8.3 第二代:长轮询(假装实时)
- 8.4 第三代:WebSocket(真正的全双工)
- 8.5 第四代:生产级WebSocket(心跳+重连+分布式)
- 8.6 四种方案横向对比
- 8.7 案例升华:WebSocket的设计哲学
- 8.8 全文知识图谱回顾
- 09.思考题与作业
- 9.1 基础思考题
- 9.2 进阶思考题
- 9.3 动手作业
# 01.工作案例引入
# 1.1 一个"消息已读"功能的技术选型之路
场景:小钱是一名后端工程师,负责公司社交 App 的即时通讯模块。产品提了一个需求——"对方看到你发的消息后,你要能立刻看到'已读'标记"。比如微信里那条"对方正在输入..."和"已读"的小字。
小钱的第一反应很简单——客户端在打开聊天页面时,调一个 POST /api/messages/read 标记已读,服务端推送一个"消息已读"通知给对方客户端。问题来了:服务端怎么"推送"给客户端? HTTP 是请求-响应模型,服务端不能主动给客户端发消息。
小钱开始了技术选型。
方案A:短轮询。客户端每隔 2 秒发一个 GET /api/messages/updates,问"有没有新消息"。服务端查一下 Redis,有就返回,没有就返回空。上线第一天,测试反馈——"消息已读"的更新延迟最高 2 秒,体感明显。而且 App 后台时还在轮询,一晚上耗了 30% 的电。
方案B:长轮询。客户端发请求,服务端不立即返回,而是"hold 住"这个连接——直到有新消息时才返回。客户端收到响应后立刻发起下一个长轮询请求。延迟降到了毫秒级,但运维发现——10 万在线用户,服务端维持了 10 万个"悬空"的 HTTP 连接(每个线程 hold 一个),线程池资源吃紧。
方案C:WebSocket。小钱用 ws 库搭建了一个 WebSocket 服务端,客户端建立 WebSocket 连接后,服务端可以随时推送消息。延迟 < 50ms,一个服务器 2GB 内存轻松支撑 5 万并发连接。但新问题又来了——用户切到后台后,iOS 系统会在几分钟后断开 WebSocket 连接,切回来时需要重新建立连接并拉取离线消息。
方案D:WebSocket + 心跳 + 重连 + 离线消息。小钱在 WebSocket 基础上加了 30 秒心跳检测,监听网络状态变化自动重连,服务端在连接断开期间缓存离线消息,重连后批量推送。
疑惑链条:
- "HTTP 为什么不能服务端主动推送?" → HTTP 是请求-响应模型,TCP 连接在响应完成后就释放(或进入 Keep-Alive 等待下一个请求),服务端没有"偷跑"消息的通道
- "短轮询和长轮询差在哪?" → 短轮询是客户端定时问"有新消息吗"→ 大部分问都是白问;长轮询是服务端 hold 住连接 → 有新消息才回 → 减少了无意义的空请求
- "长轮询为什么不能取代 WebSocket?" → 长轮询每个连接对应一个线程 → 10 万在线 = 10 万线程 = 不可行。WebSocket 用事件驱动模型,一个线程可以管理几万个连接
- "WebSocket 断了怎么办?" → TCP 连接不是永不失联的 → 心跳检测断连 → 自动重连 → 拉取离线期间的消息补漏
- "为什么 WebSocket 心跳间隔通常用 30 秒而不是 5 秒或 5 分钟?" → 5 秒太频繁,耗电+浪费带宽;5 分钟太长,中间代理(NAT/防火墙/Nginx)可能在 1~2 分钟内断开空闲连接。30 秒是"安全且不浪费"的甜点
小钱这一串问题,本质都是在问:WebSocket 和 HTTP 到底有什么不同?它怎么做到全双工的?心跳为什么要那样设计?如何从玩具级进化到生产级?——这正是"WebSocket 实时通信"要回答的。
# 1.2 选型背后的实时通信知识图谱
四种方案在服务端资源模型中的位置:
短轮询:
客户端 ──请求──→ 服务端(立即查DB/Redis,立刻返回)
循环:每2秒一次 → 浪费带宽 × 低延迟 ×
长轮询:
客户端 ──请求──→ 服务端(hold住,有消息才返回)
返回后立即发起下一个 → 低延迟 ✓ 服务端压力 ×
WebSocket:
客户端 ←──TCP全双工连接──→ 服务端
服务端随时推送 → 低延迟 ✓ 低开销 ✓ 复杂度 △
生产级WebSocket:
客户端 ←──TCP全双工连接──→ 服务端(心跳+重连+离线消息)
全场景覆盖 → 低延迟 ✓ 低开销 ✓ 高可用 ✓
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
四类方案与后续章节的映射关系:
| 方案 | 延迟 | 服务端开销 | 对应章节 |
|---|---|---|---|
| 短轮询 | ~2000ms | 低 | 2.2 |
| 长轮询 | ~50ms | 高 | 2.3 |
| 基础WebSocket | ~10ms | 极低 | 3/4章 |
| 生产级WebSocket | ~10ms | 极低+ | 5/6/7章 |
本章的主线就是沿着这四种方案的进化,一层一层拆解 WebSocket 的握手升级、帧格式、心跳保活和服务端架构。读完之后,你不仅能理解 WebSocket 的原理,还能搭建一个生产级的实时通信系统。
# 02.为什么需要WebSocket
# 2.1 HTTP的单向限制
HTTP 是请求-响应模型:客户端发请求,服务器才回响应。服务器不能主动给客户端发消息。
HTTP的局限:
客户端 ──请求──→ 服务器
客户端 ←──响应── 服务器
✗ 服务器不能主动推送数据给客户端
对于实时应用(聊天、股票行情、在线游戏)来说,这是致命的限制。
2
3
4
5
6
7
8
疑惑:HTTP/2 有 Server Push,能不能替代 WebSocket?
答疑:HTTP/2 Server Push 只能推送资源(如 CSS/JS 文件)——它在浏览器请求 HTML 前就把关联的 CSS 推过去。但它不能推送动态消息——"有人给你发了一条新微信"这不是"资源",Server Push 无法处理这类场景。Server Push 解决的是"静态资源预加载"问题,WebSocket 解决的是"双向实时通信"问题——两者的目标完全不同。
# 2.2 短轮询的代价
短轮询是最简单粗暴的方案:客户端每隔 N 秒发一次 HTTP 请求,问"有没有新消息"。
// 短轮询——最朴素的实现
setInterval(async () => {
const res = await fetch('/api/messages/updates');
const data = await res.json();
if (data.newMessages.length > 0) {
console.log('有新消息:', data.newMessages);
}
}, 2000); // 每2秒查一次
2
3
4
5
6
7
8
短轮询分析(10万在线用户,每2秒轮询一次):
服务端QPS: 10万 / 2秒 = 5万 QPS
有效请求比例: 假设平均每分钟1条新消息
每分钟30次轮询,只有1次有数据
有效比例 = 1/30 ≈ 3.3%
96.7%的请求是白发的!
带宽消耗: 每次请求+响应约500字节(含HTTP头)
5万 × 500B = 25MB/s = 200Mbps
200Mbps只是"能跑"的带宽,还没算TLS和TCP开销
2
3
4
5
6
7
8
9
10
回到小钱的方案A:短轮询在 10 万在线时会产生 5 万 QPS 的无效请求,而且每条消息的最大延迟 = 轮询间隔(2 秒),用户体感明显。
# 2.3 长轮询的改进
长轮询的改进思路:客户端发请求,服务端不立即返回,而是"hold 住"——直到有新消息时才返回。客户端收到响应后立即发起下一个长轮询。
// 服务端长轮询处理(Node.js 示例)
app.get('/api/messages/updates', async (req, res) => {
const userId = req.user.id;
// 等待新消息(带超时)
const timeout = 30000; // 30秒超时
const result = await waitForNewMessage(userId, timeout);
if (result) {
res.json({ newMessages: result.messages });
} else {
res.json({ newMessages: [] }); // 超时,返回空
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
长轮询优势:
✅ 延迟低:有消息立即返回(<50ms)
✅ 无无效请求:hold住连接直到有消息
✅ 兼容性好:纯HTTP,所有代理/防火墙都支持
长轮询劣势(对应方案B):
❌ 服务端资源:每个hold住的请求占用一个线程
10万用户 = 10万线程 = 10万MB栈空间 = 100GB内存!
❌ 队头阻塞:同一个客户端的请求是串行的
如果服务端 hold 住请求A,无法同时处理请求B
❌ 中间代理超时:Nginx默认60秒,超出后可能断开连接
2
3
4
5
6
7
8
9
10
11
# 2.4 SSE单向推送
SSE(Server-Sent Events)是 HTML5 的标准:服务端向客户端单向推送文本事件流。
// 客户端
const evtSource = new EventSource('/api/events');
evtSource.onmessage = (event) => {
console.log('收到消息:', JSON.parse(event.data));
};
// 服务端
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Connection': 'keep-alive',
'Cache-Control': 'no-cache',
});
res.write(`data: ${JSON.stringify(msg)}\n\n`);
2
3
4
5
6
7
8
9
10
11
12
13
SSE 的限制:仅支持文本、仅单向(服务端→客户端)、浏览器支持不如 WebSocket 普及。
# 2.5 四种方案对比
| 方案 | 方向 | 协议 | 延迟 | 服务端开销 | 适用场景 |
|---|---|---|---|---|---|
| 短轮询 | 客户端→服务端 | HTTP | 秒级 | 低(每次请求独立) | 不需要实时的场景 |
| 长轮询 | 客户端→服务端 | HTTP | 毫秒级 | 极高(hold连接) | 兼容性要求高的场景 |
| SSE | 服务端→客户端单向 | HTTP | 毫秒级 | 中(持久连接) | 单向推送(新闻、股票) |
| WebSocket | 双向全双工 | WebSocket | 毫秒级 | 极低(事件驱动) | 聊天、游戏、协同编辑 |
# 03.WebSocket握手与协议升级
# 3.1 HTTP升级机制
WebSocket 不是从零开始的新协议——它巧妙地利用了 HTTP 的 Upgrade 机制,通过一次 HTTP 请求"升级"为 WebSocket 连接。这意味着 WebSocket 连接可以复用 80/443 端口,可以穿越现有的 HTTP 代理和防火墙。
WebSocket 协议栈:
应用层: WebSocket 协议(帧/消息)
传输层: TCP
网络层: IP
和 HTTP 共享传输层和网络层,只是应用层协议不同
2
3
4
5
6
7
# 3.2 握手的完整流程
WebSocket握手过程:
客户端发送HTTP升级请求:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket ← 请求升级为WebSocket
Connection: Upgrade ← 使用Upgrade机制
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ← 随机16字节Base64
Sec-WebSocket-Version: 13 ← 协议版本(目前最新是13)
Sec-WebSocket-Protocol: chat, superchat ← 可选的子协议
Sec-WebSocket-Extensions: permessage-deflate ← 可选的扩展
服务端同意升级:
HTTP/1.1 101 Switching Protocols ← 101状态码:协议切换
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ← Key的SHA-1+Base64
Sec-WebSocket-Protocol: chat ← 服务端选择的子协议
之后的通信就不再是HTTP,而是WebSocket二进制帧协议
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
疑惑:握手为什么还要走 HTTP 格式?直接用二进制握手不行吗?
答疑:HTTP 握手的核心价值是"兼容性":
- 复用 80/443 端口,不需要新开端口
- 可以穿越 HTTP 代理、Nginx 反向代理、CDN
- 可以在 HTTP 层面做认证、路由、负载均衡(Nginx 可以基于握手中的
Host头做路由) - 如果升级失败,可以优雅降级回 HTTP
# 3.3 Sec-WebSocket-Key/Accept的计算
这是握手过程中最核心的安全设计——防止跨协议攻击。
Sec-WebSocket-Accept 的计算:
1. 服务端取客户端发来的 Sec-WebSocket-Key
例:dGhlIHNhbXBsZSBub25jZQ==
2. 拼接固定的魔术字符串(RFC 6455 定义):
dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11
3. 计算 SHA-1 哈希:
SHA-1 → b37a4f2cc0624f1690f64606cf385945b2bec4ea
4. Base64 编码:
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
2
3
4
5
6
7
8
9
10
11
12
13
为什么需要这个魔术字符串?
场景:攻击者在浏览器中构建一个恶意请求,试图让 HTTP 服务器把它误
认为是合法的 WebSocket 握手,从而建立非预期的全双工连接。
没有 Key/Accept 机制:
攻击者可以抓取一个合法的 WebSocket 请求,重放给服务器
有 Key/Accept 机制:
Sec-WebSocket-Key 是随机生成的 → 服务端用特定算法处理后返回
Sec-WebSocket-Accept
→ 确认服务端"理解并同意"WebSocket Upgrade
→ 这不是 HTTP 服务器意外触发的行为
→ 而是服务端明确地选择了升级
2
3
4
5
6
7
8
9
10
11
12
魔术字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 是 RFC 6455 固定定义的 GUID。使用固定字符串确保只有理解 WebSocket 协议的服务器才能计算出正确的 Accept 值。
# 3.4 扩展协商
WebSocket 支持在握手时协商扩展(Extensions):
客户端请求:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
服务端确认:
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits=15
permessage-deflate:对每条消息进行 deflate 压缩
→ 减少带宽消耗(文本消息可压缩 60~80%)
→ 增加 CPU 开销(每条消息需要压缩/解压)
→ 权衡:高延迟网络中值得,内网低延迟场景不必要
2
3
4
5
6
7
8
9
10
# 04.WebSocket帧格式深度解析
# 4.1 帧结构概览
WebSocket 通信不再是 HTTP 文本协议,而是二进制帧协议。这是 WebSocket 低开销的关键——HTTP/1.1 每次请求都要携带几百字节的文本头部,WebSocket 的帧头最少只有 2 字节。
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Extended payload length continued, if payload len == 127 |
+-------------------------------+-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------+-------------------------------+
| Payload Data continued ... |
+---------------------------------------------------------------+
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
最小帧:
[FIN=1, Opcode=1(文本)] [MASK=1, Len=5] [Masking-key(4B)] [Payload(5B)]
1字节 1字节 4字节 5字节
帧头12字节 + 数据5字节 = 17字节
2
3
# 4.2 各字段含义
| 字段 | 位数 | 含义 |
|---|---|---|
| FIN | 1位 | 是否为消息的最后一个分片。1=最后一帧/单帧消息 |
| RSV1/2/3 | 各1位 | 保留位,用于扩展(如压缩) |
| Opcode | 4位 | 帧类型:0x1文本/0x2二进制/0x8关闭/0x9 Ping/0xA Pong |
| MASK | 1位 | 是否使用掩码。客户端→服务端必须为1,服务端→客户端必须为0 |
| Payload Len | 7位 | 载荷长度:0~125=实际长度,126=后续2字节,127=后续8字节 |
| Masking-Key | 0或4字节 | 掩码密钥,仅当MASK=1时存在 |
| Payload Data | 可变 | 实际数据 |
Payload Len 的三种编码:
Len = 0~125: 直接用这7位表示长度
例:Len=5 → Payload 5字节
Len = 126: 后续2字节表示长度(16位无符号整数,网络字节序)
例:0x7E 0x01 0x00 → Payload 256字节
Len = 127: 后续8字节表示长度(64位无符号整数,网络字节序)
例:0x7F 0x00...0x01 0x00 0x00 → Payload 65536字节
2
3
4
5
6
7
8
回到小钱的场景:聊天消息通常 100~500 字节,直接用 7 位长度 + 4 字节掩码 = 每次发送增加 6 字节帧头即可,开销极低。
# 4.3 控制帧:Close/Ping/Pong
| Opcode | 名称 | 作用 |
|---|---|---|
| 0x8 | Close | 关闭连接。携带可选的状态码和原因 |
| 0x9 | Ping | 心跳检测——发送方询问"你还活着吗" |
| 0xA | Pong | 心跳响应——"我还在" |
控制帧可以穿插在数据帧之间发送,并且有优先级——即使数据帧排队,控制帧也要优先处理。
Close 帧的状态码:
1000: 正常关闭
1001: 端点离开(浏览器关闭标签页)
1002: 协议错误
1003: 收到不支持的数据类型
1008: 违反策略
1011: 服务端异常
2
3
4
5
6
# 4.4 掩码设计的深意
疑惑:为什么客户端→服务端必须用掩码,而服务端→客户端不需要?
答疑:掩码的目的不是加密,而是防止缓存投毒攻击。
攻击场景(没有掩码时):
1. 攻击者控制了一个恶意网站 evil.com
2. 用户访问 evil.com,恶意JavaScript通过WebSocket发送数据
3. 攻击者精心构造的WebSocket数据:
0x47455420... (即 "GET /secret HTTP/1.1\r\n...")
4. 这段数据经过透明代理服务器时,代理服务器解析到
"GET /secret HTTP/1.1" → 误认为是一个HTTP请求
5. 代理服务器把响应缓存下来(缓存投毒)
6. 后续其他用户访问 /secret 时,得到的是被投毒的响应
掩码的作用:
攻击者发送同样的数据 → 但与4字节随机掩码Key做XOR
→ 变成乱码 → 透明代理无法将其识别为HTTP请求
→ 攻击失效
因为Key是随机的(客户端每次生成),透明代理无法预测最终XOR后的数据
服务端→客户端为什么不需要掩码?
→ 浏览器没有"解析响应为HTTP请求"的机制
→ 缓存投毒攻击对客户端不安全的风险不存在
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
掩码不是加密——因为 Key 就在帧头里,任何人拿到帧都可以还原出原始数据。掩码的真实目的是让数据"看起来不像 HTTP 请求"。
# 4.5 分片传输
一条 WebSocket 消息可以被分成多个帧(分片)。通过 FIN 位来控制:
发送一条大消息"Hello World"(不必要分片时):
[FIN=1, opcode=0x1, payload="Hello World"] ← 单帧
分片发送:
[FIN=0, opcode=0x1, payload="Hello "] ← 第1帧(开始)
[FIN=0, opcode=0x0, payload="World"] ← 第2帧(继续)
[FIN=1, opcode=0x0, payload="!"] ← 第3帧(结束)
分片规则:
- 第一帧:opcode!==0x0,FIN=0(除非正好一帧)
- 中间帧:opcode===0x0,FIN=0
- 最后帧:opcode===0x0,FIN=1
- 控制帧可以穿插在分片帧之间
2
3
4
5
6
7
8
9
10
11
12
13
# 05.心跳与保活机制
# 5.1 为什么需要心跳
WebSocket 连接基于 TCP,TCP 连接本身有 Keep-Alive 机制。那为什么还需要应用层心跳?
TCP Keep-Alive vs WebSocket Ping/Pong:
TCP Keep-Alive(传输层):
- 默认间隔:2小时
- 只检测TCP连接是否存活
- 不检测应用层是否正常(进程 hang 住但 TCP 连接还活着)
- 无法穿越某些中间设备
WebSocket Ping/Pong(应用层):
- 自定义间隔:通常30~60秒
- 检测服务端/客户端是否能正常响应
- 可以携带业务数据
- 维持中间设备的连接表
2
3
4
5
6
7
8
9
10
11
12
13
穿透 NAT/防火墙/负载均衡器:
中间设备的连接超时:
NAT: 通常 1~5 分钟
防火墙: 通常 30~60 分钟
Nginx: 默认 keepalive_timeout 65秒
AWS ELB: 默认 60 秒
如果心跳间隔 > 最短的中间设备超时 → 空闲期间连接被设备关闭
→ 双发方然不知道 → 出现"僵尸连接"
这就是为什么心跳间隔一般为 30 秒的原因:
→ 比 Nginx 的 65 秒短
→ 比 NAT 的 1~5 分钟短
→ 比 TCP Keep-Alive 的 2 小时短得多
2
3
4
5
6
7
8
9
10
11
12
13
14
# 5.2 Ping/Pong帧的使用
// 服务端心跳实现
const HEARTBEAT_INTERVAL = 30000; // 30秒
const HEARTBEAT_TIMEOUT = 10000; // 等待Pong的超时时间
ws.on('connection', (socket) => {
socket.isAlive = true;
// 收到客户端的Pong → 标记存活
socket.on('pong', () => {
socket.isAlive = true;
});
// 定时发送Ping
const pingTimer = setInterval(() => {
if (!socket.isAlive) {
// 上次Ping没有收到Pong → 连接已死
clearInterval(pingTimer);
socket.terminate();
return;
}
socket.isAlive = false;
socket.ping(); // 发送Ping帧
// 10秒后检查(给Pong响应留时间)
setTimeout(() => {
if (!socket.isAlive) {
socket.terminate();
}
}, HEARTBEAT_TIMEOUT);
}, HEARTBEAT_INTERVAL);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 5.3 中间代理的超时
回到小钱的方案D:即使心跳正确配置,Nginx 作为反向代理时,也需要配置合理的超时:
server {
location /ws/ {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# ⚠️ 关键:代理的超时必须大于心跳间隔
proxy_read_timeout 60s; # 默认60秒,心跳30秒需要这个 > 30
proxy_send_timeout 60s;
# 禁用缓冲(WebSocket是实时协议)
proxy_buffering off;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
如果 proxy_read_timeout < 心跳间隔,即使客户端和 WebSocket 服务端之间有心跳交互,Nginx 在超时后会主动断开连接。
# 5.4 重连策略设计
// 客户端重连策略——指数退避 + 随机抖动
class WebSocketClient {
constructor(url) {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectDelay = 30000; // 最大30秒
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0; // 连接成功,重置计数器
console.log('WebSocket 已连接');
};
this.ws.onclose = (event) => {
if (!event.wasClean) {
this.reconnect();
}
};
}
reconnect() {
this.reconnectAttempts++;
// 指数退避:1s → 2s → 4s → 8s → 16s → 30s(Max)
const delay = Math.min(
this.maxReconnectDelay,
1000 * Math.pow(2, this.reconnectAttempts - 1)
);
// 加随机抖动:避免大量客户端同时重连
const jitter = Math.random() * 1000;
setTimeout(() => this.connect(), delay + jitter);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 06.服务端架构设计
# 6.1 连接管理
生产级的 WebSocket 服务需要维护一个"连接注册表"——记录谁在线、哪个连接属于哪个用户、用户的元信息。
// 连接管理器
class ConnectionManager {
constructor() {
// userId → Set<WebSocket>
this.connections = new Map();
}
add(userId, socket) {
if (!this.connections.has(userId)) {
this.connections.set(userId, new Set());
}
this.connections.get(userId).add(socket);
}
remove(userId, socket) {
const sockets = this.connections.get(userId);
if (sockets) {
sockets.delete(socket);
if (sockets.size === 0) {
this.connections.delete(userId);
}
}
}
// 给特定用户发消息(支持多端登录)
sendToUser(userId, message) {
const sockets = this.connections.get(userId);
if (sockets) {
const data = JSON.stringify(message);
sockets.forEach(socket => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 6.2 消息广播
// 广播给所有在线用户
broadcast(message) {
const data = JSON.stringify(message);
this.connections.forEach((sockets) => {
sockets.forEach(socket => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data);
}
});
});
}
// 广播给指定房间
broadcastToRoom(roomId, message) {
const room = this.rooms.get(roomId);
if (room) {
const data = JSON.stringify(message);
room.forEach(userId => {
this.sendToUser(userId, message);
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 6.3 水平扩展
单机 WebSocket 服务有连接数上限(通常 5~10 万)。需要水平扩展时,最大的挑战是:不同服务器上的用户如何互相通信?
方案1:粘性会话(Sticky Session)
负载均衡器根据 Cookie/IP 将同一用户始终路由到同一服务器
简单但不够灵活
方案2:消息队列(推荐)
WebSocket Server1 ─┐
├── Redis Pub/Sub ── WebSocket Server2
WebSocket Server2 ─┘
用户A在Server1 → 发送消息 → Redis Pub/Sub → Server2 → 推送给用户B
方案3:外部存储 + 拉模式
发消息时写入Redis → 各服务器定期拉取或订阅 → 推送给连接在本机的用户
2
3
4
5
6
7
8
9
10
11
12
13
// 基于 Redis Pub/Sub 的跨服务器消息同步
const redis = require('redis');
const pub = redis.createClient();
const sub = redis.createClient();
sub.subscribe('chat:messages');
sub.on('message', (channel, message) => {
const { targetUserId, data } = JSON.parse(message);
// 只推送给在本服务器的目标用户
connectionManager.sendToUser(targetUserId, data);
});
// 发送消息时
function sendMessage(fromUserId, toUserId, content) {
// 1. 持久化到DB
db.saveMessage({ from: fromUserId, to: toUserId, content });
// 2. 发布到Redis → 所有服务器都能收到
pub.publish('chat:messages', JSON.stringify({
targetUserId: toUserId,
data: { from: fromUserId, content }
}));
// 3. 如果目标用户在本服务器,也会通过订阅收到并推送
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 6.4 背压处理
当消息生产速度 > 消费速度时,需要处理背压(Back Pressure):
// 背压处理
ws.on('message', (data) => {
// 检查发送缓冲区
if (ws.bufferedAmount > 1024 * 1024) { // 超过1MB
console.warn('客户端背压,缓冲区超过1MB');
// 策略1:丢弃消息
// 策略2:暂停读取
ws.pause(); // Node.js 流
}
});
// 缓冲区降到安全水平时恢复
ws.on('drain', () => {
ws.resume();
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 07.安全与优化
# 7.1 WebSocket安全风险
WebSocket主要安全风险:
1. 跨站WebSocket劫持(Cross-Site WebSocket Hijacking)
攻击者网站利用用户已认证的Cookie发起WebSocket连接
防御:验证Origin头部 + CSRF Token
2. 未授权访问
WebSocket握手可以携带Cookie/Token → 必须验证身份
防御:在upgrade阶段验证JWT/Cookie
3. 拒绝服务(DoS)
恶意客户端创建大量连接不释放
防御:限制单IP最大连接数、设置连接超时
4. 消息注入
攻击者发送恶意消息(XSS、SQL注入等)
防御:对消息内容做校验和转义
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7.2 消息压缩
WebSocket 支持 permessage-deflate 扩展,对每条消息进行压缩。对于文本消息(JSON聊天),压缩率通常在 60~80%。
权衡:
压缩开启:CPU ↑ 10~20%,带宽 ↓ 60~80%
适合:高延迟网络、大量文本消息
不适合:内网低延迟、已经压缩的二进制数据(图片、视频)
2
3
4
# 7.3 二进制传输
WebSocket 支持二进制帧(opcode=0x2),可用于传输图片、文件、Protobuf 等。
// 发送二进制数据
const buffer = new ArrayBuffer(1024);
ws.send(buffer);
// 接收二进制数据
ws.on('message', (data) => {
if (data instanceof ArrayBuffer) {
// 处理二进制数据
} else {
// 处理文本数据
}
});
2
3
4
5
6
7
8
9
10
11
12
二进制传输避免了 Base64 编码带来的 33% 体积膨胀。
# 7.4 连接数优化
单机WebSocket连接数上限:
操作系统限制:
Linux:ulimit -n 65536(可调到 100万+)
macOS:默认 256
应用层限制:
Node.js:默认无硬限制(受限于内存)
Go:goroutine 开销量极低,轻松 10 万+
Java Netty:受限于线程模型(通常 5~10 万)
优化建议:
- 每个连接内存开销约 50~100KB
- 10万连接 ≈ 5~10GB 内存
- 使用消息队列做水平扩展
- 心跳间隔不宜过短(30秒是甜点)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 08.综合案例:从0到1搭建实时聊天系统
本章用一个贯穿全文的实战案例——从一个"能发消息就行"的聊天系统开始,经历四次进化,最终成为生产级的实时通信系统。
# 8.1 案例背景与目标
社交 App 聊天模块:支持单聊,在线用户 10 万,消息延迟目标 < 100ms。
| 版本 | 方案 | 延迟 | 10万在线代价 |
|---|---|---|---|
| V1 | 短轮询 | ~2000ms | 低(5万QPS但96%无效) |
| V2 | 长轮询 | ~200ms | 极高(10万线程) |
| V3 | WebSocket | ~10ms | 极低(事件驱动) |
| V4 | 生产级WebSocket | ~10ms | 极低+(心跳+重连+分布式) |
# 8.2 第一代:短轮询——能跑就行
// 客户端
setInterval(async () => {
const res = await fetch('/api/messages/poll');
const data = await res.json();
data.messages.forEach(msg => renderMessage(msg));
}, 2000);
2
3
4
5
6
V1 表现:
延迟:平均 1000ms(2秒轮询的一半)
无效请求比例:96.7%
10万用户带宽:200Mbps +
瓶颈:延迟高(对应方案A的"已读延迟2秒")
2
3
4
5
6
# 8.3 第二代:长轮询——假装实时
// 服务端(Node.js)
app.get('/api/messages/long-poll', async (req, res) => {
const userId = req.user.id;
const lastMsgId = parseInt(req.query.since) || 0;
// hold住连接,等待新消息(最多30秒超时)
const timeout = 30000;
const messages = await waitForMessages(userId, lastMsgId, timeout);
res.json({ messages: messages || [] });
});
2
3
4
5
6
7
8
9
10
11
V2 表现:
延迟:~200ms(有消息立即返回)
10万用户服务端:10万线程 = 无法部署
中间代理:Nginx超时/断开风险
瓶颈:资源消耗(对应方案B的"线程池资源吃紧")
2
3
4
5
6
# 8.4 第三代:WebSocket——真正的全双工
// 服务端(Node.js ws库)
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
const clients = new Map(); // userId → WebSocket
wss.on('connection', (ws, req) => {
// 从URL参数中提取userId(生产环境应验证Token)
const userId = new URL(req.url, 'http://localhost').searchParams.get('userId');
clients.set(userId, ws);
ws.on('message', (data) => {
const msg = JSON.parse(data);
// 转发给目标用户
const targetWs = clients.get(msg.to);
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
targetWs.send(JSON.stringify(msg));
}
});
ws.on('close', () => {
clients.delete(userId);
});
});
// 客户端
const ws = new WebSocket('ws://localhost:8080?userId=123');
ws.onopen = () => ws.send(JSON.stringify({ to: 456, text: 'Hello!' }));
ws.onmessage = (event) => renderMessage(JSON.parse(event.data));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
V3 表现:
延迟:~10ms
10万用户内存:约5GB
消息延迟目标达成 ✓
缺陷:
❌ 无心跳 → 连接断开不知道
❌ 无重连 → 网络切换后永久断连
❌ 无离线消息 → 断连期间消息丢失
❌ 单机瓶颈 → 无法水平扩展
2
3
4
5
6
7
8
9
10
# 8.5 第四代:生产级WebSocket——全场景覆盖
// 服务端(完整生产级实现)
const WebSocket = require('ws');
const redis = require('redis');
const jwt = require('jsonwebtoken');
const wss = new WebSocket.Server({ port: 8080 });
const pub = redis.createClient();
const sub = redis.createClient();
sub.subscribe('chat:messages');
// 连接管理器
class ChatServer {
constructor() {
this.clients = new Map(); // userId → WebSocket
this.heartbeatInterval = 30000;
this.heartbeatTimeout = 10000;
}
handleConnection(ws, req) {
// 1. 身份验证
const token = new URL(req.url, 'http://localhost').searchParams.get('token');
let userId;
try {
userId = jwt.verify(token, SECRET).userId;
} catch(e) {
ws.close(1008, '认证失败');
return;
}
// 2. 注册连接
this.clients.set(userId, ws);
ws.isAlive = true;
ws.userId = userId;
// 3. 拉取离线消息
this.sendOfflineMessages(ws, userId);
// 4. 心跳检测
ws.on('pong', () => { ws.isAlive = true; });
const pingTimer = setInterval(() => {
if (!ws.isAlive) { ws.terminate(); clearInterval(pingTimer); return; }
ws.isAlive = false;
ws.ping();
}, this.heartbeatInterval);
// 5. 消息处理
ws.on('message', (data) => this.handleMessage(userId, JSON.parse(data)));
// 6. 断连清理
ws.on('close', () => {
clearInterval(pingTimer);
this.clients.delete(userId);
// 记录最后在线时间,供离线消息拉取使用
redis.set(`user:${userId}:lastOnline`, Date.now());
});
}
async handleMessage(fromUserId, msg) {
// 持久化
await db.saveMessage({ from: fromUserId, to: msg.to, text: msg.text });
// 在线 → 直接推送
if (this.clients.has(msg.to)) {
this.clients.get(msg.to).send(JSON.stringify(msg));
} else {
// 离线 → 缓存到Redis
redis.lpush(`offline:${msg.to}`, JSON.stringify(msg));
redis.expire(`offline:${msg.to}`, 86400 * 7); // 7天
}
// 跨服务器同步(Redis Pub/Sub)
pub.publish('chat:messages', JSON.stringify({ to: msg.to, msg }));
}
async sendOfflineMessages(ws, userId) {
const messages = await redis.lrange(`offline:${userId}`, 0, -1);
if (messages.length > 0) {
ws.send(JSON.stringify({ type: 'offline', messages }));
redis.del(`offline:${userId}`);
}
}
}
// Redis Pub/Sub → 跨服务器接收消息
sub.on('message', (channel, data) => {
const { to, msg } = JSON.parse(data);
if (chatServer.clients.has(to)) {
chatServer.clients.get(to).send(JSON.stringify(msg));
}
});
const chatServer = new ChatServer();
wss.on('connection', (ws, req) => chatServer.handleConnection(ws, req));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
V4 表现:
延迟:~10ms
心跳检测:30秒 Ping/Pong
自动重连:指数退避 + 随机抖动
离线消息:Redis 缓存 → 重连后补推
水平扩展:Redis Pub/Sub 跨服务器同步
10万用户:单机 5GB 内存,通过负载均衡可做多台
2
3
4
5
6
7
# 8.6 四种方案横向对比
| 维度 | V1 短轮询 | V2 长轮询 | V3 基础WebSocket | V4 生产级WebSocket |
|---|---|---|---|---|
| 消息延迟 | ~1000ms | ~200ms | ~10ms | ~10ms |
| 10万在线开销 | 5万QPS(96%无效) | 10万线程(不可行) | 5GB内存 | 5GB内存 |
| 全双工 | ❌ | ❌ | ✅ | ✅ |
| 心跳检测 | ❌ | ❌ | ❌ | ✅(30s Ping/Pong) |
| 自动重连 | ❌ | ❌ | ❌ | ✅(指数退避) |
| 离线消息 | ❌ | ❌ | ❌ | ✅(Redis缓存) |
| 水平扩展 | ✅(无状态) | ✅(无状态) | ❌(单机) | ✅(Pub/Sub) |
| 对应章节 | 2.2 | 2.3 | 3/4章 | 5/6/7章 |
# 8.7 案例升华:WebSocket的设计哲学
WebSocket 协议的三个设计哲学:
1. "升级而不重来"
WebSocket没有自建一套传输层协议
而是借用了HTTP的Upgrade机制 + TCP
→ 复用80/443端口、穿越代理、兼容现有基础设施
2. "帧头极简,数据高效"
最小2字节帧头 vs HTTP几百字节请求头
→ 适合高频小消息(聊天、股票、游戏状态同步)
3. "应用层控制,传输层兜底"
WebSocket自己管理心跳(Ping/Pong)、重连、会话
不完全依赖TCP Keep-Alive
→ 应用层比传输层更了解"业务健康"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WebSocket vs HTTP 关键差异总结:
| 维度 | HTTP/1.1 | WebSocket |
|---|---|---|
| 通信模式 | 请求-响应(半双工) | 全双工双向 |
| 消息边界 | 无(字节流) | 有(帧) |
| 头部开销 | 每次请求~500字节 | 最少2字节/帧 |
| 连接生命周期 | 短(请求完释放) | 长(持久连接) |
| 服务端推送 | ❌ | ✅ |
| 二进制支持 | 需Base64编码 | 原生支持 |
# 8.8 全文知识图谱回顾
小钱的技术选型
│
┌───────┬───────┼───────┬───────┐
│ │ │ │ │
短轮询 长轮询 WebSocket 心跳 分布式
延迟2s 线程多 升级握手 Ping Pub/Sub
[2.2] [2.3] [3.2] [5.2] [6.3]
│ │ │ │ │
└───────┴───────┼───────┴───────┘
│
┌───────────┴───────────┐
│ │
帧格式 [4章] 安全与优化 [7章]
FIN/Opcode/MASK 压缩/二进制/DoS
│ │
└───────────┬───────────┘
│
V1→V2→V3→V4 实时聊天系统的四次进化
[第8章] 从轮询到生产级WebSocket
│
▼
升级而不重来 / 帧头极简 / 应用层控制
WebSocket的三大设计哲学 [8.7节]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
最终的方法论沉淀——设计实时通信系统时,都应该问自己三个问题:
- 延迟能接受吗?(轮询 vs 长轮询 vs WebSocket → 不同场景不同取舍)
- 断了怎么办?(心跳 + 重连 + 离线消息 → 网络不是 100% 可靠的)
- 能扩展吗?(单机 vs Pub/Sub 集群 → 用户量增长时的架构演进)
把这三个问题问到位,你就从"会用 WebSocket"进化到了"能设计实时通信系统的工程师"。
# 09.思考题与作业
# 9.1 基础思考题
WebSocket vs HTTP 长轮询:在 10 万用户同时在线、每条消息 500 字节的场景下,分别计算 WebSocket 和长轮询的服务端线程/内存开销。为什么长轮询不能支撑 10 万并发?
掩码不是为了加密:客户端→服务端的 WebSocket 帧必须用掩码。掩码 Key 就在帧头里,任何人都可以还原。那掩码到底保护了什么?还原一个具体的缓存投毒攻击场景。
Ping/Pong 的间隔:心跳间隔设为 5 秒和 5 分钟各有什么问题?为什么 30 秒是"甜点"?如果 Nginx 的
proxy_read_timeout=30s,心跳间隔应该设为多少?Sec-WebSocket-Key/Accept 的计算:给定
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==,手动计算Sec-WebSocket-Accept。(提示:拼接魔术字符串 → SHA-1 → Base64)
# 9.2 进阶思考题
HTTP/2 Server Push 能否替代 WebSocket?:HTTP/2 的 Server Push 可以实现服务端推送。它和 WebSocket 的本质区别是什么?在什么场景下 Server Push 可以部分替代 WebSocket?什么场景绝对不能?
WebSocket 分片的工程价值:WebSocket 支持分片传输(FIN 位控制),但在实际开发中很少使用。在什么场景下分片是必要的?如果不分片,直接
send(smallMessage)和send(largeMessage)有什么区别?(提示:从内存分配、TCP 拥塞控制角度思考)粘性会话 vs Pub/Sub 的取舍:WebSocket 水平扩展有 Sticky Session 和 Pub/Sub 两种方案。粘性会话简单但不够灵活,Pub/Sub 灵活但有额外延迟。在什么业务场景下,粘性会话就够了?在什么场景下必须用 Pub/Sub?
WebTransport 对 WebSocket 的挑战:WebTransport(基于 HTTP/3 QUIC)正在标准化,它提供了比 WebSocket 更多的能力(无序传输、部分可靠性、多流)。WebSocket 会被替代吗?什么场景仍然适合用 WebSocket?
# 9.3 动手作业
作业一(必做):用 Node.js 实现一个简单的 WebSocket 聊天室。
- 服务端:
ws库监听 8080 端口,接收消息并广播给所有连接的客户端。 - 客户端:浏览器原生 WebSocket API 连接服务端,发送和接收消息。
- 测试:打开两个浏览器标签页,验证消息能双向互通。
- 进阶:添加 Ping/Pong 心跳,断开时在服务端日志中打印"连接断开"。
作业二(选做):对比轮询 vs WebSocket 的性能。
- 分别实现短轮询(2秒间隔)和 WebSocket 两个版本的简单聊天。
- 用浏览器 DevTools 的 Network 面板对比:
- 1 分钟内的请求数
- 总传输字节数
- 消息从发送到接收的延迟
| 方案 | 1分钟请求数 | 总传输字节 | 平均延迟 |
|---|---|---|---|
| 短轮询(2s) | |||
| WebSocket |
作业三(架构思考):设计一个支持 100 万在线的 WebSocket 架构。
- 画出架构图:负载均衡器 → WebSocket 集群 → Redis Pub/Sub → 消息持久化(MySQL/Kafka)。
- 标注每层的连接数、内存消耗、单点故障风险。
- 给出具体的技术选型(WebSocket 框架、消息队列、持久化方案)。