目录

什么是 SSE:服务端推送事件详解

你有没有注意到,ChatGPT 的回答是一个字一个字"打"出来的,而不是等全部生成完毕才一次性显示?

这背后用的技术很可能就是 SSE。

AG-UI 协议的事件流传输,默认也推荐 SSE。关于 AG-UI 的介绍请移步文章:

什么是 AG-UI:AI Agent 与前端应用之间的交互协议

SSE 不算新技术——它 2006 年就被提出,2011 年进入 HTML5 规范——但在大模型应用爆发之后,重新成了高频出现的概念。


先说问题:如何做服务端实时推送

在 SSE 出现之前,Web 开发里实现"服务端主动推送数据"主要有两种思路:

1. 短轮询

前端每隔几秒发一次请求问"有没有新数据"。

简单粗暴,但延迟高、请求量大,服务端压力不小。

2. 长轮询

前端发请求,服务端憋着不回复,等到有数据了才返回。

数据及时性好一些,但每次返回后前端要立刻发下一条请求,本质上还是请求-响应模型,连接没法复用。

这两种方式的问题都指向同一个根源:HTTP 的请求-响应模型是客户端主动触发的,服务端无法主动推。

WebSocket 彻底解决了这个问题,用全双工连接替换 HTTP,客户端和服务端都可以随时发数据。

但它也带来了新的复杂度:需要协议升级握手、独立的连接管理,基础设施(代理、负载均衡)也需要额外支持。

SSE 走了一条中间路线:不升级协议,就在普通的 HTTP 响应里,让连接保持打开,服务端持续往里写数据。

/img/what-is-sse/0101.png
SSE、轮询、WebSocket对比


SSE 的工作原理

SSE 的核心就是一条"长"的 HTTP 响应。

客户端发一次普通的 GET 请求,带上 Accept: text/event-stream 头:

GET /events HTTP/1.1
Accept: text/event-stream
Cache-Control: no-cache

服务端返回时,响应头里声明 Content-Type: text/event-stream,然后不关闭连接,而是持续往响应体里写数据:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

之后服务端就可以不停往这条连接里塞事件,客户端收到数据后实时处理,直到连接断开。

/img/what-is-sse/0102.png
SSE通信时序

整个过程客户端只发了一次请求。服务端推完所有数据之前,这条 HTTP 连接一直保持打开状态。


数据帧格式

SSE 的数据格式非常简单,每条消息由若干字段组成,以空行分隔。

/img/what-is-sse/0103.png
SSE数据帧格式

字段说明:

字段 说明
data: 消息内容,必填,可多行
id: 事件 ID,用于断线续传
event: 事件类型,客户端可按类型分别处理
retry: 断线重连等待毫秒数
: 开头 注释行,客户端忽略,常用来保持连接心跳

几个细节:

  • 每条消息以空行(\n\n)结束,服务端写完一条消息后必须刷两个换行
  • data: 可以写多行,客户端会把多行拼成一个字符串再处理
  • 发送 JSON 时直接把序列化后的字符串放在 data: 后面即可

Go 实现:服务端

Go 的标准库 net/http 完全能搞定 SSE,不需要任何第三方包。

package main

import (
    "fmt"
    "net/http"
    "time"
)

func sseHandler(w http.ResponseWriter, r *http.Request) {
    // 设置 SSE 必要响应头
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")
    // 允许跨域(按需加)
    w.Header().Set("Access-Control-Allow-Origin", "*")

    // 获取 Flusher,用于实时刷新缓冲区
    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "不支持 SSE", http.StatusInternalServerError)
        return
    }

    // 监听客户端断开
    ctx := r.Context()

    // 模拟流式输出:每秒推一条事件
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():
            // 客户端断开连接,退出
            return
        default:
        }

        // 写一条 SSE 事件
        fmt.Fprintf(w, "id: %d\n", i)
        fmt.Fprintf(w, "event: message\n")
        fmt.Fprintf(w, "data: {\"index\":%d, \"text\":\"第 %d 条消息\"}\n\n", i, i+1)

        // 必须 Flush,否则数据留在缓冲区不会发出去
        flusher.Flush()

        time.Sleep(time.Second)
    }

    // 发送结束事件
    fmt.Fprintf(w, "event: done\ndata: {\"finished\":true}\n\n")
    flusher.Flush()
}

func main() {
    http.HandleFunc("/events", sseHandler)
    fmt.Println("SSE 服务启动,监听 :8080")
    http.ListenAndServe(":8080", nil)
}

