Bạn có một service gRPC chạy 3 instance để chịu tải, đặt một load balancer phía trước — và ngạc nhiên khi thấy một instance gánh gần hết traffic còn hai cái kia gần như nhàn rỗi. Đây là một trong những bất ngờ phổ biến nhất khi đưa gRPC lên production, và nguyên nhân nằm sâu trong cách gRPC dùng HTTP/2. Load balancer kiểu cũ (dành cho HTTP/1) cân tải ở mức kết nối, nhưng gRPC mở một kết nối HTTP/2 lâu dài và gửi hàng nghìn request qua đó — nên LB chỉ thấy một kết nối và ghim nó vào một backend. Giải pháp là cân tải ở mức RPC. Bài này (phần 10/12) đo thật client-side round-robin trong go-lab.
Vì sao LB mức kết nối không chia đều gRPC
Với HTTP/1, mỗi request thường dùng một kết nối (hoặc một pool kết nối ngắn), nên một L4 load balancer chia kết nối đều tay là chia request đều tay — tự nhiên. gRPC thì khác: nó chạy trên HTTP/2, nơi một kết nối duy nhất ghép (multiplex) nhiều request song song. Client mở một kết nối tới server và gửi tất cả request qua đó trong suốt vòng đời.
Hệ quả: nếu đặt một L4 balancer (cân bằng theo kết nối TCP) phía trước, nó thấy một kết nối từ client và ghim toàn bộ vào một backend. 10.000 request của bạn dồn vào một instance, hai instance kia ngồi không.

Hình 1: HTTP/1 chia theo kết nối nên LB L4 tự cân bằng; gRPC giữ một kết nối HTTP/2 lâu dài nên LB L4 ghim mọi request vào một backend. Giải pháp: cân tải ở mức RPC — client-side round-robin (resolver liệt kê backend + round_robin policy) hoặc proxy L7.
Có hai cách giải quyết đúng:
- Client-side: client tự biết danh sách địa chỉ backend (qua một name resolver), mở kết nối tới tất cả, và áp chính sách round-robin — mỗi RPC xoay sang backend kế tiếp.
- Proxy L7: đặt một proxy hiểu HTTP/2 (Envoy, nginx, linkerd) ở giữa; nó chia theo RPC chứ không theo kết nối.
conn, _ := grpc.NewClient("lb:///pingservice",
grpc.WithResolvers(r), // r trả 3 địa chỉ: :60001, :60002, :60003
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
)
Đo thật: 300 RPC chia cho 3 server
Mình chạy 3 server instance (server-A/B/C ở ba cổng), cấu hình client với manual resolver liệt kê cả ba + round_robin, rồi gọi 300 RPC và đếm mỗi server nhận bao nhiêu:

Hình 2: Đo thật. 300 RPC qua client-side round-robin: server-A nhận 100 (33,3%), server-B 99 (33,0%), server-C 101 (33,7%) — chia gần như hoàn hảo N/3. Cân bằng ở mức từng RPC, không phải mức kết nối.
Kết quả thật:
- server-A: 100 call (33,3%)
- server-B: 99 call (33,0%)
- server-C: 101 call (33,7%)
Chia gần như hoàn hảo — ~100 call mỗi server, đúng N/3. Mấu chốt: client giữ kết nối tới cả ba backend (nhờ resolver trả về cả ba địa chỉ) và xoay vòng từng RPC. Cân bằng xảy ra ở mức từng lời gọi, không phải mức kết nối. Nếu thay bằng một L4 balancer đứng trước, toàn bộ 300 call sẽ dồn vào một server (client chỉ mở một kết nối tới balancer). Đây chính là lý do gRPC cần LB phía client hoặc proxy L7.
Đánh đổi cần cân nhắc
Client-side LB cần client biết danh sách backend — và cập nhật được. Demo dùng danh sách tĩnh, nhưng production backend lên/xuống liên tục (autoscale, rolling deploy). Cần một name resolver động: DNS (trả nhiều A record), hoặc service discovery (Consul, etcd, xDS từ Envoy/Istio). Resolver phải cập nhật khi backend thay đổi, nếu không client gửi call tới instance đã chết. Đây là phần phức tạp thật của client-side LB.
Proxy L7 đơn giản hoá client nhưng thêm một hop. Dùng Envoy/linkerd làm proxy L7 nghĩa là client chỉ cần biết một địa chỉ (proxy), proxy lo phân phối RPC — client "ngu", dễ triển khai đa ngôn ngữ. Đổi lại: thêm một network hop (độ trễ), thêm một thành phần phải vận hành và chịu lỗi. Service mesh (Istio, linkerd) chính là cách đóng gói mô hình này. Chọn client-side cho hiệu năng tối đa (ít hop), proxy L7 cho đơn giản và tính năng (observability, retry, circuit-break tập trung).
round_robin không phải luôn tối ưu — có các policy khác. Chia đều số call không có nghĩa chia đều tải nếu các request nặng nhẹ khác nhau, hay backend khoẻ yếu khác nhau. gRPC có các policy khác (pick_first, hoặc weighted/least-request qua xDS). Với request đồng đều thì round-robin đủ tốt; với tải lệch, cân nhắc policy thông minh hơn — nhưng đừng tối ưu sớm khi round-robin đã giải quyết 90% trường hợp.
Ba ý mang về
- gRPC cần cân tải ở mức RPC, không phải mức kết nối. Vì HTTP/2 giữ một kết nối lâu dài ghép nhiều request, một L4 balancer sẽ ghim toàn bộ vào một backend. Đây là bẫy phổ biến khi đưa gRPC lên production.
- Client-side round-robin chia đều từng call. Đo thật: 300 RPC qua client-side round-robin chia 100/99/101 giữa 3 server (~33% mỗi cái). Client giữ kết nối tới mọi backend và xoay vòng từng RPC nhờ resolver +
round_robinpolicy. - Chọn giữa client-side và proxy L7 theo nhu cầu. Client-side cho ít hop/hiệu năng cao nhưng cần resolver động theo dõi backend; proxy L7 (Envoy/service mesh) đơn giản hoá client, đa ngôn ngữ, thêm tính năng tập trung nhưng thêm một hop. Và round-robin đủ cho tải đồng đều — policy thông minh hơn chỉ khi tải thực sự lệch.
Nguồn
- gRPC — Load Balancing (blog chính thức): https://grpc.io/blog/grpc-load-balancing/
- gRPC Go — Load balancing & name resolver: https://github.com/grpc/grpc-go/blob/master/examples/features/load_balancing/README.md
- gRPC — Custom Name Resolution: https://grpc.io/docs/guides/custom-name-resolution/
Phần sau ta sang các tính năng production: health check để orchestrator biết service sống, server reflection để gọi thử không cần .proto, và graceful shutdown để không rớt request khi deploy.