JSON 壓縮的順序別搞反:minify、gzip、brotli 實測誰在真省位元組
結論:去掉空白能省三到五成原始位元組,但伺服器端開了 gzip 之後,這份收益會被壓縮器吃掉九成。本文在四份真實樣本上實跑 gzip 三檔等級與 brotli,給出「先做哪一步」的定量結論,並標出那些該跳過的情境。

結論:先分清傳輸壓縮與結構精簡——鏈路上還沒開 gzip 或 brotli 時,第一步是配好伺服器端壓縮(gzip -6 一步就拿到 94.5%),minify 只值 0.9 個點;只有當位元組帳記在本機(請求體、Lambda 6 MB、Firestore 1 MiB、磁碟日誌)時,minify 的 46.3% 才全額到帳。
minify 工具頁上那個「節省 35%」的數字是真的,但它量的是原始位元組;你的 API 跑在 gzip 或 brotli 之上,這兩個手段吃掉的是同一批冗餘,多數頁面的乘法都算錯了。
一、「壓縮」這個詞其實是兩件事
大家說的 JSON 壓縮混了兩種完全不同的操作,混著談一定會得出錯誤結論。
| minify(去空白) | gzip / brotli(傳輸壓縮) | |
|---|---|---|
| 本質 | 文件的變換 | 傳輸的變換 |
| 產物 | 仍是合法可讀的 JSON 文字 | 二進位流,必須解碼後才能用 |
| 收益留在哪 | 磁碟、記憶體、日誌、長度限制 | 只在鏈路上和快取裡 |
| 解碼後 | 位元組變少 | 位元組回到原狀 |
| 誰負責 | 建置腳本、序列化參數 | Nginx / CDN / 伺服器端框架 |
關鍵差別在最後兩行。平台限制量的是解碼後的位元組:AWS Lambda 的 6 MB 酬載上限、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 同步酬載 | 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%,差距不大。動態 API 用 gzip -6 更省 CPU;brotli 要求 HTTPS,靜態資源建議預先壓縮成 .br 檔案。
為什麼我壓縮了 JSON,API 卻沒變快?
先用 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 的作用。