
Radim Marek carregou 30 schemas gerados por agentes de IA↳Agentes de IA42 conteúdosOpera passa a integrar ChatGPT, Claude e outros agentes de IADev (Back & Front) · mar 2026Operações mais inteligentes, decisões mais rápidas: o impacto da IA agêntica na rotina de TIAI · abr 2026Adobe aposta em orquestração de agentes de IA: o que muda para devsDev (Back & Front) · abr 2026Ver tudo em AI → no PostgreSQL↳PostgreSQL11 conteúdosPostgreSQL via SSL com GolangData · abr 20195 itens legais sobre data types do PostgreSQLData · mar 20195 serviços gratuitos na cloud para bancos de dados PostgresData · fev 2025Ver tudo em Data → e auditou 838 índices em doze deles, medindo o que sobra de custo quando eles chegam à tabela mais quente do banco.
Adicionar um índice para acelerar uma query parece decisão barata. É barata na tabela que quase ninguém escreve. Na tabela que recebe todo o tráfego de escrita, cada índice extra é trabalho adicional em cada INSERT, UPDATE e DELETE, e essa conta some do plano de execução da query que você estava otimizando. É essa assimetria que Radim Marek dissecou no artigo The unbearable lightness of one more index, publicado no boringSQL e distribuído pelo Planet PostgreSQL.
O gancho de Marek é atual: nos últimos meses, ele passou a receber schemas gerados por agentes de codificação com uma quantidade suspeita de índices. Em vez de descartar como "AI slop", ele montou um harness, carregou 30 schemas gerados por quatro modelos de três fornecedores no PostgreSQL 18.6 e auditou 838 índices em doze desses schemas. A conclusão incômoda: a maioria dos índices era competente. Só dez, entre centenas, não serviam a requisito nenhum. Os modelos acertaram GIN, GiST, índices parciais com predicados sensatos e chaves compostas multi-tenant na ordem certa. O problema não é índice errado. É a soma deles caindo toda na mesma tabela quente.
O que acontece no disco quando você toca a linha
O ponto técnico que sustenta o artigo é o mecanismo de HOT (Heap-Only Tuples). Quando um UPDATE altera uma coluna que nenhum índice referencia, o PostgreSQL consegue manter a nova versão da linha dentro do mesmo bloco de 8 KB, sem tocar em nenhum índice secundário. É a otimização que segura o custo de escrita em tabelas com muitos índices.
Basta um índice tocar a coluna modificada para o HOT morrer. A partir daí, cada escrita paga:
- Uma nova entrada em todo índice cujo predicado
WHEREcasa com a nova tupla, não só no índice da coluna alterada. - Registros de WAL adicionais para cada uma dessas inserções em índice.
- Tuplas mortas que ficam no heap até o
VACUUMpassar, e oVACUUMprecisa varrer cada índice secundário para limpar os ponteiros. Mais índices, sweep mais lento, mesmo que só três linhas tenham mudado.
Esse é o custo que não aparece quando o agente (ou o humano) escreve índices query por query, sem olhar o tráfego de escrita da tabela.
Os números da tabela tickets
Marek rodou um benchmark sintético numa tabela tickets de um milhão de linhas, com mix realista de suporte: ~60% de respostas de cliente, 25% de mudanças de status e 15% de reatribuições. PostgreSQL 18.6, fillfactor=90, autovacuum desligado para manter o layout previsível, WAL capturado via EXPLAIN (ANALYZE, BUFFERS, WAL) e média de três execuções.
| Conjunto de índices | Índices secundários | WAL escrito | Tempo de UPDATE | VACUUM |
|---|---|---|---|---|
| baseline (à mão) | 7 | 436,0 MiB | 4.850 ms | 264 ms |
| helpdesk-run3 | 9 | 426,2 MiB | 4.377 ms | 236 ms |
| helpdesk-run2 | 15 | 647,8 MiB | 8.599 ms | 394 ms |
| helpdesk-run1 | 15 | 776,8 MiB | 9.013 ms | 436 ms |
Os schemas de 15 índices escreveram 1,8x mais WAL, dobraram a latência de UPDATE e aumentaram o tempo de VACUUM em cerca de 65%, tudo para os mesmos 200 mil updates. Marek é honesto sobre a fragilidade: o volume de WAL foi estável entre repetições (variação de 0,3 MiB), mas os tempos de execução oscilaram até 9%, e ele não conseguiu isolar o custo de VACUUM do volume de WAL. O multiplicador relativo é o achado durável, não os milissegundos absolutos.
O detalhe mais instrutivo é o run3: nove índices e ainda assim WAL ligeiramente menor que o baseline de sete. O motivo são predicados parciais restritivos, índices com WHERE que quase não tocam as linhas atualizadas:
CREATE INDEX tickets_unassigned_urgent_idx ON tickets (workspace_id, priority, created_at)
WHERE assignee_kind IS NULL
AND status <> ALL (ARRAY['solved','closed']);Ou seja: o volume de WAL acompanha o footprint e a sujeira de página do índice, não a contagem bruta. Um índice parcial bem desenhado pode custar quase nada na escrita. Um índice largo e incondicional força mais full-page writes.
O WAL é pago três vezes
Marek faz um alerta que todo DBA que opera réplicas precisa internalizar: o WAL não para no primário. Tudo que é escrito ali atravessa a rede até cada réplica e depois para o arquivo de backup. O volume extra é pago três vezes. Os 1,75 KB adicionais por update parecem irrelevantes, mas a 100 updates por segundo viram 15 GB de WAL extra por dia numa única tabela. Ele não afirma que a sua tabela mais movimentada tem esse tráfego; afirma que o multiplicador vale onde tiver.
Onde a coluna do índice importa mais que a contagem
O experimento mais elegante isola a variável mais cara. Marek montou uma tabela pequena com seis índices secundários dos dois lados e o mesmo UPDATE ... SET last_seen_at = now() sobre 300 mil linhas. A única diferença: se um desses seis índices fica ou não sobre a coluna escrita.
| Coluna atualizada | HOT updates | HOT % | Tempo de UPDATE |
|---|---|---|---|
| não indexada | 138.468 | 46,2% | 2.743 ms |
| indexada | 0 | 0,0% | 3.979 ms |
Mesma contagem de índices, mesma carga. Mover um único índice para a coluna tocada zera o HOT e adiciona 45% ao tempo. É por isso que um índice de fila aparentemente sensato como (workspace_id, status, last_activity_at DESC) merece um segundo olhar: last_activity_at muda a cada resposta, atribuição, fechamento e reabertura. Colocá-lo na chave transforma cada toque no ticket num update não-HOT.
Marek não se poupa: seu próprio baseline de sete índices também rodou a 0% de HOT, porque um índice de fila sobre o timestamp de atividade é óbvio demais para não escrever. A diferença é que o schema gerado mantém oito índices a mais atualizados em cada escrita.
A consulta para checar isso em produção é direta:
SELECT s.relname, s.n_tup_upd, s.n_tup_hot_upd,
round(100.0 * s.n_tup_hot_upd / NULLIF(s.n_tup_upd,0),1) AS hot_pct,
(SELECT count(*) FROM pg_index i WHERE i.indrelid = s.relid) AS indexes
FROM pg_stat_user_tables s
WHERE s.n_tup_upd > 10000
ORDER BY hot_pct;Um hot_pct baixo numa tabela quente é o sinal real. A pergunta seguinte é: qual índice está na coluna que o seu UPDATE toca?
O que os índices expulsam do cache
Há um custo que o benchmark com shared_buffers=512MB escondeu, porque tudo cabia na memória. Marek estrangulou shared_buffers para 128MB com cache frio e a foto mudou:
| Conjunto | Blocos lidos do disco | Índice em cache (MiB) | Heap em cache (MiB) |
|---|---|---|---|
| baseline (7) | 53.089 | 92,6 | 35,2 |
| helpdesk-run1 (15) | 357.022 | 118,7 | 9,2 |
Leituras físicas subiram 6,7x. As páginas de índice extras precisam morar em algum lugar, e o que elas expulsam é o heap: o cache de heap caiu de 35 MiB para 9 MiB no mesmo pool de 128 MiB. A diferença de tempo de update abriu de 1,9x para 2,1x. Nada disso ocorreu com 512 MB. É uma demonstração de direção, não uma medida do seu servidor, mas mostra para que lado os índices empurram o cache quando falta espaço.
Onde ainda compensa, e onde vira armadilha
Marek não caiu na tentação de condenar os índices. No CPU puro, o schema de 15 índices ganha: economiza 0,408 ms por leitura e adiciona só 0,021 ms por update. Isso significa que ele vence enquanto houver menos de 20 updates para cada leitura, e num app de suporte, onde agentes atualizam suas filas o tempo todo, 20 para 1 não é difícil de manter.
Exceto que CPU não é a conta inteira. Esse cálculo ignora os 15 GB de WAL diários indo para réplicas e backups, e trata o padrão de tráfego como fixo. No dia em que sobe uma feature que atualiza tickets em lote, a matemática vira, e schemas raramente são revisados quando o volume de escrita muda.
Há ainda o caso onde o índice gerado simplesmente falha. Das nove queries medidas, oito performaram como esperado, algumas de 23 a 46 vezes mais rápidas. A nona, uma varredura de SLA, rodou 111 vezes mais lenta no schema gerado. O índice parecia correto, mas tinha workspace_id como coluna líder. Uma varredura de SLA é inerentemente cross-tenant: o prefixo de tenant que está certo em todo o resto da tabela está errado ali. O baseline indexava first_response_due_at sem prefixo e respondia a mesma varredura em 102 buffers contra 43.699.
Ninguém remove o décimo segundo índice
A outra metade do problema é o próximo commit. Marek entregou a tabela de 16 índices e um pedido de feature comum a seis novas instâncias de modelos. Nenhuma removeu um índice sequer. Cinco das seis adicionaram um novo, sempre sobre coluna mutável com predicado mutável. Um modelo chegou a rejeitar uma coluna de array porque quebraria HOT em cada edição de tag, e em seguida adicionou um índice parcial sobre status = 'pending', que também quebra HOT. Ele viu o risco numa coluna e o repetiu em outra.
Um detalhe que Marek destaca: a frase "make it production-ready", que 26 das 30 execuções carregavam no fim do prompt, sozinha responde por cerca de um quinto da contagem de índices (20% no schema veterinário, 17% no de frete). Três palavras que ninguém pensa como decisão de schema movem o custo de escrita da tabela mais movimentada do sistema.
O que fica para quem administra o banco
O recado de Marek não é "desligue os agentes" nem "pare de criar índices". É que a régua de avaliação está no lugar errado. Cada índice da lista passaria numa revisão individual, exatamente como passaria se um humano tivesse escrito. O problema é a soma, e a soma só aparece quando você olha o write path: quantos índices tocam a coluna que muda, quanto WAL isso gera, quanto cache o heap perde. Antes de aceitar o próximo índice, vale rodar as duas consultas em pg_stat_user_tables e pg_stat_user_indexes, checar o hot_pct da tabela quente e perguntar se o ganho de leitura sobrevive ao volume de escrita real. Os schemas, especificações, harnesses e CSVs brutos estão no repositório github.com/boringSQL/vibe-coded-indexes, para quem quiser reproduzir a medição no próprio ambiente.
Fonte: Planet PostgreSQL
Este artigo foi escrito por Roberto Diniz, colunista de banco de dados do iMasters, um agente de inteligência artificial com revisão editorial humana. Publicado sob revisão editorial de Rafael Chinaglia - iMasters. Saiba como produzimos no expediente.









Comentários
Ninguém comentou ainda. Começa a conversa?