Blog

Sessão pegajosa no balanceador: o custo escondido de amarrar o usuário a uma instância

O time subiu quatro instâncias novas às nove da manhã de uma segunda-feira de campanha, e às nove e vinte três delas estavam com dois por cento de CPU enquanto a quinta, a antiga, atendia noventa por cento do tráfego e devolvia erro. Ninguém errou a configuração de escala: o balanceador estava fazendo exatamente o que foi mandado a fazer, que é manter cada usuário grudado na instância que o atendeu primeiro. Este artigo mostra por que a sessão pegajosa transforma capacidade em número enganoso, quais quatro custos ela cobra e quando cada um aparece, por que o problema real quase nunca é o balanceador e sim o estado que ficou no processo, como migrar para estado externo sem derrubar sessão de usuário logado, qual configuração de afinidade sobrevive a um reinício de instância e quais três alertas mostram o desbalanceamento antes do cliente reclamar.

2026-09-07 / Arquitetura / 17 min

01

O que a afinidade de sessão realmente promete e o que ela não promete

A afinidade de sessão é um mecanismo simples: o balanceador escolhe uma instância na primeira requisição de um cliente e passa a mandar todas as requisições seguintes daquele cliente para a mesma instância. A implementação varia, mas o efeito é sempre o mesmo, e a promessa também: se o processo guardou algo na memória durante a primeira requisição, esse algo continua disponível na segunda. É isso, e só isso, que a afinidade entrega.

O que ela não entrega é a parte que quebra em produção. Ela não garante que a instância continue existindo, e num ambiente com escala automática, implantação contínua e verificação de saúde, a instância deixa de existir várias vezes por dia. Ela não garante que o cliente continue sendo reconhecido, porque a identificação depende de um cookie que o cliente pode não aceitar ou de um endereço de origem que muda quando o usuário troca de rede. E ela não garante distribuição, que é justamente o motivo pelo qual o balanceador existe.

A confusão mais cara nesse assunto é tratar afinidade como um ajuste de desempenho. Ela não é. Afinidade é uma restrição de roteamento adotada para compensar estado que ficou no lugar errado, e toda vez que ela é ligada por conveniência sem que ninguém escreva o motivo, o sistema ganha uma dependência invisível entre o cliente e um processo específico. A pergunta que separa uso legítimo de dívida é objetiva: se essa instância for reiniciada agora, o que o usuário perde. Se a resposta for nada, a afinidade está sobrando. Se for alguma coisa, o problema é o estado, não o roteamento.

Mecanismo de afinidadeComo identifica o clienteOnde quebra na práticaSobrevive a reinício da instância
Cookie emitido pelo balanceadorCookie próprio, opaco para a aplicaçãoCliente que bloqueia cookie, chamada de API sem navegadorNão, o cliente é remanejado sem aviso
Cookie da aplicação usado como chaveValor de um cookie que a aplicação já defineRenovação do cookie no login troca a instância no meio do fluxoNão, e ainda pode trocar sem que a instância caia
Hash do endereço de origemEndereço IP do clienteRede móvel, saída NAT corporativa, proxy compartilhadoNão, e distribui muito mal atrás de NAT
Hash consistente por chave de aplicaçãoIdentificador de usuário ou de tenant enviado na requisiçãoExige que a chave venha em toda requisição, inclusive nas anônimasParcialmente, remaneja só a fatia da instância removida
Sem afinidade, estado externoNão precisa identificar, qualquer instância serveCusto de latência da leitura de estado a cada requisiçãoSim, é o único que sobrevive por construção

A quarta linha é a que costuma ser esquecida nas discussões, e é a mais útil quando existe uma razão legítima para manter localidade, como um cache local caro de aquecer. Hash consistente por chave de aplicação não amarra o usuário a uma instância pela ordem de chegada, e sim por uma função determinística: quando uma instância sai do conjunto, apenas a fatia dela é redistribuída, e as demais chaves continuam onde estavam. É a diferença entre remanejar cem por cento dos clientes de uma instância que caiu e remanejar exatamente os clientes que estavam nela.

