Extended statistics in PostgreSQL

Postgres subestimado: CREATE STATISTICS (extended statistics)

Extended statistics ajudam o PostgreSQL a corrigir estimativas ruins de linhas e escolher planos melhores com custo de storage quase nulo.

Chris Engelbert 22 min

Versão curta: quando uma query está lenta, quase todo mundo pensa primeiro em índices. O PostgreSQL oferece vários: multi-column, partial, expression indexes. O problema é o custo. Mais storage, mais manutenção, mais trabalho na escrita. O que muita gente ignora é CREATE STATISTICS. Extended statistics podem melhorar o planner com um custo de armazenamento quase irrelevante.

Por trás de muitos planos ruins, o problema real não é que o planner “seja burro”. É que ele está tomando decisões racionais com uma imagem errada dos dados. Quando vários predicados estão correlacionados, estatísticas por coluna deixam de bastar. É exatamente aí que entram as extended statistics.

A versão curta

CREATE STATISTICS permite que o Postgres colete informações adicionais que as estatísticas normais por coluna não conseguem representar bem.

Na prática, isso ajuda especialmente em quatro tipos de situação:

  • filtros correlacionados em múltiplas colunas,
  • estimativas de DISTINCT multicoluna,
  • combinações de valores muito enviesadas,
  • expressões para as quais normalmente você pensaria em criar um índice sobre expressão.
-- Alternative to index on expressions
CREATE STATISTICS stats_expr
ON (date_trunc(day, created_at))
FROM events;
 
--- What people normally mean by
--- extended statistics
CREATE STATISTICS stats_multi
(dependencies, ndistinct, mcv)
ON plan_tier, region, billing_status
FROM tenants;

A primeira forma ajuda em expressões como date_trunc ou extração de JSONB. A segunda é o que a maioria das pessoas imagina quando fala em extended statistics.

Os três tipos mais importantes são dependencies, ndistinct e mcv. E, como nas estatísticas normais, CREATE STATISTICS apenas define o objeto; quem realmente coleta os dados é ANALYZE.

Por que usar extended statistics?

Por padrão, o PostgreSQL armazena principalmente estatísticas por coluna. Isso funciona bem enquanto os filtros podem ser tratados como independentes. O problema começa quando a relação entre colunas importa.

WHERE plan_tier = 'enterprise'
AND region = 'eu-central'
AND billing_status = 'active'

Em dados reais, predicados assim frequentemente são correlacionados. Sem estatísticas adicionais, o PostgreSQL se comporta como se cada filtro reduzisse o conjunto de linhas de forma independente. O resultado é uma estimativa matematicamente elegante e operacionalmente absurda.

Fixing the execution plan with extended statistics

E quando a primeira estimativa está errada, quase tudo depois começa a tremer: ordem de joins, tipo de join, paralelismo, tamanho de hash, estratégia de sort e risco de spill.

O que o Postgres sabe por padrão

A cada ANALYZE, o PostgreSQL grava informações por coluna como fração de nulos, histogramas, valores mais comuns e contagens distintas. Tudo isso é útil, mas ainda é conhecimento local a uma única coluna.

Imagine uma tabela com um milhão de linhas e a seguinte distribuição:

  • plan_tier = 'enterprise' atinge 6 %
  • region = 'eu-central' atinge 25 %
  • billing_status = 'active' atinge 80 %
1,000,000 * 0.06 * 0.25 * 0.80 = 12,000 rows

O problema é que essas colunas raramente são independentes no mundo real. A resposta real pode ser 40.000, 60.000 ou 5.000 linhas. Estatísticas por coluna não conseguem dizer isso sozinhas.

Benchmarking de extended statistics

Para tornar isso concreto, construí um workload analítico de SaaS sintético, mas intencionalmente realista, com quatro tabelas: tenants, tenant_entitlements, usage_rollups e analytics_events.

O dataset foi moldado para expor justamente os pontos cegos do planner: correlação entre plano, região e status de cobrança, concentração de entitlements premium e combinações (tenant, feature, day) que não são aleatórias.

