Arquitetura de software

Performance de aplicações: latência, percentis, profiling e a JVM

Avançado12 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

O trabalho de performance fracassa quando começa por opiniões sobre o que está lento. Este guia apresenta as medidas que descrevem um sistema com honestidade — latência versus throughput, percentis, utilização e filas —, percorre os pontos onde um serviço JVM realmente perde tempo e termina com um ciclo de otimização que produz evidências em vez de folclore.

O que você vai aprender

  • Distinguir latência, throughput e concorrência e relacioná-los pela lei de Little
  • Ler distribuições de percentis e explicar por que médias escondem problemas que o usuário sente
  • Fazer profiling de um serviço em execução para descobrir para onde realmente vão o tempo e as alocações
  • Diagnosticar as causas comuns de lentidão: queries N+1, pool esgotado, pressão sobre o GC e contenção de locks
  • Aplicar cache e limites de concorrência sem introduzir problemas de corretude ou estabilidade

Quase toda investigação de performance que dá errado começa do mesmo jeito: alguém diz que a aplicação está lenta, outra pessoa tem uma teoria, e uma semana some otimizando algo que nunca esteve no caminho crítico. A disciplina que evita isso não tem nada de glamorosa. Meça o que os usuários vivenciam, descubra com evidências para onde vai o tempo, mude uma coisa só, meça de novo e mantenha ou reverta com base no número, não na teoria.

Este guia trata desse ciclo e dos pontos onde um serviço JVM típico realmente perde tempo. O foco é o diagnóstico e a otimização de um sistema que você consegue observar; como construir a carga que revela os problemas antes que a produção o faça é assunto de metodologia de testes de carga.

As três grandezas e como elas se relacionam

Latência é quanto tempo uma operação leva. Throughput é quantas operações terminam por unidade de tempo. Concorrência é quantas estão em andamento ao mesmo tempo. Elas não são independentes, e a lei de Little as relaciona de forma exata:

L = λ × W

L = average number of requests in the system (concurrency)
λ = average arrival rate (throughput)
W = average time in the system (latency)

A lei vale para qualquer sistema estável, independentemente do funcionamento interno, o que a torna uma verificação de sanidade notavelmente boa. Um serviço que atende 500 requisições por segundo com latência média de 200 ms tem, em média, 100 requisições em andamento — então um pool de 50 threads é um teto rígido, e a fila na frente dele é onde o tempo está indo. Invertendo a conta, ela dimensiona as coisas para você: para atender 2.000 requisições por segundo a 50 ms, você precisa de 100 vagas concorrentes, e se cada uma segura uma conexão com o banco, você acaba de descobrir algo importante sobre o pool.

A segunda relação que vale internalizar é o que acontece quando a utilização sobe. Para um sistema simples, a teoria das filas dá um tempo de espera proporcional a ρ / (1 − ρ), em que ρ é a utilização. Com 50 % de utilização, a fila acrescenta mais ou menos o mesmo que o próprio tempo de serviço; com 90 %, nove vezes; com 95 %, dezenove. É por isso que um recurso a 80 % parece saudável num dashboard e se comporta muito mal, e por isso planos de capacidade que miram utilização alta produzem sistemas que caem no menor pico de tráfego.

A consequência prática: encontre o gargalo, o recurso mais perto da saturação, porque nada mais importa até que ele se mova. Otimizar qualquer outra coisa não muda nada mensurável, e depois que você o resolve o gargalo vai para outro lugar — o que é o resultado normal e esperado, não um fracasso.

Meça percentis, nunca médias

Tempos de resposta têm distribuição assimétrica com cauda longa, então estatísticas resumidas escolhidas sem cuidado descrevem um sistema que ninguém usa.

EstatísticaO que ela diz
MédiaQuase nada; esconde totalmente caudas e bimodalidade
Mediana (p50)A experiência típica
p90 / p95Onde os usuários começam a perceber
p99A cauda que seus usuários mais ativos encontram o tempo todo
p99.9Onde aparecem falhas de infraestrutura e pausas de GC
MáximoUm único ponto de dado; útil apenas como alarme

Um usuário cuja página dispara vinte chamadas ao backend esbarra no seu p95 com probabilidade próxima de dois terços. A latência de cauda, portanto, não é um caso extremo; em páginas compostas ela é o caso comum, e é por isso que "a latência de cauda domina" é a frase mais útil do trabalho de performance.

