Một bộ cân bằng tải (load balancer) chia request tới cho nhiều backend để không máy nào quá tải. Nghe đơn giản, nhưng "chia" có nhiều cách, và chúng cho kết quả khác nhau đến bất ngờ khi các backend không giống hệt nhau. Bài này dựng một nginx cân bằng tải trước ba backend của riêng tôi trong container, cố tình làm một backend chậm, rồi đo chính xác từng thuật toán phân phối request ra sao — và vấp đúng cái bẫy giữa "đều" và "cân bằng".
Vài cách chia request
Có nhiều thuật toán cân bằng tải, mỗi cái trả lời câu hỏi "gửi request kế tiếp cho backend nào?" theo một cách khác:
- Round-robin: lần lượt xoay vòng — request 1 cho backend A, request 2 cho B, request 3 cho C, rồi lại A. Chia đều theo lượt.
- Least-connections: gửi tới backend đang có ít kết nối đang mở nhất — tức backend rảnh nhất tại thời điểm đó.
- Weighted: chia theo trọng số cấu hình, ví dụ 3:1:1 nghĩa là backend A nhận gấp ba B và C.
- IP hash: băm địa chỉ client để cùng một client luôn tới cùng một backend (dính phiên).
Tôi đặt ba backend b1, b2, b3, trong đó b1 chậm (mỗi request mất 80 mili giây) còn b2, b3 trả lời tức thì — rồi bắn 60 request đồng thời qua nginx và đếm mỗi backend nhận bao nhiêu.
Đo: cùng số request, khác hẳn cách chia
| Thuật toán | b1 (chậm) | b2 | b3 |
|---|---|---|---|
| Round-robin | 20 | 20 | 20 |
| Least-connections | 2 | 29 | 29 |
| Weighted 3:1:1 | 36 | 12 | 12 |
Ba dòng này là ba triết lý khác nhau. Round-robin chia tăm tắp 20/20/20 — mỗi backend cùng số lượt, kể cả b1 chậm. Least-connections thì lệch hẳn: b1 chỉ nhận 2 request, còn b2 và b3 nhận 29 mỗi cái. Weighted 3:1:1 chia theo đúng tỷ lệ cấu hình: 36/12/12.
Cái đáng chú ý là least-connections. Nó không đếm lượt; nó nhìn số kết nối đang mở tới mỗi backend. Vì b1 chậm, các kết nối tới nó giữ mở lâu (80 ms mỗi cái), nên tại mỗi thời điểm b1 luôn có nhiều kết nối "đang bận" hơn — và least-connections thấy vậy nên tránh nó, dồn request cho b2, b3 đang rảnh. Kết quả: backend chậm gần như được để yên, và cả lô hoàn thành nhanh hơn.
Một lần tôi đo hớ: "đều" không phải "cân bằng"
Nhìn dòng round-robin ra 20/20/20 tăm tắp, phản ứng đầu tiên của tôi là khen: "phân phối hoàn hảo, tải cân bằng tuyệt đối". Đó là lúc tôi lẫn hai khái niệm khác nhau.
20/20/20 là đều theo lượt, không phải theo tải. Backend b1 chậm gấp bội hai backend kia, nhưng round-robin vẫn nhét cho nó đúng 20 request — bằng đúng số mà hai backend nhanh nhận. Hậu quả: 20 request đó dồn về đúng chỗ yếu nhất, phải chờ ở backend chậm nhất, trong khi b2 và b3 xử lý phần của mình trong nháy mắt rồi ngồi không. "Đều theo đầu người" nghe công bằng, nhưng nó phân phối số lượng chứ không phân phối công việc.
Least-connections mới thật sự cân bằng tải: bằng cách nhìn số kết nối đang bận — một chỉ dấu trực tiếp của tải hiện tại — nó tự động đưa ít việc hơn cho backend đang chật vật và nhiều hơn cho backend đang rảnh. Đó là lý do b1 chỉ nhận 2 trong khi hai backend kia gánh phần lớn.
Bài học đo lường: một phân phối "đều" theo một chiều đo có thể rất lệch theo chiều đo khác. Nếu tôi chỉ đếm số request mỗi backend nhận và thấy chúng bằng nhau, tôi kết luận nhầm là tải cân bằng — trong khi thứ quan trọng (thời gian, công việc thật) lệch hẳn. Khi đo phân phối, phải hỏi rõ "chia theo cái gì" — theo lượt, theo kết nối, hay theo trọng số — chứ không dừng ở con số đếm đầu request đẹp đẽ.
Tầng 4 và tầng 7: cân bằng ở đâu
Có một khác biệt nền tảng quyết định load balancer làm được gì. Một bộ cân bằng tầng 4 (L4) làm việc ở mức kết nối TCP: nó chọn một backend khi kết nối mở ra, rồi chuyển tiếp mọi byte của kết nối đó tới đúng backend, không đọc nội dung HTTP. Rất nhanh và rẻ, nhưng nó chỉ cân bằng theo kết nối, không theo request — nếu một kết nối keep-alive gửi 100 request, cả 100 đều tới cùng một backend.
Một bộ cân bằng tầng 7 (L7, như nginx trong bài) hiểu HTTP: nó đọc từng request, nên có thể chia từng request cho backend khác nhau, định tuyến theo đường dẫn hay header, và làm least-connections đúng nghĩa ở mức request. Đổi lại nó tốn công hơn (phải phân tích HTTP, và thường kết thúc TLS). Chọn L4 khi cần thông lượng thô và cân bằng theo kết nối là đủ; chọn L7 khi cần định tuyến thông minh theo nội dung — đúng cái cho phép phân phối theo tải mà phần đo ở trên thể hiện.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là chọn thuật toán theo dạng tải. Nếu mọi request tốn công gần như nhau và mọi backend giống nhau, round-robin đơn giản và đủ tốt — chia đều theo lượt cũng là chia đều theo tải. Nhưng ngay khi request có chi phí khác nhau (một số truy vấn nặng, một số nhẹ) hoặc backend không đồng đều (máy khỏe máy yếu, một máy đang chậm vì GC hay vì quá tải), round-robin bắt đầu dồn việc sai chỗ, và least-connections thường tốt hơn hẳn vì nó phản ứng theo tải thật. Với backend khác cấu hình phần cứng, weighted cho bạn chia theo năng lực. Với phiên đăng nhập cần dính một máy, IP hash (hoặc sticky session) là lựa chọn.
Hệ quả thứ hai là một backend chậm kéo cả round-robin xuống. Vì round-robin mù quáng đưa backend ốm yếu đúng phần của nó, một máy đang trục trặc (chậm chứ chưa chết hẳn) sẽ nhận đều request và làm chậm đúng 1/N số người dùng — một sự cố khó chịu vì cân bằng tải "trông vẫn hoạt động". Đây là lý do least-connections và các health check (tự rút một backend hỏng ra khỏi vòng) quan trọng: chúng để cân bằng tải phản ứng với tình trạng thật của backend, không chỉ đếm lượt.
Hệ quả thứ ba là một bài học tư duy rộng hơn, đúng cho cả các phần trước của sê-ri: "cân bằng" luôn phải kèm câu hỏi "cân bằng theo chỉ số nào". Cân bằng số request không phải cân bằng thời gian; cân bằng thời gian trung bình không phải cân bằng đuôi trễ. Con số mang theo: round-robin chia đều theo lượt (20/20/20), least-connections chia theo tải thật nên né được backend chậm (2/29/29), weighted chia theo trọng số (36/12/12) — chọn đúng thuật toán là chọn đúng định nghĩa "cân bằng" cho tải của bạn. Một phân phối đẹp trên bảng đếm không đảm bảo tải thật được chia đều; chỉ đo đúng chỉ số mới biết.
Thử ba mươi giây
Nếu bạn có một dịch vụ sau một load balancer nhiều backend, thêm vào mỗi backend một header phản hồi ghi tên nó (ví dụ X-Backend: b2), rồi gọi dịch vụ nhiều lần bằng curl -sI https://dich-vu | grep -i x-backend và đếm phân phối. Với round-robin bạn sẽ thấy tên xoay vòng đều; với least-connections hay dưới tải thật, phân phối sẽ lệch về các backend rảnh. Thử làm một backend chậm đi (hoặc quan sát khi một máy đang bận) để thấy thuật toán nào tránh nó và thuật toán nào cứ dồn việc vào — đúng khác biệt bài này đo.