SQLServer Geral 22 Jul 2026

Estatísticas no SQL Server: por que seus planos de execução começam aqui

Toda madrugada, uma rotina varria 2,8 bilhões de linhas para atualizar estatísticas de dados que não mudaram. Neste primeiro post da série SQL Server × PostgreSQL, mergulho no objeto que decide todos os seus planos de execução: histograma, density vector, thresholds de auto update e o que o INCREMENTAL = ON realmente faz (e o que não faz) em tabelas particionadas.

Post 01 de 03 da série Estatísticas: SQL Server × PostgreSQL

O sintoma que ninguém questiona

Toda madrugada, às 02h00, uma rotina de UPDATE STATISTICS entra em execução em um ambiente que administro. A tabela principal tem cerca de 4 bilhões de linhas e 2 TB, particionada por período. A rotina varria, a cada execução, 2,8 bilhões de linhas para atualizar estatísticas de dados que em sua esmagadora maioria não mudaram nada desde a noite anterior.

Voce pode se perguntar o Por quê? porque em uma tabela particionada por data, a escrita acontece quase toda na última partição. As partições históricas são, na prática, read-only. Mas a rotina não sabia disso, ela atualizava a estatística da tabela inteira, toda noite, consumindo horas de janela de manutenção, I/O e CPU para reler bilhões de linhas congeladas.

Esse é o retrato de muitos ambientes que avalio a rotina de estatísticas foi herdada, roda porque “sempre rodou assim, desde antes de eu entrar na empresa”, e ninguém parou para perguntar o que exatamente ela está atualizando e se precisava.

Para responder essa pergunta, precisamos voltar um passo e entender o que é, afinal, uma estatística?

O que é uma estatística (de verdade)

Estatística não é um número mágico. Segundo a documentação oficial da Microsoft, estatísticas de otimização de consulta são BLOBs (binary large objects) que armazenam informação sobre a distribuição de valores em uma ou mais colunas de uma tabela ou view indexada. O otimizador usa esses objetos para estimar a cardinalidade, o número de linhas, de cada etapa da consulta, e é essa estimativa que decide, por exemplo, entre um Index Seek e um Index Scan, entre um Nested Loops e um Hash Match.

Kalen Delaney e coautores, no livroe Microsoft SQL Server Internals (Microsoft Press), resumem bem a essência do problema: o otimizador é baseado em custo, e o custo é calculado sobre estimativas, ele nunca olha os dados reais no momento da compilação, apenas os valores estatístico que existe deles. Se os valores estão velhos ou mal amostrado, o otimizador toma uma decisão racional sobre uma premissa errada.

Fisicamente, esse objeto é composto por três partes:

1. O histograma. Uma representação da distribuição de valores da coluna líder da estatística, com no máximo 200 steps. Cada step guarda um valor de fronteira (RANGE_HI_KEY), quantas linhas têm exatamente aquele valor (EQ_ROWS), quantas caem dentro do intervalo (RANGE_ROWS) e quantos valores distintos existem (DISTINCT_RANGE_ROWS).

Pare um segundo nesse número: 200 steps. Não importa se a tabela tem 10 mil ou 4 bilhões de linhas o otimizador enxerga a distribuição dos seus dados resumida em, no máximo, 200 faixas. Em tabelas gigantes, cada step pode representar milhões de linhas, e valores “escondidos” dentro de um range podem ter estimativas bem distantes da realidade.

Um detalhe de internals que vale conhecer, o histograma não nasce de um simples “divide em 200 pedaços”. A documentação da Microsoft descreve a construção em três etapas, sobre o conjunto ordenado de valores da coluna:

  1. Inicialização: os primeiros valores da sequência ordenada são processados e até 200 valores de RANGE_HI_KEY são coletados;
  2. Scan com merge de buckets: cada valor seguinte é adicionado ao último range ou vira um novo range no fim e, quando um novo range nasce, um par de ranges vizinhos é colapsado em um só, escolhido de forma a minimizar a perda de informação. É o chamado algoritmo de maximum difference, minimizar o número de steps maximizando a diferença entre os valores de fronteira;
  3. Consolidação: ranges adicionais podem ser colapsados se isso não perder informação relevante, por isso um histograma pode terminar com menos de 200 steps mesmo em colunas com milhares de valores distintos.

