Bài trước dựng gRPC unary — một request, một response, đủ cho phần lớn RPC. Nhưng nhiều bài toán không vừa khuôn đó: gửi một feed dữ liệu dài, tải lên nhiều mảnh, hay một hội thoại hai chiều thời gian thực (chat, đồng bộ). gRPC giải chúng bằng streaming — dòng chảy message trên một kết nối HTTP/2 duy nhất. Có ba kiểu: server stream, client stream, và bidirectional. Bài này dựng cả ba chạy được, và đo thật vì sao streaming vượt trội so với gọi unary lặp lại.
Ba kiểu stream, khai bằng từ khóa stream
Trong .proto, thêm từ khóa stream vào request hoặc response (hoặc cả hai) để biến một RPC thành streaming:
service Dem {
rpc DemNguoc(BatDau) returns (stream So); // server: 1 req -> NHIỀU res
rpc Tong(stream So) returns (KetQua); // client: NHIỀU req -> 1 res
rpc VangLai(stream So) returns (stream So); // bidi: NHIỀU <-> NHIỀU
}
Server stream hợp khi kết quả là một dòng (feed, kết quả truy vấn lớn, báo tiến độ). Server gửi nhiều message qua stream.Send:
func (server) DemNguoc(r *BatDau, stream Dem_DemNguocServer) error {
for i := r.Tu; i >= 1; i-- {
stream.Send(&So{GiaTri: i}) // đẩy từng phần tử
}
return nil // return = đóng stream
}

Hình 1: Ba kiểu stream khai bằng từ khóa stream trong .proto. Server stream dùng Send; client stream dùng Recv + SendAndClose; bidi dùng cả Recv và Send trên cùng stream, client thường Send trong goroutine riêng.
Client stream và bidirectional
Client stream ngược lại: client gửi một dòng, server gộp và trả một kết quả. Server đọc bằng stream.Recv tới khi io.EOF:
func (server) Tong(stream Dem_TongServer) error {
var tong int64
for {
s, err := stream.Recv()
if err == io.EOF { return stream.SendAndClose(&KetQua{Tong: tong}) }
tong += int64(s.GiaTri)
}
}
Bidirectional cho phép cả hai đầu gửi/nhận đồng thời trên cùng stream — nền của chat, game, đồng bộ. Server Recv và Send xen kẽ; client thường chạy Send trong một goroutine riêng còn Recv ở goroutine chính để không kẹt.
Đo thật (Go 1.23, grpc 1.67), cả ba chạy end-to-end:
[server stream] DemNguoc(5): 5 4 3 2 1
[client stream] Tong(10,20,30,40) = 100
[bidi] VangLai(1,2,3) -> 2 4 6
Server stream trả dòng 5 4 3 2 1; client stream gộp 10+20+30+40=100; bidi vọng lại mỗi số gấp đôi (1,2,3 → 2,4,6).
Đo thật: streaming vs unary lặp
Vì sao không cứ gọi unary nhiều lần thay cho stream? Benchmark gửi 1000 phần tử bằng một server-stream so với 1000 lời gọi unary riêng (dùng bufconn để loại nhiễu mạng):

