1. 为什么大家都在聊SSE从轮询的痛点说起先抛一个场景你做了一个数据大屏后端每秒钟产生一批新数据前端需要实时刷新展示。第一反应可能是用定时器每3秒调一次接口把最新数据拉回来。这个方案叫轮询实现确实简单但问题也很明显——服务器被无效请求轰炸大部分响应都是没变化数据越多、刷新越快压力越大而且延迟不可控3秒轮询意味着最差要等3秒才看到新数据。我三年前第一次接手实时推送需求第一反应也是轮询直到某天线上环境突发流量数据库连接数被打满排查下来罪魁祸首就是那一堆高频轮询请求。那次事故之后我开始认真研究长连接方案最终选定了SSE也就是Server-Sent Events服务端推送事件。SSE和WebSocket常被拿来对比但两者定位完全不同。WebSocket是双向全双工通信客户端和服务端都能随时发消息适合聊天室、多人协作这种需要高频互动的场景。SSE则是单向通道只有服务端能主动推数据客户端只能接收。单看这一点SSE似乎功能更弱但恰恰是这个单向特性让它变得极其简单可靠——底层就是普通的HTTP协议没有复杂的握手升级流程没有专门的协议解析前端用浏览器原生API就能接入。适合SSE的场景其实比我最初想象的要多数据大屏实时刷新、股票行情展示、服务器日志流式输出、AI对话逐字返回甚至后台任务的进度条推送这些都只需要服务端往客户端推数据客户端几乎不需要回传用SSE天然合适。我记得有次给某实验室做图像处理Demo服务端跑一段耗时的识别算法需要把进度和中间结果实时告诉前端。当时考虑过WebSocket但要维护心跳、处理断线重连还要在网关层做协议升级的兼容配置。换用SSE之后服务端一行响应头前端两行代码DEMO直接就跑通了。从那以后我对SSE的态度就是能用HTTP解决的问题绝不再引入新协议。本文会从SSE的原理讲起然后给出一套可落地的实现方案包括服务端封装、前端接入、事件流格式细节以及我在真实项目中踩过的坑。看完你至少能回答三个问题SSE到底是怎么工作的代码怎么写才算规范上线之后容易出哪些问题2. SSE原理拆解一次HTTP请求永不结束的响应2.1 从响应头说起Content-Type的秘密SSE最核心的机制说穿了就是服务端把一个HTTP请求的响应体保持打开状态不关闭连接然后按特定格式往里面持续写数据。客户端不用发新请求就能源源不断收到服务端的内容。要让浏览器识别这是一条SSE流服务端必须在响应头里声明两件事。第一件事是Content-Type必须设置为text/event-stream这是SSE专用的MIME类型浏览器看到这个类型就知道该按SSE的方式解析数据。第二件事是必须关闭chunked传输编码的缓冲或者说禁用代理层面的缓冲用Cache-Control: no-cache告诉所有中间环节别缓存这份响应否则数据会被攒在一起客户端收到的是延迟到达的整包推送的实时性就没了。我第一次在Nginx后面配SSE就吃过这个亏。服务端明明每200毫秒推一条消息前端却要等好几秒才刷一批出来打开浏览器的网络面板一看响应体是五花八门的乱码其实是Nginx把缓冲区攒满了才吐出来。后来在location里加上proxy_buffering off问题立刻解决。HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive这四行响应头就是SSE的全部协议基础。没有升级握手没有帧协议没有二进制编解码普通HTTP响应而已。这也是SSE最大的个人体验点它不是一个新协议而是对HTTP协议的一种无限响应用法。2.2 数据帧格式data、id、event、retry响应头搞定之后服务端要往响应体里写数据。SSE的数据格式是纯文本的UTF-8每一行代表一个字段每个事件由若干行组成事件之间用空行分隔。核心字段有四个。第一个是data这是最重要也最常用的字段代表事件携带的数据内容可以连续写多行每行都会被客户端拼接成一条完整消息中间换行符保留。第二个是id用于标记事件序号客户端断线重连时会自动带上Last-Event-ID头服务端看到这个头就可以从断点继续推送这是SSE实现断点续传的关键。第三个是event用于自定义事件类型默认事件叫message前端监听message事件就能收到数据如果设置了event字段则前端需要监听对应名字的事件。第四个是retry告知浏览器断线后每隔多少毫秒自动重连不写就默认3秒。一个完整的消息体长这样id: 101 event: progress data: {percent: 23, message: 正在识别图片...} id: 102 event: progress data: {percent: 45, message: 正在提取特征...}注意每条消息之间必须用一个空行分隔这是SSE协议的语句分隔符相当于自然语言里的句号。我见过不少新手忘记加空行结果服务端输出一大堆data:xxxdata:xxx粘在一起前端EventSource解析后只能拿到一条被拼接的消息半天查不出原因。浏览器每收到一个空行就会触发一次事件派发。所以按什么频率推数据本质上就是按什么频率写空行。如果希望一条消息包含多行文本内容就连续写多个data字段浏览器会用换行符把它们拼在一起。2.3 客户端原生APIEventSource的正确打开方式服务端原理清楚了客户端接入几乎是白送的功能。浏览器内置EventSource类代码量极简const source new EventSource(/api/events); // 监听默认的message事件 source.onmessage function (event) { const data JSON.parse(event.data); console.log(收到推送:, data); }; // 监听自定义事件类型 source.addEventListener(progress, function (event) { const data JSON.parse(event.data); console.log(进度更新:, data.percent); }); // 监听错误注意断线会自动重连 source.onerror function (err) { console.warn(连接异常等待自动重连...); }; // 不再需要时手动关闭 source.close();EventSource自带自动重连机制断线之后浏览器会按照服务端retry字段指定的时间间隔自动重新建立连接不需要自己写重连逻辑。这一点比用fetch做长轮询舒服太多。不过EventSource有个硬性限制只能发起GET请求不能自定义请求头。这意味着没法在连接建立时带Authorization头做鉴权也没法走自定义协议。生产环境如果要带token一种方案是在URL上拼查询参数另一种方案是用fetch配合ReadableStream自己实现一个SSE客户端。后面落地部分我会详细讲这两种方案的取舍。还有一个容易忽略的点EventSource对跨域支持是有限制的。服务端必须明确返回Access-Control-Allow-Origin响应头并且前端必须用withCredentials模式才能携带Cookie。如果前后端不同域就绕不开CORS的配置这一条在排错时经常成为拦路虎。2.4 剖析SSE与WebSocket的定位差异很多初学者会纠结到底该用SSE还是WebSocket我的判断标准很简单通信是不是单向的如果是优先SSE。WebSocket解决的是全双工高频交互问题它需要先通过一次HTTP请求完成101协议升级之后双方就在同一个TCP连接上互相发送二进制或文本帧。功能确实强大但代价也很明显服务端要维护连接状态要做心跳保活要处理半开连接中间经过Nginx还要单独配置升级头如果是分布式部署还要考虑消息在所有节点之间的广播。这套复杂度不是所有项目都愿意承担的。SSE就简单得多它就是把一个普通HTTP请求的响应无限拉长连接状态由HTTP框架和浏览器各自管理断线重连浏览器自动做消息格式就是纯文本服务端和客户端都能直观地看到数据长什么样排错成本极低。成本上也有差异SSE本身不要求服务端额外维护内存状态即使不推送数据连接也可以一直挂着代价仅仅是占用一个文件描述符。WebSocket则需要服务端为每个连接维护收发缓冲区和状态机内存开销要看框架实现一般都比SSE重。当然SSE的下限也很明确它不支持二进制数据所有数据都必须是UTF-8文本要想传图片或文件得先Base64编码它是单向的客户端没法在同一个连接里向服务端发控制消息只能另起普通HTTP请求。这两个特性决定了SSE不适合在线游戏、实时音视频这类场景但数据推送和AI流式输出场景SSE反而是最优雅的选择。我自己的经验法则如果需求描述里出现服务器往浏览器推数据这样的表述先不要急着引WebSocket用SSE试一下绝大多数场景都撑得住。3. 落地实现服务端完整封装与客户端接入3.1 服务端实现基础版一个计数器流先从最简单的情况入手用Node.js官方HTTP模块实现一个最原始的SSE服务把原理彻底走通。const http require(http); const server http.createServer((req, res) { if (req.url /events) { // 设置SSE响应头 res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: * }); // 立即发送一条初始消息 res.write(retry: 3000\n\n); let count 0; // 每隔1秒推一条消息 const timer setInterval(() { count 1; const payload JSON.stringify({ id: count, time: Date.now() }); res.write(id: ${count}\n); res.write(data: ${payload}\n\n); // 推满10条就结束 if (count 10) { clearInterval(timer); res.end(); } }, 1000); // 客户端断开时清理定时器 req.on(close, () { clearInterval(timer); }); } else { res.writeHead(404); res.end(); } }); server.listen(3000, () { console.log(SSE server running on port 3000); });这段代码里有几个细节值得注意。第一retry字段在首帧写入告诉浏览器断线后3秒重连你也可以不写浏览器默认就是3秒。第二客户端断开时req对象会触发close事件此时必须把定时器清掉否则服务端会一直往一个已断开的连接上写数据轻则内存泄漏重则让日志里刷满错误。用浏览器直接访问测试你会发现页面一直处于待响应状态这是正常现象因为在SSE流结束之前浏览器永远不会认为请求完成。打开控制台输入代码创建一个EventSource监听数据就开始往控制台里冒出来了。3.2 生产级服务端封装心跳、断线清理、鉴权基础版验证了原理但距离上线还差三件事心跳机制、连接生命周期管理、鉴权支持。先聊心跳。SSE连接长时间没有数据流动时中间的网络设备——负载均衡器、网关、运营商设备——可能会把空闲连接判定为僵尸连接直接切断。所以即使没有业务数据要推送服务端也应该每隔一段固定时间发送一条注释行来保持连接活跃。SSE协议里以冒号开头的行是注释行浏览器收到后会直接忽略不会触发任何事件非常适合做心跳: heartbeat 2000注释行后面也要跟空行这样浏览器会认为这是一个完整的事件块虽然在事件派发时什么都没发生但连接在应用层保持着数据流动。我一般用每15秒一条心跳的频率既不会给服务端造成压力也能绕过大多数网络设备30秒空闲断连的策略。再聊断线清理。当客户端浏览器崩溃、用户直接关闭页面或者中间网络断开时服务端不一定能第一时间感知。写入数据时如果缓冲区已满Node.js会在回调里报错这是SSE服务端最需要关注的信号。我通常的做法是监听res对象的close事件配合一个标记变量后续定时器和业务推送逻辑发现标记已置位就不再执行。let isClosed false; const heartbeat setInterval(() { if (isClosed) return; res.write(: heartbeat\n\n); }, 15000); const push (event, data) { if (isClosed) return; res.write(event: ${event}\n); res.write(data: ${JSON.stringify(data)}\n\n); }; res.on(close, () { isClosed true; clearInterval(heartbeat); // 从在线连接池中移除 connectionPool.delete(res); });最后说鉴权。对于EventSource的场景因为不支持自定义请求头要在连接建立前做身份校验最实用的方案是让前端把token放在URL查询参数里服务端在写入响应头之前先校验tokenconst url new URL(req.url, http://${req.headers.host}); const token url.searchParams.get(token); if (!token || !validateToken(token)) { res.writeHead(401); res.end(unauthorized); return; }有些同学担心token暴露在URL里不安全属实有这个风险所以建议这种方案仅用于服务端无法设置自定义头的场景并且要严格控制token有效期。如果项目有条件用fetch自建客户端那么可以走Authorization头安全性更好这也是我推荐的生产级方案后面会给出具体实现。3.3 用框架做简洁版Express和Fastify对比实际项目很少直接用原生HTTP模块框架会少写不少样板代码。Express的处理方式比较直观const express require(express); const app express(); app.get(/events, (req, res) { res.set({ Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive }); res.flushHeaders(); // 直接往响应对象上写SSE数据 let timer setInterval(() { res.write(data: ${JSON.stringify({ time: Date.now() })}\n\n); }, 2000); req.on(close, () clearInterval(timer)); });需要注意res.flushHeaders这个调用在Express里如果不主动flush框架可能不会立即发送响应头某些代理层会一直等数据达到一定量才真正发给客户端。加上flushHeaders之后响应头立刻到浏览器EventSource才能建立连接。Fastify的写法类似但性能占优它还有一个专门的插件提供了更规范的事件监听和连接管理API。Fastify原生就是异步优先架构配合大量并发长连接时吞吐表现比Express好不少。如果项目推送并发量比较大比如几千个客户端同时挂着SSE我会更倾向于Fastify。const fastify require(fastify)(); fastify.get(/events, (req, reply) { reply.raw.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive }); reply.raw.write(retry: 3000\n\n); const timer setInterval(() { reply.raw.write(data: ${JSON.stringify({ time: Date.now() })}\n\n); }, 2000); req.raw.on(close, () clearInterval(timer)); });无论用哪个框架核心逻辑都不变拿到响应对象设置正确的头然后保持连接往里面写符合SSE格式的文本。框架只是简化了路由和中间件没有改变协议本身。3.4 前端预留自定义请求头的方案fetch实现SSE客户端EventSource够用但只能GET、不能自定义头这个限制在真实项目里总会碰到。前端要带Auth token或者要提交一些查询参数EventSource就显得力不从心。好在这个问题有解基于fetch的流式读取自己写一个SSE客户端。思路是这样的fetch发起请求拿到Response对象后用response.body.getReader()获取一个可读流然后边读边解析SSE格式的文本块。每遇到一个空行就按协议解析当前累积的字段再派发事件。这个方案在Chrome、Firefox、Edge上都能稳定工作代码量也不大。async function connectSSE(url, handlers) { const response await fetch(url, { headers: { Authorization: Bearer ${localStorage.getItem(token)}, Accept: text/event-stream } }); if (!response.ok) { throw new Error(SSE connection failed: ${response.status}); } const reader response.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 blocks buffer.split(\n\n); buffer blocks.pop(); // 最后一块可能不完整留到下次 for (const block of blocks) { parseEventBlock(block, handlers); } } } function parseEventBlock(block, handlers) { const fields {}; let dataLines []; for (const line of block.split(\n)) { if (line.startsWith(:)) continue; // 注释行 const index line.indexOf(:); const field line.slice(0, index); let value line.slice(index 1); if (value.startsWith( )) value value.slice(1); if (field data) { dataLines.push(value); } else { fields[field] value; } } if (dataLines.length 0) return; const data dataLines.join(\n); const type fields.event || message; const handler handlers[type] || handlers[message]; if (handler) { handler({ data, id: fields.id, retry: fields.retry }); } }这个自建客户端的最大价值在于它能自定义请求头天然能应对鉴权需求还能控制重连策略可以加入指数退避而不像EventSource那样只能被retry字段牵着走同时可以解析更复杂的字段组合。代价是失去了EventSource的自动重连。所以在上面代码里主调用至少应该包一层递归或循环在网络异常时按策略重新连接let reconnectCount 0; async function connectWithRetry() { try { reconnectCount 0; await connectSSE(/api/events, handlers); } catch (err) { console.error(连接失败:, err); const delay Math.min(30000, Math.pow(2, reconnectCount) * 1000); reconnectCount 1; setTimeout(connectWithRetry, delay); } }指数退避的初始延迟设为1秒每次翻倍最大30秒避免客户端像疯了一样地重连把服务端打挂。这个逻辑是我在自建SSE客户端时踩过坑后总结出来的EventSource默认3秒重连看起来温柔但在服务端重启的窗口期内几千个客户端同时3秒重连一次瞬间qps高得吓人。手动控制重连节奏才能给服务端留出恢复时间。3.5 分布式部署下的推送选型直接推送与消息发布订阅单机部署的SSE很简单谁收到连接谁就推数据没有跨进程问题。但生产环境往往不止一台实例Nginx负载均衡到多台Node进程就出现一个问题如果A进程收到了一条业务消息需要推给某个客户端但那个客户端的SSE连接恰好挂在B进程上这条消息就丢了。解决办法有三种常见路线。第一种是粘性会话让Nginx按会话把同一个客户端的连接和后续请求都分发到同一台实例。配置一行ip_hash;或者用Cookie做sticky。这种方案实现成本最低但缺点是负载不均衡热门用户可能把某台实例打满其他实例空闲而且实例重启时挂在它上面的所有SSE连接都会断开。第二种是进程间广播每个实例维持一个共享的连接注册表比如存在Redis里键值对是用户ID到实例ID的映射再配合一台MQ或者Redis发布订阅做消息中枢。业务服务收到消息后发给MQ所有SSE实例订阅同一个频道各自检查消息的目标用户是不是在自己本地连接池里是就推出去。这种方案部署复杂一些但各实例互不依赖一台挂了其他实例能接住新连接。第三种是在网关层统一管理连接所有SSE请求都固定打到同一组专门跑SSE服务的节点业务逻辑和推送网关分离。推送网关从MQ消费消息并推给客户端。这种架构对团队规模和基础设施要求高但最稳。我现在的推荐是流量不大的场景直接用粘性会话加连接注册表就够了流量大到要频繁横向扩容时优先用Redis发布订阅做跨实例广播消息从业务服务到SSE网关走一次异步通道不阻塞主链路。// 伪代码示例通过Redis订阅分发SSE消息 const redis require(redis); const subscriber redis.createClient(); const publisher redis.createClient(); subscriber.subscribe(sse:channel); subscriber.on(message, (channel, message) { const { userId, event, data } JSON.parse(message); const connection connectionPool.get(userId); if (connection) { connection.write(event: ${event}\n); connection.write(data: ${JSON.stringify(data)}\n\n); } }); // 业务侧推送时只发一条Redis消息 function pushToUser(userId, event, data) { publisher.publish(sse:channel, JSON.stringify({ userId, event, data })); }要并发、要扩展就逃不开这一步。SSE本身只是个传输通道跨实例的协调不是SSE协议范畴但作为落地实现这一步迟早要面对。4. 实操过程中的关键细节与踩坑记录4.1 Nginx反代下的SSE配置要点本地开发一切正常一上服务器就各种卡顿、断流十有八九是Nginx的坑。SSE的本质是一个长响应Nginx作为反代默认会做缓冲把上游的数据攒到一定量才转发给客户端这会彻底破坏SSE的流式体验。在Nginx的server配置里必须给SSE接口加上三行location /events { proxy_pass http://backend; proxy_set_header Connection ; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; chunked_transfer_encoding off; proxy_read_timeout 3600s; }逐个解释一下。proxy_buffering off关掉缓冲让上游数据立即转发到客户端proxy_set_header Connection加上空值是为了告诉上游代理关闭了HTTP连接复用避免keep-alive导致的连接状态问题proxy_http_version 1.1是因为上游的SSE响应依赖HTTP/1.1的chunked传输特性proxy_read_timeout设置长一点默认60秒内如果没读到上游数据Nginx会主动断开这在SSE静默时间较长时是致命的。如果SSE服务端已经配置了心跳那么每隔15秒就有一条数据流经Nginxproxy_read_timeout也不会触发超时。但如果业务数据密度很低又没配心跳Nginx的60秒断开就是最常见的事故来源我排查过的一个线上问题就是这么来的——服务端明明连着客户端每80秒左右就自动重连一次。还有一个容易漏的配置是location匹配前缀。如果SSE接口路径是/api/events上面那段location /events可能匹配不到务必确认路径前缀写对别让请求落到其他location的缓冲逻辑里。4.2 连接池管理与进程退出清理生产环境里SSE连接池不能只是往一个Map里塞数据。我在实践中维护的连接池至少包含以下结构// 保存用户ID到连接对象的映射 const connectionMap new Map(); // 保存用户ID到相关信息的映射用于业务判断 const userInfoMap new Map(); // 保存连接ID到定时器的映射用于清理心跳 const heartbeatTimerMap new Map();每个连接建立时注册这三项数据关闭时三个地方同时清理。最常见的坑是只清理了connectionMap忘了清定时器结果每个断开的连接还在后台每15秒执行一次res.write长期运行下来无数个幽灵定时器会把事件循环拖到崩溃。进程退出时也要处理连接。Node.js服务重启时如果不主动关掉挂着的SSE连接客户端会一直等直到TCP层超时才报错。所以进程收到SIGTERM信号后应该遍历连接池给每个连接写一条event为shutdown的消息然后再调用res.end()关闭连接让客户端知道这是服务端主动下线可以提示用户稍后重试。process.on(SIGTERM, () { for (const res of connectionMap.values()) { res.write(event: shutdown\n); res.write(data: {reason: server restarting}\n\n); res.end(); } process.exit(0); });这个细节在很多框架的默认行为里是没有的不少服务直接杀进程导致客户端疯狂重连体验很差。4.3 数据格式规范JSON字符串与多行数据拼接SSE的data字段只是纯文本不限制内容格式。但实际项目中我强烈建议data里一律写JSON字符串前端收到后再解析成对象不要用其他分隔符自造格式。原因很简单JSON自身有结构能承载复杂对象解析失败时报错信息清晰。有个细节是数据里包含换行符的情况。如果业务数据是一个多行文本直接放进data字段里协议层面是允许的——连续多个data行在浏览器端会被拼成一个字符串中间以换行符分隔。举个例子data: 第一行 data: 第二行 data: 第三行前端收到的event.data是第一行\n第二行\n第三行。所以如果数据本身就是多行文本直接每个data写一行就行如果数据是一个JSON字符串那么JSON序列化时内部的换行符会转成\n转义不会真的出现换行一行搞定。我曾经犯过的低级错误是往data里塞了英文逗号分隔的字符串想省掉JSON解析的开销结果后端多个字段里带逗号前端切分后数据全乱了。从那以后我坚持一个原则data只承载JSON协议格式永远不做业务上的二义性。4.4 浏览器兼容性与移动端限制EventSource在桌面端主流浏览器上支持得非常好Chrome、Firefox、Safari、Edge都是原生支持。但移动端有一段历史坑要提一下iOS Safari在系统层面并不支持EventSource不过微信、部分国产浏览器的内核基于WebView做了兼容实际表现要看具体App封装的webview内核版本。如果目标用户有大量iOS Safari稳妥的做法是直接用fetch流式读取自建客户端上一节那段代码就能无缝替代EventSource。如果嫌麻烦也可以用第三方SDK做polyfill其本质也是用fetch或者XMLHttpRequest去模拟SSE但要额外引入依赖。另外还有一个现实限制HTTP/1.1下浏览器对同一域名的并发连接数上限是6个。如果页面上同时开着好几个SSE连接再加上普通资源请求很容易触发连接数上限导致后续请求一直排队。遇到这种问题的解法是把SSE接口放到独立的子域名下或者升级到HTTP/2让连接复用机制更高效。我在一个数据大屏项目里就同时开了三个SSE连接后来并到一个接口里按事件类型分发前端用一个EventSource接收全部消息瞬间解决排队问题。4.5 遇到底层断网心跳与重连的配合网络环境不稳定时服务端和客户端对连接状态的感知可能完全不同步。一个典型的例子手机锁屏后无线网络被系统挂起TCP连接其实已经半开服务端觉得连接还在客户端已经断了两边都收不到对方的信号。这种场景下服务端的心跳不仅能保持中间设备的连接活跃还是提前感知客户端死亡的探针。如果连续多次心跳写入都触发错误或者res.write的callback返回false说明缓冲区已满就要考虑主动踢掉这个连接把它从连接池里摘除释放资源。前端侧的配合是无论用EventSource还是自定义客户端都要监听错误并做重连。EventSource的onerror事件在网络异常时会触发然后自动重试。自定义客户端就必须自己实现前面提到的重连策略。我把两者的配合总结成一句话服务端主动清理半开连接客户端主动重连中间靠心跳保活这三者缺一个断网恢复后就会出现连接假死的问题。5. 常见问题速查表与排查路径为方便排查我把SSE落地过程中最高频的问题整理成一张速查表放这里供直接查用。现象可能原因排查方法解决方案前端收不到任何消息Nginx默认开启了缓冲数据被攒住看Nginx日志或关掉缓冲测试配置proxy_buffering off每条消息延迟很久才到达代理层或中间件缓冲未关闭在网络面板观察响应体是整块到达还是逐块到达检查Nginx及CDN配置关闭缓冲连接60秒左右自动断开Nginx的proxy_read_timeout默认60秒查看Nginx error log设置proxy_read_timeout并配置服务端心跳浏览器报跨域错误前后端不同域缺少CORS头看浏览器控制台具体报错服务端增加Access-Control-Allow-Origin头前端频繁重连服务端未发送心跳或中间设备断开空闲连接在服务端打印连接关闭日志开启服务端心跳控制重连频率自定义请求头无法设置使用了EventSource改用fetch方案按上文fetch实现自定义SSE客户端同一域名6个连接上限被占满页面多个SSE连接加资源请求在浏览器任务管理器查看连接数合并连接或拆分域名或升级HTTP/2服务端内存缓慢增长断开的连接未清理定时器和连接池数据打点连接池大小观察在close事件中统一清理重启服务后客户端要等很久才恢复没有优雅关闭连接检查进程退出日志捕获SIGTERM信号主动关闭连接推送数据偶尔乱码EventSource对非法UTF-8会解析失败检查业务数据编码统一以JSON字符串推送确保UTF-85.1 排查SSE问题的一条通用路径上面表格能对应到大多数场景如果问题没落在表里我建议按下面这条路径排查先看浏览器网络面板里那条连接的状态Response Headers里是否出现Content-Type: text/event-stream再用curl模拟一条长连接请求观察响应体是不是逐步吐出数据然后看Nginx日志确认有没有超时断开最后看服务端日志确认连接是否正常建立和关闭。# 用curl模拟SSE连接观察流式输出 curl -N --max-time 15 http://localhost:3000/eventscurl加-N参数表示禁用缓冲立即输出内容这样能区分是服务端没推数据还是代理层没转发。如果curl能正常看到数据而浏览器收不到问题基本锁定在代理或CORS配置上。把这条路径走一遍90%的SSE问题都能定位到具体层级剩下的就是改配置或者改代码的事了。6. 从SSE到项目全貌我的选型决策流程接手任何一个涉及实时推送的需求我的选型决策流程基本固定。先问三个问题数据流是单向还是双向消息量多大对实时性要求多精确如果单向、消息量适中、秒级延迟可接受SSE就是最优解如果双向且高频果断WebSocket如果对延迟要求极高且是二进制数据那基本只有WebSocket或者UDP方案可选。SSE还有个隐藏优势它天然兼容现有的HTTP基础设施。服务端不需要额外开端口安全策略不用单独放行监控、日志、限流都可以复用HTTP那一套。我在不少项目里甚至拿SSE替代了原本用WebSocket实现的实时通知模块代码量减少一半稳定性和排查效率显著提升。如果遇到SSE无法满足的扩展需求比如某个模块后续要支持双向聊天也不必推翻重来可以在SSE之外单独开一个WebSocket通道两者共存。架构上各司其职不搞一刀切。就我个人经验SSE最大的坑不在技术本身而在于很多团队习惯性用WebSocket没给SSE留位置。下次接到推送需求不妨先问一句这个场景真的需要双向通信吗如果答案是否定的SSE会给你一个远比想象中轻松的落地体验。最后再分享一个小技巧给SSE的data字段设计一个统一的自定义协议比如固定的JSON结构里包含type、timestamp、payload三个字段。这样前端收到任何消息都能先解析出类型再分发给不同业务模块处理后续扩展新的推送类型时前端几乎不用改接入层的代码只需要新增一个handler就够了。这个习惯帮我跨了好几个大版本迭代越用越顺手。