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

资讯详情

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

SSE还是WebSocket?实时推送技术选型与生产环境实践指南

SSE还是WebSocket?实时推送技术选型与生产环境实践指南 1. 先别急着上 WebSocketSSE 到底解决什么问题1.1 拆需求大多数实时只是单向推送先说一个几乎每个团队都会经历的场景。产品让你做一个消息中心要求新消息到达后页面要在几秒内弹出来。你一听实时第一反应就是上 WebSocket然后开始调研连接池、心跳、断线重连、消息协议、鉴权握手……需求规格越写越长排期从两天变成了一周。但你冷静下来把需求拆开看会发现绝大多数所谓的实时只是单向的服务器有事要告诉浏览器浏览器基本不回话。比如行情价格变动、系统告警推送、任务进度刷新用户没有在页面上和服务器进行高频对话。这种场景用 WebSocket属于杀鸡用了牛刀还给自己惹了一身麻烦。轮询的问题其实更直观。短轮询是定时发请求明明没有新数据也要白白占用一个 HTTP 请求和一次数据库查询长轮询稍微聪明一点服务端把请求挂住有数据才返回但每次返回后都要重新发起连接两次连接之间存在天然的空窗期断线重连还得自己实现。SSEServer-Sent Events的思路完全不同它允许服务端通过一个普通的 HTTP 响应把数据按事件流的方式持续写给客户端。浏览器端用原生的EventSource接口订阅不需要第三方库也不需要自定义协议。很多人以为 SSE 是新技术其实它是 HTML5 标准里的老成员只是这几年被 AI 流式输出重新带火了。1.2 短轮询、长轮询、WebSocket、SSE 的对比我把这几种方案的差别整理成了一张表选型的时候直接对着看维度短轮询长轮询WebSocketSSE传输方向客户端主动拉取客户端主动拉取双向实时服务端单向推送底层协议HTTPHTTP独立升级协议基于 TCPHTTP自动重连无无需自己实现EventSource 内置二进制支持取决于接口取决于接口支持仅文本可传 base64代理/防火墙友好度高高需要额外配置高但需处理超时和缓冲服务端复杂度低低高低典型场景低频状态查询准实时提醒聊天、游戏、协同编辑行情、通知、AI 流式输出这里最容易被忽略的是自动重连这一行。WebSocket 的断线重连需要你手写还得考虑心跳、连接状态恢复、消息补发而 SSE 协议的EventSource在遇到连接断开时会自动重连并且通过Last-Event-ID头把上次收到的消息编号带给服务器天然支持断点续传。光这一条就能省掉一大半连接管理的代码。1.3 我的选型判断标准我自己的判断标准很简单如果客户端基本不需要主动给服务器发消息或者客户端要发的内容可以独立走普通 HTTP 接口比如点击、提交表单而实时性要求只存在于服务端主动通知这个方向那就优先用 SSE。反过来如果场景是聊天室、多人协作编辑、联机对战游戏这种高频双向交互才真正轮到 WebSocket。还有一种情况是项目本身状态同步非常重双向消息都要保证可靠投递WebSocket 加上一整套消息协议是合理的。但大量内部管理系统、营销活动页、数据看板真的用不上这么重的东西。我见过太多团队一上来就默认 WebSocket等接入到一半才发现要维护连接状态、心跳、重连、鉴权工作量全部压在客户端和服务端两边而 SSE 用半小时就能跑通稳定性和可调试性都不差。2. 拆开 HTTP 响应看 SSEevent stream 的格式就是全部真相2.1 响应头text/event-stream 和它的小伙伴们SSE 的技术含量其实很低低到有点反直觉它没有新协议就是一个普通 HTTP 响应只是Content-Type变成text/event-stream并且响应体不是一次性返回而是分块持续输出。一个最基础的 SSE 响应长这样HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: no : connected data: hello world四个头里Content-Type是硬性的浏览器靠它判断该走事件流解析逻辑Cache-Control: no-cache防止中间代理把流缓存起来Connection: keep-alive保证连接不随手关闭。X-Accel-Buffering: no不是 SSE 协议标准但它太重要了后面讲 Nginx 的时候会专门说。它是给 Nginx 看的指令告诉 Nginx 不要对这个响应做缓冲否则事件会攒够一大块才转发给客户端实时性直接归零。2.2 消息字段data、event、id、retry、注释行SSE 的消息体是纯文本每一行是一个字段字段名和值之间用冒号分隔消息与消息之间用一个空行隔开。规范里定义了这么几个字段字段作用说明data消息内容可以有多行多行内容会用换行符拼接event事件类型客户端用addEventListener监听默认是messageid事件编号断线重连时客户端会把它放在Last-Event-ID请求头上带回retry重连间隔毫秒连接断开后多久尝试重连:开头注释行客户端忽略常用来做心跳一个稍微完整的事件流示例: 连接建立成功 event: connected data: {userId: 1024} data: {text: 第一行} data: {text: 第二行} id: 42 event: progress data: {percent: 100} retry: 5000这里要注意两点第一多个data行会被浏览器自动用\n拼在一起所以上面第二条事件客户端收到的data是{text: 第一行}\n{text: 第二行}如果按 JSON 解析会失败必须自己约定好格式通常一条事件里只放一个data行最省心。第二id和event可以放在同一条消息里id不会出现在data里它是独立元信息。2.3 浏览器端解析规则以及容易写错的边界浏览器解析事件流时有一套严格的规则我从实践里总结几个关键点消息以空行结束。服务端在每条数据后必须写两个\n也就是一个空行否则浏览器会一直等。未识别的字段会被忽略。这是 SSE 的向前兼容设计将来规范加新字段老客户端不会崩。整条事件流必须是 UTF-8 编码没法传二进制。要传图片、文件之类的数据得先做 base64代价是体积膨胀一般不建议用 SSE 传大对象。行尾兼容\n、\r\n和\r。多数服务端框架会自动处理但如果你用原生 socket 写最好统一用\n。以冒号开头的行为注释浏览器会直接丢弃。这个特性免费送给我们一个心跳方案后面第 5 节重点展开。理解 SSE 的格式本质就是理解一条长 HTTP 响应里不断追加文本块每块文本遵循简单的字段约定。这一点想通了后面服务端怎么写、代理怎么配、断连怎么排查全都有了抓手。3. 服务端落地Node.js 与 FastAPI 两种写法的核心差异3.1 Node.js 原生写法一个可广播的最小实现Node.js 的http模块天然支持流式响应写 SSE 非常顺手。我拿一个最简单的定时广播例子说明const http require(http); const clients new Set(); const server http.createServer((req, res) { if (req.url ! /events) { res.writeHead(404); res.end(); return; } res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }); res.write(: connected\n\n); clients.add(res); req.on(close, () { clients.delete(res); console.log(客户端断开当前连接数:, clients.size); }); }); // 每秒给所有在线客户端广播一条消息 setInterval(() { const payload { time: Date.now(), value: Math.random() }; for (const res of clients) { res.write(data: ${JSON.stringify(payload)}\n\n); } }, 1000); server.listen(3000);这段代码的核心是用一个Set保存所有未断开的响应对象广播时遍历Set挨个write。这里最有必要强调的逻辑是req.on(close)清理浏览器关页面、网络断开、代理超时断开都会触发这个事件。如果不删除clients里的响应对象断开的连接还会被反复write积累久了连接数、内存和定时器全都会出问题。我把资源清理放在这个位置是想让这块逻辑一上来就跟着连接生命周期走而不是等出故障再补。3.2 FastAPI 写法异步生成器与生命周期Python 后端我用 FastAPI 比较多它的StreamingResponse配合异步生成器写 SSE 也很干净from fastapi import FastAPI from fastapi.responses import StreamingResponse import asyncio import json import time app FastAPI() async def event_stream(): yield : connected\n\n while True: payload {time: time.time(), value: __import__(random).random()} yield fdata: {json.dumps(payload)}\n\n await asyncio.sleep(1) app.get(/events) async def events(): return StreamingResponse( event_stream(), media_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }, )这个写法的生命周期管理比 Node 省心一些当客户端断开时Starlette 会取消异步生成器event_stream()内部的finally代码块会被执行适合做清理工作。但有一点要注意while True里尽量不要放 CPU 密集的同步计算会卡住事件循环导致整个 FastAPI 应用的其他请求变慢。真实场景里我一般用asyncio.Queue把业务事件投递给这个流协程而不是在生成器里直接算业务结果。3.3 服务端三个常见坑连接泄漏、缓冲、异常日志踩过几次坑之后我总结出服务端最容易犯的三个错误。第一个是连接泄漏上面 Node 例子里已经强调过了。第二个是响应被中间层缓冲。如果你的服务前面有 Nginx 或者云负载均衡而你没有设置X-Accel-Buffering: no数据会攒到一定大小才往客户端推实时性完全丢失。判断方法很简单看客户端是不是隔一段时间突然收到一大坨数据而不是一条一条地收。第三个是客户端异常断开时的日志轰炸。浏览器直接关页面服务端写入会触发ECONNRESET之类的异常如果每条都记 error 日志日志系统会被刷爆。正确的做法是把这类异常降级为 warn 或 debug同时触发连接清理逻辑。4. 客户端接入EventSource 帮你自动重连也悄悄埋了几个坑4.1 EventSource 基础用法监听、事件类型与状态机客户端接入 SSE 的体验好到不像前端技术一个构造函数就能建立连接const es new EventSource(/api/events); es.onopen () { console.log(SSE 连接已建立); }; es.onmessage (event) { const data JSON.parse(event.data); renderData(data); }; es.onerror () { // 连接异常EventSource 会自动重连 console.log(连接异常当前状态:, es.readyState); }; // 监听自定义事件类型对应服务端发来的 event: notice es.addEventListener(notice, (event) { showNotice(JSON.parse(event.data)); });es.readyState是理解连接状态的关键它有三个值CONNECTING0正在连接或正在重连、OPEN1连接已建立、CLOSED2已被手动关闭。注意onerror触发并不代表连接彻底失败大多数情况下是网络抖动或代理断连EventSource 会按协议自动进入重连流程。4.2 自动重连和 Last-Event-ID白给的能力但很多人没用上EventSource 最值钱的内置能力是自动重连。连接断开后浏览器会按照服务端最后一次给出的retry字段计算等待时间没收到retry就用浏览器默认值一般 3 秒左右各浏览器略有差异。重连时浏览器会自动带上一个Last-Event-ID请求头值来自上一次收到的id字段。也就是说只要服务端在下发每条消息时带上id客户端断线重连后服务端就能从Last-Event-ID知道客户端收到哪一条了把缺口补上即可。这个能力是协议白送的但很多团队并不知道服务端压根没写id字段结果一断线就丢消息。我在第 6 节会专门讲续推的完整实现。4.3 需要 POST 或自定义 Header 时fetch ReadableStream 手动解析EventSource 有两个硬限制只能用 GET不能自定义请求头。如果接口需要POST请求体或者要带Authorization头EventSource 就无能为力了。这是 AI 应用里最常见的场景——对话参数必须放请求体里不可能拼成 URL。解决办法是用fetch加ReadableStream手动解析 SSE 格式const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer token, }, body: JSON.stringify({ message: 你好 }), }); const reader resp.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // SSE 事件之间用空行分隔按 \n\n 切块 const events buffer.split(\n\n); buffer events.pop(); // 最后一块可能不完整留到下次 for (const raw of events) { const dataLine raw.split(\n).find((line) line.startsWith(data:)); if (dataLine) { const payload dataLine.slice(5).trim(); if (payload [DONE]) continue; handleMessage(JSON.parse(payload)); } } }这里最关键的细节是buffer.split(\n\n)之后要把最后一个切块放回 buffer因为网络包边界不可控一条 SSE 消息可能被拆成两个 chunk 到达也可能一个 chunk 里包含多条消息。健壮的解析器就是要处理这种半包和粘包的情况。5. 生产环境头号杀手idle timeout 断连与心跳保活5.1 stream disconnected before completion 这句日志到底在说什么如果你在日志里见过这句报错——stream disconnected before completion: idle timeout waiting for sse——恭喜你踩中了 SSE 生产环境最经典的一个坑。这句话在很多 SDK 和网关日志里都会出现它描述的事实是连接建立之后有一段比较长的时间没有任何数据流动中间某个网络设备判定这条连接空闲主动把它断掉了。这个坑几乎无法靠改业务代码规避因为 SSE 连接本身就是平时没消息、有消息才推的。尤其是行情、告警这类低频推送场景一个连接建立之后可能几十秒甚至几分钟一条数据都不发正好撞上各种中间层的空闲超时。各层设备的空闲超时默认值差异很大Nginx 作为反向代理时proxy_read_timeout默认是 60 秒云厂商的负载均衡空闲超时通常也是 60 秒左右一些企业防火墙、NAT 网关的超时时间可能更短。任何一个环节掐断连接对客户端的表现都是一样的——正在等待的下一条消息永远不来。5.2 定位断连环节用两条 curl 排除应用层排查这类问题我习惯先用 curl 做两层探测把嫌疑范围快速缩小。第一层绕过所有代理直连源站。假如源站跑在本地 3000 端口# 直连源站观察连接能否存活超过空闲超时时间 curl -N --max-time 120 http://127.0.0.1:3000/events如果直连 120 秒后仍然在不断收到数据说明应用层本身没问题。第二层走完整链路访问域名# 经过 Nginx / 负载均衡访问观察是否在固定时间点断掉 curl -N --max-time 120 https://your-domain.com/api/events如果第二条在某个固定秒数比如正好 60 秒断开而第一条没事答案基本就锁定了断连发生在代理或负载均衡这一层。接下来去翻 Nginx 的错误日志和访问日志重点看断连时间点前后有没有对应的连接关闭记录如果用了云负载均衡去控制台把空闲超时时间调大再测一轮就能确认。5.3 心跳保活SSE 注释行的正确用法定位到问题之后最稳妥的解决方案不是把各家超时时间无限调大而是让连接永远不空闲。做法很朴素服务端周期性发送 SSE 注释行比如每 20 秒发一个: ping。为什么注释行能保活因为 SSE 解析规则里以冒号开头的行会被浏览器忽略不影响事件流语义但对所有中间网络设备来说它就是普通的字节流足以证明连接仍然活跃从而刷新空闲计时器。注释行等于用零业务成本的流量换来了连接的生命周期。Node.js 服务端的心跳实现就是在已有的连接代码里加一个定时器res.write(: connected\n\n); // 每 20 秒发一次注释行低于所有中间层的空闲超时 const heartbeat setInterval(() { res.write(: ping\n\n); }, 20000); req.on(close, () { clearInterval(heartbeat); clients.delete(res); });心跳间隔怎么定原则是小于链路上最短的空闲超时时间同时留足余量。Nginx 默认 60 秒云负载均衡默认 60 秒我一般取 15 到 25 秒。不要卡在 55 秒这种极限值因为中间可能有多层代理每一层都有各自的计时器某一跳网络抖动多消耗几秒就会出现连锁断连。移动端场景还要考虑耗电心跳越频繁越费电15 到 20 秒是一个工程上的平衡点。5.4 Nginx 与云负载均衡的超时配置心跳是通用解决方案但代理层的参数也建议同步调优两者配合才是双保险。Nginx 反向代理 SSE 的标准配置长这样location /api/events { proxy_pass http://app_upstream; proxy_http_version 1.1; proxy_set_header Connection ; # 核心关闭缓冲保证事件实时转发 proxy_buffering off; proxy_cache off; # 长连接超时调大 proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_buffering off的作用和源站的X-Accel-Buffering: no是一样的都是避免 Nginx 攒包。两者只要配一个就生效但源站主动声明这个头更通用因为以后换别的代理、别的团队接手业务代码里的声明不会丢。云负载均衡这边AWS、阿里云、腾讯云都有对应的空闲超时参数有的叫 Idle Timeout有的叫会话保持超时。在控制台把它调大到 300 秒以上即可。但要记住调大超时不是一劳永逸客户端网络切换、服务器重启、负载均衡节点迁移照样会导致连接断开所以心跳必须作为长期方案保留。用表格总结一下典型症状和对应处理症状常见原因处理方式固定 60 秒左右断连Nginxproxy_read_timeout默认值调大超时 服务端心跳日志出现 idle timeout waiting for sse网关或云负载均衡空闲超时调大网关超时 心跳客户端不是实时收数据而是一坨一坨收到代理层缓冲未关闭关缓冲 设置X-Accel-Buffering: no断线后消息丢失服务端没发 id 或没做续推实现 Last-Event-ID 续推见第 6 节5.5 顺手治理服务端资源泄漏heartbeat 定时器加进去之后如果不处理连接关闭泄漏问题会被放大每条断开的连接还有一个定时器在跑res.write写到一个已断开的 socket 上错误堆积内存上涨。所以每次心跳实现必须绑定连接的清理逻辑。判断浏览器是否真的断开的可靠事件是req.on(close)它在连接关闭时必然触发不管关闭原因是什么。在回调里clearInterval、从clients集合中删除该响应对象这几行代码是长连接服务绝对省不掉的。另外如果服务端在写入时报ERR_STREAM_DESTROYED之类的错误说明连接已经断了直接做同样的清理即可不需要计入业务错误。6. 生产环境进阶续推、鉴权与多路复用6.1 Last-Event-ID 续推让断线不再丢消息SSE 协议给断线续传留了官方通道每条消息带id客户端重连时自动在请求头里带上Last-Event-ID。服务端拿到这个值后把缺口区间的消息重新推一遍客户端就能无缝衔接。我在 Node 里实现过一个轻量版核心逻辑是服务端维护一个最近 N 条消息的内存环形缓冲const history new Map(); // key: 用户标识, value: 消息数组 const MAX_HISTORY 100; function appendEvent(userKey, eventId, payload) { const list history.get(userKey) || []; list.push({ id: eventId, payload }); if (list.length MAX_HISTORY) list.shift(); history.set(userKey, list); } function resume(userKey, lastId, res) { const list history.get(userKey) || []; for (const item of list) { if (item.id lastId) { res.write( id: ${item.id}\nevent: ${item.payload.type}\ndata: ${JSON.stringify(item.payload.data)}\n\n ); } } } // 建立连接时读取请求头 const lastId Number(req.headers[last-event-id] || 0); resume(userKey, lastId, res);我需要提醒一点内存环形缓冲只适用于单实例部署一旦服务有多台机器某台机器的内存里可能根本不存在客户端要找的那几条消息。生产环境的多实例续推通常要把事件写到 Redis Stream 或者消息队列里重连时按Last-Event-ID去拉取。这部分的复杂度取决于你的消息可靠性要求不是每个系统都需要但要知道天花板在哪里。6.2 鉴权方案对比Cookie、短令牌与手动 fetchEventSource 不能自定义 Header 这个限制把很多做惯了 REST API 鉴权的人卡住了。实际可行的方案有三个。方案一是 Cookie 鉴权。EventSource 在同源场景下会自动带上 Cookie配置withCredentials后跨域也能带。项目如果已经用了基于 Cookie 的会话体系这是侵入最小的方案。方案二是 URL 短令牌。把临时令牌拼在查询参数里new EventSource(/api/events?tokenabc123)。要注意的是 URL 会被写进访问日志令牌一旦泄露就等于泄露了数据流所以令牌必须短时效、频繁轮换并且全程 HTTPS。不建议用长期有效的 API Key 走这个方案。方案三是服务端代理转发也是 AI 应用里最常用的。前端用普通 HTTP 接口把带鉴权的请求发给自己的后端后端再携带真正的密钥去请求上游服务然后把上游的事件流转发给浏览器。这样浏览器和上游永远没有直接接触密钥只存在于服务端环境变量里。6.3 多路复用一条连接推多种事件避开浏览器连接数上限浏览器对 HTTP/1.1 下每个域名的并发连接数限制大约是 6 条。假设一个后台页面同时打开行情、告警、在线人数三个 EventSource再算上页面本身的静态资源请求很快就会触顶后面排队的请求全部阻塞。所以我的实践是一个页面只维护一条 SSE 长连接服务端用event:字段区分业务类型客户端用addEventListener分别订阅。比如event: price data: {symbol: BTC, price: 43000} event: alert data: {level: warning, message: 磁盘使用率超过 90%} event: online data: {count: 128}客户端对应es.addEventListener(price, (e) renderPrice(JSON.parse(e.data))); es.addEventListener(alert, (e) showAlert(JSON.parse(e.data))); es.addEventListener(online, (e) updateOnlineCount(JSON.parse(e.data)));这样一条连接承载多种语义连接数占用降到最低服务端的连接管理也简单。HTTP/2 下浏览器多路复用能力增强连接数限制不再那么紧张但老系统和某些企业网络环境仍然跑在 HTTP/1.1 上不能默认所有人都有 HTTP/2。7. AI 流式输出SSE 正在成为 LLM 应用的事实标准7.1 为什么 LLM 服务商普遍选了 SSE这两年 AI 应用爆发SSE 突然从老技术变成了当红炸子鸡原因是 LLM 的流式输出需求恰好和 SSE 的特性完全吻合。大模型生成回答是逐 token 产生的从用户点击发送到回复完整生成中间可能持续几秒甚至几十秒。如果让用户干等完整结果体验非常差如果前端定时轮询生成状态既浪费请求又增加延迟。而 SSE 天然适合这种服务端一段一段往外吐的场景首包延迟低协议简单所有 HTTP 基础设施都认识它。OpenAI 兼容格式的接口用stream: true开启流式输出后返回的就是text/event-stream。做 AI 应用的前端甚至不用引 SDK一个fetch加一个ReadableStream就能消费。7.2 一个最小转发示例把上游 AI 流透传给浏览器如果你的服务要聚合多个模型厂商或者想隐藏上游地址通常需要做一层转发。核心思路是浏览器通过普通 POST 带参数过来后端替它去请求上游 LLM 接口然后把上游data:行里的增量内容改写成自己的事件格式再写入 SSE 响应const http require(http); const { Buffer } require(buffer); http .createServer(async (req, res) { if (req.url ! /chat) { res.writeHead(404); res.end(); return; } res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }); // 假设这里是上游 LLM 的流式接口 const upstream await fetch(https://api.llm.example/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.LLM_API_KEY}, }, body: JSON.stringify({ model: gpt-4o-mini, stream: true, messages: [{ role: user, content: 你好 }], }), }); const reader upstream.body.getReader(); const decoder new TextDecoder(); 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) continue; const payload line.slice(5).trim(); if (payload [DONE]) { res.write(event: done\ndata: {}\n\n); continue; } try { const json JSON.parse(payload); const token json.choices?.[0]?.delta?.content || ; if (token) { res.write(event: delta\ndata: ${JSON.stringify({ token })}\n\n); } } catch (_) { // 忽略无法解析的片段保持流不中断 } } } res.end(); }) .listen(3000);这段代码没有处理客户端断开时的清理实际生产里必须加上前面反复强调的 close 清理逻辑否则每次用户刷新页面都会给上游留一个半开的消费连接。7.3 AI 场景的事件格式设计与思考中的保活问题AI 流式输出的数据结构比普通 SSE 复杂一些我建议一开始就约定好事件类型event: start data: {sessionId: s_1024} event: reasoning data: {content: 正在拆解问题...} event: delta data: {token: 你好} event: delta data: {token: 世界} event: done data: {usage: {totalTokens: 128}}start用于建立会话上下文reasoning用于展示模型推理过程delta是增量内容done携带最终元信息。这个设计对前端非常友好聊天界面可以一边展示灰色推理文字一边流式渲染正式回答。最后再说一个 AI 场景特有的坑某些推理模型在思考阶段可能几十秒不输出任何 token如果链路里恰好有严格的空闲超时连接就会被误杀。解决方式和第 5 节完全一样——在上游没有增量数据时服务端也要照常往客户端发: ping注释行保持连接活跃。这条经验来自我在真实项目里被坑过一次之后的反省心跳不只是给普通 SSE 用的AI 流式转发同样需要而且因为生成过程不可控它反而更重要。我在实际项目里越来越偏爱 SSE 还有一个隐性原因全链路都是标准 HTTP出了问题可以用 curl 直接复现用 tcpdump 抓包也能一条条看懂完全不用去理解 WebSocket frame 的 mask、opcode 那套复杂结构。如果你正在做实时通知、数据看板或者 AI 流式对话这些功能我建议先按这篇文章的思路把一条最简链路跑通再考虑要不要引入 WebSocket。很多时候你会发现真正需要的其实就是一个加了心跳的 HTTP 长响应而已。
返回列表