Pular para conteúdo

Encontrando Gargalos

O teste breakpoint terminou: POST /holds mantém o p99 abaixo de 300 ms até cerca de 1 100 requisições por segundo, e a venda precisa de 4 500. E agora? Adicionar pods às cegas pode não mudar nada — ou piorar. Esta página trata do passo entre medir e corrigir: ler os resultados corretamente, localizar o recurso limitante, ver qual código ou consulta o consome e mudar uma coisa por vez até que o gargalo fique onde você quer.


Lendo os resultados

A curva em degraus

Um teste breakpoint que mantém cada taxa por alguns minutos produz um ponto por degrau: carga oferecida, vazão alcançada, p99 e erros. A tabela e o gráfico abaixo são típicos (dados sintéticos com ruído):

Offered (req/s)Achieved (req/s)p99 (ms)ErrorsVerdict
200201590.0 %within SLO
400396650.0 %within SLO
600594780.0 %within SLO
8008111050.0 %within SLO
10009941400.0 %within SLO
110011052370.0 %within SLO ← knee
120012046580.0 %SLO broken
1300123150005.4 %saturated ← max throughput
14001179465015.0 %saturated
16001118478930.6 %saturated
2026-11-01T21:55:06.855648 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 200 400 600 800 1000 1200 1400 1600 offered load (req/s) 200 400 600 800 1000 1200 1400 1600 throughput (req/s) knee ≈ 1100 req/s Stepped breakpoint test of POST /holds offered = achieved (ideal) achieved throughput p99 latency 1 0 2 1 0 3 p99 (ms, log) SLO p99 < 300 ms

Três regiões são visíveis, e cada uma tem um nome que você deve usar nos relatórios:

  • Até o joelho (≈ 1 100 req/s): alcançado = oferecido, p99 dentro do SLO. Esta é a capacidade utilizável deste deployment.
  • Entre o joelho e o máximo: alcançado ainda ≈ oferecido, mas o p99 quebra o SLO — o sistema está funcionando, só que devagar demais; filas estão se formando.
  • Depois do máximo (≈ 1 250 req/s): a vazão alcançada cai enquanto a oferecida continua crescendo, requisições expiram, aparecem erros. O sistema está saturado e, aqui, retrógrado: a sobrecarga o torna menos eficiente (timeouts, retries, comboios de locks).

A capacidade que você reporta é o joelho: "um deployment de inventory com 4 pods sustenta 1 100 reservas/s dentro do SLO; satura em ~1 250 e degrada além disso".

Sintomas e o que geralmente significam

O que você vê Causa provável Confirme com
A latência sobe com a carga e a CPU está perto de 100 % Caminho de código limitado por CPU Flame graph de CPU
A latência sobe com a carga, a CPU fica baixa Espera: um pool, um lock, I/O ou uma dependência lenta USE nos pools e locks; perfil wall-clock; traces
A vazão estabiliza enquanto a carga oferecida cresce Um recurso saturado (o gargalo) USE: encontre o que está a 100 % ou com fila
A vazão diminui a partir de um ponto Contenção / thrashing / retries (retrógrado, USL) Esperas por lock, tempo de GC, contadores de retry
Histograma de latência bimodal (duas corcovas) Dois caminhos de código: hit vs miss de cache, shard rápido vs lento, retries Separe por tag (status do cache, shard)
Picos periódicos em intervalos fixos Ciclos de GC, jobs de cron, checkpoints do banco, rotação de logs, renovação de tokens Correlacione os horários com logs de GC, agendas de cron, logs do banco
A latência cresce devagar com carga constante Vazamento ou crescimento sem limite (memória, conexões, inchaço de tabelas) Tendências do teste soak; heap depois do GC
Erros só nos primeiros minutos Partida a frio: JIT, pools e caches vazios, atraso do autoscaling Compare com uma execução aquecida
p99 do cliente ≫ p99 do lado do servidor Fila antes do serviço (load balancer, fila de accept, throttling de CPU) — ou omissão coordenada nas métricas do servidor Compare os histogramas do gateway e do serviço; contadores de throttling

Um catálogo de gargalos comuns

