一张科学插图要多少钱?AutoFigure API消耗与时间成本完整分析清单
2026/10/10 15:10:44
场景:Node.js 服务通过 SSE 给前端实时推日志,打开
compress: true后首包延迟飙到 1.2 s,Wireshark 一看——TCP 流里愣是等不到一个 FIN、也等不到一个 PSH。
结论:gzip 缓冲区把事件“憋”住了。本文记录踩坑→定位→优化的全过程,附可直接粘贴到 Koa 的中间件源码。
正文约 4 000 字,阅读时间 10 min,代码全部带 JSDoc,可直接复用。
上线第二天,客服反馈“日志大屏”经常 10 s 才刷出第一条消息。复现步骤极简:
compress: true(koa-compress 默认配置)。new EventSource('/api/log')。根因:gzip 流默认 8 k(或 16 k)才刷新一次,SSE 单条消息往往只有几百字节,于是被死死按在缓冲区里。
副作用:首包延迟↑、吞吐量↓、CPU 空转。
| 方案 | 首包延迟 | 峰值 QPS | CPU 占用 | 备注 |
|---|---|---|---|---|
| express/koa 原生压缩 | 1 200 ms | 5 800 | 110 % | 缓冲区阻塞 |
| 自定义分块压缩 | 90 ms | 9 400 | 95 % | flush 及时,内存可控 |
测试条件:4 核 8 G Docker,autocannon -c 100 -d 30s,消息大小 500 B,每秒 1 条。
zlib 提供Z_SYNC_FLUSH可以在不关闭流的前提下把当前块推出去,SSE 正好借用它实现“分块压缩”。
关键代码(TypeScript):
import { createGzip } from 'zlib'; import { Transform, TransformCallback } from 'stream'; /** * 将 gzip 流拆成“一块一条”模式,保证每条 SSE 消息及时刷新。 * 用法:res.write(data); gzipTransform.write(data); gzipTransform.flush(); */ export class SseGzipTransform extends Transform { private gzip = createGzip({ flush: constants.Z_SYNC_FLUSH }); constructor() { super(); this.gzip.on('data', chunk => this.push(chunk)); } _transform( chunk: any, encoding: BufferEncoding, callback: TransformCallback ): void { this.gzip.write(chunk, encoding, callback); } /** 手动刷新,确保压缩块立即输出 */ public flush(): void { this.gzip.flush(); } _destroy(error: Error | null, callback: TransformCallback): void { this.gzip.close(callback); } }import { Context, Next } from 'koa'; import { constants } from 'zlib'; /** * 只在 Accept-Encoding 包含 gzip 且响应类型为 text/event-stream 时启用 * @param threshold 最小字节数才压缩,以下直接透传 */ export function sseCompress({ threshold = 200 }: { threshold?: number } = {}) { return async (ctx: Context, next: Next) => { if (!ctx.acceptsEncodings('gzip')) return await next(); if (!ctx.type?.includes('text/event-stream')) return await next(); const gzip = new SseGzipTransform(); ctx.body = gzip; ctx.set('Content-Encoding', 'gzip'); ctx.set('Cache-Control', 'no-cache'); // 拦截 res.write,自动判断长度 const rawWrite = ctx.res.write.bind(ctx.res); ctx.res.write = function (chunk: any, encoding?: any) { if (chunk?.length >= threshold) { gzip.write(chunk, encoding); gzip.flush(); // 关键:及时推送 } else { rawWrite(chunk, encoding); } return true; }; await next(); // 确保流正确关闭,防止内存泄漏 ctx.res.on('close', () => gzip.destroy()); }; }调优依据
threshold=200:小于 200 B 的 heartbeat 包压缩收益不足,还浪费 CPU。Z_SYNC_FLUSH而非Z_FULL_FLUSH:后者压缩率略好但多 15 % CPU,得不偿失。res.close事件:客户端断开即销毁流,避免积压。# 优化前 autocannon -c 100 -d 30 -T 30 http://localhost:8000/api/log # 优化后 autocannon -c 100 -d 30 -T 30 http://localhost:8000/api/log| 指标 | 原生压缩 | 分块压缩 | 提升 |
|---|---|---|---|
| 平均延迟 | 1 180 ms | 92 ms | 92 %↓ |
| p99 延迟 | 1 550 ms | 140 ms | 91 %↓ |
| QPS | 5 800 | 9 400 | 62 %↑ |
| CPU | 110 % | 95 % | 14 %↓ |
| gzip level | 1 | 3 | 6(默认) | 9 |
|---|---|---|---|---|
| CPU 占用 | 78 % | 88 % | 95 % | 125 % |
| 压缩率 | 2.1× | 2.4× | 2.7× | 2.8× |
结论:SSE 场景下 3 级是甜点,压缩率与 6 级相差 10 %,CPU 降 7 %。
proxy_buffering off;否则 Nginx 也会等 4 k/8 k 才吐。gzip on;,一定加gzip_min_length 0;并排除text/event-stream,避免双重压缩。gunzip → regzip的损耗。Transfer-Encoding: chunked+ gzip,IE11 需 TLS 1.2。eventSource = null的 bug,需心跳包兜底。fetch + ND-JSON方案。res.write第一个 chunk 到flush()完成时间。(原始字节 - 压缩后字节) / 原始字节。gzip.on('error')与req.aborted,上报 Sentry。process.cpuUsage()每 10 s 自采样,写入 Prometheus。compress: true时,务必关注 zlib 缓冲区阻塞;flush()可以把压缩块及时推出去,首包延迟降 90 %;思考题:当链路全面切到 QUIC/HTTP3 时,UDP 自带流多路复用、队头阻塞更小,我们还需要“分块压缩”这种手工活吗?欢迎在评论区分享你的看法。
如果本文帮到了你,记得点个赞;踩坑日记持续更新,下一篇聊聊“WebSocket 0-RTT 的代价”。