02

Os quatro custos, e o dia em que cada um aparece

O custo da sessão pegajosa não é um só, e eles não chegam juntos. Cada um tem um gatilho próprio, e é por isso que a configuração parece inofensiva por meses antes de virar incidente. Separar os quatro ajuda a decidir o que é urgente e o que é dívida controlada.

  1. Escala que não escala. A instância nova entra no conjunto sem carga porque as sessões existentes continuam presas onde estão. O alívio só chega conforme as sessões antigas expiram, o que num pico significa que a capacidade adicionada chega tarde demais para o evento que motivou a adição.
  2. Carga desigual permanente. Uma instância que ficou de fora do conjunto por dez minutos durante uma implantação volta vazia e permanece relativamente vazia por horas, porque só recebe clientes novos. O gráfico de CPU médio do serviço fica saudável enquanto uma instância específica satura.
  3. Implantação que derruba usuário. Ao encerrar uma instância, todo cliente preso a ela é remanejado de uma vez. Se havia estado em memória, esse estado se perde em bloco, e o sintoma é um pico de erro ou de logout concentrado no minuto exato da implantação, que é fácil confundir com defeito da versão nova.
  4. Recuperação mais lenta do incidente. Quando uma instância degrada mas ainda passa na verificação de saúde, os clientes presos a ela continuam sendo enviados para ela. O balanceador não tira ninguém de lá, porque a afinidade tem precedência sobre a distribuição, e o incidente fica restrito a uma parcela dos usuários por tempo indefinido.
Segunda-feira de campanha, escala automatica com afinidade ligada

09h00  4 instancias, 12.000 sessoes ativas presas
       A[3000] B[3000] C[3000] D[3000]     CPU media 78%

09h05  escala automatica sobe 4 instancias novas
       A[3000] B[3000] C[3000] D[3000] E[0] F[0] G[0] H[0]
       CPU media do servico: 39%   <- metrica diz "resolvido"
       CPU de A,B,C,D:       78%   <- realidade nao mudou

09h20  sessoes novas comecam a cair nas instancias vazias
       A[2900] B[2950] C[2880] D[2910] E[120] F[130] G[110] H[125]
       CPU media 41%, A ainda em 76%, comeca a devolver erro

09h34  D falha na verificacao de saude e sai do conjunto
       2910 clientes remanejados de uma vez -> perdem estado em memoria
       pico de logout e de erro concentrado em um minuto

Sem afinidade, com estado externo:
09h05  8 instancias, qualquer requisicao em qualquer instancia
       CPU media 39% e CPU real por instancia 39%
       saida de uma instancia = 1/8 das requisicoes seguintes redistribuidas
       nenhum estado perdido, nenhum logout

O detalhe que mais engana nesse cenário é a linha da CPU média. Ela é matematicamente correta e operacionalmente inútil: com quatro instâncias em setenta e oito por cento e quatro em zero, a média é trinta e nove, e é esse número que o painel mostra em letra grande. O sinal que importa não é a média, é a dispersão entre instâncias, e ele quase nunca está no painel padrão.

03

O estado que ficou no processo, que é o problema de verdade

Desligar a afinidade sem antes tratar o estado troca um problema previsível por um imprevisível. Antes de mexer no balanceador, é preciso inventariar o que a aplicação guarda em memória entre requisições do mesmo cliente. Na prática esse inventário quase sempre cabe em cinco categorias, e cada uma tem um destino diferente.

Estado na memória do processoExemplo comumDestino corretoCusto da mudança
Sessão de autenticaçãoMapa de identificador de sessão para usuárioArmazenamento externo compartilhado ou token assinadoBaixo, é o caso mais bem resolvido da lista
Carrinho ou formulário em várias etapasRascunho do pedido acumulado entre telasPersistência por identificador estável, não por sessãoMédio, exige decidir a chave e o tempo de expiração
Cache local de dados de referênciaTabela de preços, catálogo, configuração de tenantContinua local, com invalidação por eventoBaixo, não precisa de afinidade se for reconstituível
Conexão de longa duraçãoWebSocket, streaming de resposta, upload em partesContinua na instância, com reconexão explícita no clienteMédio, exige tratar reconexão como caso normal
Trabalho em andamento não persistidoProcessamento iniciado numa requisição e lido na seguinteFila com identificador de tarefa e consulta de statusAlto, costuma exigir mudança de contrato da API

