Guia de otimização Go + Postgres para APIs geradas por IA: ajustar pool, ler planos EXPLAIN, indexar com sabedoria, paginar com segurança e enxugar JSON.

APIs geradas por IA podem parecer rápidas nos primeiros testes. Você chama um endpoint algumas vezes, o conjunto de dados é pequeno e as requisições chegam uma de cada vez. Então vem tráfego real: endpoints mistos, carga em rajadas, caches mais frios e mais linhas do que você esperava. O mesmo código pode começar a ficar aleatoriamente lento mesmo que nada tenha quebrado.
O lento geralmente aparece de algumas formas: picos de latência (a maioria das requisições vai bem, mas algumas demoram de 5x a 50x mais), timeouts (uma pequena porcentagem falha) ou CPU alta (CPU do Postgres por trabalho de query, ou CPU do Go por JSON, goroutines, logging e retries).
Um cenário comum é um endpoint de listagem com um filtro flexível que retorna um grande JSON. Em um banco de testes, ele escaneia alguns milhares de linhas e termina rápido. Em produção, ele escaneia alguns milhões, ordena e só então aplica um LIMIT. A API ainda “funciona”, mas a latência p95 explode e algumas requisições dão timeout durante picos.
Para separar lentidão do banco da lentidão do app, mantenha o modelo mental simples.
Se o banco está lento, seu handler Go passa a maior parte do tempo esperando a query. Você também pode ver muitas requisições “em voo” enquanto a CPU do Go parece normal.
Se o app está lento, a query termina rápido, mas o tempo se perde depois: construindo objetos grandes de resposta, serializando JSON, rodando queries extras por linha ou fazendo trabalho demais por requisição. CPU e memória do Go sobem, e a latência cresce com o tamanho da resposta.
“Bom o suficiente” antes do lançamento não é perfeição. Para muitos endpoints CRUD, mire em p95 estável (não só a média), comportamento previsível sob rajadas e sem timeouts no pico esperado. O objetivo é simples: sem requisições lentas-surpresa quando dados e tráfego crescem, e sinais claros quando algo muda.
Antes de otimizar qualquer coisa, decida o que “bom” significa para sua API. Sem uma linha de base, é fácil passar horas mudando configurações e ainda não saber se melhorou ou só mudou o gargalo.
Três números geralmente contam a maior parte da história:
p95 é a métrica do “dia ruim”. Se p95 é alto mas a média vai bem, um pequeno conjunto de requisições está fazendo trabalho demais, sendo bloqueado por locks ou acionando planos lentos.
Torne queries lentas visíveis cedo. No Postgres, ative o log de queries lentas com um limiar baixo para testes pré-lançamento (por exemplo, 100–200 ms) e registre a declaração completa para poder copiá-la num cliente SQL. Mantenha isso temporário. Logar toda query lenta em produção vira ruído rápido.
Depois, teste com requisições que pareçam reais, não apenas uma rota "hello world". Um pequeno conjunto basta se combinar com o que os usuários realmente vão fazer: uma chamada de listagem com filtros e ordenação, uma página de detalhe com alguns joins, um create/update com validação e uma query estilo busca com matches parciais.
Se você gera endpoints a partir de uma especificação (por exemplo, com uma ferramenta de vibe-coding como Koder.ai), execute o mesmo punhado de requisições repetidamente com entradas consistentes. Isso torna mudanças como índices, ajustes de paginação e reescritas de query mais fáceis de medir.
Por fim, escolha um alvo que você consiga dizer em voz alta. Exemplo: “A maioria das requisições fica abaixo de 200 ms p95 com 50 usuários concorrentes, e erros abaixo de 0,5%.” Os números exatos dependem do produto, mas um objetivo claro evita mexer sem fim.
Um pool de conexões mantém um número limitado de conexões abertas ao banco e as reutiliza. Sem pool, cada requisição pode abrir uma nova conexão, e o Postgres perde tempo e memória gerenciando sessões em vez de rodar queries.
O objetivo é manter o Postgres ocupado fazendo trabalho útil, não trocando contexto entre conexões demais. Esse é frequentemente o primeiro ganho real, especialmente para APIs geradas por IA que silenciosamente viram endpoints “tagarelas”.
Em Go, você geralmente ajusta max open connections, max idle connections e lifetime das conexões. Um ponto de partida seguro para muitas APIs pequenas é um pequeno múltiplo dos núcleos de CPU (frequentemente 5 a 20 conexões no total), com número similar mantido ocioso, e reciclando conexões periodicamente (por exemplo, a cada 30 a 60 minutos).
Se você roda múltiplas instâncias da API, lembre que o pool se multiplica. Um pool de 20 conexões em 10 instâncias é 200 conexões batendo no Postgres — e é assim que times inesperadamente batem nos limites de conexão.
Problemas de pool se sentem diferentes de SQL lento.
Se o pool for pequeno demais, requisições esperam antes de chegar ao Postgres. Latência sobe, mas CPU do banco e tempos de query podem parecer normais.
Se o pool for grande demais, o Postgres fica sobrecarregado: muitas sessões ativas, pressão de memória e latência desigual entre endpoints.
Uma forma rápida de separar é cronometrar suas chamadas ao BD em duas partes: tempo esperando pela conexão vs tempo executando a query. Se a maior parte do tempo é “espera”, o pool é o gargalo. Se a maior parte é “na query”, foque em SQL e índices.
Checagens rápidas úteis:
max_connections.Se você usa pgxpool, tem um pool orientado ao Postgres com estatísticas claras e bons defaults para comportamento do Postgres. Se usa database/sql, tem uma interface padrão que funciona entre bancos, mas precisa ser explícito sobre configurações de pool e comportamento do driver.
Uma regra prática: se você está 100% em Postgres e quer controle direto, pgxpool costuma ser mais simples. Se depende de bibliotecas que esperam database/sql, fique com ele, configure o pool explicitamente e meça esperas.
Exemplo: um endpoint de listagem de pedidos pode rodar em 20 ms, mas com 100 usuários concorrentes pula para 2 s. Se logs mostram 1,9 s esperando por conexão, otimizar query não vai ajudar até que o pool e as conexões totais do Postgres sejam dimensionadas corretamente.
Quando um endpoint parece lento, verifique o que o Postgres está realmente fazendo. Uma leitura rápida de EXPLAIN frequentemente aponta a correção em minutos.
Execute isso no SQL exato que sua API envia:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
Algumas linhas importam mais. Veja o nó de topo (o que o Postgres escolheu) e os totais no final (quanto tempo levou). Compare estimado vs real. Grandes diferenças geralmente significam que o planner errou.
Se você vê Index Scan ou Index Only Scan, o Postgres está usando um índice, o que geralmente é bom. Bitmap Heap Scan pode ser aceitável para matches de tamanho médio. Seq Scan significa que leu a tabela inteira, o que só é OK quando a tabela é pequena ou quase todas as linhas casam.
Sinais de alerta comuns:
ORDER BY)Planos lentos normalmente vêm de alguns padrões:
WHERE + ORDER BY (por exemplo, (user_id, status, created_at))WHERE (por exemplo, WHERE lower(email) = $1), que podem forçar scans a menos que você adicione um índice de expressão correspondenteSe o plano parece estranho e as estimativas estão bem erradas, as estatísticas frequentemente estão desatualizadas. Rode ANALYZE (ou deixe o autovacuum atualizar) para que o Postgres aprenda contagens atuais e distribuições de valores. Isso importa após grandes importações ou quando novos endpoints começam a escrever muitos dados rapidamente.
Índices ajudam apenas quando batem com como sua API consulta dados. Se você os cria a partir de suposições, acaba com writes mais lentos, armazenamento maior e pouco ou nenhum ganho.
Uma forma prática de pensar: um índice é um atalho para uma pergunta específica. Se sua API faz outra pergunta, o Postgres ignora o atalho.
Se um endpoint filtra por account_id e ordena por created_at DESC, um índice composto frequentemente vence dois índices separados. Ele ajuda o Postgres a encontrar as linhas certas e retorná-las na ordem certa com menos trabalho.
Regras práticas:
Exemplo: se sua API tem GET /orders?status=paid e sempre mostra os mais novos primeiro, um índice como (status, created_at DESC) é um bom ajuste. Se a maioria das queries também filtra por cliente, (customer_id, status, created_at) pode ser melhor, mas só se for assim que o endpoint roda em produção.
Se a maior parte do tráfego atinge uma fatia estreita de linhas, um índice parcial pode ser mais barato e rápido. Por exemplo, se seu app lê principalmente registros ativos, indexar apenas WHERE active = true mantém o índice menor e mais provável de ficar em memória.
Para confirmar que um índice ajuda, faça verificações rápidas:
EXPLAIN (ou EXPLAIN ANALYZE em ambiente seguro) e procure por um index scan que combine com sua query.Remova índices não usados com cuidado. Verifique estatísticas de uso (por exemplo, se um índice foi escaneado). Apague um por vez em janelas de baixo risco e mantenha um plano de rollback. Índices não usados não são inofensivos. Eles tornam inserts e updates mais lentos em cada escrita.
A paginação é muitas vezes onde uma API rápida começa a parecer lenta, mesmo quando o banco está saudável. Trate paginação como um problema de design de query, não um detalhe de UI.
LIMIT/OFFSET parece simples, mas páginas mais profundas costumam custar mais. O Postgres ainda precisa pular (e muitas vezes ordenar) as linhas que você está ignorando. A página 1 pode tocar algumas dezenas de linhas. A página 500 pode forçar o banco a escanear e descartar dezenas de milhares só para retornar 20 resultados.
Também pode criar resultados instáveis quando linhas são inseridas ou deletadas entre requisições. Usuários podem ver duplicatas ou perder itens porque o significado de “linha 10.000” muda conforme a tabela muda.
A paginação por keyset faz uma pergunta diferente: “Me dê as próximas 20 linhas depois da última que vi.” Isso mantém o banco trabalhando em uma fatia pequena e consistente.
Uma versão simples usa um id crescente:
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
Sua API retorna um next_cursor igual ao último id da página. A próxima requisição usa esse valor como $1.
Para ordenação por tempo, use uma ordem estável e desempate. created_at sozinho não basta se duas linhas tiverem o mesmo timestamp. Use um cursor composto:
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Algumas regras evitam duplicatas e itens perdidos:
ORDER BY (geralmente id).created_at e id juntos).Uma razão surpreendentemente comum para uma API parecer lenta não é o banco. É a resposta. JSON grande leva mais tempo para construir, mais tempo para enviar e mais tempo para o cliente parsear. A vitória mais rápida frequentemente é retornar menos.
Comece no seu SELECT. Se um endpoint precisa apenas de id, name e status, peça só essas colunas. SELECT * fica mais pesado silenciosamente conforme as tabelas ganham textos longos, blobs JSON e colunas de auditoria.
Outra lentidão frequente é construir respostas N+1: você busca uma lista de 50 itens e depois roda 50 queries a mais para anexar dados relacionados. Pode passar nos testes e colapsar com tráfego real. Prefira uma única query que retorne o que precisa (joins cuidadosos) ou duas queries onde a segunda faz batch por IDs.
Algumas maneiras de manter payloads menores sem quebrar clientes:
include= (ou uma máscara fields=) para que respostas de listagem fiquem enxutas e as de detalhe optem por extras.Ambos podem ser rápidos. Escolha com base no que você está otimizando.
Funções JSON do Postgres (jsonb_build_object, json_agg) são úteis quando você quer menos idas e vindas e formatos previsíveis de uma query. Moldar em Go é útil quando precisa de lógica condicional, reutilizar structs ou manter SQL mais legível. Se seu SQL de construção de JSON ficar difícil de ler, também fica difícil de otimizar.
Uma boa regra: deixe o Postgres filtrar, ordenar e agregar. Deixe o Go cuidar da apresentação final.
Se você gera APIs rapidamente (por exemplo com Koder.ai), adicionar flags de include cedo ajuda a evitar endpoints que incham com o tempo. Também dá uma forma segura de adicionar campos sem tornar cada resposta mais pesada.
Você não precisa de um laboratório enorme para achar a maioria dos problemas de desempenho. Um passe curto e repetível traz à tona os problemas que viram outages quando o tráfego aparece, especialmente quando o ponto de partida é código gerado que você planeja lançar.
Antes de mudar qualquer coisa, escreva uma linha de base pequena:
Comece pequeno, mude uma coisa por vez e re-teste após cada mudança.
Rode um load test de 10 a 15 minutos que pareça uso real. Bata nos endpoints que seus primeiros usuários vão usar (login, páginas de listagem, busca, create). Depois ordene rotas por latência p95 e tempo total gasto.
Verifique pressão de conexão antes de tunar SQL. Um pool grande demais sobrecarrega o Postgres. Um pool pequeno demais cria esperas longas. Procure aumento no tempo de espera para adquirir conexão e contagens de conexão que disparam durante rajadas. Ajuste limites de pool e idle primeiro, então reexecute o mesmo teste.
EXPLAIN as queries mais lentas e corrija o maior sinal de alerta. Os culpados habituais são scans de tabela cheia em tabelas grandes, sorts em conjuntos grandes e joins que explodem contagens de linhas. Pegue a query pior e torne-a entediante.
Adicione ou ajuste um índice e re-teste. Índices ajudam quando batem com seu WHERE e ORDER BY. Não adicione cinco de uma vez. Se o endpoint lento é “listar pedidos por user_id ordenado por created_at”, um índice composto em (user_id, created_at) pode ser a diferença entre instantâneo e doloroso.
Enxugue respostas e paginação, então re-teste de novo. Se um endpoint retorna 50 linhas com blobs JSON grandes, banco, rede e cliente pagam o preço. Retorne apenas os campos que a UI precisa e prefira paginação que não degrade conforme a tabela cresce.
Mantenha um changelog simples: o que mudou, por que e o que moveu no p95. Se uma mudança não melhorou sua linha de base, reverta e siga adiante.
A maior parte dos problemas de desempenho em APIs Go com Postgres é autoinfligida. A boa notícia é que algumas checagens pegam a maioria antes de o tráfego real chegar.
Uma armadilha clássica é tratar o tamanho do pool como um botão de velocidade. Configurá-lo “o mais alto possível” frequentemente deixa tudo mais lento. O Postgres passa mais tempo gerenciando sessões, memória e locks, e seu app começa a timing out em ondas. Um pool menor e estável com concorrência previsível geralmente vence.
Outro erro comum é “indexar tudo”. Índices extras ajudam leituras, mas também tornam writes mais lentos e podem alterar planos de consulta de maneiras surpreendentes. Se sua API insere ou atualiza frequentemente, cada índice extra adiciona trabalho. Meça antes e depois, e re-verifique planos após adicionar um índice.
Dívida de paginação entra silenciosamente. Paginação por offset parece ok no início, depois p95 sobe porque o banco tem que pular mais e mais linhas.
Tamanho do payload JSON é outro imposto escondido. Compressão ajuda banda, mas não elimina o custo de construir, alocar e parsear objetos grandes. Corte campos, evite aninhamento profundo e retorne só o que a tela precisa.
Se você só observa tempo médio de resposta, vai perder onde a dor do usuário começa. p95 (e às vezes p99) é onde saturação de pool, espera por locks e planos lentos aparecem primeiro.
Um auto-check rápido pré-lançamento:
EXPLAIN após adicionar índices ou mudar filtros.Antes de chegarem usuários reais, você quer evidência de que sua API se mantém previsível sob estresse. O objetivo não é número perfeito. É pegar os poucos problemas que causam timeouts, picos ou um banco que para de aceitar trabalho.
Rode checagens em um staging que se pareça com produção (tamanho de DB similar, mesmos índices, mesmas configurações de pool): meça p95 por endpoint chave sob carga, capture suas queries lentas por tempo total, observe tempo de espera no pool, rode EXPLAIN (ANALYZE, BUFFERS) na pior query para confirmar que está usando o índice esperado e cheque tamanhos de payload nas rotas mais ativas.
Faça então um teste do pior caso que imite como produtos quebram: solicite uma página profunda, aplique o filtro mais amplo e tente com cold start (reinicie a API e acerte a mesma requisição primeiro). Se paginação profunda fica mais lenta a cada página, mude para paginação por cursor antes do lançamento.
Anote seus padrões por padrão para que o time faça escolhas consistentes depois: limites e timeouts de pool, regras de paginação (tamanho máximo de página, se offset é permitido, formato do cursor), regras de query (selecionar só colunas necessárias, evitar SELECT *, limitar filtros caros) e regras de logging (limiar de query lenta, quanto tempo manter amostras, como rotular endpoints).
Se você constrói e exporta serviços Go + Postgres com Koder.ai, fazer um curto planejamento antes do deploy ajuda a manter filtros, paginação e formas de resposta intencionais. Depois que você começa a ajustar índices e formas de query, snapshots e rollback facilitam desfazer uma “correção” que ajuda um endpoint mas prejudica outros. Se quiser um lugar único para iterar nesse workflow, Koder.ai on koder.ai é projetado para gerar e refinar esses serviços via chat, e então exportar o código-fonte quando estiver pronto.
Comece separando o tempo de espera do DB do tempo de trabalho do app.
Adicione medições simples em “espera por conexão” e “execução da query” para ver qual lado domina.
Use uma linha de base pequena e repetível:
Escolha um alvo claro como “p95 abaixo de 200 ms com 50 usuários concorrentes, erros abaixo de 0,5%”. Depois, mude apenas uma coisa por vez e reexecute a mesma mistura de requisições.
Ative o log de queries lentas com um limiar baixo nos testes pré-lançamento (por exemplo, 100–200 ms) e registre a declaração completa para que você possa copiar para um cliente SQL.
Mantenha isso temporário:
Depois de encontrar os piores culpados, mude para amostragem ou aumente o limiar.
Um padrão prático é um pequeno múltiplo dos núcleos de CPU por instância da API, muitas vezes 5–20 conexões abertas no máximo, com número similar de conexões ociosas e reciclagem de conexões a cada 30–60 minutos.
Dois modos de falha comuns:
Lembre-se que pools se multiplicam entre instâncias (20 conexões × 10 instâncias = 200 conexões).
Meça as chamadas ao DB em duas partes:
Se a maior parte do tempo é espera no pool, ajuste o tamanho do pool, timeouts e número de instâncias. Se a maior parte é execução da query, foque em EXPLAIN e índices.
Confirme também que você sempre fecha rows prontamente para devolver conexões ao pool.
Execute EXPLAIN (ANALYZE, BUFFERS) no SQL exato que sua API envia e procure:
Índices devem corresponder ao que o endpoint realmente faz: filtros + ordem de ordenação.
Abordagem prática:
WHERE + ORDER BY.Use um índice parcial quando a maior parte do tráfego atinge um subconjunto previsível de linhas.
Padrão de exemplo:
active = trueUm índice parcial como ... WHERE active = true fica menor, tem mais chance de caber em memória e reduz overhead de escrita em comparação com indexar tudo.
Confirme com que o Postgres realmente o usa para suas queries de alto tráfego.
LIMIT/OFFSET fica mais lento em páginas profundas porque o Postgres ainda precisa pular (e muitas vezes ordenar) as linhas que você está ignorando. A página 1 pode tocar poucas dezenas de linhas; a página 500 pode forçar o banco a escanear e descartar dezenas de milhares só para devolver 20 resultados.
Prefira a paginação por chave (keyset/cursor):
Quase sempre sim para endpoints de listagem. A resposta mais rápida é a que você não envia.
Ganhos práticos:
SELECT *).include= ou para que clientes optem por campos pesados.ORDER BY)Corrija o maior sinal de alerta primeiro; não tente ajustar tudo de uma vez.
Exemplo: se você filtra por user_id e ordena pelos mais novos, um índice como (user_id, created_at DESC) costuma resolver p95 instável.
EXPLAINid).ORDER BY idêntico entre requisições.(created_at, id) ou similar em um cursor.Isso mantém o custo de cada página aproximadamente constante conforme a tabela cresce.
fields=Você frequentemente reduz CPU do Go, pressão de memória e latência de cauda apenas reduzindo o payload.