Bài trước cho thấy bốn kiểu RPC; bài này đào sâu kiểu hữu dụng nhất sau unary: server streaming. Câu hỏi thực tế: khi nào nên trả một danh sách bằng một response unary, khi nào nên stream từng phần tử? Nhiều người nghĩ "streaming để nhanh hơn" — nhưng đó là hiểu lầm cần đo để làm rõ. Streaming không làm tổng thời gian nhanh hơn; nó thắng ở ba thứ khác: độ trễ tới phần tử đầu tiên, bộ nhớ, và xử lý song song. Bài này (phần 4 loạt gRPC nâng cao) đo thật khác biệt đó để bạn biết chính xác streaming cho gì và không cho gì.
Vấn đề: dữ liệu sinh dần, client chờ gì?
Hình dung một endpoint trả danh sách N phần tử, mỗi phần tử tốn thời gian để tạo — query database, gọi service khác, tính toán. Có hai cách:
- Unary: server tạo hết N phần tử, gom vào một list, rồi trả một response. Client phải chờ tới khi phần tử cuối cùng tạo xong mới nhận được bất cứ gì.
- Server streaming: server tạo từng phần tử và
Send()ngay khi xong, không chờ gom. Client nhận và xử lý từng cái khi nó tới — thấy phần tử đầu tiên ngay sau khi nó được tạo.
// UNARY — gom HẾT rồi trả 1 lần
for i:=0; i<N; i++ {
time.Sleep(50*ms) // tạo item (query/tính)
list = append(list, item)
}
return &ItemList{Items: list} // client chờ CẢ N mới nhận
// STREAM — đẩy TỪNG item ngay khi tạo xong
for i:=0; i<N; i++ {
time.Sleep(50*ms)
stream.Send(item) // client nhận NGAY từng cái
}

Hình 1: Unary gom hết N item (mỗi cái tốn 50ms tạo) rồi trả một ItemList — client chờ cả N. Server streaming Send() từng item ngay khi tạo xong — client nhận và xử lý ngay từng cái. Khác biệt nằm ở khi nào client thấy phần tử đầu tiên.
Đo thật: item đầu tiên 58ms vs 529ms
Mình chạy cả hai trên go-lab, server tạo 10 item mỗi cái 50ms, đo thời gian tới item đầu tiên và tổng:

