Load balancing cho HTTP thường đơn giản: đặt một load balancer (nginx, ALB) trước nhiều server, nó rải từng request sang server khác nhau. Nhưng với gRPC, cách này không hoạt động tốt — vì gRPC chạy trên HTTP/2 với kết nối lâu dài (long-lived): client mở một kết nối và gửi nhiều request qua nó (multiplexing). Một load balancer tầng kết nối (L4) chỉ cân bằng kết nối, nên mọi request của một client dồn vào một server, các server khác ngồi không. Lời giải của gRPC là client-side load balancing: client tự giữ kết nối tới nhiều backend và tự rải request. Bài này (phần 10 loạt gRPC nâng cao) chạy thật để thấy chính sách mặc định dồn tải thế nào và round_robin rải đều ra sao.
Hai mảnh: resolver và balancer
Client-side LB của gRPC gồm hai phần:
- Resolver: cung cấp danh sách địa chỉ backend cho client (từ DNS, service discovery, hay khai tay). Client biết có những server nào để nối.
- Balancer (chính sách LB): quyết định mỗi request đi tới backend nào. Hai chính sách phổ biến:
pick_first(mặc định — chọn server đầu tiên nối được, dùng mãi nó) vàround_robin(luân phiên đều qua mọi backend).
// RESOLVER: cung cấp danh sách backend cho client
rb := manual.NewBuilderWithScheme("lb")
rb.InitialState(resolver.State{Addresses: []...{
{Addr:"localhost:60001"}, {Addr:"localhost:60002"}, {Addr:"localhost:60003"}}})
// (A) pick_first (MẶC ĐỊNH): dùng server đầu tiên nối được
grpc.NewClient(target, grpc.WithResolvers(rb), ...)
// (B) round_robin: rải đều qua mọi backend
grpc.NewClient(target, grpc.WithResolvers(rb),
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`))

Hình 1: Client-side LB gồm resolver (cung cấp danh sách backend) và balancer (chọn backend cho mỗi request). pick_first (mặc định) dùng một server; round_robin khai qua loadBalancingConfig rải đều. Client tự rải, không cần proxy ở giữa.
Đo thật: pick_first 300/0/0 vs round_robin 100/101/99
Mình chạy 3 server gRPC trên go-lab, mỗi server có counter thật đếm số request nhận được, rồi gửi 300 request với hai chính sách:

Hình 2: Kết quả thật. (A) pick_first (mặc định): 300 request dồn hết vào server-1, server-2 và server-3 nhận 0 — một server gánh tất, hai server ngồi không. (B) round_robin: 300 request rải đều server-1=100, server-2=101, server-3=99 (~300/3).
Đọc kết quả:
- pick_first → 300/0/0: chính sách mặc định của gRPC. Client chọn server đầu tiên nối được và gửi mọi request tới nó. Kết quả: server-1 nhận cả 300, hai server kia nhận 0. Đây không phải lỗi — pick_first được thiết kế cho trường hợp "một backend, hoặc muốn dính một server". Nhưng nếu bạn tưởng cứ có nhiều backend là tự cân bằng, đây là cú sốc: mặc định không rải tải.
- round_robin → 100/101/99: đổi sang round_robin (qua
loadBalancingConfig), client luân phiên gửi request qua cả ba backend. 300 request chia đều ~100 mỗi server (chênh lệch 1-2 do thứ tự). Giờ tải mới thực sự được cân bằng — đây là điều bạn muốn cho một service nhiều instance.
Thông điệp cốt lõi: gRPC cân bằng tải ở phía client, và mặc định (pick_first) không rải tải — phải chủ động chọn round_robin. Điều này khác hẳn trực giác từ HTTP (nơi LB ở giữa tự rải). Counter server chứng minh bằng số thật: 300/0/0 vs 100/101/99.
Vì sao gRPC không dùng được L4 load balancer
Hiểu lý do giúp tránh một lỗi kiến trúc phổ biến. HTTP/1.1 mở kết nối mới cho mỗi request (hoặc dùng lại ngắn), nên một L4 LB rải kết nối ≈ rải request. gRPC trên HTTP/2 mở một kết nối lâu dài và gửi tất cả request qua nó (multiplexing — nhiều stream trên một TCP). Một L4 LB thấy một kết nối → gửi tới một server → mọi request của client đó dồn một chỗ. Đây là lý do đặt nginx L4 trước một cụm gRPC không cân bằng được tải. Giải pháp: hoặc client-side LB (bài này), hoặc một proxy hiểu gRPC/HTTP-2 (L7 như Envoy) rải theo stream chứ không theo kết nối. Biết điều này tránh được sự cố "đã có 10 server mà chỉ 1 server quá tải".
Đánh đổi cần cân nhắc
Client-side LB đẩy phức tạp sang client. Không có proxy ở giữa nghĩa là mỗi client phải: biết danh sách backend (resolver), giữ kết nối tới tất cả chúng, theo dõi cái nào sống/chết (health check), và cập nhật khi backend thêm/bớt. Với nhiều ngôn ngữ client, logic này phải lặp lại ở mỗi ngôn ngữ (dù gRPC có sẵn phần lớn). Proxy L7 (Envoy) tập trung logic đó một chỗ nhưng thêm một chặng mạng và một thành phần phải vận hành. Đây là đánh đổi kiến trúc thật: client-side (nhanh, không nút thắt, nhưng client phức tạp) vs proxy (đơn giản cho client, nhưng thêm hop và điểm hỏng).
round_robin không biết tải thực của backend. round_robin rải đều về số lượng, nhưng không biết một request nặng hay nhẹ, hay một server đang chậm. Một server yếu nhận cùng số request như server mạnh sẽ quá tải trong khi server mạnh rảnh. Với tải không đồng đều, cần chính sách thông minh hơn (weighted round robin, least-request, hoặc load-aware). round_robin là điểm khởi đầu tốt và đủ cho phần lớn trường hợp tải đồng đều, nhưng không phải lời giải cho mọi tình huống.
Resolver phải cập nhật khi backend thay đổi. Demo dùng danh sách tĩnh (khai tay 3 địa chỉ). Thực tế backend co giãn (auto-scaling thêm/bớt instance), nên resolver phải động — thường dùng DNS (gRPC re-resolve định kỳ) hoặc service discovery (Consul, etcd, xDS). Danh sách tĩnh không cập nhật là bẫy: thêm server mới mà client không biết thì nó không được dùng; server chết mà vẫn trong danh sách thì request vào đó lỗi. LB chỉ tốt bằng độ tươi của danh sách resolver cung cấp.
Ba ý mang về
- gRPC cân bằng tải ở client, mặc định KHÔNG rải tải: đo thật pick_first (mặc định) dồn cả 300 request vào server-1 (2 server nhận 0); phải chủ động chọn round_robin để rải đều (100/101/99) — khác trực giác từ HTTP.
- L4 load balancer không hợp gRPC: vì HTTP/2 dùng một kết nối lâu dài multiplexing, L4 LB dồn mọi request một server; cần client-side LB hoặc proxy L7 (Envoy) hiểu gRPC rải theo stream.
- Client-side LB đổi đơn giản-cho-client lấy không-nút-thắt: client phải biết danh sách backend (resolver động theo DNS/service discovery), giữ kết nối tới tất cả, và round_robin không biết tải thực — cân nhắc weighted/least-request cho tải không đồng đều.
Nguồn
- gRPC docs — Load balancing: https://grpc.io/docs/guides/custom-load-balancing/
- gRPC blog — gRPC Load Balancing: https://grpc.io/blog/grpc-load-balancing/
- Envoy — gRPC & HTTP/2 load balancing: https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/upstream/load_balancing/load_balancing
Phần sau ta dùng reflection để gọi service mà không cần file .proto: grpcurl liệt kê và gọi method qua reflection, công cụ debug gRPC mạnh nhất, chạy thật.