SSE 流式传输:中断超时处理

前言

在开发 AI 聊天应用时,fetch-event-source 几乎是前端标配。但你是否思考过:为什么原生的 EventSource 不行?它是如何解析二进制流的?当网络波动导致连接"假死"时,如何实现无感重连和数据去重?本文将带你拆解这些核心细节。

一、 为什么原生 EventSource 在 AI 场景"退环境"了?

原生 EventSource 虽好,但在复杂的 AI 业务场景中有两个"致命伤":

  1. 仅支持 GET 请求:AI 对话通常需要发送长篇累牍的上下文(Context),URL 长度限制会导致请求失败。
  2. 无法自定义 Header :无法在请求头中携带 Authorization 令牌,给鉴权带来了麻烦。

fetch-event-source 的原理 :它是基于原生 fetchReadableStream(可读流) 实现的。它通过手动解析 HTTP 响应体中的二进制数据,模拟了 SSE 的行为,同时继承了 fetch 支持各种 Method 和 Header 的灵活性。


二、 核心实战:如何处理 SSE 异常中断与超时?

在长连接中,最怕"连接还在,但数据没了"的假死状态。我们需要对库进行二次封装,引入超时检测指数退避重连

1. 超时检测机制

设置一个心跳定时器。如果在规定时间内(如 15s)没有收到任何 onmessage 信号,说明连接可能已失效。

  • 动作 :主动调用 abort() 中断当前请求,并触发重连。
  • 重置:每当有新数据到达或连接开启时,重置该定时器。

2. 指数退避自动重连

为了减轻服务器压力,重连间隔不应是固定的。

  • 策略:从 2s 开始,每次失败翻倍(2s → 4s → 8s...),上限 30s。
  • 终止:设置最大重连次数(如 10 次),失败后提示用户"服务器繁忙,请手动重试"。

3. 断点续传与去重

重连后,后端可能会重新推送历史数据。

  • 前端方案 :维护一个 lastMsgId。请求时带上这个标识,让后端从断点处开始推送;或者前端根据 id 对收到的消息进行 Map 去重。

三、 中断超时处理实现:基于fetchEventSource 简易实现

js 复制代码
import { fetchEventSource } from '@microsoft/fetch-event-source'
import { ElMessage, ElMessageBox } from 'element-plus'

// 全局状态管理(避免多请求冲突)
let controller = new AbortController()
let timeoutTimer = null // 超时定时器
let reconnectCount = 0 // 重连次数
let reconnectInterval = 2000 // 初始重连间隔(2s)
const MAX_RECONNECT_COUNT = 10 // 最大重连次数
const MAX_RECONNECT_INTERVAL = 30000 // 最大重连间隔(30s)
let lastMessageId = '' // 记录最后一条消息ID(断点续传用)

/**
 * 重置超时定时器(收到消息/建立连接时调用)
 * @param {number} timeout 超时时间(默认30s)
 */
const resetTimeoutTimer = (timeout = 30000) => {
  // 清除原有定时器
  if (timeoutTimer) clearTimeout(timeoutTimer)
  // 新建超时定时器:超时未收到消息则主动中断
  timeoutTimer = setTimeout(() => {
    ElMessage.warning('连接超时,正在尝试重连...')
    controller.abort() // 主动中断请求
    reconnectStream() // 触发重连
  }, timeout)
}

/**
 * 重连流式请求(指数退避策略)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调
 */
const reconnectStream = async (url, headers, data, handleMessage) => {
  // 超过最大重连次数,停止自动重连
  if (reconnectCount >= MAX_RECONNECT_COUNT) {
    ElMessageBox.alert('服务器繁忙,请稍后手动重试', '重连失败', {
      confirmButtonText: '确定'
    })
    // 重置重连状态
    reconnectCount = 0
    reconnectInterval = 2000
    return
  }

  // 指数退避:间隔翻倍,不超过30s
  const currentInterval = Math.min(reconnectInterval, MAX_RECONNECT_INTERVAL)
  ElMessage.info(`第${reconnectCount + 1}次重连,间隔${currentInterval / 1000}s...`)

  // 延迟重连
  await new Promise((resolve) => setTimeout(resolve, currentInterval))

  // 更新重连状态
  reconnectCount++
  reconnectInterval *= 2

  // 重新发起请求(携带最后一条消息ID,实现断点续传)
  requestStream(
    url,
    headers,
    {
      ...data,
      lastMessageId: lastMessageId // 传给后端,让后端从断点续传
    },
    handleMessage
  )
}