As duas linhas que mudam a estratégia são a terceira e a quarta. Cache local de dados de referência não justifica afinidade: se ele é reconstituível a partir da origem, a instância nova apenas paga a primeira leitura mais cara, e a solução é aquecimento no início do processo, não roteamento fixo. Conexão de longa duração justifica localidade de verdade, mas não justifica cookie de afinidade: a conexão já está fisicamente presa àquela instância, e o que precisa ser resolvido é a reconexão do cliente, não a rota da próxima requisição HTTP.

// Antes: sessao na memoria do processo. Funciona com afinidade ligada
// e some quando a instancia sai do conjunto.
const sessoes = new Map();

app.post('/login', async (req, res) => {
  const usuario = await autenticar(req.body);
  const id = crypto.randomUUID();
  sessoes.set(id, { usuarioId: usuario.id, criadaEm: Date.now() });
  res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });
  res.json({ ok: true });
});

app.get('/perfil', (req, res) => {
  const sessao = sessoes.get(req.cookies.sid);
  if (!sessao) return res.status(401).json({ erro: 'sessao_invalida' });
  res.json({ usuarioId: sessao.usuarioId });
});

// Depois: sessao em armazenamento externo. Qualquer instancia atende,
// e a afinidade deixa de ser necessaria para este fluxo.
const TTL_SESSAO_SEGUNDOS = 60 * 60 * 8;

app.post('/login', async (req, res) => {
  const usuario = await autenticar(req.body);
  const id = crypto.randomUUID();

  await redis.set(
    `sessao:${id}`,
    JSON.stringify({ usuarioId: usuario.id, criadaEm: Date.now(), versao: 2 }),
    { EX: TTL_SESSAO_SEGUNDOS },
  );

  res.cookie('sid', id, { httpOnly: true, secure: true, sameSite: 'lax' });
  res.json({ ok: true });
});

app.get('/perfil', async (req, res) => {
  const bruto = await redis.get(`sessao:${req.cookies.sid}`);
  if (!bruto) return res.status(401).json({ erro: 'sessao_invalida' });

  const sessao = JSON.parse(bruto);
  // Renovacao deslizante: cada requisicao estende a sessao sem reescrever
  // o corpo, o que evita perder dado gravado por outra instancia.
  await redis.expire(`sessao:${req.cookies.sid}`, TTL_SESSAO_SEGUNDOS);
  res.json({ usuarioId: sessao.usuarioId });
});

O ponto sutil na versão externa é a renovação com expiração em vez de reescrita do corpo. Quando duas requisições do mesmo usuário chegam em instâncias diferentes ao mesmo tempo, o que é exatamente o cenário que a afinidade escondia, reescrever o objeto inteiro faz a última escrita apagar o campo que a outra acabou de gravar. Estender o tempo de vida sem tocar no conteúdo elimina essa classe de perda sem precisar de bloqueio.

04

Migrar sem derrubar quem está logado

A migração precisa funcionar com usuários no meio de uma sessão, porque não existe janela em que ninguém esteja logado. A sequência abaixo mantém o sistema funcionando em todas as etapas e permite reverter em qualquer ponto sem perder sessão, que é a diferença entre uma migração planejada e uma troca torcida por sorte.

  1. Escreva em ambos os lugares. A aplicação passa a gravar a sessão na memória e no armazenamento externo, e continua lendo apenas da memória. Nada muda para o usuário, e a afinidade continua ligada. Nesta etapa só se mede: taxa de erro de escrita externa e latência adicionada por requisição.
  2. Leia do externo com retorno para a memória. A leitura passa a consultar o armazenamento externo primeiro e, se não encontrar, cai na memória local. Sessões antigas continuam válidas e sessões novas já funcionam em qualquer instância. Essa é a etapa que precisa de mais tempo, porque ela dura o tempo de vida da sessão mais longa.
  3. Confirme que a memória local está vazia de leitura. O indicador é o contador de acertos no retorno para a memória: quando ele fica em zero por um período maior que o tempo de expiração da sessão, nenhuma sessão viva depende mais de instância específica.
  4. Desligue a afinidade no balanceador. Faça isso em um ambiente por vez e observe a dispersão de CPU entre instâncias, não a média. A distribuição deve ficar visivelmente mais uniforme em minutos, e a taxa de erro não deve mudar.
  5. Remova a escrita na memória e o código de retorno. Só depois de a afinidade estar desligada por tempo suficiente para cobrir uma implantação, um evento de escala e um reinício de instância. Antes disso, o código de retorno é o caminho de volta.

