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.

Ảnh chụp đoạn mã nền tối minh hoạ load balancing phía client vì sao gRPC khác HTTP/1, gRPC giữ một kết nối HTTP/2 lâu dài LB mức kết nối L4 sẽ dồn mọi request vào một backend phải cân tải ở mức RPC. Cạm bẫy LB L4 mức kết nối không chia đều gRPC HTTP/1 mỗi request thường một kết nối LB L4 chia đều tự nhiên HTTP/2 gRPC client mở 1 kết nối gửi hàng nghìn request qua đó client một kết nối lâu dài chỉ backend một 2 3 ngồi không LB L4 thấy 1 kết nối ghim vào 1 backend mất cân bằng. Giải pháp LB mức RPC phía client round-robin resolver liệt kê nhiều địa chỉ backend client mở kết nối tới tất cả conn bằng grpc.NewClient lb pingservice grpc.WithResolvers r r trả 3 địa chỉ 60001 60002 60003 grpc.WithDefaultServiceConfig loadBalancingConfig round_robin mỗi RPC xoay vòng sang backend kế chia đều ở mức từng call. Hai cách triển khai LB cho gRPC một client-side client tự biết danh sách backend cộng round-robin hai proxy L7 một proxy hiểu HTTP/2 Envoy nginx linkerd chia RPC L7 proxy chia theo RPC chứ không theo kết nối cân bằng đú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:

  1. 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.
  2. 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:

Ảnh chụp bảng kết quả đo thật 300 RPC chia cho 3 server round-robin output thật go-lab grpc-go v1.67.1 round_robin 3 server 3 cổng. Số call mỗi server nhận được server-A 100 call 33,3 phần trăm server-B 99 call 33,0 phần trăm server-C 101 call 33,7 phần trăm, 300 RPC chia gần như hoàn hảo khoảng 100 call mỗi server N chia 3 client giữ kết nối tới cả ba backend và xoay vòng từng RPC cân bằng ở mức từng lời gọi không phải mức kết nối nếu để LB L4 mức kết nối đứng trước cả 300 call sẽ dồn vào một server vì gRPC chỉ mở một kết nối HTTP/2 lâu dài đó là lý do gRPC cần LB phía client hoặc proxy L7

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ề

  1. 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.
  2. 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_robin policy.
  3. 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

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.