Khi hai service cần nói chuyện với nhau, lựa chọn mặc định của hầu hết lập trình viên là REST + JSON: định nghĩa vài endpoint, trả về JSON, parse ở phía kia. Nó hoạt động, nhưng khi hệ thống lớn lên — nhiều service gọi nhau hàng triệu lần, cần tốc độ, cần hợp đồng (contract) rõ ràng để không gọi nhầm — REST/JSON bắt đầu lộ chi phí: payload text cồng kềnh, không có contract bắt buộc nên dễ lệch, và không có streaming sẵn. gRPC là câu trả lời của Google cho bài toán này: bạn định nghĩa dịch vụ trong một file .proto, công cụ protoc sinh code type-safe cho cả hai phía, và lời gọi mạng trông y như gọi một hàm nội bộ. Bài mở màn sê-ri này (phần 1/12) dựng server + client gRPC thật bằng Go và đo thật những gì nó mang lại.

gRPC là gì: contract-first, code sinh tự động

Khác biệt cốt lõi của gRPC so với REST là contract-first. Bạn không viết code xử lý request trước; bạn định nghĩa hợp đồng trong .proto — các message và các RPC — rồi để protoc sinh ra stub cho server và client.

message GetOrderRequest { int64 id = 1; }
message Order { int64 id = 1; string customer = 2; double amount = 3; string status = 4; string region = 5; }
service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
}

Chạy protoc --go_out=. --go-grpc_out=. pb/order.proto sinh ra code Go: interface server để bạn cài logic, và client stub để gọi như hàm thường. Không parse URL, không marshal JSON tay, không đoán kiểu dữ liệu — tất cả type-safe và sinh tự động.

Ảnh chụp đoạn mã nền tối minh hoạ gRPC gọi hàm từ xa như gọi hàm nội bộ contract-first bằng proto, định nghĩa dịch vụ trong proto protoc sinh code server cộng client chạy trên HTTP/2 với payload nhị phân Protobuf. Bước 1 định nghĩa contract trong proto message GetOrderRequest int64 id bằng 1 message Order int64 id bằng 1 string customer bằng 2 double amount bằng 3 string status bằng 4 service OrderService rpc GetOrder GetOrderRequest returns Order. Bước 2 protoc sinh code viết server cộng client Go sinh stub type-safe cho cả server lẫn client protoc go_out go-grpc_out pb order.proto server cài logic client gọi như hàm thường o bằng cli.GetOrder ctx GetOrderRequest Id 1001 không parse URL không đọc JSON tay tất cả type-safe sinh tự động. gRPC khác REST JSON ở đâu giao thức gRPC HTTP/2 đa luồng 1 kết nối REST HTTP/1.1 phổ biến, payload gRPC Protobuf nhị phân nhỏ REST JSON text cồng kềnh, contract gRPC proto bắt buộc type-safe REST thường ad-hoc OpenAPI, streaming gRPC có sẵn 4 kiểu REST phải tự xây SSE WS, trình duyệt gọi thẳng gRPC không cần grpc-web REST có

Hình 1: Quy trình gRPC — định nghĩa .proto, protoc sinh stub, viết server + client, gọi cli.GetOrder(...) như hàm Go. Bảng so sánh gRPC (HTTP/2, Protobuf nhị phân, contract bắt buộc, streaming sẵn) với REST/JSON.

So với REST/JSON, gRPC khác ở bốn điểm chính: chạy trên HTTP/2 (nhiều request song song trên một kết nối), payload là Protobuf nhị phân (nhỏ hơn JSON), contract .proto bắt buộc (type-safe, không lệch), và streaming sẵn 4 kiểu (bài kafka... à, bài grpc-03). Đổi lại, trình duyệt không gọi thẳng gRPC được (cần grpc-web proxy) — đây là lý do gRPC chủ yếu dùng cho giao tiếp service-to-service phía backend, còn REST vẫn hợp cho API công khai ra trình duyệt.

Đo thật: unary call, kích thước và latency

Mình dựng server OrderService và client trong go-lab, gọi một unary RPC (một request → một response, kiểu RPC cơ bản nhất), rồi đo hai thứ: kích thước message trên dây và latency.

cli := pb.NewOrderServiceClient(conn)
o, err := cli.GetOrder(context.Background(), &pb.GetOrderRequest{Id: 1001})
// o.Customer == "nguyen-van-a", o.Amount == 250000 — type-safe, không cast

Ảnh chụp bảng kết quả đo thật unary call kích thước wire và latency output thật go-lab go 1.23 grpc-go v1.67.1 localhost 127.0.0.1 50051. Một kích thước message Order trên dây Protobuf vs JSON, JSON 88 byte Protobuf 41 byte, cùng một Order id customer amount status region Protobuf 41 byte so với JSON 88 byte nhỏ khoảng 2,1 lần vì mã hoá nhị phân theo field number không kèm tên trường. Hai unary call thật qua gRPC kết quả 1 call GetOrder id 1001 Order customer nguyen-van-a amount 250000, 1000 unary call localhost tổng 42,0 ms, trung bình mỗi call 0,042 ms mỗi call, thông lượng khoảng 23.800 call mỗi giây, mỗi call đi qua HTTP/2 cộng Protobuf round-trip trên localhost chỉ 42 micro-giây client gọi cli.GetOrder như một hàm Go bình thường stub do protoc sinh lo toàn bộ mã hoá giải mã