/**
 * 流式请求核心方法(带超时、重连、断点续传)
 * @param {string} url 接口地址
 * @param {Object} headers 请求头
 * @param {Object} data 请求参数
 * @param {Function} handleMessage 消息处理回调(接收流式数据)
 */
export const requestStream = (url, headers, data, handleMessage) => {
  // 中断原有请求
  if (controller) controller.abort()
  controller = new AbortController()

  // 初始化超时定时器(30s超时检测)
  resetTimeoutTimer()

  fetchEventSource(url, {
    method: 'POST',
    signal: controller.signal,
    headers: {
      ...headers,
      Accept: 'text/event-stream', // SSE必需头
      'Cache-Control': 'no-cache'
    },
    body: JSON.stringify(data),
    openWhenHidden: true, // 页面隐藏时继续请求
    async onopen(response) {
      console.log('建立连接的回调')
      // 连接建立:重置超时定时器+重连状态
      resetTimeoutTimer()
      reconnectCount = 0
      reconnectInterval = 2000

      // 校验响应合法性
      if (!response.ok) {
        throw new Error(`连接失败,状态码:${response.status}`)
      }
    },
    onmessage(msg) {
      // 收到消息:重置超时定时器
      resetTimeoutTimer()

      // 记录最后一条消息ID(断点续传核心)
      if (msg.id) lastMessageId = msg.id
      // 处理消息(去重逻辑:避免重连后数据重复)
      handleMessage(msg)
    },
    onclose() {
      console.log('连接正常关闭')
      // 清除定时器+中断请求
      if (timeoutTimer) clearTimeout(timeoutTimer)
      controller.abort()
      // 重置状态
      reconnectCount = 0
      reconnectInterval = 2000
      lastMessageId = ''
    },
    onerror(err) {
      // 清除超时定时器
      if (timeoutTimer) clearTimeout(timeoutTimer)

      // 手动中断不触发重连(比如用户点击停止)
      if (controller.signal.aborted) {
        console.log('用户手动中断请求')
        return
      }

      // 异常重连
      ElMessage.error(`连接异常:${err.message || '网络错误'}`)
      reconnectStream(url, headers, data, handleMessage)

      // 必须抛出错误才会停止当前请求循环
      throw err
    }
  })
}

/**
 * 停止流式请求(手动中断)
 */
export const stopRequest = () => {
  // 清除超时定时器
  if (timeoutTimer) {
    clearTimeout(timeoutTimer)
    timeoutTimer = null
  }
  // 中断请求
  if (controller) {
    controller.abort()
    controller = new AbortController()
  }
  // 重置重连状态
  reconnectCount = 0
  reconnectInterval = 2000
  lastMessageId = ''
  ElMessage.info('已停止数据请求')
}

四、 注意:关于 Nginx 与浏览器限制

  1. Nginx 缓存屏蔽 :一定要记得设置 proxy_buffering off;,否则 Nginx 会等缓冲区满了才一次性吐给前端,导致流式效果失效。
  2. 浏览器连接数限制 :如果是 HTTP/1.1,浏览器对同一个域名的长连接通常限制在 6 个。如果打开多个 AI 对话页,可能会导致后续连接卡死。建议升级 HTTP/2,它可以多路复用,避开此限制。
  3. 手动停止 vs 自动重连 :当用户点击"停止生成"时,必须标记一个 manualStop 状态位,否则 onerror 可能会误以为是网络异常而不断尝试重连。

五、💡 扩展:异步并发池 (Async Pool)

它不直接用于单个 SSE 连接,但在批量 AI 任务处理(例如一次性给 100 张图片生成描述)时非常有用。它可以限制同时进行的 HTTP 请求数量,防止瞬间撑爆浏览器带宽或后端并发限制。

1. 归属识别:唯一 ID + 专属缓存

  • 每个请求分配requestId(如stream-request-0);
  • streamDataCacherequestId为 key,每个请求的片段只往自己的缓存里加;
  • 即使多个请求的onmessage同时触发,也不会串数据(比如stream-request-0的片段绝不会跑到stream-request-1的缓存里)。

2. 有序拼接:数组按顺序存储片段

  • 每个请求的缓存里用fragments数组存储片段;
  • onmessage每次触发时,cache.fragments.push(msg.data)保证片段按返回顺序存储;
  • 收到结束标识[DONE]时,用join('')拼接数组,得到完整结果。

