Hình dung bạn bỏ tiền thuê dịch vụ hút chân không để nén hành lý cho nhỏ lại — rồi phát hiện khoang hàng máy bay vốn đã ép mọi kiện hàng như nhau. Cái công bạn bỏ ra không sai, chỉ là phần lớn lợi ích đằng nào cũng có, vì một tầng phía sau đã làm hộ. Chuyện "dùng Avro cho nhỏ" trên Kafka đúng là cái vali hút chân không đó, và con số chỉ lộ ra khi tôi bật đúng tầng mà mọi cụm sản xuất đều đã bật.
Lời khuyên phổ biến là "đừng dùng JSON trên Kafka, dùng Avro". Bài này đo cả ba định dạng trên cùng một tập dữ liệu, và con số quan trọng nhất chỉ xuất hiện sau khi bật nén.
Dữ liệu và cách đo
200.000 bản ghi đơn hàng, tám trường, tương tự dữ liệu thật:
{"orderId":"ORD-000000","customerId":"CUST-00000","status":"PENDING",
"currency":"VND","amount":0,"warehouse":"HCM-01",
"createdAt":"2026-08-30T10:01:07Z","channel":"WEB"}
Đo ba thứ cho mỗi định dạng: kích thước, thời gian mã hoá, thời gian giải mã.
Kết quả thô
| Byte mỗi tin | Mã hoá | Giải mã | |
|---|---|---|---|
| JSON | 174 | 147 ms | 190 ms |
| Avro | 69 | 231 ms | 63 ms |
| Protobuf | 77 | 304 ms | 82 ms |
Hai điều bất ngờ.
Avro nhỏ hơn JSON 2,5 lần — đúng như quảng cáo. Nó bỏ hẳn tên trường (lược đồ nằm ở chỗ khác), dùng số nguyên biến đổi độ dài, không có dấu ngoặc hay dấu phẩy.
Nhưng mã hoá Avro chậm hơn JSON 1,6 lần. Điều này ngược với hình dung "định dạng nhị phân thì nhanh hơn". Jackson đã được tối ưu suốt mười lăm năm cho đúng một việc, và nó làm việc đó rất tốt.
Giải mã thì đảo ngược: Avro nhanh gấp 3 lần, Protobuf gấp 2,3 lần. Phân tích JSON phải quét từng ký tự, tìm dấu ngoặc kép, khớp tên trường; định dạng nhị phân thì đọc thẳng theo vị trí.
Với Kafka, giải mã thường xảy ra nhiều hơn mã hoá — một tin ghi một lần nhưng có thể được nhiều nhóm consumer đọc. Nên cán cân nghiêng về phía nhị phân, nhưng không nhiều như bảng thô gợi ý.
Nhưng Kafka nén dữ liệu
Đây là chỗ bảng trên gây hiểu nhầm. Phần 5 đã đo: lz4 giữ 99% thông lượng mà nhỏ đi gần bảy lần, nên gần như mọi cụm sản xuất đều bật nén.
Đo lại sau khi nén:
| Thô | Sau gzip | Tỉ lệ nén | |
|---|---|---|---|
| JSON | 34.774.858 | 2.068.348 | 16,8× |
| Avro | 13.798.133 | 1.496.243 | 9,2× |
| Protobuf | 15.396.262 | 1.464.833 | 10,5× |
Và tỉ lệ giữa các định dạng:
| Trước nén | Sau nén | |
|---|---|---|
| Avro / JSON | 0,40 | 0,72 |
| Protobuf / JSON | 0,44 | 0,71 |
Lợi thế 2,5 lần co xuống còn 1,4 lần.
Lý do rõ ràng khi nhìn vào tỉ lệ nén: JSON nén được 16,8 lần còn Avro chỉ 9,2 lần. JSON lặp lại tên trường ở mọi bản ghi — "orderId", "customerId", "status" xuất hiện 200.000 lần — và đó chính xác là thứ thuật toán nén giỏi nhất. Avro đã bỏ tên trường từ trước, nên còn ít thứ dư thừa để nén.
Nói cách khác: nén làm phần lớn công việc mà Avro được ca ngợi vì đã làm. Và bạn dùng nén rồi.
Vậy lập luận về kích thước còn lại gì
Vẫn còn 1,4 lần, và với cụm hàng terabyte thì 30% đĩa không phải con số nhỏ. Nhưng nó không còn là lý do quyết định.
Lý do thật để chọn Avro hay Protobuf nằm ở chỗ khác: ép buộc lược đồ. Phần 22 đã đo một tin không đọc nổi khiến consumer ném hơn tám triệu ngoại lệ và khoá cứng partition. Đó là chuyện xảy ra khi không có gì kiểm tra lược đồ trước khi dữ liệu vào topic.
Avro cộng với một cơ quan đăng ký lược đồ chuyển việc kiểm tra đó sang lúc triển khai: producer đăng ký lược đồ mới, registry so với lược đồ cũ theo quy tắc tương thích, và từ chối nếu phá vỡ. Sự cố xảy ra ở giai đoạn triển khai, nơi bạn có thể lùi lại, thay vì ở giai đoạn chạy, nơi partition đã kẹt.
JSON cũng có thể làm vậy với JSON Schema, nhưng hệ sinh thái quanh Kafka mỏng hơn nhiều.
Chọn thế nào
JSON — một đội, ít dịch vụ, và bạn cần đọc được dữ liệu bằng mắt khi gỡ lỗi. kafka-console-consumer in ra thứ người đọc hiểu ngay, không cần công cụ gì thêm. Kém 1,4 lần về đĩa sau nén, và đó thường là cái giá chấp nhận được để đổi lấy việc gỡ lỗi dễ.
Avro — nhiều đội đọc chung một topic, và bạn cần một cơ quan đăng ký ép tương thích. Lược đồ tách rời khỏi dữ liệu, hệ sinh thái quanh Kafka đầy đủ nhất, và đây là lựa chọn mặc định của phần lớn hệ thống lớn.
Protobuf — bạn đã dùng gRPC ở nơi khác và muốn một định dạng cho cả hai. Kích thước và tốc độ tương đương Avro; khác biệt chính là lược đồ được biên dịch thành mã thay vì đọc lúc chạy.
Điều không nên làm: đổi từ JSON sang Avro chỉ vì lý do kích thước. Phép đo ở trên cho thấy lợi ích thật là 30% đĩa, còn cái giá là thêm một dịch vụ phải vận hành và một bước sinh mã trong quy trình xây dựng. Nếu bạn không cần ép tương thích, hãy bật nén và giữ JSON.
Ghi chú về phép đo
Tôi dùng Avro GenericRecord và Protobuf DynamicMessage — hai đường đi chậm nhất của cả hai thư viện, vì chúng tra tên trường lúc chạy. Với lớp được sinh sẵn từ lược đồ, con số mã hoá sẽ tốt hơn đáng kể.
Tôi chọn đường đi này vì nó không cần bước sinh mã, nên phép đo lặp lại được bằng một lệnh. Tỉ lệ kích thước — con số quan trọng nhất trong bài — không phụ thuộc vào đường đi nào, vì byte ra là như nhau.
Nếu bạn định chọn dựa trên tốc độ, hãy đo lại bằng lớp sinh sẵn trên dữ liệu của chính bạn.
Cùng một cái bẫy "một tầng rẻ hơn đã nuốt mất lợi ích của tối ưu", đổi ngăn xếp
Điều làm cả bài này lật ngược — cái lợi 2,5 lần co còn 1,4 lần vì nén đã làm hộ — không phải chuyện riêng của serializer. Nó là một cái bẫy đo lường lặp lại: một tối ưu ở tầng trên có thể trùng phần lớn với một tầng rẻ hơn ở dưới mà bạn đã bật sẵn.
Trong thế giới cơ sở dữ liệu, đây đúng là chuyện chuẩn hoá hay chọn kiểu cột gọn để tiết kiệm chỗ, trong khi engine lưu trữ đã nén trang (nén cột trong kho dữ liệu) — phần lớn cái lợi về dung lượng của việc chuẩn hoá đã bị nén trang nuốt mất. Hay thêm một index cho một truy vấn mà một covering index sẵn có đã lo; hay phi chuẩn hoá cho nhanh trong khi bộ đệm đệm đã làm phép nối gần như miễn phí.
Trong thế giới Java, đó là tự viết một object gọn để tiết kiệm bộ nhớ trong khi JVM đã có khử trùng chuỗi (-XX:+UseStringDeduplication) và nén heap khi GC; hay tự tay unroll vòng lặp trong khi JIT đã làm đúng việc đó — công tay bỏ ra bị một tầng dưới làm lại, nên lợi ích biên gần như bằng không.
Mẫu số chung: lợi ích của một tối ưu đo riêng lẻ có thể trông quyết định, nhưng nếu một tầng rẻ hơn ở phía sau đã làm gần hết cùng việc đó (nén xoá đúng phần dư thừa mà một định dạng gọn xoá bằng tay), thì lợi ích biên thật sự nhỏ hơn nhiều — nên phải đo với cả ngăn xếp đang bật, không đo cô lập; và khi lý do bạn viện ra (kích thước) đã bị tầng kia nuốt mất, hãy hỏi lại lý do thật để chọn nó là gì (ở đây: ép buộc lược đồ, không phải byte). Nên câu hỏi đúng trước khi đổi công nghệ vì một con số hấp dẫn không phải "con số đó lớn cỡ nào khi đo riêng" mà là "một tầng rẻ hơn tôi đã bật — nén, cache, JIT — có làm hộ phần lớn lợi ích này không, và nếu có thì lý do thật để tôi vẫn nên đổi là gì".
Muốn tự so kích thước trên chính dữ liệu của bạn, không cần thư viện nào, chạy thử:
# lấy 10.000 bản ghi thật
kafka-console-consumer.sh --bootstrap-server kf:9092 --topic topic-cua-ban \
--from-beginning --max-messages 10000 --timeout-ms 30000 > /tmp/mau.json
# thô và sau khi nén
wc -c < /tmp/mau.json
gzip -c /tmp/mau.json | wc -c
Chia hai số. Nếu tỉ lệ nén của bạn trên 10 lần, phần lớn kích thước đang là tên trường lặp lại — và đó chính là phần mà nén đã xử lý hộ bạn rồi.
Phần sau đo hàng đợi tin chết: thử lại tại chỗ chậm hơn 25 lần khi tỉ lệ hỏng lên 50%.