# SSE 连接可靠性方案

1. SSE 是什么

SSE,全称 Server-Sent Events,是浏览器和服务端之间的一种服务端单向推送方案。

它的特点是:

  • 前端通过 EventSource 发起一个 HTTP 请求。
  • 后端返回 text/event-stream 类型的响应。
  • 后端不立即结束响应,而是持续向这个响应流中写入数据。
  • 浏览器持续接收服务端推送的事件。
  • 通信方向主要是服务端到前端,前端不能通过同一条 SSE 连接向后端发送消息。

前端示例:

ts 复制代码
const source = new EventSource('/api/sse')

source.onopen = () => {
  console.log('SSE connected')
}

source.onmessage = (event) => {
  console.log('message:', event.data)
}

source.onerror = () => {
  console.log('SSE error')
}

后端响应头通常需要包含:

http 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

SSE 消息格式示例:

txt 复制代码
event: message
data: {"content":"hello"}

注意:一条 SSE 消息必须以空行结束。也就是 data 行后面需要有两个换行,浏览器才会认为这条事件结束并触发对应回调。

2. SSE 和 WebSocket 的区别

SSE 和 WebSocket 都可以用于实时通信,但适用场景不同。

SSE 更适合:

  • 服务端单向推送。
  • AI 流式输出。
  • 任务进度推送。
  • 后台通知。
  • 报表生成进度。
  • 导入导出进度。
  • 系统公告。
  • 低频实时状态变更。

WebSocket 更适合:

  • 高频双向通信。
  • 聊天室。
  • 游戏实时同步。
  • 协同编辑。
  • 客户端和服务端都需要频繁主动发送消息的场景。
  • 二进制数据传输。

简单判断标准:

  • 如果主要是后端向前端推消息,优先考虑 SSE。
  • 如果前后端都需要高频互发消息,优先考虑 WebSocket。

3. SSE 心跳是什么意思

后端每隔 15-30s 发一次心跳,意思是:后端通过同一条 SSE 连接,定时向前端发送一条没有业务含义的小消息。

心跳不是额外建立一个接口,也不是前端轮询,而是后端继续往当前 SSE 响应流里写数据。

推荐心跳格式:

txt 复制代码
event: heartbeat
data: {"time":1784270000000}

前端监听:

ts 复制代码
source.addEventListener('heartbeat', () => {
  lastActiveTime = Date.now()
})

心跳的作用有两个:

  • 告诉浏览器、Nginx、网关、负载均衡器这条连接还活着。
  • 让前端知道连接最近还有数据到达,可以判断连接是否假死。

很多业务场景下,服务端可能很久没有真实业务消息。如果 SSE 连接长时间没有任何数据,中间代理可能会认为连接空闲太久,然后主动断开。心跳就是为了避免这种空闲连接被中间层切断。

4. 注释心跳和事件心跳

SSE 有两种常见心跳写法。

第一种是注释心跳:

txt 复制代码
: ping

这种格式是合法 SSE 注释。它可以让连接产生数据流动,起到保活作用,但通常不会触发前端的 onmessage,也不方便前端更新最后活跃时间。

第二种是事件心跳:

txt 复制代码
event: heartbeat
data: {}

这种方式前端可以通过 addEventListener('heartbeat') 监听到,适合用来更新 lastActiveTime

如果前端需要做心跳超时检测,推荐使用事件心跳。

5. 后端重启、报错、崩溃时前端能知道什么

如果后端正常运行,前端可以持续收到业务消息或心跳。

如果后端重启、进程挂掉、容器退出、机器断开,原来的 SSE 连接会中断,前端不会再收到心跳,也不会再收到业务消息。

此时前端通常能感知到连接异常:

ts 复制代码
source.onerror = () => {
  console.log('SSE connection error')
}

但需要注意:onerror 只能说明 SSE 连接异常,不能精确说明异常原因。

onerror 可能由以下原因触发:

  • 后端服务重启。
  • 后端进程崩溃。
  • SSE 接口返回 500502503
  • Nginx 或网关断开连接。
  • 用户网络断开。
  • 浏览器到服务器之间的链路超时。
  • 代理层空闲超时。

所以前端可以知道"连接异常了",但不能只靠 onerror 百分百判断"后端重启了"。

6. 常见异常场景分析

6.1 服务端正常,但长时间没有业务数据

这种情况下 SSE 连接可能仍然是活的,前端不会触发 onerror

如果后端没有心跳,中间代理可能因为长时间没有数据而断开连接。