Importante: o histograma é construído somente sobre a primeira coluna (a coluna principal) do conjunto de chaves da estatística. As demais colunas de uma estatística multi-coluna só contribuem para o density vector, o que explica por que a ordem das colunas em índices e estatísticas importa tanto para a qualidade das estimativas.

2. O density vector: A medida de seletividade média das colunas, calculada como 1 / número de valores distintos, quanto menor a density, mais seletiva a coluna (pense numa coluna de CPF, onde cada valor identifica uma única pessoa, versus uma coluna de cidade, onde o mesmo valor se repete milhares de vezes). É ele que o otimizador usa quando não consegue usar o histograma, por exemplo, em predicados com variáveis locais ou em colunas secundárias de estatísticas multi-coluna.

E há uma sutileza que derruba muita gente: o density vector guarda um density para cada prefixo das colunas da estatística. Uma estatística em (CustomerId, ItemId, Price) tem densities para (CustomerId), (CustomerId, ItemId) e (CustomerId, ItemId, Price), mas não para (CustomerId, Price). Se a sua consulta filtra por CustomerId e Price pulando a coluna do meio, aquela correlação simplesmente não existe para o otimizador.

E o que acontece, então, quando a consulta filtra só por CustomerId e Price? O otimizador não desiste, ele estima cada predicado isoladamente (o CustomerId pelo histograma dessa estatística, já que é a coluna líder; o Price por alguma outra estatística que tenha Price como líder, tipicamente uma _WA_Sys_ auto-criada) e depois combina as duas seletividades por uma fórmula. E é aqui que a versão do Cardinality Estimator muda tudo:

  • Legacy CE: assume independência total entre os predicados e simplesmente multiplica as seletividades: S1 × S2. Se 1% das linhas são do cliente e 1% têm aquele preço, a estimativa vira 0,01% ótimo se as colunas forem de fato independentes, desastre se forem correlacionadas;
  • New CE (2014+): assume correlação parcial e usa o chamado exponential backoff, ordena os predicados do mais seletivo para o menos seletivo e amortece os seguintes S1 × S2^(1/2) × S3^(1/4) × S4^(1/8). A estimativa fica maior (mais conservadora) que a do Legacy.

Repare que nenhuma das duas fórmulas conhece a correlação real entre CustomerId e Price, ambas são chutes matemáticos. Quando as colunas são fortemente correlacionadas (cada cliente compra numa faixa de preço própria, por exemplo), as duas subestimam o Legacy mais agressivamente. Subestimativa vira Nested Loops onde deveria ser Hash Match, memory grant curto, spill em tempdb.

A solução, quando essa combinação de filtros é frequente e a estimativa comprovadamente erra, é dar ao otimizador a correlação que falta, criar uma estatística (ou índice) com o prefixo certo CREATE STATISTICS St_Customer_Price ON dbo.Pedido (CustomerId, Price). Agora (CustomerId, Price) é um prefixo válido, a density existe, e a estimativa passa a refletir a correlação real.

3. Os metadados de amostragem. Principalmente rows × rows_sampled: quantas linhas a tabela tinha no momento da atualização e quantas foram efetivamente lidas para construir o histograma. Guarde essa dupla ela volta daqui a pouco.

Como ler tudo isso

DBCC SHOW_STATISTICS ('dbo.Cliente', 'IDX_Cliente_Data');

O resultado vem em três blocos, que correspondem exatamente às três partes acima:

  • Header: Updated (última atualização), Rows, Rows Sampled, Steps;
  • Density vector: All density por combinação de colunas;
  • Histograma: os steps com RANGE_HI_KEY, EQ_ROWS, RANGE_ROWS, AVG_RANGE_ROWS.

Em versões mais recentes (SQL 2016 SP1 CU2+), dá para consultar o mesmo conteúdo via DMFs, o que facilita automatizar análises:

SELECT *
FROM sys.dm_db_stats_properties(OBJECT_ID('dbo.Cliente'), 1);

SELECT *
FROM sys.dm_db_stats_histogram(OBJECT_ID('dbo.Cliente'), 1);

Quando o otimizador erra uma estimativa em três ordens de grandeza, ele não está “com bug”. Ele está lendo exatamente o que está gravado nesse objeto. Plano de execução ruim, na maioria das vezes, é sintoma e a causa mora aqui.

De onde nascem as estatísticas: AUTO_CREATE_STATISTICS