Gargalo Sinal típico Correção típica
Índice ausente ou errado Uma consulta domina o pg_stat_statements; Seq Scan com muitas linhas removidas Índice que corresponda ao filtro e à ordenação; índice parcial
Consultas N+1 Um trace com um span pai e dezenas de spans de banco idênticos Join ou busca em lote (IN (...)), entity graph, projeção em DTO
Esgotamento do pool de conexões pending do Hikari > 0; latência ≈ espera no pool Primeiro, transações mais curtas; depois, dimensionar o pool; PgBouncer
Contenção de locks / linhas quentes CPU do banco baixa, sessões esperando em Lock: tuple ou transactionid Transações mais curtas, SKIP LOCKED, linhas de granularidade mais fina, enfileirar as escritas
Esgotamento do pool de threads Todas as threads de requisição ocupadas, CPU baixa Descubra o que elas esperam (geralmente um dos itens acima ou uma dependência); timeouts; virtual threads para código limitado por I/O
Dependência lenta Frames largos de HttpClient/leitura de socket em perfis wall-clock; spans de cliente longos Timeouts, circuit breaker, bulkhead, cache, assincronia
Pressão de GC Tempo de GC > 5–10 %, flame graph cheio de alocações Reduzir alocações (JSON grande, logs), dimensionar o heap, um coletor de pausas curtas
Throttling de CPU Picos no p99 com CPU média moderada; nr_throttled crescendo Aumentar/remover o limite de CPU, menos threads, mais pods
Serialização Frames de ObjectMapper/JSON largos em perfis de CPU Payloads menores, paginação, evitar serializar duas vezes
Logs síncronos Frames do logger no caminho quente; I/O de disco no nó Appenders assíncronos, nível de log menor, amostragem
Problemas de cache Taxa de acerto baixa; estouro (stampede) após a expiração; uma chave quente Veja Cache — modos de falha
Limites de rede Resets de conexão, esgotamento de portas efêmeras ou do conntrack com RPS alto Keep-alive e reuso de conexões, HTTP/2, ajustar limites

Profiling: de "qual recurso" para "qual código"

As métricas dizem que o inventory passa o tempo esperando ou queimando CPU; um profiler diz qual código faz isso. Profilers modernos são por amostragem: muitas vezes por segundo eles registram a pilha de chamadas de cada thread, e o código que aparece em muitas amostras é para onde vai o tempo. O overhead é baixo o suficiente (tipicamente alguns por cento) para fazer profiling sob carga, até em produção.

Dois tipos de perfil respondem a perguntas diferentes:

  • Perfis on-CPU amostram só threads que estão executando. Eles encontram código pesado em CPU — serialização, regexes, hashing, TLS.
  • Perfis off-CPU / wall-clock amostram as threads seja lá o que estiverem fazendo, inclusive esperando por locks, sockets e pools. Quando a CPU está baixa mas a latência está alta — o caso comum —, só um perfil wall-clock mostra para onde vai o tempo.

Lendo um flame graph

Um flame graph desenha as pilhas amostradas como caixas aninhadas1:

  • cada caixa é uma função (um frame da pilha); a caixa abaixo dela é quem a chamou;
  • a largura é a fração de amostras em que a função estava na pilha — mais larga significa mais tempo;
  • o eixo x não é o tempo: os frames são ordenados alfabeticamente para fundir pilhas idênticas;
  • procure caixas largas perto do topo (onde o tempo é de fato gasto) e subárvores inesperadamente largas.
2026-11-01T21:55:07.007214 image/svg+xml Matplotlib v3.11.2, https://matplotlib.org/ 0 % 25 % 50 % 75 % 100 % of samples HoldController.hold 100% HoldService.hold 100% TransactionTemplate.execute 91% HikariPool.getConnection 24% PricingClient.quote 28% HttpClient.send 28% SocketInputStream.read 28% SeatRepository.lockSeats 33% PgStatement.execute 33% SocketInputStream.read 33% Wall-clock flame graph of POST /holds (simplified)

