WebSocket 与实时 Web 应用
聊天、协作、推送、实时仪表盘——HTTP 做不好"服务端主动推"。本文讲清 WebSocket 的原理、何时该用它(vs SSE vs 轮询)、连接管理(重连/心跳/鉴权)、广播与房间、扩展(多实例下用 Redis pub/sub),以及常见的实时架构选型。
本文要点
- 主题聚焦:Next.js、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
WebSocket 与实时 Web 应用
做聊天室、协作编辑、实时仪表盘的人,迟早会撞到同一堵墙:HTTP 是请求驱动的,服务端没办法"主动"把消息塞给浏览器。你只能让客户端一遍遍去问"有新消息吗?",浪费且迟钝。WebSocket 就是为这堵墙而生。
一、为什么 HTTP 撑不起实时应用
HTTP 是单向的:客户端发起请求,服务端回应。这个模型对文档、表单、API 完美,但对"服务端有事件要立刻告诉客户端"的场景,从根上就不合适。
实时应用有三个硬需求:
- 双向:客户端要发消息(输入中、移动位置),服务端也要推消息(对方回了、数据变了)。
- 低延迟:从事件发生到用户看到,应该是毫秒级,不是几秒。
- 服务端推送:事件不等人,服务端要能主动发起。
HTTP 在这三点上都吃亏。它是请求-响应模型,服务端只能"被动应答"。你当然可以反复 fetch——但这叫做轮询,后面会讲它有多浪费。
二、三种方案对比:轮询、SSE、WebSocket
实现"服务端推消息"通常有三条路,各有取舍。
轮询(Polling):客户端每隔 N 秒发一次请求问服务端"有新的吗?"。
- 优点:实现极简,标准 HTTP,无需特殊基础设施。
- 缺点:绝大多数请求是空的(白跑一趟);真有消息时,平均要等 N/2 秒才被取走;连接频繁建立,QPS 被白白消耗。
长轮询(Long Polling):服务端收到请求后 hold 住,直到有消息或超时才返回。比普通轮询聪明,但每次返回后客户端要立刻重新建连,本质上还是 HTTP 的妥协方案。
SSE(Server-Sent Events):基于 HTTP 的单向推送通道。服务端持续往一条长连接写 data: ... 文本帧,浏览器用 EventSource 接收。
- 优点:协议简单、自带断线重连、走标准 HTTP 端口、对 HTTP/2 友好。
- 缺点:只能服务端 → 客户端单向;文本协议(发二进制要编码);浏览器
EventSource连接数有限制(同源 6 条)。
WebSocket:在一条 TCP 连接上做全双工双向通信,握手后双方随时互发消息。
- 优点:双向、低延迟、二进制友好、协议开销小(帧头 2-14 字节)。
- 缺点:协议复杂度更高;连接管理、重连、鉴权都要自己搞;对基础设施(LB、代理、CDN)有额外要求。
一句话:单向推送优先 SSE,双向交互才上 WebSocket。
三、WebSocket 原理:握手、帧、全双工
WebSocket 不是凭空的新协议,它是一次精巧的"协议升级"。
握手:连接从一个普通 HTTP 请求开始,带上升级头:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
服务端如果同意,返回 101 Switching Protocols:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
从这一刻起,这条 TCP 连接就不再是 HTTP 了——双方改用 WebSocket 的二进制帧协议通信。
帧(Frame):WebSocket 通信的最小单位。一个帧里有 opcode(标识是文本、二进制、ping、pong 还是 close)、payload 长度和数据。文本消息(opcode 1)就是 UTF-8 字节流。ping/pong 是协议内置的心跳指令。
全双工:握手后,这条连接两端都可以随时发帧,互不阻塞。这就是它能做实时交互的根基——服务端可以在用户敲字的中途把"对方正在输入"推过来,客户端也能在同一个连接里把自己的状态推上去。
Client Server
|--- HTTP Upgrade ----------->|
|<----- 101 -----------------|
|=== (full-duplex frames) ===|
|<--- push: "user joined" ----|
|---- send: "typing..." ---->|
|<--- push: new message ------|
四、何时该用 WebSocket
别把 WebSocket 当成"看起来更高级"的默认选型。它有明确的应用场景,也有明确不该用的场景。
该用:
- 聊天 / IM:双向、状态频繁变化、消息密集。
- 多人协作:协作编辑(Figma、Google Docs 这类)、共享白板,需要把对方的操作实时同步过来。
- 多人游戏:位置、动作、状态高频双向流转,延迟敏感。
- 实时仪表盘:指标频繁刷新(当然,如果是纯展示、无控制,SSE 也够)。
不该用:
- 纯通知推送(订单状态、系统告警)——SSE 更简单。
- 偶发的数据拉取——普通 HTTP 请求就行。
- 需要经过 CDN 缓存的内容——WebSocket 是有状态的,无法缓存。
判断标准很直接:只有客户端也要往服务端高频发数据时,WebSocket 才值得那套复杂度。
五、连接管理:重连、心跳、鉴权(真正的难点)
写一个 demo 的 WebSocket 很简单。写一个生产可用的连接,90% 的代码都在处理异常。
自动重连(指数退避):网络抖动、服务重启、LB 切换都会让连接断开。客户端必须自动重连,但不能疯狂重试打爆服务端。用指数退避 + 抖动:
function connect() {
const ws = new WebSocket(`wss://api.example.com/ws?token=${getToken()}`);
ws.onclose = () => {
const delay = Math.min(1000 * 2 ** retries, 30000) + Math.random() * 1000;
retries += 1;
setTimeout(connect, delay);
};
ws.onopen = () => { retries = 0; };
}
心跳(ping/pong 检活):TCP 不会告诉你"对端已经假死"(比如 NAT 表项过期、Wi-Fi 静默断开)。应用层心跳是必需的:
- 客户端每 30s 发一个 ping,服务端立刻回 pong。
- 若连续 N 次没收到 pong,主动 close 触发重连。
- 反过来,服务端也要检测客户端心跳,超时就释放资源,否则连接会泄漏。
鉴权:WebSocket 握手是 HTTP,所以 token 通常放在两个地方:
- 查询参数:
wss://.../ws?token=xxx。简单,但会出现在 access log,敏感。 Sec-WebSocket-Protocol子协议头或Cookie。更干净,但要服务端配合。
注意:浏览器 WebSocket 构造函数不能自定义 header。所以"Authorization Bearer"那条路在纯浏览器里走不通。需要自定义头,就得在服务端做一次性 ticket 换 ws 会话,或者干脆走 Cookie。
六、消息模式:广播、房间、点对点
连上只是开始,真正的设计在"消息怎么路由"。三种经典模式:
广播(Broadcast):一条消息发给所有连接。适合全局通知、系统级事件。实现最简单,但也最粗暴——不是所有用户都该收到所有消息。
房间(Room / Channel):把连接分组,按组投递。聊天室、协作工作区、直播弹幕都靠它。一个连接可以同时属于多个房间。
const rooms = new Map<string, Set<WebSocket>>();
function join(roomId: string, ws: WebSocket) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function broadcastRoom(roomId: string, msg: string) {
for (const ws of rooms.get(roomId) ?? []) {
if (ws.readyState === WebSocket.OPEN) ws.send(msg);
}
}
点对点(Direct / DM):发给指定用户。需要维护 userId -> WebSocket 的映射(同一用户可能有多端登录,所以实际是 userId -> Set<WebSocket>)。
工程上几乎都是这三种的组合:协作工具是"房间 + 点对点",IM 是"点对点 + 房间(群聊)",游戏是"房间 + 广播(同一局内)"。
七、扩展:多实例下的 Redis pub/sub
单实例能撑住的连接数有限(几万到几十万,取决于内存和 FD)。一旦你水平扩展到多实例,一个反直觉但致命的问题就出现了:
User A ──ws──> Instance 1
User B ──ws──> Instance 2
A 给 B 发消息,A 的连接在 Instance 1,但 B 的连接在 Instance 2。Instance 1 本地的 rooms 里根本没有 B——消息发不到。
解法是引入一个跨实例的消息总线,Redis Pub/Sub 是事实标准:
┌─────────── Redis Pub/Sub ───────────┐
│ channel: room:42 │
▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
│ │
subscribe subscribe
│ │
Instance 1 Instance 2
(A, C 连这里) (B 连这里)
每个实例:
- 本地维护"我这里的连接"。
- 订阅相关的 Redis channel。
- 要广播时,
PUBLISH到 channel,所有实例收到后再投递给各自的本地连接。
// 收到要广播的消息:发到 Redis,而不是本地直接 send
redis.publish(`room:${roomId}`, JSON.stringify(msg));
// 每个实例订阅并本地投递
redis.subscribe(`room:${roomId}`, (raw) => {
for (const ws of localRooms.get(roomId) ?? []) ws.send(raw);
});
更复杂的场景(消息持久化、离线消息、ACK)会上 NATS、Kafka 或专门的实时网关(Ably、Pusher、Centrifugo)。但Redis pub/sub 是绝大多数中小团队的第一步,够用、够便宜、够稳。
还有一个常被忽略的点:粘性会话(Sticky Session)。如果你的实例持有内存态,负载均衡就得做 hash 路由把同一用户的请求打到同一实例。但 WebSocket 是长连接,握手后就不走 LB 了,所以只要 LB 支持 WebSocket 升级(大多数现代 LB 都支持),就不用太担心粘性。
八、备选:Socket.io 还是原生 WS
到选型这一步,几乎所有人都会问:Socket.io 还是原生 WebSocket?
Socket.io:事实上的封装标准。带来:
- 自动重连、心跳(开箱即用)。
- 房间、命名空间(内置抽象)。
- 降级回退:WebSocket 不通时自动降级到长轮询(在受限网络里有用)。
- ACK 机制、二进制流、middleware。
代价是:自带协议(不是裸 WebSocket,Socket.io 客户端必须连 Socket.io 服务端),体积不小,性能略低于原生。
原生 WS(ws for Node.js):协议层就是标准 WebSocket。轻、快、可控。但重连、心跳、房间、广播——全都要自己写。
实战建议:
- 小团队、求快、要兼容老浏览器或受限网络:Socket.io。把烦人的细节省掉,集中精力做业务。
- 中大型团队、追求性能和可控、客户端是现代浏览器:原生 WS + 自建薄抽象。重连和心跳写成可复用的 hook,房间用上面的 Map 模式。
- 混合架构:Socket.io 跑业务,Redis adapter 做多实例广播(Socket.io 官方有
@socket.io/redis-adapter)。这是最省心的扩展路径。
一个常见误区是"Socket.io 就是 WebSocket 加点料"。它其实是基于 WebSocket(降级时轮询)的独立协议。Socket.io 客户端连不上原生 ws 服务端,反之亦然。 别混着用。
小结
- HTTP 是请求驱动的,撑不起服务端主动推送。实时应用需要双向、低延迟、服务端推送,这是另一个量级的需求。
- 三选一原则:单向推送用 SSE,双向高频交互才上 WebSocket,偶尔拉数据就老老实实 HTTP。
- WebSocket 的价值在握手后的全双工帧:协议开销小、延迟低、二进制友好,但代价是连接管理复杂度。
- 生产化 90% 的工作在异常路径:指数退避重连、应用层心跳、安全鉴权(token 走 query 或 Cookie,浏览器不能自定义 header)。一个不处理心跳的服务端迟早被假死连接拖垮。
- 多实例必上消息总线:Redis pub/sub 让任意实例的连接都能收到广播,这是水平扩展的入场券。
金句:把 WebSocket 当成"更快的 HTTP"来用,是大部分实时架构翻盘的开始。它不是请求-响应的替代品,而是有状态长连接——选它的前提,是你真的需要双向、真的愿意为连接管理买单。
Related