SSE-生产环境核心避坑指南

52 阅读4分钟

1、SSE 需要配合http2实现

HTTP/1.1 协议下,浏览器对同一个域名下**的并发 TCP 连接数有严格限制,通常为 6 个

应用中打开了 6 个 SSE 连接,当尝试打开第 7 个页签或发起一个新的普通请求时,请求会被浏览器挂起,直到前面的某个连接关闭。如图

20260129-102744.jpeg

HTTP/2 引入了多路复用机制,改变了这一局面。

最大并发流限制默认通常为 100,如果应用极其复杂,在一个页面内打开了上百个流,依然会触碰到上限。

2、断线重连与数据连续性

2.1 关键陷阱:为什么我的重连不带 Last-Event-ID

  • 后端发送的报文格式不标准,没有id字段
  • 手动断开后的再次重连不会携带

2.2 心跳机制优化:心跳包是否该带 ID?(通常不带

每 15-30 秒发一次 :ping\n\n。维持 TCP 长连接,这样不会触发前端onmessage逻辑,减少前端 CPU 的消耗。并且不用在业务逻辑里过滤心跳数据。

3、代理与网关适配

3.1 proxy_buffering off:为什么我的流是“一坨一坨”出来的,而不是“一个字一个字”?

        Nginx 默认会开启缓冲区,攒满一波数据(通常是 4k 或 8k)再发给客户端,以提高网络效率

3.2 listen 443 ssl http2:开启HTTP/2协议,解决HTTP/1.1的同域名请求限制的问题

4、SSE错误处理/重连机制

  在生产环境中,我们通常将错误分为两类:致命错误(需立即停止)和非致命错误(需尝试恢复)。

4.1 致命错误: 401 & 其他4开头的状态,throw 并手动处理,停止重试。
4.2 致命错误: 500/502/503 (服务器异常),保持沉默,让库自动进行指数退避重连。
4.3 重试原则: onopen中抛出错误,会在onerror中接收到。在onerror中继续向上抛出错误,才会停止重试。
4.4 错误处理原则: 在onopen中只做错误的分发,抛出错误。在onerror中判断终止重试并进行业务逻辑编写。

为什么要这么设计错误处理?

1、职责分离,符合单一职责原则,onopen负责“诊断”,onerror负责“处理和决策”

2、避免重复处理,onopen中抛出的错误不能直接终止重试,需要在onerror中再次抛出才会终止重试,这样就可能在onerror中需要再次检查状态码

3、无法利用 onerror 的返回值控制重试时间,比如401,在onopen中直接跳转登录了。

5、代码实现

/**
 * SSE 连接管理
 * 全局连接:消息通过 EventBus 广播
 * 业务连接:消息直接回调处理
 */

import { fetchEventSource } from "@microsoft/fetch-event-source";
import { eventBus, EventNames } from "@/utils/eventBus";
import { config } from "@/api/axios/config";
import router from "@/router";
import type { SSEEventHandler, SSEConnectionConfig, BusinessSSEOptions } from "./types";

const { base_url } = config;

export type { SSEEventHandler, SSEConnectionConfig, BusinessSSEOptions };

// 致命错误:不可重试的错误(如401认证失败、4xx客户端错误)
class FatalError extends Error {
	constructor(message: string) {
		super(message);
		this.name = "FatalError";
	}
}

// 可重试错误:临时性错误(如5xx服务器错误、网络问题)
class RetriableError extends Error {
	constructor(message: string) {
		super(message);
		this.name = "RetriableError";
	}
}

export class SSEConnection {
	private controller: AbortController | null = null;
	private lastEventId: string | null = null;
	private config: SSEConnectionConfig;

	constructor(config: SSEConnectionConfig) {
		this.config = config;
	}

	async connect(): Promise<void> {
		if (this.controller) return;

		const token = getAccessToken(); // 获取token
		this.controller = new AbortController();
		const method = this.config.method || "GET";
		const headers: Record<string, string> = {
			Accept: "text/event-stream",
			Authorization: `Bearer ${token}`,
                   // 可手动维护last-event-id,也可以交由库维护
			...(this.lastEventId ? { "last-event-id": this.lastEventId } : {}) 
		};

		if (method === "POST" && this.config.body) {
			headers["Content-Type"] = "application/json";
		}

		try {
			await fetchEventSource(this.config.url, {
				method,
				headers,
				body: method === "POST" && this.config.body ? JSON.stringify(this.config.body) : undefined,
				signal: this.controller.signal,
				openWhenHidden: false,

				onopen: async response => {
					if (response.ok) {
						this.config.isGlobal && eventBus.emit(EventNames.SSE_CONNECTED, { timestamp: Date.now() });
						this.config.onConnected?.();
						return;
					}

					if (response.status === 401) {
						throw new FatalError("登录失效,请重新登录");
					}

					if (response.status >= 400 && response.status < 500) {
						throw new FatalError(`客户端错误: ${response.status}`);
					}

					console.log(`[SSE ${this.config.id}] 连接失败 ${response.status},1秒后重试...`);
					await new Promise(resolve => setTimeout(resolve, 1000));
					throw new RetriableError(`服务器暂时不可用: ${response.status}`);
				},

				onmessage: event => {
					if (event.id) this.lastEventId = event.id;

					let data: any;
					try {
						data = JSON.parse(event.data);
					} catch {
						data = event.data;
					}

					const eventName = event.event || "message";

					if (this.config.isGlobal) {
						// 全局连接:通过 EventBus 广播
						eventBus.emit(eventName, data);
					} else {
						this.config.handlers?.[eventName]?.(data);
					}
				},

				onerror: error => {
					this.config.isGlobal && eventBus.emit(EventNames.SSE_ERROR, { error, timestamp: Date.now() });
					this.config.onError?.(error);

					if (error instanceof FatalError) {
						if (error.message.includes("登录失效")) {
							removeToken();
							router.replace(LOGIN_URL);
						}
						this.disconnect();
						throw error; // 向上抛出,彻底停止重试
					}

					return 1000;
				},

				onclose: () => {
					this.config.isGlobal && eventBus.emit(EventNames.SSE_DISCONNECTED, { timestamp: Date.now() });
					this.config.onClosed?.();
				}
			});
		} catch (error: any) {
			console.error(`SSE 连接失败 [${this.config.id}]:`, error);
		}
	}

	disconnect(): void {
		this.controller?.abort();
		this.controller = null;
	}

	isConnected(): boolean {
		return this.controller !== null;
	}
}

let globalConnection: SSEConnection | null = null;

// 初始化全局 SSE 连接
export const initSSEConnection = async (url?: string) => {
	if (globalConnection?.isConnected()) return;

	globalConnection = new SSEConnection({
		id: "global",
		url: url || `${base_url}/xxxx`,
		isGlobal: true
	});

	await globalConnection.connect();
};

export const disconnectSSE = () => {
	globalConnection?.disconnect();
	globalConnection = null;
};

// 构建查询参数字符串
const buildQueryString = (params: Record<string, any>): string => {
	const queryParams = new URLSearchParams();
	Object.entries(params).forEach(([key, value]) => {
		if (value !== undefined && value !== null) {
			queryParams.append(key, String(value));
		}
	});
	return queryParams.toString();
};

// 创建业务 SSE 连接
export const createBusinessSSE = (options: BusinessSSEOptions): SSEConnection => {
	const { id, path, method = "GET", params, eventHandlers, onConnected, onClosed, onError } = options;

	let url = `${base_url}${path}`;
	let body: Record<string, any> | undefined;

	if (method === "GET" && params) {
		const queryString = buildQueryString(params);
		if (queryString) url += `?${queryString}`;
	} else if (method === "POST") {
		body = params;
	}

	const connection = new SSEConnection({
		id,
		url,
		method,
		body,
		isGlobal: false,
		handlers: eventHandlers,
		onConnected,
		onClosed,
		onError
	});

	// 自动连接
	connection.connect();
	return connection;
};

export default { initSSEConnection, disconnectSSE, createBusinessSSE };
  • 通过initSSEConnection方法创建全局的SSE连接,可以用于网站的广播、消息等,通过eventBus来发布各种类型的消息,在不同业务中接收消息并解析。
  • 通过createBusinessSSE方法创建业务SSE连接,如在进入某个页面或点击某个按钮建立连接,在离开的时候断开连接,专为某一种类型的业务而建立的SSE连接。
  • 两个方法实际都是调用SSEConnection类来创建SSE实例,SSE的错误、重试、分发等机制统一封装在此类中,这部分代码整体可公用,后续无需再更改。

以上就是我在生产中遇到的问题和最后的结论,后续还会针对fetch-event-source库的源码进行详细分析,欢迎大家关注~