Lendo este perfil wall-clock de POST /holds de cima para baixo: quase todo o tempo está dentro de TransactionTemplate.execute — e, dentro dele, um terço das amostras espera em uma leitura de socket dentro de SeatRepository.lockSeats (o banco está nos fazendo esperar por locks de linha), mais de um quarto espera em uma leitura de socket dentro de PricingClient.quote (uma chamada HTTP dentro da transação, segurando a conexão e os locks), e quase um quarto espera em HikariPool.getConnection (esgotamento do pool, causado pelos dois itens anteriores). JSON e logs, os suspeitos de sempre, são fatias finas. Um flame graph de CPU do mesmo serviço teria mostrado principalmente JSON e logs, e apontado para a correção errada.

async-profiler para a JVM

O async-profiler é um profiler por amostragem de baixo overhead para Java que evita o viés de safepoint dos profilers de JVM mais antigos e produz flame graphs diretamente2. Ele se acopla a uma JVM em execução:

# distroless images have no shell: attach an ephemeral debug container that shares
# the pod's process namespace, built from an image that contains async-profiler
kubectl debug -it inventory-7f9c-x2k8p --image=<your-debug-image-with-async-profiler> --target=inventory

asprof -d 30 -e cpu   -f /tmp/cpu.html   1    # on-CPU samples, 30 s, JVM is PID 1
asprof -d 30 -e wall  -t -f /tmp/wall.html 1  # wall-clock, split by thread: includes waiting
asprof -d 30 -e lock  -f /tmp/lock.html  1    # contended Java monitors and locks
asprof -d 30 -e alloc -f /tmp/alloc.html 1    # allocation hot spots (GC pressure)

Rode o profiler enquanto o teste de carga está no platô — o perfil de um serviço ocioso só mostra código ocioso. Se perf_events não for permitido no container, -e itimer (ou ctimer) é uma alternativa de amostragem de CPU. O Java Flight Recorder, embutido no JDK, é a alternativa sem ferramenta extra (jcmd 1 JFR.start duration=60s filename=/tmp/inventory.jfr settings=profile); abra a gravação no JDK Mission Control ou converta-a em flame graph com o conversor do async-profiler.

py-spy para Python

O serviço assistant é Python (FastAPI). O py-spy é um profiler por amostragem que lê a memória do interpretador de fora do processo — sem mudar código, sem reiniciar3:

py-spy top    --pid 1                                   # live "top" of Python functions
py-spy record --pid 1 --duration 30 -o cpu.svg          # flame graph (SVG) of running code
py-spy record --pid 1 --duration 30 --idle -o wall.svg  # include idle/waiting threads
py-spy dump   --pid 1                                   # current stack of every thread, once

Em um container o py-spy precisa da capability SYS_PTRACE (adicione-a ao container de debug, não à aplicação). Para código asyncio, lembre-se de que corrotinas esperando não são threads: uma visão wall-clock do event loop mostra onde o loop está bloqueado — o bug clássico sendo uma chamada síncrona (um cliente HTTP bloqueante, parsing pesado em CPU) dentro de um handler async def.

Profiling contínuo

Fazer profiling só durante incidentes significa que você não tem um "antes" para comparar. O profiling contínuo amostra todas as instâncias o tempo todo com baixa frequência e guarda os perfis como métricas (Grafana Pyroscope, profilers das nuvens; o OpenTelemetry está adicionando perfis como um quarto sinal). Ele permite flame graphs diferenciais — esta versão vs a anterior —, que tornam óbvias as regressões de desempenho em um canary.


Gargalos de banco de dados

O banco de dados é o gargalo mais comum de um sistema como o BoxOffice, porque é o único componente difícil de escalar horizontalmente e que toda escrita precisa alcançar.

Quais consultas?

Comece pelo agregado, não por um palpite. A extensão pg_stat_statements registra estatísticas de cada consulta normalizada:

SELECT left(query, 70)                         AS query,
       calls,
       round(total_exec_time)                  AS total_ms,
       round(mean_exec_time::numeric, 2)       AS mean_ms,
       round(100 * total_exec_time / sum(total_exec_time) OVER (), 1) AS pct
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

Ordene pelo tempo total, não pela média: uma consulta de 2 ms chamada 3 000 vezes por segundo custa mais que um relatório de 2 s rodado uma vez por hora.

