同一个接口、同一份数据,只换一种压缩算法,从服务器到浏览器的字节数就能差 3.8 倍。我拿一份 5 万条订单的 JSON(7.6 MB)在本机跑了一遍 gzip 和 brotli,结果是这样的:
| 方案 | 传输体积 | 占原始体积 | 压缩耗时 | 解压耗时 |
|---|---|---|---|---|
| 不压缩 | 7,795,444 B(7.4 MiB) | 100% | - | - |
| gzip level 6(窗口 32 KB) | 672.8 KB | 8.84% | 33 ms | 12 ms |
| brotli quality 5(窗口 4 MB) | 175.0 KB | 2.30% | 48 ms | 8 ms |
| brotli quality 11 | 169.3 KB | 2.22% | 10,885 ms | - |
一句话结论:动态接口用 brotli quality 4~5,体积只有 gzip 的 1/3.8,CPU 只多用十几毫秒;quality 11 用 227 倍的 CPU 时间,只换来 3% 的体积------那是静态资源离线预压缩才该用的档位。
这些数字不是估的,脚本和原始输出都留在文末,node bench/test_compression.js 跑出来 12/12 通过。
为什么 br 能比 gzip 小这么多:窗口差 128 倍
两种算法都属于 LZ77 + 熵编码这一族,差别最大的一处是滑动窗口大小:
- gzip = deflate 格式,最长匹配距离由窗口决定,最大 32 KB(16 位窗口,不可再调大);
- brotli 的窗口最大 16 MB,默认 4 MB,另外还内置约 120 KB 的静态字典(常见 HTML/JS 词块)。
窗口决定"能看多远"。同一份 7.6 MB 的 JSON,我只改 brotli 的窗口参数(lgwin),体积是这样的:
| 算法 / 窗口 | 压缩后 |
|---|---|
| gzip level 6(32 KB) | 672.8 KB |
| br q5,lgwin=16(64 KB) | 285.7 KB |
| br q5,lgwin=18(256 KB) | 256.9 KB |
| br q5,lgwin=20(1 MB) | 227.5 KB |
| br q5,lgwin=22(4 MB,默认) | 175.0 KB |
| br q5,lgwin=24(16 MB) | 175.0 KB |
把窗口从 64 KB 调到 4 MB,体积掉了 39%(285.7 KB → 175.0 KB)。窗口再往上(16 MB)没有变化,说明这份数据的重复距离在 4 MB 以内就吃完了。
还有一条旁证:把 gzip 的输出再 gzip 一次,居然还能再小 55%(672.8 KB → 302.1 KB)。这说明第一遍 gzip 受 32 KB 窗口限制,远距离的重复结构根本没看见,留了一堆冗余在流里。而 brotli 一次就压到了 175.0 KB,比"gzip 压两遍"还小 42%。
我这份数据是刻意"最有利于 brotli"的形状:5 万行订单字段完全一致(orderId/userId/amount/status/createdAt/items),每行约 156 字节,行与行之间的重复距离远超 32 KB,gzip 只能靠近处匹配,brotli 能把整段结构都对上。越是"大而规则"的 JSON 数组,br 的优势越大;小而且字段随机变化的数据,两者差距会小很多。
怎么复现(3 步)
bash
# 1) 取脚本(四个文件,源码见文末清单)
# bench/compression_bench.js 主实验:7.6MB JSON 三种算法对照
# bench/brotli_window.js 窗口实验:lgwin 16→24
# bench/compression_threshold.js 阈值实验:小响应体到底该不该压
# bench/test_compression.js 12 条断言
# 2) 跑主实验(约 15 秒,其中 br q11 占 11 秒)
node bench/compression_bench.js
# 3) 跑断言
node bench/test_compression.js # 期望输出:12/12 通过
实际输出(本机 Windows 11 / Node v24.19.0,单线程 zlib,未做多次取平均):
erlang
原始 JSON 体积:7612.7 KB(7795444 字节)
算法 压缩后 占原始 压缩耗时 解压耗时
gzip-6 672.8 KB 8.8% 33 ms 12 ms
br-5 175.0 KB 2.3% 48 ms 8 ms
br-11 169.3 KB 2.2% 10885 ms -
deflate-6 672.8 KB 8.8% 28 ms -
解压后与原文一致:gzip=true,br=true
deflate-6 和 gzip-6 的体积完全一样不是巧合:gzip 就是 deflate 外面套了 10 字节文件头 + 8 字节 CRC 尾部(本机实测差值 12 字节,见第 3 条断言),裸 deflate 适合放协议内部,gzip 才有跨实现的文件封装。
踩坑 1:小响应体越压越大
压缩不是免费的,它有固定的头尾开销。同一个算法压一个 56 字节的小接口:
| 场景 | 原始 | gzip 后 | br 后 |
|---|---|---|---|
| 小接口(56 B) | 56 B | 76 B(+35.7%) | 60 B(+7.1%) |
| 中等接口(473 B) | 473 B | 167 B(-64.7%) | 123 B(-74.0%) |
| 大接口(72,661 B) | 72,661 B | 10,145 B(-86.0%) | 4,531 B(-93.8%) |
56 字节的响应压完反而涨了 36%,因为 gzip 的头尾和块结构本身就要占二十几个字节。所以网关/框架里几乎都有"最小压缩体积"阈值,常见的默认值在 1 KB 左右。本机这份数据在 473 字节时已经明显划算,但我仍然建议按 1 KB 起压------阈值定低了,省下的是几十字节,付出的是一次额外的 CPU 往返。
踩坑 2:brotli 质量档位不是"越高越好"
quality 从 5 提到 11,体积只从 2.30% 降到 2.22%(7.6 MB 里少了 5.7 KB),压缩耗时却从 48 ms 涨到 10,885 ms------227 倍 。这条断言的判定条件是"q11 更小且耗时至少是 q5 的 10 倍",实际跑出来远远超过,只好用 assert.ok 卡一个宽松下限。
落到线上:
- 动态响应 (接口 JSON、SSR 页面):用 br quality 4
5,或 gzip level 56。省字节要在毫秒内完成,否则省下的流量还不够赔 CPU。 - 静态资源 (JS/CSS/字体):构建期用 br quality 11 预压缩成
.br文件,运行时只做"透传 + 协商",压缩耗时完全不进请求链路。
踩坑 3:同一个响应用 br,也要让缓存知道
Content-Encoding 是表示层 的东西,同一个 URL 对不同客户端会返回不同字节。只改算法不加 Vary: Accept-Encoding,中间的共享缓存会把"给支持 br 的客户端的那份"发给只认 gzip 的客户端,表现形式就是某些浏览器拿到一堆乱码------这不是压缩算法的问题,是缓存键少了维度。同理,源站和 CDN 不要各压一遍:先把压缩责任定在一层(通常是边缘),另一层只负责透传。
踩坑 4:已经压过的内容不要再压
把 gzip 自己输出的二进制再丢进 gzip,体积几乎不变(第 11 条断言)。所以对 JPEG/PNG/WebP、MP4、zip/tar.gz 这类已经是压缩格式的响应体再压,收益接近零,纯浪费 CPU。我的做法是按 Content-Type 做白名单:application/json、text/*、application/javascript、image/svg+xml 才进压缩通道。
结果与下一步
跑完这一轮,我把结论收敛成四条:
- 同一份 7.6 MB 的 JSON,br(q5) 175.0 KB vs gzip(6) 672.8 KB,差 3.84 倍,根因是窗口差 128 倍,不是实现好坏。
- 默认档位选 q5:48 ms 换掉 91.2% → 97.7% 的流量,比 q11 划算两个数量级。
- 1 KB 以下不压:56 字节的响应压完涨 36%,这是实测出来的反向收益。
Vary: Accept-Encoding和Content-Type白名单是压缩的一半工作量,算法只是另一半。
下一步我打算把自己的网关配置改一遍:静态产物在构建期出 .br 和 .gz 两份、运行时按 Accept-Encoding 协商,动态 JSON 走 br q5 加 1 KB 阈值,同时把压缩耗时打进监控(P99 超过 10 ms 就降档)。压缩比是省下来的钱,压缩耗时是花出去的 CPU,两边都要看数。
本地验证与证据
| 项 | 内容 |
|---|---|
| 运行环境 | Windows 11,Node v24.19.0(V8 内置 zlib) |
| 复现命令 | node bench/compression_bench.js、node bench/test_compression.js |
| 测试结果 | 12/12 通过 (evidence/test-output.txt,退出码 0) |
| 实验脚本 | bench/compression_bench.js(md5 3a7d13e76ed2fce30d82de7bf12ec200) bench/brotli_window.js(md5 7af7d27410b2fe58612962065b23cc44) bench/compression_threshold.js(md5 8db63b724b1ace3f3240b2d996eae0cd) bench/test_compression.js(md5 3c1879a91abfba3445dbbbeda2403c38) |
| 数据来源 | 脚本生成的 5 万条订单 JSON(字段结构固定、值按模运算变化),不是线上真实流量 |
| 已知限制 | 单次测量,含 JIT 冷启动影响;不同 CPU / Node 版本绝对耗时会有差异,体积比值的结论稳定 |
写这篇文章时我没有改动任何生产配置,全部结论来自本机单进程实测。如果你要拿这套数字去说服同事,建议在自己的机器上用同样的脚本再跑一遍------体积比值基本会重合,耗时可能会因为 CPU 不同差出一截。