目录
引言
「给我加个实时功能」——这句话在需求会上出现的频率很高,但它至少可能指三件完全不同的事:
- 股票价格变了,服务器要推给浏览器;
- 用户在聊天框里发消息,双方都要立刻收到;
- 两个人要视频通话,或者直接传文件。
如果只按「实时」两个字选型,很容易走到两个极端:把所有需求都塞进 WebSocket,或者反过来,认为 SSE 只能做「服务器单向通知」而不敢用。其实这三种技术的边界很清楚,先回答几个问题,答案基本就出来了。
一、先回答三个问题,再谈协议
1. 数据往哪个方向流?
只有服务器 → 浏览器,还是双方都要发?这是最重要的一条。单向推送用 SSE 往往比 WebSocket 更省事:它就是一个普通的 HTTP 响应,鉴权、日志、网关、灰度都能复用现有设施。
2. 消息要不要经过你的服务器?
聊天、协作编辑、游戏状态通常需要一个中心节点来排序和广播;而视频通话、P2P 文件传输希望两端直连,服务器只负责「介绍他们认识」。后者是 WebRTC 的领域。
3. 你要的是「流」还是「消息」?
SSE 传输的是一条连续的文本事件流,断线后浏览器会带着 Last-Event-ID 自动重连;WebSocket 传输的是一条条有边界、可双向发送的消息,但断线重连、心跳、消息补发都得自己写。
二、SSE:把 HTTP 响应变成一条事件流
SSE(Server-Sent Events)的协议部分非常小:客户端用 EventSource 发一个 GET 请求,服务器返回 Content-Type: text/event-stream,然后保持连接,按约定格式不断写入事件。消息以空行分隔,字段只有四个:
id: 42event: priceretry: 3000data: {"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 / WebTransport | SSE 只支持 UTF-8 文本 |
| 服务器向大量客户端广播 | SSE 或 WebSocket + Pub/Sub | WebRTC Mesh 不承担广播 |
一句话版本:默认先考虑 SSE;客户端也要说话时升级到 WebSocket;只有涉及音视频或真正的 P2P 直连时才上 WebRTC。
六、三个最容易踩的坑
- SSE 被代理缓冲:本地一切正常,上线后事件延迟几十秒才到。先检查 Nginx 的
proxy_buffering、CDN 的缓冲策略和压缩中间件。 - WebSocket 忘记重连和心跳:移动网络切换、负载均衡空闲超时都会让连接悄悄消失。没有心跳,服务端要到下一次写失败才知道对方已经走了。
- WebRTC 只配了 STUN:在办公室网络或 4G 对称 NAT 下,P2P 打洞失败,必须回落到 TURN 中继;没有 TURN,用户看到的就是「一直在连接」。
结语
「实时」不是一个协议,而是一组约束:数据往哪边走、经过谁、丢了能不能补、延迟和可靠性怎么权衡。把这几条写清楚,SSE、WebSocket、WebRTC 的选择其实不需要争论。
顺带一提,SSE 的 Last-Event-ID 重放和消息队列的「至少一次投递」是同一类问题:重连可能重复收到事件,消费端必须幂等。这一点在《消息队列不生产可靠性》里有更完整的讨论;如果你的实时连接要跨越弱网和离线场景,也可以配合《Service Worker 高级用法》一起看。