接入 CDN 后,SSE 为什么攒到最后才输出?流式响应缓冲与超时排查
本内容发表于:2026-10-08 12:03:13
浏览量
1073

AI 对话已经在服务端逐段生成,浏览器却安静了十几秒,随后一下子显示整段内容。另一些请求能逐条收到消息,但空闲一段时间就断开。SSE 接入 CDN、反向代理或网关后,这两种故障需要分别处理:前者检查数据在哪一层被积攒,后者检查哪一段连接触发了空闲超时。

本文针对返回 text/event-stream 的 HTTP 接口。WebSocket 有自己的连接与升级机制,不适用这里的完整配置。以下命令和程序是诊断示例,不是对任何生产站点的实测记录。

SSE 数据在边缘网关缓冲堆积与逐条实时传输的对照示意图

确认应用发出的确实是完整事件

SSE 使用 UTF-8 文本,事件由空行结束。服务端写出一行 data: 后,如果没有再补一个空行,客户端会继续等待这条事件结束。[1]

data: 第一段内容

程序里通常写成 data: 第一段内容\n\n。浏览器原生 EventSource 解析器会按事件边界分发消息;如果客户端使用 fetch() 加自定义流解析器,还要检查它有没有先把整个响应读完,再统一更新页面。HTTP 网络数据块和 SSE 事件不保证一一对应。

可以用下面的最小 Node.js 服务隔离模型生成、数据库和业务框架。它每秒写出一条事件,共写五条,然后结束连接。flushHeaders() 负责尽早发出响应头;消息能否及时到达浏览器,还取决于后面的代理和网络。[2]

const http = require('node:http');

http.createServer((req, res) => {
  if (req.url !== '/events-test') {
    res.writeHead(404).end();
    return;
  }
  res.writeHead(200, {
    'Content-Type': 'text/event-stream; charset=utf-8',
    'Cache-Control': 'no-store',
    'X-Accel-Buffering': 'no'
  });
  res.flushHeaders();
  let n = 0;
  const timer = setInterval(() => {
    res.write(`data: ${JSON.stringify({ n: ++n, at: Date.now() })}\n\n`);
    if (n === 5) {
      clearInterval(timer);
      res.end();
    }
  }, 1000);
  res.on('close', () => clearInterval(timer));
}).listen(8080, '127.0.0.1');

这段程序默认只监听本机,适合在源站上经反向代理测试。它没有认证和业务限制,不应为了调试直接开放到公网。原生 EventSource 在连接结束后可能自动重连;测试时用 curl,或由浏览器客户端主动关闭连接。

用同一个接口对照源站与 CDN

curl 默认可能缓冲输出。检查逐条到达时间时,加上 --no-buffer,避免把终端自己的缓冲误认成 CDN 故障。[3]

curl --no-buffer --max-time 15 \
  -D sse-edge-headers.txt \
  https://api.example.com/events-test

curl --no-buffer --max-time 15 \
  --resolve api.example.com:443:203.0.113.10 \
  -D sse-origin-headers.txt \
  https://api.example.com/events-test

第一条走正常 DNS 和 CDN,第二条将同一主机名固定到测试源站。替换示例域名和保留地址 203.0.113.10;后者是文档示例地址。--resolve 保留 URL 中的主机名,便于使用相同的 Host 与 TLS 主机名检查路径。源站需要有匹配的证书,且允许测试机连接。[3]

源站只接受 CDN 流量时,在批准的测试环境使用同等入口,不要放开整个源站防火墙,也不要用 -k 跳过证书验证。测试文件可能包含敏感响应头,保存和分享前应做脱敏。

观察每次输出发生的时间,而不只是服务器消息里的时间戳:

观测结果下一步检查
直连源站也成批输出应用是否逐次写出、事件空行、框架及源站代理缓冲
直连逐条输出,经过 CDN 成批输出CDN 流式支持、响应缓冲、压缩与边缘处理规则
curl 逐条输出,页面仍一次显示前端读取和渲染逻辑、浏览器路径差异
收到消息正常,空闲后断开各层读取超时、心跳、连接总时长上限

这里的对照用于缩小问题范围。多源站、区域路由和不同请求头可能导致两条测试走向不同应用实例,需要一并记录。

Nginx 只对流式接口关闭响应缓冲