Hình 2: Ba kiểu stream chạy đúng. Benchmark: một server-stream gửi 1000 phần tử mất 663.349 ns (495 KB, 16.212 cấp phát) so với 1000 RPC unary riêng 16.381.774 ns (10 MB, 194.012 cấp phát) — nhanh hơn ~24,7 lần, ít hơn ~20 lần bộ nhớ.
- 1 server-stream (1000 phần tử): 663.349 ns/op, 495 KB, 16.212 cấp phát.
- 1000 RPC unary riêng: 16.381.774 ns/op, 10 MB, 194.012 cấp phát.
Streaming nhanh hơn ~24,7 lần và tốn ít hơn ~20 lần bộ nhớ. Lý do: mỗi RPC unary phải bắt tay HTTP/2, tạo stream mới, gửi header — trả cái giá cố định đó 1000 lần. Server-stream trả nó một lần rồi đẩy cả 1000 phần tử trên cùng stream. Ngoài tốc độ, streaming còn cho client nhận phần tử đầu tiên sớm (độ trễ thấp) và xử lý từng phần tử với bộ nhớ hằng số thay vì buffer cả tập.
Đánh đổi cần cân nhắc
Streaming giữ kết nối lâu — cần quản lý vòng đời cẩn thận. Một stream mở là tài nguyên trên cả hai đầu (goroutine, buffer, HTTP/2 stream). Với bidi và stream sống lâu, phải xử lý đóng đúng cách (context hủy, timeout, dọn goroutine) để tránh rò rỉ. Unary đơn giản hơn nhiều về mặt này — request xong là hết. Đừng dùng streaming cho request-response đơn lẻ chỉ vì nó "mạnh hơn".
Xử lý lỗi giữa dòng phức tạp hơn. Với unary, lỗi trả về một lần rõ ràng. Với stream, lỗi có thể xảy ra sau khi đã gửi/nhận một phần dòng — client phải quyết định xử lý phần đã nhận thế nào, có retry được không (thường không, vì trạng thái đã thay đổi một phần). Thiết kế streaming cần nghĩ về ngữ nghĩa "một nửa hoàn thành".
Flow control (backpressure) quan trọng ở stream lớn. Nếu server gửi nhanh hơn client đọc, dữ liệu dồn trong buffer. gRPC/HTTP/2 có flow control tự động, nhưng với stream khối lượng lớn bạn vẫn nên nghĩ về nhịp: client đọc kịp không, có cần giới hạn tốc độ gửi không. Với unary không có vấn đề này.
Ba ý mang về
- gRPC có ba kiểu stream khai bằng từ khóa
stream: server stream (1 req → nhiều res, cho feed/kết quả lớn), client stream (nhiều req → 1 res, cho tải lên/gộp), và bidirectional (nhiều ↔ nhiều đồng thời, cho chat/đồng bộ) — đo thật cả ba chạy end-to-end (5 4 3 2 1,Tong=100, bidi2 4 6). - Streaming nhanh hơn unary lặp rất nhiều: đo thật một server-stream gửi 1000 phần tử 663 µs so với 1000 RPC unary 16,4 ms (~24,7 lần) với ít hơn ~20 lần bộ nhớ — vì một RPC khấu hao chi phí dựng stream/header cho cả dòng, và client nhận phần tử đầu sớm với bộ nhớ hằng số.
- Đổi lại là quản lý vòng đời phức tạp hơn: stream giữ kết nối lâu (phải dọn goroutine, xử lý
contexthủy), lỗi giữa dòng khó xử lý hơn unary, và cần để ý flow control — dùng unary cho request-response đơn lẻ, streaming khi thật sự có dòng dữ liệu.
Bài tập làm thử
Bài 1 (đọc hiểu). Cho ba khai báo RPC:
rpc DemNguoc(BatDau) returns (stream So);
rpc Tong(stream So) returns (KetQua);
rpc VangLai(stream So) returns (stream So);
Gọi tên đúng kiểu stream của từng RPC (DemNguoc, Tong, VangLai) và giải thích chiều dữ liệu của mỗi kiểu.
Đáp án
DemNguoc là server stream: 1 request vào, nhiều response ra (stream chỉ ở phía return). Tong là client stream: nhiều request gửi lên, 1 response trả về (stream chỉ ở phía tham số). VangLai là bidirectional stream: cả hai chiều đều có stream, cho phép client và server gửi/nhận đồng thời trên cùng kết nối.
Bài 2 (sửa lỗi). Hàm server stream sau bị lỗi logic — client không bao giờ nhận được phần tử nào:
func (server) DemNguoc(r *BatDau, stream Dem_DemNguocServer) error {
for i := r.Tu; i >= 1; i-- {
so := &So{GiaTri: i}
}
return nil
}
Sửa lại.
Đáp án
func (server) DemNguoc(r *BatDau, stream Dem_DemNguocServer) error {
for i := r.Tu; i >= 1; i-- {
stream.Send(&So{GiaTri: i}) // THIẾU lời gọi Send để đẩy phần tử ra
}
return nil // return đóng stream sau khi gửi hết
}
Đoạn mã gốc chỉ tạo biến so nhưng không bao giờ gọi stream.Send(so), nên không có message nào thực sự được đẩy qua stream tới client — client sẽ chỉ thấy stream đóng ngay mà không nhận phần tử nào.
Bài 3 (vận dụng). Viết hàm server hiện thực RPC Tong(stream So) returns (KetQua) (client stream) để cộng dồn các số nhận được và trả tổng khi client đóng luồng gửi.
Đáp án
func (server) Tong(stream Dem_TongServer) error {
var tong int64
for {
s, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&KetQua{Tong: tong})
}
if err != nil {
return err
}
tong += int64(s.GiaTri)
}
}
Vòng lặp đọc từng message bằng stream.Recv() cho tới khi client đóng luồng gửi (báo hiệu bằng io.EOF), lúc đó gọi SendAndClose để trả kết quả gộp và đóng response — đúng mẫu client stream trong bài (Tong(10,20,30,40) = 100).
Bài 4 (bẫy/đánh đổi). Đo thật cho thấy một server-stream gửi 1000 phần tử (663.349 ns) nhanh hơn khoảng 24,7 lần so với 1000 lời gọi unary riêng (16.381.774 ns), và tốn ít hơn ~20 lần bộ nhớ. Giải thích vì sao khoảng cách lớn như vậy — chi phí "cố định" nào bị lặp lại 1000 lần ở cách unary?
Đáp án
Mỗi lời gọi RPC unary phải trả một chi phí cố định: bắt tay HTTP/2, tạo một stream mới, gửi header — và với 1000 lời gọi unary riêng, chi phí này bị trả 1000 lần. Với một server-stream, chi phí bắt tay/tạo stream/header chỉ trả một lần, sau đó cả 1000 phần tử được đẩy liên tục trên cùng một stream đã mở sẵn. Đây là lý do số cấp phát bộ nhớ chênh lệch rất lớn (16.212 so với 194.012 alloc) — mỗi RPC unary tạo ra bộ cấp phát riêng cho việc dựng stream/header của chính nó.
Bài 5 (đọc hiểu/đánh đổi). Một đội muốn dùng bidirectional stream cho một API chỉ cần "gửi một yêu cầu, nhận đúng một kết quả" (không có dòng dữ liệu liên tục nào). Theo các đánh đổi bài viết nêu, đây có phải lựa chọn tốt không? Vì sao?
Đáp án
Không nên. Bài viết cảnh báo "streaming giữ kết nối lâu — cần quản lý vòng đời cẩn thận" và "đừng dùng streaming cho request-response đơn lẻ chỉ vì nó mạnh hơn". Một stream mở là tài nguyên tốn kém trên cả hai đầu (goroutine, buffer, HTTP/2 stream) và cần xử lý đóng đúng cách (context hủy, timeout, dọn goroutine) — phức tạp hơn nhiều so với unary, vốn đơn giản: request xong là hết. Với nhu cầu chỉ một request - một response, RPC unary là lựa chọn đúng và đơn giản hơn.
Phần sau ta thêm một lớp cắt ngang cho gRPC giống middleware của HTTP: Phần sau mổ xẻ gRPC interceptor — cách chèn log, auth, metrics vào mọi RPC (unary lẫn stream) mà không sửa từng handler.