Antes de falar de atualização, vale responder a pergunta anterior: quem cria esses objetos? Segundo a documentação, as estatísticas nascem por dois caminhos automáticos:

1. Junto com os índices. Todo índice que você cria carrega uma estatística sobre suas colunas de chave, com o mesmo nome do índice. Se o índice é filtrado, a estatística nasce filtrada sobre o mesmo subconjunto. São as “estatísticas de índice” aquelas da Classe 2, que mais adiante veremos que só viram incrementais via rebuild.

2. Pelo AUTO_CREATE_STATISTICS. Com a opção ligada (padrão), quando uma consulta filtra por uma coluna que não tem histograma em nenhuma estatística existente, o otimizador cria na hora uma estatística de coluna única para melhorar a própria estimativa. São as famosas _WA_Sys_* que povoam qualquer base em produção. Três limites importantes que a documentação deixa claros: a opção cria apenas estatísticas de coluna única, sempre sobre a tabela inteira (nunca filtradas), e não tem relação com as estatísticas de índice desligá-la não afeta índices.

Para inventariar o que o otimizador já criou sozinho no seu banco:

SELECT OBJECT_NAME(s.object_id) AS Tabela,
       COL_NAME(sc.object_id, sc.column_id) AS Coluna,
       s.name AS Estatistica,
       STATS_DATE(s.object_id, s.stats_id) AS UltimaAtualizacao
FROM sys.stats AS s
JOIN sys.stats_columns AS sc
    ON s.stats_id = sc.stats_id AND s.object_id = sc.object_id
WHERE s.name LIKE '_WA%'
ORDER BY Tabela, Coluna;

Esse inventário conta uma história de cada _WA_Sys_* é uma coluna que alguma consulta filtrou sem ter índice ou estatística de apoio. Um volume grande delas numa mesma tabela é pista de investigação às vezes de índice faltando, às vezes de predicado que nem deveria existir.

A recomendação oficial é direta: mantenha AUTO_CREATE_STATISTICS ligado mesmo quando você cria estatísticas manualmente via CREATE STATISTICS os dois mecanismos se complementam. As criações manuais entram em cena nos casos que o automático não cobre, estatísticas multi-coluna para predicados com colunas correlacionadas que não estão no mesmo índice, e estatísticas filtradas para subconjuntos com distribuição própria.

Duas notas de versão para fechar:

  • Desde o SQL Server 2022, existe ainda o AUTO_DROP (habilitado por padrão em bases novas e migradas): estatísticas criadas manualmente passam a se comportar como as auto-created diante de mudanças de schema em vez de bloquear um ALTER TABLE, são descartadas e recriadas depois. Quem já teve deploy travado por estatística criada por ferramenta de terceiro sabe a dor que isso resolve.
  • E adiantando um gancho, no nível do banco dá para fazer as futuras auto-created já nascerem incrementais em tabelas particionadas voltamos a isso na seção de conversão.

Quando a estatística se atualiza sozinha

Com AUTO_UPDATE_STATISTICS = ON (o padrão), o SQL Server marca a estatística como desatualizada quando o contador de modificações da coluna líder (modification_counter) cruza um threshold e a atualização acontece na próxima consulta que precisar dela.

O detalhe é qual threshold:

  • Regra antiga (até 2014, sem trace flag): para tabelas com mais de 500 linhas, 500 + (0,20 × n) ou seja, 500 modificações + 20% das linhas da tabela. Em uma tabela de 100 mil linhas, isso significa ~20.500 modificações. Razoável. Já em uma tabela de 4 bilhões de linhas, significa 800 milhões de modificações antes de qualquer auto update. Na prática: nunca.
  • Regra dinâmica (2016+ com compatibility level 130+, ou TF 2371 no 2008 R2 SP1–2014): a fórmula documentada é MIN( 500 + (0,20 × n), SQRT(1.000 × n) ) o menor entre a regra antiga e a raiz quadrada. Para tabelas pequenas, nada muda (a regra dos 20% ainda vence); é a partir de ~25 mil linhas que a raiz quadrada assume. Para os mesmos 4 bilhões de linhas, o gatilho cai para cerca de 2 milhões de modificações. Ordens de grandeza mais sensato. A própria Microsoft cita, como cenário típico para habilitar a TF 2371 em versões antigas, exatamente o caso de quem depende de job noturno porque o auto update nunca dispara.

