Bài trước là server streaming (1 req → N resp). Còn hai kiểu nữa: client streaming (N req → 1 resp) và bidirectional streaming (N ↔ N). Hai kiểu này đảo hoặc mở rộng hướng luồng dữ liệu, và mỗi cái giải một lớp bài toán riêng — client streaming cho upload và gộp, bidirectional cho tương tác realtime hai chiều. Bidirectional đặc biệt mạnh (và đặc biệt dễ sai): nó là thứ gần nhất với một "đường ống hai chiều" mà REST không có nếu không dùng WebSocket. Bài này (phần 5 loạt gRPC nâng cao) chạy thật cả hai, và chỉ ra cái bẫy deadlock kinh điển của bidi.
Client streaming: gửi nhiều, nhận một
Client streaming đảo ngược server streaming: client gửi một luồng nhiều message, server gộp lại và trả một kết quả. Mô hình điển hình: upload file theo chunk, hoặc đẩy nhiều số liệu để server tổng hợp.
func Upload(stream) error {
for {
c, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&Result{...}) // 1 resp sau khi hết
}
total += len(c.Data) // gộp dần từng chunk
}
}
Server Recv() lặp tới io.EOF (client gọi CloseSend()), rồi SendAndClose() trả kết quả cuối. Khác unary ở chỗ client không phải gom cả file vào RAM trước khi gửi — nó đẩy từng chunk, phù hợp upload file lớn.
Bidirectional: hai luồng độc lập, và bẫy deadlock
Bidirectional cho client và server gửi/nhận độc lập trên cùng một kết nối. Đây là nơi dễ sai nhất: nếu bạn viết client gửi hết rồi mới nhận (hoặc server nhận hết rồi mới gửi), và cả hai cùng chờ phía kia, chúng deadlock. Giải pháp: tách việc nhận ra một goroutine riêng, để gửi và nhận chạy song song.
// phía client: goroutine RIÊNG để nhận
go func() {
for { r, err := bs.Recv(); if err != nil { break }; xuly(r) }
}()
for _, v := range nums { bs.Send(&Num{V: v}) } // gửi song song
bs.CloseSend()
// ⚠ Nếu gửi và nhận CÙNG một goroutine, cả hai cùng chờ → DEADLOCK

Hình 1: Client streaming — server Recv() lặp tới io.EOF rồi SendAndClose() trả một kết quả gộp. Bidirectional — phía client đặt việc nhận vào một goroutine riêng để gửi/nhận chạy song song; nếu gửi và nhận cùng một goroutine và cả hai cùng chờ phía kia, deadlock.
Đo thật: upload 7936 byte và chat bình phương
Mình chạy cả hai trên go-lab:

Hình 2: Kết quả thật. (A) Client streaming: client gửi 5 chunk (1024, 2048, 512, 4096, 256 byte), server nhận đủ 5 chunk và trả tổng 7936 byte trong một response (1024+2048+512+4096+256=7936). (B) Bidirectional: client gửi 3→nhận 9, gửi 5→nhận 25, gửi 8→nhận 64, gửi 10→nhận 100 — xen kẽ, server trả ngay từng số.
Đọc kết quả:
- Client streaming — 5 chunk → 7936 byte: client đẩy 5 chunk kích thước khác nhau, server
Recv()từng cái, cộng dồn, rồi trả mộtUploadResultvới tổng 7936 byte và count 5 — đúng tổng số học. Đây là mô hình upload: client không gửi cả file trong một message khổng lồ (có thể vượt giới hạn message size của gRPC, mặc định 4MB), mà xé nhỏ thành chunk và stream. Server gộp lại và trả kết quả (ví dụ checksum, số byte, id file đã lưu). - Bidirectional — gửi số, nhận bình phương ngay: client gửi 3, server trả 9 ngay; client gửi 5, server trả 25 ngay... Quan trọng: server không chờ client gửi hết 4 số rồi mới trả — nó phản hồi từng cái khi nhận. Hai luồng (client→server và server→client) chạy độc lập, xen kẽ. Đây chính là tính realtime hai chiều: như một cuộc hội thoại, không phải một cặp request-response.
Thông điệp cốt lõi: client streaming cho N→1 (upload, gộp), bidirectional cho đối thoại thật sự (realtime, chat, game) — và bidi đòi hỏi tư duy đồng thời (concurrent), không phải tuần tự.
Vì sao bidi không chỉ là "hai server streaming"
Nhìn qua, bidi giống như ghép client streaming và server streaming. Nhưng điểm khác biệt sâu là hai luồng độc lập về thời gian. Trong bidi, server có thể gửi response thứ 3 trước khi client gửi request thứ 5 — không có ràng buộc "mỗi request một response" hay thứ tự cố định. Điều này cho phép các mẫu mà request-response không làm được: server chủ động đẩy thông báo trong khi client đang gửi (ví dụ server báo "giá vừa đổi" giữa lúc client đang đặt lệnh). Chính sự bất đồng bộ giữa hai hướng là giá trị thật của bidi — và cũng là lý do nó cần goroutine riêng và tư duy đồng thời.
Đánh đổi cần cân nhắc
Deadlock là rủi ro thật, không phải lý thuyết. Bẫy gửi-rồi-nhận cùng goroutine làm treo cả hai bên, và nó không báo lỗi — chỉ treo im lặng cho tới timeout. Với bidi, luôn tách nhận vào goroutine riêng (hoặc dùng select/channel), và cẩn thận với luồng điều khiển. Đây là lớp phức tạp đồng thời mà unary/client-streaming không có — chỉ dùng bidi khi thật sự cần hai chiều độc lập, đừng dùng cho thứ request-response làm được.
Client streaming khó retry và khó cân bằng tải. Một upload stream dở (gửi 3/5 chunk rồi mất kết nối) không retry sạch được — phải gửi lại từ đầu hoặc server phải hỗ trợ resume. Và vì một stream gắn với một kết nối tới một server, load balancer không thể rải từng chunk sang server khác (khác unary mỗi call rải được). Stream dài = ít linh hoạt về phục hồi và cân bằng tải. Với upload quan trọng, cân nhắc chia thành nhiều unary call có đánh số + idempotency.
Message size vẫn có giới hạn — chunk đúng cỡ. Streaming giúp vượt giới hạn tổng kích thước (gửi 1GB qua nhiều chunk), nhưng mỗi message vẫn bị giới hạn (mặc định 4MB). Chunk quá to vẫn lỗi; chunk quá nhỏ thì overhead framing cao. Cỡ chunk hợp lý thường 16KB–1MB tuỳ mạng. Streaming không xoá giới hạn message, nó chỉ cho bạn nhiều message.
Ba ý mang về
- Client streaming gộp N→1, hợp upload và tổng hợp: đo thật client gửi 5 chunk, server gộp trả đúng 7936 byte trong một response — client xé nhỏ file thành chunk thay vì một message khổng lồ, vượt giới hạn tổng kích thước.
- Bidirectional là đối thoại hai chiều độc lập: đo thật gửi 3→nhận 9, 5→25, 8→64, 10→100 xen kẽ, server trả ngay từng cái không chờ gửi hết — hai luồng bất đồng bộ cho realtime/chat, thứ request-response không làm được.
- Bidi cần tư duy đồng thời, streaming khó retry/cân bằng tải: luôn tách nhận vào goroutine riêng (gửi+nhận cùng goroutine → deadlock im lặng); stream dài khó retry sạch và không rải tải được như unary; và mỗi message vẫn có giới hạn kích thước — chunk đúng cỡ.
Nguồn
- gRPC docs — Client/Bidirectional streaming RPC: https://grpc.io/docs/what-is-grpc/core-concepts/
- gRPC Go — Basics tutorial: https://grpc.io/docs/languages/go/basics/
- gRPC docs — Max message size & keepalive: https://grpc.io/docs/guides/performance/
Phần sau ta thêm middleware cho gRPC: interceptor — một lớp bọc mọi RPC để log, đo thời gian, xác thực mà không sửa logic từng handler, chạy thật một interceptor đo độ trễ mỗi call.