Phần trước giảm số request tới origin. Phần này giảm kích thước của những request còn lại. Nén HTTP là tính năng bật bằng một dòng cấu hình nginx, và vì thế ít ai đo nó — bật lên, thấy byte giảm, xong. Nhưng nén là một phép đổi CPU lấy băng thông, và tỉ giá của phép đổi đó thay đổi theo kích thước thân, loại dữ liệu, và băng thông thật giữa hai đầu. Có những trường hợp nén làm chậm hơn, và bài này đo để tìm ra chúng.

Thương lượng bằng header

Ảnh chụp đoạn mã nền tối nén response thương lượng bằng header hai header đi kèm với Go 1.23 compress/gzip và andybalholm/brotli v1.1.0 JSON sản phẩm từ PostgreSQL, server nén theo Accept-Encoding của client gồm mux.HandleFunc /products đặt Content-Type application/json, đặt Vary Accept-Encoding vì cache dùng chung phải tách bản nén và không nén, ae lấy từ header Accept-Encoding, switch nếu chứa br thì đặt Content-Encoding br và brotli.NewWriterLevel w 4 là mức 4 cho 9,5 phần trăm kích thước 159 µs rồi Write và Close, nếu chứa gzip thì đặt Content-Encoding gzip và gzip.NewWriterLevel w 6 là mức 6 cho 10,6 phần trăm 125 µs mặc định của nginx cũng là 6, default thì Write thô vì client không hỗ trợ, cách đo nén vào buffer rồi giải ra lấy trung bình gồm t bằng time.Now rồi gzip.NewWriterLevel buf level Write Close rồi tNen cộng time.Since t là CPU phía server, rồi gzip.NewReader và io.Copy io.Discard rồi tGiai cộng time.Since là CPU phía client, ba điều đo được mà tài liệu ít nói gồm thân dưới khoảng 100 byte gzip làm phình vì header gzip 18 đến 20 byte cộng bảng Huffman, dữ liệu đã nén sẵn như ảnh base64 trong JSON gzip chỉ còn 75 phần trăm tốn CPU gấp 17 lần thân JSON cùng cỡ, brotli-11 mua thêm 2 điểm phần trăm bằng CPU gấp 135 lần br-4 chỉ đáng cho tài nguyên tĩnh nén một lần

Hình 1: Server chọn thuật toán theo Accept-Encoding, ghi Content-Encoding và Vary. Phần đo tách riêng CPU nén (server) và CPU giải (client).

Giao thức đơn giản: client khai Accept-Encoding: br, gzip, server chọn một trong số đó, nén thân và ghi Content-Encoding. Hai header đi kèm mà thiếu là hỏng ở chỗ khác:

  • Vary: Accept-Encoding — đúng bài học của phần 4: cùng URL giờ có hai thân (nén và không nén). Cache dùng chung không có Vary sẽ trả thân gzip cho client không hỗ trợ gzip, và client đó nhận một đống byte nhị phân thay vì JSON.
  • Không Content-Length của bản thô. Nén theo luồng không biết trước kích thước; server phải dùng chunked encoding hoặc nén xong rồi mới ghi Content-Length. Ghi Content-Length của bản thô lên thân nén là client chờ byte không bao giờ tới.

Đo thật: kích thước và CPU

JSON sản phẩm lấy từ PostgreSQL, năm kích thước từ 1 dòng (308 byte) tới 7.000 dòng (2,09 MB). Mỗi ô là trung bình của nhiều lần nén rồi giải nén trong bộ nhớ:

Ảnh chụp output thật nền tối gzip-6 JSON 21 KB còn 2,2 KB trong 125 µs với Go 1.23 compress/gzip brotli v1.1.0 JSON sản phẩm từ PostgreSQL 16 trung bình nhiều lần, mục 1 theo kích thước thân gồm sau nén CPU nén server CPU giải client cho thấy 1 dòng 308 B thành gzip-1 111 B 94 µs gzip-6 110 B 77 µs giải 5 µs br-4 81 B 54 µs giải 9 µs, 7 dòng 2.1 KB thành gzip-1 318 B 103 µs gzip-6 318 B 82 µs 7 µs br-4 268 B 55 µs 17 µs, 70 dòng 21.2 KB thành gzip-1 2.3 KB 105 µs gzip-6 2.2 KB 129 µs 25 µs br-4 2.0 KB 154 µs 47 µs, 700 dòng 213.0 KB thành gzip-1 22.9 KB 432 µs gzip-6 21.4 KB 1.07 ms 231 µs br-4 19.5 KB 1.10 ms 345 µs, 7000 dòng 2.09 MB thành gzip-1 228.4 KB 3.98 ms gzip-6 212.2 KB 11.30 ms 2.34 ms br-4 189.9 KB 9.14 ms 3.57 ms, mục 2 mức nén trên thân 21,2 KB thêm CPU mua được bao nhiêu byte cho thấy gzip-1 2.3 KB 10.9 phần trăm 106 µs giải 40 µs, gzip-3 2.3 KB 10.7 phần trăm 100 µs 31 µs, gzip-6 2.2 KB 10.6 phần trăm 125 µs 26 µs, gzip-9 2.2 KB 10.6 phần trăm 130 µs 24 µs, br-1 2.3 KB 10.8 phần trăm 71 µs 47 µs, br-4 2.0 KB 9.5 phần trăm 159 µs 40 µs, br-6 2.0 KB 9.6 phần trăm 302 µs 57 µs, br-9 2.0 KB 9.6 phần trăm 700 µs 41 µs, br-11 1.8 KB 8.5 phần trăm 16.85 ms 47 µs, mục 3 khi nén làm phình cho thấy ok true gốc 11 B gzip-6 35 B cộng 24 byte, JSON một sản phẩm gốc 56 B gzip-6 81 B cộng 25 byte, JSON chứa ảnh 150 KB base64 gốc 195.3 KB gzip-6 147.2 KB tức 75.3 phần trăm nén mất 2.12 ms gấp 17 lần, mục 4 qua HTTP thật cho thấy Accept-Encoding rỗng thì Content-Encoding rỗng thân qua dây 21.2 KB, Accept-Encoding gzip thì Content-Encoding gzip thân qua dây 2.2 KB Vary Accept-Encoding, Accept-Encoding br gzip thì Content-Encoding br thân qua dây 2.0 KB Vary Accept-Encoding, nói thẳng với JSON mức nén gần như không quan trọng gzip-1 và gzip-9 chênh 0,3 điểm phần trăm thứ quan trọng là có nén hay không 10 lần và đừng nén thứ dưới 100 byte hay thứ đã nén sẵn

Hình 2: Kết quả thật. Bốn mục: theo kích thước, theo mức nén, hai trường hợp nén làm hại, và thương lượng qua HTTP thật.

JSON nén được khoảng 10 lần, và tỉ lệ đó gần như không đổi từ 2 KB tới 2 MB. 21,2 KB → 2,2 KB; 213 KB → 21,4 KB; 2,09 MB → 212 KB. JSON là định dạng cực kỳ lặp — mỗi dòng đều có "id":, "ten":, "gia": — và gzip bắt được đúng thứ đó. Với API trả danh sách, con số 10× là thứ bạn có thể trông đợi.

CPU nén tỉ lệ tuyến tính với kích thước: khoảng 5 µs mỗi KB với gzip-6. 21 KB mất 125 µs, 213 KB mất 1,07 ms, 2 MB mất 11,3 ms. Giải nén rẻ hơn 4–5 lần (26 µs cho 21 KB). Ở thân 2 MB, 11,3 ms nén là con số đáng để ý — nó lớn hơn thời gian xử lý của nhiều endpoint — và gzip-1 làm cùng việc trong 3,98 ms với kết quả chỉ to hơn 7%.

Mức nén gần như vô nghĩa với JSON. Mục 2: gzip-1 cho 10,9%, gzip-9 cho 10,6% — chênh 0,3 điểm phần trăm, tức 70 byte trên 21 KB. Brotli từ mức 1 tới 9 nằm trong 9,5–10,8%. Chỉ brotli-11 nhảy xuống 8,5%, và nó tốn 16,85 ms — gấp 135 lần brotli-4 — để mua thêm 1 điểm. Mức 11 là để nén tài nguyên tĩnh một lần lúc build; dùng nó cho phản hồi động là biến một endpoint 1 ms thành endpoint 18 ms.

Brotli thắng gzip khoảng 10% kích thước ở cùng tốc độ (br-4: 2,0 KB/159 µs so với gzip-6: 2,2 KB/125 µs). Thắng, nhưng không phải cách biệt lớn — lợi thế thật của brotli nằm ở từ điển dựng sẵn cho HTML/CSS/JS, thứ JSON không hưởng nhiều.

Khi nén làm hại

Mục 3 là phần mình thấy ít tài liệu nói:

  • {"ok":true} 11 byte nén thành 35 byte. Gzip có header 10 byte, trailer 8 byte, và một bảng Huffman — thân dưới khoảng 100 byte luôn phình. Một API trả nhiều phản hồi nhỏ ({"ok":true}, {"id":7}) mà bật nén toàn cục là tốn CPU để làm thân to hơn. nginx có gzip_min_length (mặc định 20 byte, nên đặt 256–1024) đúng vì lý do này.
  • JSON chứa ảnh base64 150 KB chỉ xuống 75%, tốn 2,12 ms — gấp 17 lần một JSON cùng cỡ. Ảnh JPEG/PNG đã nén sẵn; base64 chỉ thêm 33% mà gzip gỡ lại được phần nào, còn ruột ảnh thì không nén thêm được. Thân như vậy phải gửi Content-Encoding: identity hoặc không đi qua bộ nén, và tốt hơn là đừng nhúng ảnh vào JSON — trả URL.

