我用SSE长链接做实时推送,踩过的坑和最终落地方案
最近在一个模拟项目X里需要做实时数据推送,最开始想都没想就准备上WebSocket,后来被架构师同事拦下来聊了五分钟,最终换成了SSE长链接,也就是Server-Sent Events。说实话,这次切换不仅没让功能缩水,反而把服务端复杂度降了一个量级。今天就把这套从选型到落地的完整过程拆开讲讲,包括协议原理、服务端实现、前端消费,以及生产环境里那些文档里根本不会写的问题。
SSE长链接说直白点,就是客户端通过HTTP协议跟服务器维持一条长连接,服务器可以随时往这条连接上推数据,客户端不需要反复发起请求,服务器也不会像轮询那样频繁响应无效请求。它跟WebSocket最大的区别是单向通信——一般场景下服务器给客户端推就够了,反向通道用普通的HTTP请求就能实现,所以没必要杀鸡用牛刀。适合用SSE的场景非常多:实时股价、监控大屏、消息通知、日志流、AI大模型的结果流式输出,这些都是它的主场。
下面按我实际的推进顺序来写,从为什么要选它,到怎么实现,再到生产环境的坑,一条线走完。
1. 为什么选SSE而不是WebSocket:先弄明白协议的本质差异
1.1 HTTP单向推送的天然优势
很多开发者一听到“服务器主动推数据”,脑子里第一个冒出来的就是WebSocket。我承认WebSocket很强大,双向实时通信、全双工、低延迟,但它带来的复杂度经常被低估。WebSocket需要先通过HTTP Upgrade握手升级协议,之后连接就不再走HTTP语义,这意味着链路里所有依赖HTTP的中间设备——CDN、Nginx、网关、负载均衡器——都可能因为不认识WS协议而断开连接,或者无法正常缓存、转发、鉴权。
SSE则完全不同。它本质上还是HTTP响应,只不过Content-Type设为text/event-stream,并且响应永远不会结束。也就是说,这条长连接还是普通的HTTP请求-响应模型,服务器处于“响应持续进行中”的状态,客户端通过EventSource API发起请求并保持连接。因为协议还是HTTP,所有既有的HTTP能力全部保留:可以直接走CDN、Nginx可以正常处理、鉴权中间件可以照常工作、浏览器原生支持,不需要引入额外的客户端库。
我当初选SSE最关键的原因是:我们的部署环境中有一层公共网关,团队花了大半年时间调优这层网关的HTTP路由、限流和重试策略,如果换成WebSocket,这层网关基本就废了,所有流量都得绕过网关直连服务。但SSE不用改任何基础设施,网关照常转发,鉴权逻辑完全复用,只是响应变长了而已。
1.2 SSE和WebSocket的适用边界
我画了一个选型参考表,按项目实际情况做判断:
| 对比维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务器单向推客户端 | 双向全双工 |
| 协议基础 | 纯HTTP,天然穿透各类代理 | 独立WS协议,依赖Upgrade握手 |
| 浏览器支持 | 原生EventSource,无需依赖 | 需WebSocket API或第三方库 |
| 自动重连 | 内置,由浏览器自动处理 | 需自己实现重连逻辑 |
| 消息格式 | 文本为主,约定数据格式 | 文本或二进制均可 |
| 服务端复杂度 | 低,用普通HTTP处理即可 | 高,需处理心跳、协议细节 |
| 扩展能力 | 靠自定义事件类型和字段 | 可扩展子协议 |
| 典型场景 | 通知推送、动态刷新、流式输出 | 低延迟强交互、双向消息(IM、游戏) |
判断标准其实很简单:如果你的交互模型是“客户端看着,服务器说话”,比如监控大屏刷新、股票价格跳动、日志滚动输出、AI结果逐字生成,那SSE足够了,还不用处理二进制包、心跳帧、重连协议这些麻烦事。如果你的业务要求客户端随时说话、服务器随时回应,比如IM聊天、多人协作编辑器,那就老老实实WebSocket。
1.3 浏览器和服务端的兼容性现状
SSE在浏览器端基本全平了,现代浏览器都原生支持EventSource,包括移动端Safari和Android Chrome。老掉牙的IE不在讨论范围,这种项目本来也不会考虑IE。
服务端实现更没什么压力,SSE就是一门古老而朴素的技术。主流的语言和框架都有支持方案,我用Node.js环境来做,后面会给出完整的Java和Go的思路对照。
提示:很多人一听到SSE第一反应是“这东西是不是过时了”,但实际上它是2011年就被列入HTML5标准的技术,十多年过去浏览器支持率反而越来越稳,很多看似很新的功能(比如AI对话流式输出)在底层都在用它。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:一条永远不结束的HTTP响应
2.1 event-stream的协议格式
SSE的核心在于响应头的Content-Type是text/event-stream,并且响应体按标准约定持续发送特定格式的文本块。每一条消息由若干字段行组成,字段名有data、event、id、retry几个:
text复制data: 这是第一条消息
data: {"name":"张三","age":25}
event: userConnected
data: 连接建立成功
- data:消息主体内容,可以多行拼接,多行data会被合并为一条消息并按换行符连接
- event:事件类型,默认是message,设置后前端需要addEventListener('event名称')来接收
- id:事件ID,浏览器会记录最后一次收到的ID,断线重连时通过Last-Event-ID请求头带给服务端
- retry:重连间隔毫秒,告诉浏览器如果断线了,隔多久重新连接
每个数据块以空行(两个换行符)表示消息结束。这里有一个非常容易踩的坑:消息必须以空行分隔。如果忘了写那个空行,浏览器会一直等消息结束,数据一直不触发,看起来就像连接已经断了但服务端明明在发数据。
2.2 EventSource客户端的完整生命周期
浏览器侧的EventSource生命周期可以拆成三个阶段。建立阶段:客户端通过GET请求访问指定的URL,服务端返回200状态码、text/event-stream响应头,然后连接进入开启状态。保持阶段:服务端定时或按需发送数据块,连接持续存活,客户端通过onmessage或事件监听接收消息。断开与重连阶段:网络异常、服务端崩溃、超过一定时间无数据,都会导致连接断开,浏览器自动按retry字段指定的时间发起重连,重连的请求会自动携带Last-Event-ID头。
我整理了一张连接状态流转表,方便对照理解:
text复制连接建立成功
↓
正常接收消息(onmessage / 自定义事件监听)
↓
网络中断 / 服务端主动断开 / 空闲超时
↓
浏览器等待 retry 字段指定时间
↓
自动触发重连,携带 Last-Event-ID
↓
重新建立连接,继续接收后续消息
重点注意:EventSource在内部实现了完整的重连机制,这跟WebSocket完全不同——WebSocket断线后你需要在业务代码里手动维护重连逻辑,而SSE是浏览器内核自动处理的。但这不代表你可以什么都不做,服务端必须配合好断点续传,否则重连之后客户端会丢失断线期间的数据流。
2.3 长连接的内存与并发模型
长连接意味着一个用户占着一个连接不放开,在服务端看来就是很多个挂起的请求。这里最需要关注的是并发连接数上限和内存占用。Node.js默认2000个并发连接不会有什么大问题,但如果做2万并发连接,要留意操作系统的文件描述符限制和进程的内存水位线。
生产环境我一般用两种策略。第一种是连接数规模化:一台服务同时挂着的连接数是有限的,需要做水平扩展。因为SSE是HTTP长连接,负载均衡器必须开启会话保持(根据客户端的IP或某个自定义请求头哈希到同一台服务),或者直接通过消息中间件把推送请求路由到对应节点,不然客户端重连时可能落到另一台机器上,数据断片。第二种是连接空闲保活:长连接最怕空闲超时被中间设备切断,所以我每个SSE服务都会加心跳机制,一般是30秒由服务端发一条注释行或一个事件,告诉客户端和中间设备“连接还活着”。
注意:SSE消息里以冒号开头的行是注释,浏览器不会触发任何事件,专门用来做心跳保活。我见过有人用发空消息来保活,结果前端把一堆垃圾消息渲染了出来,这种低级错误完全可以用注释行避免。
3. 服务端实现:从零搭一个生产可用的SSE接口
3.1 Node.js环境下的完整示例
先给一个最朴素的Node.js原生HTTP实现,不依赖任何框架,方便理解底层逻辑:
javascript复制const http = require('http');
const { EventEmitter } = require('events');
const clients = new Map();
const server = http.createServer((req, res) => {
// 只处理SSE相关的GET请求
if (req.url.startsWith('/events') && req.method === 'GET') {
// 设置SSE必需的响应头
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // 禁用Nginx缓冲,后面细说
'Access-Control-Allow-Origin': '*', // 跨域按需配置
});
// 先发一条注释,确保连接建立后立即有数据流出,避免某些代理空等
res.write(': connected\n\n');
const clientId = Date.now() + '_' + Math.random().toString(36).slice(2);
clients.set(clientId, res);
// 处理客户端断开
req.on('close', () => {
clients.delete(clientId);
});
// 30秒心跳保活
const heartTimer = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30000);
res.on('close', () => {
clearInterval(heartTimer);
clients.delete(clientId);
});
return;
}
// 普通的推送接口,业务方调用它给特定或所有客户端发消息
if (req.url.startsWith('/push') && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const { id, event, data } = JSON.parse(body);
const payload = [];
if (id) payload.push(`id: ${id}`);
if (event) payload.push(`event: ${event}`);
payload.push(`data: ${JSON.stringify(data)}`);
payload.push('');
payload.push('');
const message = payload.join('\n');
clients.forEach(client => {
client.write(message);
});
res.writeHead(200);
res.end('ok');
} catch (e) {
res.writeHead(400);
res.end('bad request');
}
});
}
});
server.listen(3000, () => {
console.log('SSE server listening on 3000');
});
这个示例虽然简单,但里面已经包含了生产可用的几个关键点:连接管理Map、心跳保活、断开清理和推送接口。有一点要提醒:res.write之后数据不是立刻到客户端,要经过几个缓冲层、TCP栈、中间代理等。我在实际项目中就遇到过推送了但前端迟迟不收到的情况,排查一圈发现是默认缓冲策略惹的祸。
3.2 Express框架和Java/Go的等价实现思路
如果用Express,代码会更简洁一些,核心逻辑不变:
javascript复制const express = require('express');
const app = express();
// ... 同样的客户端Map管理和心跳逻辑
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
res.write(': connected\n\n');
// ... 其余逻辑同上
});
Java端思路一模一样,用Servlet的异步响应或WebFlux的Flux即可。之前在某公司做Java后端的时候,我用的是Servlet 3.1的异步处理,把AsyncContext保存到一个ConcurrentHashMap里,推送时对每个AsyncContext调用getResponse().getWriter().write(),每30秒发心跳。Go端则更简单,用net/http包,把ResponseWriter的Write方法当作推送通道即可。
无论用什么语言,核心都是同一套概念:保存连接、定时心跳、业务推送、断线清理。语言只是载体,思想通了迁移成本很低。
3.3 多客户端连接管理:从Map到分布式
单机版的客户端Map很好写,但一旦扩容到多节点就会遇到跨节点推送问题。比如A节点的某个连接要推送数据,但这个客户端实际连的是B节点,这时候A节点怎么把消息送过去?
一个标准解法是引入Redis的发布订阅,所有服务节点订阅同一个频道,需要推送时往频道里发布消息,各节点拿到消息后只推送给本机持有的连接。我在某跨平台系统中就是这么干的:
text复制业务服务 → Redis频道发布消息
↓
节点A(订阅频道) → 推给本机持有的SSE连接
节点B(订阅频道) → 推给本机持有的SSE连接
节点C(订阅频道) → 推给本机持有的SSE连接
每个节点只负责推本机的连接,不在本机的连接自然由其他节点推送,这样不仅解决了跨节点推送问题,还把连接与节点绑定变成了无状态,负载均衡器不需要开会话保持,因为无论客户端连到哪个节点,消息总能通过Redis转发到位。当然代价是多了一层中间件,如果Redis挂掉,推送链路会全部中断,所以生产上Redis必须走主从加哨兵的高可用模式。
4. 前端消费:EventSource的正确用法与常见写法误区
4.1 基础用法与自定义事件
前端消费SSE很简单,核心API就是EventSource。最基础的写法:
javascript复制const eventSource = new EventSource('/events');
// 默认的message事件
eventSource.onmessage = function(event) {
console.log('收到消息:', event.data);
const data = JSON.parse(event.data);
updateUI(data);
};
// 错误处理
eventSource.onerror = function(event) {
console.error('连接异常', event);
};
// 连接建立
eventSource.onopen = function() {
console.log('连接已建立');
};
如果服务端发了event字段,前端就要用addEventListener来监听对应类型:
javascript复制const eventSource = new EventSource('/events');
eventSource.addEventListener('userConnected', (event) => {
console.log('用户连接建立', event.data);
});
eventSource.addEventListener('priceUpdate', (event) => {
const data = JSON.parse(event.data);
refreshPrice(data.symbol, data.price);
});
这块很多人会犯一个错误:服务端发了自定义事件,但前端只用onmessage接收,结果业务数据一个都到不了。原因就是自定义事件不会触发onmessage,它只触发对应的事件监听器。反之也一样,如果服务端发的data没有配event字段,addEventListener是接不到的,必须用onmessage。
4.2 盲区:自动重连与Last-Event-ID的配合
EventSource自动重连很方便,但它不会自动帮你补齐断线期间丢失的消息。它做的最多就是重连时在请求头带上Last-Event-ID,告诉服务端“我最后收到的消息是这条,你接着往后发”。
服务端要做的配合就是:读取Last-Event-ID,返回从该ID之后的所有未发送消息。我在项目里给每个推送消息配了一个自增ID,客户端收到后会更新本地游标,服务端保存最近N条消息的环形缓冲数组,重连时根据Last-Event-ID把缓冲区的消息补发出去,这样前端能做到断线不丢数据。
javascript复制// 服务端处理Last-Event-ID的伪代码
const lastEventId = req.headers['last-event-id'];
let startIndex = lastEventId ? messagesBuffer.getIndexById(lastEventId) + 1 : 0;
const pendingMessages = messagesBuffer.slice(startIndex);
for (const msg of pendingMessages) {
res.write(msg.toEventStreamFormat());
}
4.3 前端主动断开:不要只想close,还要注意状态清理
很多场景下用户离开页面、切换账号、或者业务不再需要推送时,前端需要主动断开SSE连接,直接调close方法:
javascript复制const eventSource = new EventSource('/events');
// 某些条件触发后主动断开
function stopListening() {
eventSource.close();
}
close之后浏览器会中止连接,后续服务端再往这个连接写数据会报错,服务端需要捕获这个写失败了然后清理对应连接。另外还有一个细节:EventSource实例是一个持续占用资源的对象,如果组件频繁创建和销毁(比如在单页应用里),一定要在组件卸载时close,否则会出现连接泄漏。我排查过某前端应用的线上问题,页面切换十几次后EventSource连接数暴涨几十个,就是因为旧实例没被清理。
特别注意:EventSource默认只支持GET请求,不能自定义请求方法,也不能自定义请求头。这在需要鉴权时会比较头疼,后面会讲相对优雅的解决办法。
5. 生产环境避坑清单:代理、鉴权、心跳、负载均衡
5.1 Nginx反代缓冲区导致的消息延迟
这是SSE应用最经典的坑,凡是SSE挂在Nginx后面的都会被坑过一次。默认情况下Nginx会对上游响应做缓冲,它会把上游发来的数据先收集到缓冲区,直到攒够一定量或缓冲区满了才一次性发给客户端。对于SSE这种流式推送,缓冲的结果就是客户端看到的是“跳变式”的数据,严重时甚至一条消息都收不到。
解法核心是关掉Nginx对该location的缓冲:
nginx复制location /events {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
注意几个参数:proxy_buffering off关掉缓冲;proxy_read_timeout和proxy_send_timeout调大,因为长连接很稳定,默认60秒超时一旦期间没有数据交互就会被断开;chunked_transfer_encoding off确保响应按流式方式返回,不被强制分块缓冲。
我实际还遇到过一个更隐蔽的情况:即使proxy_buffering off了,如果上游响应头里有X-Accel-Buffering: no,某些的代理也会尊重这个指令而不缓冲。所以在服务端顺手加上这个响应头,双保险。
5.2 负载均衡:会话保持 vs 状态外置
前面提过,单机SSE要水平扩展时负载均衡策略很关键。如果用轮询策略,客户端可能重连到另一台机器,如果那台机器上没有对应的连接状态,推送就断了。两种主流解法:
第一种是会话保持,按客户端IP哈希到固定节点。优点是实现简单,缺点是一台节点挂了,挂在它上面的所有连接全断,其他节点的负载会跳跃式增长。第二种是状态外置,所有会话信息同步到Redis,每个节点通过Redis拿到用户连接的准确节点,推送时指定路由。复杂度高一些,但节点故障的影响面就缩小了,重连会自动负载均衡到幸存节点。
我在某跨平台系统里最终选了第二种,因为监控大屏的推送量大、对稳定性要求极高,接受不了单点故障导致一片屏幕数据停摆。
5.3 鉴权与跨域:绕过EventSource的请求限制
EventSource不能自定义请求头,而很多系统的鉴权依赖Authorization头或者特定的Token头,这确实是个痛点。我常用的替代方案有几种。
URL参数带Token:new EventSource('/events?token=xxx')。优点是实现简单,缺点是Token会暴露在URL和访问日志里,安全性要打折扣,一般只用在短时效动态Token场景。
Cookie鉴权:EventSource的请求会正常携带当前域名的Cookie。只要后端能通过Cookie里的会话信息识别用户就行。这是我目前最推荐的方式:改造少、安全系数高、浏览器自动带Cookie,不用在代码里显式处理。
子协议方案:先用普通HTTP请求鉴权,拿到一个临时channelId,然后EventSource连一个带channelId的地址。这其实和第一种类似,但缓解了Token在URL中长期暴露的问题。
至于跨域,EventSource支持CORS,需要服务端配置Access-Control-Allow-Origin,如果要带Cookie还得开启withCredentials(EventSource原生支持这个属性)并在服务端配置Access-Control-Allow-Credentials。
5.4 断线恢复与心跳保活策略
SSE断线无法完全避免:网络波动、设备休眠、网关超时、服务端重启,都会导致连接中断。需要做两个层面的防护。
第一层面,浏览器自动重连。这是EventSource内置的,配合retry字段控制间隔,比如指定3秒重连一次。但要注意:如果服务端故意断开(比如发布重启),浏览器重连会让所有连接瞬间打进来,造成连接风暴,服务端应该做一定的限流或错峰处理。我在项目里就是根据retry间隔做抖动控制,每次重连时间加一个随机偏移。
第二层面,服务端心跳。长连接30秒无数据交互,很多网络设备会判定连接空闲并回收。我每个SSE服务每30秒发一条注释心跳,保持连接活跃。对于客户端,心跳还有一个作用:如果服务端进程卡死,30秒内没有心跳,前端可以自行close并重新建立连接,实现客户端侧的自动故障恢复。
5.5 监控指标:连接数、消息量、断开原因
SSE上线后监控是必须跟上的,我自己维护的核心指标有四个:
- 当前连接数:反映服务承载压力,一般来说每个连接的内存占用大约几KB到几十KB,连接数暴涨时需留意内存。
- 每秒推送消息量:反映业务活跃度,也用于容量规划。
- 连接建立成功率:排查HTTP层问题。
- 断开原因统计:把服务端主动断开、客户端断开、异常断开分类统计,异常断开率高于阈值时说明网络或代理层有隐患。
我还在服务端为每次断开记录最后心跳时间和最后消息发送时间,用于反推具体断点。有一回排查一个“每两小时所有连接同时断开”的问题,最终定位到是网关层会话超时配置是7200秒,心跳没有及时被转发导致的,这类问题靠监控指标一下就发现了。
6. 实战经验总结与后续扩展建议
6.1 我在实际使用中沉淀的几点体会
SSE不是用来炫技的,它是“够用就好”的典型。很多中小型项目的实时需求根本没有复杂到需要WebSocket的程度,用SSE能把服务端代码量砍掉一半,前端也不需要引依赖,部署运维完全沿用原有HTTP链路,这就是它的核心竞争力。
但SSE也确实有自己的边界。如果你的项目未来大概率需要做双向通信,比如做在线协作编辑器,那趁早上WebSocket,宁可前期复杂一点也别中途推倒重来。如果只是推送数据,那SSE的语言生态和浏览器支持足够平滑。
在我最近的这个模拟项目X里,最终用SSE长链接实现了监控大屏的实时刷新,大概同时在线2000多个连接,服务端部署了4个节点,每个节点跑一个Node.js进程,加上Redis发布订阅和Nginx反代,整体跑了一个多月,稳定性表现非常好,重连率和消息丢失率都控制得很低。
6.2 还可以扩展的方向
SSE能做的比很多人想象中多。比如结合流式处理,可以做AI生成结果的逐字输出;配合数据压缩,可以做低带宽环境下的高频推送;加一层消息持久化,可以做真正的离线消息补偿。甚至前端可以做“多个事件源合并成一个统一订阅”的封装,让页面上所有动态区域共享同一条SSE连接,进一步降低连接数。
最后再分享一个小技巧:开发阶段调试SSE不要只靠浏览器,可以用命令行工具直接连:
bash复制curl -N http://localhost:3000/events
-N参数禁用curl的缓冲,让流式内容直接输出到终端。这个方式排查服务端问题特别快,能一眼看出是服务端没发数据,还是前端接收逻辑有问题。我每次排查SSE相关故障的第一件事,就是先curl一下,先把服务端这一层搞干净,再去翻前端代码。希望这篇东西能帮你少走一些弯路。
