把一台旧笔记本改成下载机之后,顺手写了个网页面板看队列。第一版用的最土:前端 setInterval 两秒一次,去问后端要任务列表。开一个标签页还行,开到第四个就开始不对劲,进度条一顿一顿地跳,风扇也起来了。后来把三种推送方式在同一台机器上各跑了一遍,才把这件事想明白。
先说结论,具体数字在后面
- 只在局域网用、任务不多、面板就自己看:轮询最省事,出问题最少。
- 手机上也要看、同时开好几个页面、还想往多个客户端推:SSE。
- 要双向(点一下暂停、拖进度条、把日志回传):WebSocket。
难的不是选,是选完之后那几个不报错、只「没反应」的坑。
三种方案在同一台机器上的实测数字
环境:Node 22.22.2,ws 8.21.0,Win11,全部本机回环,服务端每 100ms 产生一条任务事件,单条载荷约 100 字节。
| 项目 | SSE | WebSocket | 长轮询 |
|---|---|---|---|
| 单条事件端到端延迟(中位) | 0ms | 0ms | 1ms |
| 端到端 p95 | 1ms | 1ms | 2ms |
| 推送 100 条消息的服务端 CPU | 172ms | 140ms | 312ms |
| 一次推 2000 条的耗时 | 35.6ms | 361.2ms | 需要 2000 次请求 |
| 折算吞吐 | 约 5.6 万条/秒 | 约 5500 条/秒 | — |
| 500 并发连接建链 | 761ms | 804ms | — |
| 500 连接的内存增量 | 7.0MB | 5.7MB | — |
延迟一栏三者几乎打平,因为都在本机,谁也不比谁快。差别在代价:轮询推 100 条消息就是 100 次 HTTP 请求,服务端 CPU 差不多是 SSE 的两倍。局域网里这点差距无所谓,一旦走公网加 HTTPS,每次请求都要重新握手,账就不是这么算了。
一次推 2000 条,SSE 快了十倍
这个结果我一开始也不信。同一份数据、同一个进程,SSE 那边只是把 JSON 拼进一个字符串然后 res.write,2000 条 35.6ms 就出去了;走 ws 库逐条 send,花了 361.2ms,慢了整整十倍。原因是 ws 每条消息都要单独成帧、单独过一次 JS 回调,SSE 是一次写一大块文本。
所以别把「WebSocket 更现代」当成「WebSocket 更快」。高频、单向、小包多的场景(进度、日志、行情),SSE 的写法天生占便宜。反过来,需要客户端往服务端说话的时候,SSE 只能另开一条 POST,就没有意义了。
它还有个容易忽略的短板:SSE 只能传文本。想把封面缩略图、拖动预览帧也推过去,就得 base64,体积按 4/3 涨三分之一,200KB 的图变成 267KB 的字符串,还得让浏览器再解一遍。这种时候还是 WebSocket 传二进制划算。
两个只会「没反应」的坑
第一个是响应头不出来。我做了两组对照:只发响应头不发数据,客户端 3.3ms 就拿到 200;把 flushHeaders() 去掉、延迟 3 秒才写第一块数据,客户端拿到响应头的时间变成 3015.3ms。现象是 Network 面板一直 pending,EventSource 的 onopen 不触发,控制台一个字都不报。因为 Node 会把响应头和第一块 body 一起发,不 flush,头就一直排在那儿。
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
要么显式 flush,要么连上就先写一行注释帧 : ping,作用一样。
第二个是压缩。想着省流量,手动加了 Content-Encoding: gzip,把响应 pipe 给 zlib.createGzip(),结果客户端 5 秒内解压出 0 字节,一条事件都没收到。gzip 流默认要攒够一块才吐,小包写进去就躺在缓冲区。同一个服务端,每条写完补一句 gz.flush(zlib.constants.Z_SYNC_FLUSH),首条 6.8ms 就到,5 条约 429.5ms 收齐,解压出 364 字节。差的就是这一行。
结论:这条流别压缩。响应头写 Cache-Control: no-transform,中间有 Nginx 就把 proxy_buffering off、gzip off 加上。代理层那部分我在本机复现不了,但缓冲的原理是同一个。
排查这类问题别一上来就开浏览器。先用 curl -N -H "Accept: text/event-stream" http://127.0.0.1:18081/sse 直连,-N 关掉 curl 自己的缓冲,屏幕上应该一行一行往下滚;如果 curl 都不吐,那问题在服务端或中间层,跟前端没关系;curl 滚得很顺而浏览器不动,再去看 Service Worker、扩展和 6 连接上限。
说好 100ms 一条,实际跑成了 108ms
服务端 setInterval(..., 100),客户端记下每条到达时间,100 条跑下来间隔中位 108.3ms,p95 110.9ms,最长 114.7ms。WebSocket 侧是 108.6 / 113.4 / 119,一样偏。
Windows 的定时器粒度约 15.6ms,加上回环排队,稳定偏 8% 左右。影响在于:进度条别按「每 100ms 涨一格」去画,按收到的数据渲染;剩余时间、平均速度这类派生数字要用后端给的原始值算,前端自己乘增量,误差会越滚越大。
断线、超时,还有那 500 个连接
我让服务端主动掐掉全部 117 条连接,客户端按 3 秒固定间隔重连,实测从断开到收到下一条消息空了 3124.4ms。三秒在下载面板上就是「进度条不动了」。改善办法是把首次重连压到 300~500ms、之后翻倍退避、上限 10 秒。SSE 的 EventSource 自带重连,默认约 3 秒,服务端在第一帧里写 retry: 500 就能改掉;WebSocket 没有这个待遇,重连得自己写,而且要在 onclose 里判 wasClean,不然服务端重启那一下会疯狂重连。
超时是另一个坑,而且不报错。Nginx 的 proxy_read_timeout 默认 60 秒,连接空闲 60 秒就被掐,Cloudflare 那边空闲 100 秒也会断。所以 SSE 得定时发心跳——每 15~30 秒写一行 : ping 就够,业务数据一条没有也不影响。
500 条 SSE 连接 RSS 涨 7.0MB,建链 761ms;500 条 WebSocket 涨 5.7MB,804ms,两边零失败。断开三秒后再测,RSS 只回落不到 1MB——这时别急着喊内存泄漏,GC 还没跑,判断泄漏看连接数和堆的趋势。
最后两个绕不过去的硬限制。HTTP/1.1 下浏览器对同一个域名最多开 6 个并发连接,第 7 个 EventSource 会一直挂着,表现就是「新开的标签页永远没数据」;HTTP/2 下这条限制不再适用。另外 EventSource 不能带自定义请求头,鉴权只能靠 Cookie 或者在 query 里塞 token,而 query 会原样进访问日志,这一点得自己权衡。
你们的面板是用什么方式推的?有没有遇到进度条和真实下载速度对不上、越跑越偏的情况?