É um dataset sintético, sim. Mas sintético no mesmo sentido em que um túnel de vento é sintético: controlado para isolar um fenômeno real.

Isolando o problema pelo formato dos dados

Tabela: tenants

As colunas de segmentação são correlacionadas de uma forma bastante comum em dados reais de SaaS.

Tabela: tenant_entitlements

Features premium como sso, audit_logs e workflows aparecem muito mais em tenants enterprise.

Tabela: usage_rollups

Essa tabela modela um fanout temporal realista por combinação tenant-feature.

Tabela: analytics_events

Aqui está o ponto de explosão de cardinalidade. Para cada combinação tenant-feature que passa pelo filtro, pode haver muitas linhas de eventos crus.

A query de reporting

A pergunta de negócio é simples: “entre os tenants enterprise ativos de uma região importante, quais features premium geraram mais atividade recente?”

SELECT
e.feature_name,
count(*) AS total_events,
sum(r.request_count) AS rolled_requests
FROM tenants t
JOIN tenant_entitlements te
ON te.tenant_id = t.id
JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
WHERE t.plan_tier = 'enterprise'
AND t.region = 'eu-central'
AND t.billing_status = 'active'
AND te.entitlement IN ('sso', 'audit_logs')
AND r.rollup_day >= CURRENT_DATE - 30
AND e.event_type IN ('view', 'click', 'run')
GROUP BY e.feature_name
ORDER BY rolled_requests DESC;

Para analisar várias regiões ao mesmo tempo, usamos uma variante ampliada com CTE e agregação final.

WITH regional_feature_totals AS (
SELECT
t.region,
e.feature_name,
count(*) AS total_events,
sum(r.request_count) AS rolled_requests
FROM tenants t
JOIN tenant_entitlements te
ON te.tenant_id = t.id
JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
WHERE t.plan_tier = 'enterprise'
AND t.billing_status = 'active'
AND t.region IN ('eu-central', 'ap-southeast', 'us-east')
AND te.entitlement IN ('sso', 'audit_logs', 'workflows')
AND r.rollup_day >= CURRENT_DATE - 30
AND e.event_type IN ('view', 'click', 'run')
GROUP BY t.region, e.feature_name
)
SELECT region, feature_name, total_events, rolled_requests
FROM (
SELECT
region,
feature_name,
total_events,
rolled_requests,
row_number() OVER (
PARTITION BY region
ORDER BY rolled_requests DESC,
total_events DESC,
feature_name
) AS regional_rank
FROM regional_feature_totals
) ranked
WHERE regional_rank <= 3
ORDER BY region, regional_rank;

Quebrando o formato da query

Etapa 1: definir o tenant slice

WHERE t.plan_tier = 'enterprise'
AND t.billing_status = 'active'
AND t.region IN ('eu-central', 'ap-southeast', 'us-east')

Se o PostgreSQL errar essa primeira população, todo o restante do plano passa a se apoiar em uma base errada.

Etapa 2: restringir aos features permitidos

JOIN tenant_entitlements te
ON te.tenant_id = t.id
...
AND te.entitlements IN (...)

A tabela de entitlements atua aqui tanto como alvo de join quanto como filtro correlacionado de feature.

Etapa 3: ligar uso recente aos features elegíveis

JOIN usage_rollups r
ON r.tenant_id = t.id
AND r.feature_name = te.entitlement
...
AND r.rollup_day >= CURRENT_DATE - 30

Aqui começa a multiplicidade. Um tenant pode ter vários entitlements relevantes, e um par tenant-feature pode ter vários dias recentes de rollup.

Etapa 4: expandir para eventos brutos

JOIN analytics_events e
ON e.tenant_id = t.id
AND e.feature_name = r.feature_name
...
AND e.event_type IN (...)

Nesse ponto, uma estimativa ruim já custa caro. Nested loops podem parecer baratos no papel quando não são.

Etapa 5: colapsar a explosão de volta

GROUP BY e.feature_name
ORDER BY rolled_requests DESC
GROUP BY t.region, e.feature_name

O resultado final é pequeno, mas o trabalho para chegar até ele não é. É justamente isso que faz dessa query uma ótima demonstração do planner.

