资讯详情

SSE服务端推送实战:原理、应用与生产环境避坑指南

📅 2026/10/16 9:55:36 | 华诺云谱 👁 阅读
SSE服务端推送实战:原理、应用与生产环境避坑指南
聊到服务端推送很多人第一反应就是WebSocket。但我今天想聊的是另一条更朴素、却特别实用的技术路线——SSEServer-Sent Events服务器发送事件。它解决的是“服务器主动把数据推给浏览器”这件事在通知推送、实时日志、AI流式输出这些场景里SSE往往比WebSocket更轻、更稳、更好落地。我记得有一次做内部运营平台需要实时展示任务执行进度。用轮询的话一秒一次都嫌延迟高改两秒一次又浪费带宽而且服务端压力也不小。当时有几个方案摆在我面前最终选了SSE。整个接入过程比预想中顺利得多前端一行new EventSource(url)后端设置好响应头剩下的推送逻辑完全就是普通的HTTP响应写入。这篇文章就把我在实际项目里对SSE的完整理解、踩过的坑、以及可以直接抄走的实现方案全部梳理出来。适合刚接触SSE的前端同学也适合后端同学作为选型和落地参考。1. 三个推送方案里为什么偏偏是SSE1.1 轮询的问题先说最原始的轮询。前端用setInterval每隔几秒去请求一次接口看起来简单但问题是无论有没有新数据每个请求都会完整走一遍HTTP链路。用户量上来之后无效请求占用的连接、线程、带宽都很可观。我在一个报表系统里试过同时在线两三百人每三秒轮询一次后端接口的平均响应时间被拖到了八百毫秒以上排查询日志发现大量请求根本没有任何新数据可返回。轮询的延迟还很难调。频率低了消息不实时频率高了服务器受不了。而且它天生是“拉”的模式服务器有新事件也必须在下一个轮询周期才能被客户端发现。这种模型在处理低频、可容忍延迟的场景下还行但一旦对实时性有要求就比较尴尬。短轮询之外还有长轮询客户端发出请求后服务端保持连接不返回等有新数据了再响应响应完客户端立刻发起下一次请求。这个方案确实把实时性提上来了但连接频繁建立销毁服务端要额外处理挂起的请求队列复杂度其实不低。1.2 SSE和WebSocket的关键差异WebSocket是目前提到实时通信时最常被想起的方案。它和SSE都支持服务端主动推送但底层的设计逻辑差别很大。WebSocket是独立的协议握手时通过HTTP升级协议来完成但握手之后数据传输走的就不是HTTP了是双向的二进制或文本帧。这也意味着浏览器端要处理不同的协议解析、心跳维护、断线重连服务端也要专门维护一套连接状态。SSE则完全不同它本质上还是HTTP。客户端通过EventSource接口发起一个普通的GET请求服务端收到之后不结束响应而是持续往响应体里写入特定格式的文本块。因为走的就是HTTP中间经过的代理、网关、负载均衡器都能天然识别不需要做协议升级。常见的鉴权、日志、限流中间件也能直接复用。两者的差异用一个类比来说比较接地气WebSocket相当于在两端之间修了一条专用的双向管道你有完全的控制权但管道本身要自己维护SSE则是把一条本来就存在的HTTP通路“拉长”了服务器可以在这一条路上持续向你递东西但方向是单向的只能服务器往客户端送。1.3 SSE真正擅长和真正不擅长的SSE适合的场景非常明确数据主要是由服务器生成、不断推到前端展示客户端不需要往服务器频繁发送消息。典型的就是这几个消息通知中心站内信、审批提醒、系统公告。实时日志流构建日志、任务执行日志、容器日志。AI对话流式输出大模型生成的内容分片返回前端逐字渲染。数据看板大屏指标、监控图表的实时更新。SSE不擅长的场景也挺清晰如果业务是强交互的比如在线聊天、协作编辑客户端需要频繁发指令、服务端要频繁回指令两边都有大量双向实时数据流那就应该用WebSocket。毕竟SSE的客户端传输只能靠普通的HTTP请求来实现一次一个请求没法做到真正的双向实时。这里有一个容易被忽略的点SSE是单向的但大多数人做业务时细想一层其实都是单向的。就拿AI对话来说用户发消息本质上是一次普通的POST真正对实时性要求高的是流式返回这一层恰好就是SSE的主场。2. 先把协议层拆开看SSE到底在传什么2.1 一个推送数据帧长什么样SSE没有发明复杂的新协议它只是约定了一种文本格式由服务端按这个格式写入HTTP响应体。核心的MIME类型是text/event-stream这种内容类型的响应不会被浏览器当作文本一次性解析而是会被EventSource边接收边解析。一条最基本的SSE消息长这样data: hello world没错就两行。data:是数据字段后面跟的是一行文本末尾必须有一个空行来标识一条消息的结束。多条消息就是多个这样用空行分隔的块。如果推送的是结构化数据一般会把JSON序列化之后放到data字段里data: {type:progress,percent:40,message:正在处理第2步}服务端要推送一条消息只需要在响应流里写入这个格式的字符串。比如Node.js里就是res.write(data: ${JSON.stringify(payload)}\n\n);data字段还有一个特性若一条消息内容比较长可以在一个事件里写多行data:浏览器组合时会自动用换行符拼接起来data: 第一行内容 data: 第二行内容浏览器最终收到的消息字符串是第一行内容\n第二行内容。2.2 浏览器端的EventSource怎么和这些帧配合EventSource是浏览器内置的SSE客户端API不需要引入任何第三方库。构造时会直接建立连接然后按约定解析服务端发来的流。默认情况下服务端没有标注事件类型的话浏览器会触发onmessage回调const es new EventSource(/api/events); es.onmessage function (event) { console.log(event.data); };event.data就是服务端写入的那一行内容。如果服务端在消息里带了event:字段比如event: progress data: {percent:40}那么浏览器端就不能只靠onmessage了得用addEventListener监听对应的事件名es.addEventListener(progress, function (event) { const data JSON.parse(event.data); console.log(进度, data.percent); });这在多类型消息共用一个连接时特别有用。比如连接既要推送普通通知又要推送任务进度可以通过不同event:字段区分前端配合addEventListener分别处理省去了在业务层解析type字段的麻烦。除了data和event协议里还有两个重要字段。一个是id:也就是事件ID用于断线重连时的断点续传另一个是retry:单位是毫秒告诉浏览器如果连接断了要等多久才发起重连。这两个字段放到下一节一起讲。2.3 断线重连为什么是“送分题”用WebSocket做推送断线重连机制通常要自己写。网络一抖动连接断了你得手动封装心跳检测、重连策略、指数退避还得考虑消息补偿。这套代码每个项目都要来一遍工作量不小。SSE最省心的地方就是重连机制在浏览器层面已经内置。连接异常断开后浏览器会自动使用retry指定的间隔重新发起连接。正常情况下这个值是浏览器默认的服务端可以返回retry: 3000来覆盖它。更关键的是事件ID配合如果服务端在推送每条消息时都带上id:字段浏览器在重连时会自动在请求头里携带Last-Event-ID告诉服务端“我最后收到的消息ID是xx请你从下一条开始推给我”。这个机制放到业务里正好解决了消息丢失的问题。比如构建日志如果已经推送到第102行这时客户端网络断了自动重连后浏览器会把Last-Event-ID: 102带给服务端服务端只需要读取这个请求头从第103行继续推送即可。整个过程客户端不需要写任何额外代码都是浏览器原生行为。我曾经在弱网环境做过实测在开发者工具里模拟断网再恢复EventSource自动重连配合服务端读取Last-Event-ID的逻辑消息一条没丢体验非常顺滑。3. 服务端落地从内存队列到真实推送3.1 最简单的HTTP长连接推送实现理解了协议之后服务端实现就显得顺理成章了。我用Node.js的原生http模块演示一个最小实现不引入任何框架依赖方便看清楚核心逻辑。核心流程只有四步设置响应头、写入初始消息、按业务触发推送、处理断开。const http require(node:http); const clients new Set(); const server http.createServer((req, res) { if (req.url /api/events) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive }); res.write(: connected\n\n); clients.add(res); req.on(close, () { clients.delete(res); }); return; } if (req.url /api/send req.method POST) { let body ; req.on(data, (chunk) { body chunk; }); req.on(end, () { const payload JSON.parse(body); const message data: ${JSON.stringify(payload)}\n\n; for (const client of clients) { client.write(message); } res.writeHead(200, { Content-Type: application/json }); res.end(JSON.stringify({ ok: true })); }); return; } res.writeHead(404); res.end(); }); server.listen(3000, () { console.log(SSE server run at http://localhost:3000); });先看几个响应头的作用。Content-Type: text/event-stream是SSE协议的核心标识必须设置。Cache-Control: no-cache是为了防止中间缓存代理把响应体缓存住导致已推送的消息不实时到达。Connection: keep-alive在HTTP/1.1下是默认行为但明确写出来不会错。clients这个Set保存了所有活跃的响应对象。因为每个SSE连接都对应一个没有结束的res对象业务系统在需要推送时可以遍历这个集合向每个res写入格式化后的消息。连接关闭时Node.js的req对象会触发close事件在这里把对应的响应对象从Set里移除避免内存泄漏。3.2 连接管理维护订阅关系上一个例子把所有连接放在一个Set里所有客户端收到的消息一模一样这是最基础的广播模式。实际业务里很少这么简单更常见的需求是分组推送或按用户推送。比如给指定用户推送审批通知给指定任务推送日志。实现方式不复杂本质上就是建立一张映射表。下面这段代码演示了按渠道订阅的思路const channelClients new Map(); // key: channel 名称 // value: SetResponse 订阅该渠道的客户端集合 function subscribe(channel, res) { if (!channelClients.has(channel)) { channelClients.set(channel, new Set()); } channelClients.get(channel).add(res); } function unsubscribe(channel, res) { const clients channelClients.get(channel); if (!clients) return; clients.delete(res); if (clients.size 0) { channelClients.delete(channel); } } function publish(channel, payload) { const clients channelClients.get(channel); if (!clients) return; const message data: ${JSON.stringify(payload)}\n\n; for (const res of clients) { res.write(message); } }前端建立连接时通过查询参数携带订阅的渠道名const es new EventSource(/api/events?channeltask_1024);服务端在建立连接时解析req.url中的查询参数然后调用subscribe把响应对象放到对应渠道的Set里。如果项目里存在多实例部署的情况单机内存维护渠道表就不够用了需要引入分布式中间件来转发消息。常见做法是用Redis的发布订阅模式当业务触发推送时先publish到Redis频道各个服务实例订阅同一个频道收到广播后再把消息推给本机维护的SSE连接。整体思路是一样的只是把触发层从直接调用换成了中间件转发解决了跨实例的连接寻址问题。3.3 心跳保活与断线检测SSE连接本质上是一条长时间不关闭的HTTP响应。如果中间经过了代理服务器、负载均衡器这些组件往往会配置空闲超时一段时间内没有数据传输就会断开连接。这个断开动作有时候是静默的浏览器端不一定能立刻感知等真的需要推消息时才发现连接没了就会产生丢消息的隐患。常规解决办法是服务端定期发送一个SSE注释作为心跳。注释是SSE协议里一个合法的数据帧类型以冒号开头浏览器收到后会忽略它不会触发任何事件但这条网络传输本身就维持了连接的活跃状态。const heartbeat setInterval(() { for (const client of clients) { // 注释行浏览器自动忽略仅用于维持连接 client.write(: ping\n\n); } }, 15000); // 服务端关闭时清理定时器 server.on(close, () { clearInterval(heartbeat); });心跳间隔怎么定经验值是取代理空闲超时的三分之一到二分之一。如果nginx配置的keepalive_timeout是65秒那心跳设置15秒到30秒都安全。另外心跳还有个附加作用如果某条连接对应的网络链路已经断裂写入操作会很快报错这样服务端能及时发现死连接并清理避免僵尸连接占用内存。需要特别注意的是不要用res.write返回值和响应对象的finish事件来判断客户端是否还在线。我踩过这个坑——某次发现已关闭页面的连接还留在Set里推送时写入不报错导致消息持续积压。后来统一改用心跳写入时的错误回调来处理连接失效问题才解决。4. 前端接入EventSource的正确打开方式4.1 基础用法与事件绑定前端接入SSE最基础的操作就是实例化EventSource。第一个参数是请求地址支持相对路径和跨域绝对路径。相对路径下浏览器会自动以当前页面的源发出请求跨域请求与普通请求一致也支持携带相关凭证。// 基础连接 const es new EventSource(/api/events); // 标准事件 es.onopen () { console.log(SSE连接已建立); }; es.onmessage (e) { // e.data 即服务端推送的数据通常是JSON字符串 const data JSON.parse(e.data); render(data); }; es.onerror (e) { console.warn(SSE连接发生错误); };onopen在连接建立成功时触发。onmessage在接收到无event字段的默认消息时触发。onerror在连接出错、包括网络中断和连接无法建立时触发。这里有个隐藏细节onerror触发之后如果连接是因为临时的网络问题断开的浏览器并不会停在这里而是会自动进入重连状态并不会把错误抛给调用方让业务层处理。所以前端的onerror里不建议塞入太重的逻辑除非你确实能区分出连接已永久关闭的异常场景。4.2 自定义事件、重连与关闭如果服务端在推送时使用了event:字段前端需要注册相应的自定义事件监听。这是多类型消息场景下的最佳实践因为消息在协议层就被分类解析代码能写得清晰干净。const es new EventSource(/api/events); es.addEventListener(notice, (e) { // 接收普通通知 const data JSON.parse(e.data); showNotification(data); }); es.addEventListener(progress, (e) { // 接收任务进度 const data JSON.parse(e.data); updateProgressBar(data.percent); }); es.addEventListener(error, (e) { // 这是EventSource的标准onerror事件不是自定义事件注意区分 });想要断开连接调用es.close()即可。这对于页面卸载前清理连接是必要的单页应用切换到其它路由后如果不调用close连接会一直挂在那里浪费服务端资源。React或Vue组件在卸载钩子里执行es.close()已经是标配操作。浏览器自动重连的行为也可以通过retry字段调整。服务端推送一条retry: 5000的消息后客户端断线重连的等待时间就变成5秒。值得注意的是这个retry字段不会像id一样在客户端持久化。重新创建EventSource后会回到默认重连间隔。我在实际项目里习惯让服务端在收到连接请求后先推一条包含retry字段的初始注释消息这样能保证客户端一建立连接就拿到合适的重连策略。4.3 几个隐藏比较深的注意点第一个是EventSource不能用自定义请求头也不支持POST。这可以说是SSE的一大局限。如果需要携带认证信息通常做法是放在URL查询参数里或者依赖Cookie。如果是跨域场景需设置withCredentials属性为true并在服务端配合处理允许携带凭证的CORS响应头。假如业务对请求头有硬性要求那么只能退回到fetch流式读取方案用普通的fetch配合ReadableStream解析SSE数据这属于变体方案了不推荐首选用。第二个容易被忽略的点EventSource打开了之后即使页面显示“连接已建立”HTTP层面上这个连接是一次请求直接到响应过程浏览器通常不会像普通请求那样展示进度条但一部分浏览器会显示“正在等待响应”的挂起状态。在排查问题时不要因为看到了挂起状态就认为出错了——SSE连接正常情况下就是长期挂起的。第三个点涉及内存泄漏。addEventListener注册的自定义事件监听器在调用es.close()前通常不需要手动removeEventListener因为整个EventSource对象都会被垃圾回收。但如果EventSource实例一直持有不放那监听了多少次就会有多少个引用残留所以关键还是确保对EventSource对象的引用在不再需要时被释放。5. 生产环境的坑代理、并发与网关超时5.1 Nginx缓冲导致的“假掉线”这个话题值得单独拿出来详细说。很多团队第一次把SSE接到生产环境本地测得好好的部署后却发现消息不能实时到达而是每隔一段时间“涌”出一批有时候甚至完全收不到。这时候十有八九是Nginx的缓冲机制在作怪。Nginx默认会缓冲后端响应它会把后端传过来的数据先攒在自己的缓冲区里等达到一定大小或后端响应结束才一次性转发给客户端。对SSE这种无限响应体来说缓冲区无法“结束”于是后端推的数据都堵在了Nginx侧客户端看到的就是迟迟收不到消息。后端推了很多条之后缓冲区满了Nginx才被迫刷一次于是呈现“批量到达”的现象。解决办法是在SSE所在的路由里关闭代理缓冲或者在服务端响应头里加上X-Accel-Buffering: no显式通知Nginx不要缓冲这个响应。这是Nginx的理解方式Chrome浏览器的请求日志里该响应头会原样传递。location /api/events { proxy_pass http://backend; proxy_buffering off; proxy_cache off; # 如果不设置Nginx默认会对长时间连接做一些超时处理 proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_read_timeout也要注意。它是两次读操作之间的最大间隔时间如果在这个时间内后端没有返回任何数据Nginx会断开连接。SSE场景下把该值与心跳间隔对齐确保心跳都不至于触发超时。我习惯把proxy_read_timeout设为300秒心跳设为15秒留了很大的安全空间。5.2 网关空闲超时与协议层探测除了Nginx生产环境还可能经过云厂商的负载均衡、API网关这些组件也有各自的空闲超时配置。比如有的SLB默认空闲超时是60秒意味着60秒内没有任何数据流动网关会主动断开后台连接。SSE连接建立后如果业务半分钟才推一条消息很可能就触发了网关超时。这类问题排查起来有点隐蔽因为网关断开连接后浏览器并不一定会立即触发error事件有时要等下一次发数据失败才意识到连接已经失效了。最稳的生产策略就是无条件启用心跳。即使业务每分钟只推一条数据心跳也以15秒到30秒为间隔滚动发送把链路维持在“一直有数据流动”的状态。我在一个物联设备状态看板项目里把这个策略定为基操SSE心跳固定15秒网关超时无论配置多少都会被这15秒的间隔完全规避。理论上如果网关超时比心跳间隔还短那属于配置异常需要推动基础设施团队调整。5.3 同源连接数限制与多路复用浏览器对同一源下的并发连接数是有限制的。HTTP/1.1时代单个浏览器对同一个域名的并发TCP连接数通常是6条。SSE长连接会长期占用其中一条如果页面同时还需要加载图片、静态资源、调用其它接口6条配额很快就满了。额外再开两条SSE连接的话很可能导致部分请求排队阻塞。我建议在这个前提下做几件事页面里只维持一条SSE连接不要为每个业务模块单独建连接。不同类型的消息走同一个连接用event字段区分前端分发。静态资源尽量放到CDN域名下减少对主域连接数的占用。HTTP/2的情况会好很多多路复用让大量并发流共享一个TCP连接同一源下的连接数限制被大大缓解。但生产环境仍然存在HTTP/1.1的场景所以“单连接多事件”这是我反复强调的设计原则。另外要注意SSE连接如果部署在内网环境用户通过跳板机或办公网访问长连接还会受网络设备连接空闲策略的影响。这类问题最典型的特征是睡眠一段时间后恢复操作发现页面数据停在老状态。重新刷新页面才恢复。遇到这种优先怀疑是中间链路对空闲连接的限制解决方案依然是那个扎实的心跳。6. 常见问题排查速查表这一块我直接整理成排查表每一条都是踩过或者帮别人排查过的真实问题按现象、可能原因、处理办法的形式列出方便遇到问题时对照使用。现象可能原因处理办法本地能收到消息部署到服务器后收不到或延迟Nginx开启了缓冲关闭对应路径的proxy_buffering或后端添加X-Accel-Buffering: no连接一会儿就断开页面上不断重连网关空闲超时或keepalive超时小于心跳间隔缩短心跳间隔调大proxy_read_timeout断网恢复后消息大量丢失服务端未处理Last-Event-ID服务端解析请求头从指定ID续推多个SSE连接后页面普通接口请求明显变慢HTTP/1.1同源连接数被占满合并为单连接按event区分消息后端推送不报错前端却迟迟收不到消息中间层有缓冲或代理缓存排查nginx/CDN层彻底关闭缓存页面切换路由后服务端连接数量持续增长组件卸载时未调用es.close()在组件销毁逻辑中关闭EventSource某些自定义事件收不到消息服务端event字段与前端监听名称不一致检查event的名字精确匹配刷新页面后重复收到上一次已消费的消息重连时没有使用Last-Event-ID断点续传服务端必须在消息中写id字段服务端推送速度很快前端渲染卡顿前端没有做批处理或节流按帧合并渲染或使用requestAnimationFrame做缓冲response写入不报错但服务端内存里连接数越来越多客户端断开后响应对象的错误回调未被监听监听res的error事件在close中清理连接关于数据格式再补充一点。SSE里data字段值不要包含裸换行符。如果推送的是JSON字符串里如果有换行需要序列化后的字符串也就自然把换行转义成了\n所以一般没事。但如果手动拼消息内容里有原始换行解析时就会把一条消息拆成多行导致拼接异常。最好统一使用JSON.stringify来序列化所有数据都走这个出口就不会踩这个坑。还有一个经验是针对后端推送速度的。如果业务侧需要在短时间内密集推送大量消息比如一次性推送一千条日志逐个res.write也是没问题的但要注意客户端渲染压力。数据本身走网络开销并不大瓶颈通常在前端DOM渲染或者下游处理逻辑。这种情况下可以考虑服务端将一秒钟内的多条消息合并成一条批量消息推送前端做批量渲染效果会好非常多。最后再分享一个小技巧。调试SSE时浏览器自带的开发者工具就能看到完整的事件流。打开Network面板找到当前SSE请求点击EventStream标签页所有推送过来的消息会实时展示在这里包括数据帧和事件类型。这个视图在排查事件名写错、格式不对的问题时特别好用比你打一百条console日志都直观。态势判断、事件监控、调试定位这个面板一次全包了。SSE这个方案我用了这些年最大的感受就是它把“服务端推送”这个本来挺复杂的事情降维成了HTTP响应流的持续写入。理解协议层的那几个字段之后后端实现和前端接入都变得非常简单生产环境的坑也基本集中在代理层配置和连接生命周期管理。如果你正在做消息推送类的功能不妨先想清楚业务到底需不需要双向实时通信如果不需要SSE很可能是那个性价比最高的选择。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