#Next.js#TypeScript

WebSocket 与实时 Web 应用

聊天、协作、推送、实时仪表盘——HTTP 做不好"服务端主动推"。本文讲清 WebSocket 的原理、何时该用它(vs SSE vs 轮询)、连接管理(重连/心跳/鉴权)、广播与房间、扩展(多实例下用 Redis pub/sub),以及常见的实时架构选型。

本文要点

  • 主题聚焦:Next.js、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年5月26日·更新于 2026年7月23日

WebSocket 与实时 Web 应用

做聊天室、协作编辑、实时仪表盘的人,迟早会撞到同一堵墙:HTTP 是请求驱动的,服务端没办法"主动"把消息塞给浏览器。你只能让客户端一遍遍去问"有新消息吗?",浪费且迟钝。WebSocket 就是为这堵墙而生。

一、为什么 HTTP 撑不起实时应用

HTTP 是单向的:客户端发起请求,服务端回应。这个模型对文档、表单、API 完美,但对"服务端有事件要立刻告诉客户端"的场景,从根上就不合适。

实时应用有三个硬需求:

  1. 双向:客户端要发消息(输入中、移动位置),服务端也要推消息(对方回了、数据变了)。
  2. 低延迟:从事件发生到用户看到,应该是毫秒级,不是几秒。
  3. 服务端推送:事件不等人,服务端要能主动发起。

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 请求开始,带上升级头:

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
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 是协议内置的心跳指令。

全双工:握手后,这条连接两端都可以随时发帧,互不阻塞。这就是它能做实时交互的根基——服务端可以在用户敲字的中途把"对方正在输入"推过来,客户端也能在同一个连接里把自己的状态推上去。

text
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 切换都会让连接断开。客户端必须自动重连,但不能疯狂重试打爆服务端。用指数退避 + 抖动:

typescript
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 通常放在两个地方:

  1. 查询参数:wss://.../ws?token=xxx。简单,但会出现在 access log,敏感。
  2. Sec-WebSocket-Protocol 子协议头或 Cookie。更干净,但要服务端配合。

注意:浏览器 WebSocket 构造函数不能自定义 header。所以"Authorization Bearer"那条路在纯浏览器里走不通。需要自定义头,就得在服务端做一次性 ticket 换 ws 会话,或者干脆走 Cookie。

六、消息模式:广播、房间、点对点

连上只是开始,真正的设计在"消息怎么路由"。三种经典模式:

广播(Broadcast):一条消息发给所有连接。适合全局通知、系统级事件。实现最简单,但也最粗暴——不是所有用户都该收到所有消息。

房间(Room / Channel):把连接分组,按组投递。聊天室、协作工作区、直播弹幕都靠它。一个连接可以同时属于多个房间。

typescript
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)。一旦你水平扩展到多实例,一个反直觉但致命的问题就出现了:

text
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 是事实标准:

text
       ┌─────────── Redis Pub/Sub ───────────┐
       │        channel: room:42              │
       ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔
        │                              │
   subscribe                       subscribe
        │                              │
   Instance 1                     Instance 2
     (A, C 连这里)                   (B 连这里)

每个实例:

  • 本地维护"我这里的连接"。
  • 订阅相关的 Redis channel。
  • 要广播时,PUBLISH 到 channel,所有实例收到后再投递给各自的本地连接。
typescript
// 收到要广播的消息:发到 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

相关推荐