尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

前后端实时通信选型:短轮询、长轮询、SSE与WebSocket对比

前后端实时通信选型:短轮询、长轮询、SSE与WebSocket对比 前阵子做内部工单系统需求是后端任务状态一变前端要立刻弹红点提示顺便把消息卡片推出来。同事第一反应是“直接上WebSocket”我翻了翻需求最后选的却是SSE。这类选型分歧在前后端实时通信里太常见了——很多人把“实时”直接等同于WebSocket实际从短轮询到长轮询从SSE到WebSocket每种方式都有自己最合适的场景选错了不是多写几行代码的问题而是运维和体验一起遭殃。这篇文章我不打算讲那种教科书式的大全而是把我在实际项目里真正用过的几种前后端实时通信方式摊开来说每种方式怎么跑通、底层原理是什么、有哪些必须亲手踩过才会在意的坑最后再给一套我自己的选型判断顺序。无论你是刚接触这类功能的前端新人还是需要在架构里塞一条实时通道的后端开发应该都能找到能直接照搬的东西。1. 短轮询和长轮询用HTTP最朴素的姿势模拟实时先说一个容易被人忽略的事实HTTP协议本身是“请求-响应”模型客户端不发请求服务器就只能干等。前后端实时通信的很多方案本质上都在解决这个问题——要么让客户端勤快一点反复来问要么让服务器把响应“拖住”拖到有新数据了再一口气吐出来。轮询家族走的就是这两条路。1.1 短轮询简单到不需要解释的方案短轮询应该是最没有技术含量的方案但也是最稳的方案。核心思路就是前端用setInterval定时去调用普通HTTP接口拿到最新数据就刷新页面。比如我之前做过的异步导出功能导出大文件可能耗时半分钟前端完全没必要建立长连接每5秒查一次导出状态就行const checkProgress async () { const res await fetch(/api/export/status); const result await res.json(); if (result.status done) { handleExportFile(result.url); return; } // 没完成就5秒后再查一次 setTimeout(checkProgress, 5000); }; checkProgress();为什么这个方案在真实项目里永远有位置因为它对服务器没有任何特殊要求。普通的HTTP接口、任何语言框架都能写过网关、过代理、过负载均衡全都畅通无阻。线上偶发问题排查起来也方便抓包看一眼请求有没有返回就完了不用装任何协议分析工具。但它有个天生问题浪费。定时请求大量是无效请求服务器明明没有新数据也得照常接收请求、跑业务逻辑、返回响应。我见过一个后台管理系统把“消息未读数”做成轮询页面开着不动一个月产生了上百万次无意义的请求。轮询间隔还不能设得太短否则并发一上来服务端CPU和数据库查询次数都会很难看。所以短轮询适合什么场景适合实时性要求不高、操作频率低、又不想引入额外服务端逻辑的场景。比如后台任务状态查询、低频公告拉取、订单状态刷新5秒或者10秒一次已经体验良好没必要为这种需求搭一整套长连接链路。1.2 长轮询把HTTP请求挂起直到有数据再返回长轮询是短轮询的优化版思路挺巧妙客户端照样发一个普通HTTP请求但服务器收到之后不立刻返回而是把这个请求“挂住”一直等到有新数据产生了再把这个响应返回给客户端。客户端收到响应后马上发出下一次请求形成一种“伪长连接”的效果。用Node原生http模块写个简化示意大概是这样const http require(http); const events require(events); const eventBus new events.EventEmitter(); http.createServer((req, res) { if (req.url /events) { // 30秒没新事件就返回空数据避免请求挂太久 const timer setTimeout(() { res.end(JSON.stringify({ data: null })); }, 30000); eventBus.once(update, (payload) { clearTimeout(timer); res.end(JSON.stringify({ data: payload })); }); } }).listen(8080);客户端那边用一个递归调用替代setInterval请求返回后立刻发起下一次请求保证“连接”不断const longPoll async () { const res await fetch(/events); const result await res.json(); if (result.data) { handleNewData(result.data); } longPoll(); };相比短轮询长轮询有两个很明显的优点。第一服务器有新数据时能立刻返回实时性不再是“每隔N秒一次”而是真正的准实时。第二没有新数据时请求会一直挂着不会产生每秒几万次的无意义请求服务器压力小很多。但长轮询绝不是什么省心方案。最大的坑在于连接资源——每有一个客户端在等待服务器就要挂起一个HTTP请求。Java里如果用传统的Servlet线程模型一个长轮询请求占一条线程几千个用户挂在那里线程池直接被打穿。即使换成Servlet 3.0异步或协程模型每一个挂起的请求也占着文件描述符和内存。另一个坑是浏览器对同一个域名并发连接数的限制HTTP/1.1下通常是6个如果页面上有多个模块同时做长轮询很快就不够用了。1.3 轮询家族的共同软肋不管是短轮询还是长轮询本质上都在“借用”HTTP连接来完成通信只要一次请求结束了所有状态就清空了。长轮询虽然把响应时间拉长了但每个请求返回之后仍然要重新走一次HTTP建连流程TLS握手、请求头传输、服务器路由这些开销一次都省不掉。轮询家族在移动端还有一个明显的痛耗电和流量。移动网络下频繁建连和断连会让无线模块一直处于活跃状态电量和流量消耗都远高于一条保持不动的长连接。所以轮询更适合内网后台系统、PC端页面这类环境相对稳定的场景在移动App里做秒级实时通信它不太合适。2. SSE服务端单向推送的省心之选SSE全称Server-Sent Events中文常叫“服务器发送事件”。它不像WebSocket那样需要升级协议而是标准HTTP长连接上的一种特殊响应流。浏览器通过EventSource对象建立连接服务器返回text/event-stream类型响应之后就能源源不断往这条连接里写数据。2.1 事件流协议原来响应可以这么写SSE的消息格式非常直白本质就是一段UTF-8文本流用data:、id:、event:这类前缀标记每一行以空行分隔每条消息。比如服务器推送一条工单更新事件id: 1001 event: ticket-updated data: {id: 123, status: processing}data:是消息内容event:是自定义事件名id:是消息编号。客户端可以这样监听const es new EventSource(/api/ticket/stream); // 监听默认消息 es.onmessage (e) { console.log(收到消息, e.data); }; // 监听自定义事件 es.addEventListener(ticket-updated, (e) { const data JSON.parse(e.data); renderTicketStatus(data); });服务端实现也极其简单Node里只需要设置响应头然后往响应流里写数据res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive }); const timer setInterval(() { res.write(data: ${JSON.stringify({ time: Date.now() })}\n\n); }, 5000);我第一次接触SSE时的感觉就是这玩意儿怎么比重连和协议都有人帮我处理好了。这里的核心价值在于它把“服务器向客户端推送消息”这件事完全建立在HTTP语义之上不需要额外的协议解析层。2.2 自动重连和Last-Event-ID被忽视的两个宝贝SSE最容易被低估的是断线重连机制。浏览器内置的EventSource会在连接意外断开后自动重新建立连接不需要前端写一行重连代码。更妙的是它连接时会带上Last-Event-ID请求头把上一次收到的最后一条消息ID传给服务器。服务器拿到这个ID就能判断“客户端已经收到哪条消息了”然后从下一条开始补推。这让SSE天然具备“断点续传”能力。我做过一个日志流页面后端日志太多时连接偶尔会被代理掐断重连后浏览器自动带上Last-Event-ID服务器从断点位置继续推页面几乎无感知。这个机制背后的含义值得细品它不是简单的重连而是“带状态的重连”。WebSocket断线后恢复你要自己记录消息序列号、自己写补发逻辑而SSE把这条链路全部标准化了。2.3 SSE的真正短板SSE最常被诟病的是单向性。客户端只能收不能通过同一连接往服务器发消息。如果业务既要服务器推、又要客户端发SSE就需要再搭配普通HTTP请求。但这不一定是缺点——很多场景本来就不需要双向通信。比如行情推送、工单状态通知、大屏数据刷新全是服务器往客户端单向输出用SSE恰好合适。另一个短板在老浏览器和代理环境下明显。浏览器对同一个域名有并发连接数限制HTTP/1.1下SSE会长期占着6个连接里的1个如果页面上还有其他需要并发请求的资源剩余可用连接会更紧张。部署层面也坑不少最典型的是nginx默认会缓冲响应内容SSE数据会攒到一定量才吐给客户端实时性大打折扣必须关掉缓冲location /api/stream { proxy_pass http://node_backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding off; proxy_read_timeout 1h; }如果不关这个你会在“服务器已经推送、浏览器迟迟不显示”这种问题上浪费很多时间。3. WebSocket全双工通信的正确打开方式终于到WebSocket了。它是目前唯一真正在浏览器和服务器之间建立全双工、双向通信通道的标准方案。客户端和服务端都能随时往通道里扔数据不需要像SSE那样额外配HTTP接口。但它也是这几种方式里实现复杂度最高、生产环境隐藏问题最多的一种。3.1 握手从HTTP到ws的状态切换很多人不知道WebSocket并不是凭空出现的协议它借用了一次HTTP请求来完成“握手”。浏览器向服务器发送一个带Upgrade头的GET请求GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务器验证通过后返回101 Switching Protocols然后这条TCP连接就正式升级成WebSocket通道。这里有一个很多初学者不知道的细节服务器返回的Sec-WebSocket-Accept头是把Sec-WebSocket-Key加上一个固定GUID字符串后做SHA-1哈希再Base64编码得到的const crypto require(crypto); const accept crypto.createHash(sha1) .update(requestKey 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) .digest(base64);这个握手过程意味着WebSocket服务端必须能够接受并处理Via Upgrade请求而不是把它当普通HTTP请求处理。于是问题就来了如果你的WebSocket服务跑在nginx后面nginx默认是不带Upgrade转发头的不配好代理就会卡在握手阶段。后面我会专门说这个配置。3.2 帧、掩码和消息边界裸WebSocket要面对的世界握完手之后WebSocket的数据都是以帧Frame为单位传输的。帧头部结构大概是这样的字段位数干啥用的FIN1是不是最后一个分片RSV3扩展保留位opcode4帧类型1文本2二进制8关闭9Ping10PongMASK1载荷是否掩码payload len7/16/64载荷长度小数据7位够用大包会扩展有两个点如果自己做协议层一定会碰到。第一客户端发给服务器的帧必须掩码服务器发给客户端的帧不需要掩码。掩码是为了防止某些代理缓存污染听起来很玄实际用库实现时不用管但如果你拿着抓包工具研究WebSocket看到客户端帧里MASK位为1就会明白了。第二消息边界是协议层面解决的不需要像TCP那样自己拼流。每一个帧都带着长度信息比如文本帧的opcode是0x1载荷长度告诉你这帧数据有多长分片帧处理完由库负责重新组装。这也是为什么很多人第一次从裸TCP切到WebSocket时会感觉“诶消息居然不会粘包了”。理论上你可以不写任何协议解析代码直接用ws、Socket.IO这类库。但了解帧结构有个实际好处排查线上“消息内容被截断”“偶尔收到乱码”这类问题时能直接判断是传输层分片拆错了还是业务层自己拼错了。我在服务端用原生ws库时特意封装过一层简易帧解析来处理二进制流踩过不少分片的坑。3.3 心跳、重连和消息补偿保证长连接真能“长”下去长连接最大的敌人是“假死”。网络设备、云负载均衡器为了提高资源利用率会主动回收空闲的TCP连接。比如很多云厂商的LB默认空闲超时是60秒左右nginx默认proxy_read_timeout也是60秒。如果你的连接上一段时间没数据流动中间设备就把这条连接给你静默掐了。可是客户端和服务端都感知不到因为没有任何关闭帧或RST包送过来。解决办法就是心跳。WebSocket协议层有专门的Ping和Pong控制帧服务端定期发Ping客户端回Pong连接里始终有数据在流动中间设备就不会把连接当空闲回收。也有的团队习惯用应用层心跳也就是每隔一段时间发一条{type:ping}的JSON消息。两者都有都用过我的建议是协议层Ping/Pong干净高效不太依赖业务代码逻辑应用层心跳的好处是能顺便验证业务逻辑是否活着对排查“进程活着但业务卡死”的情况更有效。重连策略是另一个必须提前规划的问题。连接断开后不能一秒重试一次就算完得用指数退避而且一定要加随机抖动let retries 0; function connect() { const ws new WebSocket(url); ws.onopen () { retries 0; }; ws.onclose () { const delay Math.min(1000 * 2 ** retries, 30000) Math.floor(Math.random() * 1000); retries; setTimeout(connect, delay); }; } connect();为什么一定要加随机抖动因为如果服务器重启后所有客户端同时按同样的退避时间重连几千个连接会在同一秒涌进来直接把刚起来的服务又压垮形成“重连风暴”。让每个客户端的重连时间有一定的随机性能显著降低这种风险。消息可靠性也不能指望TCP。TCP保证的是“只要连接还活着数据不会乱序不会丢”但WebSocket连接断掉的那一刻正在传输和之后产生的消息都会丢。我处理过的方案是客户端维护一个本地的消息序号或“最后一条已确认消息ID”重连后把这个ID带给服务器服务器从该ID之后补推未送达的消息。如果业务要求更强可以让客户端在收到关键消息后回一个ACK服务器缓存未ACK的消息列表超时重发。切记这类补偿逻辑要设计成幂等的否则补发时客户端重复处理会产生脏数据。3.4 生产环境的那些坑从nginx配置到分布式广播先说最经典的nginx配置问题。WebSocket服务直接暴露端口没问题但在公司内网或云环境下几乎不可能绕过nginx。不配好代理客户端连握手都完不成location /ws { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; }这段配置里有三个关键点。proxy_http_version必须设成1.1否则nginx默认用HTTP/1.0跟后端通信不支持Upgrade。Connection: upgrade必须显式设置不能留空。proxy_read_timeout必须调大不然后端两分钟没发数据nginx就帮你把连接断了。然后是分布式场景。单机版WebSocket服务非常好写连接来了就存进一个Set广播就是遍历所有连接然后逐个send。但一旦部署多副本问题立刻出现——用户A连在节点1上用户B连在节点2上节点1往自己的连接广播时节点2上的用户B根本收不到。解决思路是引入一个“消息中转层”。常见的方案是Redis Pub/Sub业务服务把广播消息发到一个Redis频道所有WebSocket节点都订阅这个频道收到消息后再推给自己节点上挂着的连接。用伪代码表达大概是这样// 节点A发布 redisClient.publish(chat:room:1, JSON.stringify(message)); // 每个WebSocket节点订阅 redisClient.subscribe(chat:room:1, (message) { for (const client of localConnections) { client.send(message); } });这一步不是可选项而是多实例部署下绕不开的必修课。如果项目里用了消息队列也可以用MQ做同样的广播逻辑一致。最后说一个性能层面的坑WebSocket长连接的内存开销比想象中高。一个空闲连接除了Socket对象本身还要挂业务上下文、发送缓冲区、变量引用。我曾经粗略估算过一个业务稍微复杂点的连接稳定占内存可能在几十KB级别如果规划10万连接光连接本身就要吃掉数个GB内存。这不是坐在电脑前写两个demo就能感受到的一定要在压测阶段就把连接数和内存的关系算清楚。4. 四个方案的横向对比与选型判断说了这么多原理最实际的问题来了项目里到底选哪个我的建议是先不要考虑哪个技术更流行而是把需求拆成几个明确的问题。4.1 一张表看清差异维度短轮询长轮询SSEWebSocket实时性秒级取决于轮询间隔准实时取决于服务端何时返回准实时数据一到即可推实时双向即时数据方向只能拉取只能拉取延迟返回服务端单向推送全双工双向连接占用请求结束即释放请求期间占用1个连接持续占用1个连接持续占用1个TCP连接服务端复杂度极低需要挂起请求低写流即可较高要处理帧、心跳、路由自动重连无无内置且支持Last-Event-ID需要自己实现跨域/代理HTTP完全兼容HTTP完全兼容基本兼容要关缓冲代理必须配Upgrade转发典型场景低频状态查询老系统准实时通知行情、日志、通知、大屏聊天、协作编辑、游戏这张表是我自己项目选型时最常用的参照。可以看到WebSocket确实功能最全但“功能全”不等于“合适”。4.2 我的选型判断顺序我自己的判断流程是按下面这三步走的顺序很重要不能反过来。第一步先看数据流方向。只有服务器往客户端推客户端不需要在同一连接里发消息直接选SSE。双向交互玩得频繁比如聊天输入、鼠标坐标同步、在线协作再考虑WebSocket。这里SSE和WebSocket的界线非常清晰拿“数据流方向”当判据比拿“实时性”当判据准确得多因为很多伪实时需求比如刷新最新通知根本没到双向的程度。第二步看实时性要求是多少量级。如果允许几秒内的延迟短轮询或者长轮询完全能应付。秒级以下才需要考虑SSE/WebSocket。很多后台管理页面里“手动刷新一下再看最新状态”就是可接受的体验硬上长连接反而是过度设计。第三步看基础设施约束和团队维护能力。公司网络环境是否允许任意TCP端口对外代理设备能不能正确转发Upgrade头团队有没有人熟悉WebSocket的服务端维护这些问题的答案经常直接推翻前两步的结论。我遇到过一次很典型的场景客户环境只开放标准HTTP端口代理层也禁掉了UpgradeWebSocket方案从技术上可行但落地时根本穿不过那道厚重的网关最后只能用长轮询。还有一条非常重要的经验老浏览器兼容性和降级策略要想在前面。很多面向外部用户的项目会遇到一些不支持SSE/WebSocket的旧内核浏览器。如果必须覆盖可以引入Socket.IO这类封装库它底层会自动降级优先WebSocket不行切到长轮询把选择权交给运行时而不是开发时的喜好。代价是引入了一层抽象和额外的命名空间概念调试复杂度会上升。5. 实战记录几个真实系统里的落地选择理论说再多不如看看实际项目怎么做的。我不会假设一个完美的场景就说说我最近做过的两个系统里选型是怎么拍板的。5.1 异步任务系统为什么选了SSE而不是WebSocket这个系统是内部的数据报表导出中心。用户提交一个导出任务后端要跑几秒到几分钟不等完成后生成文件下载地址。需求拆完就三条任务状态变了要推给前端任务失败要推错误原因除此之外前端不需要向服务器发任何实时消息。按照我上面的选型顺序走一遍数据方向是服务端单向推直接锁定SSE。实时性要求是秒级SSE完全满足。基础设施是内网环境代理是标准nginx关掉缓冲就能跑。最后的前端代码干净得吓人核心逻辑只有几行const es new EventSource(/api/export/stream); es.addEventListener(task-status, (e) { const { taskId, status, fileUrl, error } JSON.parse(e.data); if (status done) { notifyDownloadReady(fileUrl); es.close(); } else if (status failed) { notifyError(error); es.close(); } });后端只需要维护一个任务ID到响应对象的映射在任务完成时找到对应的res往里写一条event: task-status消息然后结束响应。所有断线重连、消息ID补传都交给浏览器和协议处理。如果用WebSocket还得自己维护连接池、心跳、重连和消息路由投入产出比完全不在一个量级。这个项目还让我体会到一点SSE不是WebSocket的替代品它就是“单向推送”这种需求的正解。强行用WebSocket去实现单向推送属于拿着牛刀杀鸡最后还多了一堆牛肉要处理。5.2 聊天室系统WebSocket承担核心REST承担边界另一个项目是多人在线聊天室。这个场景里WebSocket是绕不开的。用户发送消息要即时推给所有在线成员有人进房有人离房要广播在线人数要实时更新这些都要求双向、实时、低延迟。但我没有把一切HTTP能力都搬到WebSocket里。历史消息拉取依然走REST接口用户头像、昵称等静态资料依然走HTTP缓存WebSocket通道里只传输三种类型消息聊天内容、在线状态、系统通知。这样设计的好处是WebSocket通道非常干净协议负担小即使连接断开新用户进入房间也能通过REST先拉一段历史记录体验不会因为长连接没建好而空白。聊天场景还验证了一个结论消息ID和ACK机制在重连风暴面前有多重要。客户端重连后必须要能告诉服务器“我收到的最后一条消息ID是12345”服务器才能把后续消息补过来。不做这一层用户会看到消息断档然后一脸困惑地刷新页面。这类问题等到线上才暴露修复成本会高得离谱。5.3 前瞻WebTransport会是下一个热词吗最后提一个新方向。随着HTTP/3普及浏览器开始逐步支持WebTransport协议。它同样基于UDP但引入了可靠传输能力延迟比TCP低而且支持多路复用、服务器推送、双向数据流。要把它理解成加强版WebSocket也不全对它的设计目标就是处理WebSocket在高实时性场景下的那些局限。但目前WebTransport的浏览器支持还不完整成熟的库和最佳实践也少普通项目现在切过去不划算。我个人的态度是保持关注但不在生产系统里尝鲜。实时通信这块稳定压倒一切。6. 选型之外还有几条经验想单独说前面把每种方式都拆开讲了最后想吐槽几句自己在项目实施过程中的体会这些往往比技术选型本身更影响成败。第一任何长连接方案都要考虑“连接数管理”。很多后端写惯了请求-响应模型第一次写SSE或WebSocket时会忘记把连接对象保存下来也不知道连接断开时要清理。我见过不止一次线上服务内存只涨不跌最后发现是连接Map里积压了大量已断开的Socket对象导致内存泄漏。正确做法是每个连接都绑定close和error事件的清理回调保证断开即移除。第二日志里千万不要打完整消息内容。WebSocket通道里流动的消息往往包含用户输入、实时业务数据打出来既泄露信息又会把日志文件撑爆。真要排查问题记录消息类型、长度、收发时间就足够了。第三消息系统的压力测试跟普通接口完全是两回事。普通接口按QPS压测长连接系统要按“同时在线连接数”压测还要模拟“服务端广播到N个连接”的场景。很多人上线前没做过这个测试上线后一旦业务高峰触发广播服务端CPU直接被打满。线上一个广播要推给10万连接每个连接一次写操作服务器的处理时间不是线性的。如果让我给一个最简单的选型建议就一句话不要先问用什么协议先问数据往哪个方向流以及断了能不能补。这两件事想清楚了该用SSE还是WebSocket答案是自动浮出水面的。实时通信的“实时”只是起点真正考验人的是连接断了以后你的系统还能不能把消息安安稳稳交到用户手上。这条标准比任何协议本身的优点都更能决定一个项目的长期质量。
返回列表