Hình 2: Kết quả thật. Unary: item đầu tiên sau 529ms (phải chờ cả 10 item), tổng 529ms. Stream: item đầu tiên sau 58ms (ngay sau item đầu được tạo), tổng 533ms. Time-to-first-item nhanh hơn 9×, nhưng tổng thời gian gần như bằng nhau.
Đọc kết quả:
- Unary: item đầu tiên = 529ms: client không nhận được gì cả cho tới khi server gom đủ 10 item (10 × 50ms ≈ 500ms) rồi mới gửi. Trong suốt nửa giây đó, client ngồi chờ, màn hình trống.
- Stream: item đầu tiên = 58ms: server đẩy item đầu ngay sau khi tạo xong (~50ms + overhead). Client nhận và có thể hiển thị/xử lý ngay, trong khi server vẫn đang tạo 9 item còn lại. Nhanh hơn 9 lần về time-to-first-item.
- Tổng thời gian: 529 vs 533ms — gần như bằng nhau: đây là điểm phải nói thẳng. Streaming không làm tổng nhanh hơn — server vẫn tốn cùng 10×50ms để tạo hết. Thực ra stream còn chậm hơn chút xíu (533 vs 529ms) vì overhead gửi nhiều message. Nếu bạn chọn streaming vì nghĩ nó "nhanh hơn", bạn hiểu sai.
Vậy streaming thắng ở đâu? Ba thứ: (1) time-to-first-item — user thấy kết quả sớm 9×, trải nghiệm tốt hơn hẳn dù tổng bằng nhau; (2) bộ nhớ — server không cần gom cả danh sách vào RAM (quan trọng với danh sách triệu phần tử); (3) xử lý song song — client bắt đầu làm việc với item đầu trong khi server còn tạo item sau, pipelining thật sự.
Vì sao time-to-first-item quan trọng hơn tổng
Với trải nghiệm người dùng, item đầu tiên đến khi nào thường quan trọng hơn tất cả đến khi nào. Một trang tìm kiếm hiện kết quả đầu sau 58ms rồi điền dần cảm thấy nhanh, dù mất 533ms để đủ 10 kết quả. Một trang trắng 529ms rồi hiện tất cả cùng lúc cảm thấy chậm, dù tổng bằng nhau. Đây là nguyên tắc UX đã biết (perceived performance), và server streaming là công cụ kỹ thuật để đạt nó: bắt đầu trả dữ liệu ngay khi có phần đầu, thay vì chờ hoàn tất. Netflix stream video, Google đổ kết quả tìm kiếm dần — cùng nguyên lý.
Đánh đổi cần cân nhắc
Streaming thêm phức tạp mà không phải lúc nào cũng đáng. Nếu N nhỏ và tạo nhanh (trả 5 item có sẵn trong RAM), unary đơn giản hơn và time-to-first-item gần như bằng nhau — streaming chỉ thêm code quản lý stream mà không lợi gì. Streaming đáng khi: N lớn, mỗi item tốn thời gian tạo, hoặc client cần phản hồi sớm. Với danh sách nhỏ tạo tức thì, dùng unary.
Client phải xử lý được từng phần — không phải lúc nào cũng dễ. Streaming chỉ lợi nếu client làm được gì đó với từng item khi nó tới (hiển thị, xử lý, ghi). Nếu client bắt buộc cần cả danh sách trước khi làm gì (ví dụ phải sắp xếp toàn bộ, hoặc tính tổng rồi mới hiển thị), thì streaming không cho lợi về trải nghiệm — client vẫn phải gom đủ. Trong trường hợp đó, unary (hoặc stream rồi gom lại) đơn giản hơn.
Tổng thời gian và băng thông có thể tệ hơn chút. Stream gửi nhiều message nhỏ (mỗi item một frame HTTP/2), có overhead header/framing hơn một response lớn. Với mạng thật và item nhỏ, tổng byte và tổng thời gian stream có thể nhỉnh hơn unary. Streaming tối ưu cho độ trễ cảm nhận và bộ nhớ, không phải throughput thô — đừng dùng nó để giảm tổng thời gian, vì nó không làm thế.
Ba ý mang về
- Streaming thắng ở time-to-first-item, không phải tổng: đo thật item đầu tiên stream 58ms vs unary 529ms (nhanh 9×), nhưng tổng gần bằng nhau (533 vs 529ms) — server vẫn tốn cùng thời gian tạo, stream chỉ cho client thấy sớm.
- Ba lợi ích thật: độ trễ item đầu, bộ nhớ, song song: client thấy kết quả sớm 9× (trải nghiệm tốt hơn), server không gom cả list vào RAM, và client xử lý item đầu trong khi server còn tạo — pipelining.
- Dùng đúng chỗ, không phải để "nhanh hơn": streaming đáng khi N lớn/item tốn thời gian/client xử lý được từng phần; với list nhỏ tạo tức thì hoặc client cần cả danh sách trước, unary đơn giản hơn — và streaming có thể tốn tổng/băng thông nhỉnh hơn.
Nguồn
- gRPC docs — Server streaming RPC: https://grpc.io/docs/what-is-grpc/core-concepts/#server-streaming-rpc
- gRPC Go — Basics: server-side streaming: https://grpc.io/docs/languages/go/basics/
- Ilya Grigorik — High Performance Browser Networking (perceived performance)
Phần sau ta chạy thật hai kiểu streaming còn lại — client streaming (upload luồng, gộp) và bidirectional (chat hai chiều) — và đo cách chúng xử lý luồng dữ liệu liên tục.