JSON 工具箱

JSON 压缩的顺序别搞反:minify、gzip、brotli 实测谁在真省字节

结论:去掉空白能省三到五成原始字节,但服务端开了 gzip 之后,这份收益会被压缩器压掉九成。本文在四份真实样本上实跑 gzip 三档等级与 brotli,给出「先做哪一步」的定量结论,并标出那些该跳过的场景。

进阶约 7 分钟发布于 2026-10-31更新于 2026-10-31
JSON 压缩:minify、gzip、brotli 三种手段的实测体积对比

结论:先分清传输压缩与结构精简——链路上还没开 gzip 或 brotli 时,第一步是配好服务端压缩(gzip -6 一步就拿到 94.5%),minify 只值 0.9 个点;只有当字节账记在本地(请求体、Lambda 6 MB、Firestore 1 MiB、磁盘日志)时,minify 的 46.3% 才全额到账。

minify 工具页面上那个「节省 35%」的数字是真的,但它量的是原始字节;你的接口跑在 gzip 或 brotli 之上,这两个手段吃掉的是同一批冗余,多数页面的乘法都算错了。

一、先把「压缩」这个词拆成两件事

大家说的 JSON 压缩其实混了两种完全不同的操作,混着谈一定会得出错误结论。

minify(去空白) gzip / brotli(传输压缩)
本质 文档的变换 传输的变换
产物 仍是合法可读的 JSON 文本 二进制流,必须解码后才能用
收益留在哪 磁盘、内存、日志、长度限制 只在链路上和缓存里
解码后 字节变少 字节回到原状
谁负责 构建脚本、序列化参数 Nginx / CDN / 服务端框架

关键差别在最后两行。平台限制量的是解码后的字节:AWS Lambda 的 6 MB payload 限额、Firestore 的 1 MiB 文档上限,开了 gzip 一分钱都救不回来,因为它们看的是解码结果。

二、minify 只删一种东西:结构性空白

RFC 8259 允许空白(space、\t、\n、\r)出现在任意结构字符前后。除此之外,JSON 文档里没有一样东西是可选的:键名是数据,数字保留全部位数,字符串内容一字不改。

所以 minify 的核心就是这一行:

const minified = JSON.stringify(JSON.parse(pretty));
// RFC 8259 只在结构字符前后允许空白,字符串内部的空格是数据
JSON.stringify(JSON.parse('{"note": "two spaces"}'));
// {"note":"two spaces"} —— 字符串里的两个空格是数据,原样留下

值得注意的是三种常被算进「压缩」、实际属于文档修改的操作:丢掉值为 null 的键、对浮点数取整、重排键顺序。这些改变了文档本身,不是格式变换,任何声称「深度压缩」的工具做这些都必须先确认你的消费方能接受。

因为只删空白,minify 的收益几乎完全取决于缩进占原文件的比例。这一点会在下面的实测表里反复出现。

三、实测:四份文档、三档 gzip 等级、brotli 一并比

环境:Node v22.13.1,zlib.gzipSync 的 level 1 / 6 / 9,brotliCompressSync 默认质量(11)。四份样本刻意覆盖了常见形态的差异:50 笔嵌套订单(深结构、重复键名)、6 层深层配置(长键名、短值)、200 行长日志数组(长字符串、几乎无缩进)、以及本站真实的 media-manifest.json(混合形态、含 URL 字符串)。

第一步,先看原始字节。

样本 pretty(2 空格) pretty(4 空格) minify 后 minify 省下
50 笔嵌套订单 29,551 B 40,761 B 15,881 B 46.3%
6 层深层配置 25,450 B 38,012 B 11,679 B 54.1%
200 行日志数组 14,582 B 14,982 B 13,981 B 4.1%
media-manifest.json 25,149 B 28,114 B 21,222 B 15.6%

这张表是工具页上「节省 35%」这类数字的来源,它没问题。问题出在第二步。

第二步,把它们各自压一遍,再看 minify 还能额外省多少。

样本 gzip -1 之上 gzip -6 之上 gzip -9 之上 brotli 之上
50 笔嵌套订单 21.0% 17.0% 8.9% 10.6%
6 层深层配置 74.2% 59.1% 58.0% 56.4%
200 行日志数组 -0.7% -0.4% 0.6% -0.3%
media-manifest.json 1.5% 1.0% 1.0% 1.6%

同一份嵌套订单,原始字节省了 46.3%,跑到 gzip -6 之上只剩 17.0%;而日志数组干脆是负数——minify 之后再压,比直接压原始美化版还大一点点。负数不是 bug,是完全合理的结果,下一节解释。

第三步,把「相对原始美化版的总缩减」列出来,这才是链路上的真实收益。

样本 只做 gzip -6 minify + gzip -6 minify + brotli
50 笔嵌套订单 94.5% 95.4% 96.9%
6 层深层配置 96.0% 98.4% 99.0%
200 行日志数组 89.9% 89.9% 95.0%
media-manifest.json 83.5% 83.6% 86.1%

四、gzip 为什么把 minify 的功劳吃掉了

