Nén payload API — gzip, Brotli hay zstd cho backend?
JSON là định dạng "nói nhiều" một cách đáng ngờ. Cùng một object, đổi tên field qua tiếng Việt có dấu là payload phình gấp rưỡi. Trên WiFi văn phòng thì không ai để ý, nhưng anh em nào từng ngồi đo API cho app mobile ở vùng sóng 3G chập chờn sẽ thấy: phần lớn thời gian request không nằm ở server xử lý, mà nằm ở đường truyền. Nén response là cách rẻ nhất để cắt latency mà không đụng một dòng logic nghiệp vụ nào.
Ba thuật toán, một câu hỏi sai thường gặp
Câu hỏi hay được đặt là "gzip hay Brotli nhanh hơn?". Câu hỏi đúng hơn là: nén ở tầng nào, cho loại payload nào, và đánh đổi CPU lấy bao nhiêu byte?
- gzip (DEFLATE) — ra đời 1992, client nào cũng hiểu. Ratio khá, CPU thấp, level 1-5 là điểm ngọt.
- Brotli — dictionary có sẵn cho HTML/JSON, ratio tốt hơn gzip khoảng 15-25% ở cùng "cảm giác" CPU. Nén tĩnh (q11) thì cực tốt nhưng chậm, chỉ nên chạy lúc build.
- zstd — mới được đưa vào
Content-Encodingchính thức, ratio ngang Brotli nhưng nén/decode nhanh hơn nhiều, đặc biệt ở level thấp. Hỗ trợ trình duyệt còn mới, phải checkAccept-Encodingchứ đừng mặc định.
Điểm chung: cả ba đều vô nghĩa với dữ liệu đã nén sẵn (JPEG, PNG, MP4, file zip, Parquet). Nhét gzip lên ảnh chỉ tốn CPU để ra file to hơn.
Cái bẫy Vary — nén xong CDN trả nhầm cho client
Lỗi kinh điển: bật nén, set Content-Encoding: gzip, nhưng quên Vary: Accept-Encoding. Cache (nginx, Cloudflare, Varnish) coi response gzip là bản duy nhất của URL đó, rồi đem trả cho một client không gửi Accept-Encoding: gzip — kết quả là client nhận về một đống byte rác. Luôn set Vary trước khi ghi body.
Middleware nén trong Go — gọn và đủ dùng
var gzPool = sync.Pool{New: func() any { return gzip.NewWriter(io.Discard) }}
type gzipWriter struct {
http.ResponseWriter
gw *gzip.Writer
}
func (w *gzipWriter) Write(p []byte) (int, error) { return w.gw.Write(p) }
// Lưu ý: đừng nén khi Content-Type là ảnh/video/zip (đã nén sẵn)
func Compress(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
gw := gzPool.Get().(*gzip.Writer)
defer gzPool.Put(gw)
gw.Reset(w)
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding") // BẮT BUỘC
defer gw.Close()
next.ServeHTTP(&gzipWriter{ResponseWriter: w, gw: gw}, r)
})
}
Nhưng bản trên vẫn hớ khi payload nhỏ: nén 300 byte JSON thành 280 byte mà tốn thêm CPU vô ích. Cách xử lý thực tế là buffer: gom body vào bytes.Buffer, nếu < 1KB thì ghi thẳng không nén, lớn hơn mới nén.
Bản ngắn gọn hơn nữa là để tầng hạ tầng lo:
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on; # tương đương Vary: Accept-Encoding
gzip_types application/json application/javascript text/css text/plain;
gzip_static on; # dùng file .gz prebuilt nếu có
brotli on; # cần module brotli (Cloudflare/OpenResty có sẵn)
brotli_comp_level 5;
brotli_static on;
Nén ở nginx/CDN rẻ hơn ở app vì giải phóng CPU của process Go, nhưng chỉ hiệu quả khi response đủ lớn để buffer. Với streaming (SSE, text/event-stream) thì phải tắt nén: buffer sẽ phá tính realtime.
Con số từ một lần đo thật
Một endpoint trả danh sách ~600 record (JSON ~420KB):
| Cách | Size | Ghi chú |
|---|---|---|
| Không nén | 420KB | p95 trên 4G ~1.1s |
| gzip level 5 | ~40KB | p95 ~620ms, CPU app +3% |
| Brotli q5 | ~34KB | p95 ~600ms, CPU +8% |
| Brotli q11 | ~31KB | không dùng cho dynamic |
Chênh lệch giữa q11 và q5 chỉ ~3KB — không đáng để nhân CPU lên vài lần. Còn cái chênh 420KB → 40KB mới là thứ đáng làm.
Ba cạm bẫy còn lại
- BREACH. Nén response trộn dữ liệu bí mật (CSRF token, session id) với dữ liệu người dùng nhập → kẻ tấn công đo được độ dài ciphertext để suy ra bí mật. Endpoint trả token thì tắt nén, hoặc tách token ra header riêng.
- Decompress bomb. Phía client: một response 1MB có thể giải nén thành 1GB. Không bao giờ decompress mà không giới hạn output size.
- Đo sai. Đo trên localhost thì network gần như bằng 0, nén xong kết luận "không nhanh hơn" là sai. Phải đo qua mạng thật hoặc throttle bandwidth.
Kiểm tra nhanh bằng curl:
curl -s -o /dev/null -w 'size=%{size_download} ttfb=%{time_starttransfer}\n' \
-H 'Accept-Encoding: gzip' https://api.example.com/items
Checklist
-
Vary: Accept-Encodingtrên mọi response nén - Ngưỡng min size (~1KB), bỏ qua content-type đã nén sẵn
- Level 5, không ham level 9/11 cho dynamic
- Tắt nén cho SSE/streaming và endpoint có token
- Ưu tiên nén ở CDN/nginx, app chỉ lo phần dynamic
- Đo bằng curl qua mạng thật, không đo localhost
Nén không làm code mình đẹp hơn, nhưng nó là một trong số ít tối ưu mà 30 phút bỏ ra đổi lấy cảm giác app nhanh hẳn — đặc biệt với người dùng mobile ở Việt Nam, nơi sóng 4G vẫn hay nhảy về 3G giữa đường.