Percentis devem ser calculados a partir de um histograma, não de uma amostra de "requisições lentas", e sobre uma janela declarada — um p99 de 24 horas esconde uma hora de sofrimento. Agregue somando os buckets do histograma entre instâncias; nunca tire a média de quantis pré-calculados. E cuidado com a coordinated omission, o bug de medição em que um cliente que espera uma resposta antes de enviar a próxima para de amostrar justamente quando o sistema está mais lento, fazendo os percentis reportados parecerem muito melhores do que a realidade.

Por fim, amarre os números a algo significativo: um objetivo de nível de serviço (SLO) formulado em termos do usuário, como "99 % das requisições de checkout terminam em até 800 ms, medido ao longo de 28 dias". Isso transforma performance de opinião em um orçamento que você pode gastar de forma deliberada.

Descobrindo para onde vai o tempo

Com uma meta definida, a pergunta passa a ser onde estão os milissegundos.

Comece pela borda e avance para dentro. Um trace distribuído de uma requisição lenta mostra qual salto consumiu o tempo — a aplicação, o banco de dados, um serviço downstream ou a rede entre eles. Sem tracing, horas somem no serviço errado. A instrumentação para isso é descrita em Actuator e observabilidade.

Depois, faça profiling do processo. Dois tipos de profiler respondem a perguntas diferentes, e você precisa dos dois:

  • Um profiler de CPU amostra as pilhas de chamadas para mostrar onde o tempo de processamento é gasto. Profilers de amostragem modernos percorrem a pilha de forma assíncrona, o que evita o viés de safepoint que fazia ferramentas antigas apontarem métodos completamente errados. Leia o resultado como um flame graph: a largura é o tempo total, então procure platôs largos, não torres altas.
  • Um profiler de alocação mostra quais pontos de chamada criam objetos. A alocação é o que impulsiona a coleta de lixo, e o tempo de GC costuma ser atribuído erroneamente a qualquer código que estivesse rodando naquele momento.

O Java Flight Recorder é a escolha pragmática padrão na JVM: ele vem embutido, seu overhead fica em torno de 1 % com o perfil padrão e pode ser ativado em um processo de produção por uma janela limitada.

# Attach to a running service and record for two minutes.
jcmd <pid> JFR.start name=diag settings=profile duration=120s filename=/tmp/diag.jfr

# Cheap first look while you wait: what are the threads doing?
jcmd <pid> Thread.print | grep -A 5 'java.lang.Thread.State: BLOCKED'

# GC behaviour at a glance.
jstat -gcutil <pid> 1000 30

Leia thread dumps para o que os profilers não enxergam. Três dumps com dez segundos de intervalo dizem muito mais do que um. Threads consistentemente BLOCKED no mesmo monitor indicam contenção de lock; threads em WAITING no getConnection de um pool indicam que o pool esgotou; threads em Socket.read significam que você está esperando por outra parte e o problema não está aqui.

Onde o tempo costuma estar

De um serviço JVM para outro, o mesmo punhado de causas se repete.

Acesso ao banco de dados

Quase sempre é o primeiro lugar para olhar e, em geral, o maior ganho isolado.

Queries N+1. Um ORM carrega 200 pedidos e depois dispara uma query por pedido para buscar os itens. Num teste com três linhas isso é invisível; em produção são 201 idas e voltas. A correção é um join fetch, um entity graph ou uma segunda query em lote — e a forma de pegar o problema é contar os comandos SQL por requisição nos testes, não torcer. A mecânica no JPA é tratada em Spring Data JPA.

Índices ausentes ou inutilizáveis. Leia o plano de execução em vez de adivinhar. Um scan sequencial numa tabela grande em um caminho OLTP é um defeito, assim como um índice que não pode ser usado porque o predicado envolve a coluna em uma função.

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.total
FROM   orders o
WHERE  o.customer_id = 4711
  AND  o.created_at >= now() - interval '90 days'
ORDER  BY o.created_at DESC
LIMIT  50;
-- Look for: Seq Scan on a large table, rows removed by filter, an estimate
-- far from the actual count, and sorts spilling to disk.

Buscar dados demais. Selecionar todas as colunas de todas as linhas para exibir quatro campos, ou paginar por offset lá no fundo de um resultado grande. Projete apenas o que você precisa.

Pool esgotado. Threads esperando por uma conexão aparecem como latência na aplicação enquanto o banco parece ocioso. Acompanhe o tempo de espera do pool como métrica de primeira classe, e lembre-se de que um pool maior geralmente piora a situação, em vez de melhorar.

