流式响应 线上请求出现“待处理”问题

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

文章目录

排坑指南:本地好用线上"假死"?详解 SSE 流式响应在 Nginx 下的缓冲问题

前言

最近在开发一个基于 SSE(Server-Sent Events)的流式接口,后端采用 Plan 模式生成数据。在本地开发调试时,体验非常丝滑:请求发出的第一秒,浏览器就能收到后端返回的第一个 Plan 片段,就像打字机一样一个个蹦出来。

然而,代码一部署到线上环境,诡异的事情发生了:

不管怎么测,线上的流式响应都像是"卡住"了一样。打开 Chrome 的 Network 面板,发现请求一直处于 Pending(待处理)状态,没有任何数据回传。

更糟糕的是,这种"待处理"状态会持续很久,最后要么是一次性把所有数据吐出来(流式变成了块式),要么是等待时间过长直接爆出 500504 Gateway Timeout 错误。

经过一番排查,终于揪出了幕后黑手。这篇文章就来复盘一下这个典型的 SSE + Nginx 缓冲(Buffering) 问题,并附上经过验证的解决方案和前端代码优化建议。


问题分析:谁"吃"了我的数据?

在本地开发时,浏览器通常直接连接后端服务,或者通过简单的本地代理,数据通道是畅通无阻的。

但到了线上,架构通常变成了:

txt 复制代码
Client (浏览器) -> Nginx (反向代理/网关) -> Backend (后端服务)

罪魁祸首就是 Nginx 的默认行为:Proxy Buffering(代理缓冲)。

Nginx 为了提升 I/O 效率,默认会开启缓冲。当后端开始吐出 SSE 数据流(比如几个字节的 Plan 片段)时,Nginx 觉得"这点数据太小了,不值得跑一趟",于是它把数据先拦截下来,存到内存缓冲区里。

这就导致了我们看到的现象:

  1. 后端:其实早就把数据发出去了。
  2. Nginx:拿着数据不发,想攒够一定大小(通常是 4k 或 8k)或者等请求结束再一起发。
  3. 前端 :收不到第一个字节,浏览器认为连接还没建立好,所以一直显示 Pending
  4. 超时:如果后端生成完整内容耗时太久(超过 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 处理逻辑存在两个隐患:

  1. 解析逻辑冗余onload 里重复处理了整个数据流。
  2. 内存隐患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);
            }
        }
    }
};

总结

  1. 现象:本地 SSE 正常,线上一直 Pending 或 500/504。
  2. 原因 :Nginx 开启了 proxy_buffering,拦截了后端的小块数据。
  3. 对策
    • 运维侧 :Nginx 配置 proxy_buffering off;
    • 开发侧 :响应头添加 X-Accel-Buffering: no
  4. 防守:前端代码要注意"增量读取",避免全量字符串操作带来的性能损耗。

希望这篇踩坑记录能帮到同样遇到"流式卡顿"的朋友们!

相关推荐
茶杯梦轩1 小时前
从零起步学习并发编程 || 第一章:初步认识进程与线程
java·服务器·后端
weixin199701080162 小时前
虾皮商品详情页前端性能优化实战
前端·性能优化
ArcX2 小时前
手把手从 0 诠释大模型 API 的本质: Tools + MCP + Skills
前端·后端·ai编程
慧一居士2 小时前
vue项目中,tsx格式的文件是什么,怎样使用
前端·vue.js
UrbanJazzerati2 小时前
Python 面向对象编程:抽象类、接口与继承系统教程
后端·面试
马尔代夫哈哈哈2 小时前
SpringBoot 统一功能处理
java·前端·spring boot
慧一居士3 小时前
tsconfig.json完整使用配置介绍和示例
前端·vue.js
%253 小时前
Nginx
运维·nginx
李老师的Java笔记3 小时前
深度解析 | SpringBoot源码解析系列(五):@ConfigurationProperties | 配置绑定核心原理+实战避坑
java·spring boot·后端