Điểm hoà vốn, tính từ số đo

Mọi số đo ở trên là loopback, nên không thấy được lợi ích của băng thông. Nhưng từ chúng tính được tổng thời gian cho thân 21,2 KB → 2,2 KB (tiết kiệm 19 KB) với chi phí CPU 125 + 26 = 151 µs:

băng thông thời gian truyền 19 KB tiết kiệm được CPU nén + giải lời/lỗ
10 Mbps (4G yếu) 15,2 ms 0,15 ms lời 100 lần
100 Mbps 1,5 ms 0,15 ms lời 10 lần
1 Gbps (nội bộ) 0,15 ms 0,15 ms hoà
10 Gbps (cùng rack) 0,015 ms 0,15 ms lỗ 10 lần

Bảng này là phép tính, không phải phép đo, nhưng kết luận của nó khớp với thực hành: nén cho client ở xa (trình duyệt, app di động) gần như luôn lời; nén giữa hai service trong cùng datacenter thường lỗ. Với gRPC nội bộ, hoặc REST giữa các microservice, tắt nén thường nhanh hơn — và CPU đó dùng vào việc khác.

Đánh đổi cần cân nhắc

Nén ở đâu: ứng dụng hay nginx. Để nginx làm (gzip on; gzip_types application/json; gzip_min_length 512;) là cách ít code nhất và tách CPU nén khỏi tiến trình ứng dụng. Nén trong ứng dụng cho phép quyết định theo endpoint — tắt cho thân nhỏ, tắt cho thân chứa nhị phân — và quan trọng hơn: nén trước khi cache. Lưu bản nén trong Redis hoặc cache HTTP rồi phục vụ thẳng là cách duy nhất để nén không tốn CPU mỗi request; nginx có gzip_static cho tĩnh, còn API động thì phải tự làm.

ETag và nén. Bài phần 5 dùng strong ETag; cùng thân nén bằng gzip và br cho byte khác nhau, nên theo RFC thì ETag phải khác — nginx tự gỡ ETag hoặc đổi sang weak khi nén động. Nếu ETag sinh trong ứng dụng và nén ở nginx, kiểm tra xem 304 còn hoạt động không; nhiều hệ mất 304 im lặng sau khi bật gzip.

BREACH. Nén thân chứa cả bí mật (token CSRF, session) lẫn dữ liệu do người dùng điều khiển, qua TLS, là mở cửa cho tấn công đoán bí mật qua kích thước. API JSON trả token trong thân kèm trường do client gửi lên là đúng hình mẫu đó. Cách chữa: không đặt bí mật trong thân có nén, hoặc tắt nén cho những endpoint đó.

Số đo của một luồng, một CPU. 125 µs mỗi request ở 1.000 request/giây là 12,5% một lõi — không nhỏ, không lớn. Ở 10.000 request/giây, nén chiếm hơn một lõi và là thứ đầu tiên hiện trong profile. Lúc đó câu trả lời không phải "tắt nén" mà là "nén trước khi cache", như trên.

Ba ý mang về

  1. JSON nén được 10 lần và mức nén gần như không quan trọng: gzip-1 và gzip-9 chênh 0,3 điểm phần trăm, còn brotli-11 tốn 16,85 ms (gấp 135 lần br-4) để mua thêm 1 điểm — mức 11 chỉ dành cho tài nguyên tĩnh nén một lần.
  2. Hai nơi nén làm hại: thân dưới ~100 byte phình (11 → 35 byte), và dữ liệu đã nén sẵn (ảnh base64) tốn CPU gấp 17 lần cho 25% tiết kiệm. Đặt gzip_min_length và đừng nhúng nhị phân vào JSON.
  3. Nén là đổi CPU lấy băng thông, tỉ giá phụ thuộc đường truyền: 151 µs CPU đổi 19 KB là lời 100 lần ở 10 Mbps, hoà ở 1 Gbps, lỗ ở 10 Gbps — nén cho client ở xa, cân nhắc tắt giữa service nội bộ, và nén trước khi cache nếu lưu lượng lớn.

Nguồn

Phần sau chuyển từ "làm phản hồi nhỏ hơn" sang "từ chối bớt request": giới hạn tần suất bằng token bucket, các header chuẩn RateLimit-* và Retry-After, và đo xem client xử lý 429 đúng cách khác client xử lý sai bao nhiêu.