← [ BACK_TO_NOTES ]

Go SSE 代理模式

业务后端作为 SSE 代理透传上游流式响应:正确事件头、禁用缓冲、逐块 Flush、客户端断开检测、超时与错误映射,避免整包缓冲导致「假流式」。

#concept #growing #dev #backend #go

[!info] Related notes

Go SSE 代理模式

这个概念为什么会出现

浏览器或 App 不宜直连上游 AI/模型/内部流式服务,常见原因:

  • 上游密钥不能下发到客户端
  • 需要在边缘做登录态、配额、审计、上下文拼装
  • 需要统一公网域名与 CORS 策略
  • 需要屏蔽上游错误细节或做协议适配

于是业务后端成为 SSE(Server-Sent Events)代理:对客户端表现为 text/event-stream,对上游则是流式 HTTP 客户端。

难点在于:代理普通 JSON 可以整包读完再写;代理 SSE 必须:

  1. 尽快把上游字节送到客户端
  2. 禁止网关/框架/中间件缓冲整响应
  3. 客户端断开时停止拉上游,避免浪费配额
  4. 正确处理超时、非 200、中途断流

[!abstract] 一句话理解 SSE 代理是「带身份与策略的流式透传」:设好事件流响应头,按块读取上游 body,每次写入后 Flush,并用 Request.Context() 感知客户端离开。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:聊天流式接口代理上游 AI SSE

产品页 GET /api/chat/stream 不能把模型 API Key 下发给浏览器。
业务后端做 SSE 代理:

  1. 对前端:text/event-stream 真流式
  2. 对上游:带服务端 Token 拉流
  3. 客户端断开被服务器检测到后,取消本地上游请求;远端是否停止生成/计费取决于上游实现

以下用 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,不转发上游错误体。

结合场景再看四个关注点

  1. 先断言 http.Flusher。
  2. 上游请求绑定 r.Context(),客户端断开则取消上游。
  3. 禁止 io.ReadAll 后再一次性写出。
  4. 每次读到字节后写入并检查 Write/Flush 错误;读取块不必等于 SSE 事件。

核心概念与准确模型

SSE 在 HTTP 上的形态

  • 长连接响应,Content-Type: text/event-stream
  • 文本帧:event: / data: / 空行分隔等(见 sse)
  • 方向以服务器 → 客户端为主(与 WebSocket 全双工不同)

代理通常有两种策略:

策略做法适用
字节透传上游已是 SSE,保持正文原始字节AI 网关、已统一协议
协议适配上游 chunked JSON/gRPC stream,本地组装为 SSE上游协议不对外

本篇聚焦透传;适配时仍遵守 Flush 与取消。

为何「缓冲」是第一大敌

缓冲可能来自:

  1. 应用代码 ReadAll / 拼完 string 再写
  2. 中间件 gzip、某些日志包装 Writer
  3. 反向代理(nginx proxy_buffering)
  4. 云 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.ServerReadHeaderTimeout 等防慢连接;流式接口的 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 的包装。机制与标准库相同。

设计动机

  1. 密钥与策略留在服务端
  2. 用户体验要真流式,而不是假进度
  3. 成本控制:传播取消,并核对上游是否实际停止工作
  4. 可观测:request id 贯穿接入层与上游调用

边界情况与反直觉行为

  1. 先 WriteHeader(200) 再发现上游失败
    无法改成 HTTP 502;只能结束流,或在协议与帧边界明确时发约定的错误事件。示例先校验上游响应头,但不能据此保证后续流一定成功。
  2. bufio.Scanner 默认 token 上限
    默认约 64 KiB,超长行会失败;调大 Buffer 仍有上限。ScanLines 会改写 CRLF、不能按 SSE 的单独 CR 规则分行;原始字节透传用分块 Read,避免行长度限制和换行重写。
  3. 包装 ResponseWriter 丢失 Flusher
    运行期才发现「不流式」。
  4. gzip 中间件
    可能缓冲、延迟交付;正确压缩/解压本身不改变 SSE 逻辑帧边界。
  5. 多 goroutine 写同一 ResponseWriter
    无同步则数据竞态;透传循环应单写者。
  6. 上游也是代理
    超时与缓冲问题会叠加,需端到端压测。

常见误区

[!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 事件。

工程实践

  1. 路径级中间件:SSE 跳过 gzip/缓存。
  2. 鉴权在建立上游连接之前完成。
  3. 结构化日志:开始、首字节时间、断开原因、上游状态码、bytes。
  4. 限流与配额:按用户在代理层拒绝,避免打爆上游。
  5. 心跳:防中间设备空闲断连。
  6. 测试:httptest.ResponseRecorder 只能记录 Flush 调用;用 httptest.Server 和真实 HTTP 客户端验证首块到达及取消,再验证实际代理部署链路。
  7. 安全:不要把上游原始错误与密钥回传浏览器。
  8. 可配置上游 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。

自测题

概念题

  1. 为什么 X-Accel-Buffering: no 不能单独保证浏览器实时显示?
  2. 为何上游请求要用 NewRequestWithContext(r.Context(), ...)?
  3. 已经 WriteHeader(200) 且 Content-Type: text/event-stream 后,上游返回 401,还能改成 HTTP 401 JSON 吗?

代码推理题

中间件按顺序包装:gzip(logging(sseHandler)),可能出现哪些流式故障?

工程思考题

产品要求「用户关闭页面后 1 秒内停止计费上游」。代理层要满足哪些条件才近似可达?

参考答案

展开
  1. 应用层若不 Flush 或中间件缓冲,仍假流式;该头只影响部分 nginx 行为。
  2. 客户端断开时取消上游,节约资源与费用。
  3. 不能改写已经发送的 HTTP 状态和响应头;应事先决策,或结束流/按明确协议报错。 代码:gzip 常缓冲;logging 包装 Writer 可能丢 Flusher。
    工程:ctx 绑定上游、上游尊重取消、循环可中断读、计费以实际上游用量并对取消做对账。

延伸阅读与资料来源

资料类型支撑
MDN — Server-sent events文档SSE 协议与事件格式
Package net/http标准库Flusher、Request.Context、Client
Package context标准库取消传播
Go Blog — context博客请求生命周期

笔记元信息

  • 建议文件名:go-sse-proxy-pattern.md
  • 所属阶段:阶段七(Web 后端 / 流式集成)
  • 本篇状态:已深化
  • 建议下一篇:健康检查端点 或 优雅关闭
创建于 2026/6/25 更新于 2026/10/7
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本