首页/文章/八股文

SSE 长链接频繁断连怎么处理

2026-09-15
1505 字约 6 分钟
...

场景

AI 流式问答、实时通知、大模型 token 推送等场景下,前端通过 SSE(Server-Sent Events)建立长链接接收服务端持续推送。但生产环境经常遇到:

  • 用户网络切换(4G → WiFi、VPN 进出、地铁进隧道)
  • Nginx 反代默认 60s 超时主动断开
  • 云厂商负载均衡器(ALB、SLB)空闲连接清理
  • 服务端网关 / 反代默认无 keep-alive 优化
  • 浏览器后台标签节流导致连接被回收

接口返回的事件流被中间链路频繁掐断,前端页面白屏、AI 回答到一半就停。

核心答案

SSE 断连处理是一个多层防线问题,不能只靠前端。需要从浏览器、网络、反代、服务端、业务层五层分别加固:

  1. 浏览器层:用 EventSource 自带的重连机制 + 自研指数退避做兜底
  2. 网络层:开启 fetch + ReadableStream 自管心跳,跳出 EventSource 的限制
  3. 反代层:Nginx / CDN 关闭缓冲、关闭 idle 超时、加 keep-alive
  4. 服务端:定期发心跳帧、定时器续期、消息持久化 + Last-Event-ID 续传
  5. 业务层:消息 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 + 反代配置 + 服务端续传,六件套缺一不可。

排查顺序:先看反代 → 再看服务端心跳 → 再看客户端退避 → 最后看浏览器兼容。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
八股文2026-08-27
定时器按顺序播放多个音频,切后台音频会乱
本身不会补触发,但 它会被系统级冻结 ——iOS Safari 后台完全停止计时,切回前台后 只补执行一次 (不是不补,而是"缺失的中间状态补不上")。间隔短的不会出大问题, 间隔长的会出现"音频流被压缩" ——切回来后本该播 5 分钟的间隙,实际只过了 3 分钟,结果整段对不上。 核心原则 :定时器只能用来"提醒一次", 真正决定"该不该播"的是绝对时间戳 。 setTimeout 在后台会发生什么(按平台) | 平台 | 后台行为...
面试
八股文2026-08-27
线上项目白屏的原因
白屏的本质是 渲染管线某一环断了 ——可能是资源、JS、接口、路由、样式、兼容性任一环节出问题。排查按"控制台 → 网络 → DOM → 环境"四步定位。 本质(前端类比) 把网页想成一栋楼: 白屏 ≠ 一定是同一种原因——这是面试想听的层次。 六大类原因(按出现频率) | 类别 | 典型表现 | 真实案例 | | : | : | : | | ① 资源加载失败 | DOM 是空的 | 入口 JS 404、CDN 挂了、CSS 阻塞 |...
面试
八股文2026-08-27
背景图就是 1MB 大图,怎么优化
1MB 大背景图优化分 三步 :压缩体积(10 30x)、按需加载(按设备/视口/网速)、渲染期优化(GPU 合成)。背景图跟 不同——它是 CSS,不走浏览器的原生懒加载机制,得手动处理。 背景图 vs 的关键区别(先讲清楚这个) 这是面试官想听的"针对性认知"——背景图不是普通图片,不能套通用方案。 三步优化(按优先级) 第 1 步:压缩体积(最重要,立竿见影) 1MB 的来源一般是这几种 ,对应解决方案: | 原始问题 | 体积来...
面试
八股文2026-08-27
项目里很多图片和视频,怎么优化
图片视频优化分 四层 :网络层(CDN/格式)、加载层(懒加载/预加载)、渲染层(解码/缓存)、业务层(按需/降级)。面试要把这四层都讲到位才算有体系。 四层优化模型 第 1 层:网络层(省钱、省时间) 核心目标:让资源体积小、让用户拿到资源快 | 手段 | 作用 | 关键点 | | : | : | : | | 图片格式 | WebP/AVIF 比 JPEG 小 25 50% | 兼容 fallback | | 视频格式 | H.265...
面试
八股文2026-08-20
Embedding 向量模型:从语义表示到相似度计算
前言 大模型「读懂」文字靠的是 token,但 token 之间只有离散的编号关系,模型并不知道「苹果」和「李子」在语义上很近。要让程序能「理解」两段文字的相似程度,必须先把文本映射成一个 高维向量 ,再用几何方法比较。这一步就是 Embedding。 本篇基于我本地 今天的真实代码,从语义表示讲到余弦相似度,并复盘几个踩过的真实坑。 一、为什么需要 Embedding 传统关键词检索是「字面匹配」: Embedding 做的是「语义匹...
面试
八股文2026-08-19
前端面试100题
前端面试 100 题 适用方向:中高级前端 / React / Vue / Next.js / Nuxt / TypeScript / 工程化 / 实时通信 / Electron / Node.js / AI 应用前端 使用方式:优先掌握“标准回答”,再练“面试官追问”,最后把“结合你的简历怎么答”组织成自己的项目故事。 说明 “结合你的简历怎么答”只使用你简历中已经出现的项目与技术事实。 “标准回答 / 追问”属于通用前端知识总结,用...
面试

评论

请登录后发表评论

去登录
加载评论中...

目录