gRPC được nhắc tới như một cách gọi dịch vụ nhanh và gọn hơn REST truyền thống: nó dùng Protocol Buffers (nhị phân) thay cho JSON, và chạy trên HTTP/2 thay cho HTTP/1.1. Câu hỏi thực tế: một lời gọi gRPC rẻ hơn một lời gọi REST bao nhiêu, và rẻ ở đâu? Bài này biên dịch một .proto thật, đo kích thước và tốc độ đóng gói của protobuf so với JSON — và phát hiện cái lợi lớn nhất không nằm ở nơi tôi tưởng.
gRPC gói một lời gọi khác REST thế nào
Một lời gọi REST điển hình gửi dữ liệu dưới dạng JSON — văn bản, con người đọc được, tên trường lặp lại trong mỗi bản ghi — thường trên HTTP/1.1. gRPC gói dữ liệu bằng Protocol Buffers: một định dạng nhị phân có lược đồ (schema) khai báo trước trong file .proto, nên trên dây chỉ có số hiệu trường và giá trị đã mã hóa gọn, không có tên trường. Và nó chạy trên HTTP/2, thừa hưởng ghép luồng và streaming hai chiều (như bài HTTP/2 đã đo). Cái giá của một lời gọi vì thế nằm ở hai chỗ: kích thước dữ liệu trên dây, và thời gian mã hóa/giải mã.
Đo: nhỏ hơn hai lần, nhanh hơn bảy lần
Tôi định nghĩa một bản ghi User (id, tên, email, tuổi, cờ, danh sách thẻ, thành phố, quốc gia), mã hóa nó bằng cả protobuf lẫn JSON, rồi đo:
| Bản ghi | protobuf | JSON | Tỉ lệ |
|---|---|---|---|
| Nhiều chuỗi (user) | 87 B | 182 B | 2,1× nhỏ hơn |
| Nhiều số (telemetry) | 81 B | 200 B | 2,5× nhỏ hơn |
Và tốc độ mã hóa cộng giải mã một bản ghi User (lấy lô nhanh nhất trong ba lần, mỗi lô 20.000 vòng):
protobuf : 0,31 micro giây / lần
JSON : 2,23 micro giây / lần (7,2× chậm hơn)
Protobuf nhỏ hơn JSON khoảng 2 lần và mã hóa/giải mã nhanh hơn khoảng 7 lần. Cả hai đều là lợi ích thật. Nhưng khi nhìn kỹ hai con số này, tôi nhận ra mình đã vào bài với một kỳ vọng sai.
Một lần tôi đo hớ: cái lợi không ở nơi tôi tưởng
Tôi vào đo với niềm tin phổ biến: "protobuf nhỏ hơn JSON cả chục lần", và định lấy con số kích thước làm điểm chính của bài. Nhưng đo ra chỉ 2 tới 2,5 lần, không phải 10.
Lý do rõ khi nhìn vào dữ liệu. Phần lớn byte của một bản ghi là nội dung thật — chuỗi tên, email, thành phố; các giá trị — và cả hai định dạng đều lưu nội dung đó gần như y hệt: chữ "nguyenvana@example.com" tốn chừng ấy byte dù trong JSON hay protobuf. Chỗ protobuf thật sự tiết kiệm là tên trường (JSON lặp lại "email": trong mỗi bản ghi, protobuf chỉ dùng một số hiệu) và cách mã hóa số (số 1048576 trong JSON là 7 ký tự văn bản, trong protobuf là 3 byte varint). Nên bản ghi nhiều số tiết kiệm hơn bản ghi nhiều chuỗi (2,5× so với 2,1×) — nhưng cả hai đều xa con số "chục lần".
Tôi còn đoán nhầm một chuyện thứ hai: tưởng nén gzip sẽ san bằng khác biệt (nén JSON xuống gần cỡ protobuf). Đo ra ngược lại với payload nhỏ: gzip một protobuf 87 byte cho ra 104 byte — lớn hơn bản thô, vì phần khung cố định của gzip lớn hơn cái nó tiết kiệm được trên một payload tí xíu, đúng như bài về nén đã đo.
Vậy cái lợi thật và ổn định của protobuf không phải kích thước (chỉ ~2×, và gzip không cứu được payload nhỏ), mà là tốc độ mã hóa/giải mã — nhanh 7 lần. Con số đó nghe nhỏ ở một lời gọi (0,31 so với 2,23 micro giây), nhưng nó là chi phí CPU thuần, và nhân với hàng chục nghìn lời gọi mỗi giây trên một dịch vụ bận rộn, nó thành khác biệt lớn về tải CPU và độ trễ đuôi. Bài học đo lường: danh tiếng của một công nghệ hay chỉ đúng một nửa; phải đo để biết cái lợi thật nằm ở trục nào. Tôi suýt viết cả bài về "protobuf nhỏ hơn chục lần" — một điều đúng với vài trường hợp cực đoan nhưng sai với bản ghi thực tế; cái đáng nói hơn lại là CPU và lược đồ.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là hiểu đúng khi nào một lời gọi rẻ hơn thật sự quan trọng. Với một lời gọi đơn lẻ qua mạng, cả gRPC lẫn REST đều tốn khoảng một vòng khứ hồi trên kết nối ấm — RTT lấn át hoàn toàn phần đóng gói (vài micro giây mã hóa chẳng là gì so với hàng chục mili giây RTT). Nên nếu bạn gọi một dịch vụ vài lần mỗi giây, chọn gRPC hay REST gần như không đổi độ trễ cảm nhận. Cái lợi của protobuf chỉ tỏa sáng ở quy mô: hàng vạn lời gọi mỗi giây (nơi 7× CPU và 2× băng thông cộng dồn thành tiền thật), và ở các luồng streaming dài (nơi HTTP/2 và mã hóa gọn giúp liên tục).
Hệ quả thứ hai là cái lợi lớn nhất của gRPC thường không phải hiệu năng mà là lược đồ. File .proto là một hợp đồng kiểu chặt chẽ, sinh mã cho cả client lẫn server ở nhiều ngôn ngữ, bắt lỗi kiểu lúc biên dịch thay vì lúc chạy. Với hệ thống nhiều microservice nói chuyện với nhau, sự chặt chẽ đó — cộng streaming hai chiều của HTTP/2 — thường là lý do chọn gRPC, chứ không phải vài micro giây hay vài chục byte.
Hệ quả thứ ba là REST + JSON vẫn đúng chỗ của nó. JSON đọc được bằng mắt, gỡ lỗi bằng curl dễ dàng, được mọi trình duyệt và công cụ hỗ trợ sẵn — vô giá cho API hướng ra ngoài, cho việc khám phá, cho con người. gRPC đổi những cái đó lấy hiệu năng và hợp đồng kiểu, đáng cho giao tiếp nội bộ giữa các dịch vụ nhưng phiền cho một API công khai. Con số mang theo: protobuf nhỏ hơn JSON khoảng 2 lần và mã hóa nhanh hơn 7 lần — cái lợi thật là CPU và lược đồ ở quy mô lớn, không phải một cú cắt byte thần kỳ, và với một lời gọi đơn lẻ thì RTT lấn át tất cả. Chọn gRPC vì thông lượng, streaming và hợp đồng kiểu; chọn REST vì đơn giản và dễ đọc — và đừng chọn theo một con số kích thước bị thổi phồng.
Bốn kiểu gọi, không chỉ hỏi-đáp
Một điểm nữa mà so sánh "byte và micro giây" bỏ sót: nhờ HTTP/2 làm nền, gRPC có bốn kiểu gọi chứ không chỉ hỏi-đáp một lần như REST. Unary là hỏi một, đáp một (giống REST). Nhưng còn server-streaming (client hỏi một lần, server trả về một dòng nhiều phản hồi — hợp với theo dõi sự kiện, kết quả chảy dần), client-streaming (client gửi một dòng dữ liệu, server tổng hợp rồi đáp — hợp với tải lên hay đo lường liên tục), và bidirectional streaming (cả hai gửi qua lại đồng thời trên cùng một luồng — hợp với trò chuyện, đồng bộ thời gian thực).
Ba kiểu streaming đó là thứ REST trên HTTP/1.1 rất khó làm gọn (thường phải dùng long-polling, SSE, hay WebSocket riêng), còn gRPC coi chúng là công dân hạng nhất nhờ luồng hai chiều của HTTP/2. Với nhiều hệ thống, đây — chứ không phải vài byte — mới là lý do thật sự chọn gRPC.
Thử ba mươi giây
So kích thước hai cách gói cùng một dữ liệu ngay trong đầu: lấy một đối tượng JSON bạn hay dùng, đếm số byte của nó, rồi ước lượng phần "khung" — các dấu ", :, ,, {}, và tên trường lặp lại. Đó chính là phần protobuf cắt được; phần nội dung (giá trị thật) thì không đổi. Nếu đối tượng của bạn nhiều trường nhỏ với tên dài ("created_at_timestamp": 1700000000), protobuf tiết kiệm nhiều; nếu nó là vài chuỗi văn bản lớn, protobuf gần như không nhỏ hơn. Đó là cách nhanh để đoán gRPC có đáng cho tải của bạn không — trước khi viết một dòng .proto nào.