Nginx 的 proxy_buffering 控制代理响应缓冲。关闭后,它会在收到上游数据时同步向客户端传递;上游也可以使用 X-Accel-Buffering: no 控制该行为,但管理员可通过 proxy_ignore_headers 禁用这种控制。[4]

如果 /events/ 是专门的流式路径,可以在已有代理配置上做局部调整:

location /events/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_set_header Host $host;

    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 75s;
}

保留现有鉴权、限流、日志、其他必要的转发头和访问限制,不要直接用示例替换生产 location。proxy_pass 未附加 URI,转发时保留请求路径;上面的 /events-test 最小程序需要对应的测试路由,不能直接拿它验证 /events/ 下的业务路径。

75s 是示例值,需要结合业务和代理链路设置。Nginx 的 proxy_read_timeout 限制两次读取上游响应数据之间的间隔,不是整个响应允许持续的总时长。[4] 因此,流式请求持续数分钟和某一段空闲超过 75 秒,是两种不同情况。

proxy_request_buffering 管的是客户端请求体,修改它通常不能解决下行 SSE 响应积攒。压缩也作为独立变量测试:临时在这个路径关闭 gzip,并检查边缘是否仍做压缩或响应转换,不要全站关闭已有优化。修改前备份配置,运行 nginx -t,通过后再按既定变更流程重载。

CDN 配置需要单独核对

源站的 X-Accel-Buffering: no 可以影响支持它的代理。不能据此推断 CDN 一定会关闭缓冲。逐项查看当前 CDN 产品对流式响应的支持,以及该路径实际匹配的配置:

  • 动态事件接口绕过缓存,不复用包含用户信息的响应;

  • 不执行需要等待完整响应体的页面改写或边缘程序逻辑;

  • 分开检查压缩、响应缓冲及回源连接参数;

  • 明确空闲超时与总连接时长限制,确认两者是否都存在。

源站返回 Cache-Control: no-store 是建议配置,仍应检查 CDN 上显式覆盖缓存行为的规则。不要只看响应里有没有某个命中标记就判断整条链路可流式传输。

测试时一次改变一个因素,并记录请求路径、响应头、客户端实际接收时间和规则版本。全站关缓存容易扩大性能影响,也会掩盖具体是哪条规则出了问题。

空闲断线用心跳验证,恢复连接靠事件编号

SSE 支持以冒号开头的注释行。对于可能较长时间没有业务数据的连接,服务端可以定时写出注释心跳,例如 : ping\n\n。规范中的作者建议提到约每 15 秒发送一条注释以应对部分旧代理;实际间隔应低于链路中最短的空闲超时,并按负载进行验证。[1]

心跳也必须及时穿过代理。如果仍被缓冲,发送频率再高也未必能保持客户端这一段连接。硬性的总连接时长上限也不会因为心跳自动消失。

原生 EventSource 支持重连,以及通过事件中的 id: 和后续请求中的 Last-Event-ID 传递恢复位置。[1] 应用需要自行保留可重放记录,定义重放范围,并处理重复事件;浏览器自动重连不会替你补齐丢失的业务数据。使用 fetch() 的自定义客户端,应自行实现相应的重连与恢复流程。

用户专属事件流还要检查认证、跨域策略和断开后的资源清理。不要用关闭鉴权或共享缓存的方式换取“能收到消息”。

验收看逐条到达和断线恢复

先保留一次变更前的结果,再用同一客户端、同一接口对照修改后表现。最小程序的五条消息应按时间分开到达;业务接口还要覆盖长空闲、正常结束、客户端主动断开与网络中断后的恢复。

通过源站代理和 CDN 分别跑测试,检查第一条事件等待时间、连续事件到达间隔、重连后的重复或丢失情况。确认目标路径的规则生效后,再验证普通页面与下载接口没有受到影响。若修改无效,回滚到备份配置,继续定位上一层,不要累积一组无法解释的超时与缓冲开关。

参考资料

[1] WHATWG HTML Living Standard:Server-sent events(事件格式、注释、重连及 Last-Event-ID)。

[2] Node.js HTTP 文档:response.flushHeaders() 与 response.write()。

[3] curl man page:--no-buffer、--resolve、--max-time。

[4] Nginx ngx_http_proxy_module:proxy_buffering、proxy_read_timeout、proxy_request_buffering。

文档查阅日期:2026 年 10 月 8 日。示例未在用户生产环境执行。