解决方案:

  • 后端定时发送 heartbeat
  • 前端收到心跳后更新最后活跃时间。
  • 前端超过一段时间没有收到任何数据时主动重建连接。

6.2 后端业务报错,但 SSE 连接没有断

如果后端只是某个任务执行失败,但 SSE 连接还在,前端不会自动知道这是业务错误。

解决方案:后端通过自定义事件通知前端。

txt 复制代码
event: app-error
data: {"code":"TASK_FAILED","message":"任务执行失败"}

前端监听:

ts 复制代码
source.addEventListener('app-error', (event) => {
  console.warn('app error:', event.data)
})

6.3 后端优雅重启

如果后端能捕获关闭信号,并且还有机会向客户端发送消息,可以在关闭前发送服务重启事件。

txt 复制代码
event: server-restarting
data: {"message":"服务即将重启"}

前端监听:

ts 复制代码
source.addEventListener('server-restarting', () => {
  console.warn('server restarting')
})

这种情况下前端可以展示"服务重启中,正在重连"。

6.4 后端突然崩溃或被强制 kill

如果后端进程直接崩溃、容器被强制杀掉、机器断电,后端来不及发送任何 SSE 事件。

前端只能通过以下方式间接感知:

  • onerror 触发。
  • 长时间没有收到心跳。
  • 重连失败。
  • 后续重连成功。

6.5 网络抖动或代理断开

网络异常和后端异常对前端来说表现可能很像,都是连接断开或长时间无数据。

解决方案不是精确区分所有原因,而是建立统一的恢复机制:

  • 检测连接异常。
  • 标记状态为重连中。
  • 使用退避策略重连。
  • 重连成功后恢复业务。

7. 业界推荐整体方案

可靠的 SSE 方案通常由以下部分组成:

  • 后端定时发送心跳。
  • 前端维护最后活跃时间。
  • 前端做心跳超时检测。
  • 前端监听 onerror
  • 前端使用退避重连。
  • 后端通过自定义事件传递业务错误。
  • 后端优雅重启前发送重启事件。
  • 重要消息带 id
  • 后端支持 Last-Event-ID 补发。
  • Nginx 或网关关闭响应缓冲。
  • 页面销毁时前端主动关闭 SSE。

一句话概括:

txt 复制代码
心跳解决连接是否还活着,onerror 解决连接是否断了,退避重连解决如何恢复,Last-Event-ID 解决消息是否会丢。

8. 前端实现策略

前端需要维护几个状态:

  • 当前 EventSource 实例。
  • 当前连接状态。
  • 最后一次收到数据的时间。
  • 当前重连次数。
  • 心跳检查定时器。
  • 重连定时器。

连接状态可以定义为:

ts 复制代码
type SseStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'error'

核心示例:

ts 复制代码
type SseStatus = 'connecting' | 'open' | 'reconnecting' | 'closed' | 'error'

let source: EventSource | null = null
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null

let status: SseStatus = 'closed'
let retryCount = 0
let lastActiveTime = 0

const HEARTBEAT_TIMEOUT = 60 * 1000
const CHECK_INTERVAL = 10 * 1000

function getReconnectDelay() {
  const delays = [1000, 2000, 5000, 10000, 30000]
  return delays[Math.min(retryCount, delays.length - 1)]
}

function connectSse(url: string) {
  closeSse()

  status = retryCount === 0 ? 'connecting' : 'reconnecting'
  lastActiveTime = Date.now()

  source = new EventSource(url)

  source.onopen = () => {
    status = 'open'
    retryCount = 0
    lastActiveTime = Date.now()
  }

  source.onmessage = (event) => {
    lastActiveTime = Date.now()
    console.log('message:', event.data)
  }

  source.addEventListener('heartbeat', () => {
    lastActiveTime = Date.now()
  })

  source.addEventListener('app-error', (event) => {
    lastActiveTime = Date.now()
    console.warn('app error:', event.data)
  })

  source.addEventListener('server-restarting', (event) => {
    lastActiveTime = Date.now()
    console.warn('server restarting:', event.data)
  })

  source.onerror = () => {
    status = 'error'
    reconnect(url)
  }

  heartbeatTimer = setInterval(() => {
    if (!source) return

    const idleTime = Date.now() - lastActiveTime

    if (idleTime > HEARTBEAT_TIMEOUT) {
      console.warn('SSE heartbeat timeout, reconnecting')
      reconnect(url)
    }
  }, CHECK_INTERVAL)
}