Um detalhe pouco conhecido da documentação: a marcação de estatística desatualizada pelo contador de modificações acontece mesmo com AUTO_UPDATE_STATISTICS = OFF. Com a opção desligada, o SQL Server continua sabendo que a estatística está velha ele só não faz nada a respeito, e os planos seguem usando o objeto desatualizado. Por isso a recomendação oficial é manter a opção ligada, com a rotina manual atuando como complemento, não como substituto.

Se você administra bases grandes em compatibility level antigo e nunca habilitou a TF 2371, é bem provável que suas estatísticas de tabelas grandes só sejam atualizadas quando a sua rotina manual roda. O auto update, para elas, é apenas decorativo.

Sync ou async?

Por padrão, o auto update é síncrono, a consulta que disparou a atualização espera ela terminar para compilar o plano. Em estatística pequena, imperceptível. Em tabela grande, é aquela query que “às vezes demora do nada”.

AUTO_UPDATE_STATISTICS_ASYNC = ON inverte a lógica: a consulta compila com a estatística velha e a atualização roda em background. A documentação recomenda o modo assíncrono justamente para os cenários clássicos de OLTP, aplicações que executam as mesmas consultas com frequência e precisam de tempo de resposta previsível, e aplicações que já sofreram timeout de cliente por consultas esperando atualização de estatística. Você troca “uma consulta aleatória paga o pato” por “o plano fica levemente defasado por alguns instantes”.

Mas o async tem um efeito colateral de concorrência que pouca gente conhece, a atualização em background precisa de um lock de modificação de schema (Sch-M) no metadado da estatística para gravar o resultado. Em workloads com compilações muito frequentes, essa disputa de locks pode gerar bloqueio, a sessão de background segurando (ou esperando) o Sch-M enquanto outras sessões precisam do Sch-S para compilar. A partir do SQL Server 2022 (e no Azure SQL), existe a resposta para isso: a configuração de escopo de banco ASYNC_STATS_UPDATE_WAIT_AT_LOW_PRIORITY, que coloca a gravação da estatística numa fila de baixa prioridade o background só adquire o lock quando ninguém mais está segurando o metadado, e as demais sessões seguem compilando com a estatística existente:

ALTER DATABASE SCOPED CONFIGURATION
SET ASYNC_STATS_UPDATE_WAIT_AT_LOW_PRIORITY = ON;

Se você está em SQL Server 2022 com async habilitado em base de alto volume, essa configuração merece entrar no seu checklist.

A pegadinha do sample automático

Aqui está o ponto que mais engana em VLDB quando o auto update dispara (ou quando você roda UPDATE STATISTICS sem especificar sample), o SQL Server decide sozinho a taxa de amostragem e ela cai conforme a tabela cresce. Em tabelas de bilhões de linhas, o sample automático pode ficar abaixo de 1%.

Volte à dupla rows × rows_sampled do header do DBCC SHOW_STATISTICS. Se você vê 4 bilhões em rows e 30 milhões em rows_sampled, seu histograma de 200 steps foi construído lendo menos de 1% dos dados. Para distribuições uniformes, funciona. Para dados enviesados e dados reais quase sempre são é receita para estimativa ruim. E lembre da nota da documentação: quando o histograma nasce de amostra, os valores de EQ_ROWS, RANGE_ROWS e afins são estimativas por isso você vê números quebrados no histograma.

Duas armadilhas correlatas, direto da documentação, que merecem destaque em ambiente particionado:

  • Rebuild de índice particionado não faz FULLSCAN de estatística. Desde o SQL Server 2014, criar ou reconstruir um índice particionado não varre todas as linhas para a estatística, usa o algoritmo de sample padrão. Ou seja, aquele ALTER INDEX ... REBUILD que você contava como “de brinde atualiza a estatística com fullscan” não entrega isso em índice particionado. Se precisar de fullscan, é UPDATE STATISTICS ... WITH FULLSCAN explícito.
  • PERSIST_SAMPLE_PERCENT (2016 SP1 CU4+): quando você calibra um sample decente para uma estatística crítica, o próximo auto update joga sua calibragem fora e volta ao sample automático. Essa opção resolve o percentual definido fica persistido para as atualizações seguintes que não especificarem sample:
UPDATE STATISTICS dbo.Cliente (St_Cliente_DataMovimento)
WITH SAMPLE 10 PERCENT, PERSIST_SAMPLE_PERCENT = ON;

