1. 实时通信选型:先看清这四种方案的真实边界
先说一个我面试前端候选人时经常问的问题:如果让你实现一个类似在线客服、协作白板或直播弹幕的功能,你会怎么选技术方案?十个人里有八个第一反应是WebSocket。但真的所有场景都适合WebSocket吗?HTTP轮询虽然“老土”,SSE虽然“小众”,在某些业务里却可能是比WebSocket更合适的答案。而一旦涉及音视频通话和点对点文件传输,WebRTC又是另一个维度的选择。这四者不是层层替代的关系,而是各自守着不同的能力边界,选错方案后续踩的坑往往是架构级别的。
从字面理解,HTTP轮询、SSE、WebSocket、WebRTC解决的是同一个问题——让数据突破“请求-响应”的一次性模型,在客户端和服务端之间持续流动。但它们的实现原理、连接模型、延迟特性、服务端资源占用完全不同,适用的业务规模也天差地别。这篇内容我想从实战角度把四条技术路线都拆开来看:底层逻辑是什么、用代码怎么写、真实落地会踩哪些坑、什么场景下选谁最合理。不是那种罗列API文档式的整理,而是把这几年在多个前端项目中碰到的真实问题和取舍逻辑讲透。
为了方便后续讨论,先把四种方案放在同一个坐标系里看个大概,后面再逐个展开。
| 方案 | 通信方向 | 连接模型 | 典型延迟 | 服务端实现成本 | 浏览器兼容性 |
|---|---|---|---|---|---|
| HTTP轮询 | 客户端主动拉取 | 无状态请求 | 取决于轮询间隔 | 极低 | 全兼容 |
| SSE | 服务端单向推送 | 长连接(HTTP) | 毫秒级 | 较低 | 除IE外基本全兼容 |
| WebSocket | 全双工双向 | 长连接(TCP) | 毫秒级 | 较高 | 全兼容 |
| WebRTC | P2P双向 | 点对点直连 | 毫秒级(音视频) | 需搭建信令服务 | 现代浏览器全兼容 |
这个表格只是起点,真正决定选型的因素远不止这几行,后面我会结合具体业务场景继续展开。
2. HTTP轮询:最朴素但永远有用的降级方案
2.1 短轮询的机制与代码写法
HTTP轮询本质上是客户端按照固定时间间隔反复发起普通HTTP请求,由服务端返回最新数据。理解它的关键在于:客户端每次请求都是“干净”的,服务端不维护任何连接状态,请求结束连接即释放。因此它天然无状态、无跨域限制(CORS正常处理即可)、无连接数压力,任何语言任何框架都能零成本实现。
后端按最普通的接口写就行:
// Node.js Express 示例 app.get('/api/polling', (req, res) => { const latestData = getLatestData(); res.json({ code: 0, data: latestData, timestamp: Date.now() }); });前端用一个简单的定时器循环请求:
async function pollData() { try { const res = await fetch('/api/polling'); const data = await res.json(); updateUI(data); } catch (err) { console.error('轮询失败', err); } finally { setTimeout(pollData, 3000); // 3秒后发起下一次请求 } } pollData();这里有两个细节值得注意。第一,用setTimeout而不是setInterval,是为了避免请求响应时间不稳定导致的重叠请求——如果上一次请求因为网络原因迟迟没返回,setInterval到点又会发起下一个请求,服务端压力翻倍且数据顺序错乱。第二,轮询间隔要结合业务容忍延迟来设定,3秒一次意味着数据更新理论上最多延迟3秒,如果业务要求秒级以内延迟,轮询基本就不合适了。
2.2 长轮询:用请求挂起换更低的延迟与代价
长轮询是对短轮询的改良,思路是:客户端发起请求后,服务端不立即返回,而是将这个请求在服务端挂起,等待有新数据才响应。如果等待超过一个阈值(如30秒)仍无新数据,返回一个空包让客户端重新发起连接。这样数据产生后能立刻返回,延迟大幅降低,空转请求也少了很多。
// Express 长轮询 app.get('/api/long-poll', async (req, res) => { const TIMEOUT = 30000; const newData = await waitForData(TIMEOUT); // 阻塞等待数据或超时 res.json({ data: newData || null }); }); // 服务端有一个事件队列,有新数据时通知所有挂起的请求 const pendingClients = []; function waitForData(timeout) { return new Promise((resolve) => { const timer = setTimeout(() => resolve(null), timeout); pendingClients.push({ timer, resolve }); }); } function notifyClients(data) { pendingClients.forEach(({ timer, resolve }) => { clearTimeout(timer); resolve(data); }); pendingClients.length = 0; }长轮询的代价是服务端必须为每个挂起的连接维护上下文,包括连接对象、定时器、待推送事件等,连接数上来后内存压力非常明显。而且要处理连接异常断开后的清理,否则会内存泄漏。所以长轮询适合数据产生不频繁但要求尽快感知的消息场景,且服务端能承担的并发连接数有限的情况下才用。
2.3 轮询最常见的三个坑
第一个坑是并发叠加。很多初学的人确实会用setInterval,但没有考虑到如果一次请求耗时超过间隔,会导致多个请求并发在途。除了改用setTimeout,还可以在请求前用AbortController取消上一次未完成的请求,或者用队列锁保证任何时刻只存在一个在途请求。
第二个坑是轮询节奏不感知页面可见性。用户切换标签页或最小化浏览器后,轮询还在继续,白白消耗带宽和电量。监听visibilitychange事件,页面隐藏时暂停轮询,回到前台再恢复,这在移动端尤其重要。
document.addEventListener('visibilitychange', () => { if (document.hidden) { clearTimeout(timer); } else { pollData(); } });第三个坑是服务端返回的数据量不可控。轮询接口如果每次都返回全量数据,数据量大时对流量是很大浪费。妥协会用增量时间戳参数,客户端每次请求携带上次数据的时间戳,服务端只返回增量数据。
3. SSE:被低估的单向推送方案
3.1 SSE的核心原理与EventSource接口
SSE(Server-Sent Events)的字面意思是服务端发送事件,是一种基于HTTP长连接的服务端单向推送协议。客户端通过EventSource接口建立一个HTTP连接,服务端可在这个连接上持续向客户端推送数据。与WebSocket的“先握手,再切换协议”不同,SSE连接的响应头是Content-Type: text/event-stream,本质上就是服务端告诉你:这个连接不会立刻结束,我会持续给你发东西。
后端写法(Express + 原生实现):
app.get('/api/sse', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.flushHeaders(); // 立即发送响应头,使客户端感知到连接建立 const timer = setInterval(() => { // SSE 格式:注释行、事件行(event)、数据行(data) res.write(`data: ${JSON.stringify({ time: Date.now() })}\n\n`); }, 1000); req.on('close', () => { clearInterval(timer); res.end(); }); });SSE消息格式要求严格:每行以\n结尾,数据内容放在data:之后,一个消息块以空行\n\n结束。上面的代码中,每1000毫秒写入一条JSON数据。
前端接收非常简单:
const source = new EventSource('/api/sse'); source.onopen = () => console.log('SSE连接已建立'); source.onmessage = (event) => { const data = JSON.parse(event.data); updateUI(data); }; source.onerror = (err) => { console.error('SSE连接异常', err); // EventSource 默认会自动重连 };EventSource最方便的地方在于内置断线自动重连。只要不是客户端主动close(),服务端断开连接后浏览器会自动重新发起连接,且重连遵循指数退避算法,不会造成重连风暴。这一点相比WebSocket需要自己完善重连逻辑,SSE是真的省心。
3.2 SSE字段:命名事件、自定义ID与last-Event-ID
除了最基本的data字段,SSE协议还支持event和id字段,这两个字段解决了实际业务里很关键的两个问题:消息类型路由和断线续传。
服务端这样推送带事件类型的消息:
res.write(`event: userLogin\n`); res.write(`data: {"userId": 123}\n\n`); res.write(`event: orderUpdate\n`); res.write(`data: {"orderId": 456, "status": "paid"}\n\n`);前端可以这么处理:
const source = new EventSource('/api/sse'); source.addEventListener('userLogin', (event) => { const data = JSON.parse(event.data); // 处理登录事件 }); source.addEventListener('orderUpdate', (event) => { // 处理订单事件 });再看id字段。服务端可以在消息中附带id: 12345,客户端断线重连时,浏览器会自动在重连请求的Header中带上Last-Event-ID: 12345,服务端读取这个字段就可以确定从哪条消息开始补发。这是做断线续传最优雅的手段,不需要自己设计额外的消息号同步机制。
res.write(`id: 12345\n`); res.write(`data: {"msg": "hello"}\n\n`);3.3 SSE的鉴权问题与浏览器连接数限制
SSE项目里最常见的限制是浏览器对同域HTTP/1.1并发连接数限制——HTTP/1.1规范建议每域名最多6个连接。SSE是长连接,每开一个EventSource就占一个连接,如果一个页面同时开了多个SSE通道,很容易就把连接数打满。解决办法:一是尽量合并消息通道,用一个SSE连接推送所有类型的事件,用event字段区分;二是正式环境上HTTP/2,HTTP/2的并发请求不受6个限制,多个长连接可以复用同一个TCP连接;三是用Web Worker中建立EventSource,不过Worker也受相同浏览器限制,并没根本上解决。
另一个典型问题是鉴权。EventSourceAPI没办法自定义请求头,只能用GET方法,不能带Authorization Header。所以SSE鉴权的通用做法是用一个普通HTTP接口先换取token,然后把它拼在URL的查询参数里:
// 先取token const token = await fetch('/api/auth', { method: 'POST', body: ... }).then(r => r.json()); // 通过查询参数鉴权 const source = new EventSource(`/api/sse?token=${token}`);服务端在建立连接前校验token,校验不通过直接返回401即可。这种方式带来的问题是token会出现在访问日志里,所以SSE场景中token要尽量短命,或者只做一次性鉴权:首次请求时校验成功即销毁token,后续连接保持只认连接本身。
SSE适合哪些业务?典型如股票行情、做任务进度推送、通知栏消息、AI流式输出。它的优势是服务端实现简单,天然支持重连和续传,因为本质是HTTP协议也更容易穿透各种代理。但致命伤是单向通信——只能服务端到客户端,如果业务需要客户端向服务端发指令,就得另加一个HTTP接口配合使用,这是个可接受的组合模式。
4. WebSocket:全双工连接的正确打开方式
4.1 从握手到消息帧:WebSocket连接的本质
WebSocket是全双工通信协议,客户端和服务端都能随时向对方发送数据。很多人对WebSocket有个误解,以为它是一个和HTTP完全无关的新协议。实际上,WebSocket的建立过程就是一次特殊的HTTP升级请求——客户端发送Upgrade: websocket请求头,服务端返回101状态码,协议就从HTTP切换为WebSocket协议(使用独立于HTTP的帧协议,但握手借道HTTP)。
握手请求长这样:
GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13服务端计算出一个Sec-WebSocket-Accept响应头返回101,连接就建立了。这个握手过程带来的重要含义是:WebSocket可以沿用HTTP的端口(80/443)、经过常规的负载均衡和反向代理,但后续的数据帧传输不再走HTTP语义,也不受HTTP连接数的限制。
前端建立连接的API很简单:
const ws = new WebSocket('wss://example.com/socket'); ws.onopen = () => { console.log('连接已建立'); ws.send(JSON.stringify({ type: 'join', room: 'lobby' })); }; ws.onmessage = (event) => { const data = JSON.parse(event.data); handleMessage(data); }; ws.onclose = (event) => { console.log('连接关闭', event.code, event.reason); }; ws.onerror = (err) => { console.error('WebSocket错误', err); };注意wss://是WebSocket的加密协议,对应HTTPS。生产环境务必使用wss://,否则在HTTPS页面混入明文WebSocket连接会被浏览器拦截,也容易遭遇中间人攻击。
4.2 心跳机制:为什么不能只依赖onclose
WebSocket的onclose事件只能捕获“连接明确关闭”的情况。但如果网络中间设备(如移动网络切换、路由器空闲超时、防火墙空闲超时)因为长时间无数据而静默断开连接,客户端其实是感知不到的——TCP层没有数据传输,客户端不会立刻发现连接已经死了。这种情况表现为:页面还显示“在线”,但消息已经发不出去了,服务端也不会主动推送过来。
解决办法是心跳机制。客户端每N秒发送一个ping帧或一条自定义心跳消息,服务端收到后响应pong帧或心跳回包,如果客户端连续M次没收到响应,就主动close()当前连接,然后在重连逻辑中重新建立新连接。
const HEARTBEAT_INTERVAL = 30000; // 每30秒一次心跳 const HEARTBEAT_TIMEOUT = 10000; // 10秒收不到pong就判定超时 let heartbeatTimer = null; let pongTimeout = null; function startHeartbeat() { // 常规心跳 heartbeatTimer = setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'ping' })); pongTimeout = setTimeout(() => { console.warn('心跳超时,正在重连...'); ws.close(); }, HEARTBEAT_TIMEOUT); } }, HEARTBEAT_INTERVAL); ws.addEventListener('message', (event) => { // 收到任何消息都清一下pong超时,服务端的心跳回包也可能是空消息 if (pongTimeout) { clearTimeout(pongTimeout); pongTimeout = null; } }); }心跳不仅仅是保活连接,也是判断对端是否存活的依据。实践中服务端也应该主动检测空闲连接并清理,否则会积累大量半开连接占用内存和文件描述符。
4.3 onclose 1006这个错误码到底意味着什么
大量WebSocket项目里都会遇到onclose, code: 1006, reason: ""这个问题。1006是WebSocket规范中的“连接非正常关闭”错误码,表示连接在没有收到正常关闭帧的情况下被中断。通俗解释就是:客户端和服务端之间的连接“突然没了”,但客户端不知道具体原因。
造成1006的常见原因:
- 服务端进程崩溃或被杀,没有机会发送关闭帧
- 网络链路中断,如断网、路由器重启、手机切换WiFi到4G
- 反代或网关超时,比如Nginx配置的
proxy_read_timeout默认60秒,超过60秒没有任何数据交换,Nginx直接断开连接,客户端收到的就是1006 - 服务器在发送关闭帧前就中断了响应
排查1006的思路很明确:先确认是否因为长时间无数据导致的超时断开,这类问题的出现规律一般是“连上之后一段时间不动,然后断开”。解决方案是加心跳保活,让连接一直有数据流动;其次检查反代超时配置,适当调大proxy_read_timeout并通过心跳解决超时断开问题。如果1006出现时机无规律,就要结合服务端日志看进程是否在报错、内存是否溢出。
4.4 WebSocket断线重连与消息补偿
心跳解决的是“如何发现连接断了”,接下来要解决的是“断了之后怎么办”。成熟的WebSocket客户端必须实现三重能力:指数退避重连、消息队列缓存、消息序号去重补偿。
最简单的重连策略是固定延迟重连,但这对服务端不友好——大量客户端同时断线后同时重连会形成峰值。更好的策略是指数退避加抖动:
let retryCount = 0; function connect() { ws = new WebSocket(WS_URL); ws.onclose = () => { const delay = Math.min(500 * Math.pow(2, retryCount), 30000) + Math.random() * 1000; retryCount++; setTimeout(connect, delay); }; ws.onopen = () => { retryCount = 0; // 连接恢复后,补发离线期间积压的消息 }; }消息补偿指的是,连接断开期间用户发起的消息不能直接丢弃,也不能在页面提示“请稍后再试”。正确做法是把发送接口封装成带队列的形式:发消息时推入一个sendingQueue中,同时写入localStorage持久化;收到服务端确认再出队;重连成功后,把队列中未确认的消息按顺序重新发送。
服务端侧需要配合做消息幂等——客户端重发消息时带上唯一消息ID,服务端校验重复ID直接返回确认但不重复处理业务。没有这一步,网络抖动时很容易出现订单重复创建的灾难。
5. WebRTC:浏览器里的P2P实时通信
5.1 一对一直连:从SDP交换到ICE候选人
WebRTC(Web Real-Time Communication)和前面三种方案有一个本质区别:前面三种都是客户端-服务器架构,所有数据都要经过服务端中转;WebRTC的目标是浏览器之间的点对点直连,数据流不经过服务器,服务端只负责配对协调(信令)。
WebRTC建立连接的过程分为两步:SDP协商和ICE候选人收集交换。
SDP(Session Description Protocol)本质是“通话双方的媒体能力描述”,包括音视频编解码方式、加密参数、传输协议等。A端创建offer并发送给B端,B端创建answer返回A端。这个交换过程需要借助某种传输通道——通常就是WebSocket或HTTP请求,对应服务端的角色就是“信令服务器”。
以两个浏览器建立音视频通话为例,核心流程如下:
- A端创建
RTCPeerConnection,添加本地媒体流,创建offer:
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); // 获取本地摄像头和麦克风 const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); stream.getTracks().forEach(track => pc.addTrack(track, stream)); // 创建offer并设置本地描述 const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // 通过信令通道发送offer给对端 sendToPeer({ type: 'offer', sdp: offer });- 对端通过信令通道收到offer,设置远程描述并创建answer:
await pc.setRemoteDescription(offer); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendToPeer({ type: 'answer', sdp: answer });- 同时,双方都要通过
onicecandidate回调收集ICE候选人,并经由信令通道发给对端:
pc.onicecandidate = (event) => { if (event.candidate) { sendToPeer({ type: 'candidate', candidate: event.candidate }); } };- 双方都完成候选交换后,浏览器自行筛选出最优数据通路,进入连接状态:
pc.onconnectionstatechange = () => { console.log('连接状态:', pc.connectionState); };5.2 NAT穿透:STUN与TURN的取舍
前面提到WebRTC是P2P直连,但很多设备其实处于NAT后面(比如家庭路由器的内网环境),双方所在的内网地址不能直接被对方访问。ICE框架的候选类型有三种:host(本机内网地址)、srflx(通过STUN服务器获取的公网映射地址)、relay(通过TURN服务器中转的地址)。
STUN服务器的作用是帮客户端发现自己的公网映射地址。工作逻辑类似“对面镜子”:你问STUN服务器“我的公网IP是什么”,它回“你的公网IP和端口是xxx”,通信双方拿到对方的公网映射后尝试直接连通。
但如果双方都处于严格的对称型NAT后面(这种情况在移动运营商网络比较常见),直接连不成功,就必须走TURN服务器中转。TURN服务器是一个中继数据流量的媒体服务器,所有音视频数据都经过它转发,带宽成本很高。
选型上的经验是:搭WebRTC应用必须同时配好STUN和TURN。只用STUN成功率大概在70-85%,很多打电话打不通的case都是对称NAT问题。生产环境建议用coturn或licode做TURN服务器,并通过服务质量做好转发压力预估,如果业务以音视频通话为主,TURN的带宽费用是核心成本之一,要专门评估。
前端可以通过以下方式动态获取ICE候选信息,方便排查问题:
pc.onicecandidate = (event) => { if (event.candidate) { console.log(event.candidate.candidate); // 完整候选字符串 console.log(event.candidate.type); // host / srflx / relay } };5.3 DataChannel:不止音频视频的点对点传数据
WebRTC不只是音视频,它还有DataChannel,可以实现任意二进制和文本数据的P2P传输,不需要经过服务器。相比WebSocket,DataChannel的优势是数据直接点对点传输,没有服务器中转延迟和带宽成本,非常适合大文件传输、实时游戏指令、协作白板的笔迹同步。
// 创建DataChannel(需在offer创建之前) const dc = pc.createDataChannel('fileTransfer', { ordered: true }); // 另一端监听 pc.ondatachannel = (event) => { const receiveChannel = event.channel; receiveChannel.onmessage = (msg) => { // 处理收到的数据 }; };ordered: true表示数据按发送顺序到达,适合文本消息、操作指令这类需要保序的场景。如果需要传输实时音视频兼容数据,可以设置ordered: false, maxRetransmits: 0,让数据包不可靠传输,保证低延迟优先。文件传输推荐ordered: true加maxPacketLifeTime,在有序与延迟之间找一个均衡点。
WebRTC传输大文件的另一个经验是把文件切片后逐个通过DataChannel发送,同时做本地确认队列,对端收到数据后发ACK。和WebSocket消息补偿逻辑类似,只是传输通道换成了P2P。断线后的处理要麻烦一些,因为P2P连接重建还要重新交换SDP,断点续传状态需要自己维护。
5.4 信令服务器的最佳实践:为什么还是离不开WebSocket
WebRTC虽然数据不走服务器,但连接建立过程的信令交换绕不开服务端。自己实现信令服务器时,最合理的落地方案就是结合前面讲的WebSocket——负责SNP交换和ICE候选人转发,同时可以顺便承担在线状态管理、房间管理、身份鉴权等业务逻辑。
信令服务器的核心职责有:
- 管理在线用户列表与在线状态
- 创建和销毁通话房间
- 转发offer/answer/candidate消息给目标用户
- 在ICE连接失败时下发TURN服务器凭证
实战中,信令通道可以做得非常轻量,本质就是一个带鉴权的消息转发服务。但一定要处理异常消息格式——收到非法的JSON或未知type字段要忽略而不是让整个连接崩溃。另外要注意的是,信令本身并不可靠保证对端一定收到,所以在超时未收到answer时要有重发offer或提示“用户无响应”的兜底逻辑。
6. 四种方案横向对比与选型决策路径
6.1 从延迟、流量、服务端压力三个维度做量化对比
很多团队选型完全凭“WebSocket很流行所以用它”,这是很危险的。我按项目诉求从数据量化角度做了个对比表,可以更直观地看到差异。
| 维度 | HTTP轮询 | SSE | WebSocket | WebRTC |
|---|---|---|---|---|
| 消息延迟 | 取决于轮询间隔(秒级) | 毫秒级 | 毫秒级 | 毫秒级 |
| 流量开销 | 高(固定请求+响应头) | 低(一次连接多头推送) | 低(但帧头也有一点) | 最低(P2P无中转) |
| 服务端并发压力 | 高(连接数=请求数,有大峰值) | 中(每个连接保持一个长连接) | 中高(长连接保有内存/文件描述符) | 低(数据不经过服务端,但信令有压力) |
| 方向性 | 客户端拉取 | 服务端推送到客户端 | 双向实时 | 双向实时 |
| 实现复杂度 | 极低 | 低 | 中 | 高 |
| 典型场景 | 兼容旧系统的兜底方案 | 通知推送、行情、日志流 | 聊天、协作、游戏 | 视频会议、P2P文件传输 |
数据的差异背后是架构决策。流量能不能接受几十倍的开销,服务端有没有扛住成千上万长连接的能力,用户体验能不能容忍秒级延迟,这几件事一次要想清楚。
6.2 场景化的选型建议
贴近具体业务场景来谈选型会清晰很多。
如果你的业务是低频状态刷新,比如订单状态每30秒才可能变一次,且用户能接受几秒的延迟,HTTP轮询就够了。强行上WebSocket不为性能,反而增加了服务端连接管理成本。
如果是服务端向单一用户推送通知,比如待办提醒、系统广播、下单后的物流动态通知,SSE是性价比最高的方案。实现成本低,自动重连,配合Last-Event-ID还能无痛做消息补偿。要注意的只是在HTTP/1.1下连接数的限制,HTTP/2适配后没大问题。
如果是实时双向交互,比如IM聊天、在线协作编辑、多人游戏房间同步,WebSocket是标配。但它不是银弹,需要你额外投入心跳、重连、消息补偿、幂等这些长连接的配套功夫。
如果是音视频通话、屏幕共享、点对点传大文件,WebRTC是唯一选择。但注意WebRTC不适合纯文本小消息的应用——引入P2P连接管理和TURN成本对IM消息来说太重了,不如直接用WebSocket。
6.3 混合架构:实际项目里很少有人只用一种
成熟的前端实时系统,往往是多种方案配合使用。拿一个典型的在线客服系统举例:客服与访客之间的文字对话走WebSocket,保证双向实时;访客排队进度和系统通知走SSE,服务端单向推送更新;如果两个用户需要语音沟通就升级WebRTC建立通话;作为兜底,如果WebSocket连续重连失败,自动降级为2秒一次的HTTP轮询。
这种混合架构的关键在于抽象出统一的消息收发层:
// 统一消息入口,屏蔽底层传输实现 class RealtimeClient { constructor() { this.transport = null; // WebSocket或SSE或轮询 } async connect() { try { await this.connectWebSocket(); } catch (err) { this.connectSSE(); } } send(message) { if (this.transport instanceof WebSocketTransport) { this.transport.send(message); } else { // 降级模式下,改用HTTP POST发送 fetch('/api/send', { method: 'POST', body: JSON.stringify(message) }); } } }这类降级思路在弱网环境尤其重要。很多用户处在网络很差的场景下,WebSocket长时间心跳超时如果直接展示“无法连接”,体验很差。降级成轮询信号后可能慢,但功能可用,业务不会完全中断。这个设计会多花大概一天时间,但产出物健壮很多。
7. 从前端视角看完这四种方案后的技术债复盘
具体聊一聊我实际在项目里踩过的坑和最终的总结。说到技术选型,翻车的多半不是因为技术本身不够好,而是因为对业务场景的理解不够精确。一些经验是这样沉淀下来的。
第一,优先考虑“如果是先发”用户的数量和消息频率决定方案,而不是优先考虑“技术的先进性”。早期一个数据大屏项目用了WebSocket,因为总觉得轮询不够“高级”。结果服务端是PHP跑在Apache上,WebSocket进程管理方案不稳定,运维配置也很麻烦,最后被迫换回SSE。这个项目里服务端本来就是单向推数据,SSE比WebSocket省了一个量级的复杂度。
第二,每一个实时通信方案都要提前设计监控和可观测性。WebSocket项目最常见的线上事故,是服务端连接数撑爆导致新的用户全部连不上。如果没有连接数、消息吞吐、心跳成功率的监控,这类问题往往要用户投诉了才发现。当时我用了一个简单的统计接口,每5秒记录一次在线连接数、消息量、重连次数,配合告警,后续问题能第一时间定位。
第三,端上一定要有状态机和日志体系。实时通信链路涉及连接层、协议层、业务层多个层次,出问题时要能快速定位在哪个环节。我常用的做法是在每个关键节点输出一条结构化日志:连接创建、握手成功、首次消息、心跳超时、断线重连,配合时间线能很快还原问题现场。
最后说一下,如果完全从团队能力与业务阶段考虑:开发资源紧张就选SSE加上普通接口组合,不要一上来就上WebSocket;业务已经有成熟的IM需求再全面切换WebSocket;有音视频需求就把WebRTC作为独立模块建设,不要试图用WebSocket承载音视频流。
回头来看这四个技术,本质上它们解决的是同一个问题的不同侧面。HTTP轮询解决的是“有没有”的问题,SSE解决的是“服务端推”的问题,WebSocket解决的是“双向实时”的问题,WebRTC解决的是“点对点高效传输”的问题。理解清楚这个问题边界,比机械背诵哪种协议更先进重要得多。下次再遇到“实时推送选什么”的问题,先问自己:业务真的需要实时双向吗?服务端能承受多少连接?传输的数据是什么类型?答案会自然浮现。