SSE 深度解析:从协议原理到生产落地
三行代码跑通 SSE 很容易。难的是第二天 Nginx 把你消息攒了 60 秒才发出去,第三周断连后客户端再也收不到数据,第三个月十万连接把服务器打到 OOM——而你翻遍日志,找不到一个报错。
这篇文章把 SSE 从头剖到尾:协议本质、业务选型、服务端与客户端的工程实现、安全防御纵深、性能的分层调优。每一层都带着踩坑实录。
一、先从业务上想清楚:为什么我们需要"服务端主动推送"
1.1 一个每天都在发生的故事
下午三点,产品经理走过来拍了你肩膀:“对话框能不能像 ChatGPT 那样一个字一个字往外蹦?”
你脑子里闪过三个方案:
方案 A —— 前端轮询。 每秒发一个 GET /messages,有数据就渲染,没有就空转。但是——1000 个用户同时开页面,每秒 1000 个请求砸到后端,其中 950 个返回空。CPU 和带宽一半花在了"确认没有新消息"这件事上。
方案 B —— WebSocket。 全双工,性能好,业界标杆。但是——你的场景是服务端单向推送消息给浏览器,客户端几乎不发。为了一个"服务器说话"的需求,引入一套双向协议,等于买了一辆卡车来送一份外卖。
方案 C —— SSE。 就一条 HTTP GET 连接,服务端在 Response Body 里持续写数据,浏览器用原生 EventSource API 一行一行读。不需要新协议,不需要额外的库,不需要改造负载均衡。
你会选哪个?
这其实不是一个技术问题。这是一个"用最合适的工具解决最具体的问题"的决策问题。SSE 的生存空间就在这里——单向数据推送场景下,它比轮询聪明,比 WebSocket 轻。
1.2 四个最适合 SSE 的业务画面
画面一:AI 对话流式输出。 用户发了一条 prompt,大模型一个字一个字地生成回复。数据流向天然是服务端 → 客户端。从 OpenAI 到 Anthropic 到国内的文心、通义千问,流式 API 清一色走 SSE。这不是巧合——AI 文本生成是单向数据流的最完美范本。
画面二:实时数据大盘。 屏幕上一个个数字在跳动:实时订单量、服务器 CPU 水位、在线用户数。这些数据每 2 秒刷新一次,用 SSE 替代前端轮询,99% 的无用请求直接消失。
画面三:业务通知推送。 “订单已发货”、“审批被驳回”、“系统将在 30 分钟后维护”——一条 SSE 连接,所有通知实时到达,不需要用户手动刷新页面。
画面四:异步任务进度追踪。 一个 Excel 导出任务,后端逐行计算,前端进度条从 10% 走到 100%。上传走 HTTP POST,进度走 SSE,各司其职。
1.3 什么时候该绕道走
SSE 的边界很清晰。遇到这些场景,别硬套:
- 双向高频通信(聊天 IM、多人协作、在线游戏)→ 上 WebSocket。SSE 是单向通道,客户端要往回发消息只能另开 HTTP 请求,来回切换的代码复杂度远超直接用一个 WebSocket。
- 二进制数据传输(音视频流、文件传输)→ WebSocket。SSE 的线格式是纯文本,传二进制得 Base64 编码,体积膨胀 33%。
- 5 秒一次的健康检查 → 轮询就够。为这个维持一条长连接反而浪费。
一条决策经验:先问自己"数据主力流向是哪边"。服务端→客户端优先考虑 SSE;双向高频再去碰 WebSocket。
1.4 一个 2025 年值得关注的信号
Model Context Protocol(MCP)在 2025 年主动从 SSE 迁到了 Streamable HTTP。原因有三:
- SSE 长连接很难被标准安全中间件(WAF、API Gateway)逐消息检查
- 认证只在连接建立那一刻做,中途 Token 过期了连接照样活着
EventSourceAPI 只能用 GET,敏感 Token 被迫放 URL 参数里——服务器日志、浏览器历史、Referrer 头三处泄漏
MCP 的选择不代表 SSE 不行了——它只是说明一件事:如果你的场景需要"每一条消息都独立认证"或"严格的中间件审计",SSE 不是最优解。 但对绝大多数服务端推送场景(AI 流式、大盘、通知),SSE 依然是正确选择。
二、协议层:先看清 SSE 在 TCP 连接里写了什么
业务选型定了,接下来就是理解 SSE 的物理本质。很多人写了半年 SSE,从没抓包看过数据长什么样。但生产线上 80% 的诡异 bug,都藏在这个简单的文本格式里。
2.1 SSE 线格式——四个字段和一条致命底线
SSE 没有二进制帧头,没有 opcode。它就是 HTTP Response Body 里的一段文本。一个完整的事件长这样:
id: 42
event: order_shipped
retry: 5000
data: {"orderId": "ORD-001", "status": "shipped"}
五行的含义:
id: 42—— 事件序号。重连时浏览器把它填进请求头Last-Event-ID: 42。发了就得负责:如果服务端不提供"从 ID 42 之后的消息"的 Replay 能力,客户端断连再重连就是静默丢消息。event: order_shipped—— 事件类型。对应 JS 里的es.addEventListener('order_shipped', fn)。不写这行默认触发onmessage。retry: 5000—— 告诉浏览器:“断了等 5 秒再连我”。关键细节:值必须是纯 ASCII 数字。retry: 3000末尾那个空格会让部分浏览器解析失败——静默的,不会报任何错。data: ...—— 消息体,多行data:自动用\n拼接。如果你的 JSON 里恰好也带\n,事件边界就毁了。- 末尾的空行 —— 这是 SSE 协议里最致命的单字节。 空行(连续的
\n\n)标记事件结束。没有它,浏览器就当这条事件还没写完。
用十六进制看就清楚了:
64 61 74 61 3A 20 7B ... ← "data: {..."
0A 0A ← "\n\n" 事件在这里结束
这意味着你得警惕两个场景。一是 JSON payload 不能包含裸 \n,否则解析器会把它当成事件分隔符。二是 TCP 分帧可能把 \n\n 切成两半——服务端的写操作必须保证原子性。
2.2 为什么 .flush() 是你最重要的朋友
HTTP 响应的写出不是实时的。操作系统有缓冲区,框架有缓冲区,反向代理还有缓冲区。.flush() 就是告诉每一层:“别攒了,把缓冲区的东西立刻推到 TCP 连接的对面去。”
忘掉 flush 会发生什么?服务端每隔 1 秒写一条消息,但客户端 60 秒后一次性收到 60 条——因为中间有一层在等缓冲区填满。
Go 服务端写 SSE 的"最小正确单元":
func sendEvent(w http.ResponseWriter, event SSEEvent) error {
flusher, ok := w.(http.Flusher)
if !ok {
return fmt.Errorf("当前 HTTP 栈不支持 flush")
}
if event.ID != "" {
fmt.Fprintf(w, "id: %s\n", event.ID)
}
if event.Event != "" {
fmt.Fprintf(w, "event: %s\n", event.Event)
}
for _, line := range strings.Split(event.Data, "\n") {
fmt.Fprintf(w, "data: %s\n", line) // 多行 data 逐行写入
}
fmt.Fprintf(w, "\n") // 空行,事件结束的标记
flusher.Flush() // 立刻推送到 TCP
return nil
}
三个关键点:多行 data 要逐行拆开写、末尾空行不可省略、写完立即 Flush。
2.3 SSE 解析器的字符流状态机
如果你需要手写一个 SSE 解析器(比如在 Node.js 里用 fetch + ReadableStream 替代 EventSource),你处理的不只是完整的 event 对象,而是一个逐字节到达的字符流:
初始 → 读到冒号前是字段名
→ "id:" → 把冒号后的值记到 idBuffer
→ "event:" → 记到 eventBuffer
→ "data:" → 追加到 dataBuffer(冒号后面可以有一个空格,规范允许)
→ "retry:" → 更新重连间隔
→ ":" 开头 → 注释行,整行丢弃
→ "\n" → 当前字段结束,等待下一个字段或空行
→ "\n\n" → 事件完成!触发分发,清空所有 buffer
→ EOF 且没遇到 "\n\n" → 整条事件丢弃(规范强制)
规范明确要求最后一个不完整事件必须丢弃,所以服务端务必要保证写完 \n\n 之后再 Flush。
三、工程落地——服务端:从跑通 Demo 到扛住生产流量
理解了协议层,接下来是写代码。这一节的目标不是教你"怎么写一个 SSE 端点"(那个实在太简单了),而是在并发生产环境下怎么组织代码才不容易烂。
3.1 一种经得起时间考验的架构:Broker 模式
SSE 服务端本质上做了三件事:管理谁连进来了、把消息分发给有兴趣的人、在有人断开时清理资源。这就是经典的 Pub-Sub Broker 模型。
Go 语言实现,稍作简化但保留完整语义:
type Broker struct {
clients map[chan SSEEvent]struct{} // 每个 client 独占一个 channel
mu sync.RWMutex
register chan chan SSEEvent
unregister chan chan SSEEvent
incoming chan SSEEvent // 上游业务往这里投事件
}
func NewBroker() *Broker {
b := &Broker{
clients: make(map[chan SSEEvent]struct{}),
register: make(chan chan SSEEvent),
unregister: make(chan chan SSEEvent),
incoming: make(chan SSEEvent, 256),
}
go b.run()
return b
}
// run 是 Broker 的心脏。单一 goroutine,所有状态变更串行化,天然线程安全。
func (b *Broker) run() {
for {
select {
case ch := <-b.register:
b.mu.Lock()
b.clients[ch] = struct{}{}
b.mu.Unlock()
case ch := <-b.unregister:
b.mu.Lock()
delete(b.clients, ch)
close(ch)
b.mu.Unlock()
case event := <-b.incoming:
b.mu.RLock()
for ch := range b.clients {
select {
case ch <- event:
default:
// 慢消费者:不阻塞,跳过。否则一个慢客户端拖死整个广播
}
}
b.mu.RUnlock()
}
}
}
这部分架构的核心思维是 “所有竞争状态收敛到一个 goroutine 里”。run() 通过 channel 接收注册、注销、广播三个信号,单线程串行处理,不需要到处加锁。
3.2 连接处理:写对 Response Header 比写对业务逻辑更重要
func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming not supported", http.StatusInternalServerError)
return
}
// 这组 header 的每一个字段都是线上经验换来的
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // Nginx 用户的生命线
messageCh := make(chan SSEEvent, 256) // 256 的缓冲意味着一秒内的突发不会丢消息
b.register <- messageCh
defer func() { b.unregister <- messageCh }()
ctx := r.Context()
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-ctx.Done():
return // 客户端断开
case <-heartbeat.C:
fmt.Fprintf(w, ": heartbeat\n\n") // 注释行,浏览器无视,但 TCP 连接保持活跃
flusher.Flush()
case event := <-messageCh:
sendEvent(w, event) // 用上一节的 sendEvent 写出去
}
}
}
3.3 三个 Go 语言的专属天坑
如果把 Go 用在 SSE 上,这三个坑碰到的概率极高:
| 坑 | 你看到的症状 | 在底层发生了什么 | 怎么修 |
|---|---|---|---|
| HTTP/2 无视 Flush | Flush() 调了但客户端隔好几秒才收到一批消息 |
Go http.Server 启用 H2 后,内部帧缓冲机制绕过了 Flush() |
在 SSE 端点不启用 H2,或写 2KB 以上的 Padding 注释行强制撑满帧缓冲区 |
| WriteTimeout 悄悄杀连接 | 流连接一到 N 秒就准时断开 | WriteTimeout 是整个响应的总时限,不是空闲超时 |
SSE 端点设 WriteTimeout = 0;如需超时控制,用 context.WithTimeout 单独管理 |
| X-Accel-Buffering 缺失 | 本地完美,上 Nginx 后每条消息延迟几十秒 | Nginx 默认缓冲上游响应体直到填满或连接关闭 | 响应头加 X-Accel-Buffering: no + Nginx 配置 proxy_buffering off |
3.4 Node.js 和 Spring Boot 的要点
Node.js(Express)场景,核心关注点是 res.write() 的返回值检测和背压处理:
class SSEClientManager {
private clients = new Map<string, Response>();
add(id: string, res: Response) {
this.clients.set(id, res);
res.on('close', () => this.clients.delete(id));
}
sendTo(id: string, event: string, data: unknown): boolean {
const res = this.clients.get(id);
if (!res) return false;
// 关键:res.write() 返回 false 表示下游消费不过来,但 SSE 场景我们
// 通常不做主动背压——消息丢了可以靠 Last-Event-ID 重放补回来
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
return true;
}
broadcast(event: string, data: unknown) {
const payload = JSON.stringify(data);
this.clients.forEach(res => {
res.write(`event: ${event}\n`);
res.write(`data: ${payload}\n\n`);
});
}
}
Spring Boot 场景,SseEmitter 默认 30 秒超时是最容易遗漏的配置项。显式设大,或者直接 Long.MAX_VALUE,然后靠业务层的心跳来管理连接活性。
四、工程落地——客户端:别让 onmessage 成为你唯一的防线
浏览器原生 EventSource 的 API 设计极其克制——克制到很多人误以为它已经够用。实际生产中有四个明显缺口:
- HTTP 错误(500、502、503)——浏览器直接放弃,不会重连。
- 不支持自定义请求头——Token 只能放 Cookie 或 URL 参数。
- 没有请求超时检测——连接"假死"时(TCP 没断但数据停止流动),客户端毫无感知。
readyState === CLOSED和readyState === CONNECTING语义完全不同,但onerror事件里你不主动区分,行为就不可预测。
4.1 一个生产级封装应该做对什么
type ConnectionStatus =
| 'connecting' // 正在建立首次连接
| 'connected' // 正常收数据
| 'reconnecting' // 断连后正在尝试恢复
| 'disconnected' // 主动断开
| 'failed'; // 重试次数耗尽,永久放弃
export class RobustSSEClient {
private source: EventSource | null = null;
private reconnectAttempt = 0;
private locking = false; // 重连状态锁,防止并发重连
private heartbeatWatchdog: ReturnType<typeof setTimeout> | null = null;
private readonly config = {
url: '',
maxRetries: 5,
baseDelay: 1000,
maxDelay: 30000,
heartbeatTimeout: 45000, // 45 秒没收到任何数据 → 主动重连
onEvent: (_: SSEEvent) => {},
onStatusChange: (_: ConnectionStatus) => {},
};
connect(url: string) {
this.source?.close();
this.emitStatus('connecting');
this.source = new EventSource(url);
// 命名事件 → 类型安全的事件分发
this.source.addEventListener('update', (e: MessageEvent) => {
this.resetWatchdog();
this.config.onEvent({
type: 'update',
id: e.lastEventId,
data: this.safeJSON(e.data),
timestamp: Date.now(),
});
});
this.source.onopen = () => {
this.reconnectAttempt = 0;
this.emitStatus('connected');
this.resetWatchdog();
};
this.source.onerror = () => {
if (this.source?.readyState === EventSource.CLOSED) {
this.scheduleReconnect(); // 浏览器放弃了,我们自己来
} else if (this.source?.readyState === EventSource.CONNECTING) {
this.emitStatus('reconnecting'); // 浏览器自己在重试
}
};
this.resetWatchdog();
}
// 指数退避 + 随机抖动。不加抖动的话,服务端重启后 10 万个客户端同时重连——雷击时刻
private scheduleReconnect() {
if (this.locking) return; // 状态锁
if (this.reconnectAttempt >= this.config.maxRetries) {
this.emitStatus('failed');
return;
}
this.locking = true;
const delay = Math.min(
this.config.baseDelay * Math.pow(2, this.reconnectAttempt),
this.config.maxDelay
);
const jitter = delay * (0.7 + Math.random() * 0.6); // ±30% 随机偏移
setTimeout(() => {
this.reconnectAttempt++;
this.locking = false;
this.connect(this.config.url);
}, jitter);
}
// 心跳看门狗:定时器不断被 resetHeartbeat() 重置。如果 45 秒都没重置一次,
// 说明连接已经"假死"了——TCP 表面上还活着,但数据早就不流动了。
private resetWatchdog() {
if (this.heartbeatWatchdog) clearTimeout(this.heartbeatWatchdog);
this.heartbeatWatchdog = setTimeout(() => {
console.warn('[SSE] 心跳超时,主动重连');
this.source?.close();
this.scheduleReconnect();
}, this.config.heartbeatTimeout);
}
private safeJSON(raw: string): unknown {
try { return JSON.parse(raw); }
catch { return raw; }
}
private emitStatus(s: ConnectionStatus) {
this.config.onStatusChange(s);
}
disconnect() {
this.source?.close();
if (this.heartbeatWatchdog) clearTimeout(this.heartbeatWatchdog);
this.emitStatus('disconnected');
}
}
4.2 Last-Event-ID 的杀伤力:断点续传不只是概念
SSE 协议的设计者在这里展现了惊人的远见。浏览器在每次重连请求中都会自动带上 Last-Event-ID 头,值为断开前最后收到的那条事件的 id 字段。服务端只要把这个 ID 映射到自己的事件存储上,就能精确地从断开那一刻续传。
服务端对应的处理:
app.get('/api/events', async (req, res) => {
const lastId = req.headers['last-event-id'] || '0';
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
});
// 第一步:把断开期间积压的消息补发给客户端
const missed = await eventStore.since(parseInt(lastId as string, 10));
for (const evt of missed) {
res.write(`id: ${evt.id}\n`);
res.write(`event: ${evt.type}\n`);
res.write(`data: ${evt.data}\n\n`);
}
// 第二步:续上实时流
realtimeStream.pipe(res);
});
客户端再做一层兜底——把 lastEventId 存到 sessionStorage,即使浏览器标签页刷新也能恢复:
es.addEventListener('update', (e) => {
if (e.lastEventId) {
sessionStorage.setItem('sse_checkpoint', e.lastEventId);
}
});
// 恢复时,URL 带 checkpoint 参数作为查询兜底
const checkpoint = sessionStorage.getItem('sse_checkpoint');
const url = checkpoint
? `/api/events?since=${checkpoint}`
: '/api/events';
4.3 那如果我真的需要自定义 Header 呢
EventSource 不支持自定义请求头,这是很多人的阻塞点。两种绕行路线:
路线一(轻量级方案)—— Cookie 认证。 服务端设 HttpOnly + SameSite=Strict Cookie,连接建立时自动携带。简单、安全、零代码。缺点是跨域场景下 SameSite 限制会导致 Cookie 不发送。
路线二(重型方案)—— 先用 POST 换一次性 Ticket,再用 Ticket 连 SSE。 本质上是用一个短暂的临时凭证,把"认证"和"建立流"拆成两步,避免 Token 长期暴露在 URL 里。
路线三(完全自控)—— 放弃 EventSource,用 fetch + ReadableStream 手写 SSE 客户端。 自定义 Header、POST 请求、流控策略全由你掌控,代价是需要手写解析器。但解析逻辑并不复杂——就是上一节的状态机:
async function* sseStream(url: string, token: string): AsyncGenerator<SSEEvent> {
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'text/event-stream',
},
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n');
buf = parts.pop() || ''; // 最后一段可能是半个事件,留到下一轮
for (const line of parts) {
if (line.startsWith('data: ')) {
yield {
data: JSON.parse(line.slice(6)),
id: '',
timestamp: Date.now(),
};
}
}
}
}
五、安全防线:每条消息都该为自己负责
一条 SSE 连接是服务端对客户端的一条单向敞开的管道。你没法在消息发出后拦截它、收回它、修改它。所以安全必须在消息进入管道之前完成。
5.1 认证:把 Token 从 URL 上赶走
最糟糕也最常见的做法——Token 直接拼在 URL 里:
// 这条 URL 会留在:服务器访问日志、浏览器地址栏历史、Nginx 日志、
// CDN 日志、Referrer 头——任何一个环节泄露,Token 就暴露了
const es = new EventSource(`/events?token=${accessToken}`);
Cookie 方案是第一选择。HttpOnly 让 JS 读不到、SameSite 限制跨站携带、Secure 限制 HTTPS 传输——三重保护,零额外代码。
当 Cookie 不可行(比如跨顶级域调用),则采用"换票"模式:先发 POST 用 Bearer Token 换一个 60 秒有效期的一次性 ticket,再用 ticket 建立 SSE。ticket 即使泄漏,窗口也只有 60 秒,且只能用一次。
5.2 CORS 不该是 *
Access-Control-Allow-Origin: * 对 SSE 意味着:任何网站都可以用 JavaScript 打开你的事件流并读取每一条推送消息。不需要 Cookie,不需要用户交互——一个恶意页面在后台 new EventSource('https://your-api.com/events') 就能静默获取所有数据。
// 白名单是最低防线
const TRUSTED = new Set([
'https://app.yourcompany.com',
'https://admin.yourcompany.com',
]);
function corsGuard(req, res) {
const origin = req.headers['origin'];
if (!origin || !TRUSTED.has(origin)) {
res.writeHead(403);
res.end();
return true; // 已处理,调用方直接 return
}
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin'); // CDN 缓存 key 要区分 Origin
return false;
}
5.3 纵深防御:连接限制、事件权限、速率控制
第一层:连接配额。 全局上限 + 单用户上限,防止单个用户(或攻击者)通过建立大量空闲 SSE 连接耗尽服务端资源。
type ConnectionGate struct {
global int64
perUser map[string]int
maxGlobal int64
maxPerUser int
mu sync.Mutex
}
func (g *ConnectionGate) Admit(userID string) bool {
g.mu.Lock()
defer g.mu.Unlock()
if g.global >= g.maxGlobal || g.perUser[userID] >= g.maxPerUser {
return false
}
g.global++
g.perUser[userID]++
return true
}
func (g *ConnectionGate) Release(userID string) {
g.mu.Lock()
defer g.mu.Unlock()
g.global--
if g.perUser[userID] > 0 {
g.perUser[userID]--
}
}
第二层:事件级权限。 “用户连上了"不等于"用户能看所有事件”。假设你在做一个订单系统:销售 A 只能接收自己负责的订单状态变更,不能接收销售 B 的订单数据。这个过滤逻辑必须在事件离开服务端之前执行。
func (b *Broker) SendToUser(userID string, event SSEEvent) {
// 静默跳过——打日志反而可能泄露"某用户试图访问某事件"这个事实
if !b.authz.CanReceive(userID, event.Type, event.Scope) {
return
}
event.Data = b.sanitizer.StripSensitive(userID, event.Data)
b.mu.RLock()
ch := b.userChannels[userID]
b.mu.RUnlock()
if ch != nil {
select {
case ch <- event:
default:
// 客户端消费不过来,跳过而非阻塞
}
}
}
第三层:速率限制。 令牌桶算法是 SSE 场景的经典选择——允许短时突发,但拉长看速率恒定:
type TokenBucket struct {
tokens float64
capacity float64
rate float64
last time.Time
mu sync.Mutex
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
tb.tokens = math.Min(tb.capacity, tb.tokens + now.Sub(tb.last).Seconds() * tb.rate)
tb.last = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
5.4 上线前自检清单
- SSE 端点强制 HTTPS
- 认证在
res.writeHead()之前完成,不在流建立之后补 - CORS 是精确白名单,不是
* - Token 不放在 URL query string
- 全局连接上限 + 单用户上限均生效
- 有事件级权限校验(不同角色看到的事件子集不同)
- Payload 输出前脱敏(不在
json.Marshal时全量 dump 数据模型) - 日志不记录 payload 明文内容
- 响应头含
X-Accel-Buffering: no - 有心跳机制(服务端发注释行,客户端设看门狗定时器)
六、性能:性能问题不只在代码里
SSE 的性能调优是按层展开的。绝大多数人直觉上以为性能瓶颈在应用代码——序列化慢了、channel 阻塞了。实际经验是相反的:80% 的 SSE 性能问题出在反向代理层,15% 出在 HTTP 协议栈,应用代码只占 5%。
6.1 分层诊断法
应用层 │ 序列化策略、flush 频率、channel 缓冲大小
─────────┼────────
HTTP栈 │ HTTP/2 vs HTTP/1.1、WriteTimeout、TCP_NODELAY
─────────┼────────
代理层 │ Nginx proxy_buffering、proxy_read_timeout、连接数上限
─────────┼────────
内核层 │ 文件描述符、TCP backlog、epoll、TIME_WAIT
出问题时自上而下排查:先看应用日志有没有报错,再看 Nginx access log 的连接状态码,然后用 ss -s 和 netstat -ant | grep ESTABLISHED | wc -l 看 TCP 连接数是否异常,最后看 /proc/sys/fs/file-nr 确认文件描述符是否被打满。
6.2 Nginx:最大的性能开关
默认的 Nginx location 配置是对普通 HTTP 请求优化的——缓冲整个响应体,一次性发给客户端。这对 SSE 是灾难:每条消息到达 Nginx 后会被 hold 住几十秒,直到缓冲区填满或连接关闭才一次性推给浏览器。 线上最经典的 bug 形态:本地开发完美,部署后消息延迟 60 秒才到。
location /api/events {
proxy_pass http://backend;
# 对 SSE 来说,这三行是生死线
proxy_buffering off; # 不缓冲,上游写什么代理就传什么
proxy_cache off; # 不对事件流做缓存
proxy_read_timeout 600s; # 别用默认的 60s,SSE 连接都是几分钟以上
# HTTP/1.1 长连接
proxy_http_version 1.1;
proxy_set_header Connection '';
# 传递客户端真实 IP(安全审计需要)
proxy_set_header X-Real-IP $remote_addr;
}
X-Accel-Buffering: no 这个响应头是第二条防线。当 Nginx 配置改不了时,服务端主动声明"不要缓冲我"——Nginx 看到这个头会自动等效于 proxy_buffering off。
6.3 HTTP/2 的双面刃
HTTP/2 为 SSE 带来了革命性的多路复用能力——同一个域下多个 SSE 连接共享一个 TCP Socket,解决了 HTTP/1.1 的 6 连接上限。但 Go 语言的 http.Server 在 H2 模式下有一个顽固的 Bug:Flush() 调用被底层帧缓冲机制吞掉了,数据卡在几 KB 的帧里发不出去。
三种对抗方法:
最简单——在 SSE 端点直接不用 HTTP/2。 反正 SSE 本来就占一条连接,多路复用的优势在单 SSE 场景下体现不出来。
最 Hack——写 2KB+ 的 Padding。 在流开头写一堆注释行,总量超过 H2 帧的缓冲区阈值,强行触发一次帧发送,之后的小消息就不会被缓冲挡住了。
最干净——改用 HTTP/1.1 处理 SSE 端点,用 HTTP/2 处理其他所有 API。 这是大多数生产环境的实际做法。
6.4 TCP_NODELAY:AI 场景的 44ms → 0.6ms
Nagle 算法是 TCP 栈里的一个优化:攒小包,等凑够一个 MSS 或收到 ACK 再发。对批量文件传输这是好事,对 LLM Token 逐字流式输出这是灾难——每一个 Token(几个字节)都不应该等。
实测对比(Go + 本地 LLM 推理):
| 指标 | Nagle 开启(默认) | TCP_NODELAY |
|---|---|---|
| 首个 Token 延迟 (单用户) | 44ms | 0.6ms |
| 首个 Token 延迟 (64 并发, median) | 70ms | 1.8ms |
| 最大吞吐 | 96k tokens/s | 76k tokens/s |
吞吐下降了约 20%,但在 Token 生成场景(模型每 ~6ms 产出一个 Token),这个吞吐减少根本不会被感知到。反过来看首 Token 延迟——44ms 意味着用户点击发送后要等超过 1/25 秒才开始看到回复,而 0.6ms 就是即时的。在 AI 对话产品的用户体验中,这个差距直接决定用户觉得"快"还是"卡"。
Go 里设置 TCP_NODELAY 的方式:
// 比较麻烦,需要 Hijack 下层连接
if hj, ok := w.(http.Hijacker); ok {
conn, _, _ := hj.Hijack()
if tcpConn, ok := conn.(*net.TCPConn); ok {
tcpConn.SetNoDelay(true)
}
}
6.5 内核参数:10 万连接不是梦
当连接数突破 5 万,限制你的不再是 Go 的 goroutine 调度器(goroutine 很轻),而是 Linux 内核的默认参数。一台 8C/16G 云服务器在参数调优后,单进程稳定持有 137,842 个 SSE 连接,P99 延迟 < 86ms(2026 年实测)。
# /etc/sysctl.conf — 关键项
# 1. 文件描述符是第一个天花板
fs.file-max = 2097152
# 2. 连接队列——内核在应用 accept 之前能暂存多少个连接
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 5000
# 3. 端口不够用时复用 + 缩小 TIME_WAIT 窗口
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 10
# 4. Epoll 上限(每个连接需要一个 epoll watch)
fs.epoll.max_user_watches = 524288
# 5. Socket buffer——默认值对 10 万并发太小
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = "4096 87380 16777216"
net.ipv4.tcp_wmem = "4096 65536 16777216"
别忘了用户级限制:
# /etc/security/limits.conf
* soft nofile 655360
* hard nofile 655360
6.6 应用层的三个简单优化
预序列化。 广播场景下,同一条事件推送给 10000 个客户端——不要对每个客户端各做一次 json.Marshal,序列化一次,然后每个 client channel 发同一份 payload 字符串。
// 一次序列化,全体复用
payload, _ := json.Marshal(event)
msg := string(payload)
for _, ch := range clients {
select {
case ch <- msg:
default:
}
}
channel 缓冲。 256 的缓冲意味着短时突发不会丢消息。但缓冲不能解决"消费者持续性慢于生产者"的问题——那是业务设计要解决的。
慢消费者跳过。 select { case ch <- msg: default: } 是 Go 里最优雅的过载保护——一行代码,防止一个慢客户端拖死整个广播系统。
七、选择:一张表格胜过千言万语
7.1 三个方案的深层对比
| 维度 | SSE | WebSocket | 轮询 |
|---|---|---|---|
| 数据方向 | 服务端→客户端 | 双向 | 客户端→服务端 |
| 底层协议 | HTTP(S) 标准 | ws:// / wss:// 升级协议 |
HTTP(S) |
| 连接模式 | 一条长连接复用 | 一条长连接复用 | 每次请求新建 |
| 浏览器 API | EventSource(零依赖) |
WebSocket(零依赖) |
fetch |
| 自动重连 | 内置 + Last-Event-ID 续传 | 需手写退避逻辑 | 天然每次新请求 |
| 自定义 Header | ❌ | ✅(连接握手时) | ✅ |
| HTTP/2 多路复用 | ✅ | ❌ | ✅ |
| 防火墙/CDN 友好 | ✅ | ❌(很多企业防火墙拦截) | ✅ |
| 每消息协议开销 | ~14 bytes(字段前缀+分隔符) | 2-6 bytes(二进制帧头) | HTTP 头(数百 bytes) |
| 适合的频率 | 中低频(毫秒到分钟级) | 高频(亚毫秒到秒级) | 低频(秒级以上) |
7.2 选型速查
你要做的事情 推荐 为什么
──────────────────────────────────────────────────────────────────
AI 对话流式输出 SSE 单向、0 依赖、全行业通用
实时数据大盘 SSE 低频单向、自动重连省心
业务通知推送 SSE 原生重连 + Last-Event-ID = 几乎不丢消息
异步任务进度条 SSE 进度只走一个方向
即时通讯(双向消息) WebSocket 双向是必需的
多人在线协作编辑 WebSocket OT/CRDT 要求客户端能推
游戏状态同步 WebSocket 延迟敏感 + 二进制
服务心跳检查(5 秒一次) 轮询 为这维持长连接反而浪费
低频配置拉取(分钟级) 轮询 请求频率越低,长连接越不值
八、SSE 有什么不可替代的
讨论到这儿,一个自然的追问是:“既然 WebSocket 也能做服务端推送,为什么还要 SSE?”
答案藏在基础设施的惯性里。
第一,SSE 走的是每个人都已经有的东西。 不需要在 ALB/Nginx 上开新端口、不需要让安全团队审批 ws 协议、不需要在 WAF 规则里加 WebSocket frame 检测。TLS 证书复用你已有的 HTTPS 证书,中间任何一个环节都不需要为新协议做适配。这对大公司的内部审批流程来说,比任何技术优势都实在。
第二,浏览器的自动重连和断点续传是免费的。 WebSocket 断开后,你得自己实现重连逻辑、退避策略、Seq ID 协议、ACK 确认。SSE 呢?浏览器替你搞定了前面三步,你只要在服务端实现"给定一个 ID,返回这个 ID 之后的消息"就闭环了。
第三,curl 就能调试。 curl -N https://your-api.com/events,消息一行一行在终端里打印出来。WebSocket 调试你需要 wscat 或者专门的帧分析工具。这种摩擦力在开发、测试、oncall 排障时每天都在积累。
第四,渐进式改造成本最低。 你的系统已经有 50 个 REST API。现在产品说要加实时推送。用 SSE,你在已有路由里加一个 GET /events,零改动原有逻辑。用 WebSocket,你需要引入 ws 协议处理、连接管理、心跳保活——整个架构的复杂度上一级。
九、总结:从业务出发,回到业务
回看整篇文章的结构,四个层次恰好对应了把 SSE 用好的四个阶段:
- 业务判断 —— 你的场景是单向推送还是双向通信?频率是毫秒级还是秒级?防火墙和 CDN 是现实约束还是可以绕过的?
- 技术落地 —— 协议格式理解到位了吗(空行、id、retry 的隐藏规则)?服务端的 Broker 模式、客户端的重连策略写对了吗?
- 工程思维 —— 80% 的问题在 Nginx,不是代码。性能优化是分层递进的,先排查代理层,再动内核参数。HTTP/2 不是银弹,对 Go 用户甚至是双刃剑。
- 防御兜底 —— 认证在连接建立时做、CORS 精确白名单、连接配额、事件权限、速率限制、Payload 脱敏。安全不是"写完代码再考虑"的事,因为 SSE 是一条只能向前、不能收回的管道。
最后三个务实的建议:
- 默认选 SSE。 覆盖你 80% 的服务端推送需求,代码量省一半,运维复杂度的差异比你和 WebSocket 之间任何一个 benchmark 都真实。
- AI 流式输出 → 锁死 SSE。 整个行业都选了这条路,意味着生态、工具、经验都是最丰富的。
- 上线前,先怀疑你的 Nginx。
proxy_buffering off+X-Accel-Buffering: no—— 搞定这两行,你就已经避开了一半的 SSE 生产事故。
写完这篇,回头看自己第一次写 SSE 时那个 20 行的 demo——当时真以为自己搞懂了。
技术的有趣之处就在这里:表面越简单的东西,底下能挖的深度越让人意外。而真正优秀的工程,从来不是把简单的事情搞复杂,是把看起来简单的事情,在每一层都做对了。
2026 年 7 月 | 参考来源:W3C Eventsource 规范、MDN EventSource API、RFC 8441、Go net/http 源码、2026 年 Go SSE 并发基准测试、MCP Streamable HTTP 迁移文档
更多推荐


所有评论(0)