REST về cơ bản chỉ có một mô hình giao tiếp: client gửi một request, server trả một response, hết. Muốn nhận nhiều kết quả thì phải phân trang hoặc polling; muốn đẩy dữ liệu thì phải dùng WebSocket riêng. gRPC đi xa hơn: nó có bốn kiểu RPC ngay trong lõi, và chuyển giữa chúng chỉ bằng một từ khoá — stream — trong file .proto. Bốn kiểu này không phải "tính năng hay ho" mà là bốn mô hình luồng dữ liệu khác nhau, mỗi cái hợp một bài toán. Hiểu và chọn đúng kiểu là một quyết định thiết kế API thật sự. Bài này (phần 3 loạt gRPC nâng cao) chạy thật cả bốn trong một service để thấy chúng khác nhau thế nào.

Bốn kiểu, một từ khoá: stream

Đặt stream ở phía request, phía response, hoặc cả hai, ta được bốn tổ hợp:

  • Unary (không stream): 1 request → 1 response. Giống REST — request-response thông thường.
  • Server streaming (stream ở response): 1 request → N response. Server đẩy nhiều kết quả qua một kết nối (feed, danh sách lớn, tiến trình).
  • Client streaming (stream ở request): N request → 1 response. Client gửi nhiều, server gộp lại trả một kết quả (upload, tổng hợp).
  • Bidirectional streaming (stream cả hai): N ↔ N, hai bên gửi/nhận xen kẽ độc lập (chat, realtime, trò chơi).
service Shop {
  rpc GetOrder(OrderReq) returns (Order);              // unary
  rpc ListOrders(OrderReq) returns (stream Order);     // server streaming
  rpc SumAmounts(stream Order) returns (Sum);          // client streaming
  rpc Chat(stream Msg) returns (stream Msg);           // bidirectional
}

Phía Go, streaming dùng Send()/Recv() lặp cho tới io.EOF, thay vì một lời gọi trả giá trị:

// server streaming: gọi Send() nhiều lần
for i:=1; i<=5; i++ { stream.Send(&Order{...}) }
// client streaming: Recv() tới EOF rồi SendAndClose()
for { o,err := stream.Recv(); if err==io.EOF { ... } }

Ảnh chụp đoạn mã nền tối bốn kiểu RPC từ khoá stream trong proto, service Shop rpc GetOrder OrderReq returns Order unary, rpc ListOrders OrderReq returns stream Order server streaming 1 req N resp, rpc SumAmounts stream Order returns Sum client streaming N req 1 resp, rpc Chat stream Msg returns stream Msg bidirectional N N hai chiều, phía server Go server streaming gọi Send nhiều lần for i 1 đến 5 stream Send Order, client streaming Recv tới EOF rồi SendAndClose, mỗi kiểu hợp một dạng dữ liệu 1 phát tải lớn đẩy liên tục realtime

Hình 1: Bốn kiểu RPC khai bằng stream trong .proto — ở request, response, hoặc cả hai. Phía Go, streaming dùng Send()/Recv() lặp tới io.EOF thay vì một lời gọi trả giá trị đơn.

Đo thật: chạy cả bốn kiểu

Mình cài đặt cả bốn RPC trong một service Shop trên go-lab, chạy server + client trong cùng process, và đo số message mỗi chiều:

Ảnh chụp output thật nền tối chạy cả bốn kiểu RPC server client gRPC Go, 1 unary 1 req 1 resp gửi 1 req id 7 nhận 1 resp order id 7 amount 100, 2 server streaming 1 req N resp gửi 1 req nhận 5 resp stream, 3 client streaming N req 1 resp gửi 4 req 50 100 150 200 nhận 1 resp total 500 count 4, 4 bidirectional N N gửi 3 msg nhận 3 msg xen kẽ hai chiều, chọn kiểu theo luồng dữ liệu unary request-response thường server-stream tải danh sách feed lớn client-stream upload gộp nhiều bidi chat realtime hai chiều

Hình 2: Kết quả thật. (1) Unary: gửi 1 req (id=7) → nhận 1 resp (amount=100). (2) Server streaming: gửi 1 req → nhận 5 resp qua stream. (3) Client streaming: gửi 4 req (50+100+150+200) → nhận 1 resp với total=500, count=4. (4) Bidirectional: gửi 3 msg → nhận 3 msg xen kẽ hai chiều.

