bouine 从不进行不必要的响应体缓冲。本页描述三种流式行为,以及与 之交互的两项过载保护。

Server-Sent Events(SSE)

bouine 将 Server-Sent Events 作为端到端的实时流服务。携带 Accept: text/event-stream 的请求(WHATWG 客户端契约——浏览器、 EventSource 和 AI SDK 的行为)以无缓冲流的方式服务:永不缓存、 永不被 singleflight 合并到其他客户端的流上。

routes:
  - match: { path_prefix: /chat/ }
    pool: llm
    cache:
      ttl_default: 60s

无需任何路由级配置:Accept 头即契约。基于 POST 的 SSE(AI API 的 主流形态——请求体后跟流式响应)同样适用,其 写方法失效 语义保持不变:2xx/3xx 响应在收到响应头时即清除受影响的缓存 条目,而非等待(无限的)响应体。

SSE 服务契约

行为说明
X-CacheBYPASS — 完全跳过缓存读取
存储永不存储
Singleflight永不合并;每个客户端获得自己的源站 fetch
Fetch 槽位收到响应头时即释放——活跃流不占用 max_fetch_concurrency 槽位
刷新每个事件到达即刷新给客户端
源站读取预算10 分钟空闲预算,每个事件重置——活跃流永远不会被时钟切断
客户端写入预算基于空闲(H1 fast path 上每次写入重置 5 分钟;未启用时为 1 小时绝对值)

后两行是关键属性:源站持续发送事件的流无限期保持打开,而死掉的 对端或停止读取的客户端仍会被切断。

未声明的 SSE

源站可能对未携带 Accept 头的请求返回 Content-Type: text/event-stream。此类响应仍以无缓冲方式流式传输, 但受该路由 fetch_timeout 的限制(源站连接的读取截止时间在 得知响应是流之前就已设置)。应修正客户端使其发送该头,而不是 提高 fetch_timeout。同一 URL 的并发未声明请求不会合并到一个流上 ——没有任何缓冲,因此不存在可共享的结果。

SSE 路由调优

  • 稀疏事件流(间隔超过 10 分钟且无心跳):无需调整。源站必须 发送 SSE 注释行心跳,否则流在 10 分钟空闲预算后被切断,客户端 会重连。
  • 大量并发流:每个流在其生命周期内占用一条客户端连接和一条 源站连接。面向 SSE 密集路由的 pool 应提高 upstream_pools[].connect.max_connections(默认 64),并为数据 面提高 listen.max_connectionsmax_fetch_concurrency 无需 提高——流在收到响应头时即释放 fetch 槽位。
  • 挂起的源站:已声明的 fetch 若源站接受连接但从不发送响应头, 会在 10 分钟空闲预算内占用一个 fetch 槽位(而非 response_header_timeout)。只有显式声明流式意图的请求走此路径。

故障模式

症状原因
流在约 10 分钟静默后结束空闲预算触发——源站停止发送且无心跳
流恰好在 fetch_timeout 时结束客户端未发送 Accept: text/event-stream(未声明路径)
未启用 fast path 时流在 1 小时结束普通 fasthttp 服务路径上的预期行为;启用 experimental.h1_fast_path 或依赖客户端重连
流开始时收到 503 + Retry-Afterfetch_wait_timeout 内 fetch 队列已满——提高 max_fetch_concurrency 或排查源站延迟

流式 miss

可缓存的 miss 在后台将响应体 tee 到存储的同时流式传输给客户端, 客户端无需等待完整响应体即可收到首字节。Tee 缓冲受以下限制:

  • 单流:max_response_bytes(超出即以 502 中止 fetch)。
  • 单路由:max_streaming_buffer_bytes——该路由所有并发 miss fetch 持有的活跃 tee 缓冲总字节数。超限时,新的可缓存 miss 回退为同步 缓冲(客户端等待完整响应体,缓冲不再活跃)。默认值从 GOMEMLIMIT 推导(7%),内置下限 64 MiB。可通过 bouine_streaming_buffer_bytesbouine_streaming_fallback_total 观察压力。

源站 fetch 卸载

源站缓慢是反向代理的经典故障模式:请求 goroutine 等待 fetch 槽位 而无限堆积,pod 进入无法自恢复的活锁。bouine 选择卸载。

当前台 miss 无法在 fetch_wait_timeout(默认 100ms,校验上限 1s) 内获取源站 fetch 槽位(由每路由 max_fetch_concurrency 限定)时:

  1. 作用域内存在过期对象则直接用过期对象服务(RFC 5861 风格),或
  2. 客户端收到 503 + Retry-After: 1——与源站故障的 502 映射不同。

Singleflight 跟随者与进行中流的跟随者以 leader 的卸载结果一并解除 阻塞。bouine_fetch_shed_total 计数器暴露卸载率用于告警。

routes:
  - match: { path_prefix: / }
    pool: app
    cache:
      max_fetch_concurrency: 32
      fetch_wait_timeout: 100ms

等待上限存在的意义是吸收亚秒级的 fetch 队列突发,而非在持续过载 中排队:当到达速率超过排空速率时,任何有限等待都无法排空队列, 更长的上限只会让 goroutine(及其连接)在卸载前被更久地挂住。 应提高 max_fetch_concurrency 或横向扩容,而非提高 fetch_wait_timeout