最近要做一个实时通知功能,第一反应是什么?以前我大概率会直接上WebSocket,直到认真用了SSE长链接之后,才发现很多场景根本不需要那么重的东西。SSE长链接,对应的是Server-Sent Events,是一套基于HTTP的服务器推送方案,浏览器通过原生EventSource API就能接收服务端不断推来的数据,连接保持打开、数据持续下发,页面不用轮询、不用折腾协议升级。如果你正在做实时通知、数据看板、日志流、或AI回复的流式输出,又不想引入WebSocket那套复杂度,SSE长链接往往是最省事的选择。下面直接说协议,说实现,说踩坑,把这条路一次走通。
先解释一下标题里的“长链接”。这里的“链接”不是SEO里那个外链概念,而是“长连接”:一条HTTP连接建立后不立刻关闭,服务端可以在这个连接上持续写数据。SSE的全部精髓就在这一句话里,但因为牵扯到代理、缓存、重连机制,实际落地时会翻车的地方不少,这部分我会在第四章重点讲。
1. SSE长链接是什么:先搞清楚协议本质
1.1 从HTTP响应说起:text/event-stream
SSE不是新协议,它就是普通的HTTP。客户端发起一个GET请求,服务端返回状态码200,然后把Content-Type设置为text/event-stream。区别在于,普通HTTP响应写完就end()了,而SSE响应不结束,服务端不断通过res.write()往同一个连接里写入事件流。用生活化的话说,普通请求像拧开水龙头接一杯水然后关掉,SSE像把水龙头一直开着,水持续流出来。
浏览器端的EventSource会自动维持这个“水龙头”:连接建立后,只要服务端不断写入事件消息,onmessage回调就不断触发。消息格式非常固定,每一条事件由若干行组成,行与行之间用空行分隔:
text复制id: 1
event: update
data: {"message":"hello"}
id:事件编号,断线重连时浏览器会记住它。event:事件类型,默认是message,也可以自定义。data:事件内容,可以多行拼接,也可以是JSON字符串。- 空行:表示一条事件结束。
很多资料看一遍觉得“这不就是普通文本吗”,真去写代码时才会发现,服务端一旦设置了text/event-stream,就必须放弃“一次性返回”的思路,整个生命周期都要围着write()转。这是理解SSE最核心的分水岭。
1.2 和WebSocket、长轮询放一起看
选型时,SSE最常见的比较对象是WebSocket和长轮询。直接上对比表:
| 维度 | SSE长链接 | WebSocket | 长轮询 |
|---|---|---|---|
| 传输方向 | 服务端单向推送 | 双向实时通信 | 服务端单向,靠反复请求模拟 |
| 协议成本 | 普通HTTP,无升级过程 | 需要ws/wss协议升级 | 普通HTTP |
| 浏览器API | EventSource,原生支持 | WebSocket对象 | 无标准API,自己封装 |
| 自动重连 | 内置自动重连、续传ID | 需要自己实现 | 需要自己处理重连 |
| 消息格式 | UTF-8文本为主 | 文本和二进制都支持 | 文本和二进制取决于传输 |
| 防拥塞/背压 | 有基础处理 | 需要专门设计 | 每个请求独立,压力较轻 |
长轮询本质上就是“请求-等待-再请求”:客户端发一个请求,服务端有数据才返回,没有数据就挂着,返回后客户端立刻发下一个请求。前端看起来像实时,但存在频繁的连接建立和释放,服务端压力也不小。WebSocket是真正的双向全双工通道,适合聊天、协作编辑、在线游戏这类需要客户端持续回传数据的高频互动场景。
SSE的定位很清楚:如果只需要服务端往客户端推数据,客户端几乎不回传,那SSE就是最优解。推送系统、告警通知、AI流式输出这类场景,上WebSocket属于功能过剩,不仅服务端要实现心跳、重连、二进制协议,前端还要处理各种异常状态,复杂度会成倍增加。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 服务端怎么把SSE长链接跑起来
2.1 协议头与响应格式
服务端实现SSE,第一件事是设置正确的响应头。一个完整的响应头看起来像这样:
http复制HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no
每个头的意义:
Content-Type: text/event-stream:让浏览器知道当前是事件流。Cache-Control: no-cache:防止代理或浏览器缓存数据,实时推送不能被缓存。Connection: keep-alive:保持连接不关闭,虽然是HTTP/1.1的默认行为,写上更保险。X-Accel-Buffering: no:这是给Nginx一类反向代理看的,告诉它不要缓冲响应。没有这个头,数据可能被代理层卡住,浏览器迟迟收不到内容,我在第四章会专门说这个坑。
响应体就是一系列事件文本。每条事件以空行结束,服务端写完一条可以继续写下一条,不需要关闭连接。
2.2 Node.js实现:一段能用的代码
用Node.js原生http模块实现最直接,不依赖框架:
javascript复制const http = require('http');
const { randomUUID } = require('crypto');
http.createServer((req, res) => {
if (req.url === '/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});
let id = 0;
const send = (event, data) => {
res.write(`id: ${id}\n`);
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
id++;
};
// 心跳:防止连接被代理或防火墙判定为闲置而断开
const heartbeat = setInterval(() => {
res.write(': ping\n\n');
}, 15000);
// 模拟实时数据,真实场景换成消息订阅即可
const timer = setInterval(() => {
send('update', { id: randomUUID(), time: Date.now() });
}, 2000);
req.on('close', () => {
clearInterval(heartbeat);
clearInterval(timer);
res.end();
});
} else {
res.statusCode = 404;
res.end();
}
}).listen(3000);
注意几个容易写错的点:
- 不要调用
res.end(),除非连接真的要关闭。SSE是长连接,调用res.end()等于主动断开推送。 - 必须监听
req.on('close'),在客户端断开时清理定时器,否则会产生大量空转定时器,连接数一多就会泄漏。 res.write()可以连续调用,Node底层会按顺序写入,不用手动处理分片。
如果项目里用了Express或Koa,思路完全一样,拿到res对象后写响应头和write()。唯一要注意的是,如果开了compression中间件,最好对text/event-stream禁用压缩,因为实时事件流通常是小数据频繁推送,压缩收益有限,反而会增加延迟。
2.3 Python实现:Flask和FastAPI两种写法
Python后端用Flask也能做,关键是返回一个生成器对象:
python复制from flask import Flask, Response
import json
import time
app = Flask(__name__)
@app.route("/events")
def events():
def generate():
i = 0
while True:
event = json.dumps({"index": i})
yield f"id: {i}\nevent: update\ndata: {event}\n\n"
i += 1
time.sleep(1)
return Response(
generate(),
mimetype="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
用Flask时有一个很隐蔽的问题:默认开发服务器和部分WSGI服务器可能会缓冲响应,导致数据没有立即刷新到客户端。生产环境用Gunicorn时,建议使用gevent或threads类型的worker,因为同步worker会阻塞整个进程,一个SSE连接占满worker后,其他请求就全部卡住了。
FastAPI的写法更现代,直接声明成StreamingResponse,异步生成器天然支持长连接:
python复制from fastapi import FastAPI, StreamingResponse
import asyncio
import json
app = FastAPI()
@app.get("/events")
async def events():
async def generate():
i = 0
while True:
yield f"id: {i}\nevent: update\ndata: {json.dumps({'i': i})}\n\n"
i += 1
await asyncio.sleep(1)
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
服务端写出一个能跑的SSE只是起点,真正的挑战在连接管理。如果只有一个客户端连接,想怎么写都行;当连接数到了上百上千,定时器、内存、代理层的配合,才是SSE长链接能不能真正落地的关键。
3. 前端接入:EventSource的正确姿势
3.1 入门:new EventSource与监听
前端接入SSE简单到让人怀疑。事件流地址一传,连接就建立:
javascript复制const es = new EventSource('/events');
es.onopen = () => {
console.log('SSE连接已打开');
};
es.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
es.onerror = (error) => {
console.log('连接异常,浏览器会自动重连');
};
EventSource实例的readyState有三种状态:
0(CONNECTING):正在连接或断线后正在重连。1(OPEN):连接已建立,正在接收事件。2(CLOSED):已被手动关闭,不会再自动连接。
有一点很多新手容易误解:onerror触发不代表连接已经彻底失败,浏览器会根据服务端设置的retry时间自动重连。如果没有主动调用es.close(),看着报错了,实际它自己会“复活”。所以前端不要因为一次onerror就立刻销毁实例,那反而会破坏内置的重连机制。
自定义事件也是SSE的常用能力。服务端发event: update,前端要这样监听:
javascript复制es.addEventListener('update', (event) => {
console.log(JSON.parse(event.data));
});
如果用同一个连接推送多种业务事件,比如告警、通知、心跳、数据变化,用自定义事件区分是推荐做法,比开多个EventSource再想办法合并要优雅得多。
3.2 重连机制和Last-Event-ID
EventSource最顺手的就是内置自动重连。连接断了,浏览器自己重新发请求,没有Python里实现“断线重连”那么痛苦。但要实现“续传”,必须配合服务端的事件编号。
服务端发送消息时带上id::
text复制id: 12
data: {"tick": 123}
id: 13
data: {"tick": 130}
浏览器每次收到事件,都会记住最新一条的id。下次重连时,浏览器会在新请求里自动带上一个请求头:
http复制Last-Event-ID: 13
服务端读取这个请求头,就能知道客户端最后处理到哪一条,然后只推送从第14条开始的数据。这就像看书看到第100页,再打开时用书签直接翻到101页,不用从头读。
服务端代码只需加一行:
javascript复制const lastEventId = req.headers['last-event-id'];
然后在发送循环里跳过id <= lastEventId的事件即可。需要注意的是,Last-Event-ID只在“同一连接路径”的重连中生效,如果请求URL里带了不同参数,浏览器会把它们当成不同连接,续传逻辑就会失效。实际项目中,我会把用户标识放在URL的query参数里,并确保无论重连多少次,URL都不变。
3.3 连接生命周期与手动关闭
EventSource默认不关闭,页面不关它就一直挂在那。这是设计的一部分,但也会带来资源问题。页面上如果跳转路由或切换模块,旧连接没关,新连接又创建,连接数会悄悄累积。
比较规范的处理是结合页面可见性:
- 页面隐藏时,调用
es.close()暂停推送。 - 页面重新可见时,调用
new EventSource()重建连接。
javascript复制document.addEventListener('visibilitychange', () => {
if (document.hidden) {
es.close();
} else {
es = new EventSource('/events');
}
});
这样做不是单纯省网络流量,更重要的是避免移动端在后台长时间占用TCP连接、耗电发热的问题。对数据看板这种需要盯着屏幕看的场景,这个策略尤其有效。
有一个绕不开的限制:EventSource不支持自定义请求头。需要带Token时,只能走query参数或Cookie。如果走query参数,一定不要打印完整URL日志,Token泄露风险很大。这也说明,SSE适合前端相对信任、同源部署的推送场景,必须走复杂鉴权协议时,WebSocket会更灵活。
4. 实战中的坑和排查技巧
4.1 代理层缓冲导致“假死”
这个坑可以说是SSE落地时最常遇到的。服务端明明在res.write(),前端却迟迟拿不到数据,过了很久或者连接关闭后,数据才一次性全部到达。发生这种情况,十有八九是中间的反向代理在缓冲。
以Nginx为例,它默认会缓冲后端响应,积攒到一定大小再转发给客户端。SSE是源源不断的小块数据,正好被这个机制卡住。解决办法有三个层次:
- 给SSE的响应头加上
X-Accel-Buffering: no,Nginx会直接关闭对该响应的缓冲。 - 在Nginx的location配置里写
proxy_buffering off;。 - 如果代理是其他组件,先去查它的缓冲策略,把SSE对应路径的缓冲关掉。
我在服务端代码.headers里始终带上X-Accel-Buffering: no,因为这样能保证即使代理层配置漏了,SSE也能正常工作,比只改Nginx配置更稳。
4.2 心跳保活与代理超时
SSE连接长时间没有数据,会出现两种问题:
- 反向代理或负载均衡有
proxy_read_timeout,空闲连接会被自动断开。 - 客户端和服务器之间的运营商设备可能把长时间空闲的TCP连接回收。
浏览器本身会重连,但断线到重连之间有几秒甚至更长的空白期,实时性会打折扣。更关键的是,如果服务端一直有心跳,而某一个客户端突然不发送心跳了,前端能立刻感知连接异常,而不是等很久之后才发现数据不动了。
心跳最简单的方式是定时发送一个注释行:
text复制: this is a heartbeat
这个以冒号开头的行,在SSE协议里是注释,浏览器不会触发任何事件,但它确确实实是一段数据,能让连接保持活跃。我用的是15秒一次,比较保守。如果数据更新本来就频繁,可以适当拉长到30秒。
同时服务端要检查客户端是否还活着。req.on('close') 可以捕获底层连接断开,但如果客户端网络异常但没有通知TCP断开,服务端可能一直保留无用连接。比较好的做法是结合心跳回复或定期清理过期连接,避免连接数只涨不降。
4.3 事件编号与重复消息
浏览器自动重连,带来一个容易被忽略的问题:重连成功之后,客户端可能重复收到之前已经处理过的消息。不加id:时,EventSource重连后从当前最新状态开始接收,也可能漏掉断线期间的增量,至少会产生不完整的通知列表。
我的建议是:服务端尽量给每条事件都加id:,并在业务上把消息ID做成单调递增的整数或时间戳。然后服务端在推送循环中处理Last-Event-ID,跳过已经发过的消息。
如果对事件顺序和幂等要求很高,比如“通知只能收到一次”,客户端还要自己做一层去重。SSE本身是“至少一次”的传输语义,要变成“精确一次”,需要客户端记录已处理ID,再配合服务端的消息历史回放。很多项目做到“至少一次”就够了,没必要把所有实时系统都做成分布式消息队列。
4.4 浏览器并发连接限制
EventSource是长连接,会长期占着浏览器到服务器的TCP连接。HTTP/1.1下,浏览器对同一域名并发连接数有限制,通常在6个左右。如果页面里有SSE连接,同时还在加载大量图片、接口请求,那6个配额很快会被占满,后续请求只能排队。
实际表现是:页面打开后别的接口请求明显变慢,但看不出哪里有死循环。排查时可以先数一下页面一共发了几个EventSource。
解决办法:
- 尽量只保留一个EventSource,用自定义事件区分业务。
- 有条件就启用HTTP/2,同一个域名下的多个连接可以复用在一条TCP连接上,SSE占连接的问题会被大幅缓解。
- 不要把多个实时模块拆成多个SSE路径,除非你很清楚自己在做什么。
这个坑在只有一个推送功能时不明显,一旦页面模块多了,几个team各自加一个EventSource,就会莫名其妙影响全站网络性能。
5. 什么时候别用SSE:场景边界与更优解
5.1 适合的场景:单向通知、日志流、AI流式回复
SSE最适合的是服务端主动、客户端被动接收的场景。我实际用过几个典型场景,效果都不错:
- 运维告警平台:服务端有告警事件发生时,直接推给前端弹窗。
- 日志实时展示:后端流水日志通过SSE流式发到页面,省去前端定时拉取。
- AI大模型回复:现在很多AI应用都在用SSE做流式输出,模型生成一段文字就推一段,用户看到的是打字机效果。
- 数据大屏:订单量、实时指标每秒钟刷新,不需要WebSocket的双向能力,SSE足够。
这些场景的共同点是没有“客户端发消息给服务端”的强需求,即使有,也可以用普通HTTP请求单独完成。把一个大的下单动作拆成“用POST提交订单,用SSE接收订单状态更新”,这种模式比整个交互都塞进WebSocket更好维护。
5.2 不适合的场景:双向高频交互
反向的例子同样清楚。在线文档协同、聊天软件、实时对战游戏,这些场景客户端要高频向服务端发操作、位置、消息,服务端也要不断回推其他人的数据,是典型的双向全双工需求。SSE只支持服务端到客户端,客户端回传必须另开HTTP请求,在高频交互下会平白增加大量连接和请求开销。这时候用WebSocket更合适,它天生支持双向收发,二进制传输也能直接支持游戏点位、音频片段等数据。
SSE对二进制也没有原生支持,data字段本质是UTF-8文本。传输二进制要先Base64,体积上会有损耗,所以需要传文件片段或摄像头流媒体时,首选仍是WebSocket。
5.3 规模化部署时怎么扩展
SSE连接数上去之后,首先要面对的是长连接占资源的问题。每个连接都对应一个服务端套接字和事件循环上下文,单机能扛几千连接并不稀奇,但还要看具体业务逻辑里有没有为每个连接占用额外内存的队列或定时器。
水平扩展时,最稳妥的思路是:服务端节点不保存客户端相关的状态,把事件源放到共享通道里,比如Redis的发布订阅、消息队列的Topic。某个节点收到业务事件后,广播给所有连接在该节点上的客户端。这样即使某条连接重连后漂移到了另一个节点,它也能通过同一个共享通道继续收到消息,不会因为“会话不亲和”而断流。
反向代理配置粘性会话(比如按用户IP哈希)也是一种临时方案,但它会让连接固定在一个节点上,节点挂了之后重连效果会很差。我更倾向于让每个SSE请求都无状态化,事件的续传通过消息ID和共享历史来实现,而不是依赖“每次重连都必须回到同一个进程”。这才是SSE长链接规模化之后最值得花心思设计的地方。
我这几年的实际体会是,SSE长链接绝大多数翻车都不是协议本身的问题,而是代理缓冲、心跳、事件编号、连接数配额这些边边角角没处理好。只要在服务端响应头里带好X-Accel-Buffering: no,定时写一行冒号心跳,并且给事件老老实实编上递增ID,SSE这套方案基本不会出现玄学故障。如果你正在做实时推送,先别急着上重武器,SSE长链接很可能就是你等了一下午的那个简单答案。
