WebSocket 很容易学会,但生产级 WebSocket 并不简单

在前端开发中,我们经常会遇到“实时更新”的需求。
比如:

  • 聊天室中新消息需要立即出现
  • 体育比赛比分需要实时更新> WebSocket 很容易学会,但生产级 WebSocket 并不简单

在前端开发中,我们经常会遇到“实时更新”的需求。
比如:

  • 聊天室中新消息需要立即出现
  • 体育比赛比分需要实时更新
  • 股票价格不断变化
  • 在线协作需要同步其他用户的操作
  • 服务端任务完成后需要主动通知用户
  • AI 生成内容需要持续向前端输出 最直观的想法是:

浏览器隔一段时间请求一次服务器,看看有没有新数据。

这叫做轮询。
然而,当用户数量和实时数据量不断增加后,轮询会产生大量没有意义的请求。
WebSocket 的出现就是为了解决这类问题。
本文从一个最简单的 WebSocket 服务器开始,一步一步把它演进成一个真正的实时通信系统。


一、为什么 HTTP 不适合实时通信?

HTTP 最基本的通信模型是:

Client ── Request ──> Server
Client <── Response ── Server

客户端发起请求。
服务器处理请求。
服务器返回响应。
例如:

GET /api/matches/123

服务器:

{
  "homeScore": 1,
  "awayScore": 0
}

这种模型非常适合:

  • 查询数据
  • 创建资源
  • 删除资源

也就是我们熟悉的 CRUD。
但实时系统存在一个问题:

服务器什么时候知道客户端需要新的数据?

假设我们现在正在开发一个体育比分网站。
数据库里现在是:
曼联 1 :0 阿森纳
10 秒后,曼联进球。
服务器知道:

1 : 0
↓
2 : 0

但浏览器并不知道。
因为 HTTP 的通信方向通常是:

Client → Server
       Request

Server → Client
       Response

服务器不能在没有请求的情况下,随时使用一个普通 HTTP Response 推送数据。


二、轮询:最简单的解决方案

最容易想到的方法是:

setInterval(async () => {
  const response = await fetch("/api/matches/123");
  const match = await response.json();
  updateUI(match);
}, 2000);

浏览器每两秒请求一次:

GET /api/matches/123

然后:

GET /api/matches/123

再:

GET /api/matches/123

如果比赛 10 分钟没有变化,那么服务器可能收到几百次请求。
但绝大多数请求得到的结果都是:
“没变化”。
这就是轮询最大的缺点:

客户端不知道服务器什么时候有数据,只能不断询问。

如果用户数量继续增加:
10 users -> 1000 users -> 100000 users
大量无效请求就会产生明显的服务器压力。


三、WebSocket 的核心思想

WebSocket 换了一种思路:

不要让客户端不停询问,而是建立一条长期存在的连接,让服务器在有数据的时候主动发送。

于是通信模型变成:

Client ═══════════════ Server
双向通信

客户端可以发送:

Client ──────────────> Server

服务器也可以主动发送:

Client <────────────── Server

而且连接不会因为一次消息发送完成就立刻结束。
所以 WebSocket 最核心的两个特点就是:
持久连接(Persistent Connection)全双工通信(Full Duplex)


四、WebSocket 为什么从 HTTP 开始?

很多刚接触 WebSocket 的人会产生一个疑问:

WebSocket 和 HTTP 到底是 什么关系?

答案是:

WebSocket 的连接建立过程使用 HTTP,但建立成功以后,就不再按照普通 HTTP 请求 - 响应模型通信。

客户端首先发送一个特殊的 HTTP 请求

GET / HTTP/1.1 
Host: example.com 
Upgrade: websocket 
Connection: Upgrade

这里最重要的是:
Upgrade: websocket
它是在告诉服务器:

我现在想把这个 HTTP 连接升级成 WebSocket 连接。

服务器如果接受:

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket 
Connection: Upgrade
101 Switching Protocols

表示协议切换成功
HTTP -> Upgrade -> 101 -> WebSocket
连接就进入 WebSocket 通信阶段。


五、先写一个最简单的 WebSocket Server

Node.js 生态中,ws 是一个比较轻量的 WebSocket 库。

安装:

pnpm add ws

服务端:

import { WebSocketServer } from "ws";
const wss = new WebSocketServer({
  port: 8080
});
wss.on("connection", (socket) => {
  console.log("客户端连接");
  socket.on("message", (message) => {
    console.log("收到消息:", message.toString());
    socket.send("服务器收到你的消息了");
  });
});

浏览器:

const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => { 
  console.log("连接成功"); 
  socket.send("Hello WebSocket"); 
}); 
socket.addEventListener("message", (event) => { 
  console.log("服务器:", event.data); 
});

六、WebSocket 是一条“长期存在”的连接

普通 HTTP:

Request
   ↓
Response
   ↓
结束

WebSocket:

Connect
   ↓
Open
   ↓
Message
   ↓
Message
   ↓
Message
   ↓
Message
   ↓
Close

这意味着服务器必须开始关注:

现在到底有哪些客户端连接着?

例如:

const clients = new Set();
wss.on("connection", (socket) => {
  clients.add(socket);
  socket.on("close", () => {
    clients.delete(socket);
  });
});

服务器现在维护了一份:
当前在线连接。
这和传统的 HTTP 服务有很大的区别。
HTTP 请求处理完后,请求状态通常就结束了。
但 WebSocket 不一样,只要连接成功,就一直有状态,并且可以互相发送消息,直到某一方主动断开或者出现意外等。


七、从 Echo Server 到聊天室:Broadcast

有了连接之后,我们可以实现一个最简单的聊天室。

客户端:

User A
  ↓
"Hello"

服务器:

Server
 ├── User A
 ├── User B
 ├── User C
 └── User D

服务器把消息广播给所有连接:

wss.on("connection", (socket) => {
  socket.on("message", (message) => {
    for (const client of wss.clients) {
      if (client.readyState !== WebSocket.OPEN){
         continue;
      }
      client.send(message);
    }
  })
})

这里出现了第一个实时通信概念:
Broadcast: 1 → N

一个客户端发送,所有客户端接收。
但是这种方式很快会出现问题。

假设一个体育网站有 10,000 个用户

其中:
2,000 人看比赛 A
3,000 人看比赛 B
5,000 人看比赛 C

比赛 A 进球。
如果仍然广播:

Server
 ↓
10,000 users

那么:

8000 个根本不关心比赛 A 的用户也收到了消息。

所以需要更精确的消息路由。


八、Unicast:只发给某一个用户

如果我们知道用户 ID:user123

可以维护:

const users = new Map();
// 连接时

users.set(userId, socket);
// 发送时
const socket = users.get(userId);
if (socket?.readyState === WebSocket.OPEN) {
  socket.send(message);
}

这就是Unicast:1 → 1
例如:

Server
  ↓
User A

适合:

  • 私聊
  • 私人通知
  • 订单状态
  • 用户任务完成通知

九、Room / Subscription:只通知感兴趣的人

如果一个消息需要发送给一组用户:1 -> N

但不是所有用户。

那么可以引入:
Room / Topic / Subscription

例如:

match:123

代表:

比赛 123 的实时消息。

服务端可以维护:

const matchSubscribers = new Map();

结构类似:

match:123 
├── socketA 
├── socketB 
└── socketC 

match:456 
├── socketD 
├── socketE 
└── socketF

客户端发送:

{
  "type": "subscribe",
  "matchId": 123
}

服务器把客户端加入:

match:123

比赛发生变化:

Match 123
   ↓
Score Updated
   ↓
match:123 subscribers
   ↓
A / B / C

这样消息就不会发送给无关用户。


十、为什么需要设计消息协议?

如果 WebSocket 只发送:hello,系统很快就会无法扩展。
因为服务器无法判断:
hello 到底是什么意思?
是聊天?
还是加入房间?
还是订阅比赛?
所以生产系统通常会设计统一消息结构:

{
 "type": "chat", 
 "id": "msg_123", 
 "payload": { "message": "hello" },
 "metadata": {}
}

例如:

{ 
"type": "subscribe", 
"id": "req_123", 
"payload":  { 
  "matchId": 123 
  } 
}

或者:

{ 
"type": "match.updated", 
"id": "event_456", 
"payload":  { 
  "matchId": 123, 
  "homeScore": 2, 
  "awayScore": 1 
  } 
}

于是服务器可以:

switch (message.type) { 
  case "chat": 
    handleChat(message);
    break; 
  case "subscribe": 
    handleSubscribe(message); 
    break; 
  case "unsubscribe":
    handleUnsubscribe(message);
    break;
}

这一步非常重要,因为 WebSocket 本身只提供通信能力,但真正的业务协议需要应用自己定义。


十一、WebSocket 连接并不是永远可靠的

到这里,我们已经可以:

连接
 ↓
发送消息
 ↓
广播
 ↓
订阅

但是生产环境马上会遇到一个问题:

如果客户端突然断网怎么办?

比如:

Browser ═════════ Server

用户突然断网,
服务器可能没有立即收到一个正常的 close
但是服务器内部仍然保存 socket
于是服务器认为:

用户还在线。

这种连接就可能变成:
Ghost Connection(僵尸连接)
如果系统长期运行:

100 个僵尸连接
↓
10,000 个
↓
100,000 个

最终会消耗大量资源。


十二、Heartbeat:解决僵尸连接

WebSocket 系统解决僵尸连接的一种办法是心跳检测。

基本思路:

Server ─── Ping ───> Client
Server <── Pong ──── Client

服务端定期检测:
客户端还活着吗?

如果客户端持续响应:

ping

pong

说明连接正常。

如果长时间没有响应:

ping

timeout

terminate

cleanup

这就是为什么:

长连接最大的难点之一不是建立连接,而是管理连接。


十三、连接关闭时必须清理状态

假设一个用户订阅了:

match:123
match:456
match:789

内部可能是:

match:123 → socketA 
match:456 → socketA 
match:789 → socketA

当 socketA 关闭以后:

socket.on("close", () => { // 清理所有订阅 });

如果不清理:

Map

仍然保存 socketA

就可能产生:

  • 内存泄漏
  • 无效连接
  • 错误广播
  • 状态污染

所以一个完整的 WebSocket 生命周期应该是:

Connect
   ↓
Authenticate
   ↓
Subscribe
   ↓
Message
   ↓
Heartbeat
   ↓
Disconnect
   ↓
Cleanup

十四、REST + WebSocket:实际项目最常见的组合

很多人会问:

既然 WebSocket 可以通信,那是不是以后所有 API 都用 WebSocket?

实际上没有必要。

一个更合理的架构是:


                  Backend
                 /       \
              REST       WebSocket
               ↓            ↓
          Initial State   Realtime Event

例如打开体育比赛页面。

首先:

GET /matches/123

获取当前完整状态:

{
  "id": 123,
  "homeScore": 1,
  "awayScore": 1,
  "status": "LIVE"
}

然后建立 WebSocket:

GET /matches/123
      ↓
Initial State

WebSocket
      ↓
Future Events

之后发生进球:

Database
   ↓
Score = 2:1
   ↓
WebSocket Event
   ↓
Browser
   ↓
React State

这是一种非常经典的架构:

REST 获取状态,WebSocket 获取状态变化。


十五、数据库才是真正的 Source of Truth

这里还有一个非常重要的设计原则。
不要把 WebSocket 当数据库。
例如:

PostgreSQL
     ↓
Source of Truth

WebSocket:
Event Distribution

一个完整流程应该是:

Client
  ↓
POST /matches/123/score
  ↓
Business Logic
  ↓
PostgreSQL
  ↓
State Changed
  ↓
Event
  ↓
WebSocket
  ↓
Subscribers

也就是说:

先改变真实状态,再通知客户端。

可以把这个模式抽象成:

Command
   ↓
State Change
   ↓
Event
   ↓
Broadcast

例如:

POST /matches/123/score
          ↓
       修改比分
          ↓
       DB 更新
          ↓
 MatchScoreUpdated
          ↓
      WebSocket
          ↓
    Subscribers

这样即使 WebSocket 服务器重启,客户端重新连接以后仍然可以通过 REST 从数据库获取完整状态。


十六、一个更完整的 Express + WebSocket 架构

实际 Node.js 项目中,我们往往不会让 Express 和 WebSocket 各自启动一个端口。
而是:

import express from "express";
import { createServer } from "node:http"; 
import { WebSocketServer } from "ws"; 

const app = express(); 
const server = createServer(app); 
const wss = new WebSocketServer({ server }); 

app.get("/api/matches", (req, res) => { 
  res.json([]); 
}); 

wss.on("connection", (socket) => { 
  console.log("WebSocket connected"); 
}); 

server.listen(3000);

这里有三个东西:

Express App
     ↓
Node HTTP Server
     ↓
┌───────────────┐
│               │
REST          WebSocket

express() 创建的是 Express 应用。
而:

createServer(app) 创建的是底层 Node HTTP Server。

最终:

HTTP Server :3000
       │
       ├── HTTP Request
       │       ↓
       │    Express
       │
       └── Upgrade
               ↓
           WebSocket

因此:

REST 和 WebSocket 完全可以共享同一个 HTTP Server 和端口。


十七、WebSocket 可靠性:ACK 与 Retry

WebSocket 本身并不等于:

消息一定被业务成功处理

假设:

Client
  │
  │ message #123
  ↓
Server

如果网络突然断开:

Client ✕ Server

客户端并不知道:

Server 到底有没有收到消息?

对于普通聊天消息可能问题不大。

但如果消息非常重要,就可以设计 ACK:

Client ── message #123 ──> Server
Client <──── ACK #123 ──── Server

如果:

timeout

客户端可以:

retry

于是业务层拥有了:

Message ID
+
ACK
+
Retry

进一步还可以考虑:

  • 幂等
  • 去重
  • 顺序
  • 重放
  • 消息持久化

这时你会发现:

WebSocket 只是传输层,可靠消息系统需要更上层的设计。


十八、真正容易被忽略的问题:Backpressure

假设服务器每秒产生:

10,000 messages

但是某个客户端处理能力只有:

100 messages/s

那么:

Server
↓↓↓↓↓↓↓↓↓↓↓
Slow Client

消息就可能不断堆积。

如果服务器无脑:

socket.send(message);

可能导致:

Buffer ↑
Memory ↑
Latency ↑

这就是:

Backpressure(背压)

生产系统需要考虑:

  • 慢客户端
  • 消息缓冲
  • 发送队列
  • 限流
  • 丢弃策略
  • 消息优先级

例如实时股票系统里:

价格变化:
100.01
100.02
100.03
100.04
100.05

如果客户端已经落后很多消息,那么有时候没有必要把所有历史价格逐条发送过去。

可能更合理的是:

直接发送最新状态。

所以实时系统经常需要在:

实时性、完整性、资源消耗 之间做取舍。


十九、单机 WebSocket 到多实例

单机情况下:

Client A ──┐
Client B ──┼──> WebSocket Server
Client C ──┘

服务器知道所有连接。

但是生产环境通常需要水平扩展:


                 Load Balancer
                 /            \
                ↓              ↓
          Server A          Server B
             ↓                 ↓
        Clients A         Clients B

现在问题出现了。

假设:

User A → Server A
User B → Server B

User A 发送消息。

Server A 知道。

但是 Server B 怎么知道?

如果 Server A 直接广播:

Server A
   ↓
自己的 Clients

User B 收不到。

这就是 WebSocket 分布式化以后最核心的问题之一:

连接状态分散在不同服务器上。


二十、Redis Pub/Sub

一个常见的解决方式是使用 Redis Pub/Sub:

                Redis Pub/Sub
                /           \
               ↑             ↑
          Server A        Server B
             ↓                ↓
          Clients          Clients

Server A 收到事件:

Match 123 Updated

然后:

PUBLISH match:123

Server B 订阅:

SUBSCRIBE match:123

于是:

Server A
   ↓
Redis
   ↓
Server B
   ↓
Client B

这样不同 WebSocket Server 就能够共享事件。

整个系统从:

单机 WebSocket

演进成:

Load Balancer
      ↓
WebSocket Servers
      ↓
Redis Pub/Sub
      ↓
Database

这已经开始进入分布式系统的范畴。


二十一、生产级 WebSocket 的安全问题

WebSocket 是长连接,因此不能只按照普通 HTTP API 的方式考虑安全。

至少需要解决几个问题。

1. Authentication

连接建立以后,需要知道:

这个用户是谁?

可以使用:

Cookie / Session
JWT
其他认证机制

2. Authorization

知道用户是谁以后,还需要判断:

他有没有权限访问这个 Room?

例如:

User A
 ↓
subscribe match:123

服务器不能只因为请求来了就允许。

应该:

Authentication
       ↓
Authorization
       ↓
Subscribe

3. Rate Limit

客户端不能无限发送消息:

message
message
message
message
...

否则可能成为资源消耗攻击。

所以需要限制:

连接建立频率
+
消息发送频率

4. Payload Limit

不能允许客户端发送无限大的消息。

例如 ws 支持配置:maxPayload

限制单个 WebSocket 消息大小。


二十二、WebSocket 的监控和 HTTP 完全不同

普通 HTTP 服务可能关注:

QPS
HTTP 200
HTTP 500
Response Time

但 WebSocket 最大的特点是:

连接可能持续很长时间。

所以需要额外关注:

Concurrent Connections
Message Throughput 
Message Latency 
Disconnect Rate 
Connection Errors 
CPU 
Memory 
Event Loop Lag

例如:

Active Connections: 100,000
Messages/sec:       50,000
Avg Latency:        25ms
Event Loop Lag:     10ms

这些指标对于实时系统来说非常重要。
尤其是 Node.js。
因为大量连接和消息处理最终都会影响:
Event Loop
所以 WebSocket 的性能分析不能只看:

“接口是不是 200。” 而应该看: 整个实时连接系统现在到底处于什么状态。


二十三、WebSocket、SSE 和 WebRTC 怎么选?

实时通信并不只有 WebSocket。
可以简单做一个区分:

技术方向典型场景
HTTP请求→响应CRUD
WebSocket双向聊天、实时协作
SSE服务端→客户端AI Streaming、通知
WebRTCP2P音视频
WebTransport双向特殊低延迟场景

例如:

AI → Browser

如果只是服务器不断输出:






AI


SSE 就可能已经足够。

但如果是:

Browser ↔ Server

双方都需要持续发送消息:

  • 聊天
  • 多人协作
  • 实时游戏
  • 实时控制

WebSocket 通常更加合适。

所以:

不要因为 WebSocket 能做实时通信,就什么实时需求都使用 WebSocket。


二十四、WebSocket 和 Socket.IO

Node.js 生态中还经常看到 Socket.IO。

可以简单理解:

ws

更接近 WebSocket:

Application
     ↓
   ws
     ↓
WebSocket

它比较轻量,也给开发者更多控制权。

但是:

  • Room
  • 重连
  • 事件协议
  • 一些可靠性能力

可能需要自己实现。

而 Socket.IO 提供了更高层的抽象:

Application
     ↓
 Socket.IO
     ↓
实时通信能力

它提供:

Event
Room
Broadcast
Reconnect
Adapter 等能力

因此选择哪一个,不应该简单理解成:

“Socket.IO 比 WebSocket 高级。”

而应该根据需求选择抽象层级。

如果你想学习 WebSocket 原理,ws 很适合。

如果希望快速构建复杂实时应用,Socket.IO 往往更加方便。


二十五、最终形成一个生产级架构

把前面所有东西串起来:

                         Load Balancer
                              │
                ┌─────────────┴─────────────┐
                ↓                           ↓
        WebSocket Server A          WebSocket Server B
                │                           │
                └─────────────┬─────────────┘
                              ↓
                        Redis Pub/Sub
                              │
                              ↓
                       Business Logic
                              │
                              ↓
                         PostgreSQL

客户端:

                     React
                    /     \
                   /       \
                  ↓         ↓
                REST       WS
                  │         │
                  ↓         ↓
            Initial State  Events

一次完整的业务流程可能是:

用户打开比赛页面
        ↓
GET /matches/123
        ↓
获取当前完整状态
        ↓
建立 WebSocket
        ↓
subscribe match:123
        ↓
等待实时事件
        ↓
比赛发生进球
        ↓
Database 更新
        ↓
MatchUpdated Event
        ↓
Redis Pub/Sub
        ↓
WebSocket Servers
        ↓
找到 match:123 subscribers
        ↓
推送给客户端
        ↓
React 更新 UI

这时候 WebSocket 已经不再只是:

socket.send()

而是一整套实时通信基础设施。


二十六、从几十行代码到生产系统

回过头看整个演进过程,其实非常有意思。

最开始:
WebSocket
只是:
Client ═══ Server

然后发现需要知道谁连接了:
Connection State

发现连接可能死掉:
Heartbeat

发现所有人都收到消息太浪费:
Broadcast

Unicast

Room / Subscription

发现字符串消息无法扩展:
Message Protocol

发现网络可能断:
ACK / Retry

发现客户端处理不过来:
Backpressure

发现恶意客户端:
Authentication
Authorization
Rate Limit
Payload Limit

发现一台服务器不够:
Multi-instance

发现服务器之间无法共享消息:
Redis Pub/Sub

发现系统出了问题却不知道:
Observability

最终:

WebSocket
   ↓
Connection Management
   ↓
Message Protocol
   ↓
Routing
   ↓
Reliability
   ↓
Security
   ↓
Backpressure
   ↓
Observability
   ↓
Pub/Sub
   ↓
Distributed System

这其实就是一个非常典型的后端系统演进过程。


二十七、我认为 WebSocket 最值得理解的三个问题

学 WebSocket 的时候,我觉得最重要的并不是记住所有 API。

而是想明白三个问题。

第一个问题:服务器为什么能主动发消息?

因为建立 WebSocket 连接以后:

Client ═════════ Server

它不再是传统的一问一答,而是双方都可以主动发送数据。

第二个问题:为什么 WebSocket 越深入越复杂?

因为它是长连接。

长连接意味着:

连接状态
+
心跳
+
清理
+
认证
+
订阅
+
消息路由
+
资源管理

都变成了服务器需要负责的问题。

第三个问题:为什么分布式以后更复杂?

因为:

Connection State

通常存在于具体的 WebSocket Server 内。

当:

Server A
Server B
Server C

同时存在时,就需要解决:

不同服务器之间如何共享事件?

于是才有了:
Redis Pub/Sub

以及进一步的分布式架构。


二十八、最后总结

如果把 WebSocket 的核心知识压缩成一条线:

HTTP
 ↓
Upgrade
 ↓
101 Switching Protocols
 ↓
WebSocket
 ↓
Persistent Connection
 ↓
Connection State
 ↓
Heartbeat
 ↓
Message Protocol
 ↓
Broadcast / Unicast / Room
 ↓
ACK / Retry
 ↓
Backpressure
 ↓
Authentication / Authorization
 ↓
Rate Limit
 ↓
Observability
 ↓
Redis Pub/Sub
 ↓
Multi-instance

WebSocket 真正解决的问题其实只有一个:

让客户端和服务器之间拥有一条可以长期存在、双向通信的实时连接。

但从这条连接出发,会自然产生一系列工程问题:

  • 连接怎么管理?
  • 消息怎么定义?
  • 消息发给谁?
  • 连接死了怎么办?
  • 消息丢了怎么办?
  • 客户端处理不过来怎么办?
  • 多个服务器怎么办?
  • 如何鉴权?
  • 如何限流?
  • 如何监控?

所以:

WebSocket API 很简单,生产级实时通信系统一点也不简单。

而这可能也是学习 WebSocket 最有价值的地方。
它表面上是在学习一种通信协议,实际上是在通过“实时通信”这个问题,逐渐接触:

网络、连接管理、状态管理、消息系统、缓存、Pub/Sub、可靠性、安全、性能以及分布式系统。

当你能够从:

“我会用 WebSocket”

进一步回答:

“为什么需要 WebSocket?”
“为什么需要心跳?”
“为什么需要 Room?”
“为什么需要 Redis?”
“为什么数据库才是 Source of Truth?”
“为什么 WebSocket 和 REST 应该配合?”
“为什么生产环境必须考虑背压和可观测性?”

这时候,才算真正理解了 WebSocket。

  • 股票价格不断变化
  • 在线协作需要同步其他用户的操作
  • 服务端任务完成后需要主动通知用户
  • AI 生成内容需要持续向前端输出 最直观的想法是:

浏览器隔一段时间请求一次服务器,看看有没有新数据。

这叫做轮询。
然而,当用户数量和实时数据量不断增加后,轮询会产生大量没有意义的请求。
WebSocket 的出现就是为了解决这类问题。
本文从一个最简单的 WebSocket 服务器开始,一步一步把它演进成一个真正的实时通信系统。


一、为什么 HTTP 不适合实时通信?

HTTP 最基本的通信模型是:

Client ── Request ──> Server
Client <── Response ── Server

客户端发起请求。
服务器处理请求。
服务器返回响应。
例如:

GET /api/matches/123

服务器:

{
  "homeScore": 1,
  "awayScore": 0
}

这种模型非常适合:

  • 查询数据
  • 创建资源
  • 删除资源

也就是我们熟悉的 CRUD。
但实时系统存在一个问题:

服务器什么时候知道客户端需要新的数据?

假设我们现在正在开发一个体育比分网站。
数据库里现在是:
曼联 1 :0 阿森纳
10 秒后,曼联进球。
服务器知道:

1 : 0
↓
2 : 0

但浏览器并不知道。
因为 HTTP 的通信方向通常是:

Client → Server
       Request

Server → Client
       Response

服务器不能在没有请求的情况下,随时使用一个普通 HTTP Response 推送数据。


二、轮询:最简单的解决方案

最容易想到的方法是:

setInterval(async () => {
  const response = await fetch("/api/matches/123");
  const match = await response.json();
  updateUI(match);
}, 2000);

浏览器每两秒请求一次:

GET /api/matches/123

然后:

GET /api/matches/123

再:

GET /api/matches/123

如果比赛 10 分钟没有变化,那么服务器可能收到几百次请求。
但绝大多数请求得到的结果都是:
“没变化”。
这就是轮询最大的缺点:

客户端不知道服务器什么时候有数据,只能不断询问。

如果用户数量继续增加:
10 users -> 1000 users -> 100000 users
大量无效请求就会产生明显的服务器压力。


三、WebSocket 的核心思想

WebSocket 换了一种思路:

不要让客户端不停询问,而是建立一条长期存在的连接,让服务器在有数据的时候主动发送。

于是通信模型变成:

Client ═══════════════ Server
双向通信

客户端可以发送:

Client ──────────────> Server

服务器也可以主动发送:

Client <────────────── Server

而且连接不会因为一次消息发送完成就立刻结束。
所以 WebSocket 最核心的两个特点就是:
持久连接(Persistent Connection)全双工通信(Full Duplex)


四、WebSocket 为什么从 HTTP 开始?

很多刚接触 WebSocket 的人会产生一个疑问:

WebSocket 和 HTTP 到底是 什么关系?

答案是:

WebSocket 的连接建立过程使用 HTTP,但建立成功以后,就不再按照普通 HTTP 请求 - 响应模型通信。

客户端首先发送一个特殊的 HTTP 请求

GET / HTTP/1.1 
Host: example.com 
Upgrade: websocket 
Connection: Upgrade

这里最重要的是:
Upgrade: websocket
它是在告诉服务器:

我现在想把这个 HTTP 连接升级成 WebSocket 连接。

服务器如果接受:

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket 
Connection: Upgrade
101 Switching Protocols

表示协议切换成功
HTTP -> Upgrade -> 101 -> WebSocket
连接就进入 WebSocket 通信阶段。


五、先写一个最简单的 WebSocket Server

Node.js 生态中,ws 是一个比较轻量的 WebSocket 库。

安装:

pnpm add ws

服务端:

import { WebSocketServer } from "ws";
const wss = new WebSocketServer({
  port: 8080
});
wss.on("connection", (socket) => {
  console.log("客户端连接");
  socket.on("message", (message) => {
    console.log("收到消息:", message.toString());
    socket.send("服务器收到你的消息了");
  });
});

浏览器:

const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => { 
  console.log("连接成功"); 
  socket.send("Hello WebSocket"); 
}); 
socket.addEventListener("message", (event) => { 
  console.log("服务器:", event.data); 
});

六、WebSocket 是一条“长期存在”的连接

普通 HTTP:

Request
   ↓
Response
   ↓
结束

WebSocket:

Connect
   ↓
Open
   ↓
Message
   ↓
Message
   ↓
Message
   ↓
Message
   ↓
Close

这意味着服务器必须开始关注:

现在到底有哪些客户端连接着?

例如:

const clients = new Set();
wss.on("connection", (socket) => {
  clients.add(socket);
  socket.on("close", () => {
    clients.delete(socket);
  });
});

服务器现在维护了一份:
当前在线连接。
这和传统的 HTTP 服务有很大的区别。
HTTP 请求处理完后,请求状态通常就结束了。
但 WebSocket 不一样,只要连接成功,就一直有状态,并且可以互相发送消息,直到某一方主动断开或者出现意外等。


七、从 Echo Server 到聊天室:Broadcast
有了连接之后,我们可以实现一个最简单的聊天室。

客户端:

User A
  ↓
"Hello"

服务器:

Server
 ├── User A
 ├── User B
 ├── User C
 └── User D

服务器把消息广播给所有连接:

wss.on("connection", (socket) => {
  socket.on("message", (message) => {
    for (const client of wss.clients) {
      if (client.readyState !== WebSocket.OPEN){
         continue;
      }
      client.send(message);
    }
  })
})

这里出现了第一个实时通信概念:
Broadcast: 1 → N

一个客户端发送,所有客户端接收。
但是这种方式很快会出现问题。

假设一个体育网站有 10,000 个用户

其中:
2,000 人看比赛 A
3,000 人看比赛 B
5,000 人看比赛 C

比赛 A 进球。
如果仍然广播:

Server
 ↓
10,000 users

那么:

8000 个根本不关心比赛 A 的用户也收到了消息。

所以需要更精确的消息路由。


八、Unicast:只发给某一个用户

如果我们知道用户 ID:user123

可以维护:

const users = new Map();
// 连接时

users.set(userId, socket);
// 发送时
const socket = users.get(userId);
if (socket?.readyState === WebSocket.OPEN) {
  socket.send(message);
}

这就是Unicast:1 → 1
例如:

Server
  ↓
User A

适合:

  • 私聊
  • 私人通知
  • 订单状态
  • 用户任务完成通知

九、Room / Subscription:只通知感兴趣的人

如果一个消息需要发送给一组用户:1 -> N

但不是所有用户。

那么可以引入:
Room / Topic / Subscription

例如:

match:123

代表:

比赛 123 的实时消息。

服务端可以维护:

const matchSubscribers = new Map();

结构类似:

match:123 
├── socketA 
├── socketB 
└── socketC 

match:456 
├── socketD 
├── socketE 
└── socketF

客户端发送:

{
  "type": "subscribe",
  "matchId": 123
}

服务器把客户端加入:

match:123

比赛发生变化:

Match 123
   ↓
Score Updated
   ↓
match:123 subscribers
   ↓
A / B / C

这样消息就不会发送给无关用户。


十、为什么需要设计消息协议?

如果 WebSocket 只发送:hello,系统很快就会无法扩展。
因为服务器无法判断:
hello 到底是什么意思?
是聊天?
还是加入房间?
还是订阅比赛?
所以生产系统通常会设计统一消息结构:

{
 "type": "chat", 
 "id": "msg_123", 
 "payload": { "message": "hello" },
 "metadata": {}
}

例如:

{ 
"type": "subscribe", 
"id": "req_123", 
"payload":  { 
  "matchId": 123 
  } 
}

或者:

{ 
"type": "match.updated", 
"id": "event_456", 
"payload":  { 
  "matchId": 123, 
  "homeScore": 2, 
  "awayScore": 1 
  } 
}

于是服务器可以:

switch (message.type) { 
  case "chat": 
    handleChat(message);
    break; 
  case "subscribe": 
    handleSubscribe(message); 
    break; 
  case "unsubscribe":
    handleUnsubscribe(message);
    break;
}

这一步非常重要,因为 WebSocket 本身只提供通信能力,但真正的业务协议需要应用自己定义。


十一、WebSocket 连接并不是永远可靠的

到这里,我们已经可以:

连接
 ↓
发送消息
 ↓
广播
 ↓
订阅

但是生产环境马上会遇到一个问题:

如果客户端突然断网怎么办?

比如:

Browser ═════════ Server

用户突然断网,
服务器可能没有立即收到一个正常的 close
但是服务器内部仍然保存 socket
于是服务器认为:

用户还在线。

这种连接就可能变成:
Ghost Connection(僵尸连接)
如果系统长期运行:

100 个僵尸连接
↓
10,000 个
↓
100,000 个

最终会消耗大量资源。


十二、Heartbeat:解决僵尸连接

WebSocket 系统解决僵尸连接的一种办法是心跳检测。

基本思路:

Server ─── Ping ───> Client
Server <── Pong ──── Client

服务端定期检测:
客户端还活着吗?

如果客户端持续响应:

ping

pong

说明连接正常。

如果长时间没有响应:

ping

timeout

terminate

cleanup

这就是为什么:

长连接最大的难点之一不是建立连接,而是管理连接。


十三、连接关闭时必须清理状态

假设一个用户订阅了:

match:123
match:456
match:789

内部可能是:

match:123 → socketA 
match:456 → socketA 
match:789 → socketA

当 socketA 关闭以后:

socket.on("close", () => { // 清理所有订阅 });

如果不清理:

Map

仍然保存 socketA

就可能产生:

  • 内存泄漏
  • 无效连接
  • 错误广播
  • 状态污染

所以一个完整的 WebSocket 生命周期应该是:

Connect
   ↓
Authenticate
   ↓
Subscribe
   ↓
Message
   ↓
Heartbeat
   ↓
Disconnect
   ↓
Cleanup

十四、REST + WebSocket:实际项目最常见的组合

很多人会问:

既然 WebSocket 可以通信,那是不是以后所有 API 都用 WebSocket?

实际上没有必要。

一个更合理的架构是:


                  Backend
                 /       \
              REST       WebSocket
               ↓            ↓
          Initial State   Realtime Event

例如打开体育比赛页面。

首先:

GET /matches/123

获取当前完整状态:

{
  "id": 123,
  "homeScore": 1,
  "awayScore": 1,
  "status": "LIVE"
}

然后建立 WebSocket:

GET /matches/123
      ↓
Initial State

WebSocket
      ↓
Future Events

之后发生进球:

Database
   ↓
Score = 2:1
   ↓
WebSocket Event
   ↓
Browser
   ↓
React State

这是一种非常经典的架构:

REST 获取状态,WebSocket 获取状态变化。


十五、数据库才是真正的 Source of Truth

这里还有一个非常重要的设计原则。
不要把 WebSocket 当数据库。
例如:

PostgreSQL
     ↓
Source of Truth

WebSocket:
Event Distribution

一个完整流程应该是:

Client
  ↓
POST /matches/123/score
  ↓
Business Logic
  ↓
PostgreSQL
  ↓
State Changed
  ↓
Event
  ↓
WebSocket
  ↓
Subscribers

也就是说:

先改变真实状态,再通知客户端。

可以把这个模式抽象成:

Command
   ↓
State Change
   ↓
Event
   ↓
Broadcast

例如:

POST /matches/123/score
          ↓
       修改比分
          ↓
       DB 更新
          ↓
 MatchScoreUpdated
          ↓
      WebSocket
          ↓
    Subscribers

这样即使 WebSocket 服务器重启,客户端重新连接以后仍然可以通过 REST 从数据库获取完整状态。