SSE 实战指南:从协议细节到生产落地的实时推送与排障

发布时间:2026/10/26 5:49:14
SSE 实战指南:从协议细节到生产落地的实时推送与排障 SSE 这个东西我在三四个项目里用过最早一次是在一个任务调度后台做进度推送当时团队为了“要不要上 WebSocket”吵了一下午最后选了 SSE原因很简单我们只需要服务端往客户端说话客户端不需要回话。后来在大模型流式输出、站内通知、实时日志这几类需求里又反复用到它慢慢攒下了一些文档里不会写的经验。这篇就把 SSEServer-Sent Events服务器发送事件从协议细节到生产落地完整拆一遍包括它到底适合干什么、不适合干什么、服务端怎么写才不会被网关掐断、前端 EventSource 有哪些坑、出问题怎么排查。内容偏实战前端、后端、运维都能找到自己要的那部分刚接触的新手可以从第 1 节顺着看有经验的可以直接跳到第 3、6 节。1. 先把 SSE 讲透它到底解决的是什么问题1.1 一根 HTTP 连接上的单向水管SSE 的本质非常朴素客户端发起一个普通的 HTTP GET 请求服务端返回200然后把Content-Type设成text/event-stream接着不关闭响应持续往这个响应体里写符合特定格式的文本块。浏览器端的EventSource对象会一直读这条流每读到一个完整的事件块就派发一次事件。你可以把它理解成一根单向水管水数据只能从服务端流向客户端客户端全程只是“接水”不能往管子里灌东西。它在协议层面就是一个长连接的 HTTP 响应没有协议升级、没有握手协商、没有二进制帧甚至连Upgrade头都不需要。这一点决定了它的全部优缺点——简单到极致但能力边界也清晰到极致。对比一下常见的三种“客户端要拿服务端新数据”的方案差别就很直观了方案通信方向连接开销服务端实现复杂度典型延迟自动重连短轮询双向但每次都新建高每次完整请求极低取决于轮询间隔不需要长轮询双向但挂起等待中一次数据一个请求中要处理挂起和超时低需自己写SSE服务端到客户端单向低一条连接长期复用低就是一个不结束的响应极低浏览器内置WebSocket全双工低一条连接高要处理帧、心跳、集群广播极低需自己写这张表里最关键的一列其实是“自动重连”。浏览器对EventSource内置了断线重连逻辑断了自己连回来还能带上Last-Event-ID告诉服务端“我上次读到哪儿了”。这个能力是白送的不用写一行代码。在自己实现长轮询或者 WebSocket 的时候重连逻辑往往是最容易被写错、也最容易在线上出问题的部分。1.2 什么情况下该用它什么情况下别碰我一般用三条判断标准来决定是否上 SSE第一条通信是不是严格单向。如果客户端除了“我要订阅”之外不需要再说话SSE 就是最优解。进度推送、通知下发、日志流、行情快照、大模型逐字输出全都符合。第二条数据是不是文本为主。SSE 的协议是纯 UTF-8 文本二进制得先 base64体积会涨三分之一。如果是要传音频帧、视频帧、高频二进制传感器数据那就该考虑别的路子。第三条客户端环境是不是以浏览器为主。EventSource是浏览器原生 API移动端原生、桌面客户端就不一定有现成的实现了得自己按协议解析。这种场景下 WebSocket 的生态反而更统一。反过来说需要客户端高频上行协同编辑、即时通讯里的发送、游戏操作同步的场景SSE 完全不适合硬做只能用“SSE 下发 普通 POST 上行”的方式凑但延迟和顺序都难保证不如直接上 WebSocket。提示有一个常见的误判——觉得“我们既要推送又要上行那就 SSE 接口双通道”。这方案在小流量下能跑但两个通道的状态一致性很难维护比如上行成功了下行还没到界面就会出现短暂的不一致。真要做双向别犹豫上 WebSocket。1.3 那些被低估的适用场景除了大家都知道的“消息推送”SSE 在几个地方特别香我实际用过并且效果不错长任务的进度条。用户点了个“导出全部数据”背后跑十几分钟。用 SSE 建一条/tasks/{id}/progress的流服务端每完成一批就写一条事件前端进度条平滑走完用户不会以为页面卡死而反复点。后台操作的实时日志。运维面板上点“执行部署”日志一行一行滚出来这种体验比“转圈等到结束再一次性显示”强太多而且出问题时用户能立刻看到卡在哪一步。大模型的逐 token 输出。这个几乎是当下最典型的用法首字延迟从“等十秒”变成“半秒就开始动”体感差异巨大。配置变更、开关下发的轻量广播。比如某个功能灰度开关变了服务端往所有在线连接推一条事件前端就地刷新不用用户手动刷页面。这些场景的共同点是单向、低频到中频、文本、对实时性有要求但不需要毫秒级。SSE 在这些地方比 WebSocket 省事太多代码量可能只有三分之一。2. 协议细节拆解浏览器和服务器之间的暗号2.1 一个合法事件流的完整长相很多人第一次手写 SSE 服务端会失败问题几乎都出在格式上。规范对报文的要求比想象中严格我把它拆成三层来看。第一层是响应头。服务端必须返回HTTP/1.1 200 OK Content-Type: text/event-stream; charsetutf-8 Cache-Control: no-cache, no-transform Connection: keep-alivecharsetutf-8建议显式写上不写在某些代理链路里会出现中文乱码。no-transform是给中间代理看的告诉它别自作主张压缩或改写内容这一条在实际排障中救过我好几次。第二层是事件块的字段格式。每一行是字段名: 值冒号后面那个空格可有可无但建议加上。字段只有五个字段作用说明data事件载荷可以出现多次多行会用\n拼成一个字符串event事件类型名不写就是默认的message前端用addEventListener监听id事件编号会写入浏览器的lastEventId重连时通过请求头带回retry重连间隔毫秒服务端告诉浏览器断线后等多久再连:开头注释行整行被忽略专门用来做心跳保活第三层是块的分隔。一个事件块必须以两个连续的换行\n\n结尾才会被派发。这是最容易踩的坑少一个换行前端什么反应都没有Network 面板里看着数据在流但onmessage死活不触发。一个最小可用的完整报文长这样retry: 3000 id: 1 event: progress data: {percent: 12, msg: 正在读取文件} id: 2 event: progress data: {percent: 45, msg: 正在解析} : heartbeat注意最后那个只有冒号的行它不构成事件纯粹是防止中间设备因为长时间无数据而断开。2.2 EventSource 的几个反直觉行为EventSource用起来只有三行但它的行为里有几个地方和直觉不符我逐个说。onmessage收不到带event名的事件。这是新手最常问的问题。es.onmessage只处理没有event字段的默认事件一旦服务端写了event: progress就必须用es.addEventListener(progress, fn)来监听。所以我的习惯是如果业务里有多种消息类型全部用具名事件路由清晰如果只有一种就不写event字段省事。data里的换行会被吃掉。如果data的值里本身包含换行不能直接塞进去得拆成多行datadata: 第一行 data: 第二行前端拿到的是第一行\n第二行。反过来说如果你发送的是 JSON一定要JSON.stringify之后再放否则对象里的换行会把一条事件拆成两条。连接建立是异步的但不代表数据到了。onopen触发只说明 HTTP 响应头回来了此时还没有任何业务数据。真正的“就绪”信号应该由服务端发一条event: ready来标记前端收到这条之后才认为通道可用。这个约定在弱网环境下特别重要否则前端可能一直在“已连接”但实际收不到数据的状态里。返回 204 会彻底停掉重连。规范里规定如果服务端返回204 No ContentEventSource会认为“服务端不想让我连了”直接进入CLOSED状态并且不再重连。这个特性非常有用——用户登出、权限被收回、任务已结束时服务端返回 204 就能干净地终止连接比自己写es.close()靠谱因为客户端可能已经崩了。2.3 断线重连与 Last-Event-ID 的工作机制这套机制值得单独拎出来讲因为它是 SSE 相比自己手搓长连接的最大优势。流程是这样的浏览器和服务端之间的连接因为网络抖动、代理超时、服务重启等原因断了EventSource会在retry指定的毫秒数后默认大约 3 秒自动重新发起同一个 URL 的 GET 请求并且在请求头里带上Last-Event-ID: 最后收到的 id。服务端读到这个头就知道该从哪儿继续把漏掉的数据补上。要让它真正发挥作用服务端必须做两件事给每条事件写id并且 id 要能用来定位数据位置。我一般用单调递增的序号、或者带时间戳的复合 id比如1700000000000-42。不要用随机 UUID那玩意儿没法定位。在响应开始时检查Last-Event-ID。如果带了就从那个位置之后开始发如果没带从“当前”开始发大多数场景下这是正确语义因为历史数据应该走 REST 接口补不该塞进流里。这里有个容易忽略的细节Last-Event-ID只在自动重连时带上手动new EventSource()不会带。所以如果前端自己实现了重连逻辑比如要加退避就必须手动把lastEventId拼到 URL query 上不然服务端不知道断点在哪会重复推送或者漏推。还有一个更隐蔽的问题多个标签页共享同一个Last-Event-ID是不存在的每个EventSource实例维护自己的lastEventId。多标签页打开同一个流就是多条独立连接服务端会重复推送。这个在通知场景下会导致用户看到重复消息解决办法在 4.4 节讲。2.4 浏览器连接数限制这个隐形天花板这是我见过的、最容易在项目后期爆发的坑。HTTP/1.1 下浏览器对同一个域名准确说是同一个 origin且不分端口的并发连接数限制是 6 个左右。SSE 的连接是常驻的会一直占着这个配额。如果页面里开了两个 SSE比如一个通知流、一个任务流再叠加上几个正在飞的 XHR很快就能把配额吃满表现是页面里其他请求全部卡住、排队用户看起来就是“整个网站卡死了”。更麻烦的是多标签页场景用户开了 5 个标签页每个页面两条 SSE那就是 10 条连接第 7 条开始就排队了后面的标签页永远连不上控制台也不会报错就是没数据。解决路径有三条按推荐程度排序上 HTTP/2。HTTP/2 下同域名是复用一条 TCP 连接的多路复用流浏览器对流的并发限制通常是 100SSE 占用的不再是 TCP 连接而是流配额压力骤降。这是根治方案现在的主流网关和 CDN 基本都支持。合并流。一个页面只保留一条 SSE 连接所有类型的消息都走这条连接用event字段区分类型。这是最省资源的做法我在实际项目里都是这么做的。用 SharedWorker 或 BroadcastChannel 做连接共享。多标签页共用一条连接由其中一个标签页或 Worker持有收到消息后广播给所有页面。这套方案复杂度不低只在标签页极多的后台系统里才值得上。注意如果你在本地开发时一切正常一上线就出现“部分用户收不到推送”先别怀疑代码去数一数页面上有几个EventSource实例再看看是不是 HTTP/1.1。这个排查顺序能省掉半天时间。3. 服务端实现从能跑到能扛3.1 一个最小可用的 Node.js 版本先用最短的代码把链路跑通再谈优化。下面这段可以直接node server.js起来验证const http require(http); http.createServer((req, res) { if (req.url ! /events) { res.writeHead(404).end(); return; } res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, no-transform, Connection: keep-alive, // 给 Nginx 看的告诉它不要缓冲这条响应 X-Accel-Buffering: no, }); // 先告诉浏览器重连间隔 res.write(retry: 3000\n\n); // 从 Last-Event-ID 续传 const lastId Number(req.headers[last-event-id] || 0); let cursor lastId; const push (payload) { cursor 1; res.write(id: ${cursor}\n); res.write(event: progress\n); res.write(data: ${JSON.stringify(payload)}\n\n); }; // 业务数据定时器真实项目里由事件驱动 const timer setInterval(() { push({ at: Date.now() }); }, 1000); // 心跳只写注释行不构成事件 const heartbeat setInterval(() { res.write(: ping\n\n); }, 15000); // 客户端断开时必须清理否则定时器泄漏 req.on(close, () { clearInterval(timer); clearInterval(heartbeat); res.end(); }); }).listen(3000, () { console.log(sse server on http://localhost:3000/events); });这段代码里有三处是“少了就出问题”的X-Accel-Buffering: no防代理缓冲、req.on(close)清理定时器、心跳定时器。前两个不写会分别导致“数据一次性到达”和“服务端内存缓慢上涨”后一个不写会导致“空闲连接被网关掐断”。3.2 Go 和 Python 里需要注意的差异换成 Go最大的坑是必须显式 Flush。Go 的http.ResponseWriter自带缓冲你Write之后不Flush数据会攒在缓冲区里等到缓冲区满了才一次性吐出去表现为“前端每隔十几秒突然收到一大串”。func sseHandler(w http.ResponseWriter, r *http.Request) { flusher, ok : w.(http.Flusher) if !ok { http.Error(w, streaming unsupported, http.StatusInternalServerError) return } h : w.Header() h.Set(Content-Type, text/event-stream; charsetutf-8) h.Set(Cache-Control, no-cache, no-transform) h.Set(Connection, keep-alive) h.Set(X-Accel-Buffering, no) w.WriteHeader(http.StatusOK) flusher.Flush() // 从请求头取断点 lastID : r.Header.Get(Last-Event-ID) _ lastID enc : json.NewEncoder(w) for { select { case -r.Context().Done(): return // 客户端断开立刻退出别泄漏 goroutine case -ticker.C: fmt.Fprint(w, event: progress\n) fmt.Fprint(w, data: ) enc.Encode(map[string]any{at: time.Now().UnixMilli()}) fmt.Fprint(w, \n) flusher.Flush() } } }Go 版本里第二关键的是r.Context().Done()。每个 SSE 连接占一个 goroutine如果客户端断开后不退出goroutine 会一直挂着连接数一多就是内存泄漏。生产上我见过一个服务因为没处理这个跑三天内存涨了两倍。Python 的 Flask 用生成器返回、配Response(..., mimetypetext/event-stream)但 Flask 默认的开发服务器是单线程阻塞的一个 SSE 连接就能把工作线程占死第二个请求进不来。必须上 gunicorn 的 gevent/eventlet worker或者直接用 ASGI 框架的流式响应。这一点我在早期吃过亏本地测得好好的一部署就“整个站点只有一个用户能用”。3.3 生产环境的四件套心跳、超时、清理、限流最小版本跑通之后上生产前有几件事必须补齐。我把它们叫做“四件套”。心跳。间隔设多少合适我的经验值是15 到 25 秒。原因是大多数反向代理和云负载均衡的空闲超时默认在 30 秒到 60 秒之间心跳必须比它短。如果链路中间有多个网关按最短的那个来。心跳内容只写: ping\n\n不要写带event的否则前端要为它写一个专门的空处理函数纯属浪费。超时控制。SSE 连接是要“无限长”的但业务上往往不该无限长。我的做法是给每个连接一个最大存活时间比如 30 分钟到时间后主动发一条event: reconnect然后结束响应让浏览器自动重连。这样做的价值是可以让负载均衡有机会把连接重新分配到其他节点也能避免单节点连接数只增不减。资源清理。除了req.on(close)清理定时器如果服务端维护了一个“连接注册表”比如用来做广播必须在关闭时把它从注册表里移除。这块的典型错误是用 Map 存了连接客户端断开时忘了 deleteMap 无限膨胀最后 OOM。连接数限流。单个用户允许几个并发连接必须设上限比如 5 个。SSE 太容易被滥用一个死循环里new EventSource就能把服务端打满。做法是在建连时校验超过上限直接返回 429 或者 204204 的好处是浏览器不会重连。3.4 反向代理和网关的配置要点这一节是我踩坑最多的地方。SSE 在本地直连时一切正常加了网关就开始出各种诡异现象原因几乎都指向两个词缓冲和超时。以 Nginx 为例必须显式关掉缓冲否则数据会被攒起来location /events { proxy_pass http://backend; # SSE 必须走 HTTP/1.1 且长连接 proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 关键三行关缓冲、关缓存、加长超时 proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; # 不要压缩流式响应 gzip off; }逐行解释一下为什么这么写。proxy_http_version 1.1是因为 SSE 用 chunked 传输HTTP/1.0 不支持不写的话 Nginx 会退回 1.0流式直接失效。proxy_set_header Connection 是为了清掉Connection: close让连接能持续。proxy_buffering off是核心Nginx 默认会缓冲上游响应等积累了足够数据或者响应结束才转发给客户端对 SSE 来说这是灾难。gzip off是因为压缩模块也会缓冲而且压缩后的分块边界可能把中文字符切断导致前端TextDecoder解出乱码。如果你前面还有云厂商的负载均衡还要注意它的空闲超时很多默认 60 秒并且确认它支持 HTTP/1.1 的 chunked 或者 HTTP/2。有些团队的做法是在应用层直接把连接存活时间设成比负载均衡超时短一点主动断开重连用“可控的重连”对抗“不可控的掐断”这个思路我觉得很务实。提示排查“是不是被代理缓冲了”有一个特别快的办法——在服务端每秒写一条事件观察前端是每秒收到一条还是每 10 秒收到 10 条。后者 100% 是缓冲问题别去查业务代码。4. 前端接入与实战套路4.1 EventSource 的标准用法和一层薄封装原生 API 太裸了直接用会让业务代码里到处是回调。我一般包一层把事件路由、状态、错误暴露清楚function createSSE(url, { onEvent, onStateChange } {}) { let es null; let retries 0; let closedByUser false; const setState (s) onStateChange onStateChange(s); function connect() { setState(connecting); es new EventSource(url, { withCredentials: true }); es.onopen () { retries 0; setState(open); }; // 默认事件 es.onmessage (e) { onEvent onEvent({ type: message, data: e.data, id: e.lastEventId }); }; // 具名事件要显式监听 [progress, notify, ready].forEach((name) { es.addEventListener(name, (e) { onEvent onEvent({ type: name, data: e.data, id: e.lastEventId }); }); }); es.onerror () { if (closedByUser) return; // readyState 为 CLOSED 说明浏览器放弃重连了比如 204 if (es.readyState EventSource.CLOSED) { setState(closed); return; } retries 1; setState(reconnecting); // 浏览器自己会重连这里只做退避提醒 if (retries 5) { // 连续失败太多次手动降级成轮询或者提示用户刷新 setState(degraded); } }; } connect(); return { close() { closedByUser true; es es.close(); setState(closed); }, }; }这段封装里最关键的是readyState的区分。很多人只写了onerror就直接弹“连接失败”结果用户看到一闪而过的报错提示其实浏览器已经自动连回来了。readyState为CONNECTING表示正在重连为CLOSED才是真的结束了。4.2 需要带鉴权头或者 POST 怎么办EventSource有两个硬限制不支持自定义请求头不支持 POST 请求体。这在真实项目里经常卡人。鉴权的问题首选方案是用 Cookie。同源的 SSE 请求会自动带上 CookiewithCredentials: true就能跨域带上服务端从 Cookie 里取会话即可不需要额外传 token。如果必须用 Bearer token退而求其次是把 token 放 query string注意日志脱敏别把 token 写进访问日志但有长度限制和泄漏风险。真要自定义头或者 POST就用fetch 流式读取自己解析 SSE 协议。这份代码不长我贴一下核心部分async function streamSSE(url, { body, headers, signal, onEvent }) { const res await fetch(url, { method: POST, headers: { Content-Type: application/json, ...headers }, body: JSON.stringify(body), signal, }); if (!res.ok) throw new Error(HTTP ${res.status}); if (!res.body) throw new Error(stream unsupported); const reader res.body.getReader(); const decoder new TextDecoder(utf-8); let buf ; while (true) { const { done, value } await reader.read(); if (done) break; // stream: true 很关键避免把多字节字符切断 buf decoder.decode(value, { stream: true }); // 兼容 \r\n 和 \n\n 两种分隔 let idx; while ((idx buf.search(/\r?\n\r?\n/)) 0) { const raw buf.slice(0, idx); buf buf.slice(buf.slice(idx).match(/^\r?\n\r?\n/)[0].length idx); parseBlock(raw, onEvent); } } } function parseBlock(raw, onEvent) { let type message; let id ; const dataLines []; for (const line of raw.split(/\r?\n/)) { if (!line || line.startsWith(:)) continue; const i line.indexOf(:); const field i -1 ? line : line.slice(0, i); const value i -1 ? : line.slice(i 1).replace(/^ /, ); if (field event) type value; else if (field data) dataLines.push(value); else if (field id) id value; } if (dataLines.length) { onEvent({ type, id, data: dataLines.join(\n) }); } }用 fetch 的代价是断线重连和 Last-Event-ID 都要自己实现。我的做法是记录最后一个id在 catch 到网络异常后用指数退避重连把 id 拼到请求体里。如果这层逻辑不想写那就老老实实回退到EventSource Cookie 鉴权。这里有个TextDecoder的细节值得多说一句decode(value, { stream: true })这几个字不能省。一个中文字符在 UTF-8 里占 3 字节网络分块完全可能在字符中间切断不加stream: true就会解出。服务端那边同理如果自己拼字符串写响应要保证一次write的是一段完整的事件块别把一个中文字拆两次写。4.3 页面切后台、休眠、恢复的处理移动端和笔记本的场景比桌面复杂得多。浏览器标签页切到后台后很多浏览器会节流定时器长时间后台甚至会直接断开连接。用户切回来时EventSource可能正在重连也可能已经静默失败了。我的处理套路是监听visibilitychange切回前台时检查连接状态document.addEventListener(visibilitychange, () { if (document.visibilityState ! visible) return; if (!es || es.readyState EventSource.CLOSED) { // 重新建立并带上最后的 id 补数据 reconnectWithLastId(); } else if (es.readyState EventSource.CONNECTING) { // 正在重连给个 UI 提示就好 showBanner(正在重新连接…); } });同时要注意断线期间丢的数据不能靠 SSE 补。Last-Event-ID能做的是“服务端有历史缓冲”时续传但服务端通常不会为每个连接维护无限长的缓冲。所以我的原则是SSE 只负责“增量通知”全量数据永远走一次 REST 拉取。切回前台时先拉一次当前状态再靠 SSE 追增量这样即使漏了几条事件也不会导致状态错乱。这个“先拉全量、再订阅增量”的模式是我做实时功能的标准姿势。4.4 多标签页重复消息的解法前面提过多标签页 多条连接 重复推送。用户在两个标签页里都看到同一条通知体验很差。成本最低的解法是用localStorage做一个简单的“领导者选举”每个页面启动时尝试写一个带时间戳的 key谁的写入最新谁就当 leader只有 leader 建立 SSE 连接收到消息后通过BroadcastChannel广播给其他页面。const bc new BroadcastChannel(sse-hub); if (isLeader()) { const sse createSSE(/events, { onEvent: (evt) bc.postMessage(evt), }); } else { bc.onmessage (e) handleEvent(e.data); }BroadcastChannel的兼容性现在已经很好了不支持的环境降级成localStorage的storage事件也能凑合。这套方案我用了两个项目稳定代码量不到 60 行。如果页面数量少比如后台系统最多开两三个也可以偷懒不处理但在通知类产品里必须做。5. 典型场景落地拆解5.1 大模型对话的流式输出这是目前 SSE 最火的应用。核心诉求是“首字延迟要低”用户看到第一个字蹦出来体感上就认为系统很快。链路一般是这样的前端发一个 POST 带上对话内容服务端转发给模型服务模型边生成边返回服务端边收边往前端写 SSE。这里有几个实操要点。第一别用EventSource用fetch 流式读。因为对话内容可能很长放 query string 不合适必须 POST。同时前端要能中途取消用户点了“停止生成”EventSource没有 abort 能力fetch的AbortController可以。第二注意上游的返回格式可能不是标准 SSE。有些模型服务返回的是每行一个 JSONNDJSON或者有自己的分块格式中间要做一层转换。我一般写一个适配器把上游的每一块统一转成data: {delta: ...}再下发前端只认这一种格式将来换供应商不用改前端。第三要考虑“服务端提前结束”的表达。流结束的时候不能只是关连接得发一条event: done带终止原因正常结束、被截断、内容审核前端据此决定是显示“已停止”还是“生成完成”。少了这个前端只能靠连接关闭来判断分不清是正常结束还是网络断了会触发无意义的重连。第四超时要分层。首字超时比如 10 秒没收到第一个 token 就报错和整体超时比如 5 分钟强制结束是两回事都要设。我见过一个 case模型服务挂了但连接没断SSE 一直挂着不返回前端转圈转了十分钟用户以为死机了。5.2 长任务进度推送这类场景的坑和上一种完全不同重点不在延迟而在状态最终一致。一个典型的导出任务用户点导出服务端创建一个任务 id返回给前端前端拿着 id 去订阅/tasks/{id}/progress。这里必须处理一个竞态如果任务特别快前端还没订阅上任务就完成了那 SSE 永远等不到完成事件前端卡在“进行中”。我的处理是双保险订阅流的同时立刻发一次状态查询请求。查询返回“已完成”就直接按完成处理不管流里有没有没完成就等流的事件。同时服务端在订阅建立时先补发一次“当前快照”事件这样即使中间漏了也能对齐。另外任务状态机要有终态。success、failed、canceled三个终态一旦进入服务端就发event: end然后结束响应或者返 204前端收到后关闭连接。一定要主动关别等服务端超时否则用户开着页面不动连接一直挂着白占资源。进度事件的频率也要控制。有的实现是每处理一条数据就推一次几万条数据就是几万条事件前端渲染都跟不上。我一般做节流进度变化小于 1% 或者距上次推送不足 200 毫秒就合并只在日志类场景才逐条推。5.3 站内通知与消息中心通知场景的特点是连接数多、单条消息小、对可靠性要求高。用户关掉页面就等于离线回来要能看到期间的所有消息。这套东西不能只靠 SSE 做标准架构是三层层职责实现要点存储层消息持久化、未读计数数据库存消息和已读状态别放内存推送层在线用户实时下发SSE 连接注册表按用户 id 索引补偿层离线消息补拉上线时拉一次增量按游标翻页SSE 在这里只负责“在线时的即时性”可靠性完全靠存储层和补偿层。具体做法是用户建立连接时前端带上自己最后看到的游标服务端先查一次有没有更新的消息有就先推一批event: backlog推完再发event: ready之后才开始推新的。这样即使断线很久回来也能补齐。还有一个细节是多设备。同一个账号在手机和电脑上都登录服务端要维护“用户 id → 连接集合”的映射广播时遍历所有连接。这时候要注意单设备的连接可能有很多条多标签页前面说的 leader 选举就是为了压这个数量。5.4 实时日志和监控面板日志流的特点是吞吐高、文本长、用户往往同时开好几个面板。这个场景下最容易翻车的是缓冲和渲染。服务端侧的重点是别让单条日志太大。一条几十 KB 的堆栈信息如果一次性写进一个data浏览器解析会很吃力而且中间任何一环的分块都可能把它切开虽然协议上没问题但排障时看着乱。我的做法是按行切分每条日志一个事件超过一定长度的截断并标记。前端侧的重点是渲染节流。日志每秒几百条进来如果每条都appendChild页面会直接卡死。必须做批量渲染事件先塞进一个数组用requestAnimationFrame或者 100 毫秒的定时器批量刷到 DOM并且限制最大行数比如只保留最近 2000 行超出就丢头部。监控面板还有一个特殊需求数字要平滑。指标从 42 跳到 87 再跳到 13视觉上很跳。可以在前端做一点插值动画或者至少让服务端按固定间隔推送快照而不是变化即推这样曲线看起来更自然。我一般让服务端固定 1 秒推一次聚合后的快照比“有变化就推”稳得多。6. 常见问题排查实录6.1 一份能直接照着查的速查表下面这张表是我这些年攒下来的按“现象”查“原因”基本覆盖了九成问题现象最可能的原因处理方式数据一次性到达不是流式代理或框架缓冲关proxy_buffering、关 gzip、Go 里加Flush()、加X-Accel-Buffering: no前端onmessage不触发缺\n\n结尾或用了具名event却用onmessage监听检查报文结尾改成addEventListener每 30/60 秒断一次网关空闲超时心跳设 15-25 秒部分用户完全收不到HTTP/1.1 六连接配额耗尽上 HTTP/2或合并成一条流中文显示乱码或缺 charset或分块切断了多字节字符加charsetutf-8前端decode加stream: true断线后重复收到旧消息没有维护Last-Event-ID或 id 不可定位给事件写递增 id服务端读Last-Event-ID连接一直重连停不下来服务端返回了错误状态码明确结束时返 204让浏览器停止重连服务端内存持续上涨断开时没清理定时器/注册表监听close/Context().Done()做清理前端控制台无报错但没数据看 Network 里那条请求是不是一直 pendingpending 是正常的重点看 Response 有没有内容6.2 几个印象比较深的坑“本地好好的线上不行”的经典案例。某次上线后一个进度条功能在测试环境完美生产环境变成“转圈 30 秒然后进度直接 100%”。查了两个小时业务代码没发现问题最后在网关配置里看到proxy_buffering是默认的on。改成off之后立刻正常。这件事之后我养成了一个习惯任何流式功能先让运维把链路配置贴出来再开始写代码比写完再查高效得多。“定时器泄漏”的隐形成本。有个服务跑了几天内存缓慢上涨重启就恢复。用 pprof 一看几万个定时器对象挂在堆上。原因是客户端断开时close事件在某个框架版本里没触发定时器没清。后来改成“每次定时器回调里检查一下连接的存活标记”并且给每个连接加了最大存活时间双保险之后就没再出过。经验是任何挂在连接上的定时器都要有一个兜底的超时自杀机制别完全相信close一定会来。“看似是 SSE 的问题其实是客户端”。某同学反馈“在某个浏览器里消息延迟特别大”查下来发现那个浏览器在标签页进入后台后会把EventSource的连接降级处理。解决方案是把关键操作比如支付结果通知从“纯推送”改成“推送 主动查询兜底”用户点确认的时候再查一次接口保证结果准确。这件事让我记住一个原则SSE 用来提升体验不用来保证正确性。所有关键状态的最终确认都要有一次性查询兜底。“心跳反而害了事”。我见过一个实现把心跳设成了 5 秒一次结果因为事件太密集某些移动网络下反而频繁触发重连。心跳不是越密越好比中间层的空闲超时略短就够了通常 15 到 25 秒是甜区。设得太密除了浪费流量没有任何好处。“忘了限流被自己的健康检查打满”。有个内部系统做了个自动刷新的仪表盘每个页面 20 个卡片每个卡片一个 SSE 连接。10 个人打开就是 200 条连接服务端直接被打满。后来改成整个页面一条连接前端按 topic 分发连接数直接降到 10 条。教训是连接数要按“页面”算不能按“组件”算。我个人在实际操作中的体会是SSE 这东西技术上没什么门槛真正的门槛在“知道它的边界在哪”。它是单向的、文本的、靠 HTTP 的所以它天生简单可靠但也正因为靠 HTTP它对代理配置极其敏感、对连接数极其敏感。把这两点记住了用它做实时推送会非常顺手比折腾 WebSocket 省下的时间足够你多写两个功能。后续如果要在同一套连接上做“按主题订阅”可以在建连时加一个topics参数服务端维护“主题到连接”的索引这一层扩展做起来也不复杂我在通知中心里就是这么干的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询