默认Gin路由跑SSE会卡住大量goroutine,因gzip、CORS、日志等中间件阻塞Flush()、污染CloseNotify()、忽略自定义响应头,导致流式推送失效和连接无法及时释放。

为什么默认 Gin 路由跑 SSE 会卡住大量 goroutine
因为 Gin 默认中间件(gzip、CORS、日志)会在 handler 返回前阻塞 Flush(),导致每条 SSE 消息无法及时写出,连接长期挂起在写缓冲区。更严重的是,c.Writer.CloseNotify() 在某些中间件下失效,goroutine 无法感知客户端断开,最终堆积成千上万泄漏的协程。
- gzip 中间件会重写
Content-Type为text/event-stream; charset=utf-8并加Content-Encoding: gzip,浏览器直接拒绝解析 - CORS 中间件提前调用
WriteHeader(200),你后续设置的Cache-Control和Connection头被忽略 - 日志中间件等 handler 执行完才记录,但 SSE handler 是无限循环,它根本“执行不完”
必须用 r.NoRoute() 或独立 gin.New() 实例注册 SSE 路由
绕过所有中间件是硬性前提。Gin 没有“禁用某几个中间件”的语法,只能物理隔离。
- 推荐做法:
r := gin.New()新建一个无中间件引擎,只挂 SSE handler;再用另一个gin.Default()处理普通 API - 不推荐
r.Use()后再手动skip,因为CloseNotify()仍可能被中间件污染 - 如果你用
gin.Default(),唯一安全方式是r.NoRoute(sseHandler),但语义不清,易被误删
每次写事件后必须显式 f.Flush() 并检查 error
不能依赖 defer f.Flush(),也不能只在循环末尾 flush——那等于没流式推送。每条完整消息(含 \n\n)写完就得立刻刷出。
- 先做类型断言:
f, ok := c.Writer.(http.Flusher),失败就返回 500 - 用
fmt.Fprintf(f, "data: %s\n\n", msg),确保结尾是两个换行符 -
if err := f.Flush(); err != nil时,需判断是否为连接关闭:errors.Is(err, net.ErrClosed) || errors.Is(err, syscall.EPIPE),是则return - 别用
json.Marshal直接拼进data:字段——中文、双引号、换行都会破坏格式;应先strings.ReplaceAll(msg, "\n", "\ndata: ")
防 Nginx/ALB 断连的关键不是心跳,而是三处超时协同
单靠服务端发 :heartbeat\n\n 不够。真实链路中,Nginx 的 proxy_read_timeout、Go 的 http.Server.WriteTimeout、浏览器 EventSource 的默认重连间隔必须对齐,否则连接会在静默中被层层掐断。
- Go 侧:启动 server 时设
WriteTimeout: 0(禁用写超时),ReadHeaderTimeout: 10 * time.Second - Nginx 侧:
location /events { proxy_read_timeout 300; proxy_buffering off; proxy_cache off; } - 服务端每 25 秒发一次注释心跳:
fmt.Fprint(f, ":heartbeat\n\n")(冒号开头行客户端忽略,但能保活) - 别用
c.Request.Context().Done()唯一判断断连——它在反向代理后常延迟触发,必须结合Flush()error 和心跳响应时间综合判断
真正卡并发的地方从来不是写数据,而是连接生命周期管理失当。漏掉任意一环——中间件干扰、flush 缺失、超时错配、error 未判——都会让 goroutine 堆积成雪球,直到 OOM。











