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
}

Ảnh chụp đoạn mã Go nền tối minh hoạ gRPC streaming server client và bidirectional stream, ba kiểu stream khai trong proto bằng từ khóa stream service Dem rpc DemNguoc BatDau returns stream So server stream 1 request nhiều response rpc Tong stream So returns KetQua client stream nhiều request 1 response rpc VangLai stream So returns stream So bidirectional nhiều nhiều đồng thời, server stream gửi nhiều bằng stream Send func server DemNguoc r con trỏ BatDau stream Dem_DemNguocServer error for i bằng r Tu i lớn hơn bằng 1 i trừ trừ stream Send So GiaTri i đẩy từng phần tử return nil return bằng đóng stream, client stream nhận nhiều bằng Recv trả 1 lần func server Tong stream Dem_TongServer error var tong int64 for s err bằng stream Recv nếu err bằng io EOF return stream SendAndClose KetQua Tong tong tong cộng bằng int64 s GiaTri, bidi đọc và ghi đồng thời trên cùng stream func server VangLai stream Dem_VangLaiServer error for s err bằng stream Recv nếu err bằng io EOF return nil stream Send So GiaTri s GiaTri nhân 2 vọng lại gấp đôi client thường Send trong goroutine riêng Recv ở goroutine chính

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):

Ảnh chụp bảng kết quả đo thật nền tối 3 kiểu stream chạy end-to-end 1 server-stream nhanh hơn 1000 unary 25x, go run cộng go test bench bufconn Go 1.23 grpc 1.67 arm64 10 core, ba kiểu streaming chạy thật server stream DemNguoc 5 bằng 5 4 3 2 1 1 req dòng response client stream Tong 10 20 30 40 bằng 100 dòng req 1 res bidi VangLai 1 2 3 bằng 2 4 6 gửi nhận đồng thời x2, benchmark 1 server-stream 1000 phần tử vs 1000 RPC unary 1 server-stream 663.349 ns 495.487 byte 16.212 alloc 1000 RPC unary riêng 16.381.774 ns 10.071.277 byte 194.012 alloc server-stream nhanh hơn 24,7x 663 micro giây vs 16,4 mili giây và tốn 20x ít bộ nhớ 1 RPC khấu hao chi phí dựng stream header cho cả 1000 phần tử thay vì trả phí đó 1000 lần, vì sao streaming thắng 1 lần bắt tay HTTP 2 cộng header cho cả dòng không lặp mỗi item client nhận phần tử đầu sớm không chờ gom hết độ trễ thấp bộ nhớ hằng số xử lý từng item không buffer cả tập bidi cho hội thoại thực chat đồng bộ unary không làm được, cốt lõi server stream 1 req nhiều res feed kết quả lớn tiến độ client stream nhiều req 1 res tải lên gộp số liệu bidi nhiều nhiều đồng thời chat game đồng bộ nhanh hơn 25x so unary lặp 20x ít bộ nhớ đo thật đánh đổi giữ kết nối lâu xử lý lỗi giữa dòng phức tạp hơn

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ề

  1. 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, bidi 2 4 6).
  2. 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ố.
  3. Đổ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ý context hủ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.