function reconnect(url: string) {
  source?.close()
  source = null

  if (reconnectTimer) return

  status = 'reconnecting'

  const delay = getReconnectDelay()
  retryCount += 1

  reconnectTimer = setTimeout(() => {
    reconnectTimer = null
    connectSse(url)
  }, delay)
}

function closeSse() {
  source?.close()
  source = null
  status = 'closed'

  if (heartbeatTimer) {
    clearInterval(heartbeatTimer)
    heartbeatTimer = null
  }

  if (reconnectTimer) {
    clearTimeout(reconnectTimer)
    reconnectTimer = null
  }
}

如果后端心跳间隔是 20s,前端超时时间建议设为 60s90s。不要把超时时间设得太短,否则普通网络抖动也会导致频繁重连。

9. 原生 EventSource 的自动重连

原生 EventSource 在连接异常时会自动重连,这是浏览器内置行为。

但实际项目中仍然常常需要手动控制重连,原因包括:

  • 需要展示连接状态。
  • 需要处理心跳超时导致的假死连接。
  • 需要控制重连间隔,避免高频请求。
  • 需要在页面销毁时彻底关闭连接。
  • 需要在鉴权失效时停止重连并引导用户重新登录。

因此,推荐做法是把 SSE 封装成一个统一的客户端模块或 Vue hook,在其中集中处理连接、心跳、异常、重连、关闭。

10. 退避重连策略

后端异常时,前端不能无限制高频重连,否则会在服务恢复前给后端和网关造成额外压力。

推荐使用退避策略:

ts 复制代码
const delays = [1000, 2000, 5000, 10000, 30000]

含义:

  • 第 1 次失败后,等待 1s 重连。
  • 第 2 次失败后,等待 2s 重连。
  • 第 3 次失败后,等待 5s 重连。
  • 第 4 次失败后,等待 10s 重连。
  • 后续最多等待 30s

重连成功后,应将失败次数清零。

如果系统用户量很大,可以在退避时间上增加随机抖动,避免大量客户端同时重连。

示例:

ts 复制代码
function getReconnectDelay() {
  const delays = [1000, 2000, 5000, 10000, 30000]
  const baseDelay = delays[Math.min(retryCount, delays.length - 1)]
  const jitter = Math.floor(Math.random() * 1000)
  return baseDelay + jitter
}

11. Last-Event-ID 和消息补偿

如果业务消息非常重要,不能丢消息,后端需要给每条重要消息加 id

示例:

txt 复制代码
id: 1024
event: message
data: {"content":"xxx"}

浏览器在 SSE 断开后重新连接时,会自动带上 Last-Event-ID 请求头。后端可以根据这个 ID 判断客户端最后收到哪条消息,然后补发之后遗漏的消息。

这适合:

  • 审批流状态。
  • 订单状态。
  • 任务步骤进度。
  • 重要通知。
  • 需要保证前端最终一致的消息流。

如果只是 AI 流式输出,是否需要补偿要看业务要求。如果用户刷新页面后可以重新发起任务或重新查询结果,则可以不做严格补偿。

前端也建议做消息去重,避免重连补发导致重复处理。

12. 鉴权方案

原生 EventSource 不能直接设置自定义请求头,例如不能直接加:

http 复制代码
Authorization: Bearer token

常见鉴权方案:

  • 使用 Cookie 鉴权。
  • 在 URL 上携带短期 token。
  • 使用 fetch 读取流,自行实现 SSE 解析。

推荐优先使用 Cookie 鉴权,原因是原生 EventSource 使用简单,浏览器会自动携带同域 Cookie。

如果必须使用 Authorization 请求头,可以考虑不用原生 EventSource,改用 fetchReadableStream,但实现复杂度会更高,需要自己处理流解析、重连、事件格式。

13. Nginx 和网关配置

SSE 很怕代理层缓冲。如果 Nginx 开启响应缓冲,后端即使已经写入消息,Nginx 也可能先缓存,不会立即推给浏览器。

推荐配置:

nginx 复制代码
location /api/sse {
  proxy_pass http://backend;
  proxy_http_version 1.1;
  proxy_set_header Connection "";
  proxy_buffering off;
  proxy_cache off;
  proxy_read_timeout 3600s;
}

关键配置说明:

  • proxy_http_version 1.1:使用 HTTP/1.1 长连接。
  • proxy_set_header Connection "":避免代理错误处理连接头。
  • proxy_buffering off:关闭响应缓冲,保证消息实时到达前端。
  • proxy_cache off:关闭缓存。
  • proxy_read_timeout 3600s:延长代理读取超时时间。

