Ở 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 } }

Ảnh chụp đoạn mã nền tối minh hoạ hiệu năng streaming và flow control vì sao một stream đè bẹp nhiều unary call, mỗi unary call tốn overhead thiết lập cộng round-trip riêng streaming trả cả loạt message trên một kết nối đang mở. N unary call mỗi call là một vòng round-trip riêng mỗi lần gọi mở stream HTTP/2 mới gửi header và chờ response rồi mới gọi tiếp for i 0 i nhỏ hơn N item bằng c.GetOne ctx req round-trip tuần tự gửi chờ nhận N lần thiết lập call cộng N lần chờ round-trip overhead cộng dồn. 1 server-stream gửi cả loạt không chờ từng cái mở một stream server đẩy N message liên tiếp client đọc liên tục stream bằng c.GetStream ctx Req N for item err bằng stream.Recv if err io.EOF break 1 lần thiết lập không chờ round-trip giữa các message nhanh hơn rất nhiều. Flow control HTTP/2 tự điều tiết backpressure HTTP/2 có window bên nhận báo nó sẵn sàng nhận bao nhiêu byte consumer đọc chậm window đầy Send phía server tạm chặn stream tự chậm lại theo bên yếu nhất không làm tràn bộ nhớ

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:

Ảnh chụp bảng kết quả đo thật 50.000 message stream vs unary output thật go-lab grpc-go v1.67.1 localhost payload khoảng 100 byte. Tổng thời gian gửi nhận 50.000 message, 50.000 UNARY call riêng lẻ 2,06 giây 24.253 msg mỗi giây, 1 SERVER-STREAM 50.000 msg 33,6 ms 1.488.147 msg mỗi giây, streaming nhanh gấp khoảng 61 lần unary, cùng 50.000 message gọi unary từng cái mất 2,06 giây mỗi call tốn thiết lập cộng một round-trip tuần tự còn một server-stream chỉ 33,6 mili-giây streaming khử phần lớn overhead per-call nên với dữ liệu dạng luồng nó nhanh hơn hàng chục lần đo một luồng trên localhost mạng thật round-trip lớn hơn thì khoảng cách còn rộng hơn vì unary chờ round-trip mỗi call

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ề

  1. 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.
  2. Flow control cho streaming vừa nhanh vừa an toàn. HTTP/2 window tạo backpressure: consumer đọc chậm thì Send phí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.
  3. 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

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.