Text vs Binary: Tăng gấp 5 lần hiệu suất API khi thay thế JSON
JSON hoạt động với tốc độ không cao ở quy mô lớn vì nó chuyển đổi dữ liệu có cấu trúc thành văn bản và ngược lại trong mỗi yêu cầu. Bốn định dạng này: Protobuf, MessagePack, Avro và FlatBuffers, giải quyết vấn đề đó ở cấp độ truyền tải. Hãy sử dụng JSON ở các public edge và các format này ở mọi nơi mà máy móc giao tiếp với nhau.
Vấn đề ít được nhắc đến
JSON dễ đọc, dễ gỡ lỗi và được sử dụng rộng rãi trong các API. Tuy nhiên, sự tiện lợi này cũng đi kèm chi phí về hiệu năng. Mỗi lần truyền dữ liệu, hệ thống phải tuần tự hóa (serialize) dữ liệu thành chuỗi văn bản và phía nhận lại phải phân tích (deserialize) để khôi phục cấu trúc ban đầu, tạo thêm chi phí xử lý khi số lượng yêu cầu lớn.
Trong các hệ thống có lưu lượng cao, định dạng nhị phân (binary) thường cho hiệu quả tốt hơn. Ví dụ, cùng một tập dữ liệu gồm 10.000 bản ghi, JSON có thể tạo payload khoảng 2,3 MB và mất khoảng 180 ms để serialize, trong khi định dạng binary chỉ khoảng 620 KB và mất 34 ms. Điều này cho thấy việc lựa chọn định dạng dữ liệu phù hợp có thể cải thiện đáng kể hiệu năng của API.
1. Protocol Buffers (Protobuf)
Google đã tự phát triển Protobuf trong nhiều năm trước khi cho ra mắt mã nguồn mở này. Ý tưởng rất đơn giản: bạn định nghĩa schema của mình một lần trong tệp .proto, và thư viện sẽ xử lý việc mã hóa và giải mã ở một binary format chặt chẽ.
// user.proto
syntax = "proto3";
message User {
int32 id = 1;
string name = 2;
string email = 3;
}
Tiếp theo là encoding và decoding trong Node.js:
const protobuf = require('protobufjs');
async function run() {
const root = await protobuf.load('user.proto');
const User = root.lookupType('User');
const msg = User.create({ id: 1, name: 'Arjun', email: 'a@dev.io' });
const buf = User.encode(msg).finish(); // binary buffer
const decoded = User.decode(buf); // back to object
}
Chỉ có số thứ tự của các trường (1, 2, 3) được truyền đi - chứ không phải tên trường. Chuỗi ký tự “name” trong JSON tốn 4 byte mỗi lần truyền. Trong Protobuf, khi được sử dụng làm field tag, nó chỉ tốn 1 byte.
Hãy nhân con số đó với một triệu request mỗi ngày. Đó không phải là một sự tối ưu nhỏ mà có khả năng giảm chi phí infrastructure thực sự.
Phù hợp nhất cho: Giao tiếp nội bộ giữa các dịch vụ, API gRPC, các data pipeline có high-throughput.
2. MessagePack
Có thể xem MessagePack là một phiên bản nhị phân của JSON. Định dạng này vẫn sử dụng mô hình key-value và hỗ trợ các kiểu dữ liệu tương tự JSON, nhưng thay vì lưu trữ dưới dạng văn bản, toàn bộ dữ liệu được mã hóa ở dạng nhị phân. Một ưu điểm lớn là MessagePack không yêu cầu schema, vì vậy các đối tượng JSON hiện có có thể được chuyển đổi sang MessagePack với rất ít thay đổi trong ứng dụng.
Nhờ khả năng mã hóa nhị phân, MessagePack giúp giảm đáng kể kích thước dữ liệu truyền tải so với JSON trong khi vẫn duy trì tính linh hoạt và dễ tích hợp. Điều này khiến nó trở thành một lựa chọn phù hợp cho các hệ thống cần tối ưu băng thông mà không muốn thay đổi kiến trúc dữ liệu hiện có.
const msgpack = require('@msgpack/msgpack');
const data = { id: 1, name: 'Arjun', score: 98.5 };
const encoded = msgpack.encode(data); // Uint8Array, ~20 bytes
const decoded = msgpack.decode(encoded); // back to plain object
So với JSON, chẳng hạn {"id":1,"name":"Arjun","score":98.5} chiếm khoảng 37 byte ở định dạng ASCII, MessagePack thường có kích thước nhỏ hơn nhờ sử dụng mã hóa nhị phân. Các giá trị phổ biến như số nguyên nhỏ, giá trị boolean hay null chỉ cần một byte để biểu diễn, thay vì chuỗi ký tự như trong JSON.
Đổi lại, dữ liệu MessagePack không còn dễ đọc bằng mắt thường. Vì vậy, trước khi áp dụng, cần đảm bảo hệ thống ghi log và công cụ gỡ lỗi có thể xử lý dữ liệu binary.
Phù hợp cho: truyền dữ liệu qua WebSocket, lưu trữ trong Redis hoặc các hệ thống mà cả bên gửi và bên nhận đều do bạn kiểm soát.
Kiến trúc: Vị trí của các format này
Đây là mô hình tư duy hiệu quả. Định dạng bạn sử dụng nên phụ thuộc vào việc ai đang đọc dữ liệu — con người hay máy móc.
Client App
|
| (REST/HTTP — JSON fine here, humans debug this)
v
API Gateway
|
| (MessagePack or Protobuf — machines only)
v
Service A ------> Service B ------> Service C
| |
| (Avro — event streaming) |
v v
Kafka / Redpanda Cache (MessagePack)
Ranh giới giữa các thành phần trong hệ thống đóng vai trò quan trọng. Các API công khai nên sử dụng JSON để thuận tiện cho việc phát triển và gỡ lỗi, trong khi giao tiếp nội bộ giữa các service nên ưu tiên các định dạng như MessagePack hoặc Protobuf để tối ưu hiệu năng.
Nhiều hệ thống sử dụng JSON cho mọi trường hợp, dẫn đến chi phí truyền tải và xử lý không cần thiết. Việc lựa chọn đúng định dạng dữ liệu theo từng ngữ cảnh có thể giúp cải thiện đáng kể hiệu suất của toàn hệ thống.
3. Apache Avro
Apache Avro là một định dạng tuần tự hóa dữ liệu được sử dụng phổ biến trong các nền tảng như Apache Kafka. Tương tự Protocol Buffers, Avro sử dụng schema để mô tả cấu trúc dữ liệu. Tuy nhiên, điểm khác biệt là schema có thể được lưu trữ cùng hoặc quản lý song song với dữ liệu, giúp hỗ trợ tốt cho việc thay đổi cấu trúc dữ liệu theo thời gian (schema evolution).
Nhờ đó, các ứng dụng tiêu thụ dữ liệu vẫn có thể đọc được các sự kiện cũ ngay cả khi schema đã được bổ sung hoặc thay đổi các trường mới. Điều này khiến Avro trở thành lựa chọn phù hợp cho các hệ thống event streaming và xử lý dữ liệu phân tán, nơi khả năng tương thích giữa các phiên bản dữ liệu là yếu tố quan trọng.
const avro = require('avsc');
const UserEvent = avro.Type.forSchema({
type: 'record',
name: 'UserEvent',
fields: [
{ name: 'id', type: 'int' },
{ name: 'event', type: 'string' },
{ name: 'ts', type: 'long' }
]
});
const buf = UserEvent.toBuffer({ id: 42, event: 'login', ts: Date.now() });
const obj = UserEvent.fromBuffer(buf);
Sau khi chuyển hệ thống internal event sang Avro, độ trễ của Kafka consumer đã giảm từ 40s trong peak load xuống dưới 4s. Cùng một hardware, cùng một topic partition, chỉ khác định dạng truyền tải.
Việc tích hợp vào schema registry giúp hệ thống sẵn sàng cho môi trường production đối với các team đang vận hành các pipeline quy mô lớn.
Phù hợp nhất cho: Kafka event streaming, data lake, những hệ thống cần ưu tiên cho schema thay đổi.
4. FlatBuffers
Đây là định dạng đang có vị trí thấp nhất trong list này, và cũng là định dạng bị hiểu lầm nhiều nhất.
FlatBuffers, cũng đến từ Google, sử dụng một cách tiếp cận hoàn toàn khác. Thay vì encoding object thành byte rồi decoding ở phía bên kia, nó xây dựng byte buffer để sao cho code có thể đọc trực tiếp mà không cần bất kỳ bước giải mã nào cả.
Giống như là không phải bạn đang giải nén một box mà là đang đọc chính box đó.
// After generating JS code from your .fbs schema:
const flatbuffers = require('flatbuffers');
const { Monster } = require('./monster_generated');
const builder = new flatbuffers.Builder(128);
const name = builder.createString('Orc');
Monster.startMonster(builder);
Monster.addHp(builder, 300);
Monster.addName(builder, name);
const orc = Monster.endMonster(builder);
builder.finish(orc);
const buf = builder.asUint8Array();
const monster = Monster.getRootAsMonster(new flatbuffers.ByteBuffer(buf));
console.log(monster.name()); // 'Orc' - read directly from buffer, zero copy
console.log(monster.hp()); // 300
// After generating JS code from your .fbs schema:
const flatbuffers = require('flatbuffers');
const { Monster } = require('./monster_generated');
const builder = new flatbuffers.Builder(128);
const name = builder.createString('Orc');
Monster.startMonster(builder);
Monster.addHp(builder, 300);
Monster.addName(builder, name);
const orc = Monster.endMonster(builder);
builder.finish(orc);
const buf = builder.asUint8Array();
const monster = Monster.getRootAsMonster(new flatbuffers.ByteBuffer(buf));
console.log(monster.name()); // 'Orc' - read directly from buffer, zero copy
console.log(monster.hp()); // 300
Không cần sao chép, không cần cấp memory khi đọc. Đối với các hệ thống nhạy cảm về latency — bảng tin real-time, dữ liệu giao dịch tài chính, game backend - đây là một level hiệu năng hoàn toàn khác. Phần giải mã dữ liệu thời gian trong bảng so sánh hiệu năng bên dưới sẽ cho chúng ta thấy điều này.
Phù hợp nhất cho: Hệ thống real-time , bảng tín dữ liệu tài chính, game servers, embedded systems.
Kết quả kiểm tra hiệu năng (Số liệu thực tế)
Một thử nghiệm với tệp dữ liệu gồm 5.000 record, mỗi record có 8 field, trên Node.js 20:
Format | Size | Serialize | Deserialize
------------- | -------- | ---------- | -----------
JSON | 1.8 MB | 142 ms | 98 ms
MessagePack | 1.1 MB | 61 ms | 44 ms
Protobuf | 680 KB | 38 ms | 29 ms
Avro | 590 KB | 35 ms | 31 ms
FlatBuffers | 720 KB | 28 ms | ~2 ms *
*Thời gian giải mã FlatBuffers gần như bằng không vì không có quá trình giải mã — code đọc trực tiếp từ raw buffer trong memory.
Vậy có nên loại bỏ hoàn toàn JSON không?
Câu trả lời là không. Đó sẽ là một nhận định sai lầm. JSON rất tuyệt vời cho các public API, config file và trong tất cả các tình huống con người cần đọc dữ liệu. Khả năng gỡ lỗi rất quan trọng. Khả năng hỗ trợ công cụ cũng rất quan trọng.
Lần đầu tiên bạn gặp sự cố trong môi trường production và log của bạn là những khối dữ liệu binary không thể đọc được, bạn sẽ hiểu rõ ý định của nhận định này.
Sự thay đổi thực sự là học cách làm việc có chủ đích. Sử dụng JSON ở những trường hợp con người tương tác với hệ thống. Sử dụng binary format ở tầng sâu bên trong, nơi các máy móc giao tiếp với nhau ở quy mô lớn.




















