SSE 长链接频繁断连怎么处理
场景
AI 流式问答、实时通知、大模型 token 推送等场景下,前端通过 SSE(Server-Sent Events)建立长链接接收服务端持续推送。但生产环境经常遇到:
- 用户网络切换(4G → WiFi、VPN 进出、地铁进隧道)
- Nginx 反代默认 60s 超时主动断开
- 云厂商负载均衡器(ALB、SLB)空闲连接清理
- 服务端网关 / 反代默认无 keep-alive 优化
- 浏览器后台标签节流导致连接被回收
接口返回的事件流被中间链路频繁掐断,前端页面白屏、AI 回答到一半就停。
核心答案
SSE 断连处理是一个多层防线问题,不能只靠前端。需要从浏览器、网络、反代、服务端、业务层五层分别加固:
- 浏览器层:用
EventSource自带的重连机制 + 自研指数退避做兜底 - 网络层:开启
fetch + ReadableStream自管心跳,跳出EventSource的限制 - 反代层:Nginx / CDN 关闭缓冲、关闭 idle 超时、加 keep-alive
- 服务端:定期发心跳帧、定时器续期、消息持久化 + Last-Event-ID 续传
- 业务层:消息 ID 去重 + 断点续传 + 客户端状态恢复
最终方案是:前端自研 SSE Client(fetch + 心跳 + 退避重连)+ 后端配合(心跳、续传 ID、反代配置)。
实现要点
1. 优先用 fetch + ReadableStream 替代 EventSource
EventSource 不支持自定义 header(没法塞鉴权 token),不支持 POST,没法手动控制重连逻辑。生产环境建议自研:
async function createSSE(url: string, options: { token: string; onMessage: (data: string, id: string) => void }) {
const { token, onMessage } = options;
const response = await fetch(url, {
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
'Authorization': `Bearer ${token}`,
// 续传关键:告诉服务端上次收到哪条
'Last-Event-ID': localStorage.getItem('lastEventId') || '',
},
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 留半行
for (const line of lines) {
if (line.startsWith('id:')) {
const id = line.slice(3).trim();
localStorage.setItem('lastEventId', id);
} else if (line.startsWith('data:')) {
onMessage(line.slice(5).trim(), localStorage.getItem('lastEventId') || '');
}
}
}
}
2. 指数退避 + 随机抖动重连
固定间隔重连会造成"惊群效应"(服务端恢复时所有客户端同时打过来)。需要:
- 基础间隔 1s,每次失败 ×2
- 上限 30s
- 加 ±30% 随机抖动
- 重连成功后重置计数
class ReconnectManager {
private retryCount = 0;
private readonly maxRetry = 10;
getDelay() {
const base = Math.min(1000 * Math.pow(2, this.retryCount), 30000);
const jitter = base * 0.3 * (Math.random() * 2 - 1);
return Math.floor(base + jitter);
}
onSuccess() {
this.retryCount = 0;
}
onFailure() {
this.retryCount = Math.min(this.retryCount + 1, this.maxRetry);
}
}
3. 客户端心跳检测
中间链路(CDN、反代)经常会"假活":TCP 连接没断但数据流被掐死。光靠 onerror 检测不到,必须主动心跳:
let lastMessageTime = Date.now();
setInterval(() => {
if (Date.now() - lastMessageTime > 30000) {
// 30s 没收到任何数据,主动断开重连
controller.abort();
reconnect();
}
}, 10000);
// 服务端每 15s 发一个 ":heartbeat\n\n"(SSE 注释帧,前端不处理但能保活)
4. 服务端配合:Last-Event-ID 续传
这是断点续传的核心:服务端记录每个连接的最后 ID 写到 Redis,重连时按 ID 拉取未送达消息。
// 服务端 Node.js 示例
app.get('/sse', async (req, res) => {
const lastId = req.headers['last-event-id'];
const missedMessages = await redis.zrangebyscore('sse:msgs', lastId, '+inf');
// 先补发断线期间丢失的消息
for (const msg of missedMessages) {
res.write(`id: ${msg.id}\ndata: ${msg.data}\n\n`);
}
// 再进入实时推送循环
subscribe(res);
});
5. Nginx 反代配置(90% 的断连来自这里)
默认 Nginx 60s 空闲就掐连接,必须显式配置:
location /api/sse {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection ''; # 关掉 close
proxy_buffering off; # 关键:禁缓冲,否则事件攒批发送
proxy_cache off;
proxy_read_timeout 24h; # 关掉默认 60s 超时
proxy_send_timeout 24h;
keepalive_timeout 24h;
# 关键:转发原始响应头,否则浏览器拿不到 content-type
proxy_pass_header Content-Type;
}
阿里云 SLB / AWS ALB 也要调:默认 idle timeout 是 60s,需要调到 ≥ 服务端心跳间隔的 3 倍。
6. 消息去重
重连 + 续传经常出现边界问题(消息发了一半重连),必须客户端去重:
const receivedIds = new Set<string>();
onMessage((data, id) => {
if (receivedIds.has(id)) return;
receivedIds.add(id);
// 限制 Set 体积,防止内存泄漏
if (receivedIds.size > 1000) {
const arr = Array.from(receivedIds).slice(-500);
receivedIds.clear();
arr.forEach(id => receivedIds.add(id));
}
render(data);
});
算法流程
用户发起连接
↓
检查 Last-Event-ID → 服务端补发遗漏消息
↓
进入实时推送循环
↓
收到数据 → 更新 lastMessageTime → 渲染
↓
┌──────────┴──────────┐
↓ ↓
主动心跳超时 连接 error
↓ ↓
abort + 重连 指数退避 + 抖动
↓ ↓
└──────→ 重连 ←───────┘
↓
成功? → 是 → 重置计数,回到推送循环
↓ 否
达到上限? → 是 → 上报错误 + UI 提示
常见误区
1. 以为 EventSource 自动重连就够了 它只重连一次、间隔写死 3s,碰到网络抖动就崩。要自管。
2. 不配 Nginx 还怪前端 生产环境 80% 的 SSE 断连是反代默认配置导致的。前端代码写得再好也白搭。
3. 服务端不写心跳 不发心跳的话,云厂商负载均衡器会按 idle timeout 默默掐死连接,浏览器收不到任何 error 事件。
4. 用 WebSocket 代替 SSE WebSocket 没有原生断点续传机制(Last-Event-ID 是 SSE 独有的)。AI 流式场景下 SSE 更合适。
5. 客户端不存 Last-Event-ID 存 localStorage(单页)或 IndexedDB(多 Tab 共享)。否则刷新页面就丢进度。
进阶:背压与流量控制
高 QPS 下,浏览器处理不过来会产生背压。处理方式:
- 用
ReadableStream的pull()主动控制拉取节奏 - 服务端按客户端确认速率(
ack)推送 - 客户端用 Web Worker 解析,主线程只渲染
总结口诀
fetch + 心跳 + 退避 + Last-Event-ID + 反代配置 + 服务端续传,六件套缺一不可。
排查顺序:先看反代 → 再看服务端心跳 → 再看客户端退避 → 最后看浏览器兼容。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录