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、通知 |
| WebRTC | P2P | 音视频 |
| 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 从数据库获取完整状态。