Etapa 6: ranking na query multi-região

row_number() OVER (
PARTITION BY region
ORDER BY rolled_requests DESC,
                 total_events DESC,
                 feature_name
) AS regional_rank
...
--- followed by
WHERE regional_rank <= 3

Aqui entram mais sort, mais pressão sobre temp I/O e mais complexidade. Ou seja: não é só um problema de joins e agregação, mas também de ranking.

Extended statistics para o resgate

O reflexo mais comum diante desse tipo de problema é adicionar mais índices multi-column, partial ou sobre expressões. Aqui, em vez disso, usamos três objetos estatísticos para corrigir diretamente os pressupostos errados do planner.

  • colunas correlacionadas de tenants,
  • combinações tenant-entitlement,
  • combinações de rollup.

E claro: depois de criar, é preciso rodar ANALYZE.

Tipo: dependencies

Estatísticas de dependência funcional ajudam o PostgreSQL a entender que certas colunas predizem outras de forma forte o suficiente para que tratá-las como independentes seja errado.

  • País frequentemente prevê moeda.
  • Estado frequentemente prevê fuso horário.
  • O segmento do tenant frequentemente prevê famílias de entitlement.
CREATE STATISTICS stats_tl_segment_dependencies (dependencies)
ON id, plan_tier, region, billing_status
FROM tenants;
WHERE plan_tier = 'enterprise'
AND region = 'eu-central'
AND billing_status = 'active'

Como regra mental: dependencies corrige “esses filtros não são independentes”.

Tipo: mcv

MCV significa “most common values”, mas aqui é mais útil pensar em “most common combinations”.

CREATE STATISTICS stats_tl_segment_mcv (mcv)
ON plan_tier, region, billing_status
FROM tenants;

Assim o PostgreSQL aprende que certas combinações são especialmente comuns, especialmente raras ou impossíveis. Como regra mental: mcv corrige “essa combinação específica se comporta diferente da média”.

Tipo: ndistinct

ndistinct diz ao PostgreSQL quantas combinações distintas de várias colunas realmente existem, em vez de conhecer apenas o distinct de cada coluna isoladamente.

CREATE STATISTICS stats_tel_entitlement_ndistinct (ndistinct)
ON tenant_id, entitlement
FROM tenant_entitlements;
 
CREATE STATISTICS stats_url_feature_ndistinct (ndistinct)
ON tenant_id, feature_name, rollup_day
FROM usage_rollups;

Como regra mental: ndistinct corrige “essas combinações de group by ou join não são tão numerosas quanto o planner imagina”.

O que o PostgreSQL armazena

Aqui a coisa fica realmente interessante. Poderíamos tentar resolver tudo com mais índices. Só que índices custam caro para manter e custam caro em storage.

Extended statistics, por outro lado, são surpreendentemente baratas. As definições vivem em pg_statistic_ext e os dados materializados em pg_statistic_ext_data.

  • pg_statistic_ext diz quais objetos estatísticos existem.
  • pg_statistic_ext_data diz quais dados foram de fato coletados.

Neste benchmark, o footprint total ficou em cerca de 2.059 bytes:

  • Tenant-Segment mcv: ~1.049 bytes
  • Tenant-Segment dependencies: ~518 bytes
  • Entitlement ndistinct: ~220 bytes
  • Rollup ndistinct: ~272 bytes

Esse é o ponto central: não são índices gigantes “sombra”, e sim pequenos metadados que impedem o planner de alucinar uma distribuição errada.

Corrigindo execution plans

Quando comecei a escrever este post, a minha ideia inicial era provar tempos melhores. Houve melhora, sim, mas o ganho mais importante foi na qualidade das estimativas.

ScenarioAverage TimeWorst EstimateAdditional Storage
Extended statistics2,857.896 ms48.27x2.0 KiB
Baseline + Composite Join-Key Indexes5,882.659 ms106.48x37.64 MiB
Baseline + Events (feature_name, event_type, tenant_id)4,781.551 ms108.7x31.45 MiB
Baseline + Partial Hot-Path Indexes4,765.351 ms106.22x30.20 MiB
Extended Statistics + Partial Hot-Path Indexes4,876.187 ms46.65x30.21 MiB

