Ở bài grpc-03 ta học bốn kiểu RPC và nói "streaming hợp khi dữ liệu là một luồng". Nhưng hợp tới mức nào? Nếu bạn cần gửi 50.000 bản ghi từ server sang client, gọi GetOne() 50.000 lần có sao không, hay phải dùng server-stream? Câu trả lời không phải "hơn một chút" mà là hàng chục lần. Bài này (phần 9/12) đo thật khác biệt đó trong go-lab, và giải thích cơ chế HTTP/2 đứng sau — gồm cả flow control, thứ giữ cho một stream nhanh không làm tràn bộ nhớ bên nhận.
Vì sao nhiều unary call chậm
Mỗi unary call, dù nhẹ, mang theo overhead cố định: mở một stream HTTP/2 mới, gửi header, gửi request, rồi chờ response quay về mới gọi tiếp. Khi lặp tuần tự, bạn trả cái giá đó mỗi lần — và cộng thêm một round-trip mạng mỗi message.
// N unary call: round-trip tuần tự, overhead per-call cộng dồn
for i := 0; i < N; i++ {
item := c.GetOne(ctx, req) // gửi -> CHỜ -> nhận, rồi mới lặp
}
Một server-stream thì khác hẳn: mở một stream, server đẩy cả loạt message liên tiếp, client đọc liên tục — không chờ round-trip giữa từng message:
stream := c.GetStream(ctx, &Req{N: N}) // mở 1 stream
for { item, err := stream.Recv(); if err == io.EOF { break } }

Hình 1: Nhiều unary call phải thiết lập và chờ round-trip mỗi lần; một server-stream thiết lập một lần rồi đẩy cả loạt. HTTP/2 flow control (window) tự điều tiết: consumer đọc chậm thì Send phía server tạm chặn — backpressure giữ cho stream không làm tràn bộ nhớ.
Đo thật: 50.000 message, hai cách
Mình gửi 50.000 message (payload ~100 byte) từ server sang client bằng hai cách và đo tổng thời gian:

Hình 2: Đo thật. 50.000 unary call riêng lẻ: 2,06 giây (24.253 msg/giây). 1 server-stream cùng 50.000 message: 33,6 mili-giây (1.488.147 msg/giây). Streaming nhanh gấp ~61 lần.
Kết quả thật gây sốc:
- 50.000 unary call riêng lẻ: 2,06 giây — chỉ ~24.253 msg/giây.
- 1 server-stream (50.000 message): 33,6 mili-giây — ~1.488.147 msg/giây.
- Streaming nhanh gấp ~61 lần cho cùng lượng message.
Vì sao chênh tới 61 lần? Toàn bộ nằm ở overhead per-call. Mỗi unary call phải thiết lập logic stream HTTP/2, truyền header, và — quan trọng nhất — client gọi tuần tự, chờ response của call này mới gửi call sau. 50.000 lần "gửi rồi chờ" cộng dồn thành 2 giây. Server-stream trả lời một lần: client gửi một request, server tuôn 50.000 message liên tiếp mà không ai phải chờ round-trip ở giữa.
Một lưu ý trung thực về bối cảnh đo: đây là localhost, round-trip gần như bằng 0. Trên mạng data center thật (round-trip 0,1–1 ms), khác biệt sẽ còn lớn hơn nữa — vì unary phải cộng nguyên một round-trip mạng mỗi call (50.000 × round-trip), trong khi stream chỉ trả một round-trip khởi đầu. Con số 61× ở đây là cận dưới, không phải cường điệu.
Flow control: nhanh nhưng không làm tràn
Một câu hỏi tự nhiên: nếu server tuôn 50.000 message mà client đọc chậm, chẳng phải bộ nhớ sẽ phình? Không — HTTP/2 có flow control. Bên nhận duy trì một "window" báo nó sẵn sàng nhận bao nhiêu byte; khi consumer đọc chậm và window đầy, lời gọi Send() phía server sẽ tạm chặn (block) cho tới khi client đọc bớt. Đây là backpressure tự động: stream tự chậm lại theo tốc độ của bên yếu nhất, không dồn dữ liệu làm tràn RAM. Nhờ vậy streaming vừa nhanh (khi hai bên đều nhanh) vừa an toàn (tự điều tiết khi một bên chậm) — khác với việc bạn tự đẩy dữ liệu vào một buffer vô hạn.
Đánh đổi cần cân nhắc
Streaming không phải lúc nào cũng đúng — chỉ khi dữ liệu là luồng. Con số 61× đến từ việc gửi nhiều message liên quan trong một phiên. Nếu bạn chỉ cần một kết quả mỗi lần (query lẻ, lệnh đơn), unary đơn giản hơn, dễ retry, dễ load-balance (bài grpc-10). Đừng gói một request-response đơn vào stream chỉ vì "stream nhanh hơn" — overhead quản lý stream không đáng cho một message.
Stream dài giữ tài nguyên và khó cân tải. Một stream chạy lâu giữ một luồng HTTP/2 mở suốt thời gian đó, chiếm goroutine/bộ nhớ hai phía, và — như bài grpc-03 nhắc — khó phân phối lại qua load balancer (một stream dính vào một backend). Với dữ liệu rất lớn hoặc rất lâu, cân nhắc chia thành nhiều stream vừa phải, hoặc dùng phân trang (pagination) để stream có điểm dừng tự nhiên.
Đo trên tải thật, không chỉ localhost. 61× là số localhost; hướng đúng nhưng giá trị tuyệt đối sẽ khác trên hạ tầng thật (round-trip mạng, TLS, serialization payload lớn hơn). Dùng kết quả này để quyết định kiến trúc (dữ liệu luồng → dùng stream), nhưng benchmark lại trên môi trường gần production để có con số dung lượng chính xác.
Ba ý mang về
- Với dữ liệu dạng luồng, streaming nhanh hơn nhiều unary call hàng chục lần. Đo thật: 50.000 message qua 50.000 unary call mất 2,06 giây (24k msg/s), qua một server-stream chỉ 33,6 ms (1,49 triệu msg/s) — ~61×. Overhead per-call (thiết lập + round-trip tuần tự) là thủ phạm, và trên mạng thật khoảng cách còn rộng hơn.
- Flow control cho streaming vừa nhanh vừa an toàn. HTTP/2 window tạo backpressure: consumer đọc chậm thì
Sendphía server tự chặn lại — stream chậm theo bên yếu nhất, không làm tràn bộ nhớ. Không cần tự quản buffer. - Chọn streaming theo bản chất dữ liệu, không theo "nhanh hơn". Dữ liệu là luồng nhiều message → stream thắng lớn; một request-response lẻ → dùng unary (đơn giản, dễ retry/cân tải). Stream dài giữ tài nguyên và khó load-balance; và luôn benchmark lại trên tải thật.
Nguồn
- gRPC — Performance best practices: https://grpc.io/docs/guides/performance/
- HTTP/2 — RFC 9113: Flow Control: https://httpwg.org/specs/rfc9113.html#FlowControl
- gRPC Go — Streaming RPCs: https://grpc.io/docs/languages/go/basics/#server-side-streaming-rpc
Phần sau ta sang bài toán quy mô: load balancing phía client trong gRPC — vì sao gRPC khác HTTP/1 về cân tải, name resolver, và round-robin giữa nhiều backend.