杀死流的东西在中间
一句话总结
流式传输出问题的地方,通常不在应用本身,而在夹在中间的东西——代理缓冲区、读取超时,以及浏览器的并发连接上限。
为什么需要它
在本地,令牌是一个字一个字流出来的,部署之后,回答却要等全部结束才一下子出来。应用代码一行都没有不同。看日志,也写着服务器是按时发送的。
这是因为中间有代理。nginx 的 proxy_buffering 在文档中的默认值是 on,开启时,会把响应收集到缓冲区之后再发出去。流式传输的全部意义就是“少量而频繁”,而缓冲的目的是“攒起来一次发出”,所以正好相反。
第二种经常碰到的是超时。同一份文档中,proxy_read_timeout 的默认值是 60s,说明也很明确——如果被代理的服务器在这段时间内什么都没有发送,就会关闭连接。 在用户不提问的夜间时段,通知流每隔 1 分钟就会断开,原因就在这里。
工作原理
关闭缓冲的两个位置
可以在配置文件中用 proxy_buffering off 关闭,但这样那条路径上的所有响应都会失去缓冲。同一份文档还写了一种范围更窄的办法——用响应头来关闭。
X-Accel-Buffering: no
用这个响应头下发 yes 或 no,就只会对这一个响应开启或关闭缓冲。只在流式传输的端点加上这个响应头,其余路径就继续保留缓冲的好处。这种设计让应用在最了解自身情况的地方做决定,所以在没有权限修改配置文件时也能使用。
需要注意的是,这只是对 nginx 说的话。如果前面还有 CDN 或其他代理,就必须对它们另外再说一遍。如果只解开了一层就说“修好了”,在其他层仍然会被堵住。
心跳不是礼节,而是必需
避免读取超时的办法,是消除安静的区间。规范规定的注释行正好适合这个位置——以冒号开头的行会被忽略,所以对客户端没有任何影响,只有字节在流动。
: keep-alive
发送周期必须比代理能容忍的时间短得多。在默认值是 60 秒的地方使用 55 秒的周期,一次抖动就会断开。在实际工作中,会使用它的一半以下。
心跳还有第二个用处。它能让客户端测出“从什么时候开始什么都没有到达”。在事件本来就很少的流中,无法区分安静是正常还是已经死了,而有了心跳,这个判断就能成立。
浏览器的并发连接上限
在 HTTP/1.1 中,浏览器会限制对一个源(origin)打开的连接数。MDN 写道:“曾经在 2 到 3 之间的默认值,现在通常是 6 个”(HTTP/1.x 连接管理)。SSE 连接是永不结束的响应,所以会一直占着那个位置。开六个标签页,六个位置就全满了,第七个标签页自不必说,连发往该源的普通 API 请求也要排队。 界面看起来像卡住了,服务器日志里请求却连到达都没到达,所以原因尤其难找。
解决办法有两个。一个是升级到 HTTP/2——在一个 TCP 连接上对多个流进行多路复用(RFC 9113),问题就变成了一个连接的问题。另一个是不要每个标签页都建立连接。由共享 worker(SharedWorker)持有一个连接,再通过 BroadcastChannel 分发给同源的其他标签页。
在现场相遇的样子
“在本地正常,只有在预发布环境里才会一下子出来”这样的反馈,几乎总是缓冲。确认的办法不是看应用日志,而是看到第一个字节为止所花的时间。把流式路径和一次性返回的路径的首字节时刻并排测一下,立刻就能分出来——总时间相同,只有首字节不同,就说明中间在攒着。
只在夜里断开的通知流,要么是没有心跳,要么是周期离超时太近。看起来很难复现,但“只在安静的时段发生”这个事实本身,就已经是“因为什么都没发送”的线索。
并发连接上限在 QA 中几乎发现不了,因为测试是用一个标签页做的。反馈总是以“开着多个标签页就会变慢”的形式进来,而一旦把这句话当成性能问题来读,几天的时间就消失了。
下一项测验要确认什么
通过六道题,确认用响应头关闭缓冲的位置、心跳周期应以什么为基准来设定,以及并发连接上限会以什么症状表现出来。