什么是 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 响应里,让连接保持打开,服务端持续往里写数据。
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之后服务端就可以不停往这条连接里塞事件,客户端收到数据后实时处理,直到连接断开。
整个过程客户端只发了一次请求。服务端推完所有数据之前,这条 HTTP 连接一直保持打开状态。
数据帧格式
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)
}两个关键点:
http.Flusher接口的Flush()调用必不可少——Go 的http.ResponseWriter默认有缓冲,不手动 Flush 数据不会发出去- 每条消息末尾必须是
\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 已经是后端开发的基本功之一了。
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!