ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSE流式传输实战:从协议原理到生产环境避坑指南

SSE流式传输实战:从协议原理到生产环境避坑指南 1. 从一次线上事故说起为什么流式传输值得单独拎出来讲去年帮一个团队排查线上问题现象很典型AI 对话页面在回答较长内容时用户要盯着空白转圈十几秒然后整段文字啪地一下全冒出来。产品经理觉得是模型太慢运维觉得是带宽不够最后定位到根因——后端把大模型的输出全部缓冲完才一次性返回给前端。模型其实早就在吐字了只是被中间层攒着没发出去。这个问题的解法就是流式传输streaming而 Web 场景下最常用的落地手段就是SSE 协议Server-Sent Events服务器推送事件。它解决的问题很朴素让服务器产生的数据产生一点就发一点客户端收到一点就渲染一点而不是等全部生产完再打包发货。我写这篇东西是想把 streaming 和 SSE 这两件事从会用讲到知道为什么这么用。适合三类人看一是正在做 AI 对话、实时日志、进度推送这类功能的前后端二是被打字机效果需求折磨过、想搞清楚底层机制的开发者三是面试时被问到SSE 和 WebSocket 有什么区别却只能背八股的朋友。全文会从协议原理、技术选型、代码实操、踩坑排查四个层面展开尽量做到看完能直接抄作业。先给一个最直观的类比。传统 HTTP 请求像去餐厅点菜你点完厨房做完一整桌服务员一次性端上来。流式传输像回转寿司厨师捏一个传送带送一个你看到哪个拿哪个。SSE 就是那条传送带的标准化协议——规定了盘子怎么摆、传送带断了怎么重连、厨师想标记这盘是第几号该怎么写。2. 流式传输到底在传什么核心概念与协议选型2.1 流式传输的本质是边生产边消费要理解 streaming先要理解它对抗的是什么。传统请求-响应模型里数据在时间轴上是离散的请求发出等待响应到达结束。整个过程中客户端和服务器之间只有两次交互。而流式传输把响应体变成一个持续的字节流服务器可以在这个流上不断写入新数据客户端可以不断读取直到服务器主动关闭或连接中断。这里有个容易被忽略的点HTTP/1.1 本身是支持流式响应的。只要服务器不设置Content-Length改用Transfer-Encoding: chunked就可以一块一块地发数据。SSE 正是建立在这个基础之上的应用层协议约定。所以严格来说SSE 不是发明了一种新传输方式而是给HTTP 分块传输套上了一层标准化的语义外壳。为什么需要这层外壳因为裸的分块传输只解决了能分块发没解决客户端怎么知道这一块是消息、那一块是心跳、断了怎么续。SSE 把这些约定都定死了浏览器原生提供EventSourceAPI 来解析开发者不用自己处理粘包和分帧。2.2 SSE 协议格式看起来简单细节全是坑SSE 的报文格式用一句话概括纯文本以\n\n分隔消息每行是字段: 值。就这么简单。一个典型的消息长这样event: message data: {content: 你好} id: 1001拆开看四个字段各有用途data消息正文可以有多行多行会被拼接成一个字符串行间用\n连接。event自定义事件类型客户端可以用addEventListener监听特定类型不写默认是message。id消息 ID浏览器会自动记录最后收到的 ID重连时通过Last-Event-ID请求头带给服务器用于断点续传。retry告诉浏览器断线后多久重连单位毫秒。注意data字段的值里如果本身包含换行必须拆成多个data:行不能直接塞一个带\n的字符串。这是新手最常犯的格式错误会导致消息被截断。还有一个反直觉的细节冒号后面那个空格是可选的。data:hello和data: hello都合法但前者解析出来的值是hello后者是hello带前导空格。规范里说如果冒号后紧跟一个空格这个空格会被忽略如果没有空格则从冒号后第一个字符开始算。所以为了可读性建议统一写data: xxx。2.3 SSE、WebSocket、轮询三种方案怎么选这是被问得最多的问题。我整理了一张对比表把关键维度拉平来看维度SSEWebSocket短轮询长轮询通信方向服务器到客户端单向双向双向伪双向伪底层协议HTTP/1.1 或 HTTP/2独立协议需握手升级HTTPHTTP浏览器原生支持EventSource 原生支持WebSocket API 原生支持无特殊 API无特殊 API自动重连内置需自己实现不涉及不涉及断点续传内置 Last-Event-ID需自己实现不涉及不涉及二进制支持不支持仅文本支持支持支持连接数限制HTTP/1.1 下同域约 6 个无此限制无无代理/网关友好度高就是普通 HTTP部分老网关会拦截升级高高典型场景AI 流式输出、日志推送、进度通知聊天室、协同编辑、游戏简单状态查询消息通知选型逻辑其实很清晰。如果只需要服务器单向推数据给客户端SSE 是首选因为它就是普通 HTTP穿透代理、负载均衡、鉴权中间件都毫无压力还自带重连和续传。如果需要双向高频通信才上 WebSocket。轮询则是实在没别的办法时的兜底延迟和资源浪费都明显。AI 对话场景几乎清一色用 SSE原因就在这模型输出是单向的用户提问走普通 POST 就行回答走 SSE 流。用 WebSocket 属于杀鸡用牛刀还要额外维护连接状态和心跳。2.4 为什么 AI 交互逻辑偏爱 SSE 封装现在很多 AI 应用的交互层是这么设计的用户消息通过一个普通 POST 提交服务端拿到请求后不立即返回而是建立一条 SSE 通道把模型的 token 逐个推给前端。前端每收到一个 token 就追加到消息气泡里形成打字机效果。这套封装之所以流行有几个现实原因。第一HTTP 语义完整鉴权、限流、日志、链路追踪这些基础设施全部复用不需要为长连接单独造轮子。第二前端实现成本极低EventSource几行代码就能跑起来不需要引入额外库。第三对中间层友好Nginx、网关、CDN 都能正常处理只要关掉响应缓冲即可。但这里有个关键前提必须关闭所有中间层的响应缓冲。我见过太多案例代码写得没问题就是前端一直转圈最后发现是 Nginx 的proxy_buffering默认开着把流式响应又攒成了整块。这个坑后面会专门讲。3. 手把手实现从后端推流到前端渲染3.1 后端实现 SSE 的通用骨架不管用什么语言SSE 后端的核心就三件事设置正确的响应头、保持连接不关闭、按格式写入数据。先看响应头这是最容易被写错的地方Content-Type: text/event-stream; charsetutf-8 Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no逐个解释。Content-Type必须是text/event-stream这是 SSE 的身份证浏览器靠它识别。Cache-Control: no-cache防止中间层缓存流式响应。Connection: keep-alive保持长连接。X-Accel-Buffering: no是给 Nginx 看的告诉它别缓冲这个响应——这一行能省掉你半天的排查时间。用 Java 实现的话Spring 提供了SseEmitter用起来很顺手GetMapping(value /stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter stream(RequestParam String prompt) { SseEmitter emitter new SseEmitter(0L); // 0 表示不超时 executor.execute(() - { try { for (String token : modelService.generate(prompt)) { emitter.send(SseEmitter.event() .id(String.valueOf(counter.incrementAndGet())) .name(message) .data(token)); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }这里有几个实操要点。SseEmitter的构造参数是超时时间传0L表示永不超时但生产环境不建议真的永不超时一般设个 5 到 10 分钟防止连接泄漏。发送数据用SseEmitter.event()链式构造它会自动帮你拼好data:、event:、id:这些字段比手写字符串安全得多。最后一定要调complete()或completeWithError()否则连接会一直挂着。如果用 Node.js原生http模块就能做不需要框架app.get(/stream, (req, res) { res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.setHeader(X-Accel-Buffering, no); res.flushHeaders(); const timer setInterval(() { res.write(data: ${JSON.stringify({ t: Date.now() })}\n\n); }, 1000); req.on(close, () { clearInterval(timer); res.end(); }); });注意res.flushHeaders()这一句它强制把响应头立刻发出去不等 body。没有它某些 Node 版本会等第一块 body 才发头导致客户端迟迟收不到连接建立的信号。还有req.on(close)里的清理逻辑客户端断开时必须停掉定时器否则就是内存泄漏。3.2 前端消费 SSEEventSource 的正确打开方式前端用EventSource消费流基础用法简单到离谱const es new EventSource(/stream?prompt encodeURIComponent(prompt)); es.onmessage (e) { appendToBubble(e.data); }; es.onerror (err) { console.error(SSE error, err); es.close(); };但真实项目里光这样是不够的。有几个必须处理的点。第一EventSource只支持 GET 请求。这是它的硬伤因为规范就是这么定的。如果你的 prompt 很长塞 URL 里会超长如果涉及敏感信息放 URL 里会进日志。解决办法有两个一是用 POST 提交拿到一个streamId再用 GET 带着streamId去订阅流二是放弃EventSource用fetchReadableStream手动解析。后者更灵活后面会讲。第二EventSource默认会自动重连。这本来是优点但在 AI 对话场景里可能是灾难——模型已经生成完了连接正常关闭浏览器却以为断线了又发起重连导致重复请求。所以服务端在结束时应该发送一个明确的结束事件前端收到后主动close()。第三错误处理要区分可重试和不可重试。网络抖动导致的中断让浏览器自动重连没问题但如果是 401 鉴权失败或 404 路径错误重连多少次都没用应该直接关闭并提示用户。3.3 用 fetch 替代 EventSource更可控的方案EventSource的 GET 限制和自动重连在很多场景下反而是负担。用fetch配合ReadableStream可以完全掌控流程const controller new AbortController(); async function streamChat(prompt) { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), signal: controller.signal, }); const reader resp.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const parts buffer.split(\n\n); buffer parts.pop(); // 最后一段可能不完整留到下次 for (const part of parts) { const line part.split(\n).find(l l.startsWith(data:)); if (line) { const data line.slice(5).trim(); if (data [DONE]) return; appendToBubble(JSON.parse(data)); } } } } // 用户点停止生成 function abort() { controller.abort(); }这段代码有几个精妙之处值得说。decoder.decode(value, { stream: true })的stream: true参数很关键它告诉解码器当前块可能不是完整字符遇到多字节字符被切断时要保留状态下次继续拼。没有这个参数中文和 emoji 会出现乱码。buffer.split(\n\n)之后pop()出最后一段是因为网络传输不保证按消息边界到达一次read()可能读到半条消息。把不完整的部分留在 buffer 里等下一块数据到了再拼这是处理流式数据的标准姿势。AbortController是停止生成按钮的实现基础。用户点停止调controller.abort()fetch会抛出一个AbortError连接随之关闭服务端也能通过req.on(close)感知到并停止模型推理省算力。3.4 服务端如何感知客户端断开这一点经常被忽略但直接影响成本。用户关了页面如果服务端还在傻乎乎地跑模型、发数据那就是纯浪费。不同技术栈的感知方式不一样。Node.js 里监听req.on(close)或res.on(close)。Java 的SseEmitter提供了onCompletion、onTimeout、onError三个回调可以在里面做清理。Go 的话用r.Context().Done()这个 channel。emitter.onCompletion(() - { log.info(client closed, stop generation); generationTask.cancel(true); }); emitter.onTimeout(() - { log.warn(sse timeout); emitter.complete(); });实操心得onCompletion在正常完成和客户端断开时都会触发所以清理逻辑放这里最稳妥。但要注意它可能在另一个线程执行涉及共享状态时要加锁。4. 生产环境避坑指南那些文档不会告诉你的事4.1 缓冲问题流式传输的头号杀手我敢说80% 的SSE 不生效问题都出在缓冲上。数据从服务端到浏览器中间可能经过 Nginx、网关、CDN、Service Worker任何一层开了缓冲流式就变成了批量。排查方法很简单打开浏览器开发者工具的 Network 面板找到那个 SSE 请求看 Response 是不是一次性出现的。如果是说明被缓冲了。正常情况下你应该能看到数据一块一块地追加。逐层排查的清单Nginxproxy_buffering off;和proxy_cache off;或者用响应头X-Accel-Buffering: no让后端告诉 Nginx。网关如 Spring Cloud Gateway确认没有配置响应体聚合。CDN大部分 CDN 默认会缓冲小响应需要针对text/event-stream配置不缓存。压缩中间件gzip 压缩会缓冲数据以提升压缩率SSE 场景要禁用压缩或者用flush强制输出。应用层框架某些框架的响应包装类会缓冲输出需要显式 flush。4.2 心跳机制让连接活着长连接最怕的是中间设备以为你死了然后悄悄掐断。运营商 NAT、负载均衡的空闲超时、防火墙都可能在你不知情的情况下断开连接。表现就是一段时间没数据连接就没了而且客户端可能收不到任何错误。解决办法是定期发送心跳。SSE 里心跳可以是一条注释行以冒号开头客户端会忽略它但能保持连接活跃: heartbeat注意注释行也要以\n\n结尾。心跳间隔一般设 15 到 30 秒比大多数中间设备的空闲超时短就行。我一般用 20 秒实测下来在各种网络环境下都比较稳。const heartbeat setInterval(() { res.write(: ping\n\n); }, 20000);4.3 连接数限制与 HTTP/2 的救赎HTTP/1.1 有个众所周知的限制同一域名下浏览器最多维持 6 个并发连接。SSE 是长连接会一直占着这 6 个名额之一。如果你开了多个标签页每个都建 SSE很快就耗尽了导致其他请求排队。这个问题的根治方案是上HTTP/2。HTTP/2 支持多路复用多个流共享一个 TCP 连接不再有 6 个的限制。而且 HTTP/2 对 SSE 的支持是原生的不需要任何特殊配置。如果你的服务已经上了 HTTPS大概率已经在用 HTTP/2 了可以用curl -I --http2确认一下。如果暂时上不了 HTTP/2退而求其次的做法是多个组件共享一条 SSE 连接通过事件类型分发消息。比如一个页面只建一条流服务端推送的消息里带event: chat、event: notification等类型前端按类型分发到不同组件。4.4 常见问题速查表现象可能原因排查方向前端一直转圈最后一次性显示中间层缓冲检查 Nginx、网关、压缩配置中文乱码解码未用 stream 模式TextDecoder加{ stream: true }消息被截断未按\n\n分帧检查 buffer 拼接逻辑连接频繁断开重连心跳缺失或超时太短加心跳调大超时重复收到消息自动重连导致服务端发结束事件前端主动 close多标签页后请求卡住HTTP/1.1 连接数限制升级 HTTP/2 或共享连接用户关闭页面后服务端仍在跑未监听断开事件加onCompletion或req.on(close)报错 stream disconnected before completion: idle timeout空闲超时加心跳检查代理超时配置最后一行那个报错是很多用第三方 AI 服务的同学会遇到的。它通常不是你的代码问题而是中间某段链路可能是服务商的网关在空闲一段时间后主动断开了流。解法就是保证有稳定的心跳输出别让连接真的空闲。5. 进阶话题SSE 在复杂场景下的取舍5.1 断点续传Last-Event-ID 的正确用法SSE 规范里内置了断点续传机制。服务端每条消息带一个id浏览器记录最后收到的那个。重连时浏览器自动在请求头里带上Last-Event-ID: xxx服务端据此从断点继续推。听起来很美但实际用起来有个前提服务端必须能根据 ID 找回后续数据。对于 AI 对话这意味着你要把已生成的 token 缓存起来或者记录生成进度。如果模型是无状态的、每次重新生成那续传就没意义了。我的建议是短对话场景断线后直接重新生成用户体验损失不大长文档生成场景才值得做真正的续传把已生成内容存到 Redis 或数据库按 ID 索引。5.2 SSE 与 MQTT、WebSocket 的边界热词里出现了 MQTT、WebSocket、AMQP 这些协议说明大家在选型时确实会纠结。简单划一下边界。MQTT是为物联网设计的轻量级发布订阅协议适合设备数量多、网络不稳定、消息量小的场景。它需要独立的 Broker浏览器端要用 MQTT over WebSocket。如果你做的是设备监控面板MQTT 可能比 SSE 更合适因为设备侧本来就在用 MQTT。WebSocket适合双向高频交互比如协同编辑、实时游戏、聊天室。它的优势是真正的全双工劣势是需要自己处理重连、心跳、鉴权且部分老网关不友好。SSE适合服务器单向推送尤其是 Web 场景下的 AI 输出、日志、进度。它的优势是零依赖、穿透性好、自带重连劣势是单向、仅文本、HTTP/1.1 下有连接数限制。选型的核心问题是你的数据流是单向还是双向频率高不高客户端是浏览器还是设备想清楚这三个问题答案基本就出来了。5.3 安全与鉴权SSE 场景的特殊处理EventSource不支持自定义请求头这意味着你没法像普通fetch那样带Authorization。常见的解法有几种。一是用 Cookie 鉴权浏览器会自动带上同域的 Cookie服务端从 Cookie 里读会话。这是最简单的方案但要注意跨域场景下 Cookie 的SameSite和withCredentials配置。二是用 URL 参数传 token比如/stream?tokenxxx。简单但有风险token 会进服务器日志、浏览器历史。如果要用token 应该是一次性的、短时效的。三是用fetch替代EventSource这样就能自由设置请求头了。这也是我推荐生产环境用fetch方案的原因之一。注意无论哪种方案SSE 连接建立后是长连接鉴权只在建立时做一次。如果会话在连接期间过期服务端应该主动关闭连接让客户端重新鉴权。6. 我踩过的几个真实坑第一个坑是压缩中间件。项目里配了 gzip所有响应都压缩。SSE 流被压缩后数据攒在压缩缓冲区里出不来前端一直转圈。排查了半天才想到是压缩的锅。后来针对text/event-stream单独关掉压缩问题解决。这个坑的教训是流式响应和压缩天然冲突因为压缩需要攒数据才能提升压缩率。第二个坑是负载均衡的空闲超时。云厂商的负载均衡默认空闲超时是 60 秒而我们的心跳设的是 90 秒结果连接总是在心跳发出前被掐断。表现是每隔一分钟左右就断一次前端频繁重连。把心跳改成 20 秒后彻底稳定。教训是心跳间隔一定要小于链路上最短的那个超时时间而且要留足余量。第三个坑是**SseEmitter的线程安全**。多个线程同时往一个 emitter 写数据偶尔会抛异常或者数据错乱。后来改成单线程串行发送或者用队列加锁问题消失。教训是emitter 不是线程安全的并发写要自己保证串行。第四个坑是前端 buffer 拼接。一开始没做pop()保留不完整片段导致消息偶尔被截断尤其是网络抖动时。加上 buffer 逻辑后再没出现过截断。教训是流式数据的边界不保证对齐必须自己维护缓冲区。7. 一个可以直接抄的最小可用方案如果你现在就要落地一个 SSE 流式输出我建议的最小方案是这样。后端用 Spring 的SseEmitter超时设 5 分钟响应头带X-Accel-Buffering: no每条消息带自增 ID结束发一个event: doneonCompletion里取消生成任务。心跳用 20 秒的注释行。前端用fetchReadableStreamTextDecoder带stream: truebuffer 按\n\n分帧并保留尾段用AbortController支持停止收到done事件主动关闭。Nginx 配置里针对这个路径关掉proxy_buffering和proxy_cache心跳间隔小于负载均衡超时。这套方案我在几个项目里用过覆盖了 AI 对话、实时日志、任务进度三种场景稳定性没问题。真正需要额外处理的无非是断点续传和更复杂的鉴权那些属于锦上添花先把基础跑通再说。流式传输这件事难的不是写代码而是理解数据在链路上每一段的行为。你把缓冲、心跳、分帧、断开感知这四件事想明白了剩下的都是体力活。
返回列表