目录
2680 字
13 分钟
WebSocket、SSE 与 WebRTC:实时通信怎么选,先看数据往哪边走

引言#

「给我加个实时功能」——这句话在需求会上出现的频率很高,但它至少可能指三件完全不同的事:

  • 股票价格变了,服务器要推给浏览器;
  • 用户在聊天框里发消息,双方都要立刻收到;
  • 两个人要视频通话,或者直接传文件。

如果只按「实时」两个字选型,很容易走到两个极端:把所有需求都塞进 WebSocket,或者反过来,认为 SSE 只能做「服务器单向通知」而不敢用。其实这三种技术的边界很清楚,先回答几个问题,答案基本就出来了。

一、先回答三个问题,再谈协议#

1. 数据往哪个方向流?#

只有服务器 → 浏览器,还是双方都要发?这是最重要的一条。单向推送用 SSE 往往比 WebSocket 更省事:它就是一个普通的 HTTP 响应,鉴权、日志、网关、灰度都能复用现有设施。

2. 消息要不要经过你的服务器?#

聊天、协作编辑、游戏状态通常需要一个中心节点来排序和广播;而视频通话、P2P 文件传输希望两端直连,服务器只负责「介绍他们认识」。后者是 WebRTC 的领域。

3. 你要的是「流」还是「消息」?#

SSE 传输的是一条连续的文本事件流,断线后浏览器会带着 Last-Event-ID 自动重连;WebSocket 传输的是一条条有边界、可双向发送的消息,但断线重连、心跳、消息补发都得自己写。

是 否 否 是 是 否 需要实时通信 需要传音视频或 P2P 直连? WebRTC 客户端也要主动发消息? SSE 需要极低延迟的双向消息? WebSocket

二、SSE:把 HTTP 响应变成一条事件流#

SSE(Server-Sent Events)的协议部分非常小:客户端用 EventSource 发一个 GET 请求,服务器返回 Content-Type: text/event-stream,然后保持连接,按约定格式不断写入事件。消息以空行分隔,字段只有四个:

id: 42
event: price
retry: 3000
data: {"symbol":"BTC","price":101234}

含义分别是:id 用于断线重连时的 Last-Event-ID,event 是事件名(客户端用 addEventListener 监听),retry 建议重连间隔,data 是载荷。以 : 开头的行是注释,常用来发心跳。

一个不依赖任何框架的 Node.js 服务端:

import http from "node:http";
const clients = new Set();
const history = []; // 演示用;生产环境换成 Redis Stream / 数据库
let nextId = 0;
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== "/events") {
res.writeHead(404).end();
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no", // 告诉 Nginx 不要缓冲这条响应
});
// 浏览器重连时会自动带上 Last-Event-ID
const lastId = Number(req.headers["last-event-id"] ?? -1);
for (const event of history) {
if (event.id > lastId) writeEvent(res, event);
}
clients.add(res);
req.on("close", () => clients.delete(res));
});
function writeEvent(res, { id, type, data }) {
res.write(`id: ${id}\n`);
if (type) res.write(`event: ${type}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
// 心跳:防止代理或负载均衡器把空闲连接掐掉
setInterval(() => {
for (const res of clients) res.write(": heartbeat\n\n");
}, 15_000);
// 模拟业务事件
setInterval(() => {
const event = {
id: nextId++,
type: "price",
data: { symbol: "BTC", price: 100_000 + Math.random() * 1_000 },
};
history.push(event);
if (history.length > 100) history.shift();
for (const res of clients) writeEvent(res, event);
}, 1_000);
server.listen(3000);

浏览器端只需要一个 EventSource:

const source = new EventSource("/events");
source.addEventListener("price", (event) => {
console.log(JSON.parse(event.data));
});
source.onerror = () => {
// 浏览器会自动重连;只有 readyState 变成 CLOSED 才是彻底放弃
if (source.readyState === EventSource.CLOSED) {
console.error("连接已关闭,需要重新创建 EventSource");
}
};

注意几个容易忽略的细节:

  • 只能 GET,不能自定义请求头。认证通常靠同源 Cookie;把 token 放在查询参数里会进访问日志,要谨慎。
  • 204 No Content 会让客户端停止重连。如果服务端确定「没有更多事件了」,返回 204 比一直挂着更明确。
  • 代理和压缩中间件会缓冲。Nginx 需要 X-Accel-Buffering: no,压缩中间件最好对这个路由关闭,否则事件会攒成一坨才到浏览器。
  • HTTP/1.1 下同源连接数有限。常见浏览器对同一源大约只有 6 条并发连接,而且是整个浏览器共享的;开六个标签页各挂一条 SSE,就可能把连接池占满。生产环境尽量走 HTTP/2,或者用 BroadcastChannel / Web Locks 让多个标签页共享一条连接。

三、WebSocket:全双工,但重连要自己写#

WebSocket 通过一次 HTTP Upgrade 握手切换到全双工协议(RFC 6455),之后双方可以随时发送文本或二进制帧。它没有 SSE 那样的自动重连:连接一断,WebSocket 实例就永久进入 CLOSED 状态,再调用 send() 会直接抛错。

服务端用 ws 库的最小实现,加上心跳清理半开连接:

import http from "node:http";
import { WebSocketServer } from "ws";
const server = http.createServer();
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", () => {
socket.isAlive = true;
});
socket.on("message", (raw) => {
const message = JSON.parse(raw.toString());
for (const client of wss.clients) {
if (client.readyState === client.OPEN) {
client.send(JSON.stringify({ type: "chat", text: message.text }));
}
}
});
});
// 每 30 秒探活一次,清掉已经死掉但没收到 close 的连接
const timer = setInterval(() => {
for (const socket of wss.clients) {
if (!socket.isAlive) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(timer));
server.listen(3000);

客户端要自己补上指数退避重连,并加一点随机抖动,避免服务端重启时所有客户端同时回来:

function connect(url) {
const socket = new WebSocket(url);
let retry = 0;
let closedByUser = false;
socket.addEventListener("open", () => {
retry = 0;
console.log("connected");
});
socket.addEventListener("message", (event) => {
console.log(JSON.parse(event.data));
});
socket.addEventListener("close", (event) => {
if (closedByUser) return;
const base = Math.min(30_000, 500 * 2 ** retry++);
const delay = base * (0.5 + Math.random()); // 0.5x ~ 1.5x 抖动
console.warn(`closed (${event.code}), retry in ${Math.round(delay)}ms`);
setTimeout(() => connect(url), delay);
});
return {
send: (data) => socket.send(JSON.stringify(data)),
close: () => {
closedByUser = true;
socket.close(1000, "bye");
},
};
}

几个实践要点:

  • 浏览器不能给 WebSocket 设置自定义请求头。认证要么靠 Cookie,要么在连接后的第一条消息里发 token,要么用 Sec-WebSocket-Protocol 子协议传。
  • 关闭码要区分。1000 是正常关闭,1006 表示连接异常断开、通常意味着网络或服务端出了问题,值得记一条日志。
  • 多实例部署需要广播层。上面的 wss.clients 只包含连到当前进程的客户端;多台机器之间要用 Redis Pub/Sub、NATS 之类的消息总线转发,或者做粘性会话。
  • HTTP/2 下的 WebSocket 由 RFC 8441 定义,通过扩展 CONNECT 复用同一条连接;但很多网关仍按 HTTP/1.1 处理,上线前要确认链路支持。

四、WebRTC:P2P,不是用来替代服务器的#

WebRTC 解决的是另一类问题:让两个浏览器直接传输音视频或数据,服务器只负责信令和 NAT 穿透。W3C 的《WebRTC: Real-Time Communication in Browsers》已经是正式 Recommendation(2025 年 3 月),浏览器支持相当稳定。

它有三个绕不开的组件:

  • 信令:协议本身不规定。你可以用 WebSocket、SSE,甚至手动复制粘贴 SDP,只要双方能交换 offer / answer / ICE candidate。
  • ICE / STUN / TURN:STUN 帮双方发现公网地址;对称 NAT 或严格防火墙下必须走 TURN 中继,所以「能连上」的前提往往是「你有一台 TURN 服务器」。
  • DTLS / SRTP:音视频走 SRTP,数据通道走 SCTP over DTLS,可以按需选择有序/无序、可靠/不可靠。

一个建立数据通道的最小示例:

const config = {
iceServers: [
// 替换成你自己的 STUN 服务
{ urls: "stun:stun.example.com:3478" },
// 生产环境还需要 TURN:
// { urls: "turn:turn.example.com:3478", username: "u", credential: "p" },
],
};
// 假设 signaling 是一个已经连好的 WebSocket,send / onmessage 由你实现
async function startCaller(signaling) {
const pc = new RTCPeerConnection(config);
const channel = pc.createDataChannel("chat", { ordered: true });
channel.onopen = () => channel.send("hello from caller");
channel.onmessage = (event) => console.log("caller recv:", event.data);
pc.onicecandidate = ({ candidate }) => {
if (candidate) signaling.send({ type: "candidate", candidate });
};
await pc.setLocalDescription(await pc.createOffer());
signaling.send({ type: "offer", sdp: pc.localDescription.sdp });
return pc;
}
async function startCallee(signaling) {
const pc = new RTCPeerConnection(config);
// 必须在 setRemoteDescription(offer) 之前注册
pc.ondatachannel = ({ channel }) => {
channel.onmessage = (event) => console.log("callee recv:", event.data);
};
pc.onicecandidate = ({ candidate }) => {
if (candidate) signaling.send({ type: "candidate", candidate });
};
return pc;
}

收到信令后,callee 依次执行 setRemoteDescription(offer)、createAnswer()、setLocalDescription(answer) 并把 answer 发回去;双方再把收到的 candidate 交给 addIceCandidate()。真实项目里还要处理 candidate 先于 remote description 到达、协商冲突等情况,通常用「perfect negotiation」模式统一处理。

WebRTC 不适合做大规模广播:一对一通话是点对点;多人会议要么走 Mesh(每个客户端连所有人,人数一多带宽爆炸),要么上 SFU(选择性转发服务器)。如果你的需求只是「服务器把状态推给十万个客户端」,那还是 SSE 或 WebSocket。

五、选型表#

场景首选原因
LLM 流式输出、行情推送、通知流SSE单向、纯 HTTP、自动重连、网关友好
聊天室、协作编辑、游戏状态同步WebSocket双向、低延迟、消息边界清晰
视频通话、屏幕共享、P2P 传文件WebRTC媒体传输、NAT 穿透、UDP 低延迟
单向但需要二进制或超高频率WebSocket / WebTransportSSE 只支持 UTF-8 文本
服务器向大量客户端广播SSE 或 WebSocket + Pub/SubWebRTC Mesh 不承担广播

一句话版本:默认先考虑 SSE;客户端也要说话时升级到 WebSocket;只有涉及音视频或真正的 P2P 直连时才上 WebRTC。

六、三个最容易踩的坑#

  1. SSE 被代理缓冲:本地一切正常,上线后事件延迟几十秒才到。先检查 Nginx 的 proxy_buffering、CDN 的缓冲策略和压缩中间件。
  2. WebSocket 忘记重连和心跳:移动网络切换、负载均衡空闲超时都会让连接悄悄消失。没有心跳,服务端要到下一次写失败才知道对方已经走了。
  3. WebRTC 只配了 STUN:在办公室网络或 4G 对称 NAT 下,P2P 打洞失败,必须回落到 TURN 中继;没有 TURN,用户看到的就是「一直在连接」。

结语#

「实时」不是一个协议,而是一组约束:数据往哪边走、经过谁、丢了能不能补、延迟和可靠性怎么权衡。把这几条写清楚,SSE、WebSocket、WebRTC 的选择其实不需要争论。

顺带一提,SSE 的 Last-Event-ID 重放和消息队列的「至少一次投递」是同一类问题:重连可能重复收到事件,消费端必须幂等。这一点在《消息队列不生产可靠性》里有更完整的讨论;如果你的实时连接要跨越弱网和离线场景,也可以配合《Service Worker 高级用法》一起看。

参考来源#

WebSocket、SSE 与 WebRTC:实时通信怎么选,先看数据往哪边走
https://www.hehonglei.cn/posts/websocket-sse-webrtc-realtime-communication-selection/
作者
Honglei He
发布于
2026-10-19
许可协议
CC BY-NC-SA 4.0