两个关键点:

  1. http.Flusher 接口的 Flush() 调用必不可少——Go 的 http.ResponseWriter 默认有缓冲,不手动 Flush 数据不会发出去
  2. 每条消息末尾必须是 \n\n(两个换行),否则客户端不知道这条消息结束了

Go 实现:客户端

服务端推的 SSE 流,Go 客户端直接用标准 HTTP 读就行:

package main

import (
    "bufio"
    "fmt"
    "net/http"
    "strings"
)

func main() {
    resp, err := http.Get("http://localhost:8080/events")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    scanner := bufio.NewScanner(resp.Body)
    for scanner.Scan() {
        line := scanner.Text()

        // 空行是消息分隔符,跳过
        if line == "" {
            continue
        }
        // 注释行跳过
        if strings.HasPrefix(line, ":") {
            continue
        }

        // 解析字段
        if strings.HasPrefix(line, "data: ") {
            data := strings.TrimPrefix(line, "data: ")
            fmt.Println("收到:", data)
        }
        if strings.HasPrefix(line, "event: ") {
            eventType := strings.TrimPrefix(line, "event: ")
            if eventType == "done" {
                fmt.Println("流结束")
                return
            }
        }
    }

    if err := scanner.Err(); err != nil {
        fmt.Println("读取错误:", err)
    }
}

断线自动重连

SSE 有一个原生支持的特性,轮询和 WebSocket 都要手动实现:浏览器会在连接断开后自动重连

重连时,浏览器会在请求头里带上 Last-Event-ID,值是上一次成功收到的事件 ID:

GET /events HTTP/1.1
Last-Event-ID: 42

服务端读到这个头,就知道从哪条事件开始续传,不会重复发已经收到的内容。

重连等待时间由服务端的 retry: 字段控制,单位毫秒:

retry: 3000

这意味着断线后 3 秒再重连,而不是立即重试轰炸服务端。


SSE 的局限

SSE 不是银弹,有几个真实存在的限制:

1. 单向通信。

SSE 只能服务端推给客户端,客户端想发消息还是要另发 HTTP 请求。

对于纯推送场景(新闻、通知、AI 流式输出)这完全够用;对于需要双向实时交互的场景(多人协作、游戏),WebSocket 更合适。

2. HTTP/1.1 下浏览器连接数限制。

每个域名在 HTTP/1.1 下最多 6 条并发连接,SSE 长连接会占用其中一条。

如果一个页面开了多条 SSE,可能会影响其他请求。HTTP/2 多路复用解决了这个问题。

3. 中间代理可能缓冲响应。

某些 Nginx 或代理服务器配置下,响应体会被缓冲,SSE 的实时性会受影响。需要在 Nginx 配置里关闭缓冲:

proxy_buffering off;

什么时候用 SSE,什么时候用 WebSocket

场景 推荐选择
AI 流式回答(ChatGPT 风格) SSE
实时通知、消息推送 SSE
进度条、任务状态更新 SSE
在线协作文档 WebSocket
多人实时游戏 WebSocket
实时聊天(需要客户端频繁发消息) WebSocket

判断标准很简单: 如果通信主要是服务端往客户端推,用 SSE

如果双方都需要频繁互发数据,用 WebSocket。


常见问题

SSE 和流式 HTTP 响应有什么区别?

本质上 SSE 就是一种流式 HTTP 响应,区别在于 SSE 规定了固定的数据格式(data: / event: / id: 字段)和客户端行为(自动重连、Last-Event-ID)。

原始的流式响应可以传任意格式,但需要自己约定解析规则;SSE 有浏览器原生的 EventSource API 支持,开箱即用。

服务端用 fmt.Fprintf 写数据为什么没有实时发出去?

没调用 Flush()。Go 的 http.ResponseWriter 内置缓冲,写数据后必须调 flusher.Flush() 才会真正发送到客户端。

SSE 支持二进制数据吗?

不直接支持。

SSE 是文本协议,二进制数据需要先 Base64 编码成字符串再发送。

如果需要大量二进制数据,WebSocket 更合适。

如何处理大量并发 SSE 连接?

每条 SSE 连接是一个 goroutine,Go 的并发模型处理这类场景很自然。注意把 channel 或 context 传进去,这样连接断开时能及时清理 goroutine,避免泄漏。


SSE 的设计哲学和它的用法一样简洁:用已有的 HTTP 协议解决问题,而不是引入新协议。

在大模型应用把"流式输出"变成标配之后,理解 SSE 已经是后端开发的基本功之一了。

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/what-is-sse/

备用原文链接: https://blog.fiveyoboy.com/articles/what-is-sse/