Load balancing: como distribuir requisição entre várias instâncias

Entenda por que uma única instância de servidor tem limite, como um load balancer distribui requisição entre várias, as estratégias mais comuns (round-robin, least connections) e o problema real da sticky session.


Já falei aqui sobre fila de mensagem, uma forma de lidar com processamento pesado sem travar a requisição, e sobre CI/CD, automatizando o deploy. Load balancing resolve um problema diferente dos dois: o que fazer quando uma instância de servidor sozinha já não dá conta do volume de requisição que está chegando.

O limite de uma instância só

Todo servidor tem um teto de quanto consegue processar ao mesmo tempo, limitado por CPU, memória e quantidade de conexão simultânea que consegue segurar. Enquanto o tráfego do sistema fica abaixo desse teto, uma instância sozinha resolve bem. O problema aparece quando o volume cresce além do que essa instância aguenta: requisição começa a demorar mais pra responder, depois começa a falhar, e em algum ponto a instância pode até cair.

A saída mais direta não é procurar uma máquina cada vez mais potente (isso tem limite físico e fica caro rápido), é rodar várias instâncias do mesmo sistema em paralelo, e distribuir a requisição entre elas. É exatamente esse o papel do load balancer: ficar na frente de várias instâncias e decidir, a cada requisição que chega, pra qual delas mandar.

                    ┌─→ instância 1
usuário → load balancer ─→ instância 2
                    └─→ instância 3

Do ponto de vista de quem faz a requisição, existe só um endereço, o do load balancer. A decisão de qual instância realmente processa aquela chamada fica escondida por trás dele.

Round-robin: a estratégia mais simples

A forma mais direta de distribuir é o round-robin: a primeira requisição vai pra instância 1, a segunda pra instância 2, a terceira pra instância 3, e a quarta volta pra instância 1, girando em ciclo. Simples de implementar e funciona bem quando todas as instâncias têm capacidade parecida e as requisições demoram um tempo parecido entre si.

upstream backend {
  server instancia1:3000;
  server instancia2:3000;
  server instancia3:3000;
}

Com o Nginx configurado como load balancer, esse bloco upstream já distribui em round-robin por padrão, sem precisar de configuração extra.

Least connections: quando as requisições variam muito

Round-robin assume que todas as requisições custam praticamente o mesmo. Isso quebra quando algumas demoram bem mais que outras: se a instância 1 pegou uma requisição pesada e ainda está processando ela, round-robin continua mandando novas requisições pra ela do mesmo jeito, na vez certa do ciclo, mesmo que ela já esteja sobrecarregada.

A estratégia least connections resolve isso olhando pra carga real de cada instância no momento, mandando a próxima requisição pra quem está com menos conexão ativa naquele instante, em vez de seguir uma ordem fixa e cega:

upstream backend {
  least_conn;
  server instancia1:3000;
  server instancia2:3000;
  server instancia3:3000;
}

O problema real: sticky session

Aqui mora a armadilha que mais gente esbarra na primeira vez que configura load balancing. Se o sistema guarda o estado de sessão do usuário na memória da própria instância (aquele modelo de sessão que já expliquei no post sobre JWT versus sessão), a instância 1 sabe quem é aquele usuário logado, mas a instância 2 não sabe nada sobre essa sessão, porque cada instância tem sua própria memória, isolada das outras.

Se a primeira requisição de um login vai pra instância 1, e a próxima requisição do mesmo usuário é distribuída pra instância 2, o sistema simplesmente não reconhece esse usuário como logado, mesmo ele tendo acabado de fazer login segundos atrás.

Uma solução paliativa é a sticky session: o load balancer lembra qual instância atendeu aquele usuário na primeira vez, e força todas as próximas requisições dele pra sempre irem na mesma instância. Isso resolve o sintoma, mas cria um problema novo: se aquela instância específica cair, o usuário perde a sessão inteira, e a distribuição de carga deixa de ser realmente equilibrada, porque um usuário fica preso numa instância só, mesmo que outra esteja bem mais livre.

A solução real: tirar o estado da instância

O jeito mais robusto de resolver isso não é forçar sticky session, é parar de guardar estado na memória de cada instância. Guardando a sessão num lugar compartilhado entre todas elas, como Redis, qualquer instância consegue atender qualquer usuário, porque nenhuma delas depende de memória local pra saber quem está logado:

                    ┌─→ instância 1 ─┐
usuário → load balancer ─→ instância 2 ─┼─→ Redis (sessões compartilhadas)
                    └─→ instância 3 ─┘

Isso é o que se chama de instância stateless: qualquer requisição pode ser atendida por qualquer instância, sem depender de qual delas atendeu a requisição anterior. É esse desenho que permite escalar de verdade, adicionando ou removendo instância livremente, sem se preocupar em qual delas cada usuário "pertence".

Fechando

Load balancing resolve o problema de uma instância só não dar conta do volume de requisição, mas a estratégia de distribuição sozinha não é o ponto mais importante, é o desenho de estado por trás que determina se isso funciona bem de verdade. Sticky session é um remendo que funciona, mas limita o quanto o sistema escala de forma equilibrada. Tirar o estado da instância, guardando ele num lugar compartilhado, é o que permite qualquer instância atender qualquer requisição, sem exceção.

Direto na sua
caixa de entrada.

Um aviso por e-mail sempre que eu publicar um post novo. Sem spam, sem newsletter chata, só isso.