Cada query foi executada cinco vezes. Os planos completos e os resultados estão no repositório.

Query de região única

O schema base usa índices de coluna única. Isso evita execução totalmente sequencial, mas ainda obriga o planner a estimar a interseção de predicados correlacionados em tenants, entitlements, rollups e events.

MetricValue
Average execution time14,168.174 ms
Average planning time4.134 ms
Execution samples31,093.955 ms,
29,065.788 ms,
3,564.811 ms,
3,570.114 ms,
3,546.200 ms
Planning samples5.058 ms,
3.952 ms,
8.969 ms,
1.899 ms,
0.793 ms
Join chainNested Loop:Inner -> Nested Loop:Inner -> Nested Loop:Inner
Scan nodesIndex Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan, Index Scan
Top-level rows7 estimated / 2 actual
Worst estimateNested Loop: 178,389 estimated / 18,774,720 actual (105.25x)
Worst estimate pathSort -> Aggregate -> Nested Loop

Com extended statistics, a história melhora bastante:

MetricValue
Average execution time2,857.896 ms
Average planning time3.025 ms
Execution samples4,318.854 ms,
2,612.261 ms,
2,396.848 ms,
2,467.702 ms,
2,493.816 ms
Planning samples6.719 ms,
2.067 ms,
2.128 ms,
2.243 ms,
1.970 ms
Join chainHash Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesBitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows7 estimated / 2 actual
Worst estimateHash: 12,966 estimated / 625,824 actual (48.27x)
Worst estimate pathSort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Hash Join -> Hash

O planner deixa de agir como se os volumes intermediários fossem minúsculos. Um detalhe importante: o custo total estimado sobe. Isso não é regressão; é um sinal de que o PostgreSQL passou a acreditar em um modelo mais realista.

Query multi-região

Na versão multi-região, o ganho de runtime ficou ainda mais evidente.

Baseline:

MetricValue
Average execution time24,933.705 ms
Average planning time3.648 ms
Execution samples84,815.859 ms,
12,696.190 ms,
11,819.487 ms,
8,482.936 ms,
6,854.054 ms
Planning samples6.125 ms,
5.681 ms,
2.067 ms,
2.181 ms,
2.187 ms
Join chainMerge Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesSubquery Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows28 estimated / 5 actual
Worst estimateSort: 53,580 estimated / 17,164,496 actual (320.35x)
Worst estimate pathIncremental Sort -> Subquery Scan -> WindowAgg -> Incremental Sort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Merge Join -> Sort

Com extended statistics:

MetricValue
Average execution time7,289.319 ms
Average planning time2.875 ms
Execution samples7,503.924 ms,
8,261.715 ms,
7,433.035 ms,
8,181.565 ms,
5,066.357 ms
Planning samples9.244 ms,
0.932 ms,
1.039 ms,
1.801 ms,
1.359 ms
Join chainHash Join:Inner -> Nested Loop:Inner -> Merge Join:Inner
Scan nodesSubquery Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Bitmap Heap Scan, Bitmap Index Scan, Index Scan
Top-level rows28 estimated / 5 actual
Worst estimateNested Loop: 68,387 estimated / 1,113,566 actual (16.28x)
Worst estimate pathIncremental Sort -> Subquery Scan -> WindowAgg -> Incremental Sort -> Aggregate -> Gather Merge -> Sort -> Aggregate -> Hash Join -> Hash -> Nested Loop

Isso não é um ajuste marginal. É o PostgreSQL voltando a operar mais ou menos no mesmo CEP da realidade.

As queries realmente ficaram mais rápidas?

Às vezes sim, às vezes não. É exatamente por isso que o assunto merece cuidado.

Runtime de região única

  • Média baseline: 14,168.174 ms
  • Média com extended statistics: 2,857.896 ms