3. 并发控制:不等待 Promise 完成,只控制启动数

  • runningRequestCount记录正在运行的请求数;
  • runTasks里用while (runningRequestCount >= limit)等待,直到有请求结束、并发数下降;
  • 每个请求结束后(onclose/onerror),runningRequestCount--,并自动执行下一个任务;
  • 这种方式既限制了并发数,又不阻塞流式请求的 "持续返回片段"。
js 复制代码
/**
 * 异步任务池(适配流式请求的并发控制)
 * @param {Array<Object>} requestList 批量请求列表(含url/headers/data)
 * @param {number} limit 最大并发数
 * @param {Function} onComplete 单个请求完成回调(参数:requestId, fullResult)
 */
export const batchStreamRequest = async (requestList, limit = 3, onComplete) => {
  // 为每个请求分配唯一ID
  const requestListWithId = requestList.map((item, index) => ({
    ...item,
    requestId: `stream-request-${index}`
  }))

  // 任务执行队列:递归执行,控制并发数
  const runTasks = async (taskIndex = 0) => {
    // 所有任务处理完毕
    if (taskIndex >= requestListWithId.length) return

    const currentTask = requestListWithId[taskIndex]
    const { requestId, url, headers, data } = currentTask

    // 等待:直到并发数低于限制
    while (runningRequestCount >= limit) {
      await new Promise((resolve) => setTimeout(resolve, 100)) // 每100ms检查一次
    }

    // 启动当前流式请求
    runningRequestCount++
    console.log(`启动请求${requestId},当前并发数:${runningRequestCount}`)

    // 执行单个流式请求(不等待完成,只标记启动)
    singleStreamRequest(requestId, url, headers, data, onComplete)
      .catch((err) => console.error(`请求${requestId}失败:`, err))
      .finally(() => {
        // 当前请求结束后,自动执行下一个任务
        runTasks(taskIndex + 1)
      })

    // 立即执行下一个任务(检查并发数)
    runTasks(taskIndex + 1)
  }

  // 启动任务队列
  await runTasks(0)
}

/**
 * 停止单个/所有流式请求
 * @param {string} [requestId] 可选:指定停止的请求ID,不传则停止所有
 */
export const stopStreamRequest = (requestId) => {
  if (requestId) {
    // 停止指定请求
    const controller = requestControllers[requestId]
    if (controller) {
      controller.abort()
      delete requestControllers[requestId]
      // 标记缓存为完成
      if (streamDataCache[requestId]) {
        streamDataCache[requestId].isCompleted = true
      }
      runningRequestCount--
    }
  } else {
    // 停止所有请求
    Object.keys(requestControllers).forEach((id) => {
      requestControllers[id].abort()
      delete requestControllers[id]
      if (streamDataCache[id]) {
        streamDataCache[id].isCompleted = true
      }
    })
    runningRequestCount = 0
    ElMessage.info('已停止所有流式请求')
  }
}

// ---------------------- 调用示例 ----------------------
// 批量请求列表
const batchRequests = [
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSO单点登录' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍Token无感刷新' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍SSE流式请求' } },
  { url: '/api/stream/ai', headers: {}, data: { prompt: '介绍asyncPool并发控制' } }
]

// 执行批量请求(限制最大并发数2)
batchStreamRequest(batchRequests, 2, (requestId, fullResult) => {
  // 单个请求完成后的回调:拿到拼接好的完整结果
  console.log(`请求${requestId}完成,完整结果:`, fullResult)
  // 这里可以做后续处理:渲染、入库等
})
相关推荐
李剑一2 小时前
别再瞎写电子围栏了!这2种动态效果,科技感直接拉满,源码直接抄走!
前端·vue.js·cesium
木易士心2 小时前
从 MVP 到千万级并发:AI 在前后端开发中的差异化落地指南
前端·后端
葡萄城技术团队2 小时前
字体与打印:前端开发最常见的三个“为什么”
前端
王夏奇3 小时前
python中的深浅拷贝和上下文管理器
java·服务器·前端
siger3 小时前
徒手开荒-我用纯Nodejs+pnpm+monorepo改造了一个多vue2的iframe"微前端"项目
前端·node.js·前端工程化
lichenyang4533 小时前
海克斯大乱斗攻略网站 —— 从零开发到云服务器部署全记录
前端
你的代码僚机3 小时前
《别再被 SSO 骗了!前端单点登录原理+避坑指南》
前端
皙然3 小时前
深入理解 Java HashMap:从底层原理、源码设计到面试考点全解析
java·开发语言·面试
不懂代码的切图仔3 小时前
移动端h5实现横屏在线签名
前端·微信小程序