A conclusão parcial é incômoda: em tabelas gigantes, o auto update chega tarde (ou nunca) e, quando chega, amostra pouco. Por isso a rotina manual de estatísticas existe e é indispensável em VLDB. O problema é como ela costuma ser escrita e é aí que o particionamento entra.

Particionamento: N partições, UMA estatística

Aqui mora um mal-entendido, mais comum que encontro em ambientes particionados.

Quando você particiona uma tabela, o SQL Server divide o armazenamento em N partições cada uma com seus segmentos de dados, alocação e possibilidade de manutenção individual de índice. É natural assumir que as estatísticas seguem a mesma lógica.

E a resposta é: Não seguem. Por padrão, a estatística de uma tabela particionada é uma só, agregada, no nível da tabela. Um único histograma de 200 steps descrevendo 4 bilhões de linhas espalhadas por dezenas de partições.

As consequências práticas:

  1. Manutenção tudo-ou-nada: para atualizar a estatística, o SQL Server precisa reler a tabela inteira (ou uma amostra dela inteira). Não existe, no modelo padrão, “atualiza só a partição de julho”. Foi exatamente isso que gerou a varredura de 2,8 bilhões de linhas da abertura: 99% do esforço relendo partições que não mudaram um byte.
  2. 200 steps para tudo: a partição corrente justamente a mais consultada e a mais volátil divide os mesmos 200 steps com todo o histórico.

É para atacar o problema nº 1 que existe o INCREMENTAL = ON.

INCREMENTAL = ON: o que ele realmente faz

UPDATE STATISTICS dbo.Cliente(St_Cliente_DataMovimento)
WITH FULLSCAN, INCREMENTAL = ON;

Com estatísticas incrementais, o SQL Server passa a manter, internamente, uma estrutura de estatística por partição e mescla essas estruturas para produzir a estatística final da tabela. A partir daí, quando os dados de uma partição mudam, você pode atualizar só ela:

-- Atualiza apenas a partição corrente (ex.: partição 87)
UPDATE STATISTICS dbo.Cliente(St_Cliente_DataMovimento)
WITH RESAMPLE ON PARTITIONS (87);

O SQL Server relê apenas a partição indicada, reconstrói a estatística daquela fatia e refaz a mesclagem. No caso da abertura deste post, isso transformou uma varredura noturna de 2,8 bilhões de linhas em uma leitura da ordem de dezenas de milhões só a partição ativa. A janela de manutenção agradece; o I/O do storage também.

De quebra, o threshold de auto update passa a ser avaliado por partição, o que faz o gatilho da partição quente disparar com muito mais sensibilidade do que o threshold calculado sobre a tabela inteira.

A ressalva que quase ninguém explica

Agora, o ponto mais importante deste post e o motivo de muita frustração com o recurso:

INCREMENTAL = ON barateia a MANUTENÇÃO. Ele NÃO muda o que o otimizador enxerga.

O otimizador de consultas continua lendo o histograma mesclado, no nível da tabela, com os mesmos 200 steps. Não existe “histograma por partição” na hora de estimar cardinalidade mesmo que sua consulta tenha eliminação de partição perfeita e toque uma única partição, a estimativa vem do histograma agregado.

Ou seja: se o seu problema é custo e duração da rotina de manutenção, estatísticas incrementais são a solução. Se o seu problema é qualidade de estimativa (aqueles 200 steps espremendo 4 bilhões de linhas), incremental não resolve para isso, o caminho envolve estatísticas filtradas, revisão de sampling (FULLSCAN/PERSIST_SAMPLE_PERCENT) e, em último caso, redesenho.

Saber separar esses dois problemas é o que evita implantar o recurso com a expectativa errada

As três classes de estatísticas (e o custo de conversão de cada uma)

Na hora de implantar incremental em um ambiente que já existe, descobri na prática que as estatísticas se dividem em três classes com custos de conversão completamente diferentes:

Classe 1 – Estatísticas standalone e auto-created (_WA_Sys_*): conversão barata. São as estatísticas criadas via CREATE STATISTICS ou automaticamente pelo AUTO_CREATE_STATISTICS. A conversão é um UPDATE STATISTICS ... WITH FULLSCAN, INCREMENTAL = ON custa uma atualização completa (uma única vez), e a partir daí toda a manutenção vira incremental. Em paralelo, vale habilitar no nível do banco para que as futuras auto-created já nasçam incrementais:

ALTER DATABASE [SeuBanco]
SET AUTO_CREATE_STATISTICS ON (INCREMENTAL = ON);

Classe 2 – Estatísticas de índice: só via REBUILD. A estatística que nasce junto com um índice não aceita conversão via UPDATE STATISTICS. O único caminho é reconstruir o índice com a opção:

ALTER INDEX IDX_Cliente_Data ON dbo.Cliente
REBUILD WITH (STATISTICS_INCREMENTAL = ON);

Em um índice de 2 TB, isso não é uma decisão de rotina de estatísticas é uma decisão de projeto, com janela, log, espaço e impacto próprios. No ambiente do caso real, a conversão dessas estatísticas foi deliberadamente adiada para o projeto de migração da base, quando os índices serão reconstruídos de qualquer forma. Não force um rebuild de terabytes só para converter estatística: encaixe a conversão em um momento em que o rebuild já vai acontecer.

Classe 3 – Estatísticas de tabelas não particionadas (ou de índices não alinhados à partição): fora do jogo. INCREMENTAL = ON só existe para objetos alinhados ao esquema de particionamento. A documentação lista explicitamente os casos não suportados, se a estatística cair em um deles, a opção é ignorada e o SQL Server apenas gera um warning:

  • Estatísticas de índices não alinhados ao particionamento da tabela base;
  • Estatísticas de índices filtrados;
  • Estatísticas sobre views;
  • Estatísticas de índices espaciais ou XML;
  • Estatísticas em bases read-only e em secundárias legíveis do Always On;
  • Estatísticas de tabelas internas.

Para tudo isso, a manutenção continua no modelo tradicional e a sua rotina precisa saber diferenciar.

Montando a rotina: dirigida por modificação, por partição

Com as estatísticas convertidas, a rotina deixa de ser “atualiza tudo toda noite” e passa a ser dirigida por evidência: atualize apenas as partições cujo contador de modificações justifica. A DMF que viabiliza isso é a sys.dm_db_incremental_stats_properties:

SELECT
    OBJECT_NAME(s.object_id)      AS Tabela,
    s.name                        AS Estatistica,
    isp.partition_number          AS Particao,
    isp.rows                      AS Linhas,
    isp.rows_sampled              AS LinhasAmostradas,
    isp.modification_counter      AS Modificacoes,
    isp.last_updated              AS UltimaAtualizacao
FROM sys.stats AS s
CROSS APPLY sys.dm_db_incremental_stats_properties(s.object_id, s.stats_id) AS isp
WHERE s.object_id = OBJECT_ID('dbo.Cliente')
  AND isp.modification_counter > 0
ORDER BY isp.modification_counter DESC;

A partir daí, a lógica da rotina fica simples de descrever:

  1. Varra as estatísticas incrementais e colete o modification_counter por partição;
  2. Aplique o seu critério de corte (percentual sobre rows da partição, ou um valor absoluto calibrado pelo throughput do ambiente);
  3. Gere dinamicamente os comandos UPDATE STATISTICS ... WITH RESAMPLE ON PARTITIONS (n) só para as partições que cruzaram o corte;
  4. Para as estatísticas não incrementais (classe 3), mantenha o fluxo tradicional baseado em sys.dm_db_stats_properties.

O resultado é uma rotina que trabalha proporcionalmente ao que mudou, não ao que existe. Em uma tabela onde 99% do dado é histórico congelado, essa diferença é a diferença entre horas e minutos.

Bônus: descobrindo quais estatísticas o plano realmente usou

Para fechar o ciclo diagnóstico, o plano de execução conta quais estatísticas o otimizador carregou na compilação: o elemento OptimizerStatsUsage do XML do plano lista, para cada estatística, o nome, o ModificationCount no momento da compilação, o SamplingPercent e o LastUpdate. No SSMS, basta selecionar o operador raiz do plano e expandir OptimizerStatsUsage na janela de propriedades.

É a ponte perfeita entre “essa query está lenta” e “essa estatística estava velha/mal amostrada quando o plano nasceu”: um ModificationCount alto em relação ao tamanho da tabela, ou um SamplingPercent baixo em coluna enviesada, apontam o culpado sem achismo. E o inverso também informa: se a coluna do seu filtro não aparece na lista, o otimizador não encontrou estatística relevante para ela candidata a CREATE STATISTICS.

