Go SSE 代理模式
业务后端作为 SSE 代理透传上游流式响应:正确事件头、禁用缓冲、逐块 Flush、客户端断开检测、超时与错误映射,避免整包缓冲导致「假流式」。
[!info] Related notes
- 所属 MOC:Go Web 与后端 · 学习路线
- 前置概念:net/http、context、中间件、SSE
- 并列 / 后续:透传模式、多服务 SSE 管道、前端 SSE 消费、优雅关闭
- 容易混淆:WebSocket 代理、整包
ReadAll、反向代理默认缓冲
Go SSE 代理模式
这个概念为什么会出现
浏览器或 App 不宜直连上游 AI/模型/内部流式服务,常见原因:
- 上游密钥不能下发到客户端
- 需要在边缘做登录态、配额、审计、上下文拼装
- 需要统一公网域名与 CORS 策略
- 需要屏蔽上游错误细节或做协议适配
于是业务后端成为 SSE(Server-Sent Events)代理:对客户端表现为 text/event-stream,对上游则是流式 HTTP 客户端。
难点在于:代理普通 JSON 可以整包读完再写;代理 SSE 必须:
- 尽快把上游字节送到客户端
- 禁止网关/框架/中间件缓冲整响应
- 客户端断开时停止拉上游,避免浪费配额
- 正确处理超时、非 200、中途断流
[!abstract] 一句话理解 SSE 代理是「带身份与策略的流式透传」:设好事件流响应头,按块读取上游 body,每次写入后
Flush,并用Request.Context()感知客户端离开。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:聊天流式接口代理上游 AI SSE
产品页 GET /api/chat/stream 不能把模型 API Key 下发给浏览器。
业务后端做 SSE 代理:
- 对前端:
text/event-stream真流式 - 对上游:带服务端 Token 拉流
- 客户端断开被服务器检测到后,取消本地上游请求;远端是否停止生成/计费取决于上游实现
以下用 Go 1.22+ 标准库突出机制(含方法路由)。示例监听回环地址,未实现用户鉴权、配额或续传;部署前需要接入这些策略。
package main
import (
"context"
"io"
"log"
"mime"
"net/http"
"os"
"time"
)
// URL/Token 由服务端可信配置提供,不能直接采用请求参数中的 URL。
var upstreamURL = os.Getenv("SSE_UPSTREAM_URL")
var upstreamToken = os.Getenv("SSE_UPSTREAM_TOKEN")
var upstreamClient = func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = 15 * time.Second
return &http.Client{
Transport: transport,
// 不自动跟随重定向,避免携带凭证访问非预期目标。
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
// 总时长由请求 context 控制;不是无限等待。
}
}()
func sseProxy(w http.ResponseWriter, r *http.Request) {
// 此示例要求 Writer 直接暴露 Flusher;包装器也必须转发它。
if _, ok := w.(http.Flusher); !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "invalid upstream configuration", http.StatusInternalServerError)
return
}
req.Header.Set("Accept", "text/event-stream")
if upstreamToken != "" {
req.Header.Set("Authorization", "Bearer "+upstreamToken)
}
resp, err := upstreamClient.Do(req)
if err != nil {
if r.Context().Err() != nil {
return
}
if ctx.Err() != nil {
http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
} else {
http.Error(w, "upstream unavailable", http.StatusBadGateway)
}
return
}
defer resp.Body.Close()
mediaType, _, mediaErr := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if resp.StatusCode != http.StatusOK || mediaErr != nil || mediaType != "text/event-stream" {
// 不把原始错误体、重定向目标或凭证发送给客户端。
http.Error(w, "invalid upstream response", http.StatusBadGateway)
return
}
// 确认上游状态/媒体类型后,才提交成功的 SSE 响应。
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("X-Accel-Buffering", "no") // nginx 提示,需核对部署配置
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil {
return
}
buffer := make([]byte, 32*1024)
for {
n, readErr := resp.Body.Read(buffer)
if n > 0 {
written, writeErr := w.Write(buffer[:n])
if writeErr != nil || written != n {
return // defer cancel / Close 停止本地上游读取
}
if err := rc.Flush(); err != nil {
return
}
}
if readErr != nil {
if readErr != io.EOF && ctx.Err() == nil {
log.Print("upstream stream interrupted")
}
return // 已提交响应,不拼接错误事件污染可能未完成的 SSE 帧
}
}
}
func main() {
if upstreamURL == "" {
log.Fatal("SSE_UPSTREAM_URL is required")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/chat/stream", sseProxy)
srv := &http.Server{
Addr: "127.0.x.x:8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
保存为 main.go,配置可信上游后运行;真实服务 Token 从环境变量或密钥管理注入:
SSE_UPSTREAM_URL='http://127.0.x.x:9090/stream' go run main.go
# 另一个终端(需先启动该 mock 或换成真实上游):
curl -N http://127.0.x.x:8080/api/chat/stream
成功时响应头含 text/event-stream,字节逐块到达;浏览器仍需等事件结束的空行才分派事件。上游连接失败、非 200 或错误媒体类型在提交流之前返回 HTTP 502,不转发上游错误体。
结合场景再看四个关注点
- 先断言
http.Flusher。 - 上游请求绑定
r.Context(),客户端断开则取消上游。 - 禁止
io.ReadAll后再一次性写出。 - 每次读到字节后写入并检查 Write/Flush 错误;读取块不必等于 SSE 事件。
核心概念与准确模型
SSE 在 HTTP 上的形态
- 长连接响应,
Content-Type: text/event-stream - 文本帧:
event:/data:/ 空行分隔等(见 sse) - 方向以服务器 → 客户端为主(与 WebSocket 全双工不同)
代理通常有两种策略:
| 策略 | 做法 | 适用 |
|---|---|---|
| 字节透传 | 上游已是 SSE,保持正文原始字节 | AI 网关、已统一协议 |
| 协议适配 | 上游 chunked JSON/gRPC stream,本地组装为 SSE | 上游协议不对外 |
本篇聚焦透传;适配时仍遵守 Flush 与取消。
为何「缓冲」是第一大敌
缓冲可能来自:
- 应用代码
ReadAll/ 拼完 string 再写 - 中间件 gzip、某些日志包装 Writer
- 反向代理(nginx
proxy_buffering) - 云 LB / CDN 默认策略
结果:前端要等整段生成结束才突然刷出——假流式。
缓解:
- 应用层:分块写 +
Flush - nginx:
X-Accel-Buffering: no与proxy_buffering off(按部署确认) - 中间件:SSE 路径跳过压缩与全量 body 中间件
客户端断开检测
服务端请求 context 会在连接关闭、HTTP/2 请求取消或 Handler 返回时取消;检测时机受网络影响。仅检查 context 不能打断随后阻塞的 Read,应把它绑定到上游请求:
select {
case <-r.Context().Done():
return
default:
}
// 或绑定到 NewRequestWithContext,使上游 Read 可中断(取决于实现)
取消能停止本地 HTTP 请求和读取,但不能保证远端计算或计费立即停止。
超时怎么设
| 层级 | 建议 |
|---|---|
http.Server | ReadHeaderTimeout 等防慢连接;流式接口的 WriteTimeout 要谨慎(可能砍长流) |
上游 Client.Timeout | 覆盖整个请求生命周期;长生成常设 0,改用 ctx / 空闲超时 |
context.WithTimeout | 给「最大会话时长」硬上限 |
| Transport | 拨号、TLS、响应头超时与连接池 |
原则:区分「连不上」与「已经在流式生成」。示例有响应头超时和 5 分钟上游请求上限,没有字节空闲超时、下游写入超时或心跳;context 取消本身不能保证解除阻塞的下游 Write。
错误映射
- 上游不可达:对客户端可用 SSE
event: error或在尚未写入流式头之前返回 JSON 502(二选一,勿混用到一半) - 上游 4xx/5xx:映射为安全的本地错误;如需读取错误体作诊断,还须限制长度、读取时长并脱敏,限长本身不能防止泄漏
- 中途断流:结束响应;不要在未知的半帧后直接拼接 error 事件。正常结束与中断需由应用协议的完成标记区分。EventSource 会尝试重连;本例不转发
Last-Event-ID,不承诺续传,生成类请求重连可能重复计算
与反向代理、K8s 的配合
- 空闲超时要 大于 上游心跳间隔;可定期发送 SSE 注释行
: ping - 负载均衡「整段缓冲」类设置必须关
- 优雅关闭时,in-flight SSE 应在宽限期内自然结束或被 ctx 取消,见 go-graceful-shutdown
Gin 场景注意点
Gin 中仍须:
c.Writer.Header().Set("Content-Type", "text/event-stream")
// ...
c.Writer.Flush()
// 使用 c.Request.Context()
且避免在中间件里对 Writer 做破坏 Flusher 的包装。机制与标准库相同。
设计动机
- 密钥与策略留在服务端
- 用户体验要真流式,而不是假进度
- 成本控制:传播取消,并核对上游是否实际停止工作
- 可观测:request id 贯穿接入层与上游调用
边界情况与反直觉行为
- 先
WriteHeader(200)再发现上游失败
无法改成 HTTP 502;只能结束流,或在协议与帧边界明确时发约定的错误事件。示例先校验上游响应头,但不能据此保证后续流一定成功。 bufio.Scanner默认 token 上限
默认约 64 KiB,超长行会失败;调大 Buffer 仍有上限。ScanLines 会改写 CRLF、不能按 SSE 的单独 CR 规则分行;原始字节透传用分块 Read,避免行长度限制和换行重写。- 包装 ResponseWriter 丢失 Flusher
运行期才发现「不流式」。 - gzip 中间件
可能缓冲、延迟交付;正确压缩/解压本身不改变 SSE 逻辑帧边界。 - 多 goroutine 写同一 ResponseWriter
无同步则数据竞态;透传循环应单写者。 - 上游也是代理
超时与缓冲问题会叠加,需端到端压测。
常见误区
[!warning] 常见误区:ReadAll 再当 SSE 返回 错误:
body, _ := io.ReadAll(resp.Body); w.Write(body)
正确:边读边写边 Flush。
[!warning] 常见误区:忽略客户端断开 错误:只
for scanner.Scan()不看 ctx。
正确:NewRequestWithContext(r.Context(), ...)+ 循环检查。
[!warning] 常见误区:用过短的 Client.Timeout 切长对话 错误:60s 超时砍掉正常长生成。
正确:会话级 ctx 上限 + 连接级超时分离。
[!warning] 常见误区:错误时混用 JSON 与已开始的 SSE 错误:已发
text/event-stream又c.JSON(502, ...)。
正确:提交响应模式前完成分支决策,或全程走 SSE 事件。
工程实践
- 路径级中间件:SSE 跳过 gzip/缓存。
- 鉴权在建立上游连接之前完成。
- 结构化日志:开始、首字节时间、断开原因、上游状态码、bytes。
- 限流与配额:按用户在代理层拒绝,避免打爆上游。
- 心跳:防中间设备空闲断连。
- 测试:
httptest.ResponseRecorder只能记录 Flush 调用;用httptest.Server和真实 HTTP 客户端验证首块到达及取消,再验证实际代理部署链路。 - 安全:不要把上游原始错误与密钥回传浏览器。
- 可配置上游 URL/模型:但凭证仅环境变量/密钥管理。
反例 vs 正例
// ❌ 假流式
body, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write(body)
// ✅ 真流式
// 见上文分块 Read + 检查 Write/Flush + ctx
验证记录(Go 1.26.8):使用 httptest.Server 和真实 HTTP 客户端,通过 race 检查验证了超过 1 MiB 的行、CR/CRLF 字节保留、上游结束前交付、断开取消,以及连接失败、错误状态/媒体类型、重定向拒绝和错误体隔离。此验证不覆盖 nginx/CDN、远端计费或慢客户端写入。
本节总结
- SSE 代理 = 服务端持密 + 流式透传 + 策略执行。
- 三件套:正确头、分块 Flush、ctx 取消。
- 缓冲可能来自代码、中间件、nginx,需分层关闭。
- 超时要区分建连与长生成。
- 下一步:前端消费 frontend-sse-consumption;多段管道 multi-service-sse-pipeline。
自测题
概念题
- 为什么
X-Accel-Buffering: no不能单独保证浏览器实时显示? - 为何上游请求要用
NewRequestWithContext(r.Context(), ...)? - 已经
WriteHeader(200)且Content-Type: text/event-stream后,上游返回 401,还能改成 HTTP 401 JSON 吗?
代码推理题
中间件按顺序包装:gzip(logging(sseHandler)),可能出现哪些流式故障?
工程思考题
产品要求「用户关闭页面后 1 秒内停止计费上游」。代理层要满足哪些条件才近似可达?
参考答案
展开
- 应用层若不 Flush 或中间件缓冲,仍假流式;该头只影响部分 nginx 行为。
- 客户端断开时取消上游,节约资源与费用。
- 不能改写已经发送的 HTTP 状态和响应头;应事先决策,或结束流/按明确协议报错。
代码:gzip 常缓冲;logging 包装 Writer 可能丢 Flusher。
工程:ctx 绑定上游、上游尊重取消、循环可中断读、计费以实际上游用量并对取消做对账。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| MDN — Server-sent events | 文档 | SSE 协议与事件格式 |
| Package net/http | 标准库 | Flusher、Request.Context、Client |
| Package context | 标准库 | 取消传播 |
| Go Blog — context | 博客 | 请求生命周期 |