三行代码跑通 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 过期了连接照样活着
  • EventSource API 只能用 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 设计极其克制——克制到很多人误以为它已经够用。实际生产中有四个明显缺口:

  1. HTTP 错误(500、502、503)——浏览器直接放弃,不会重连。
  2. 不支持自定义请求头——Token 只能放 Cookie 或 URL 参数。
  3. 没有请求超时检测——连接"假死"时(TCP 没断但数据停止流动),客户端毫无感知。
  4. readyState === CLOSEDreadyState === 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 -snetstat -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 用好的四个阶段:

  1. 业务判断 —— 你的场景是单向推送还是双向通信?频率是毫秒级还是秒级?防火墙和 CDN 是现实约束还是可以绕过的?
  2. 技术落地 —— 协议格式理解到位了吗(空行、id、retry 的隐藏规则)?服务端的 Broker 模式、客户端的重连策略写对了吗?
  3. 工程思维 —— 80% 的问题在 Nginx,不是代码。性能优化是分层递进的,先排查代理层,再动内核参数。HTTP/2 不是银弹,对 Go 用户甚至是双刃剑。
  4. 防御兜底 —— 认证在连接建立时做、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 迁移文档

Logo

一站式 AI 云服务平台

更多推荐