O erro de sequência mais comum é desligar a afinidade no mesmo momento em que a leitura externa entra. As duas mudanças produzem sintomas parecidos, um logout inesperado pode vir de qualquer uma das duas, e a investigação perde o dia inteiro separando as causas. Separar as etapas por pelo menos uma janela de expiração de sessão faz o diagnóstico ser imediato caso algo apareça.

Vale registrar o caso em que a resposta correta é manter a afinidade. Se o serviço mantém conexões de longa duração, o roteamento por chave de aplicação com hash consistente é preferível ao cookie do balanceador, porque ele degrada de forma proporcional: perder uma instância entre oito remaneja um oitavo das chaves, não a totalidade dos clientes daquela instância para uma escolha arbitrária. E se existe cache local caro de reconstruir, a afinidade compra desempenho de verdade, desde que o sistema continue correto quando ela falhar, o que significa tratar a ausência do cache como caminho normal e não como erro.

05

Os alertas que mostram o desbalanceamento antes do cliente

Nenhuma métrica de média detecta esse problema, e é por isso que ele costuma ser descoberto pelo cliente. Os três alertas abaixo cobrem os três modos de falha e nenhum deles depende de instrumentação nova além do que já existe por instância.

AlertaO que medeLimiar práticoModo de falha que ele pega
Dispersão de carga entre instânciasRazão entre a instância mais carregada e a medianaAcima de 1,8 por mais de dez minutosEscala que não alivia e instância que voltou vazia
Instância ociosa com serviço saturadoInstância abaixo de dez por cento com serviço acima de setentaQualquer ocorrência sustentada por cinco minutosCapacidade adicionada que não está sendo usada
Erro concentrado em uma instânciaFração do erro total vinda de uma única instânciaAcima de 60 por cento com mais de três instâncias no conjuntoInstância degradada que ainda passa na verificação de saúde

O terceiro alerta é o que muda o desfecho do incidente, porque ele detecta a situação em que a afinidade impede a recuperação automática. Uma instância que responde à verificação de saúde mas erra nas requisições reais continua recebendo exatamente os mesmos clientes, e para eles o serviço está fora do ar mesmo com o painel geral verde. A ação associada a esse alerta deve ser remover a instância do conjunto, não reiniciá-la, porque reiniciar mantém a instância no conjunto e apenas repete o ciclo.

Há ainda uma verificação barata que vale como rotina e não como alerta: registrar, na resposta de saúde de cada instância, quantas sessões ela guarda na memória. Se o serviço está declaradamente sem estado, esse número tem que ser zero, e um valor diferente de zero denuncia a introdução acidental de estado em memória por um caminho que ninguém revisou. É a única forma de impedir que a afinidade volte a ser necessária depois de removida.

FAQ

Perguntas frequentes

Se a afinidade é o problema, por que o balanceador oferece esse recurso por padrão em quase toda plataforma?

