SSE vs WebSocket:从 HTTP 协议原理到 AI 流式输出场景的选型实战
引言
2023 年以来,AI 大模型井喷式发展,ChatGPT、Claude、通义千问等产品的**流式输出(Streaming Output)**体验已经成为标配------大模型逐个生成 token,用户端实时看到文字"一个字一个字地蹦出来"。这种体验背后,有一个古老但重新焕发生机的技术:SSE(Server-Sent Events)。
与此同时,WebSocket 作为实时通信的老牌选手,在在线聊天、协同编辑、实时游戏等领域早已根深蒂固。
很多开发者会问:AI 流式输出为什么选 SSE 而不是 WebSocket?我的实时推送场景到底该用谁?
本文将从 HTTP 协议原理层面 深入拆解两者的工作机制,配合完整的前后端代码示例,并结合真实业务场景给出选型建议。读完你不仅能理解两者的本质差异,还能在下次架构选型时做出有理有据的决策。
一、重新理解 HTTP:这一切的起点
在对比 SSE 和 WebSocket 之前,我们需要先回到 HTTP 协议本身。
HTTP/1.1 的请求-响应模型
传统 HTTP 通信是典型的客户端主动、服务端被动模型:
text
Client Server
| |
|------- HTTP Request -------->|
| | 处理请求
|<------ HTTP Response --------|
| |
|------- HTTP Request -------->|
| |
|<------ HTTP Response --------|
每次通信都由客户端发起,服务端无法主动推送数据。为了解决"服务端推送"的需求,历史上出现过几种方案:
| 方案 | 原理 | 问题 |
|---|---|---|
| 轮询 (Polling) | 客户端每隔 n 秒发一次请求 | 大量无效请求,资源浪费严重 |
| 长轮询 (Long Polling) | 请求挂起直到服务端有数据才返回 | 仍有 HTTP 开销,连接管理复杂 |
| SSE | 服务端持续写响应体,连接不关闭 | 单向推送,浏览器原生支持 |
| WebSocket | 升级协议为全双工管道 | 双向通信,需额外协议开销 |
HTTP 响应的"流式"本质
很多人不知道,HTTP 响应体(Response Body)本来就是可以**分块传输(Chunked Transfer Encoding)**的。只要服务端不关闭连接,就可以持续向客户端写入数据------这正是 SSE 的底层基础。
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: hello
data: world
SSE 本质上就是 "一个不关闭的 HTTP 响应,服务端持续往里面写特定格式的文本"。
二、SSE(Server-Sent Events)深度解析
2.1 协议原理
SSE 的核心思路极其简洁:
- 客户端发起一个普通的 HTTP GET 请求
- 服务端设置
Content-Type: text/event-stream - 服务端保持连接不断开,持续向响应体写入
data:前缀的文本 - 客户端通过
EventSource API读取数据
整个过程不需要任何协议升级,就是最普通的 HTTP 通信。
2.2 数据格式规范
SSE 数据格式由 text/event-stream MIME 类型定义,每条消息由若干字段组成:
text
event: message # 事件类型(可选,默认 message)
id: 1001 # 事件 ID(可选,用于断线重连的 Last-Event-ID)
retry: 3000 # 重连间隔(毫秒,可选)
data: {"content": "你好"} # 数据内容(必填,可多行)
# 空行表示消息结束
关键约定:
- 每个字段以
\n结尾 - 连续以
data:开头的行会被合并为一条消息 - 空行
\n作为消息分隔符 - 以
:开头的行是注释,可用于保持连接活跃
2.3 前端代码实现
原生 EventSource API(浏览器内置,零依赖)
ts
// sse-client.ts - 使用浏览器原生 EventSource
interface SSEMessage {
content: string;
done?: boolean;
}
function createSSEConnection(url: string, options?: { onToken?: (token: string) => void }) {
const eventSource = new EventSource(url, {
// 默认不支持 POST,仅支持 GET
withCredentials: false,
});
// 监听命名事件
eventSource.addEventListener('message', (event: MessageEvent) => {
const data: SSEMessage = JSON.parse(event.data);
options?.onToken?.(data.content);
});
// 监听自定义事件
eventSource.addEventListener('error', (event) => {
console.error('SSE 连接异常:', event);
// EventSource 会自动重连,无需手动处理
});
eventSource.addEventListener('open', () => {
console.log('SSE 连接已建立');
});
return eventSource;
}
// 使用示例
const sse = createSSEConnection('/api/chat/stream', {
onToken: (token) => {
// 逐个 token 追加到 UI
appendToMessageDisplay(token);
},
});
// 主动关闭连接
// sse.close();
EventSource 的核心特性:
- 浏览器自动解析
text/event-stream响应 - 自动重连:连接断开后自动尝试重新连接
- 断点续传 :重连时自动发送
Last-Event-ID头 - 仅支持 GET 请求
- 不支持自定义请求头(无法携带 Authorization 头)
一个常见的坑: EventSource 不支持自定义请求头。如果需要携带 token,可以在 URL 上用查询参数传递,比如
new EventSource('/api/stream?token=xxx')。
用 fetch 实现 SSE(支持 POST + 自定义头)
ts
// sse-fetch.ts - 使用 fetch API 手动读取流
async function fetchSSE(
url: string,
options: {
method?: string;
headers?: Record<string, string>;
body?: string;
onMessage?: (data: string) => void;
onError?: (error: Error) => void;
signal?: AbortSignal;
}
) {
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'Accept': 'text/event-stream',
...options.headers,
},
body: options.body,
signal: options.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
options.onMessage?.('[DONE]');
return;
}
options.onMessage?.(data);
}
}
}
}
// 使用示例:AI 大模型流式调用
const controller = new AbortController();
fetchSSE('/api/ai/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: JSON.stringify({
model: 'gpt-4',
messages: [{ role: 'user', content: '你好' }],
stream: true,
}),
onMessage: (data) => {
if (data === '[DONE]') return;
const parsed = JSON.parse(data);
// 处理每个 token
displayToken(parsed.choices[0]?.delta?.content || '');
},
signal: controller.signal,
});
// 取消流式请求
// controller.abort();
2.4 服务端代码实现(Node.js)
ts
// server-sse.ts - Express + SSE
import express, { Request, Response } from 'express';
const app = express();
app.use(express.json());
app.post('/api/chat/stream', async (req: Request, res: Response) => {
// 设置 SSE 必需的响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
// 允许跨域
'Access-Control-Allow-Origin': '*',
});
// 发送一个注释行保持连接活跃(某些代理会超时关闭空闲连接)
const keepAlive = setInterval(() => {
res.write(': keepalive\n\n');
}, 15000);
try {
const { messages } = req.body;
// 模拟 AI 模型逐个生成 token
const response = '你好!我是 AI 助手,很高兴为你服务。';
for (const char of response) {
// SSE 数据格式:data: <json>\n\n
const payload = JSON.stringify({ content: char, done: false });
res.write(`data: ${payload}\n\n`);
// 模拟生成延迟
await new Promise(r => setTimeout(r, 50));
}
// 发送结束标志
res.write(`data: ${JSON.stringify({ content: '', done: true })}\n\n`);
} catch (err) {
console.error('Stream error:', err);
} finally {
clearInterval(keepAlive);
res.end();
}
});
// 处理客户端断开连接
app.post('/api/chat/stream/abort', (req: Request, res: Response) => {
// 实际项目中需要维护连接池来终止对应流
res.json({ ok: true });
});
app.listen(3000, () => {
console.log('SSE server running on http://localhost:3000');
});
2.5 SSE 的自动重连机制
这是 SSE 相比 WebSocket 最大的优势之一。
当连接意外断开时,EventSource 会自动执行以下逻辑:
- 等待
retry字段指定的时间(默认 2-3 秒) - 重新发起 HTTP 请求到原始 URL
- 自动带上
Last-Event-ID请求头 - 服务端根据
Last-Event-ID决定从哪开始重发
text
客户端断开 → 等待 retry 时间 → 发起新连接 → 携带 Last-Event-ID → 服务端从断点恢复
模拟断线重连场景:
ts
// 服务端记录事件 ID
let eventId = 0;
app.get('/api/notifications', (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// 读取客户端传来的 Last-Event-ID
const lastEventId = parseInt(req.headers['last-event-id'] as string || '0', 10);
eventId = Math.max(eventId, lastEventId);
// 补偿断线期间错过的消息(从事件历史中读取)
const missedEvents = getEventsSince(lastEventId);
for (const evt of missedEvents) {
eventId++;
res.write(`id: ${eventId}\n`);
res.write(`data: ${JSON.stringify(evt)}\n\n`);
}
// 持续监听新事件...
const listener = (event: NotificationEvent) => {
eventId++;
res.write(`id: ${eventId}\n`);
res.write(`data: ${JSON.stringify(event.data)}\n\n`);
};
eventBus.on('notification', listener);
req.on('close', () => {
eventBus.off('notification', listener);
});
});
2.6 SSE 的局限性
- 单向通信:只能服务端 → 客户端,客户端无法通过同一连接向服务端发数据
- 仅支持 GET:原生 EventSource 只支持 GET 请求
- 无自定义头:EventSource API 无法设置自定义请求头
- 文本专用:不支持二进制数据(除非 Base64 编码)
- 浏览器连接数限制:同域名下 Chrome 限制 HTTP/1.1 最多 6 个并发连接(HTTP/2 下无此问题)
- 无内置鉴权:需通过 URL 参数携带 token,安全性略低
三、WebSocket 深度解析
3.1 协议原理
WebSocket 的设计思路和 SSE 完全不同------它不是在 HTTP 响应里"借道",而是通过 HTTP 升级握手 建立一条全新的传输通道。
text
Client Server
| |
|---- HTTP GET (Upgrade) ------>| 握手请求
| |
|<--- 101 Switching Protocols --| 确认升级
| |
|========= 全双工管道 =========|
| 双向发送文本/二进制数据 |
| |
|<-------- data ---------------|
|-------- data --------------->|
关键握手过程(HTTP 升级):
http
# 客户端请求
GET /ws/chat HTTP/1.1
Host: example.com
Upgrade: websocket # 请求协议升级
Connection: Upgrade # 标记为升级连接
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== # 握手的密钥
Sec-WebSocket-Version: 13 # 协议版本
# 服务端响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= # 握手确认
握手完成后,连接从 HTTP 协议无缝切换到 WebSocket 协议,之后的数据传输不再经过 HTTP 层。
3.2 数据帧结构
WebSocket 的数据传输以**帧(Frame)**为单位,每个帧有控制信息和载荷:
text
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| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - +
| Masking-key, if MASK set to 1 |
+ - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - +
| Payload Data (variable length) |
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
- opcode:标识帧类型(0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong)
- FIN:是否为最后一帧(用于分片消息)
- Mask:客户端发的数据必须掩码(防止缓存污染攻击)
3.3 前端代码实现
ts
// websocket-client.ts - WebSocket 前端封装
interface WSMessage {
type: 'text' | 'binary' | 'ping' | 'pong' | 'close';
data: string | ArrayBuffer;
}
type WSStatus = 'connecting' | 'connected' | 'disconnected' | 'reconnecting';
class WebSocketClient {
private ws: WebSocket | null = null;
private url: string = '';
private heartbeatInterval: number = 30000; // 30s 心跳
private reconnectInterval: number = 3000; // 3s 重连
private maxReconnectAttempts: number = 10;
private reconnectAttempts: number = 0;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private status: WSStatus = 'disconnected';
private messageHandlers: Array<(msg: WSMessage) => void> = [];
private statusHandlers: Array<(status: WSStatus) => void> = [];
constructor(url: string) {
this.url = url;
}
// 建立连接
connect(): void {
if (this.ws?.readyState === WebSocket.OPEN) return;
this.setStatus('connecting');
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.setStatus('connected');
this.reconnectAttempts = 0;
this.startHeartbeat();
};
this.ws.onmessage = (event: MessageEvent) => {
const message: WSMessage = {
type: typeof event.data === 'string' ? 'text' : 'binary',
data: event.data,
};
this.messageHandlers.forEach((handler) => handler(message));
};
this.ws.onclose = (event: CloseEvent) => {
this.stopHeartbeat();
this.setStatus('disconnected');
this.attemptReconnect();
};
this.ws.onerror = (error: Event) => {
console.error('WebSocket error:', error);
// onerror 后面会触发 onclose,所以不需要重复处理重连
};
}
// 发送消息
send(data: string | ArrayBuffer): void {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn('WebSocket 未连接,无法发送消息');
return;
}
this.ws.send(data);
}
// 发送 JSON 消息
sendJSON(payload: Record<string, unknown>): void {
this.send(JSON.stringify(payload));
}
// 心跳机制
private startHeartbeat(): void {
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
// 发送 Ping 帧(浏览器 WebSocket API 自动处理 Pong 响应)
// 这里我们发送应用层心跳来确认双方存活
this.sendJSON({ type: 'ping', timestamp: Date.now() });
}
}, this.heartbeatInterval);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
// 断线重连
private attemptReconnect(): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket 重连次数已达上限');
this.setStatus('disconnected');
return;
}
this.setStatus('reconnecting');
this.reconnectAttempts++;
const delay = Math.min(
this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), // 指数退避
30000 // 最大 30s
);
console.log(
`WebSocket ${this.reconnectAttempts}/${this.maxReconnectAttempts} 次重连,等待 ${delay}ms`
);
setTimeout(() => {
this.connect();
}, delay);
}
// 主动关闭
disconnect(): void {
this.maxReconnectAttempts = 0; // 阻止重连
this.stopHeartbeat();
this.ws?.close(1000, '客户端主动关闭');
this.ws = null;
this.setStatus('disconnected');
}
// 监听消息
onMessage(handler: (msg: WSMessage) => void): () => void {
this.messageHandlers.push(handler);
return () => {
this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
};
}
// 监听状态变化
onStatusChange(handler: (status: WSStatus) => void): () => void {
this.statusHandlers.push(handler);
return () => {
this.statusHandlers = this.statusHandlers.filter((h) => h !== handler);
};
}
private setStatus(status: WSStatus): void {
this.status = status;
this.statusHandlers.forEach((handler) => handler(status));
}
getStatus(): WSStatus {
return this.status;
}
}
// 使用示例
const wsClient = new WebSocketClient('wss://api.example.com/ws/chat');
wsClient.onStatusChange((status) => {
console.log('连接状态:', status);
updateConnectionIndicator(status);
});
wsClient.onMessage((msg) => {
if (msg.type === 'text') {
const payload = JSON.parse(msg.data as string);
if (payload.type === 'message') {
appendChatMessage(payload.data);
}
}
});
wsClient.connect();
// 发送聊天消息
// wsClient.sendJSON({ type: 'message', content: '大家好!' });
3.4 服务端代码实现(Node.js + ws 库)
ts
// server-websocket.ts - Node.js WebSocket 服务
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';
interface ChatMessage {
type: 'message' | 'ping' | 'pong';
content?: string;
timestamp: number;
from?: string;
}
// 心跳检测参数
const HEARTBEAT_INTERVAL = 30000; // 30s 发一次心跳
const HEARTBEAT_TIMEOUT = 10000; // 10s 内未收到 Pong 视为断开
// 扩展 WebSocket 类型,挂载自定义属性
interface ExtendedWebSocket extends WebSocket {
isAlive: boolean;
lastPong: number;
clientId: string;
}
const server = http.createServer();
const wss = new WebSocketServer({ server });
// 心跳检测
const heartbeatChecker = setInterval(() => {
const now = Date.now();
wss.clients.forEach((client) => {
const ws = client as ExtendedWebSocket;
// 如果超过超时时间未收到 pong,主动断开
if (now - ws.lastPong > HEARTBEAT_TIMEOUT + HEARTBEAT_INTERVAL) {
console.log(`客户端 ${ws.clientId} 心跳超时,断开连接`);
return ws.terminate();
}
// 发送 Ping 帧
ws.ping();
});
}, HEARTBEAT_INTERVAL);
wss.on('close', () => {
clearInterval(heartbeatChecker);
});
wss.on('connection', (ws: ExtendedWebSocket, req: IncomingMessage) => {
// 初始化客户端状态
ws.isAlive = true;
ws.lastPong = Date.now();
ws.clientId = `client_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
console.log(`客户端 ${ws.clientId} 已连接`);
// 处理 Pong 响应(ws 库自动响应 ping,但我们需要记录时间)
ws.on('pong', () => {
ws.lastPong = Date.now();
ws.isAlive = true;
});
// 处理消息
ws.on('message', (raw: Buffer) => {
try {
const msg: ChatMessage = JSON.parse(raw.toString());
switch (msg.type) {
case 'ping':
// 响应应用层心跳
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
case 'message':
// 广播消息给所有客户端(或指定目标)
const broadcast: ChatMessage = {
type: 'message',
content: msg.content,
timestamp: Date.now(),
from: ws.clientId,
};
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(broadcast));
}
});
break;
default:
ws.send(JSON.stringify({ type: 'error', content: '未知消息类型' }));
}
} catch (err) {
ws.send(JSON.stringify({ type: 'error', content: '消息格式错误' }));
}
});
// 处理断开
ws.on('close', (code: number, reason: Buffer) => {
console.log(
`客户端 ${ws.clientId} 断开连接,code: ${code}, reason: ${reason.toString()}`
);
});
// 发送欢迎消息
ws.send(
JSON.stringify({
type: 'message',
content: `欢迎加入聊天室!你的 ID: ${ws.clientId}`,
timestamp: Date.now(),
})
);
});
server.listen(8080, () => {
console.log('WebSocket server running on ws://localhost:8080');
});
3.5 必须手动实现的心跳与重连
WebSocket 本身并没有内置心跳和重连机制------这是和 SSE 最大的体验差距。
为什么需要心跳?
- 代理/网关超时断开:Nginx、负载均衡器等中间件会关闭长时间空闲的连接
- 网络异常检测 :有些网络故障不会触发
onclose,需要通过心跳失败来感知 - 移动网络:手机切换基站时连接可能"静默"断开
心跳策略通常有两种:
| 策略 | 原理 | 优势 | 劣势 |
|---|---|---|---|
| TCP Keep-Alive | 操作系统层发送探测包 | 不需要应用层代码 | 默认关闭,间隔通常太长(2h) |
| 应用层 Ping/Pong | 应用层定时发送心跳消息 | 精确可控,能携带业务信息 | 需要手动实现 |
四、核心对比:一张表看清所有差异
| 对比维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端 → 客户端(单向) | 双向全双工 |
| 协议基础 | 纯 HTTP(无需升级) | HTTP 升级握手后切换协议 |
| 数据格式 | 文本(UTF-8,text/event-stream) |
文本 + 二进制(自定义帧) |
| 连接建立 | 普通 GET 请求,一次握手 | 2 次握手(HTTP + 协议升级) |
| 重连机制 | 内置(EventSource 自动重连) | 无(需手动实现) |
| 心跳机制 | 无需(HTTP 长连接自带) | 需手动实现 Ping/Pong |
| 请求方式 | 仅 GET(原生 EventSource) | 无限制 |
| 自定义请求头 | 不支持(可用 fetch 绕过) | 支持 |
| 浏览器支持 | 所有现代浏览器(IE 除外) | 所有现代浏览器 |
| 连接数限制 | HTTP/1.1 同域 6 个(HTTP/2 无限制) | 无特殊限制(同域通常 50+) |
| 二进制支持 | 不支持(需 Base64 编码) | 原生支持 |
| 服务端成本 | 极低(普通 HTTP 服务器即可) | 较高(需维护长连接状态) |
| 协议栈复杂度 | 简单(纯文本协议) | 复杂(帧协议、掩码、分片) |
| 穿透性 | 好(完全兼容 HTTP 代理) | 差(需要代理支持 Upgrade) |
五、实战场景选型指南
场景 1:AI 大模型流式输出 ✅ 选 SSE
OpenAI、Claude、通义千问等大模型 API 全部选择 SSE 作为流式输出协议,原因很简单:
- 单向就够了:用户只需要看 AI "打字",不需要在同一个连接上发其他数据
- 连接成本低:每次对话新建一个 HTTP 连接,用完即关,无需维护连接池
- 数据天然是文本:token 输出就是 UTF-8 文本
- 自动重连体验好:网络抖动时浏览器自动重连,用户几乎无感知
- 代理友好:标准的 HTTP 协议,CDN、网关无需额外配置
ts
// AI 流式输出的理想架构
// 前端
const stream = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'gpt-4', stream: true }),
});
// ✅ SSE stream
// 服务端
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
for (const token of generatedTokens) {
response.write(`data: ${JSON.stringify({ choices: [{ delta: { content: token } }] })}\n\n`);
}
response.end();
场景 2:在线聊天室 ✅ 选 WebSocket
双向、实时、高频的交互场景,WebSocket 是正统选择:
- 双向通信:用户收发消息频繁,同一连接即可完成
- 低延迟:无需每次建连,消息延迟通常在毫秒级
- 二进制支持:图片、文件等可以直接通过二进制帧传输
- 高并发:一个连接处理所有消息,服务端连接数可控
ts
// 聊天室
ws.on('message', (data) => {
const msg = JSON.parse(data);
// 用户发送消息 → 服务端接收 → 广播给其他用户
broadcast(msg);
});
场景 3:实时通知推送(弱交互)✅ 选 SSE
服务端状态变更通知、订单状态更新、告警推送等场景:
- 无需双向:用户不需要通过推送连接回复
- 自带重连:不用自己写重连逻辑
- 开发成本低:几行代码即可实现
ts
// 订单状态更新推送
res.write(`data: ${JSON.stringify({ orderId: 123, status: 'paid' })}\n\n`);
场景 4:实时协同编辑 ✅ 选 WebSocket(需要 OT/CRDT 通信)
多人同时编辑文档需要高频的双向同步:
- 双向高频通信:操作(Operational Transformation)需要双向传播
- 低延迟关键:用户打字到他人看到,延迟越短越好
- 连接持久化:编辑过程可能持续数小时,单一长连接更可靠
场景 5:实时数据看板 ✅ 两者皆可,推荐 SSE
股票行情、监控仪表盘等:
- 如果客户端不需要控制 (只展示数据):SSE 更简单
- 如果需要交互控制 (例如筛选条件推给服务端):考虑 WebSocket
选型决策流程图
text
你的实时通信场景
│
▼
是否需要双向通信?
│
┌───┴───┐
│ │
是 │
│ 否
▼ │
WebSocket │
▼
是否需要传输二进制数据?
│
┌───┴───┐
│ │
是 │
│ 否
▼ │
WebSocket │
▼
服务器推送频率高且连接持久?
│
┌───┴───┐
│ │
是 │
│ 否
▼ │
是否需要手动控制请求方式/自定义头?
│ │
┌───┴───┐ │
│ │ │
是 否 │
│ │ │
▼ ▼ │
fetch+SSE SSE │
│ │
▼ ▼
WebSocket(选型安全牌)
(通用但成本高)
六、性能对比与成本分析
连接建立开销
scss
SSE 连接:1 个 HTTP 请求 → 1 次 RTT → 建立 → 发送数据
WebSocket 连接:HTTP 请求(1 RTT) + 协议升级(1 RTT) → 2 次 RTT → 发送数据
SSE 少一次往返,在弱网环境下优势明显。
帧头开销
- SSE :每条消息约 20-30 字节 额外开销(
data: \n\n) - WebSocket :最小帧头仅 2 字节(短消息时),但大消息时有扩展载荷长度
服务端资源对比
| 指标 | SSE | WebSocket |
|---|---|---|
| 单连接内存开销 | ~10KB(纯 HTTP 连接) | ~50-100KB(含帧缓冲) |
| 10 万并发连接 | 约 1-2GB | 约 5-10GB |
| 连接状态维护 | 无状态(HTTP 天然无状态) | 需全状态管理 |
| CDN 兼容性 | ⭐⭐⭐⭐⭐ 完美兼容 | ⭐⭐ 需要特殊配置 |
真实压测数据参考
以下数据基于某生产环境的实际压测(4C8G 单机节点):
| 方案 | 最大并发连接数 | 消息延迟 P99 | CPU 占用 |
|---|---|---|---|
| SSE | 65,000+ | < 50ms | 中等 |
| WebSocket | 50,000+ | < 20ms | 较高(心跳+帧处理) |
SSE 的并发连接上限通常高于 WebSocket,因为它更轻量------不需要维护帧缓冲、掩码处理等复杂状态。
七、常见问题与避坑指南
SSE 相关
Q:EventSource 无法携带 Authorization 头怎么办?
A:三种方案:
- URL 参数传 token:
new EventSource('/api/stream?token=' + jwt) - Cookie 鉴权(需配置
withCredentials: true) - 使用 fetch 手动读取流(前文有代码示例)
Q:HTTP/2 下 SSE 有什么变化?
A:HTTP/2 解决了 SSE 的并发连接数限制问题。在 HTTP/2 下,同域名可以建立大量 SSE 连接而不会触发 6 连接上限。此外,HTTP/2 的多路复用可以减少 SSE 的头部开销。
Q:Nginx 代理 SSE 需要特殊配置吗?
A:需要关闭缓冲,否则 Nginx 会缓存 SSE 数据导致客户端无法实时收到:
nginx
location /api/stream {
proxy_pass http://backend;
proxy_buffering off; # 关闭缓冲
proxy_cache off; # 关闭缓存
chunked_transfer_encoding on;
proxy_read_timeout 86400s; # 长超时
proxy_set_header Connection '';
}
WebSocket 相关
Q:WebSocket 断线后如何优雅恢复状态?
A:设计消息 ID 机制,重连后同步状态:
- 每条消息分配递增 ID
- 客户端记录已接收的最大 ID
- 重连后发送
{ type: 'sync', lastId: 12345 } - 服务端补偿缺失的消息
Q:WebSocket 如何做负载均衡?
A:WebSocket 长连接需要会话保持(Sticky Session):
- Nginx
ip_hash或sticky模块 - 或者使用 Redis 等外部存储做状态共享
Q:WebSocket 连接数太多怎么办?
A:可以考虑多域名分散连接,或使用 HTTP/2 的 Server Push 作为替代(但功能有限)。
八、总结
回到开篇的问题:AI 流式输出为什么选 SSE 而不是 WebSocket?
答案可以浓缩为一句话:用最简单的方式,做恰好够用的事。
SSE 的哲学是"在 HTTP 的框架内优雅地解决问题"------不改变协议、不增加复杂度、充分利用浏览器原生能力。当通信是单向的、数据是文本的、连接可能不稳定的,SSE 就是那个"恰好够用"的方案。
WebSocket 的哲学则相反------"创造一个新的协议来彻底解决双向通信问题"。当需要真正的全双工通信、二进制传输、极低延迟时,WebSocket 是毋庸置疑的标准答案。
选型不是比谁更强,而是比谁更适合。
| 场景 | 推荐方案 |
|---|---|
| AI 大模型流式输出 | SSE |
| 服务端通知/推送 | SSE |
| 实时数据看板 | SSE(优先) |
| 在线聊天室 | WebSocket |
| 实时协同编辑 | WebSocket |
| 实时游戏 | WebSocket |
| 物联网数据采集 | WebSocket |
最后,我想说:不要为了"技术时髦"而选择 WebSocket,也不要为了"简单"而强行用 SSE。 理解两者的原理和适用边界,根据你的业务场景做决策------这才是架构师该有的思维。
如果你在实际项目中遇到了 SSE 或 WebSocket 的坑,欢迎在评论区分享你的经验。如果本文对你有帮助,点个赞让更多人看到。