gzip 用的是 LZ77 加霍夫曼编码。LZ77 的核心动作是「向后看 32 KB 窗口,找到重复片段,替换成(距离,长度)指针」。缩进恰好是这种片段里最好找的一种:每一层嵌套都产生一串完全相同的空格,相邻几十行又是同样的组合。霍夫曼编码接着给高频符号分配短码,\n 加 8 个空格这种组合在一整个文件里可能重复上千次,最后用两三个比特就表达了。

所以 minify 删掉的那些字节,本来就是压缩器几乎免费就能消掉的字节。你先删一遍,等于替压缩器做了一次它已经很擅长的工作——收益被摊薄到 17%,这就是前面那张表的成因。

日志数组的负数更值得说清。这类样本几乎全是长字符串(日志行、URL、base64),重复片段少,熵高,压缩器本来就没多少冗余可删;而美化版里的换行恰好给匹配器提供了稳定的切分点。把它删掉之后,LZ77 失去稳定的匹配边界,匹配率反而略降:这份日志样本在 gzip -1 下直接压美化版是 1,508 字节,先 minify 再压是 1,518 字节,多了 10 个字节。

把杠杆按量级排一下,顺序就很清楚了:

手段 相对原始美化版省下(嵌套订单样本) 代价
minify 46.3%(仅原始字节) 可读性归零,需构建步骤
gzip -6 94.5% 服务端少量 CPU,基本为零配置
minify + gzip -6 95.4% 上面两项相加
minify + brotli 96.9% 需 HTTPS 与 brotli 模块,静态资源还需预压

从 0 到 94.5% 这一跳是 gzip 干的,从 94.5% 到 95.4% 这 0.9 个点是 minify 干的。资源该投在哪一步,这张表已经说完了。

五、压缩等级才是隐藏变量

把所有讨论都建立在「gzip 等级 = 某固定值」上是站不住的,等级直接改变 minify 的边际收益:

传输压缩设置 minify 还能额外省下(嵌套订单样本)
gzip -1(Nginx gzip_comp_level 默认值) 21.0%
gzip -6(多数语言的标准库默认) 17.0%
gzip -9 8.9%
brotli(默认质量) 10.6%

传输压缩越弱,minify 越有用。这条规律给出了一条很实用的判断:如果 Nginx 是开箱即用、gzip_comp_level 还停在默认的 1,minify 能拿到 21%;如果你已经调到 9 或换成了 brotli,minify 只剩 9% 到 11%,基本可以不做。

动手之前先量一下自己的链路到底走到哪一档,比争论「该不该 minify」有用:

# 同一份响应,分别用三种 Accept-Encoding 取回,比较实际落地的字节数
for enc in identity gzip br; do
  size=$(curl -s -o /dev/null -w '%{size_download}' \
    -H "Accept-Encoding: $enc" https://api.example.com/v1/orders)
  echo "$enc -> $size bytes"
done

三个数字出来之后,「有没有开压缩」「开了哪一档」「还剩多少空间给 minify」一次性全有了。如果 identity 和 gzip 的数字几乎相等,那说明压缩根本没生效,这时该修的是 Nginx 而不是 JSON:

gzip on;
gzip_comp_level 6;
gzip_min_length 256;      # 小于此值不压,避免小响应越压越大
gzip_types application/json application/javascript text/css;

六、什么时候 minify 真的有用

判断标准只有一条:省下的字节会不会被记录到、会不会碰到某个按原始字节计量的上限。 只要答案是会,minify 的 46% 就一分不少地到账;只要答案是「它只在链路上」,那 17% 才是它的真实价值。

第一种,HTTP 请求体。绝大多数服务端不会解压请求体,gzip 也基本只在响应方向配置。也就是说你 POST 出去的这段 JSON,传输层的保护通常是不存在的,minify 省下的字节是原价全额。同理还有 Webhook 回调、MQTT 上报、没协商 permessage-deflate 的 WebSocket 帧。

第二种,存在字节硬上限的通道,而且这些上限量的都是解码后的字节:

通道 上限 gzip 能否帮上忙
AWS Lambda 同步 payload 6 MB 不能,按解码后计
Firestore 单文档 1 MiB 不能
Kafka 单条消息(默认) 1 MB 不能,且 broker 侧压缩在另一层
localStorage 配额 约 5 MB(按字符计) 不能
GET 请求 URL 服务端各异,常见 8 KB 不能

这一类里 minify 不是优化,是能不能成功提交的差别。把 JSON 塞进 URL 查询参数时尤其明显:URL 既不会被压缩,又有长度限制,这时候必须先 minify 再考虑要不要 base64。

第三种,写到磁盘或日志里的 JSON。日志和索引里存的是原文,没有传输层。不过这里有个容易忽略的前置条件:只有当你确实在写美化版时,minify 才有收益。前面那个 200 行日志样本只省了 4.1%,原因就是每行内容本身已经很紧凑,缩进占比极低;反过来,如果你的 logger 写了 JSON.stringify(obj, null, 2),把第三个参数删掉就是纯赚。

最后提醒一句:如果原始字节真的紧张,键名通常比缩进更值得动。在 media-manifest.json 上,把 14 个独特键名换成 k0 到 k13,minify 版会从 21,222 B 掉到 18,887 B,又省 11.0%;压缩之后再看,gzip -6 下只剩 2.4%,brotli 下只剩 1.3%。这条结论和前面所有结果是同一个道理——缩进和键名都是重复的结构信息,压缩器抢的是同一份红利。

七、四个「做完反而更糟」的场景

纳进版本控制的文件不要压。 把一处 "qty": 2 改成 3,美化文件的 git diff 是一行清晰可见的改动;压缩版是整行重排,几百个字段时人眼根本定位不到。这条与本站《JSON 文件的 Git 合并冲突怎么解》是同一个道理——diff 工具按行工作,单行 JSON 会让每一处变更都变成「整行不同」。正确做法是仓库里存美化版,只在构建或写入存储时才压缩。

小响应体别开压缩。 一个只含 {"ok": true} 的响应,写成带缩进的美化版是 16 字节,过一遍 gzip -6 变成 36 字节,比原来还大 20 字节——gzip 的字典和头部本身要占地方。这不是理论担忧,Nginx 的 gzip_min_length 就是为此存在的,默认 20 字节太低,生产环境通常调到 256 以上。

别指望它让解析变快。 把同一份订单数据放大到 4000 笔,美化版 2,374,378 字节、压缩版 1,282,358 字节,体积少了 46%,但 JSON.parse 的中位耗时只从 5.7ms 降到 5.0ms,快了约 12%。体积减半不等于时间减半,因为解析器的成本大头在切分字符串和转换数字,跳过空白只是顺手做的事。真正影响解析耗时的是键数量和字符串内容,这部分本站《大 JSON 文件的解析、渲染与流式处理》里有更完整的拆解。

已经在跑 brotli 或 gzip -9 的服务不必再补。 收益降到 9% 到 11%,而代价是可读性永久归零、排障时看不到结构。这时更该做的事是检查 gzip_types 有没有覆盖 application/json——很多站点配了 gzip 却只压了 text/html,JSON 一直是裸奔的,这种情况下第一步该做的根本不是 minify。

八、按场景查表:先做哪一步

你的现状 第一步该做的 minify 排在第几位
完全没开传输压缩 配 Nginx gzip_types 或开 CDN 自动压缩 先别做,收益会被摊掉九成
开了 gzip -1(Nginx 默认) 把 gzip_comp_level 调到 6 第二位,能拿 21%
已经是 brotli 或 gzip -9 检查长键名、做分页或字段裁剪 不必做,只剩 9%~11%
撞上 Lambda 6 MB、Firestore 1 MiB 这类上限 直接 minify,46% 全额有效 第一位
POST 请求体、Webhook、MQTT 上报 直接 minify,这里的传输层通常不生效 第一位
配置文件要进 Git 保持美化版入库 只在构建产物里压
日志、埋点、离线落盘 关掉 logger 的 null, 2 参数 等价于第一步

一句话判断标准:去看你要省的那个字节账本,是记账在本地(磁盘、配额、平台上限、请求体)还是只发生在链路上。 前者先 minify,后者先开 brotli 或 gzip。

具体到操作,顺序建议是这样:先用前面那条 curl 循环确认链路压缩已经生效,再用本站的 JSON 压缩工具把样本过一遍,看它的「节省比例」读数落在哪一段——如果读数是 40% 以上而 curl 显示 identity 和 gzip 的数字差不多,说明你的瓶颈在服务端配置而不在 JSON;如果两个数字已经很接近,说明这份 JSON 已经没什么可省的,该动的是字段裁剪和分页,这部分在《JSON API 数据契约设计要点》里展开。

minify 之后的 JSON 还算合法 JSON 吗?

算。minify 只删 RFC 8259 允许出现在结构字符前后的空白,字符串内部的内容一律不动,JSON.parse 出来的对象和原文档完全一致,区别只有肉眼可读性。

brotli 和 gzip 该选哪个?

同一份样本 minify 之后,brotli 总节省 96.9%,gzip -6 是 95.4%,差距不大。动态接口用 gzip -6 更省 CPU;brotli 要求 HTTPS,静态资源建议预先压缩成 .br 文件。

为什么我压缩了 JSON,接口却没变快?

先用 curl -sI 看响应头里有没有 Content-Encoding: gzip。很多 Nginx 的 gzip_types 默认只有 text/html,JSON 压根没被覆盖,这种情况下 minify 省下的 46% 留到链路上的还不到两成。

配置文件要不要提交压缩版?

不要提交。diff 工具按行比对,单行 JSON 会让每次改动都显示成「整行不同」,合并冲突的排查成本远大于省下的字节。仓库里留美化版,只在构建产物或写入存储时压缩。

多大的响应体才值得开 gzip?

经验阈值是 256 字节到 1 KB。像 {"ok": true} 这样加缩进只有 16 字节的响应,过一遍 gzip -6 会变成 36 字节,字典和头部的开销盖过了收益,这也是 Nginx 里 gzip_min_length 的作用。