前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。

文章目录
- [排坑指南:本地好用线上"假死"?详解 SSE 流式响应在 Nginx 下的缓冲问题](#排坑指南:本地好用线上“假死”?详解 SSE 流式响应在 Nginx 下的缓冲问题)
-
-
- 前言
- 问题分析:谁"吃"了我的数据?
- 解决方案:如何打通"任督二脉"
-
- [✅ 方法一:修改 Nginx 配置(已验证有效)](#✅ 方法一:修改 Nginx 配置(已验证有效))
- [⚡ 方法二:后端设置响应头(推荐的最佳实践)](#⚡ 方法二:后端设置响应头(推荐的最佳实践))
- 进阶优化:前端代码的健壮性提升
- 总结
-
排坑指南:本地好用线上"假死"?详解 SSE 流式响应在 Nginx 下的缓冲问题
前言
最近在开发一个基于 SSE(Server-Sent Events)的流式接口,后端采用 Plan 模式生成数据。在本地开发调试时,体验非常丝滑:请求发出的第一秒,浏览器就能收到后端返回的第一个 Plan 片段,就像打字机一样一个个蹦出来。
然而,代码一部署到线上环境,诡异的事情发生了:
不管怎么测,线上的流式响应都像是"卡住"了一样。打开 Chrome 的 Network 面板,发现请求一直处于 Pending(待处理)状态,没有任何数据回传。
更糟糕的是,这种"待处理"状态会持续很久,最后要么是一次性把所有数据吐出来(流式变成了块式),要么是等待时间过长直接爆出 500 或 504 Gateway Timeout 错误。
经过一番排查,终于揪出了幕后黑手。这篇文章就来复盘一下这个典型的 SSE + Nginx 缓冲(Buffering) 问题,并附上经过验证的解决方案和前端代码优化建议。
问题分析:谁"吃"了我的数据?
在本地开发时,浏览器通常直接连接后端服务,或者通过简单的本地代理,数据通道是畅通无阻的。
但到了线上,架构通常变成了:
txt
Client (浏览器) -> Nginx (反向代理/网关) -> Backend (后端服务)
罪魁祸首就是 Nginx 的默认行为:Proxy Buffering(代理缓冲)。
Nginx 为了提升 I/O 效率,默认会开启缓冲。当后端开始吐出 SSE 数据流(比如几个字节的 Plan 片段)时,Nginx 觉得"这点数据太小了,不值得跑一趟",于是它把数据先拦截下来,存到内存缓冲区里。
这就导致了我们看到的现象:
- 后端:其实早就把数据发出去了。
- Nginx:拿着数据不发,想攒够一定大小(通常是 4k 或 8k)或者等请求结束再一起发。
- 前端 :收不到第一个字节,浏览器认为连接还没建立好,所以一直显示
Pending。 - 超时:如果后端生成完整内容耗时太久(超过 Nginx 超时设置),Nginx 等不及了,直接断开连接,抛出 500/504 错误。
解决方案:如何打通"任督二脉"
既然知道了原因是 Nginx 缓冲,解决思路就是告诉 Nginx:"这还是个急件,来多少发多少,不要攒!"
✅ 方法一:修改 Nginx 配置(已验证有效)
这是最直接的运维层面手段,特别适合无法修改后端代码或者全局代理配置的场景。我们需要在 Nginx 对应的 location 中显式关闭缓冲。
配置示例:
Nginx
location /project/content/completion {
proxy_pass http://backend_service;
# 1. 核心:关闭代理缓冲
# 告诉 Nginx 不要缓存后端响应,收到什么直接发给客户端
proxy_buffering off;
# 2. 可选:关闭 gzip
# 流式数据如果被 gzip 压缩,通常也需要积攒数据块,建议关闭
gzip off;
# 3. 关键:增加超时时间
# AI 生成/流式响应通常耗时较长,默认的 60s 很容易超时
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
实测效果:
配置生效后,线上的 Network 面板终于恢复正常,TTFB(首字节时间)瞬间降低,流式效果与本地一致。
⚡ 方法二:后端设置响应头(推荐的最佳实践)
如果你不想改 Nginx 配置,或者希望把控制权掌握在代码里,可以让后端在返回 SSE 数据时,带上特定的 HTTP Header。这是一种更优雅的解耦方式。
在 SSE 响应头中添加:
HTTP
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
X-Accel-Buffering: no <-- 这一行是写给 Nginx 看的
X-Accel-Buffering: no:这是一个 Nginx 特有的指令,Nginx 看到这个头,就会自动禁用当前连接的缓冲,而不需要运维去改配置文件。
进阶优化:前端代码的健壮性提升
解决了网络传输问题,回过头看前端的解析逻辑。在排查过程中,我发现原本使用的 XMLHttpRequest 处理逻辑存在两个隐患:
- 解析逻辑冗余 :
onload里重复处理了整个数据流。 - 内存隐患 :
xhr.responseText是不断累加的,如果流式内容非常长,内存占用会越来越大。
针对上述问题,这里提供一份更健壮的 XHR 处理代码(兼容性好,逻辑严密):
TypeScript
const handleStreamRequest = () => {
const xhr = new XMLHttpRequest();
xhr.open('POST', fullUrl, true);
// ... 设置 header ...
// 游标:记录已经处理过的字符长度
let seenBytes = 0;
// 缓存:用于存放尚未组成完整消息的碎片数据
let rawResponseBuffer = '';
xhr.onprogress = (event) => {
// 1. 增量获取:只截取本次新增的数据,避免重复处理
const currentResponse = xhr.responseText;
const newChunk = currentResponse.slice(seenBytes);
seenBytes = currentResponse.length; // 更新游标
// 2. 拼接到缓冲区
rawResponseBuffer += newChunk;
// 3. 按双换行符分割 SSE 消息块
const parts = rawResponseBuffer.split('\n\n');
// 核心逻辑:保留最后一个可能不完整的块
// parts.pop() 取出的永远是最后一段(可能是空的,也可能是一半的数据)
// 将其放回 buffer 等待下一次拼接
rawResponseBuffer = parts.pop() || '';
// 4. 处理完整的消息块
for (const part of parts) {
parseAndDispatch(part);
}
};
xhr.onload = () => {
// 处理最后残留在 buffer 中的数据
if (rawResponseBuffer.trim()) {
parseAndDispatch(rawResponseBuffer);
}
console.log('Stream completed');
};
// ... 发送请求 ...
};
// 抽离解析逻辑
const parseAndDispatch = (message: string) => {
if (!message.trim()) return;
const lines = message.split('\n');
for (const line of lines) {
if (line.startsWith('data:')) {
const dataStr = line.replace(/^data:\s*/, '').trim();
if (dataStr === '[DONE]') return;
try {
const parsedData = JSON.parse(dataStr);
// 更新 UI 逻辑...
} catch (e) {
console.error('JSON Parse error:', e);
}
}
}
};
总结
- 现象:本地 SSE 正常,线上一直 Pending 或 500/504。
- 原因 :Nginx 开启了
proxy_buffering,拦截了后端的小块数据。 - 对策 :
- 运维侧 :Nginx 配置
proxy_buffering off;。 - 开发侧 :响应头添加
X-Accel-Buffering: no。
- 运维侧 :Nginx 配置
- 防守:前端代码要注意"增量读取",避免全量字符串操作带来的性能损耗。
希望这篇踩坑记录能帮到同样遇到"流式卡顿"的朋友们!