Por que está lenta? EXPLAIN ANALYZE

O EXPLAIN mostra o plano escolhido pelo otimizador; o EXPLAIN (ANALYZE, BUFFERS) executa a consulta e reporta tempos reais, contagens reais de linhas e páginas lidas4. A consulta do mapa de assentos da venda:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, section, row_label, seat_number, price_cents
FROM seats
WHERE event_id = 4242 AND status = 'AVAILABLE'
ORDER BY section, row_label, seat_number
LIMIT 8;
Before: no suitable index (illustrative output, simplified)
Limit  (cost=187432.10..187432.12 rows=8 width=40) (actual time=412.551..412.555 rows=8 loops=1)
  Buffers: shared hit=2112 read=59861
  ->  Sort  (cost=187432.10..187507.10 rows=30000 width=40) (actual time=412.549..412.551 rows=8 loops=1)
        Sort Key: section, row_label, seat_number
        Sort Method: top-N heapsort  Memory: 26kB
        ->  Seq Scan on seats  (cost=0.00..186832.00 rows=30000 width=40) (actual time=0.031..405.127 rows=31480 loops=1)
              Filter: ((event_id = 4242) AND (status = 'AVAILABLE'::text))
              Rows Removed by Filter: 5968520
Planning Time: 0.142 ms
Execution Time: 412.601 ms

Como ler, de baixo para cima (o nó mais interno executa primeiro):

  • Seq Scan com Rows Removed by Filter: 5968520 — o PostgreSQL leu todos os 6 milhões de assentos de todos os eventos para ficar com 31 480. Esse é o problema.
  • Buffers: … read=59861 — cerca de 60 000 páginas (8 KB cada, ≈ 470 MB) vieram do disco ou do cache do SO, e não do cache de buffers do PostgreSQL. Sob carga, isso é saturação de I/O.
  • Sort … top-N heapsort — ordenar 31 480 linhas para ficar com 8; barato aqui, mas evitável.
  • Compare as linhas estimadas rows=30000 com as reais rows=31480: próximas, então as estatísticas estão boas. Uma diferença grande (100× fora) apontaria estatísticas desatualizadas (ANALYZE) ou colunas correlacionadas.

Um índice parcial que corresponda ao filtro e à ordenação permite ao PostgreSQL ler os 8 primeiros assentos disponíveis diretamente, em ordem:

CREATE INDEX CONCURRENTLY seats_available_idx
    ON seats (event_id, section, row_label, seat_number)
    WHERE status = 'AVAILABLE';