Isso parece espetacular, mas o baseline ficou muito distorcido por duas primeiras execuções extremamente lentas. A leitura mais honesta é: o planner passou a entender melhor o problema, e isso se correlacionou com melhor comportamento em regime estável.

Runtime multi-região

  • Somente statistics: 7,289.319 ms
  • Melhor estratégia apenas com índices: 6,886.473 ms

Isso não enfraquece o caso de CREATE STATISTICS. Apenas o deixa mais preciso: estatísticas melhoram a cardinalidade, índices criam caminhos físicos de acesso.

Por que a comparação com índices importa

Um dos resultados mais interessantes foi que alguns cenários cheios de índices pareciam atraentes em custo ou runtime, mas continuavam muito piores em qualidade de estimativa.

  • Extended statistics: cerca de 2.0 KiB
  • Alternativas pesadas em índices: cerca de 30.20 MiB a 37.64 MiB

Difference in storage usage between extended statistics and indexes

Esse é o ponto subestimado: com cerca de 2 KiB de metadados, o PostgreSQL fica muito menos errado. Já as alternativas baseadas em índices consomem dezenas de MiB extras e muitas vezes nem atacam o problema real do planner.

Por que “usar os dois” não venceu automaticamente

Também testei a pergunta óbvia: extended statistics somadas a índices ideais seriam automaticamente melhores?

Não necessariamente.

Single-region:

  • Somente statistics: 2,857.896 ms
  • Statistics + Partial Hot-Path Indexes: 4,876.187 ms

Multi-region:

  • Somente statistics: 7,289.319 ms
  • Statistics + Partial Hot-Path Indexes: 8,950.207 ms

Mais índices não criam apenas novas oportunidades. Eles também mudam o espaço de busca do planner. “Usa os dois e pronto” não é lei da natureza; é mais uma hipótese para benchmark.

Caveats práticos

Existem algumas limitações que vale explicitar:

  • Extended statistics só valem dentro de uma única relação.
  • O PostgreSQL não as usa diretamente para estimativa de selectivity de joins entre tabelas.
  • Nem todo workload se beneficia. Se as colunas forem realmente quase independentes, o ganho pode ser pequeno.
  • Elas não substituem bom desenho de query, índices úteis, particionamento, memória bem configurada e storage rápido.

É uma funcionalidade de informação para o planner, não um amuleto universal de performance.

Quando recorrer ao CREATE STATISTICS

A regra prática é simples: se o plano parece errado, e essa “estranheza” está ligada a múltiplas colunas sendo filtradas ou agrupadas juntas de uma forma que o planner modela mal, pense em extended statistics antes de sair criando mais um índice por reflexo.

Bons candidatos incluem:

  • colunas de filtro fortemente correlacionadas,
  • atributos de dimensão guiados por segmento,
  • workloads de group by com cardinalidade claramente errada,
  • problemas de fanout multicoluna,
  • combinações enviesadas de valores frequentes,
  • planos em que uma má estimativa de uma única relação contamina todo o resto.

Piores candidatos incluem problemas de caminho de acesso físico inexistente, workloads claramente dominados por I/O e casos em que as colunas são de fato quase independentes.

Use EXPLAIN (ANALYZE, VERBOSE, BUFFERS). Se estimated rows e actual rows divergem muito, extended statistics merecem um teste sério.

Prefira verdade a truques

A melhor coisa em CREATE STATISTICS é que ela alimenta o planner com mais verdade, em vez de simplesmente empilhar mais estruturas em cima dos dados.

Os benchmarks não dizem “extended statistics sempre vencem”. Eles dizem algo mais útil:

  • elas tornaram o PostgreSQL muito menos errado,
  • mudaram o formato do plano de forma significativa,
  • custaram quase nada em storage,
  • e muitas vezes atacaram o problema do planner mais diretamente do que dezenas de MiB em índices extras.

O benchmark, as queries e os resultados estão no GitHub. Você também pode testar tudo na nossa Vela Postgres Sandbox.

E se você já olhou para um plano e pensou “não existe universo em que esse join deveria estar aninhado três vezes dentro de um sobretudo”, existe uma boa chance de que o recurso que você procurava fosse justamente extended statistics.