1. WebSocket到底是什么:从一次脉脉消息推送说起
屏幕突然亮起来,锁屏页弹出脉脉的一条新通知:有人查看了你的职业档案,附带着一个头像和一个公司名。指尖点进去,消息列表里那条“谁看过我”已经躺在那里,没有重新加载,没有转圈刷新,一切像是秒级到达。
这个场景放在今天你觉得理所当然,但早个十年,要让一个网页在不出浏览器的情况下实时收到服务器主动推送的消息,是个相当折腾的事。而改变这一切的底层技术,就是 WebSocket。它也是我在很长一段时间里真正“用懂”之后,才敢说自己入了实时通信这个门的核心协议。
这篇文章我打算用最坦白的方式聊一聊 WebSocket 实时通信的入门路径——协议怎么握手、数据帧长什么样、心跳断线如何应对、抓包怎么看、生产环境有哪些坑。之所以标题里要提一嘴“脉脉”,是因为当初我就是一边拿浏览器开发者工具观察脉脉这类真实应用的消息推送,一边对着协议文档一点点试出来的。比起干啃 RFC 6455,有个看得见摸得着的参照物,入门速度快很多。
这篇文章不只写给后端,前端、客户端、运维、刚转行做全栈的朋友都能看。只要你需要在自己的项目里做消息推送、在线聊天、实时通知或者看板刷新,WebSocket 几乎是你绕不开的第一站。看完你至少能回答三个问题:WebSocket 解决了什么、一条消息从服务器到你屏幕上经历了什么、真出问题了我该从哪里下手查。
1.1 从 HTTP 轮询到长连接,实时通信经历了什么
要理解 WebSocket 的价值,得先看看它出现之前大家是怎么做“实时”的。
最原始的做法是轮询,前端每隔一两秒向后端发一次 HTTP 请求,问一句“有没有新消息”。这种方式实现起来几乎没有门槛,但问题也肉眼可见:大部分请求是空手而归的,服务器白忙活,带宽白白占用。哪怕把间隔拉到五秒,消息的实时性也只能说“差不多”,不是真正意义上的即时。要是请求频率一高,数据库和负载都会被无谓的查询拖垮,这就是著名的“C10K 问题”在应用层的常见体现之一。
后来有人发明了长轮询,也就是前端发请求过去,服务器不着急响应,等真有新消息了再返回,或者等个几十秒超时再让前端重新连。这种方式比普通轮询实时性好一些,但本质上还是“一问一答”,每来一条消息都要重新走一遍 HTTP 请求头、响应头,浪费依然存在,而且服务器的连接管理变得很复杂。再后来还有 Flash Socket、ActiveX 之类的方案,都因为兼容性、安全性、平台限制没能成为通用标准。
WebSocket 的思路完全不同。它一开始也只发一个 HTTP 请求,但目的不是拿数据,而是“申请升级协议”。服务器同意后,双方之间的连接就从 HTTP 变成了 WebSocket,这是一条全双工的、长久的、轻量级的通道。全双工的意思是客户端和服务端都可以随时发消息,不用等对方先开口。类比一下,HTTP 像寄信,你来我往,每封信都有完整信封和邮戳;WebSocket 像是两个人之间拉了一根专线电话,拨通之后谁想说就说,说多久都行。
1.2 WebSocket 与 HTTP 的核心区别
两者不是取代关系,而是分工关系。HTTP 仍然负责绝大多数请求响应场景——打开网页、提交表单、调用接口。WebSocket 则专门为“连接建立之后需要持续双向通信”的场景而生。
| 维度 | HTTP | WebSocket |
|---|---|---|
| 通信模式 | 半双工,请求-响应 | 全双工,双向随时推送 |
| 连接生命周期 | 一次请求一次响应后即结束 | 一次握手,长连接,直到主动关闭 |
| 消息开销 | 每次请求都要带头部,动辄几百字节 | 帧很小,文本帧仅需额外几个字节 |
| 实时性 | 取决于轮询频率 | 秒级甚至毫秒级 |
| 典型场景 | 页面加载、 REST API、表单提交 | IM 聊天、行情推送、在线协同、多人在线游戏 |
简单记一句话:HTTP 是“你问我答”,WebSocket 是“我们连线聊”。
1.3 脉脉里的哪些场景在用 WebSocket
回到脉脉这个 App 和它的网页版。你打开开发者工具,切到 Network 面板,刷新一下页面,找到类型是 WS 的请求,会看到一条以 ws:// 或 wss:// 开头的连接。这条连接就是它做实时推送的命脉。
我当初观察到的典型场景有这么几类。一是新消息提醒,有人给你发私信或者评论你的动态,服务器通过这条 WebSocket 连接主动推给你,不需要你手动刷新页面。二是“谁看过我”这类动态通知,大多数情况下不是实打实即时推送,而是服务端检测到你在线后通过连接下发。三是在线状态,你在聊天列表里看到对方“在线”或“离开”,背后也是 WebSocket 维持心跳并同步状态的结果。
如果你还没有留意过真实应用的连接长什么样,我强烈建议你打开浏览器开发者工具,随便找一个带实时功能的网站,点开 Network 面板,过滤 WS,然后一边操作页面一边观察消息列表。这种“偷师”式的学习方法,比看十篇理论文章都有用。这也是为什么我把脉脉称作我的“好搭档”——它就是我的活体协议教学标本。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WebSocket 核心机制拆解:握手、数据帧与会话生命周期
很多人学 WebSocket 的时候被卡住,不是因为不会写代码,而是因为对协议本身没有一个整体画面。其实整个 WebSocket 会话就分四个阶段:握手建立连接、双向传输数据帧、心跳保活、关闭连接。把这四个阶段搞明白,后面写代码、调 bug 都会顺很多。
2.1 建立连接:HTTP Upgrade 握手过程
WebSocket 连接不是凭空从 TCP 上变出来的,而是靠 HTTP 协议“升级”过来的。客户端先发起一个普通 TCP 连接,然后发送一个带着升级意图的 HTTP 请求。
客户端发送的请求头大致长这样:
http复制GET /websocket HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
这里面的核心字段有三个。Upgrade: websocket 和 Connection: Upgrade 告诉服务器“我想升级协议”。Sec-WebSocket-Version: 13 表示当前使用的协议版本号,13 对应 RFC 6455,也就是现在的通用标准。Sec-WebSocket-Key 是一个随机生成的 Base64 字符串,用于让服务器证明它真的支持 WebSocket。
服务器收到请求后,如果同意升级,会返回这样的响应:
http复制HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
状态码 101 表示“我同意切换协议”。Sec-WebSocket-Accept 不是随便生成的,它的计算规则是固定的:把客户端传来的 Sec-WebSocket-Key 加上一个固定的 GUID 字符串(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做一次 SHA-1 哈希,再对结果做 Base64 编码。
之所以要加 GUID 做校验,是为了防止普通的 HTTP 缓存代理误把 WebSocket 升级请求当成普通 GET 请求缓存下来,同时也保证握手双方确实都理解 WebSocket 协议。任何一个以“ WebSocket 服务器”自居的服务端,都必须能正确算出这个 Accept 值。
2.2 数据帧结构:二进制视角看一条消息
握手完成后,双方之间传输的就不再是 HTTP 报文,而是 WebSocket 数据帧。很多人第一次看帧格式会头皮发麻,其实结构并不复杂,把每个比特的位置对上就行。一个数据帧大概长这样:
code复制 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| | |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+-------------------------------+
| Extended payload length continued, if payload len == 126 |
+-------------------------------+-------------------------------+
| Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Payload Data |
+-------------------------------+-------------------------------+
翻译成人话,关键信息就四个。
第一个是 FIN 位,占 1 bit,标记这一帧是不是消息的最后一帧。如果一条消息比较大,发送方可以把它拆成多个帧,除了最后一帧,其余帧的 FIN 都是 0。
第二个是 opcode,占 4 bit,用来区分帧的类型。0x0 表示这是继续帧,0x1 表示文本帧,0x2 表示二进制帧,0x8 是关闭连接帧,0x9 是 ping 帧,0xA 是 pong 帧。
第三个是 MASK 位,占 1 bit,表示客户端发给服务器的数据是否加了掩码。按照协议规定,客户端到服务器的帧必须设置 MASK,服务器到客户端的帧不能设置。所以你在抓包工具里看到的客户端发送帧,都带一个 4 字节的 Masking-key,服务端返回的帧则没有。
第四个是 Payload len,占 7 bit,表示载荷数据的长度。如果值小于 126,那这个值就是实际长度。如果等于 126,接下来会有 2 个字节表示真正的长度。如果等于 127,接下来会有 8 个字节表示长度。这种设计是为了让短消息更节省空间,同时又支持超大消息。
理解帧结构有什么用?最直接的用处是排查问题。比如你抓包看到客户端发出去的帧没有 MASK,就知道这不是标准 WebSocket 实现,服务器拒绝是正常的。再比如你发现收到的消息总是缺尾巴,那很可能是处理分片消息时没有把 FIN=0 的继续帧拼完。
2.3 心跳机制与保活
WebSocket 连接建立之后不是永久稳定的。中间网络设备、代理服务器、NAT 网关,任何一个环节都可能把空闲太久的连接回收掉。这个现象在移动端尤其明显,手机熄屏之后系统会休眠网络,运营商网关也会回收长时间无流量的连接。
解决思路是心跳。WebSocket 协议原生提供了 ping 帧和 pong 帧,一端发 ping,另一端必须回 pong,否则就认为连接已经不健康,可以主动断开重连。但在实际业务里,光有协议层心跳还不够,很多团队还会在业务层再做一层心跳——比如每 30 秒通过 WebSocket 发送一个 {"type":"heartbeat","timestamp":...} 的 json 消息,服务器收到后返回一个对应的 ack。协议层心跳是底层兜底,业务层心跳是应用层确认,两个都做,双保险。
我在刚上手的时候犯过一个典型的错:只做了业务层心跳,没做协议层心跳。在那之前我对 WebSocket 的认知只停留在“长连接不会被断开”的错觉上,结果在真实网络环境里,连接静默超时三分钟之后就再也收不到任何消息了,前端还没报错,只是在某个时间点之后所有推送都停了。排查了整整一天,最后打开抓包工具才发现连接早被中间设备悄悄掐断了。
2.4 关闭连接与状态码
连接不会永远存在,总有断开的时候。断开有两种,一种是正常断开,比如用户关页面、退出登录、服务器主动踢掉某条连接;另一种是异常断开,比如网络中断、进程崩溃、对端没有响应。
正常关闭时,一端会发送一个 opcode 为 0x8 的关闭帧,里面可以带上一个关闭码和原因字符串。常见的关闭码有这么几个:
| 关闭码 | 含义 | 常见场景 |
|---|---|---|
| 1000 | 正常关闭 | 客户端主动退出或服务端主动结束 |
| 1001 | 正在离开 | 页面跳转、服务器重启 |
| 1002 | 协议错误 | 帧格式不对、握手有问题 |
| 1003 | 不支持的数据类型 | 收到了无法处理的二进制数据 |
| 1006 | 异常关闭 | 连接直接断开,没有关闭帧 |
| 1007 | 数据不一致 | 文本帧里包含非法字符 |
| 1009 | 消息过大 | 超出了配置的最大帧长度 |
前端 onclose 事件里能拿到关闭码和原因字符串。后端日志里也一定要记录关闭码,排查的时候能省很多事。比如我看到 1006 就知道多半是网络层的问题,不用再往业务逻辑里钻。而看到 1009 就要检查是不是消息体太大,服务端有默认限制而你没有调。
3. 从零写一个 WebSocket 实时推送 Demo:Python 后端加前端页面
理论说了那么多,不动手写代码等于白看。这一节我用 Python 写一个极简的 WebSocket 推送服务端,再用纯 HTML 加 JavaScript 写一个前端页面,让浏览器和服务器真正建立起一条实时通道。选 Python 是因为它语法清晰、WebSocket 库生态成熟、入门门槛最低,哪怕你的主力语言是 Java 或 Go,这个示例也足够让你看懂 WebSocket 交互的完整流程。
3.1 准备工作与依赖安装
我用的 Python 版本是 3.10 以上,依赖库是 websockets,这是目前 Python 社区最主流的 WebSocket 库,纯异步实现,支持 RFC 6455。安装只需要一条命令:
bash复制pip install websockets
如果你是刚接触 Python 的环境,建议先创建一个虚拟目录再安装依赖,避免污染全局环境:
bash复制python -m venv ws-demo
source ws-demo/bin/activate # Windows 下是 ws-demo\Scripts\activate
pip install websockets
websockets 库的 API 在 10.x 和 11.x 之间有比较大的变化,老版本用 websockets.serve 加 asyncio.get_event_loop().run_until_complete() 的方式,新版本推荐直接用 websockets.serve 配合 asyncio.run()。为了避免旧教程把你带沟里,我这个示例直接按当前稳定版 API 来写。
3.2 服务端实现:一个能主动推送的 WebSocket 服务
先写一个最简单的服务端,能接收连接、能接收客户端消息、能主动向连接的客户端推送消息。我这里加了一个 connection_manager,把当前所有活跃的连接放在一个集合里,这样后续做“广播”和“定向推送”都很方便。
python复制import asyncio
import json
import websockets
# 保存所有活跃连接的集合
active_connections = set()
def get_current_time():
from datetime import datetime
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
async def handle_connection(websocket):
# 连接建立时,把这个连接加入集合
active_connections.add(websocket)
print(f"[{get_current_time()}] 客户端连接建立,当前连接数: {len(active_connections)}")
try:
# 接收客户端的消息
async for message in websocket:
print(f"[{get_current_time()}] 收到客户端消息: {message}")
# 解析 JSON,然后根据消息类型做响应
try:
data = json.loads(message)
msg_type = data.get("type", "unknown")
if msg_type == "hello":
response = {
"type": "welcome",
"message": f"你好,{data.get('name', '朋友')}!当前服务器时间: {get_current_time()}",
"count": len(active_connections)
}
elif msg_type == "heartbeat":
response = {
"type": "heartbeat_ack",
"timestamp": get_current_time()
}
else:
response = {
"type": "echo",
"message": message
}
except json.JSONDecodeError:
response = {
"type": "echo",
"message": message
}
await websocket.send(json.dumps(response, ensure_ascii=False))
except websockets.exceptions.ConnectionClosed as e:
print(f"连接关闭: code={e.code}, reason={e.reason}")
finally:
# 连接断开时,把它从集合中移除
active_connections.discard(websocket)
print(f"[{get_current_time()}] 连接断开,当前连接数: {len(active_connections)}")
async def push_timer():
# 每 5 秒向所有在线连接推送一条时间消息,验证服务端主动推送能力
while True:
await asyncio.sleep(5)
if active_connections:
message = json.dumps({
"type": "server_push",
"message": f"服务器主动推送,当前时间: {get_current_time()}",
"online_count": len(active_connections)
}, ensure_ascii=False)
# 注意:广播时要把消息逐个发送
await asyncio.wait([
conn.send(message) for conn in active_connections.copy()
])
async def main():
# 启动广播任务
asyncio.create_task(push_timer())
# 启动 WebSocket 服务
async with websockets.serve(handle_connection, "0.0.0.0", 8765):
print("WebSocket 服务已启动,监听端口 8765")
await asyncio.Future() # 一直运行
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("服务已停止")
几个细节值得解释一下。连接集合用 set 存储,天然去重;发送广播时用 active_connections.copy() 是为了避免在遍历过程中连接断开导致集合变化而报 RuntimeError: Set changed size during iteration。push_timer 用 asyncio.create_task 放到后台运行,模拟的是“服务器自己主动推送”的场景。这个能力是 HTTP 做不到的,也正是 WebSocket 的杀手锏。
3.3 客户端页面实现:浏览器的 WebSocket API
前端代码我刻意写得尽量简单,不用任何框架,一个 HTML 文件就够。浏览器原生提供了 WebSocket 对象,打开连接、监听事件、发送消息都在这个对象上完成。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>WebSocket 实时通信 Demo</title>
<style>
body { font-family: sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; }
#log { background: #f5f5f5; padding: 12px; border-radius: 6px; height: 300px; overflow-y: auto; }
.push { color: #2d6cdf; }
.self { color: #333; }
.system { color: #999; }
#status { font-weight: bold; }
button { padding: 6px 16px; }
input { padding: 6px; }
</style>
</head>
<body>
<h1>WebSocket 实时通信 Demo</h1>
<p>当前状态:<span id="status">未连接</span></p>
<div>
<input type="text" id="input" placeholder="输入消息内容">
<button id="connectBtn">建立连接</button>
<button id="sendBtn">发送消息</button>
<button id="closeBtn">断开连接</button>
</div>
<div id="log"></div>
<script>
let ws = null;
const statusEl = document.getElementById('status');
const logEl = document.getElementById('log');
function addLog(text, className = '') {
const line = document.createElement('div');
line.className = className;
const now = new Date().toLocaleTimeString();
line.textContent = `[${now}] ${text}`;
logEl.appendChild(line);
logEl.scrollTop = logEl.scrollHeight;
}
document.getElementById('connectBtn').addEventListener('click', () => {
if (ws && ws.readyState === WebSocket.OPEN) {
addLog('连接已存在,不要重复连接', 'system');
return;
}
ws = new WebSocket('ws://localhost:8765');
ws.onopen = () => {
statusEl.textContent = '已连接';
addLog('连接已建立,可以开始通信', 'system');
ws.send(JSON.stringify({ type: 'hello', name: '前端小白' }));
};
ws.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (e) {
data = { raw: event.data };
}
if (data.type === 'server_push') {
addLog(`[服务器主动推送] ${data.message}`, 'push');
} else {
addLog(`收到服务端消息: ${JSON.stringify(data)}`, 'push');
}
};
ws.onclose = (event) => {
statusEl.textContent = '已断开';
addLog(`连接关闭,关闭码: ${event.code},原因: ${event.reason || '无'}`, 'system');
};
ws.onerror = (error) => {
addLog(`连接出错: ${error.message || '未知错误'}`, 'system');
console.error('WebSocket 错误', error);
};
});
document.getElementById('sendBtn').addEventListener('click', () => {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
if (!ws || ws.readyState !== WebSocket.OPEN) {
addLog('请先建立连接', 'system');
return;
}
ws.send(JSON.stringify({ type: 'chat', content: text }));
addLog(`我发送: ${text}`, 'self');
input.value = '';
});
document.getElementById('closeBtn').addEventListener('click', () => {
if (ws) {
ws.close(1000, '用户主动关闭');
ws = null;
}
});
</script>
</body>
</html>
浏览器的 WebSocket API 只有四个核心事件:onopen、onmessage、onclose、onerror,外加 send 和 close 两个方法。对初学者来说,记住一个判断就行了——调用 send 之前,先检查 readyState === WebSocket.OPEN,否则很可能抛出 InvalidStateError 异常。
3.4 联调与抓包验证:用 Wireshark 和浏览器工具看帧
代码跑起来之后,只是看到页面能收到消息还远远不够,我建议你把抓包这一步做掉,亲眼看看帧在网络上长什么样。
最简单的观察方式是 Chrome 开发者工具。打开页面,按 F12,切到 Network 面板,刷新,在过滤框里输入 ws,就能看到那条 WebSocket 连接。点进去有一个专门的 WS 子标签页,里面会按时间顺序列出所有发送和接收的消息帧,包括文本帧、ping/pong 帧、关闭帧。消息详情里能看到 payload 内容和长度,这是日常排查最常用的一招。
如果想看更底层的 TCP 交互,那就用 Wireshark。启动抓包后,在过滤器里输入:
code复制websocket
就能过滤出所有 WebSocket 相关报文。再配合 tcp.port == 8765 这样的条件,可以直接盯着本机和服务器之间的你来我往。Wireshark 会把每个 TCP 报文中携带的 WebSocket 帧解析出来,包括 FIN 位、opcode、MASK 标志、掩码、载荷长度,一清二楚。我第一次在 Wireshark 里看到服务端发来的帧没有 MASK 而客户端发出去的帧带着 MASK 时,才真正理解了协议里那条规定为什么要这么设计。
建议的验证步骤是这样的:先启动服务端和页面,建立连接,然后在浏览器里发送一条消息,同时在 Wireshark 里观察;接着停手五秒,看服务端主动推送的帧是怎么来的;最后断开连接,看关闭帧的 opcode。三条都走一遍,你对整个协议交互就有了直观印象。
4. 从 Demo 到实际项目:WebSocket 落地要处理的那些事
写完 Demo 只是第一步。真实业务里的 WebSocket 远没有这么单纯——你要考虑连接管理、断线重连、消息可靠性、鉴权安全、性能监控。这一节我以“做一个类似脉脉消息提醒”的功能为参照,把项目落地时绕不开的几个点拆开讲。
4.1 单机到集群:连接管理怎么做
Demo 里连接存在一个进程内的 set 里,这在单机部署下没问题,但线上服务基本不可能单机扛住所有连接。一旦你部署了两台以上 WebSocket 服务,就面临一个问题:客户端连的是 A 机器,但业务逻辑跑在 B 机器上生成了一条推送消息,B 怎么知道这条消息该发给谁?
常规的做法是引入一个消息发布订阅层。比如用 Redis 的 Pub/Sub 或者 Kafka 做广播,所有业务服务器把需要推送的消息发布到同一个 channel/topic,所有 WebSocket 服务节点都订阅这个 channel/topic。A 机器收到消息后,检查目标用户 ID 是否在自己的本地连接表里,如果在就通过本地 WebSocket 连接推出去。
这套机制里,连接和用户之间的映射关系是核心。比较通用的方案是这样:客户端带上 token 握手,服务端鉴权通过后,把这个连接注册到一个内存 Map 里,key 是用户 ID,value 是这个用户的 WebSocket 连接对象。当同一个用户从多个设备登录时,Map 的 value 可能要改造成一个连接集合,因为你不能肯定他只在一台设备上。
假如你现在还处于“只会在单机 Demo 里跑 WebSocket”的阶段,我的建议是暂时别急着上集群。先把单机版解决得干净利落,再考虑多机扩展。
4.2 断线重连与心跳策略
网络环境不是完美的,WebSocket 连接随时可能断。断线之后要自动重连,这是实时功能的基本要求。但不加限制的“断线就重连、连不上就再连”会造成重连风暴,服务器分分钟被打满。
业界比较通用的做法是指数退避加重试次数上限。第一次断开后等待 1 秒重连,失败再等 2 秒,再失败等 4 秒、8 秒、16 秒……一直到 30 秒或 60 秒封顶。这个等待时间不能是纯递增的,最好加一个随机抖动,比如在计算出的基础退避时间上乘以一个 0.5 到 1.5 之间的随机数。为什么要加随机抖动?因为如果所有客户端都同步掉线,再同步重连,服务器会在同一瞬间收到成千上万次连接请求,相当于自己给自己制造了一次 DDoS。加一点随机性,让大家都错峰重连,服务器压力会平滑很多。
心跳方面,我建议做两层。底层由 WebSocket 协议里的 ping/pong 帧承担,稍微靠谱一点的 WebSocket 库都会自动响应 ping,所以这层成本很低。业务层也要做,我通常的做法是每 30 秒发一条 {"type":"heartbeat"},如果 90 秒内既没有心跳回包也没有任何其他消息,就判定连接已死,主动关闭重连。这个时间窗口要结合业务场景调整,游戏类恨不得 10 秒没回包就重连,而看板类压根不需要这么激进。
4.3 消息格式设计与 ACK 机制
很多新手最容易忽略的是消息格式设计。WebSocket 本身不关心你的消息内容,你发字符串也行,发二进制也行。但一个实际的项目如果没有统一的消息格式,后面调试、加功能、排查问题都会很痛苦。
我比较常用的消息结构是这样的:
json复制{
"type": "message",
"data": {
"msg_id": "uuid-123456",
"from": "user_1",
"to": "user_2",
"content": "你好",
"timestamp": 1700000000000
}
}
type 用于区分消息类型,比如 message、heartbeat、ack、server_push。data 是业务数据,里面最重要的字段是 msg_id,它是消息的唯一标识,也是实现 ACK 机制的关键。
所谓 ACK 机制,就是客户端收到一条消息后,回一条 {"type":"ack","msg_id":"uuid-123456"} 给服务器。服务器如果在规定时间内没收到 ACK,就认为这条消息没送达,触发重发。为什么要这么做?因为 WebSocket 虽然是可靠的 TCP 传输,但不能保证消息被业务层正确处理——比如客户端收到消息后解析异常、页面正在卡死、消息还没渲染完就断网了。有了 ACK,服务端才能确认消息真正消费掉了。这对 IM 场景特别重要,涉及消息的保序和去重,客户端收到重复消息时靠 msg_id 去重。
4.4 安全性:WSS、鉴权与数据校验
WebSocket 的默认端口是 80(ws://)和 443(wss://)。线上环境必须用 wss://,类似 HTTP 和 HTTPS 的区别,ws:// 是明文传输,抓包的人能直接看到聊天内容。按照我的习惯,从第一天写线上代码就只允许 wss,不允许 ws 端口对外开放,省得后面整改。
鉴权也是一个容易漏的点。WebSocket 握手是一个 HTTP 请求,所以可以在请求头里带 Authorization,或者在 URL 参数里带 token。我比较推荐放在握手 URL 的 query 参数里,比如 wss://example.com/ws?token=xxx,因为很多 WebSocket 客户端库对自定义请求头的支持并不统一,但 URL 参数是通用的。服务端在 handle_connection 拿到 WebSocket 对象后,先解析握手时的请求路径和参数,token 校验不通过就立刻关闭连接。我给出的示例代码里没有体现鉴权,但真实项目里,这一段是必须的。
还要提醒一点:WebSocket 服务端接收到的每一条消息都要做大小限制,不然一个客户端疯狂发大消息就能把内存打爆。
5. 常见报错与排查技巧实录
WebSocket 入门阶段的报错,绝大多数都集中在几个固定模式里。这一节把我自己踩过的、以及帮别人排查过的典型问题整理一下,每条都能直接在实战中对号入座。
5.1 连接被服务端提前关闭:最常见的一类报错
报错信息往往长这样:stream disconnected before completion: websocket closed by server before response,或者 failed to send websocket request: io 之类的。看到这种话术先别慌,它翻译过来就是“你的连接还没等来完整的响应,服务器就把它关了”。
导致这个问题的原因有三大类。第一类是代理层拦截,Nginx 没配好 WebSocket 升级头,或者负载均衡器不支持 WebSocket 协议,握手请求还没到后端就被挡下了。此时检查 Nginx 配置里有没有 proxy_set_header Upgrade $http_upgrade; 和 proxy_set_header Connection "upgrade"; 这两行。第二类是服务端确实在拒绝连接,比如鉴权失败、端口错了、服务没起来,看服务端日志就知道。第三类是客户端连接的地址问题,比如你用 ws:// 连了本应走 wss:// 的端口。
排查路径我一般是这样:先用浏览器直接访问握手地址,如果浏览器报错“WebSocket connection failed”,再打开 Wireshark 看握手过程是 TCP 层就失败还是 HTTP 101 没返回。一层层往下剥,基本不会跑偏。
5.2 高版本浏览器无法启用 WebSocket
网上有人反馈说“谷歌浏览器高版本无法启用 WebSocket”,其实大多数情况不是浏览器禁用了,而是页面本身是 HTTPS 环境,却尝试连接 ws:// 地址。浏览器安全策略要求 HTTPS 页面只能发起 wss:// 的 WebSocket 连接,ws:// 会被 Mixed Content 规则拦截。解决办法很简单:把 ws:// 改成 wss://,并确保服务端支持 TLS。
另一个容易踩的坑是浏览器对 WebSocket 并发连接数的限制,HTTP/1.1 时代一个域名默认最多 6 个 WebSocket 连接,超过之后连接会排队等待。如果你的页面里创建了多个 WebSocket 实例且没有关闭旧连接,就可能出现“有些连接连不上”的诡异现象。建议封装一个全局唯一的连接管理器,确保整个页面只有一个 WebSocket 连接,用消息路由分发到不同的业务模块。
5.3 WebSocket 导致浏览器崩溃或内存泄漏
有时候长连接页面用久了会越来越卡,严重时浏览器直接崩溃。这通常不是 WebSocket 协议本身的问题,而是代码里没有处理好消息监听和 DOM 更新。最常见的 bug 是每次收到消息都往一个数组里 push 数据而不做清理,时间一长内存占用越来越高。另一个是忘记在组件销毁或页面卸载时调用 ws.close(),导致旧连接一直持有引用,无法被垃圾回收。
排查这种问题的方式比较直接,打开 Chrome 开发工具的内存面板,录制一段堆快照,对比用一段时间前后的内存增长。如果发现消息内容对象持续累积在内存里,就顺着消息处理链找找被谁引用了。我自己的经验是,凡是涉及实时消息流的项目,消息处理函数里最好对数据做节流和聚合,比如聊天消息按会话分组,未读数单独存储,不要把每一条历史消息都铺在页面上。
5.4 抓包工具使用要点:Charles 与 Wireshark 的分工
很多新手问“WebSocket 看不了包”,其实不是看不了,是方法不对。
Charles 之类的代理工具要监听 WebSocket,有一个前提:连接必须经过代理。你在 Charles 里开启 SSL 代理后,WebSocket 请求会出现在 Structure 或 Sequence 列表里,点击后有一个 “WebSocket” 标签页,里面能看到帧数据。如果你的项目用了 wss,务必确认代理证书已经安装并信任,否则连接会握手失败。
Wireshark 看的是网卡层面的报文,不需要证书,但需要你理解 TCP 流的重组方式。在 Wireshark 里,WebSocket 帧的关键过滤器是 websocket,如果发现过滤不到,先看看是不是走了 TLS 加密。wss:// 的流量在 Wireshark 里显示为 TLS,需要配置 SSLKEYLOGFILE 环境变量才能解密看到。Chrome 支持设置 SSLKEYLOGFILE 导出会话密钥,Wireshark 里在 TLS 协议设置的 RSA 密钥列表里导入这个文件,就能解开 wss 流量。
我的日常分工是:浏览器 DevTools 看应用层帧和业务消息,Wireshark 看网络层和协议层细节,Charles 用来做中间人调试,比如篡改握手请求或者模拟弱网。三样工具配合,WebSocket 的绝大多数问题都能定位到具体环节。
6. 从入门到能干活:我的几条实操心得
最后说几句心里话。WebSocket 的入门门槛,说实话不高。它不像某些底层技术那样需要大量前置知识,只要你能理解“HTTP 升级成 WebSocket”这一瞬间发生的事情,再写一个 Demo 连上跑通,架构上的所有问题就都变成时间和经验的积累问题。
我在最开始学的时候,最大的障碍不是协议,而是“没有真实场景”。看文档总觉得道理都懂,但不知道实际项目里为什么要这么设计。后来我养成了一个习惯:随身带着浏览器开发者工具,遇到一个实时功能做得好的网站,就打开 Network 面板去看它的 WebSocket 消息流,分析它的消息格式、心跳频率、重连策略。脉脉就是其中一个让我反复观察的对象,它的消息类型怎么设计、哪些场景走推送、在线状态如何变化,这些真实的交互让我对 WebSocket 的理解有了质的飞跃。
如果你问我学这个的“最短路径”是什么,我会给你一条非常具体的路线:先照着这篇文章的 Demo 把服务端和前端跑通,然后用 Wireshark 抓一次包看帧结构,接着把 Demo 改造成一个带心跳和断线重连的小项目,最后找一个开源 WebSocket 库的项目源码通读一遍。四步走完,你已经比大多数停留在“会用 new WebSocket”阶段的人走得远多了。
技术这条路,就怕只看不做,尤其是 WebSocket 这种网络协议相关的东西,只有自己亲手把连接建立起来、亲眼看到断线、亲手把重连做稳,才能真正变成自己的经验。希望这篇文章能成为你入门路上的一块垫脚石,更希望你早日写出属于自己的实时应用。