A armadilha final: ascending key + Legacy CE

Um alerta antes de encerrar, porque ele derruba até quem fez tudo certo até aqui.

Tabelas particionadas por data quase sempre têm o padrão ascending key: os novos valores (a data de hoje, o próximo ID) entram sempre acima do maior valor registrado no histograma. Entre uma atualização de estatística e outra, existe uma faixa de valores recém-inseridos que o histograma ainda não conhece.

O que o otimizador estima para um predicado que cai nessa faixa depende do Cardinality Estimator:

  • Legacy CE (compatibility level ≤ 110, ou forçado via hint/TF 9481): estima 1 linha. A consulta “me traga as posições de hoje” justamente as mais comuns executada nas aplicações recebe estimativa de 1 linha, ganha um plano de Nested Loops com lookup, e executa isso contra milhões de linhas reais. Desastre clássico. (No Legacy CE, as trace flags 2389/2390 existem exatamente para remendar esse cenário, marcando a coluna como ascending.)
  • New CE (2014+, compatibility level ≥ 120): assume que existem linhas além do último step do histograma e usa uma extrapolação baseada no modification_counter. Imperfeito, mas ordens de grandeza melhor para esse padrão.

A lição que amarra o post inteiro: estatística perfeita não salva plano ruim se o modelo de estimativa não souber usá-la. Rotina incremental impecável, sample generoso, tudo em dia e ainda assim, no Legacy CE, a consulta mais importante do sistema pode estar estimando 1 linha. Estatística, threshold, sampling e Cardinality Estimator formam um sistema; otimizar uma peça ignorando as outras é enxugar gelo.

Fechando – e o que vem no próximo post

Recapitulando o caminho:

  • Estatística é histograma (≤ 200 steps) + density vector + metadados de amostragem e o plano de execução nasce dali;
  • Elas nascem por dois caminhos: junto com os índices e via AUTO_CREATE_STATISTICS (as _WA_Sys_*) coluna única, tabela inteira, e um inventário que conta história sobre o seu workload;
  • Em tabelas grandes, o auto update chega tarde (threshold) e amostra pouco (sample automático) rotina manual é obrigatória em VLDB;
  • Tabela particionada tem N partições, mas uma estatística agregada e é isso que faz rotinas ingênuas varrerem bilhões de linhas paradas;
  • INCREMENTAL = ON mantém estatística por partição e mescla barateia (e muito) a manutenção, mas o otimizador continua lendo o histograma mesclado;
  • A conversão tem três custos: barata (standalone/auto-created), cara (stats de índice, só via rebuild encaixe em um projeto) e impossível (não particionadas/filtradas);
  • A rotina moderna é dirigida por modification_counter por partição, via sys.dm_db_incremental_stats_properties;
  • E nada disso salva você do padrão ascending key rodando em Legacy CE.

No post 02, atravessamos a ponte: como o PostgreSQL resolve exatamente os mesmos problemas ANALYZE, default_statistics_target (o “200 steps” de lá, só que configurável), pg_stats, autovacuum/autoanalyze e seus thresholds. E com uma inversão curiosa: no PostgreSQL, cada partição tem estatística própria de verdade… mas o autovacuum não roda ANALYZE na tabela pai o “esquecido” da história é outro. Quem trabalha com os dois engines vai gostar do espelho.

Até lá!

Referências e leitura recomendada

Microsoft Learn – Statistics (SQL Server):
learn.microsoft.com/en-us/sql/relational-databases/statistics/statistics – a página oficial sobre estatísticas, base das definições de histograma, density vector, thresholds de auto update e opções INCREMENTAL/ASYNC discutidas neste post;

Microsoft Learn
DBCC SHOW_STATISTICS, UPDATE STATISTICS, sys.dm_db_stats_properties e sys.dm_db_incremental_stats_properties – referências de sintaxe e metadados usados nos exemplos; Delaney, K.; Freeman, C.; et al.

Microsoft SQL Server 2012 Internals (Microsoft Press, 2013) – leitura obrigatória para entender como o otimizador baseado em custo consome estatísticas, o papel do Cardinality Estimator e o armazenamento interno desses objetos. Boa parte da intuição deste post sobre “estimativa versus realidade” vem da forma como o livro ensina a raciocinar sobre o otimizador.