Đọc kết quả:

  • Unary — 1 req → 1 resp: giống REST. Client gọi GetOrder(id=7), nhận một Order. Đơn giản, đồng bộ, dễ hiểu — phù hợp đại đa số API (lấy/tạo/sửa một thứ).
  • Server streaming — 1 req → 5 resp: client gửi một request rồi lặp Recv() nhận 5 kết quả. Khác phân trang REST ở chỗ: một kết nối, server đẩy liên tục, client xử lý ngay khi nhận từng cái (không chờ hết). Hợp cho tải danh sách lớn, feed sự kiện, báo tiến trình.
  • Client streaming — 4 req → 1 resp (total=500): client lặp Send() 4 order rồi CloseAndRecv() nhận một kết quả gộp. Total = 50+100+150+200 = 500 đúng, count=4 — server tích luỹ qua stream rồi trả một lần. Hợp cho upload (gửi file theo chunk), gộp số liệu.
  • Bidirectional — 3 ↔ 3: client và server gửi/nhận độc lập, xen kẽ. Client gửi 3 msg, nhận lại 3 msg echo. Hai luồng chạy song song trên một kết nối — điều REST không làm được mà không có WebSocket. Hợp cho chat, cộng tác realtime, trò chơi.

Thông điệp cốt lõi: gRPC cho bạn chọn mô hình luồng khớp với bài toán, thay vì ép mọi thứ vào request-response. Và vì tất cả là stream trong .proto, chuyển đổi chỉ là một dòng khai báo — codegen lo phần còn lại.

Vì sao streaming khác polling/phân trang

Người quen REST hay hỏi: "server streaming khác gì phân trang?". Khác ở một kết nối, đẩy chủ động. Phân trang REST: client gửi N request riêng (?page=1, ?page=2...), mỗi cái một round-trip, server bị động chờ hỏi. Server streaming: một kết nối, server chủ động đẩy khi có dữ liệu, client nhận ngay. Với dữ liệu sinh dần theo thời gian (sự kiện, log, kết quả tính toán lâu), streaming cho độ trễ thấp hơn nhiều và không tốn round-trip. Nhưng với dữ liệu tĩnh có thể nhảy trang (duyệt sản phẩm), phân trang linh hoạt hơn (nhảy trang 5, quay lại trang 2). Chọn theo bản chất dữ liệu: sinh dần → stream, tĩnh/nhảy được → phân trang.

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

Streaming phức tạp hơn unary — chỉ dùng khi thật cần. Unary là một lời gọi hàm: dễ viết, dễ test, dễ retry (bài sau). Streaming cần quản lý vòng đời stream, xử lý io.EOF, lỗi giữa chừng, goroutine cho bidi (như demo dùng channel done). Nhiều lỗi tinh vi đến từ streaming: quên CloseSend(), deadlock khi cả hai cùng chờ nhận. Mặc định dùng unary; chỉ nâng lên streaming khi mô hình dữ liệu thật sự cần (nhiều kết quả sinh dần, hai chiều realtime).

Retry khó hơn với streaming. Unary RPC retry đơn giản: gọi lại. Nhưng một stream đang dở (đã gửi 3/10 message rồi lỗi) không retry sạch được — gửi lại từ đầu có thể gây trùng, tiếp tục giữa chừng cần server hỗ trợ resume. Streaming đánh đổi tính dễ phục hồi lấy throughput và realtime. Với thao tác quan trọng cần đảm bảo, cân nhắc unary + idempotency (như loạt Message Queue) thay vì streaming.

Bidirectional dễ gây deadlock nếu thiết kế sai. Trong bidi, nếu cả client và server cùng chờ nhận trước khi gửi, chúng khoá chết nhau. Demo tránh điều này bằng cách tách việc nhận ra một goroutine riêng. Luồng gửi/nhận phải thiết kế để không bao giờ cả hai cùng chờ — đây là lỗi kinh điển của bidi streaming, và là lý do nó là kiểu khó dùng đúng nhất trong bốn kiểu.

Ba ý mang về

  1. Bốn kiểu RPC mở khoá bằng stream: đo thật unary (1→1), server streaming (1→5), client streaming (4→1 với total=500 đúng), bidirectional (3↔3) — chuyển đổi chỉ bằng đặt stream ở request/response trong .proto.
  2. Mỗi kiểu hợp một luồng dữ liệu: unary cho request-response thường, server-stream cho tải danh sách/feed sinh dần, client-stream cho upload/gộp, bidi cho chat/realtime — chọn theo bản chất dữ liệu, không ép mọi thứ vào một mô hình.
  3. Streaming mạnh nhưng phức tạp hơn — dùng đúng liều: unary dễ viết/test/retry; streaming cần quản lý vòng đời + io.EOF, khó retry sạch, và bidi dễ deadlock nếu cả hai cùng chờ — mặc định unary, chỉ nâng lên streaming khi thật cần.

Nguồn

Phần sau ta đào sâu server streaming thực chiến: đẩy nhiều response qua một kết nối, đo khác biệt so với trả một lần và khi nào streaming thực sự thắng.