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.
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.