Load balancer: algoritmos e quando usar
1. Visão geral
Quando seu sistema tem muitas instâncias no backend, a pergunta é sempre a mesma: para qual instância o novo request deve ir? Este artigo explica os algoritmos de load balancing mais comuns, como funcionam através de código Java simulado, e os critérios para escolher o algoritmo certo para cada tipo de workload. Depois de ler, você saberá quando Round Robin é suficiente, quando precisa de Least Connections ou Consistent Hashing.
2. O que é load balancer
Load balancer (LB) é o componente que fica entre o cliente e um grupo de servidores (backend pool), com a responsabilidade de distribuir requests de forma que:
- Nenhum servidor fique sobrecarregado enquanto outro fica ocioso.
- O sistema aguente quando um backend falha (failover).
- Seja possível escalar horizontalmente (adicionar/remover instâncias) sem o cliente perceber.
O LB pode ser software (Nginx, HAProxy, Envoy), hardware (F5), ou um serviço gerenciado (AWS ALB/NLB, GCP Load Balancer). Quanto à posição de operação, o LB se divide em dois tipos principais:
- Layer 4 (transport layer): roteia baseado em IP/porta, não lê conteúdo HTTP. Rápido, pouco overhead.
- Layer 7 (application layer): consegue ler headers, path, cookies… então roteia de forma mais inteligente (por exemplo, rotear por path
/api/orders) mas consome mais CPU.
Atenção: Escolher a camada errada é um erro de design comum. Se você precisa rotear por URL path ou sticky session baseado em cookie, Layer 4 não consegue fazer — precisa usar Layer 7.
3. Algoritmos de load balancing comuns
3.1. Round Robin
Requests vão alternando entre cada servidor em ordem fixa, voltando ao início quando a lista acaba.
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 o contador de forma segura entre múltiplas threads, % servers.size() leva o índice de volta ao intervalo válido. Com 3 servidores [A, B, C], uma sequência de chamadas a nextServer() resulta em:
A, B, C, A, B, C, ...
Round Robin é simples, não precisa conhecer o estado do servidor. Desvantagem: se os requests têm pesos diferentes (um request processa em 1ms, outro em 500ms), o servidor processando requests pesados fica drenado mais facilmente ao longo do tempo.
3.2. Weighted Round Robin
Cada servidor tem um “peso” que reflete sua capacidade de processamento (CPU, RAM). Servidores mais fortes recebem mais requests.
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 é o algoritmo “smooth weighted round robin” usado no Nginx: servidores com peso alto são escolhidos mais frequentemente, mas ainda assim distribuídos uniformemente, não consecutivamente. Com a configuração {A: 5, B: 1, C: 1}, a sequência de seleção fica aproximadamente:
A, A, B, A, C, A, A
3.3. Least Connections
Escolhe o servidor com menos conexões ativas sendo processadas. Apropriado quando o tempo de processamento dos requests varia bastante.
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() varre o mapa e pega o servidor com menos conexões. onRequestStart/onRequestEnd precisam ser chamados em pares corretos (por exemplo, em try/finally) para que o contador não fique desalinhado.
Atenção: Least Connections requer que o LB mantenha estado (stateful) — custa mais se você tiver múltiplas instâncias de LB rodando em paralelo e precisar sincronizar. Round Robin é completamente stateless.
3.4. Least Response Time
Variação de Least Connections que escolhe o servidor com (número de conexões × tempo médio de resposta) mais baixo. Mais complexo, mas reflete a carga real com mais precisão por incluir a latência de processamento.
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 geralmente é calculado como exponential moving average, atualizado após cada response retornar. Este algoritmo é excelente para sistemas com mix de APIs rápidas (cache lookup) e lentas (relatórios, cálculos pesados).
3.5. IP Hash / Consistent Hashing
Usado quando você precisa de sticky session — o mesmo cliente sempre é roteado para o mesmo servidor (por exemplo, o servidor guarda sessão em memória, ou tem cache 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 é distribuído em múltiplos “nós virtuais” em um anel de hash (ring) para distribuição mais uniforme. serverFor encontra o ponto mais próximo no anel no sentido horário correspondente à chave do cliente (geralmente IP ou user ID). Vantagem grande: quando você adiciona/remove um servidor, apenas uma pequena parte das chaves é remapeada — não embaralha tudo como hash comum (hash(key) % n).
Teste rápido para confirmar que a mesma chave sempre vai para o mesmo 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 e Power of Two Choices
Random simplesmente escolhe aleatoriamente — tão eficaz quanto Round Robin com grande volume de requests mas não requer manutenção de estado. Power of Two Choices (P2C) melhora isso: escolhe 2 servidores aleatoriamente, depois pega o que tem menos conexões entre os 2. Este algoritmo foi provado reduzir significativamente “tail latency” comparado a random puro, sem precisar rastrear o cluster inteiro como 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 e failover
O algoritmo de escolha de servidor só funciona se o LB souber qual servidor está “vivo”. Health check normalmente tem dois tipos:
- Active check: o LB mesmo envia requests periodicamente para um endpoint como
/health. - Passive check: o LB marca um servidor como “down” quando recebe erros/timeouts consecutivos de requests reais.
Exemplo de configuração de active health check com 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 após 3 falhas consecutivas, o servidor é retirado do pool por fail_timeout segundos e depois testado novamente. Sem health check, o LB continua enviando requests para o servidor morto até o cliente sofrer timeout — experiência muito ruim.
5. Tabela de comparação: quando usar cada algoritmo
| Algoritmo | Quando é apropriado | Quando evitar |
|---|---|---|
| Round Robin | Servidores homogêneos, requests com peso similar (REST APIs simples) | Servidores com configs diferentes, requests com pesos muito variados |
| Weighted Round Robin | Cluster com servidores de capacidades diferentes (migração gradual para nova instância) | Precisa reagir à carga em tempo real |
| Least Connections | Requests com tempo de processamento muito variável (upload de arquivo, long-polling) | Múltiplas instâncias de LB, custo alto de sincronização de estado |
| Least Response Time | Mix de APIs rápidas/lentas, otimizar latência geral | Sistema pequeno, dados de response time não confiáveis |
| IP Hash / Consistent Hashing | Precisa de sticky session, locality de cache, sharding por cliente | Servidor sobe/desce frequentemente (apesar do remapeamento reduzido, ainda tem overhead) |
| Random / P2C | Sistema muito grande, quer evitar overhead de rastreamento centralizado | Precisa garantir distribuição absolutamente uniforme em curto prazo |
6. Erros comuns ao projetar load balancing
- Usar Round Robin para workload não homogêneo: alguns servidores recebem todos os requests pesados enquanto outros ficam ociosos — causa desequilíbrio de carga silencioso, difícil de detectar no monitoring geral (CPU média ainda parece “normal”).
- Sticky session por IP Hash quando cliente está atrás de NAT/proxy compartilhado: múltiplos clientes reais acabam com o mesmo IP, fazendo um servidor receber carga anormalmente alta. Use sticky session baseado em cookie em Layer 7 em vez de IP Hash neste caso.
- Sem health check ou health check errado:
/healthretorna200 OKmas não verifica conexão com banco de dados — servidor “vivo” para o LB mas na verdade não consegue processar requests. - Ignorar connection draining ao fazer scale-in: desligar servidor imediatamente descarta requests em processamento. Precisa esperar as conexões atuais terminarem (graceful shutdown) antes de remover o servidor do pool.
- Single point of failure no próprio LB: se houver apenas uma instância de LB, ela vira o ponto crítico de falha de todo o sistema. Precisa de pelo menos 2 instâncias de LB com DNS failover ou IP virtual (keepalived).
7. Conclusão
Load balancer não é só “dividir requests uniformemente” — escolher o algoritmo certo depende das características do workload: homogêneo ou não, precisa de sticky session ou não, o sistema tem capacidade de rastrear estado centralizado ou não. Round Robin e Weighted Round Robin funcionam bem para a maioria dos casos simples; Least Connections e Least Response Time lidam bem com workloads variáveis; Consistent Hashing resolve o problema de sticky session e sharding de forma robusta quando o cluster muda. Mais importante que o algoritmo, health check e failover corretos são o que realmente determina a confiabilidade real do sistema.