Hình 2: Đo thật trong go-lab. Cùng message Order: Protobuf 41 byte, JSON 88 byte (~2,1× nhỏ hơn). Unary call GetOrder(1001) trả về đúng Order; 1000 call hết 42 ms, trung bình 0,042 ms/call, ~23.800 call/giây trên localhost.

Kết quả thật:

  • Protobuf 41 byte vs JSON 88 byte cho cùng một message Order. Protobuf nhỏ hơn ~2,1 lần vì mã hoá nhị phân theo field number (số 1, 2, 3... trong .proto) thay vì nhúng tên trường như JSON. Bài grpc-02 sẽ mổ xẻ wire format này.
  • Unary call hoạt động type-safe: cli.GetOrder(ctx, &GetOrderRequest{Id:1001}) trả về *Order với các trường đã có kiểu — không cast, không parse. Stub do protoc sinh lo toàn bộ việc mã hoá sang Protobuf, gửi qua HTTP/2, và giải mã.
  • Latency rất thấp: 1000 call hết 42 ms, trung bình 0,042 ms/call (42 micro-giây round-trip trên localhost), tức ~23.800 call/giây một luồng tuần tự. Trên localhost con số này chủ yếu đo chi phí mã hoá + HTTP/2 framing, không phải mạng; nhưng nó cho thấy gRPC "nhẹ" cỡ nào ở tầng gọi.

Lưu ý trung thực: 23.800 call/giây là tuần tự một luồng trên localhost — không phải giới hạn throughput của gRPC (gọi song song nhiều luồng sẽ cao hơn nhiều). Và latency thật trên mạng data center sẽ bị cộng thêm round-trip mạng (thường 0,1–1 ms), lớn hơn hẳn 42 micro-giây localhost. Số ở đây để thấy chi phí tầng gRPC, không phải để suy ra hiệu năng production.

Đánh đổi cần cân nhắc

gRPC mạnh cho service-to-service, không cho API ra trình duyệt. Vì trình duyệt không nói HTTP/2 trailers kiểu gRPC cần, muốn gọi từ JavaScript phải qua grpc-web + proxy — thêm một tầng. Với API công khai ra web/mobile, REST/JSON (hoặc GraphQL) thường gọn hơn. Chọn gRPC khi hai đầu đều là service của bạn.

Contract-first là kỷ luật, không phải gánh nặng — nếu quản lý tốt. .proto bắt buộc đồng bộ giữa client và server; đổi contract phải sinh lại code cả hai phía. Đây là điểm mạnh (không lệch âm thầm như JSON) nhưng cần quy trình: lưu .proto ở nơi dùng chung, sinh code trong CI, versioning rõ ràng. Thiếu kỷ luật này, nhiều team sinh code lệch nhau là một mớ hỗn độn.

Khó debug hơn REST. Payload nhị phân không đọc được bằng mắt như JSON; không curl thẳng được. Cần công cụ riêng (grpcurl, server reflection — bài grpc-11) để gọi thử. Đổi lấy hiệu năng và type-safety là mất đi sự tiện lợi "mở trình duyệt xem JSON". Cân nhắc điều này cho trải nghiệm phát triển của đội.

Ba ý mang về

  1. gRPC là contract-first: định nghĩa .proto, sinh code, gọi như hàm nội bộ. protoc sinh stub type-safe cho cả server lẫn client; cli.GetOrder(ctx, req) trông như một lời gọi hàm Go bình thường mà thực chất đi qua mạng. Không parse URL, không marshal tay.
  2. Protobuf nhỏ hơn JSON đáng kể. Đo thật: cùng message Order, Protobuf 41 byte vs JSON 88 byte (~2,1× nhỏ hơn) nhờ mã hoá nhị phân theo field number. Trên khối lượng lớn, chênh lệch này là băng thông thật.
  3. gRPC nhẹ ở tầng gọi, nhưng chọn đúng ngữ cảnh. Đo thật: unary call ~0,042 ms trên localhost (~23.800 call/giây một luồng). gRPC toả sáng cho giao tiếp service-to-service hiệu năng cao; cho API ra trình duyệt hay cần debug bằng mắt thì REST/JSON vẫn hợp hơn.

Nguồn

Phần sau ta mổ xẻ Protocol Buffers ở mức byte: wire format nhị phân, varint và field number mã hoá thế nào, và vì sao Protobuf vừa nhỏ vừa nhanh hơn JSON — đo thật từng byte.