Índices compostos no Postgres: sete padrões que equipes erram

Um índice composto no Postgres com colunas na ordem errada é decoração. O planner segue regras de prefixo à esquerda — colunas de igualdade primeiro, colunas de range por último — e sete padrões comuns transformam índices em overhead de escrita sem benefício de leitura.

Engenharia6 min de leitura
PostgresBanco de dadosÍndicesPerformance de queriesEXPLAIN
Compartilhar

O índice existe. EXPLAIN mostra um Index Scan. A latência p99 ainda está em 800 ms. O índice composto em (created_at, tenant_id, status) foi criado porque essas três colunas aparecem na cláusula WHERE — mas a query filtra tenant_id = 42 e status = 'active' com created_at > now() - interval '30 days'. O Postgres usa o índice primeiro para o range de datas, lê trinta dias de linhas de todos os tenants e depois filtra. O índice composto no Postgres funciona exatamente como a ordenação btree dita. A equipe construiu decoração.

Índices compostos são ordenados lexicograficamente: linhas ordenadas pela coluna um, depois dentro de valores iguais da coluna um pela coluna dois, e assim por diante. O planner só consegue usar o índice com eficiência quando a query fornece um prefixo contíguo de colunas — começando pela esquerda. Entender essa regra evita sete padrões recorrentes de mau uso que desperdiçam disco, tornam escritas mais lentas e enganam dashboards que contam índices sem ler planos.

A regra do prefixo à esquerda governa cada decisão de índice composto

Um índice btree em (a, b, c) suporta:

  • WHERE a = ?
  • WHERE a = ? AND b = ?
  • WHERE a = ? AND b > ?
  • WHERE a = ? ORDER BY b

Não suporta:

  • WHERE b = ? sozinho
  • WHERE a = ? AND c = ? sem b no predicado (Postgres pode usar skip scan em algumas versões, mas não confie nisso)
  • WHERE c = ? sozinho

A ordem da cláusula WHERE em SQL não importa — o planner reordena predicados. A ordem das colunas do índice importa por completo.

A ordem das colunas do índice não é estilística. É o plano de query.

Regra prática: predicados de igualdade primeiro, em ordem de seletividade dentro das igualdades; predicados de range e colunas de ORDER BY por último. (tenant_id, status, created_at) para WHERE tenant_id = ? AND status = ? AND created_at > ?. Não (created_at, tenant_id, status) quando created_at é um range sobre uma coluna temporal de alta cardinalidade.

Sete padrões de índice composto que equipes erram

1. Coluna de range liderando o índice

Liderar com created_at ou updated_at quando a query usa um range (>, <, BETWEEN) força o índice a escanear cada linha na janela de datas antes de aplicar filtros de tenant ou status. Colocar a coluna de range por último, depois de filtros de igualdade em colunas de alta seletividade.

2. Coluna de baixa cardinalidade primeiro

Liderar com status (cinco valores) ou is_active (booleano) estreita pouco. O índice agrupa a maior parte da tabela em poucos buckets. (tenant_id, status) supera (status, tenant_id) quando ambas aparecem em queries — tenant costuma filtrar mais linhas.

3. Indexar para condições OR

WHERE tenant_id = 1 OR tenant_id = 2 pode não usar um índice composto com eficiência. WHERE tenant_id IN (1, 2) costuma se comportar melhor. Índices separados em ramos OR às vezes superam um composto mal ordenado. Verificar EXPLAIN — Bitmap Index Scans que mesclam vários índices são válidos.

4. Índices de coluna única redundantes

Um índice em (tenant_id, created_at) torna redundante um (tenant_id) standalone para queries que filtram por tenant_id. Remover o índice redundante — dobra o custo de escrita. Manter índices separados só quando padrões de query divergem de fato.

5. Colunas INCLUDE confundidas com colunas de acesso

CREATE INDEX ... INCLUDE (col) armazena colunas em páginas folha para index-only scans, mas não torna col pesquisável via btree. Colocar colunas de filtro apenas em INCLUDE quando precisam ser condições de índice produz index-only scans que ainda leem toda a partição do tenant.

6. Índice composto para sort sem prefixo de filtro

ORDER BY created_at DESC sozinho precisa de (created_at DESC) — não (user_id, created_at DESC) a menos que queries sempre filtrem user_id primeiro. A coluna líder deve aparecer na cláusula WHERE ou o planner não consegue alcançar a coluna de ordenação com eficiência.

7. Oportunidade de índice parcial perdida

