我给自己的知识库做了个"边生成边显示"的输入框,本地开发正常,部署到云主机后用户反馈说看着像在转圈,但一个字都没有。
查下来不是后端的问题,是反向代理把流式响应缓冲住了。SSE 跟普通 HTTP 共享同一套网络栈,资源占用模型却完全不一样。
SSE 和普通请求的根本差别
普通请求是"来一个请求走一个来回,连接释放"。SSE 是"连接建立后一直挂着,直到服务关闭或客户端断开"。
一个 SSE 连接从握手到结束会持续占用:
- 一个 TCP 连接(两个方向的缓冲区)
- 一个文件描述符
- 服务端的一个协程/线程上下文
- 中间每一层代理(CDN、负载均衡、Nginx)的连接表项
- 如果放在容器里,还有 conntrack 表项
这些资源你用 curl 测单条请求时,一个都不会暴露。
连接数:三层限制,取最小值
这是我踩得最疼的一个。三层限制,任何一层不够都会出问题,症状还各不相同。
第一层,系统 fd 上限。
# 查当前进程的 fd 上限
cat /proc/sys/fs/file-max # 系统级
ulimit -n # 当前 shell
ls /proc/$(pgrep -f uvicorn | head -1)/fd | wc -l # 实际用了多少
默认往往只有 1024。1000 个并发用户各占一个连接,加上游连接池,几千个 fd 轻松超掉。
症状分层很有意思:ulimit 生效但不超过 file-max 时,应用会抛 Too many open files;如果 file-max 也到了,内核会直接丢弃 SYN 包,客户端看到的是"连接超时"而不是"连接被拒绝"。后者难查得多,日志里什么都没有。
第二层,Nginx 的 worker 连接数。
worker_processes auto;
events {
# 每个 worker 的最大连接数
worker_connections 8192;
}
worker_connections 算的是所有连接,包括到上游的。客户端占一个,连接池再占一个,能承载的并发大约是它的一半。还有个坑:它也受 ulimit 约束,两者得一起抬。
第三层,内核的连接跟踪表。
# 查看 conntrack 表容量和当前占用
cat /proc/sys/net/netfilter/nf_conntrack_max
cat /proc/sys/net/netfilter/nf_conntrack_count
SSE 连接在 conntrack 表里会一直占条目,直到连接真正关闭。表满了新连接会被丢弃——症状和 fd 超限一样,都是连接超时。跑 Docker 时这张表默认几万个,几百个并发长连接很快就满了。
经验公式:并发用户数乘以 2(客户端 + 上游连接),再乘以 1.5 的余量,跟三层的限制取最小值。
空闲断开:中间设备会杀你
这是 SSE 最隐蔽的问题:没人报错,就是断了。
一个 SSE 连接长时间没有数据传输,中间任何一环都可能掐掉它:
| 环节 | 典型空闲超时 | 能不能配 |
|---|---|---|
| 云厂商负载均衡 | 30 到 60 秒 | 看产品文档,部分可调 |
| Nginx proxy_read_timeout | 60 秒(默认) | 能配 |
| 应用层读超时 | 视框架 | 能配 |
| 中间的 CDN | 通常 30 到 100 秒 | 一般不可配 |
问题在于,大模型的流式输出天然有停顿。模型在"想"的时候可能好几秒不吐 token,复杂推理能到十几秒。
于是有了这个组合:LB 的空闲超时 60 秒 < 模型思考时间。请求发过去,LB 认为这条连接是死的,悄悄断掉。客户端收到一个不完整的流,而且HTTP 状态码是 200——响应已经开始了,中途断开在协议层面不算错误。
做法是两层。
第一层,超时拉到远大于最坏思考时间。
location /v1/chat/stream {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_connect_timeout 5s;
}
第二层,加心跳。 每隔 15 秒发一个 SSE 注释行,让连接在中间设备眼里始终"活着":
import asyncio
async def sse_with_heartbeat(event_iter, interval=15):
"""在事件流空闲时插入心跳注释行,防止中间设备判定连接空闲。"""
agen = event_iter.__aiter__()
try:
while True:
try:
event = await asyncio.wait_for(agen.__anext__(), timeout=interval)
except asyncio.TimeoutError:
# SSE 注释行,客户端会忽略但能让连接保活
yield b": ping\n\n"
continue
yield event
except StopAsyncIteration:
pass
finally:
await agen.aclose()
心跳间隔要比最短的那层空闲超时小一个数量级。我取 15 秒是因为链路上最短一环是 60 秒;用了会 30 秒断连的 CDN,就得改 5 秒。
asyncio.wait_for 包一层还有个副作用好处:它顺带解决了"上游卡住"的问题。后端真挂了,你还可以直接断掉让客户端重连。
缓冲:为什么本地正常线上不正常
前面那个"一个字都不显示",根因就是缓冲。
SSE 靠 Content-Type: text/event-stream 和不使用 Content-Length 表达"这是个持续的流"。HTTP 代理不知道这个,只看到 text/* 响应,自然就想缓冲。
# 1. 关掉代理缓冲
proxy_buffering off;
# 2. 关掉缓存
proxy_cache off;
# 3. 关键:告诉上游别缓冲我
proxy_set_header X-Accel-Buffering no;
第三条是给 上游应用看的。Nginx 收到 X-Accel-Buffering: no 时,即使全局开了 buffering 也不缓冲这个响应。很多流式框架会带这个头,也有不会的——手动加更保险:
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
Cache-Control: no-cache 也别漏。CDN 那层如果不认这个头,会尝试缓存这个流——缓存策略和流式响应天然冲突。
还有一个本地测不出来的问题:gzip。gzip 压缩有缓冲,要攒够一块才输出。开了 gzip 的 SSE 会"憋一大段再突然出来",SSE 的 location 里要写上 gzip off;。 我一开始没找到原因,是因为本地直连时 curl -N 能看到逐行输出,上了 Nginx 就是一次性输出——问题在 Nginx 不在应用。这个对比方法我固定下来了:直连能流式、过代理不行,就是代理缓冲问题。
客户端侧的断线重连
即便服务端做对了,客户端也一定会遇到断线——网络切换、负载均衡重调度都会断。
SSE 规范自带重连机制,客户端会自动带上 Last-Event-ID,服务端要从那个位置继续发。所以发事件时要带 id:
async def event_stream():
last_id = request.headers.get("last-event-id", "0")
cursor = int(last_id)
async for item in fetch_since(cursor):
yield f"id: {item.id}\n".encode()
yield f"data: {json.dumps(item.payload, ensure_ascii=False)}\n\n".encode()
cursor = item.id
不做断点续传,客户端重连会从头开始,用户的整个回答被重放一遍。
压测时的注意事项
我压 SSE 时踩过一个坑:用 ab 或 wrk 测出来的并发数和真实场景完全不同。这类工具默认不保持连接、不处理流式响应,测的是"建连速率"不是"维持连接的能力"。
真实压测得自己写,比如这样:
import asyncio
import aiohttp
import time
async def one_client(session, sem, url, payload):
async with sem:
t0 = time.monotonic()
first_token = None
chunks = 0
try:
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=300)) as r:
async for line in r.content:
if line.startswith(b"data:"):
chunks += 1
if first_token is None:
first_token = time.monotonic() - t0
except Exception as e:
return {"error": str(e)}
return {"ttft": first_token, "chunks": chunks, "total": time.monotonic() - t0}
async def main(n, url, payload):
sem = asyncio.Semaphore(n)
async with aiohttp.ClientSession() as session:
return await asyncio.gather(*[one_client(session, sem, url, payload) for _ in range(n)])
要盯的不只是错误率,还有:首 token 延迟(TTFT)、中途断流比例、fd 使用量。中途断流最容易漏掉——它不在错误率里,只在"收到的 chunk 数偏少"。
收尾
教训是:它看起来像 HTTP,所以大家用 HTTP 的经验去配,结果全错。
资源占用模型、超时语义、中间设备行为、故障表现都不一样(200 状态码 + 内容截断,最坏的组合)。
每一层代理都要显式配置,SSE 没有默认行为可言。
至于 fd 上限、conntrack 表大小、LB 空闲超时这些数值,取决于实例规格、镜像类型和网络环境,别照抄,挨个查自己那台机器。