Load balancer: algoritmos y cuándo usarlos
1. Descripción general
Cuando tu sistema tiene múltiples instancias en el backend, la pregunta siempre es: ¿a qué instancia debe llegar la nueva solicitud? Este artículo explica los algoritmos de load balancing más comunes, cómo funcionan a través de código Java simulado, y los criterios para elegir el algoritmo adecuado para cada tipo de carga. Después de leerlo, sabrás cuándo es suficiente usar Round Robin, cuándo necesitas Least Connections o Consistent Hashing.
2. Qué es un load balancer
Un load balancer (LB) es el componente que se sitúa entre el cliente y un grupo de servidores (backend pool), encargado de distribuir las solicitudes de modo que:
- Ningún servidor se sobrecargue mientras otro está inactivo.
- El sistema pueda tolerar que un backend falle (failover).
- Se pueda escalar horizontalmente (añadir/eliminar instancias) sin que el cliente lo sepa.
El LB puede ser software (Nginx, HAProxy, Envoy), hardware (F5) o un servicio gestionado (AWS ALB/NLB, GCP Load Balancer). Por el nivel en el que opera, el LB se divide en dos tipos principales:
- Capa 4 (capa de transporte): enrutamiento basado en IP/puerto, sin leer el contenido HTTP. Rápido, bajo overhead.
- Capa 7 (capa de aplicación): puede leer headers, path, cookies… por lo que el enrutamiento es más inteligente (por ejemplo, enrutar según la ruta
/api/orders) pero consume más CPU.
Nota: Elegir la capa equivocada es un error de diseño común. Si necesitas enrutar según la ruta de URL o sesiones sticky basadas en cookies, la Capa 4 no puede hacerlo — debes usar la Capa 7.
3. Algoritmos de load balancing más comunes
3.1. Round Robin
Las solicitudes pasan por orden a través de cada servidor de forma cíclica, volviendo al principio cuando se agota la lista.
public class RoundRobinBalancer {
private final List<String> servers;
private final AtomicInteger index = new AtomicInteger(0);
public RoundRobinBalancer(List<String> servers) {
this.servers = servers;
}
public String nextServer() {
int i = index.getAndIncrement() % servers.size();
return servers.get(Math.abs(i));
}
}
getAndIncrement incrementa el contador de forma segura entre múltiples threads, % servers.size() mantiene el índice dentro del rango válido. Con 3 servidores [A, B, C], llamadas sucesivas a nextServer() producen:
A, B, C, A, B, C, ...
Round Robin es simple, no necesita conocer el estado del servidor. Desventaja: si las solicitudes tienen pesos diferentes (una solicitud toma 1ms, otra 500ms), el servidor que procesa solicitudes pesadas tiende a sobrecargarse más con el tiempo.
3.2. Weighted Round Robin
Cada servidor tiene un “peso” que refleja su capacidad de procesamiento (CPU, RAM). Los servidores más potentes reciben más solicitudes.
public class WeightedRoundRobinBalancer {
private record WeightedServer(String name, int weight) {}
private final List<WeightedServer> servers;
private int currentIndex = -1;
private int currentWeight = 0;
public WeightedRoundRobinBalancer(Map<String, Integer> weightedServers) {
this.servers = weightedServers.entrySet().stream()
.map(e -> new WeightedServer(e.getKey(), e.getValue()))
.toList();
}
public synchronized String nextServer() {
int maxWeight = servers.stream().mapToInt(WeightedServer::weight).max().orElse(1);
int gcdWeight = servers.stream().mapToInt(WeightedServer::weight)
.reduce((a, b) -> java.math.BigInteger.valueOf(a).gcd(java.math.BigInteger.valueOf(b)).intValue())
.orElse(1);
while (true) {
currentIndex = (currentIndex + 1) % servers.size();
if (currentIndex == 0) {
currentWeight -= gcdWeight;
if (currentWeight <= 0) {
currentWeight = maxWeight;
}
}
if (servers.get(currentIndex).weight() >= currentWeight) {
return servers.get(currentIndex).name();
}
}
}
}
Este es el algoritmo “smooth weighted round robin” usado en Nginx: los servidores con peso alto se seleccionan más frecuentemente pero siguen distribuidos uniformemente, sin agruparse consecutivamente. Con la configuración {A: 5, B: 1, C: 1}, la secuencia de selección aproximada es:
A, A, B, A, C, A, A
3.3. Least Connections
Selecciona el servidor que tiene menos conexiones activas siendo procesadas. Adecuado cuando el tiempo de procesamiento de las solicitudes varía mucho.
public class LeastConnectionsBalancer {
private final Map<String, AtomicInteger> activeConnections = new ConcurrentHashMap<>();
public LeastConnectionsBalancer(List<String> servers) {
servers.forEach(s -> activeConnections.put(s, new AtomicInteger(0)));
}
public String nextServer() {
return activeConnections.entrySet().stream()
.min(Comparator.comparingInt(e -> e.getValue().get()))
.map(Map.Entry::getKey)
.orElseThrow();
}
public void onRequestStart(String server) {
activeConnections.get(server).incrementAndGet();
}
public void onRequestEnd(String server) {
activeConnections.get(server).decrementAndGet();
}
}
nextServer() escanea el mapa y obtiene el servidor con el menor número de conexiones. onRequestStart/onRequestEnd deben ser llamados en parejas correctas (por ejemplo, en try/finally) para que los datos no se desvíen.
Nota: Least Connections requiere que el LB mantenga estado (stateful) — consume costo adicional de sincronización si tienes múltiples instancias de LB ejecutándose en paralelo. Round Robin es completamente stateless.
3.4. Least Response Time
Variante de Least Connections que selecciona el servidor con el menor (número de conexiones × tiempo promedio de respuesta). Es más complejo pero refleja la carga real con más precisión porque considera también la latencia de procesamiento.
public record ServerStats(String name, int activeConnections, double avgResponseTimeMs) {
double score() {
return activeConnections * avgResponseTimeMs;
}
}
public String pickFastest(List<ServerStats> stats) {
return stats.stream()
.min(Comparator.comparingDouble(ServerStats::score))
.map(ServerStats::name)
.orElseThrow();
}
avgResponseTimeMs generalmente se calcula usando media móvil exponencial, actualizada después de cada respuesta. Este algoritmo es excelente para sistemas con mezcla de APIs rápidas (búsqueda en caché) y APIs lentas (reportes, cálculos pesados).
3.5. IP Hash / Consistent Hashing
Se usa cuando necesitas sesiones sticky — el mismo cliente siempre se enruta al mismo servidor (por ejemplo, el servidor mantiene la sesión en memoria, o tiene caché local).
public class ConsistentHashBalancer {
private final SortedMap<Long, String> ring = new TreeMap<>();
private static final int VIRTUAL_NODES = 100;
public ConsistentHashBalancer(List<String> servers) {
for (String server : servers) {
for (int i = 0; i < VIRTUAL_NODES; i++) {
ring.put(hash(server + "#" + i), server);
}
}
}
public String serverFor(String clientKey) {
long hash = hash(clientKey);
SortedMap<Long, String> tail = ring.tailMap(hash);
Long nodeKey = tail.isEmpty() ? ring.firstKey() : tail.firstKey();
return ring.get(nodeKey);
}
private long hash(String input) {
return input.hashCode() & 0xFFFFFFFFL;
}
}
Cada servidor se distribuye en múltiples “nodos virtuales” en un anillo hash (ring) para una distribución más uniforme. serverFor encuentra el punto más cercano en el anillo en sentido de las agujas del reloj correspondiente a la clave del cliente (generalmente IP o ID de usuario). Gran ventaja: cuando añades/eliminas un servidor, solo una pequeña parte de las claves se remapean — no se revuelve todo como con hash normal (hash(key) % n).
Prueba rápida para confirmar que la misma clave siempre enruta al mismo servidor:
@Test
void sameClientKey_alwaysRoutesToSameServer() {
ConsistentHashBalancer balancer =
new ConsistentHashBalancer(List.of("server-1", "server-2", "server-3"));
String first = balancer.serverFor("user-42");
String second = balancer.serverFor("user-42");
assertThat(first).isEqualTo(second);
}
3.6. Random y Power of Two Choices
Random simplemente selecciona al azar — la eficiencia es similar a Round Robin con un gran número de solicitudes pero sin necesidad de mantener estado. Power of Two Choices (P2C) mejora esto: selecciona aleatoriamente 2 servidores, luego toma el servidor con menos conexiones entre los 2. Se ha demostrado que este algoritmo reduce significativamente la “tail latency” comparado con random puro, sin necesidad de monitorear todo el cluster como en Least Connections.
public String pickWithPowerOfTwo(List<String> servers, Map<String, Integer> loadMap, Random random) {
String candidate1 = servers.get(random.nextInt(servers.size()));
String candidate2 = servers.get(random.nextInt(servers.size()));
return loadMap.get(candidate1) <= loadMap.get(candidate2) ? candidate1 : candidate2;
}
4. Health check y failover
El algoritmo de selección de servidor solo tiene sentido si el LB sabe qué servidores están “vivos”. Los health checks típicamente tienen dos tipos:
- Active check: el LB envía solicitudes periódicamente a un endpoint como
/health. - Passive check: el LB marca un servidor como “down” cuando recibe errores/timeouts consecutivos de solicitudes reales.
Ejemplo de configuración de active health check con Nginx:
upstream backend_pool {
server 10.0.0.1:8080 max_fails=3 fail_timeout=30s;
server 10.0.0.2:8080 max_fails=3 fail_timeout=30s;
}
max_fails=3 significa que después de 3 fallos consecutivos, el servidor se excluye del pool durante fail_timeout segundos y luego se vuelve a intentar. Sin health check, el LB sigue enviando solicitudes a un servidor muerto hasta que el cliente agota el timeout — la experiencia es muy pobre.
5. Tabla comparativa: cuándo usar cada algoritmo
| Algoritmo | Cuándo es apropiado | Cuándo evitar |
|---|---|---|
| Round Robin | Servidores idénticos, solicitudes de peso similar (REST API simple) | Servidores con diferente configuración, solicitudes de peso variable |
| Weighted Round Robin | Cluster con servidores de capacidad diferente (migración gradual a nueva instancia) | Necesidad de reaccionar según carga real en tiempo real |
| Least Connections | Solicitudes con tiempo variable de procesamiento (subida de archivos, long-polling) | Sistema con múltiples instancias de LB, alto costo de sincronización de estado |
| Least Response Time | Mezcla de APIs rápidas/lentas, necesidad de optimizar latencia global | Sistema pequeño, datos de response time insuficientes o inestables |
| IP Hash / Consistent Hashing | Necesidad de sesiones sticky, localidad de caché, sharding según cliente | Servidores que escalan frecuentemente (aunque el impacto de remapeo es menor, sigue teniendo overhead) |
| Random / P2C | Sistemas muy grandes, deseo de evitar overhead de monitoreo de estado centralizado | Necesidad de garantizar distribución absolutamente uniforme en corto plazo |
6. Errores comunes en el diseño del load balancing
- Usar Round Robin para workloads no uniforme: algunos servidores reciben todas las solicitudes pesadas mientras otros están inactivos — causa desbalance de carga silencioso, difícil de detectar mediante monitoreo agregado (CPU promedio sigue siendo “normal”).
- Sesiones sticky mediante IP Hash cuando los clientes están detrás de NAT/proxy compartido: múltiples clientes reales se agrupan en una misma IP, resultando en un servidor recibiendo carga anormalmente alta. Es mejor usar sesiones sticky basadas en cookies en Capa 7 en su lugar.
- Sin health check o health check incorrecto:
/healthdevuelve200 OKpero no verifica la conexión a la base de datos — el servidor está “vivo” según el LB pero en realidad no puede procesar solicitudes. - Ignorar connection draining al hacer scale-in: apagar un servidor inmediatamente causa pérdida de solicitudes en proceso. Debes esperar a que se completen las conexiones actuales (graceful shutdown) antes de eliminarlo del pool.
- Single point of failure en el propio LB: si solo hay una instancia de LB, se convierte en el punto débil de todo el sistema. Necesitas al menos 2 instancias de LB con DNS failover o IP virtual (keepalived).
7. Conclusión
Un load balancer no es solo “dividir solicitudes equitativamente” — elegir el algoritmo correcto depende de las características del workload: uniforme o no, necesidad de sesiones sticky o no, capacidad del sistema para monitorear estado de forma centralizada o no. Round Robin y Weighted Round Robin son apropiados para la mayoría de casos simples; Least Connections y Least Response Time manejan bien workloads variables; Consistent Hashing resuelve de forma robusta el problema de sesiones sticky y sharding cuando el cluster cambia. Más importante que el algoritmo, los health checks y failover correctos son lo que realmente determina la fiabilidad del sistema.