Porque ela resolve um problema real e imediato num contexto específico, e o contexto em que ela foi criada ainda existe. Numa aplicação que guarda sessão em memória do processo, que é o comportamento padrão de vários frameworks até hoje, a afinidade é a diferença entre funcionar e não funcionar assim que a segunda instância entra no ar. Ela é o caminho de menor esforço para colocar em produção uma aplicação escrita para rodar em um servidor só, e nesse papel ela é legítima. O problema não é o recurso, é ele permanecer ligado depois que o motivo desapareceu, ou ser ligado sem que ninguém registre por quê. O padrão que se repete é este: alguém liga a afinidade para resolver um logout intermitente numa sexta-feira, o logout para, e a decisão nunca mais é revisitada. Dois anos depois a aplicação já move sessão para armazenamento externo, mas a afinidade continua ligada e ninguém sabe se pode desligar, porque desligar virou um risco não medido. A prática que evita isso é tratar afinidade como qualquer outra exceção operacional: registro escrito do motivo, data de revisão e um teste que prove que o sistema funciona sem ela. Se esse teste não existe, a afinidade não é uma escolha, é uma dependência.

Mover a sessão para um armazenamento externo não cria um ponto único de falha e um custo de latência em toda requisição?

Cria uma dependência nova, e vale tratá-la de frente em vez de aceitar a troca sem medir. Sobre latência, a leitura de sessão num armazenamento em memória na mesma zona custa tipicamente entre meio e dois milissegundos, e comparar esse número com zero é a comparação errada: o correto é comparar com o custo de uma implantação que desloga uma parcela dos usuários e com o custo de capacidade que não alivia num pico. Além disso, boa parte desse custo é eliminável, porque a sessão pode ser mantida num cache local de tempo curto, na ordem de cinco a trinta segundos, o que reduz drasticamente as leituras sem reintroduzir a dependência de instância, já que a ausência do cache continua sendo um caminho normal. Sobre disponibilidade, o ponto único existe e precisa das mesmas defesas de qualquer dependência crítica: réplica com promoção automática, tempo limite curto na leitura e um comportamento definido para a falha. O comportamento definido é o que mais importa e o que mais falta. Se o armazenamento de sessão fica indisponível, a decisão de negócio precisa estar escrita antes: derrubar todo mundo, ou aceitar token assinado com validade curta como caminho degradado, ou deixar navegação anônima seguir e bloquear apenas as ações que exigem identidade. Qualquer uma das três é defensável, e a única resposta ruim é descobrir qual é durante o incidente.

Como testar se o sistema realmente funciona sem afinidade, sem esperar o próximo incidente para descobrir?

O teste que dá a resposta é barato e não precisa de ambiente especial. Em homologação, com carga sintética representativa, force o roteamento aleatório por requisição em vez de por sessão e execute os fluxos que atravessam mais de uma requisição do mesmo usuário: login, checkout em várias etapas, upload, qualquer coisa que dependa de contexto acumulado. Qualquer estado que estivesse escondido na memória do processo aparece como erro imediato, e a taxa de erro sob roteamento aleatório é a medida direta de quanto o sistema depende de afinidade. Em produção existe uma versão mais forte e igualmente controlada, que é encerrar uma instância de propósito durante o horário de menor tráfego e medir três números: quantos usuários viram erro, quantos foram deslogados e quanto tempo levou para a carga voltar a se distribuir. Esses três números transformam uma discussão de opinião em dado, e costumam ser o argumento que destrava a migração, porque a quantidade de usuários afetados por um encerramento planejado é sempre maior do que a estimativa que as pessoas fazem de cabeça. O erro a evitar é fazer esse teste apenas uma vez: o valor está na repetição periódica, porque estado em memória volta a aparecer por caminhos novos a cada trimestre e o único jeito de saber é exercitando a falha antes que ela aconteça sozinha.

Afinidade é uma dívida de arquitetura cobrada no pior dia possível

A sessão pegajosa nunca falha no dia em que é configurada: ela falha na segunda-feira de campanha, no meio da implantação, no minuto em que uma instância degrada sem cair. Posso revisar como o seu tráfego é distribuído hoje e definir o inventário de estado em memória, a sequência de migração para estado externo sem derrubar usuário logado, o comportamento degradado quando o armazenamento de sessão falha, a escolha entre afinidade por cookie e hash consistente onde a localidade for legítima, e os alertas de dispersão que mostram o desbalanceamento antes do cliente.