如果后端能设置响应头,也可以加:

http 复制代码
X-Accel-Buffering: no

用于提示 Nginx 不要缓冲该响应。

14. 后端实现注意事项

后端需要注意:

  • 设置正确响应头。
  • 写入消息后及时 flush。
  • 定时发送心跳。
  • 客户端断开后清理连接资源。
  • 服务关闭前尽量发送 server-restarting
  • 业务异常用 app-error 事件通知。
  • 重要消息写入消息存储,支持断线补发。
  • 控制单用户连接数,避免重复打开多个连接。

SSE 消息格式可以封装成统一函数:

txt 复制代码
id: 1024
event: message
data: {"content":"hello"}

如果只有 data,事件会进入前端的 source.onmessage

如果指定了 event,前端需要用 source.addEventListener(eventName, handler) 监听。

15. 前端页面生命周期

在 Vue 页面中使用 SSE 时,需要在页面卸载时关闭连接。

ts 复制代码
import { onMounted, onUnmounted } from 'vue'

onMounted(() => {
  connectSse('/api/sse')
})

onUnmounted(() => {
  closeSse()
})

如果不关闭,用户离开页面后连接仍可能存在,导致:

  • 多个页面重复连接。
  • 重复收到消息。
  • 内存泄漏。
  • 后端连接数增加。

如果 SSE 是全局通知类能力,可以放在全局 store 或应用入口中统一管理,而不是每个页面单独创建。

16. 状态展示建议

前端可以对用户展示以下状态:

  • connecting:正在连接。
  • open:已连接。
  • reconnecting:连接中断,正在重连。
  • error:连接异常。
  • closed:连接已关闭。

并不一定所有状态都要强提醒用户。推荐策略:

  • 短时间重连中可以弱提示。
  • 长时间重连失败再明显提示。
  • 关键任务场景需要提示"连接中断,正在恢复"。
  • 后端明确发送 server-restarting 时提示"服务重启中"。

17. 最终推荐标准

后端标准:

  • SSE 响应头正确。
  • 15-30s 发送一次 heartbeat
  • 业务异常发送 app-error
  • 优雅重启前发送 server-restarting
  • 重要消息携带 id
  • 支持 Last-Event-ID 补偿。
  • 写入数据后及时 flush。
  • 客户端断开后释放资源。
  • Nginx 或网关关闭 buffering。

前端标准:

  • 监听 onopenonmessageonerror
  • 监听 heartbeat
  • 收到任何消息都更新 lastActiveTime
  • 超过 60-90s 没有任何消息时主动重建连接。
  • 重连使用退避策略。
  • 重连成功后清零失败次数。
  • 页面销毁时调用 close()
  • 关键业务消息做去重。
  • 鉴权失效时停止重连并走登录流程。

18. 总结

SSE 连接可靠性不能只依赖浏览器的 EventSource 自动重连。

成熟方案应该同时处理四件事:

  • 用心跳判断连接是否还活着。
  • onerror 感知连接是否断开。
  • 用退避重连恢复连接。
  • Last-Event-ID 处理关键消息补偿。

心跳不是为了让后端崩溃后还能继续发送消息,而是为了在后端正常时保持连接活跃,并帮助前端识别连接假死。

当后端真正重启、崩溃或断开时,前端无法继续收到原连接上的消息,只能通过 onerror、心跳超时和重连结果判断连接状态,并在服务恢复后重新建立 SSE 连接。

相关推荐
weedsfly1 小时前
从表单校验到订单流转,彻底搞懂前端开发中的策略模式与状态模式
前端·javascript·面试
qq_589666051 小时前
npm install npm install -g @vue/cli 分别是什么意思以及安装路径
前端·vue.js·npm
用户0207199207722 小时前
Kafka 与事务 Outbox
前端
IT_陈寒2 小时前
Vite的静态资源路径搞得我头秃,原来这样配才对
前端·人工智能·后端
BerryS3N2 小时前
前端应用的离线暂停更新策略:构建稳定可靠的渐进式部署方案
前端
LaughingZhu3 小时前
Product Hunt 每日热榜 | 2026-07-17
前端·数据库·人工智能·经验分享·mysql·chatgpt·html
潇凝子潇3 小时前
UnsupportedOperationException
java·前端·数据库
卷无止境3 小时前
Python 跨平台 GUI 解决方案全景报告
前端·python
万少9 小时前
用腻了 WorkBuddy 的默认界面?这个开源小工具给它换上了毛玻璃
前端·javascript·后端