Transações abertas durante chamadas remotas. Um método @Transactional que chama uma API HTTP segura uma conexão com o banco e seus locks durante toda essa chamada. Faça o trabalho remoto fora da transação.

Memória e garbage collection

O heap da JVM tem gerações porque a maioria dos objetos morre jovem. Coletas da geração jovem são baratas e frequentes; é o trabalho na geração antiga que causa pausas perceptíveis.

Diagnostique antes de ajustar. Ative os logs de GC e observe três coisas: a porcentagem do tempo de relógio gasta em GC (acima de uns 5 % vale investigar), a distribuição dos tempos de pausa em relação à sua meta de latência e se o heap vivo após cada coleta da geração antiga está com tendência de alta — a assinatura de um vazamento.

-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20M
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/

A maioria dos problemas de GC é problema de alocação. Um serviço que aloca 2 GB por segundo para montar strings e coleções intermediárias vai coletar o tempo todo, seja qual for o coletor, e reduzir a alocação resolve de vez. Quando a escolha do coletor é mesmo o problema: o G1 é um bom padrão, equilibrando throughput e tempo de pausa; ZGC e Shenandoah miram pausas abaixo de um milissegundo ao custo de algum throughput e de um footprint maior, e valem a pena quando a latência p99.9 é dominada por pausas. Um heap generoso reduz a frequência de GC, mas alonga cada coleta e aumenta a disputa com o page cache — e, num container, sempre defina o heap explicitamente ou confirme que a JVM reconhece o container, porque um limite que a JVM não enxerga vira um OOM kill sem heap dump.

Concorrência e contenção

Contenção de locks aparece como latência que cresce com a concorrência enquanto a CPU fica estável. Procure blocos synchronized em volta de caches ou contadores compartilhados, instâncias únicas compartilhadas de objetos caros e logging configurado de forma síncrona para um appender lento. As correções, em ordem de preferência: eliminar o compartilhamento, encolher a seção crítica, trocar o lock por uma estrutura de dados concorrente ou uma variável atômica, ou particionar os dados para que as threads disputem faixas diferentes.

A falha oposta é a concorrência sem limite: um executor com fila ilimitada aceita trabalho indefinidamente, então um pico vira problema de memória e toda requisição enfileirada acaba estourando o timeout de qualquer jeito. Limite todas as filas e rejeite cedo.

Serialização, rede e o trabalho que você não precisava fazer

O excesso de idas e voltas nas chamadas remotas, a serialização JSON de payloads enormes, as configurações de compressão e os handshakes TLS em conexões que deveriam estar num pool aparecem nos perfis com mais frequência do que se imagina. Trabalho redundante também: calcular o mesmo valor cinco vezes por requisição, logar em debug dentro de um loop quente ou montar um grafo de objetos que quem chamou descarta.

Cache, com cuidado

Cache é a otimização de maior alavancagem e a que tem os piores modos de falha, porque um cache transforma um problema de performance em um problema de corretude.

Camada por camada: o navegador e a CDN cuidam dos recursos estáticos e das respostas GET cacheáveis usando os validadores discutidos em design de APIs REST; um cache distribuído como o Redis guarda resultados calculados compartilhados; um cache local, dentro do processo, guarda dados quentes, pequenos e majoritariamente lidos com a menor latência possível; e o buffer pool do próprio banco faz cache de páginas por baixo de tudo isso.

As decisões que determinam se ele ajuda:

  • Que grau de desatualização é aceitável? Se a resposta for "nenhum", você não quer um cache, quer uma query mais rápida.
  • Estratégia de expiração. Expiração por tempo é simples e previsível; invalidação explícita na escrita é precisa e fácil de errar; um TTL curto combinado com invalidação é um meio-termo robusto.
  • Tamanho limitado com uma política de despejo sensata, porque um cache local sem limite é um vazamento de memória com nome simpático.
  • Proteção contra stampede. Quando uma chave popular expira, centenas de requisições dão miss ao mesmo tempo e todas recalculam. Use um lock por chave, ou sirva o conteúdo desatualizado enquanto um único chamador o atualiza.
  • Meça a taxa de acerto. Um cache abaixo de uns 80 % de hits muitas vezes adiciona latência e complexidade em troca de muito pouco, e você precisa saber disso em vez de supor.

A abstração de cache do Spring e seus provedores são tratados em cache.

