Nén payload API — gzip, Brotli hay zstd cho backend?

Phong Hy

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-Encoding chí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 check Accept-Encoding chứ đừ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

  1. 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.
  2. 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.
  3. Đ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-Encoding trê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.