流式与实时响应
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-Cache | BYPASS — 完全跳过缓存读取 |
| 存储 | 永不存储 |
| 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_connections。max_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-After | fetch_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_bytes和bouine_streaming_fallback_total观察压力。
源站 fetch 卸载
源站缓慢是反向代理的经典故障模式:请求 goroutine 等待 fetch 槽位 而无限堆积,pod 进入无法自恢复的活锁。bouine 选择卸载。
当前台 miss 无法在 fetch_wait_timeout(默认 100ms,校验上限 1s)
内获取源站 fetch 槽位(由每路由 max_fetch_concurrency 限定)时:
- 作用域内存在过期对象则直接用过期对象服务(RFC 5861 风格),或
- 客户端收到 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。