O ciclo de otimização

  1. Definir a meta

    Escolha uma operação visível para o usuário e um número: "p95 de GET /orders abaixo de 300 ms a 400 requisições por segundo". Sem isso, você não sabe quando parar.
  2. Reproduzir sob carga representativa

    Uma carga que espelhe o mix de requisições, o volume de dados e a concorrência da produção. Otimizar contra um banco vazio não ensina nada.
  3. Medir e localizar

    Faça tracing para achar o salto lento, profiling para achar o código lento, e identifique o recurso que é o gargalo.
  4. Formular uma hipótese com efeito previsto

    "Juntar estas 40 queries em uma só deve economizar uns 120 ms." Uma previsão que se revela errada é informação; uma mudança sem previsão é um chute.
  5. Mudar uma coisa só

    Uma mudança por medição, ou você não vai saber qual fez diferença — e uma delas pode ter piorado as coisas.
  6. Medir de novo e decidir

    Compare com a linha de base na mesma carga. Mantenha a mudança se ela moveu o número, reverta se não moveu, e registre os dois resultados.
  7. Repetir até atingir a meta, e então parar

    O gargalo terá se movido. Continue só enquanto a meta não for atingida; otimizar além de um objetivo cumprido é custo sem benefício.

Dois hábitos tornam isso duradouro. Mantenha o benchmark no pipeline para que uma regressão seja pega pelo build, e não por um cliente. E anote o que você tentou e o que aconteceu, incluindo os fracassos — senão a próxima pessoa que olhar este sistema vai repetir cada um deles.

Principais conclusões

  • Latência, throughput e concorrência estão ligados pela lei de Little, e os efeitos de fila fazem com que um recurso acima de 80 % de utilização já esteja em apuros.
  • Reporte percentis a partir de histogramas combinados sobre uma janela declarada, nunca médias nem médias de quantis; em páginas com várias chamadas, a cauda é a experiência comum.
  • Faça tracing primeiro para achar o salto lento, depois profiling de CPU e de alocação, e leia vários thread dumps para identificar contenção e pool esgotado; o acesso ao banco — queries N+1, índices ausentes, busca de dados demais, pools de conexões esgotados — é o gargalo mais comum e o mais barato de corrigir.
  • A maioria dos problemas de GC é problema de alocação; diagnostique pelos logs de GC e reduza o lixo antes de trocar de coletor, e sempre defina o heap explicitamente em containers.
  • Use cache só depois de medir, com uma tolerância de desatualização declarada, tamanho limitado, proteção contra stampede e taxa de acerto monitorada.
  • Otimize em ciclo: defina a meta, reproduza, localize, preveja, mude uma coisa só, meça e pare quando o objetivo for atingido.

Fontes e leituras complementares

Perguntas frequentes

Por que o tempo de resposta médio engana?
Porque os tempos de resposta não têm distribuição simétrica. Um serviço com média de 40 ms pode facilmente atender uma em cada vinte requisições em mais de um segundo, e essas requisições lentas se concentram nos usuários que estão fazendo as coisas mais valiosas: os maiores carrinhos, as maiores carteiras. A média também esconde completamente um comportamento bimodal: cache hits de 5 ms e misses de 900 ms dão uma média que nenhuma requisição jamais experimenta.
Posso tirar a média dos percentis de vários servidores?
Não. A média do p99 de cada nó não é o p99 do serviço como um todo e pode estar muito errada. Exporte os buckets de histograma de cada instância, some os buckets de forma centralizada e calcule o percentil a partir do histograma combinado. A maioria dos sistemas de métricas suporta isso; se o seu publica quantis pré-calculados por instância, eles não podem ser agregados de forma significativa.
Qual deve ser o tamanho de um pool de conexões com o banco de dados?
Bem menor do que a maioria imagina. Conexões além do que o banco consegue executar em paralelo apenas ficam na fila, adicionando latência sem ganho de throughput, e um pool grande permite que a aplicação sobrecarregue o banco. Parta da capacidade paralela do banco — aproximadamente o número de núcleos mais o número efetivo de discos —, dimensione o pool perto disso e confirme aumentando-o: o throughput não melhora enquanto a latência piora.
Quando devo adicionar um cache?
Depois de medir que o mesmo resultado caro está sendo calculado repetidamente e de conseguir dizer qual grau de desatualização é aceitável. Um cache é um risco para a corretude e uma dependência operacional, então deve vir de uma medição, não de um palpite. Corrigir uma query N+1 ou adicionar um índice muitas vezes elimina totalmente a necessidade do cache, sem custo algum em consistência.