1. 从"轮询焦虑"说起:为什么我们需要重新理解服务端推送
做过Web实时功能的人大概都有过这种体验:页面上的数据要"实时"更新,第一反应就是开个定时器,每隔几秒发一次请求。简单粗暴,能跑通,但心里总不踏实——请求发得太密,服务器压力大;发得太疏,用户觉得卡。更尴尬的是,绝大多数请求返回的都是"没变化",白白浪费了一次完整的HTTP往返。
这个矛盾在早期做消息通知、订单状态、协作编辑这类场景时特别突出。后来大家开始用长轮询(Long Polling)来缓解:客户端发一个请求,服务端不立刻返回,而是"挂住"这个连接,等真有数据了再响应。这招确实把"空轮询"的比例降下来了,但它本质上还是"一问一答"的模型,每次收到响应后客户端还得重新发起下一次请求,一来一回的头部开销和连接重建成本始终存在。
再往后,SSE(Server-Sent Events,服务端发送事件)进入了视野。它基于普通的HTTP连接,服务端可以持续不断地往客户端"推"数据,客户端只需要监听一个事件流。配合长轮询的思路去理解,SSE其实可以看作是长轮询的"终极形态"——连接建立后不再断开,服务端想什么时候推就什么时候推。这篇内容我想把"基于长轮询的SSE"这件事从头到尾讲透:它到底解决了什么问题、底层是怎么工作的、实际落地时有哪些坑、以及我在几个项目里踩出来的经验。不管你是刚接触实时推送的新手,还是已经用过WebSocket想搞清楚SSE差异的老手,应该都能从中拿到能直接用的东西。
2. 长轮询与SSE的本质差异:一次连接模型的彻底重构
2.1 长轮询到底"长"在哪里
先把长轮询讲清楚,因为不理解它的局限,就理解不了SSE的价值。
普通轮询是客户端定时发请求,服务端立刻返回(无论有没有新数据)。长轮询的区别在于:服务端收到请求后,如果暂时没有新数据,就不立即响应,而是把这个请求"挂起",直到有新数据产生或者超时,才返回响应。客户端收到响应后,立刻再发起下一次长轮询请求,如此循环。
用一个生活化的类比:普通轮询像是你每隔5分钟去前台问一次"有我的快递吗",不管有没有都跑一趟;长轮询像是你站在前台不走,前台说"有了我叫你",你等到有快递了拿走,然后再站回去继续等。
长轮询的关键参数是超时时间。如果设得太短,比如10秒,那服务端挂10秒没数据就返回一个空响应,客户端又得重连,空转率上升;设得太长,比如5分钟,那连接资源被长时间占用,服务端能同时挂起的连接数就成了瓶颈。这个超时时间的选择,本质上是在"连接资源占用"和"空响应频率"之间做权衡。
长轮询还有一个隐蔽的问题:每次响应后客户端都要重新建立请求。这意味着每次循环都有完整的HTTP头部交换(Cookie、User-Agent、各种Header),在移动网络下这些开销相当可观。而且客户端处理响应、发起新请求之间总有间隙,这个间隙里服务端产生的数据无法及时送达,形成了"推送空窗期"。
2.2 SSE把"循环"变成了"流"
SSE的核心改变在于:一次HTTP请求,服务端保持连接不关闭,持续往客户端写数据。客户端不需要反复发起请求,只需要在一个连接上监听事件流。
从协议层面看,SSE的响应头里有一个关键字段:
Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alivetext/event-stream告诉浏览器这是一个事件流,浏览器收到后不会把它当作一次性响应处理完就关闭,而是保持连接打开,持续解析后续到达的数据。服务端每次写入的数据遵循一个简单的文本格式:
data: 这是一条消息\n\n两个换行符表示一条消息结束。还可以带event:指定事件类型、id:指定消息ID、retry:指定重连间隔。
这个模型和长轮询的根本区别在于:长轮询是"请求-响应-再请求"的循环,SSE是"一次请求,持续响应"的流。前者每次循环都有连接层面的开销和推送空窗,后者连接建立后就是一条畅通的管道。
2.3 为什么说SSE是"基于长轮询"的演进
这里要澄清一个容易混淆的点。SSE和长轮询并不是两个完全独立的东西,SSE在实现思路上继承了长轮询"服务端挂起连接等待数据"的核心思想,但把"等待-返回-重连"的循环,改造成了"等待-持续推送"的流。
可以这样理解演进路径:
| 阶段 | 模型 | 连接行为 | 主要问题 |
|---|---|---|---|
| 普通轮询 | 定时请求 | 每次请求立即返回 | 大量空请求,延迟高 |
| 长轮询 | 挂起请求 | 有数据才返回,返回后重连 | 重连开销,推送空窗 |
| SSE | 持久流 | 一次连接持续推送 | 单向,连接数受限于服务端 |
所以"基于长轮询的SSE"这个说法,准确的理解是:用长轮询"服务端主动等待并推送"的思想,通过SSE的持久连接机制来实现真正的服务端推送。它保留了长轮询对HTTP基础设施的友好性(不需要像WebSocket那样升级协议),又消除了长轮询反复重连的缺陷。
2.4 SSE与WebSocket:不是替代,是分工
很多人一提到服务端推送就想到WebSocket,觉得SSE是"低配版"。这个看法不准确。
WebSocket是全双工协议,客户端和服务端可以随时互发消息,适合聊天、协作编辑、游戏这类需要双向高频通信的场景。但它的代价是需要协议升级(从HTTP升级到WS),部分代理、网关、负载均衡设备对WS的支持并不完美,配置起来也更麻烦。
SSE是单向的(服务端到客户端),基于纯HTTP,不需要协议升级。对于"服务端推、客户端只收"的场景——比如消息通知、实时日志、股票行情、进度更新——SSE完全够用,而且部署简单得多。客户端要发数据?再发一个普通HTTP请求就行了,没必要为了偶尔的上行把整个连接搞成双工。
我在实际选型时的判断标准很简单:如果上行数据是低频的、独立的,用SSE;如果上行是高频的、和下行强耦合的,用WebSocket。大部分业务场景其实落在前者。
3. 服务端如何"挂住"连接:SSE的底层工作机制拆解
3.1 一个连接的生命周期
要理解SSE怎么工作,得先看清楚一个SSE连接从建立到关闭经历了什么。
客户端发起请求,带上Accept: text/event-stream。服务端识别出这是SSE请求后,设置响应头,然后不结束响应,保持输出流打开。在Node.js里,这意味着不调用res.end();在Java的Servlet里,意味着拿到AsyncContext并保持;在Go里,意味着持有http.ResponseWriter并定期Flush。
连接建立后,服务端每产生一条数据,就往这个流里写一段符合SSE格式的文本,然后flush。客户端浏览器收到后解析成事件,触发对应的监听器。
连接可能因为多种原因关闭:客户端主动关闭(关闭页面、调用EventSource.close())、服务端主动关闭、网络中断、超时。其中网络中断是最需要处理的,因为客户端和服务端可能都不知道连接已经断了。
3.2 心跳机制:让"挂住"的连接保持活性
SSE连接挂久了,中间的网络设备(路由器、负载均衡、反向代理)可能会认为这是一个空闲连接而把它掐掉。这是实际部署中最常见的坑之一。
解决办法是心跳:服务端定期往流里写一点数据,哪怕没有业务数据,也写一个注释行或空事件,保持连接活跃。
SSE协议里,以冒号开头的行是注释,客户端会忽略:
: heartbeat\n\n这个注释不触发任何事件,但能让连接上的数据流动起来,防止被中间设备判定为空闲。心跳间隔一般设15到30秒,具体要看中间设备的空闲超时配置。我一般设20秒,比较稳妥。
注意:心跳不能太频繁,否则会浪费带宽和CPU;也不能太稀疏,否则连接可能被掐。如果部署环境里有Nginx,默认的
proxy_read_timeout是60秒,心跳间隔必须小于这个值。
3.3 断线重连与Last-Event-ID
SSE的一大优势是浏览器原生支持断线重连。当连接意外断开时,浏览器会自动尝试重新连接,默认间隔约3秒(可以通过服务端发送retry:字段调整)。
更妙的是Last-Event-ID机制。服务端在推送消息时可以带上id:字段:
id: 1001\ndata: 订单状态更新\n\n如果连接断了,浏览器重连时会在请求头里带上Last-Event-ID: 1001,告诉服务端"我最后收到的是1001号消息"。服务端据此可以从1001之后继续推送,避免消息丢失或重复。
这个机制在消息通知、日志流这类场景里非常有用。但要注意:服务端必须自己维护消息历史或游标,浏览器只是把ID带回来,具体怎么续传是服务端的责任。如果服务端不处理这个头,那重连后就只能从当前最新开始推,中间断掉的消息就丢了。
3.4 服务端资源占用:连接数才是真正的瓶颈
SSE连接是长连接,每个连接都会占用服务端的一个文件描述符和一部分内存。这意味着服务端能支撑的并发连接数是有上限的。
一个普通的Linux服务器,默认的文件描述符限制可能是1024或65535。每个SSE连接占一个fd,那理论上限就是fd数量减去其他开销。但实际瓶颈往往不在fd,而在每个连接对应的应用层对象——比如一个Servlet的AsyncContext、一个Go的goroutine、一个Node的回调闭包,这些都有内存开销。
粗略估算:如果每个连接占用10KB内存,1万个连接就是100MB,10万个连接就是1GB。这个量级对现代服务器不算夸张,但需要合理配置。关键是不要为每个连接创建重量级对象,尽量用轻量的数据结构维护连接状态。
另外,如果用了反向代理(如Nginx),代理层也会为每个连接占用资源,而且代理的超时配置会直接影响SSE连接的存活。这部分在部署章节会详细讲。
4. 从零搭一个SSE服务:可复现的实现路径
4.1 技术选型:为什么我优先考虑Node.js或Go
实现SSE服务端,语言选择上我一般在这两个里挑:
Node.js的优势是天然的事件驱动、非阻塞IO模型,处理大量并发长连接非常合适。一个简单的SSE服务几十行代码就能跑起来,适合快速验证和中小规模场景。
Go的优势是goroutine轻量、并发能力强、部署简单(单二进制)。每个SSE连接开一个goroutine,几万连接也不在话下,适合对稳定性和性能要求更高的生产环境。
Java也可以用(Servlet 3.0+的异步支持、Spring的SseEmitter),但配置相对重,启动慢,适合已经在Java生态里的团队。Python的话,同步框架(如Flask)处理长连接会很吃力,得用异步框架(如FastAPI + asyncio),但生态和性能不如前两者。
我的建议:验证阶段用Node.js,生产环境看团队技术栈,Go和Node都是好选择。
4.2 一个最小可用的Node.js SSE服务
先看服务端。核心就是设置正确的响应头,然后保持连接不关闭,定期写数据。
const http = require('http'); const clients = new Set(); 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'); res.write('data: connected\n\n'); // 把响应对象加入客户端集合 clients.add(res); // 心跳:每20秒发一个注释行 const heartbeat = setInterval(() => { res.write(': heartbeat\n\n'); }, 20000); // 连接关闭时清理 req.on('close', () => { clearInterval(heartbeat); clients.delete(res); }); } else if (req.url === '/push' && req.method === 'POST') { // 一个简单的推送接口,实际项目里应该做鉴权 let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { const message = JSON.parse(body); broadcast(message); res.writeHead(200); res.end('ok'); }); } else { res.writeHead(404); res.end(); } }); function broadcast(data) { const payload = `id: ${Date.now()}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of clients) { client.write(payload); } } server.listen(3000, () => { console.log('SSE server running on port 3000'); });这段代码有几个关键点值得说明。
第一,retry: 3000告诉浏览器断线后3秒重连,这个值可以根据业务调整。第二,连接建立后立刻发一条connected消息,让客户端知道连接成功了,否则客户端无法区分"连接中"和"已连接但没数据"。第三,心跳用注释行,不触发客户端事件,纯粹保活。第四,req.on('close')里清理定时器和集合,防止内存泄漏——这是最容易忘的一步。
4.3 客户端:EventSource的用法与限制
客户端用浏览器原生的EventSource:
const es = new EventSource('http://localhost:3000/events'); es.onopen = () => { console.log('连接已建立'); }; es.onmessage = (event) => { console.log('收到消息:', event.data); // event.lastEventId 可以拿到消息ID }; es.onerror = (err) => { console.log('连接出错,浏览器会自动重连'); // 注意:不要在这里手动重连,浏览器会处理 };EventSource用起来很简单,但有几个限制必须知道。
限制一:只能发GET请求。EventSource不支持自定义请求方法,也不支持自定义请求体。如果需要带复杂的鉴权信息,只能通过URL参数或Cookie传递。这是个硬限制,绕不过去。
限制二:不能自定义请求头。想加Authorization头?做不到。所以鉴权一般靠Cookie(同源自动携带)或URL里的token。
限制三:跨域需要服务端配合。如果客户端和服务端不同源,服务端必须设置Access-Control-Allow-Origin,而且不能用*配合凭证(Cookie),得指定具体域名。
限制四:IE不支持。虽然现在IE基本退场了,但如果要兼容老环境,得准备polyfill或者降级到长轮询。
如果需要更灵活的控制(自定义头、POST请求),可以用fetch+ReadableStream手动解析SSE流,但那就失去了浏览器原生重连的便利,得自己实现重连逻辑。
4.4 消息格式的细节:别在换行上栽跟头
SSE的消息格式看着简单,但换行处理是个高频坑。
一条消息的格式是:
id: 123 event: update data: 第一行 data: 第二行注意最后那个空行,它是消息结束的标志。如果data的内容本身包含换行,必须拆成多个data:行,每个data:行对应内容里的一行。客户端解析时会把多个data:行用换行符拼起来。
我踩过的坑:直接把JSON字符串塞进data:,如果JSON里有换行(比如格式化过的JSON),就会破坏消息结构。正确做法是先把JSON压缩成单行(JSON.stringify默认就是单行),或者手动处理换行。
还有一个坑是中文和特殊字符。SSE的编码是UTF-8,一般没问题,但如果消息里包含\n、\r这类字符,要确保它们被正确转义或拆分,否则会提前结束消息。
5. 生产环境部署:那些文档不会告诉你的坑
5.1 反向代理的超时配置是头号杀手
本地跑得好好的SSE服务,一上生产就频繁断连,十有八九是反向代理的超时配置在作祟。
以Nginx为例,默认的proxy_read_timeout是60秒。意思是:如果Nginx在60秒内没从上游服务读到任何数据,就认为连接超时,主动断开。SSE连接如果60秒内没有数据推送(心跳间隔大于60秒,或者心跳没生效),就会被Nginx掐掉。
解决办法有两个:一是把心跳间隔设得比代理超时短(比如代理60秒,心跳20秒);二是调大代理的超时:
location /events { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; chunked_transfer_encoding off; }这里几个配置都很关键。proxy_buffering off必须关,否则Nginx会缓冲响应,数据不能实时到达客户端。proxy_http_version 1.1和Connection ''是为了保持长连接。chunked_transfer_encoding off在某些Nginx版本上能避免分块传输的问题。
提示:不同版本的Nginx对SSE的支持有差异,部署前一定要在测试环境验证。我遇到过某个版本的Nginx即使关了buffering,仍然会缓存前几KB数据的情况,最后靠升级版本解决。
5.2 负载均衡下的连接粘性问题
如果服务端是多实例部署,前面挂了负载均衡,SSE连接会被分配到某个具体实例上。这时候有个问题:推送消息时,怎么知道某个用户连在哪个实例上?
如果推送是广播式的(所有实例都推给各自连接的客户端),那没问题。但如果是定向推送(给特定用户推消息),就需要知道用户连在哪个实例。
常见方案有三种。第一种是连接注册表:每个实例把"用户ID -> 实例ID"的映射写到Redis,推送时先查Redis找到实例,再通过内部通信转发。第二种是消息队列广播:所有实例订阅同一个消息队列,收到消息后各自检查自己有没有这个用户的连接,有就推。第三种是粘性会话:负载均衡把同一用户的请求都路由到同一实例,但这对SSE来说不太可靠,因为重连时可能被路由到别的实例。
我一般用第二种(消息队列广播),实现简单,扩展性好。Redis的Pub/Sub或者专业的消息队列都行。
5.3 连接数上限与资源监控
生产环境必须监控SSE连接数。连接数突然飙升可能是客户端异常重连风暴,连接数突然下降可能是服务端出了问题。
需要关注的指标:当前活跃连接数、连接建立速率、连接关闭速率、平均连接存活时长、每条连接的内存占用。这些指标能帮你判断服务是否健康。
另外,操作系统的文件描述符限制要调大。Linux默认可能是1024,对SSE服务来说远远不够。修改/etc/security/limits.conf:
* soft nofile 65535 * hard nofile 65535同时确认服务进程的实际限制(用ulimit -n查看),有些环境里systemd服务有自己的限制配置。
5.4 客户端重连风暴的预防
当服务端重启或网络抖动时,大量客户端会同时断线,然后几乎同时重连。如果重连间隔是固定的3秒,那3秒后会有海量请求同时涌入,可能把服务端打垮。这就是"重连风暴"。
预防措施有两个。一是服务端在retry:里给一个较大的值,比如10秒,降低重连频率。二是客户端加随机抖动,在重连间隔上叠加一个随机值,把重连时间打散。原生EventSource不支持自定义重连逻辑,所以如果需要抖动,得自己用fetch实现SSE客户端。
还有一个技巧是服务端在关闭连接前发一个提示,让客户端知道是正常关闭,可以稍后再重连,而不是立刻重连。
6. 实战中的取舍:SSE适合什么、不适合什么
6.1 适合SSE的典型场景
消息通知是最典型的。服务端有新通知就推给客户端,客户端只负责展示,不需要上行。这种场景SSE几乎是完美匹配。
实时日志/监控面板也很合适。服务端持续产生日志或指标,推给前端展示。数据是单向流动的,SSE的流式特性正好。
进度更新,比如文件处理、任务执行的进度。服务端处理过程中定期推送进度百分比,客户端展示进度条。这种场景数据量小、频率适中,SSE很稳。
股票/行情推送,服务端推价格变化,客户端展示。虽然频率可能较高,但单向推送的特性让SSE比WebSocket更简单。
6.2 不适合SSE的场景
高频双向通信,比如在线聊天、协作编辑。这些场景上行数据频繁,SSE的单向特性会导致上行还得另开HTTP请求,不如直接用WebSocket。
需要二进制传输的场景。SSE是文本协议,传二进制得先Base64编码,效率低。这种场景WebSocket更合适。
对延迟极度敏感的场景。SSE基于HTTP,虽然有持久连接,但相比WebSocket的帧协议,还是有一点额外开销。不过对绝大多数业务来说,这点差异可以忽略。
6.3 一个真实的取舍案例
我之前做过一个后台任务系统,任务执行过程中需要把日志实时展示到前端。最初用的是WebSocket,但发现几个问题:一是部署时反向代理配置复杂,二是前端只需要接收日志,不需要发送,WebSocket的双工能力浪费了,三是断线重连逻辑得自己写。
后来改成SSE,服务端用Go实现,每个任务一个SSE连接,日志产生就往流里写。前端用EventSource接收,断线重连浏览器自动处理。部署上就是普通的HTTP接口,Nginx配置简单。整体代码量减少了一半,稳定性反而更好。
这个案例说明:不要因为WebSocket"更强"就无脑选它,匹配场景才是关键。
7. 几个容易被忽略的细节与我的实操心得
7.1 消息顺序与幂等
SSE本身保证消息按发送顺序到达(TCP的特性),但在断线重连场景下,如果服务端用Last-Event-ID续传,要小心重复推送。比如服务端从ID 1001开始续传,但客户端其实已经收到了1001,就会重复。
解决办法是客户端做幂等处理,根据消息ID去重。或者服务端维护更精确的游标,确保不重不漏。这个在消息通知场景里尤其重要,重复通知比丢失通知更让人烦。
7.2 内存泄漏的隐蔽来源
SSE服务最常见的内存泄漏是连接关闭后没有清理相关对象。比如客户端断开了,但服务端的定时器还在跑、集合里的引用还在、事件监听器还挂着。时间一长,内存就涨上去了。
我的习惯是:每个连接建立时,把所有需要清理的资源(定时器、监听器、集合引用)都记下来,在close事件里统一清理。用Go的话,goroutine退出时defer清理;用Node的话,req.on('close')里清理。这个习惯能避免绝大多数泄漏。
7.3 调试SSE的实用技巧
调试SSE不像调试普通HTTP那么直观,因为连接是持久的。我常用的几个方法:
用curl直接看流:
curl -N http://localhost:3000/events-N参数关闭缓冲,能实时看到服务端推送的内容。这是验证服务端是否正常推送的最快方法。
浏览器开发者工具的Network面板里,SSE请求会显示为一直pending的状态,点进去能看到EventStream标签,实时显示收到的事件。这个对排查客户端问题很有用。
如果怀疑是代理层的问题,可以在代理前后分别用curl测试,对比结果,快速定位问题在哪一层。
7.4 安全考量不能省
SSE接口是公开的HTTP接口,必须做鉴权。常见做法是在建立连接时校验token或Cookie,校验不通过直接返回401,不建立SSE连接。
另外要注意连接数限制,防止单个用户或IP建立过多连接耗尽服务端资源。可以按用户ID或IP做连接数上限,超过就拒绝。
还有消息内容过滤,推送给客户端的数据要确保不包含敏感信息,尤其是日志类场景,别把内部错误堆栈直接推给前端。
8. 写在最后的一点个人体会
SSE这个技术,刚接触时觉得它"不上不下"——比轮询高级,又不如WebSocket强大。但用得多了会发现,它的定位其实非常精准:在不需要双向通信的场景里,用最简单的HTTP基础设施实现可靠的服务端推送。
"基于长轮询的SSE"这个说法,我现在的理解是:它继承了长轮询"服务端主动等待并推送"的核心思想,但通过持久连接把长轮询的循环重连问题彻底解决了。理解了这层演进关系,再去看SSE的各种特性——心跳、重连、Last-Event-ID——就都能找到它们存在的理由。
实际项目里,我现在的默认选择是:能用SSE就用SSE,确实需要双向高频通信才上WebSocket。这个原则帮我省了不少部署和调试的麻烦。当然,具体怎么选还得看业务场景和团队技术栈,没有银弹,只有合适的工具。
如果你正在做实时推送相关的功能,不妨先用SSE搭个原型跑一跑,感受一下它的简洁和稳定。很多时候,简单方案带来的可维护性,比技术上的"先进"更有价值。