After: index scan (illustrative output, simplified)
Limit  (cost=0.43..9.87 rows=8 width=40) (actual time=0.041..0.058 rows=8 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using seats_available_idx on seats  (cost=0.43..35392.11 rows=30000 width=40) (actual time=0.040..0.055 rows=8 loops=1)
        Index Cond: (event_id = 4242)
Planning Time: 0.210 ms
Execution Time: 0.079 ms

De 412 ms para 0,08 ms e de ~62 000 páginas para 5. O CONCURRENTLY constrói o índice sem bloquear escritas — essencial em uma tabela em uso. (Índices não são de graça: cada um desacelera as escritas e usa memória; indexe o que o modelo de carga diz que é quente.)

No código do BoxOffice: um índice em vez de um cache (lab 6)

O Hands-on 6 — Índices faz isso no endpoint lento desde o lab 1, a listagem do catálogo "próximos eventos de uma cidade, os mais próximos primeiro". O EXPLAIN ANALYZE mostrou um Parallel Seq Scan e uma ordenação de 200.000 linhas para devolver 20. Um índice na ordem de starts_at deixa o PostgreSQL percorrer os eventos já ordenados e parar depois de uma página (189 entradas de índice lidas): na execução do lab, a consulta da página foi de ~37 ms para 0,62 ms, e a contagem de ~14 ms para ~4 ms com um Index Only Scan. A migração é um arquivo novo — V1 e V2 nunca são editados — e o lab mede com o cache desligado, para que o índice seja julgado sozinho.

services/catalog/src/main/resources/db/migration/V3__index_events.sql
-- Lab 6: indexes for the listing "upcoming events in a city, soonest first".
-- Walk events in starts_at order and stop after one page, instead of sorting 20,000 rows:
CREATE INDEX events_starts_at_idx ON catalog.events (starts_at, id);
-- Count/filter the events of a city's venues without reading the whole table:
CREATE INDEX events_venue_starts_at_idx ON catalog.events (venue_id, starts_at);
CREATE INDEX venues_city_idx ON catalog.venues (city);

Contenção de locks e linhas quentes

Quando todos querem os mesmos assentos, as transações fazem fila por locks de linha. Descubra quem espera por quem:

SELECT pid,
       pg_blocking_pids(pid)        AS blocked_by,
       wait_event_type, wait_event,
       now() - query_start          AS waiting_for,
       left(query, 60)              AS query
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;

Três mudanças reduzem a contenção, em ordem de esforço:

  1. Mantenha as transações curtas. Nunca chame outro serviço (preços, pagamento) segurando uma transação: calcule o preço antes do BEGIN, chame o provedor de pagamento depois do COMMIT (com um outbox ou uma saga — veja Mensageria — padrões).
  2. Não brigue pela mesma linha. Quando o fã pede "2 assentos no setor A", quaisquer dois assentos livres servem. O SKIP LOCKED permite que transações concorrentes peguem linhas livres diferentes em vez de fazer fila na primeira:

    BEGIN;
    SELECT id FROM seats
    WHERE event_id = 4242 AND section = 'A' AND status = 'AVAILABLE'
    ORDER BY row_label, seat_number
    LIMIT 2
    FOR UPDATE SKIP LOCKED;
    UPDATE seats
       SET status = 'HELD', held_by = $1, hold_expires_at = now() + interval '10 minutes'
     WHERE id = ANY($2);
    COMMIT;
    
  3. Controle a admissão. Se 50 000 fãs tentam reservar assentos no mesmo segundo, nenhuma estratégia de lock deixa o PostgreSQL feliz. Uma sala de espera admite fãs na taxa que o inventário consegue absorver — transformando contenção em uma fila ordenada (Mensageria — filas).

Dimensionando o pool de conexões

Um pool de conexões pode ser gargalo nos dois sentidos: pequeno demais e as requisições esperam uma conexão; grande demais e o banco passa o tempo alternando entre centenas de sessões, brigando por locks e memória. Dimensione-o com a Lei de Little — conexões necessárias = vazão × tempo em que cada requisição segura uma conexão:

Design de POST /holds Conexão segurada por A 1 500 reservas/s Por pod (10 pods)
Chamada de preços dentro da transação 6 ms de SQL + 40 ms de HTTP = 46 ms 1 500 × 0,046 ≈ 69 conexões ocupadas ~7, mais para rajadas
Chamada de preços antes da transação 6 ms 1 500 × 0,006 ≈ 9 conexões ocupadas ~1, mantenha um pool pequeno

A primeira correção não é um pool maior — é segurar a conexão por menos tempo. Depois, dimensione pela capacidade do banco: o PostgreSQL funciona melhor com um número de conexões ativas da ordem de algumas vezes seus núcleos de CPU (o ponto de partida muito citado do HikariCP é conexões ≈ núcleos × 2 + discos efetivos)5. Vinte pods × um pool de 20 = 400 conexões está muito além disso; coloque o PgBouncer em modo transaction pooling entre os pods e o banco, para que centenas de conexões de clientes compartilhem algumas dezenas de conexões de servidor. E falhe rápido:

inventory/src/main/resources/application.yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 10            # a fixed-size pool: no ramp-up during the spike
      connection-timeout: 2000    # wait at most 2 s for a connection (the default is 30 s)

No código do BoxOffice: um pool que move o joelho, e um pooler (labs 2 e 6)

No Hands-on 2 o pool do catalog (10 conexões, DB_POOL_SIZE) é o primeiro recurso a saturar, e subi-lo para 30 move o joelho para cima — até a única CPU do catalog ou o PostgreSQL saturarem em seguida. O Hands-on 6 — Réplicas e PgBouncer então conta quanto custam pools maiores: cada réplica de cada serviço abre o seu. Em pooling por transação, o PgBouncer empresta uma de 20 conexões reais por uma transação de cada vez: na execução do lab o PostgreSQL viu cerca de 60 conexões da aplicação, 20 delas do PgBouncer servindo todo o inventory. Sem ele, INVENTORY_POOL_SIZE=40 contra max_connections=80 falha com too many clients already; com ele, a mesma demanda vira fila (cl_waiting). O preço: recursos de sessão (os locks do Flyway) precisam contornar o pooler.

infra/pgbouncer/pgbouncer.ini
; PgBouncer: thousands of client connections, a few dozen real PostgreSQL connections.
[databases]
boxoffice = host=db port=5432 dbname=boxoffice

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = /etc/pgbouncer/userlist.txt
admin_users = boxoffice

; transaction pooling: a server connection is lent for ONE transaction, then returned
pool_mode = transaction
; real connections to PostgreSQL per (database, user) pair
default_pool_size = 20
; client connections PgBouncer accepts (all services and replicas together)
max_client_conn = 1000
; protocol-level prepared statements work in transaction mode (PgBouncer >= 1.21)
max_prepared_statements = 200
ignore_startup_parameters = extra_float_digits,options

Uma mudança por vez: um diário de otimização

Mantenha um diário de cada execução: o que mudou, o novo joelho e o que saturou em seguida. Um gargalo removido revela o próximo; o objetivo não é "nenhum gargalo" (sempre há um), mas o gargalo no lugar certo com capacidade suficiente. O diário abaixo ilustra uma campanha típica em POST /holds:

Execução Mudança (uma por execução) Joelho (reservas/s dentro do SLO) O que saturou em seguida
1 Base: 4 pods, pool 10 400 I/O do PostgreSQL (Seq Scan do mapa de assentos)
2 Índice parcial nos assentos disponíveis 700 Pool Hikari: 10/10 ativas, 150 pendentes
3 Chamada de preços movida para fora da transação 1 100 Esperas por locks de linha em setores quentes
4 FOR UPDATE SKIP LOCKED na seleção de assentos 1 800 CPU do inventory a 85 % (JSON, TLS)
5 4 → 10 pods (mínimo do HPA), PgBouncer 3 400 CPU do primário do PostgreSQL a 80 %
6 Sala de espera admitindo 3 000 reservas/s 3 000 admitidas, p99 140 ms — por design: a admissão é o limite

Depois da execução 6, o gargalo é deliberado: a taxa de admissão da sala de espera, definida abaixo do joelho do banco. Escalar além disso exigiria particionar o inventário por evento ou local (Bancos de dados — particionamento) — uma mudança muito maior, agora justificada por números.

Experimente: o cenário monolith (um servidor de aplicação e um PostgreSQL, perfil breakpoint) produz as duas primeiras linhas de um registro desses em poucos minutos. Rode como está e abra o Relatório: o gargalo é o app, já 86 % ocupado a cerca de 430 req/s, e o sistema quebra a cerca de 590 req/s. Mude uma coisa — selecione o app e suba as réplicas para 6 — e rode de novo: o ponto de ruptura salta para cerca de 2.520 req/s, e o gargalo passa para o db, 87 % ocupado a cerca de 2.180 req/s. Agora mude a coisa errada: 12 réplicas em vez de 6. O ponto de ruptura continua em cerca de 2.520 req/s enquanto o custo mensal vai de cerca de 1.170 para 1.890 USD — acrescentar capacidade a um componente que não é o gargalo não compra nada.

Na entrevista

Na etapa de "gargalos e trade-offs", percorra o caminho da requisição e diga qual é o primeiro recurso a saturar, com um número: "A 4 500 reservas/s, o banco primário é o gargalo — toda reserva é uma escrita com lock. Eu manteria as transações curtas, usaria SKIP LOCKED para que os fãs não façam fila na mesma linha, dimensionaria os pools com a Lei de Little atrás do PgBouncer e colocaria uma sala de espera na frente, para que a admissão nunca passe do que o banco aguenta." Mencionar profiling wall-clock vs de CPU e EXPLAIN ANALYZE mostra que você já encontrou gargalos de verdade, não só desenhou caixas.

Boas práticas

  • Ligue o pg_stat_statements e o log de consultas lentas em todo ambiente desde o primeiro dia — por exemplo log_min_duration_statement = 200ms; a pergunta "qual consulta?" é feita durante um incidente, sobre dados que já precisam existir.
  • Entregue cada índice como uma nova migração versionada, e construa-o com CONCURRENTLY em tabelas grandes em uso — a mudança de esquema é revisada, repetível em todo ambiente e não bloqueia escritas. No BoxOffice: o V3__index_events.sql do lab 6.
  • Coloque um pooler na frente do PostgreSQL quando vários serviços e réplicas se conectam, e falhe rápido na espera por conexão — o pooling por transação mantém as conexões no servidor perto do que a Lei de Little exige; um connection-timeout de 2 s em vez de 30 s transforma uma fila num erro claro. No BoxOffice: o infra/pgbouncer/pgbouncer.ini do lab 6.
  • Mantenha o registro de otimizações no repositório — uma linha por execução: a mudança, o novo joelho, o que saturou em seguida; é a evidência por trás de cada número de capacidade. No BoxOffice: as varreduras com pool 10 e pool 30 do lab 2.
  • Rode profiling contínuo em produção com uma taxa de amostragem baixa — alguns por cento de overhead compram o flame graph do incidente antes que alguém o peça.

Armadilhas

  • Fazer profiling de um sistema ocioso: faça no platô do teste de carga.
  • Flame graphs de CPU para um problema de espera: use perfis wall-clock (off-CPU) quando a CPU está baixa.
  • Otimizar pelo tempo médio de consulta: ordene pelo tempo total; consultas baratas e frequentes dominam.
  • EXPLAIN sem ANALYZE em uma consulta lenta: estimativas não são medições.
  • Aumentar o pool para resolver esperas no pool: primeiro encurte o que segura a conexão; pools maiores só movem a fila para dentro do banco.
  • Mudar três coisas ao mesmo tempo: você não vai saber qual ajudou — nem qual atrapalhou.

Como se conecta

flowchart LR
    CURVE["stepped curve<br/>and symptoms"] --> RES["which resource?<br/>USE → Methodology"]
    RES --> PROF["which code? flame graphs,<br/>async-profiler, py-spy"]
    RES --> DBQ["which query? pg_stat_statements,<br/>EXPLAIN ANALYZE"]
    DBQ --> IDX["indexes, N+1<br/>→ Databases models"]
    DBQ --> LOCK["hot rows, SKIP LOCKED<br/>→ Databases transactions"]
    DBQ --> POOL["pool sizing by Little's Law,<br/>PgBouncer"]
    RES --> CACHE["low hit ratio, stampede<br/>→ Caching failure modes"]
    RES --> DEP["slow dependency<br/>→ Reliability patterns"]
    PROF --> LOG["one change at a time:<br/>the optimization log"]

Componentes da paleta: app (CPU, GC, pools de threads, serialização) · sql (índices, locks, pools de conexões) · cache (taxa de acerto, chaves quentes, stampedes) · queue (tirar escritas disputadas do caminho da requisição) · thirdparty (dependências lentas) · tracing (consultas N+1 aparecem como dezenas de spans idênticos) e metrics (contadores de pool e de throttling). Cenários: monolith (perfil breakpoint: encontrar o primeiro recurso a saturar), read-heavy-cache, flash-sale (linhas quentes de assentos). Continua em: Bancos de dados — transações, Bancos de dados — particionamento, Cache — modos de falha, Mensageria — filas e pub/sub, Confiabilidade — padrões, Planejamento de capacidade.



  1. GREGG, B. The Flame Graph. ACM Queue, v. 14, n. 2, 2016. ↩

  2. PANGIN, A. et al. async-profiler. ↩

  3. FREDERICKSON, B. py-spy: Sampling profiler for Python programs. ↩

  4. Documentação do PostgreSQL. Using EXPLAIN; pg_stat_statements. ↩

  5. HikariCP wiki. About Pool Sizing. ↩