WHERE status = 'pending' ORDER BY total DESC LIMIT 50 em uma tabela onde 2% das linhas estão pending pede:

CREATE INDEX idx_pending_by_total
  ON orders (total_amount_cents DESC)
  INCLUDE (order_id, user_id, created_at)
  WHERE status = 'pending';

Um composto completo em (status, total_amount_cents) indexa 100% das linhas quando só 2% precisam de indexação. Índices parciais são menores, mais rápidos de manter e combinam com a query — até o filtro ampliar para status IN ('pending', 'processing'), quando um composto mais amplo pode vencer.

EXPLAIN separa decoração de índices úteis

Executar EXPLAIN (ANALYZE, BUFFERS) — não EXPLAIN sozinho. Buffer hits revelam pressão real de I/O.

Sinal do planoSignificado
Index Cond em colunas líderesÍndice usado como esperado
Filter em colunas indexadas após Index ScanOrdem de colunas errada ou prefixo faltando — linhas lidas e descartadas
Seq Scan em tabela grandeÍndice não elegível — coluna líder faltando ou seletividade ruim
Index Only ScanÍndice covering acertado — colunas INCLUDE funcionando
Passo Sort após Index ScanColuna de sort fora da ordem do índice — reordenar o composto

Uma linha Filter em tenant_id abaixo de um Index Scan em created_at é a prova definitiva do padrão #1. O índice trabalhou — o trabalho errado.

Row-Level Security amplifica erros de índice. Políticas que chamam auth.uid() por linha transformam planos já marginais em desastres. Corrigir a ordem do índice primeiro, depois aplicar padrões de performance de RLS — a briga com o planner é a mesma.

Como equipes devem projetar índices compostos no Postgres?

Estas perguntas evitam índices que parecem corretos em arquivos de migration e falham em produção.

A ordem das colunas na query importa?

Não. A ordem dos predicados em SQL é irrelevante. A ordem das colunas do índice é tudo. Projetar índices a partir de padrões de query, não da ordem em que colunas aparecem em cláusulas WHERE.

Quantas colunas cabem em um índice composto?

Raramente mais de três, salvo uso de tabela extremamente estilizado. A documentação do Postgres alerta que compostos largos costumam ser pouco úteis. Preferir índices parciais focados a compostos de quatro colunas de propósito geral que nada usa com eficiência.

Quando usar índices separados em vez de um composto?

Quando queries filtram subconjuntos distintos de colunas sem prefixos líderes compartilhados — queries (tenant_id) e (status, created_at) podem precisar de dois índices mesclados por Bitmap Scan em vez de um composto que não serve bem a nenhuma.

Uma visão oposta

A postura contrária sustenta que armazenamento é barato e índices devem ser adicionados com liberalidade — melhor sobre-indexar do que perder uma query lenta.

A amplificação de escrita discorda. Cada índice atualiza em cada insert e em updates de colunas indexadas. Índices redundantes e mal ordenados tornam escritas mais lentas, aumentam trabalho de vacuum e confundem o planner com caminhos concorrentes. Em cargas serverless com connection pooling, escritas mais lentas compõem latência em muitas transações curtas.

Indexar com intenção. Medir com EXPLAIN ANALYZE. Remover índices que planos de produção nunca usam.

Key takeaways

  • Índices compostos no Postgres seguem regras de prefixo à esquerda — projetar a partir de padrões de query, não listas de colunas.
  • Colunas de igualdade primeiro; colunas de range e ORDER BY por último.
  • Filter após Index Scan significa ordem errada — linhas lidas e descartadas.
  • Índices parciais superam compostos completos quando queries filtram uma fração pequena de linhas.
  • Remover índices de coluna única redundantes absorvidos por compostos.
  • INCLUDE habilita index-only scans, mas não torna colunas pesquisáveis.
  • Verificar com EXPLAIN (ANALYZE, BUFFERS), não só com a existência do índice.

Conclusion

Índices compostos não são "adicionar todas as colunas do WHERE". São ordens de sort btree que devem alinhar com como queries eliminam linhas. Os sete padrões de mau uso compartilham uma causa raiz: criar índices a partir de intuição de migration em vez de ler planos de query.

A auditoria é simples: pegar as cinco queries de produção mais lentas, executar EXPLAIN ANALYZE, procurar linhas Filter em colunas indexadas, reordenar ou substituir índices, medir de novo. O planner não se importa com intenções. Importa-se com a ordem do prefixo.

Artigos relacionados

Paleta de comandos

Pesquise um comando para executar...