Pular para o conteúdo
Voltar para o acervo
Engenharia5 min de leituraPasso 15 de 22

Por que o seu serviço piora com o tempo

O serviço sobe bem e piora ao longo de dias. Três causas explicam quase todos os casos, e as três têm sinais distintos.

· Gabriel Dias
vazamento-de-memoriacontencaofalse-sharinggarbage-collector

O padrão é conhecido: o serviço sobe bem depois do deploy. No segundo dia está um pouco mais lento. No quarto, alguém reinicia. E o ciclo recomeça.

"Reiniciar resolve" é um diagnóstico, não uma solução, e ele aponta para três causas possíveis.

Causa 1: vazamento

Vazamento é segurar um recurso que você não precisa mais. Existem três tipos, e todos têm o mesmo sintoma temporal.

Vazamento de memória. Você mantém referência para objetos que deveriam ter morrido. Em linguagem com coletor de lixo, isso não é um free esquecido: é uma referência viva.

O campeão absoluto: cache sem limite. Um Map que só cresce, populado a cada requisição, sem política de expulsão. Parece um cache; é um vazamento com nome bonito.

Segundo lugar: listeners e callbacks registrados e nunca removidos. Terceiro: ThreadLocal em pool de threads, onde a thread volta ao pool carregando o valor.

Vazamento de thread. Você cria thread ou executor e nunca encerra. O número de threads cresce monotonicamente. Sintoma final: falha ao criar thread, ou lentidão por troca de contexto excessiva.

Vazamento de descritor. Arquivo, socket ou conexão aberto e não fechado, normalmente num caminho de erro que não passa pelo bloco de fechamento. Sintoma final: "too many open files".

Como distinguir: acompanhe três números ao longo de um dia: memória residente, contagem de threads, contagem de descritores. O que cresce e nunca cai é o seu vazamento.

ps -o rss= -p <pid>          # memória residente
ls /proc/<pid>/task | wc -l   # threads
ls /proc/<pid>/fd | wc -l     # descritores

Causa 2: contenção

Contenção é threads brigando pelo mesmo recurso protegido.

O sinal é característico e fácil de reconhecer: você adiciona CPU e a vazão não sobe, ou cai.

Isso acontece porque o trabalho útil está serializado num ponto. Mais threads significa mais gente na fila do mesmo lock, mais trocas de contexto, e mais tempo gasto coordenando do que trabalhando.

A Lei Universal de Escalabilidade formaliza isso: além da parcela sequencial da Lei de Amdahl, existe um custo de coordenação que cresce com o quadrado do número de participantes. Em algum ponto, adicionar capacidade piora o desempenho.

Como medir: não olhe só tempo de CPU. Olhe tempo gasto esperando lock. A maioria dos profilers modernos tem um modo para isso; em Java, é o profiler de contenção de monitor.

E o false sharing, que é contenção invisível: duas variáveis independentes caem na mesma linha de cache de 64 bytes. Threads em núcleos diferentes mexem em variáveis diferentes, mas o processador invalida a linha inteira toda vez.

Você paga sincronização sem ter escrito nenhum lock. A correção é padding: preencher com espaço para separar as variáveis em linhas diferentes. É um problema raro em código de aplicação e comum em estrutura de dados de alta vazão.

Causa 3: coletor de lixo

O coletor geracional parte de uma observação empírica: a maioria dos objetos morre jovem.

Então ele divide a memória em geração nova e velha. Coleta a nova com frequência, e é barato, porque a maior parte já morreu e só o que sobrevive precisa ser copiado. O objeto que sobrevive várias coletas é promovido para a geração velha, coletada raramente.

O que dói é a pausa. Coletores modernos (G1, ZGC, Shenandoah) fazem quase todo o trabalho em paralelo com a aplicação, e as pausas caíram para a casa do milissegundo.

Mas elas continuam existindo, e continuam aparecendo no seu p99 como picos periódicos sem causa aparente no gráfico de tráfego.

O que fazer, em ordem de eficácia

1. Aloque menos. A maior parte do problema de GC é lixo desnecessário criado em laço quente. Concatenação de string dentro de loop, objetos intermediários em stream, boxing de primitivo. Um profiler de alocação mostra isso em minutos.

2. Dimensione a heap com folga. Heap apertada coleta o tempo todo. Heap grande demais aumenta a duração de cada coleta completa. Existe um ponto ótimo, e ele se encontra medindo.

3. Escolha o coletor pelo objetivo. Vazão ou latência: os coletores são otimizados para um ou para o outro, e o padrão pode não ser o que você quer.

4. Monitore o tempo total em pausa, não o número de coletas. Cem coletas de um milissegundo é melhor que duas de meio segundo.

  1. Vazamentocresce com o tempoDegradação ao longo de horas ou dias. Reiniciar resolve. Memória, threads ou descritores crescendo e nunca caindo.
  2. Contençãocresce com a cargaPiora no pico, melhora no vale. Adicionar CPU não ajuda, e às vezes piora.
  3. Coletor de lixopicos periódicosSaltos no p99 sem correlação com tráfego. O p50 continua ótimo. Aparece no log de GC.
As três têm assinaturas diferentes, e é isso que torna o diagnóstico rápido.

Como distinguir as três

Elas têm assinaturas diferentes, e isso é o que torna o diagnóstico rápido:

Vazamento: degradação monotônica ao longo de horas ou dias. Reiniciar resolve. Memória, threads ou descritores crescendo sem cair.

Contenção: degradação correlacionada com concorrência, não com tempo. Piora no pico, melhora no vale. Adicionar CPU não ajuda.

GC: picos periódicos no p99, sem correlação com tráfego. O p50 fica ótimo. Aparece nos logs de GC.

Se você não sabe qual é, comece pelos três contadores do vazamento, que é o mais barato de descartar.

O caso mais comum de todos

Na minha experiência, o campeão é o cache sem limite.

Alguém adiciona um Map estático para evitar uma consulta repetida. Funciona lindamente. Ninguém coloca limite de tamanho nem expiração, porque "são poucas chaves".

Seis meses depois, as chaves incluem o identificador da requisição, e o mapa tem dez milhões de entradas.

A correção é trivial: usar uma estrutura com limite e política de expulsão, tipo Caffeine em Java ou um LRU simples. A dificuldade é encontrar, e a forma de encontrar é um dump de heap comparando dois momentos.

O hábito que previne

Uma prática barata que evita a maior parte disso: teste de resistência.

Rode a carga normal do sistema por algumas horas (não a de pico, a normal) e acompanhe memória, threads e descritores.

Se algum dos três crescer monotonicamente ao longo de quatro horas, você tem um vazamento, e acabou de descobri-lo antes de produção.

É o tipo de teste que quase ninguém roda e que paga sozinho na primeira vez que pega algo.

Leia depois disto

Fala comigo

Dúvida sobre o artigo? Me chama no WhatsApp

Sem formulário e sem lista de e-mail. Se você discorda de alguma coisa que eu escrevi, ou quer contar como resolveu aí, a conversa é direta comigo.

Abrir conversa