提到实时推送,很多人第一反应是上 WebSocket,结果为了一个“进度条动起来”的需求,硬生生把服务端改造成有状态的长连接管理,还要自己实现心跳、重连、消息序号,最后维护成本远超收益。SSE(Server-Sent Events,服务器发送事件)就是在这个位置被大量低估的技术:它只用一条普通 HTTP 响应,就能把服务端的数据源源不断推给浏览器,浏览器那边还自带了断线重连。前几年在大模型流式输出流行起来之后,SSE 又回到了主流视野——现在主流的大模型接口在stream=true模式下吐字用的就是这套格式。
这篇内容适合三类人看:正在评估实时推送方案、还没决定选谁的人;已经用了 SSE、但线上出现过“消息发出去客户端没反应”的人;以及想把 SSE 从 Demo 做到生产可用的人。下面我按“它到底是什么、什么时候该用、两端怎么写、线上会怎么翻车、不同场景差在哪”这个顺序拆开讲,穿插一些我实际排查过的故障链路,能抄的部分直接抄。
1. SSE 到底是什么:一条不肯挂断的 HTTP 响应
1.1 拆开协议,它只是 HTTP 加了一份约定
先把最容易误解的点说清楚:SSE 不是新协议,也不是什么特殊的长连接黑科技。它就是一次普通的 HTTP GET 请求,服务端返回200 OK,然后不结束响应体,隔一会儿往里写一段数据,一直写下去,直到某一方主动断开。连接还是那条 TCP 连接,走的还是 80/443 端口,中间的任何代理、网关、负载均衡器看到的都是一次“响应特别慢、特别长”的请求。
所以它天然具备几个好处:复用现有的鉴权链路(Cookie、Header 里的 token 都能正常带)、天然穿透大多数防火墙、不需要额外的端口和协议升级握手、服务端不需要为每个连接维护复杂的帧状态机。代价也很明确——它是单向的,只能服务端往客户端推,客户端想说话只能另开一条普通请求。
服务端必须设置的响应头有这么几个,少一个都可能出问题:
| 响应头 | 值 | 作用与不设置的后果 |
|---|---|---|
Content-Type | text/event-stream; charset=utf-8 | 浏览器靠它识别事件流,值不对 EventSource 直接报错且不重连 |
Cache-Control | no-cache, no-transform | 防止中间层缓存;no-transform是阻止代理改写响应体 |
Connection | keep-alive | HTTP/1.1 下保持连接,HTTP/2 下会被忽略 |
X-Accel-Buffering | no | 告诉常见反向代理别缓冲,这是排障时的关键一手 |
编码一定要显式写 UTF-8。事件流规范里规定就是 UTF-8,但如果不写charset,某些中间件会按响应体猜编码,中文内容就会出现乱码,而且这种乱码往往只在特定部署环境复现,本地怎么测都是好的。
1.2 消息帧的四个字段决定了它能干什么
事件流的报文格式简单到有点朴素:纯文本,一行一个字段,字段名: 值,一个空行表示一条消息结束。一共就四个合法字段,外加一种注释行。
data:—— 消息正文。可以出现多行,多行会被用换行符拼接成一条数据。这是唯一承载业务内容的字段。event:—— 事件名,默认是message。前端用addEventListener('事件名')分别监听,等于自带了一个轻量的多路复用能力。id:—— 消息序号。浏览器会记住最后收到的id,重连时自动放进Last-Event-ID请求头带给服务端,这是做断点续传的抓手。retry:—— 告诉浏览器重连间隔,单位毫秒。不设置就用浏览器默认值。:开头的行 —— 注释,浏览器直接忽略。这是做心跳的标准做法。
一个典型的多事件帧长这样:
id: 1024 event: progress data: {"percent": 42, "stage": "解析中"}注意末尾那个空行,它就是“本条消息到此结束”的分隔符。少了它,浏览器会一直攒着不发出去——这是新手最常犯的错误,代码看起来完全正常,就是收不到消息。
event:字段的价值在于省事。一个连接上既要推进度又要推通知还要推告警,用不同事件名区分,前端挂三个监听器就行,不用在业务层自己写type字段再分发。id:字段的价值在于它是协议级的,浏览器重连时会自动带回去,不需要你在业务代码里手动记录 offset。
1.3 浏览器内置的重连机制,是把双刃剑
EventSource 有一个其他方案都没有的能力:自动重连是浏览器实现的,不用写一行代码。连接断了之后,浏览器会等一段时间(默认约 3 秒,可以被retry:覆盖),然后重新发起同样的请求。服务端如果返回204 No Content,浏览器会认为“服务端让我别连了”,主动停止重连;返回其他非 200 状态码,行为各不相同,500会重连,4xx(除了 204)通常直接失败。
这个机制好用,但坑也埋在这里。第一个坑是重连不带请求体,因为 SSE 只能是 GET,所以所有参数都得塞进 URL 或 Cookie 里,业务上的“订阅某个任务”变成了/events?taskId=xxx,长参数和敏感信息都不好放。第二个坑是重连时机不受你控制,你想做个指数退避,做不到;想区分“网络抖动”和“服务端挂了”,只能靠时间窗口自己判断。第三个坑最隐蔽——重连成功意味着服务端会重新执行一遍订阅逻辑,如果你的推送不是幂等的,用户就会看到重复消息。
提示:服务端处理重连请求时,优先读
Last-Event-ID请求头,有值就说明这是断线重连,从该 ID 之后继续推;没值才当成新订阅。这一条能省掉大量重复消费的投诉。
2. 选型这道坎:SSE、WebSocket、轮询各自的位置
2.1 三种方案在同一张表里的真实差距
选型讨论最容易变成站队,但实际上三者的差异集中在很具体的几个维度上,摊开看就没那么玄乎了。
| 维度 | SSE | WebSocket | 短轮询 / 长轮询 |
|---|---|---|---|
| 通信方向 | 服务端 → 客户端单向 | 双向 | 客户端主动拉 |
| 底层协议 | 普通 HTTP | 独立协议,需协议升级握手 | 普通 HTTP |
| 服务端连接状态 | 仅需持有的响应对象 | 需维护连接状态机、会话 | 无(长轮询挂起时算一个) |
| 断线重连 | 浏览器内置 | 自己实现 | 天然无连接,下次请求即重连 |
| 消息格式 | 纯文本,靠约定 | 文本 + 二进制帧 | 任意 |
| 鉴权 | 复用 Cookie / 但无法自定义 Header | 握手阶段可带 Header | 完全自由 |
| 连接数限制 | HTTP/1.1 下同域约 6 个 | 同域数百个 | 受浏览器并发限制 |
| 中间设施兼容 | 很好 | 需代理支持 Upgrade,个别网关会掐 | 很好 |
| 调试成本 | curl就能看流 | 需要专用工具 | 看访问日志即可 |
这张表里最值得盯的是“服务端连接状态”和“重连”这两行。SSE 把连接管理的复杂度压到了最低——服务端拿到的就是一个可以在上面write的响应对象,其它什么都不用管;WebSocket 则需要一套完整的心跳、超时、会话恢复逻辑,代码量和出问题的概率完全不是一个量级。
2.2 业务单向性判断:什么时候 SSE 明显更划算
判断要不要用 SSE,我一般只问一个问题:这条链路上,客户端有没有必要“随时”给服务端发东西?如果没有,或者客户端发的东西低频、可预测、可以合并在独立请求里,那 SSE 就是更经济的选择。
典型符合这个特征的场景:
- 大模型流式输出。用户提交 prompt 后,服务端一段段吐 token 出来,客户端全程只需接收,用户中断就调一个独立的中断接口。这里用 SSE 几乎是业界默认做法,因为格式简单、
curl就能验证、出错时一眼看出是哪个字段不对。 - 长耗时任务的进度推送。导出报表、视频转码、批量处理,客户端只想知道“到哪一步了”,不需要沿着这条连接回传任何东西。
- 监控看板、日志尾巴。数据只出不进,天然适配。
- 站内通知、告警推送。一条连接上开多个
event类型,前端按类型分发。
反过来,如果客户端需要高频上行(多人协同编辑、在线游戏、实时白板),那就别犹豫了,直接上双向方案。用 SSE 硬撑双向,最终会变成“SSE 收 + 另一个请求发”的缝合怪,延迟和时序问题会让你非常难受。
2.3 三个信号出现,说明你该换方案了
有时候不是你选错了,是业务长歪了。下面三个信号任意出现一个,就该重新评估:
第一,上行频率上来了。如果客户端从“偶尔发一次”变成“每秒要发好几次”,那条单向连接就形成了事实上的半双工,你得自己在业务层做序号对齐、乱序重排,这时候 WebSocket 反而更省心。
第二,消息体积大、且需要二进制。SSE 只能传文本,传二进制要先 base64,体积膨胀约三分之一,还要多一次编解码。传图片、音频片段这类场景,SSE 不是好选择。
第三,连接数压力顶不住了。这点放在后面第 5 章细说,简单讲:HTTP/1.1 下浏览器对同域名的连接数有硬限制,SSE 会把名额吃掉,多标签页的用户会直接受影响。
3. 服务端实现:从十行 Demo 到能扛住生产
3.1 最小可运行骨架与三个必须做的动作
先看 Node.js + Express 的最小版本,这段代码本身不长,但每一行都有原因。
const express = require('express'); const app = express(); app.get('/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); // 关键:立刻把响应头吐出去 let id = 0; const timer = setInterval(() => { id += 1; res.write(`id: ${id}\nevent: tick\ndata: ${JSON.stringify({ ts: Date.now() })}\n\n`); }, 1000); const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000); req.on('close', () => { clearInterval(timer); clearInterval(heartbeat); res.end(); }); });三个必须做的动作:res.flushHeaders()把响应头先送出去,否则客户端会一直卡在等待首字节的状态,看不到“已连接”;每条消息末尾必须是\n\n双换行;req.on('close')里必须清理定时器和其它资源,否则连接断了但定时器还活着,内存会一点点漏光。
Python 侧如果用 FastAPI,思路完全一样,把生成器交给 StreamingResponse:
from fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio, json app = FastAPI() async def event_stream(): i = 0 while True: i += 1 payload = json.dumps({"ts": i}, ensure_ascii=False) yield f"id: {i}\nevent: tick\ndata: {payload}\n\n" await asyncio.sleep(1) @app.get("/events") async def events(): return StreamingResponse( event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}, )注意ensure_ascii=False,否则中文会被转成\uXXXX,虽然能解析,但抓包和日志里完全看不懂,排障时非常难受。
Java 侧常见的是SseEmitter,有个关键点:业务逻辑要放到独立线程池里跑,别占着请求线程。容器给 SSE 请求分配的工作线程应该尽早释放回池子,否则并发连接一多,线程池会被长连接全部占满,整个服务连健康检查都响应不了。
3.2 连接对象的管理与回收
Demo 里的连接是匿名的,生产里必须能管理。我一般会维护这么一个结构:一个Map<连接ID, { res, taskId, lastEventId, createdAt, lastActiveAt }>,注册在建立连接时、注销在close事件里。
为什么一定要有这个 Map?因为你需要能做三件事:主动踢掉某个用户的连接(比如权限变更、订阅取消);按业务键广播(比如所有订阅了某个任务的连接);做连接数监控和超时清理。少了这个 Map,服务端就是瞎子,出了问题只能靠猜。
回收这块最容易出两个问题。第一个是只注册不注销,客户端断开了没触发清理,Map 里的对象越积越多,res.write写到already关闭的连接上还会抛异常,进而可能打断整个广播循环。第二个是广播时没有容错,一个连接写失败导致后面的连接全跳过。正确做法是广播时逐个 try/catch,失败的立即从 Map 里移除并标记待清理,下一轮统一回收。
3.3 多实例之后,广播这件事就变复杂了
单机部署时,连接 Map 在进程内存里,一切都好办。一旦上了多实例,问题立刻暴露:用户 A 的连接落在实例 1,业务事件在实例 2 产生,实例 2 的 Map 里根本没有 A 的连接,消息就丢了。
这个问题的本质是“连接是黏在实例上的,但事件是全局的”。三种解法,各有取舍:
- 会话粘滞 + 事件路由:负载均衡按用户 ID 做一致性哈希,保证同一用户的连接固定落到某台机器,再通过内部通道把事件转发到目标实例。改动最大,但最精准。
- 共享发布订阅:所有实例订阅同一个内部消息通道,任何实例产生事件都往通道里发,各实例收到后推给本地持有的连接。实现最简单,代价是有广播放大,连接数多的时候内部流量不小。
- 网关层统一出口:单独做一个推送网关,业务侧只负责往网关投递,网关持有全部连接。架构最干净,但多了一个必须高可用的组件。
中小规模我一般选第二种,几个实例的量级完全可以扛住。但如果你的场景是秒级高频推送加海量连接,第二种的内部流量会变成瓶颈,那时候就得认真考虑第三种。
3.4 心跳不是可选项
很多人觉得 SSE 的: ping注释行只是锦上添花,实际上它是保命的。中间链路上到处是超时设置:负载均衡的空闲超时、反向代理的读超时、容器平台的连接回收、移动网络的 NAT 表老化。这些值可能是 60 秒、300 秒,也可能是 5 分钟,你没法全部改。
心跳的作用是持续制造“有数据流动”的事实,让这些超时计时器不断被重置。间隔设多少?比链路上最短的那个超时值小一半比较稳。常见的组合是代理读超时 60 秒、心跳 15 到 20 秒一次。我见过设成 55 秒的,在代理超时正好 60 秒的时候,偶尔就会因为调度抖动踩线断开,表现是“每隔一分钟断一次”,特别迷惑。
注意:心跳发出去的是注释行,浏览器不会触发任何事件监听,所以不用担心前端多做无谓处理;但它确实占用了带宽,海量连接下要把心跳体积压到最小,就一个冒号加几个字符。
4. 前端接收端:EventSource 的能力边界与绕行方案
4.1 EventSource 的常规用法和它的静默失败
原生用法简单到不像话:
const es = new EventSource('/events?taskId=abc'); es.addEventListener('tick', (e) => { console.log(JSON.parse(e.data)); }); es.addEventListener('progress', (e) => { updateBar(JSON.parse(e.data)); }); es.onerror = () => { // 浏览器会自动重连,这里只做 UI 状态提示 setStatus('reconnecting'); };es.readyState有三个值:0连接中、1已连接、2已关闭。判断“到底连上没有”不能靠onopen有没有触发,得看readyState,因为某些环境下onopen的时机不太可靠。
这里最容易吃瘪的是静默失败:服务端返回了200,但Content-Type写错了,或者响应体第一段不是合法的事件流格式,EventSource 直接进入CLOSED状态,不重连、不报错,前端看起来就是“什么都没发生”。排这种问题的时候,先开浏览器开发者工具的网络面板,看响应的Content-Type到底是不是text/event-stream,90% 的原因在这里。
4.2 它办不到的三件事
EventSource 的能力是刻意做窄的,这带来安全性的同时也带来限制。有三件事它明确做不到:
不能自定义请求头。这意味着 Authorization 里的 Bearer token 带不进去。绕法有两种:把凭证放进 Cookie(配合withCredentials = true,服务端需要正确设置 CORS 的Access-Control-Allow-Credentials和明确 Origin),或者把短期票据塞进 URL 查询参数(注意 URL 会进日志,票据要短时效、单用途)。
不能发 POST。大模型对话这类场景,prompt 往往很长,塞 URL 里不现实。这是很多人最终放弃 EventSource 的直接原因。
不能控制重连策略。想加指数退避、想在重连时刷新票据、想在重连前先做一次健康检查,都做不到。只能在onerror里手动es.close()然后自己调度重建,等于把内置能力废掉自己实现一遍。
4.3 用 fetch + ReadableStream 自己解析事件流
上面三条限制,用fetch拿流然后手动解析,全部绕开:
async function streamSSE(url, body, onMessage, signal) { const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' }, body: JSON.stringify(body), signal, }); if (!resp.ok || !resp.body) throw new Error(`bad response: ${resp.status}`); const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf('\n\n')) !== -1) { const raw = buf.slice(0, idx); buf = buf.slice(idx + 2); const evt = { event: 'message', data: '', id: null }; for (const line of raw.split('\n')) { if (!line || line.startsWith(':')) continue; const sep = line.indexOf(':'); const field = sep === -1 ? line : line.slice(0, sep); let val = sep === -1 ? '' : line.slice(sep + 1); if (val.startsWith(' ')) val = val.slice(1); if (field === 'event') evt.event = val; else if (field === 'data') evt.data += (evt.data ? '\n' : '') + val; else if (field === 'id') evt.id = val; } if (evt.data) onMessage(evt); } } }这段代码有几个容易写错的地方,我逐个点出来。decoder.decode(value, { stream: true })的stream: true不能省,中文在多字节边界上被切断时会变成乱码,加上这个参数才会把半个字符缓存到下一次。分隔符要用\n\n但不能只认这个,实际响应里可能是\r\n\r\n,稳妥做法是先把\r\n归一化成\n再处理。data:多行拼接用的是换行符,不是直接字符串相加,每条data行前面那个空格是规范里的可选单空格,必须剥掉。
用这套方案之后,POST、自定义 Header、AbortController 取消、自己控制重连节奏,全部拿回手里。代价是你得自己实现重连和Last-Event-ID的回传——后者就是在重连请求里手动带上Last-Event-ID: xxx头。
5. 线上故障复盘:消息发出去但客户端没反应的几种成因
5.1 缓冲区连环套:响应头对了,消息还是被攒着
这是最经典的一个坑,特征是:本地开发一切正常,一上测试环境就变成“消息攒一批突然全到”,或者干脆一直不动。
排查链路我一般是这么走的。第一步,绕过所有中间层直连应用端口,如果这时候消息是实时到达的,问题就一定在中间层。第二步,看响应头里Content-Type和X-Accel-Buffering在不在——有些代理会重写响应头,no-transform就是防这个的。第三步,检查反向代理的缓冲配置,比如常见的proxy_buffering on会攒够一定大小才往下发,必须关掉;proxy_read_timeout默认值往往偏短,长连接会被它掐断。第四步,检查应用层有没有开压缩。这一条最隐蔽:gzip 中间件会为了压缩率攒数据,事件流这种小包高频的流量被它一攒,实时性直接没了。
还有一种情况是应用框架自己的缓冲。某些语言的标准输出流默认是块缓冲,必须显式flush才真的发出去。Node.js 的res.write一般不缓冲,但一旦套了压缩中间件就开始攒;Python 的同步流式响应要留意flush()调用。判断方法很简单:服务端日志打印“已写入”,客户端却迟迟收不到,中间又没有代理,那就是应用层的缓冲。
5.2 六个标签页就卡死:连接数天花板
用户反馈“开多了页面就转圈”,一看网络面板,请求全是pending,一个都不返回。这个现象在 HTTP/1.1 下几乎是必然的——浏览器对同一个域名最多维持 6 个左右的并发连接,SSE 每条连接都长期占着一个名额。用户开 6 个标签页,第 7 个就开始排队,而且排队的不只是 SSE,连页面上普通的接口请求和静态资源都一起被卡住。
解法有四个,按推荐顺序排:
- 上 HTTP/2。多路复用之后,连接数限制从 6 变成几十上百(具体受服务端
SETTINGS_MAX_CONCURRENT_STREAMS约束),这个问题基本消失。这是最根本的解法,成本主要在部署侧。 - 全局只开一条连接。用 SharedWorker 或者 Service Worker 统一持有,各标签页通过消息通道共享数据。改动大,但能省掉 N 倍的连接数,还能顺带解决“每个标签页都发一遍重连”的问题。
- 用 BroadcastChannel 做标签页间协调,选一个主标签页持有连接,其它标签页订阅广播。实现比 SharedWorker 简单,副作用是主标签页被关掉时要重新选举。
- 给 SSE 单独分配子域名。这招能绕开同域限制,但属于治标,连接总数并没有减少,只是分散到了不同域名上,服务器压力还在。
5.3 重连引发的重复消费
现象是用户看到同一条通知出现两三次,或者进度条来回跳。原因通常是重连之后服务端把它当成了新订阅,从头开始推。
修复的核心就是前面提过的Last-Event-ID。服务端收到请求先看这个头,有值就从该 ID 之后继续;客户端侧要保证每条消息的id是单调递增且稳定的,不能用随机数或时间戳——时间戳在并发下会重复,随机数根本没法比较大小。如果用fetch方案自己实现重连,记得手动把最后收到的 id 带上。
另一个容易被忽略的点是业务上报的幂等。就算服务端做了断点续传,网络抖动期间客户端可能已经收到过某条消息但没来得及渲染,重连后又会收到一次。稳妥做法是前端也维护一个已处理 id 的滑动窗口,重复的直接丢弃。
5.4 服务端连接数只涨不跌
监控面板上连接数曲线一路向上,内存跟着涨,最后 OOM。这个问题几乎都是清理逻辑没做全,我在不同项目里见过三种成因。
第一种,只在req.on('close')里清理,但某些运行环境下客户端异常断开时这个事件不触发(比如中间有代理,代理侧已经断开但应用侧没感知)。加固办法是加一个“最后活跃时间”字段,起一个定时扫描,超过 N 个心跳周期没写入成功的连接强制清理。第二种,广播时某个连接写失败抛出的异常被外层 catch 吞掉了,但那个连接没有从 Map 里删。第三种,定时器在闭包里持有连接对象,连接已经从 Map 删了,但闭包引用没断,GC 回收不了,属于典型的内存泄漏。
提示:上线前一定加一个“当前连接数”的指标,按业务维度分组,再配一个阈值告警。这个指标比任何日志都直观,连接数异常增长基本等价于清理逻辑有洞。
6. 几个典型场景的落地差异
6.1 大模型流式输出:要处理的是“半句话”
这个场景的特征是消息碎、频率高、需要即时呈现。实现上有几个和普通推送不一样的地方。
消息粒度上,服务端拿到的往往是 token 片段,直接原样推给前端就行,不需要攒成完整句子再发——攒了就失去了流式的意义。前端要做的是把片段追加到 DOM,而不是每次都替换整个文本节点,否则滚动位置和光标选中状态会不断被打断。渲染频率要做节流,比如 50 毫秒合并一次,否则高频 setState 会让页面明显卡顿。
中断处理是重点。用户点“停止生成”时,前端用 AbortController 取消请求,服务端要能感知到连接关闭并及时停掉后端推理任务,否则算力会白白消耗。很多实现漏了这一步,表现为用户点了停止,服务端还在跑,账单照算。
异常呈现也要单独设计。流式输出的失败发生在“半句话”中间,不能像普通请求那样整个替换成错误页,得在当前内容后面追加一段提示,并且保证用户能重试。这里我一般会区分两类错误:连接断开(可自动重连或提示重试)和业务错误(比如内容被拦截,需要明确告知)。
6.2 长耗时任务进度:要考虑“任务结束了连接怎么办”
导出报表、批量处理这类场景,SSE 承担的是“告知进度”的角色,真正的结果还是要靠一次普通请求去取。设计上有两个关键决定。
第一,任务完成后的收尾。任务跑完不能直接掐断连接,那样客户端看到的和网络异常没有区别。正确做法是先推一条event: done的消息,带上结果标识,稍等一下再关闭。前端收到done就更新状态并主动es.close(),避免浏览器傻乎乎地重连。
第二,页面刷新后的恢复。用户刷新页面,SSE 连接重建,但任务还在跑。这时候服务端要能根据任务 ID 返回当前进度,而不是从 0 开始。这也是Last-Event-ID之外的另一条路径:任务 ID 才是真正的锚点,事件 ID 只解决消息级续传。
还有个现实问题:任务和连接的绑定关系。一个任务可能被多个页面同时订阅(用户开了两个标签页看同一个导出),广播时要遍历所有匹配taskId的连接,而不是只推给发起者。
6.3 看板与站内通知:多路复用和降级
监控看板的特点是:一屏上有很多个数据块,每个数据块的更新频率不同。用一个 SSE 连接分配多个event类型比开多个连接合理得多,连接数压力小,服务端也好管理。
具体做法是每个数据块一个事件名,比如event: metric:qps、event: metric:latency,前端按名字挂监听。服务端在广播时按订阅关系过滤——用户没在看某个图表,就别给他推那部分数据。
站内通知则要注意降级路径。SSE 连接不可用的时候(比如某些企业网络环境的代理会中断长连接),不能整个功能就废了,得有一个兜底的轮询或者“进入页面时拉一次未读数”。降级判断不能只看onerror,还要看“连接建立后多久没收到任何数据”,超过阈值就认为链路不健康,切到轮询。
我个人在这些场景里踩过最深的一个坑,是把心跳间隔设得比用户网络环境的空闲超时还长。表现是移动端用户切到后台再回来,通知就再也不来了,必须手动刷新页面。后来改成前后端都做活跃检查——前端定时看“上次收到数据的时间”,超过两倍心跳周期就主动重建连接,问题才彻底消失。这套逻辑不复杂,但如果不做,用户是感知不到“连接已经死了”的,只会觉得“这个功能时好时坏”。
至于选择用哪套方案,我的经验是:能用 EventSource 就别自己写解析,内置的重连和事件分发确实省事;一旦碰到“需要 POST”“需要自定义 Header”“需要精细控制重连”这三件事中的任何一件,果断切到fetch流方案,别在 EventSource 上打补丁,补丁打多了比从头写还乱。