Otimização de consulta SQL Server
Estudo de caso focado em identificar gargalos de performance, analisar a causa do problema e reestruturar uma consulta para melhorar sua eficiência.
Pergunta de negócio
Como identificar os clientes que mais geraram receita nos últimos 90 dias sem comprometer o tempo de resposta do dashboard?
Contexto do problema
Uma consulta utilizada para alimentar um relatório comercial apresentava aumento progressivo no tempo de execução conforme o volume de vendas crescia.
O objetivo da investigação foi identificar quais operações estavam causando maior custo ao SQL Server e reduzir o processamento sem alterar o resultado esperado pelo negócio.
Query original
Consulta utilizada antes da otimização.
SELECT
c.CustomerKey, -- Identifica cada cliente de forma única
c.CustomerName, -- Exibe o nome do cliente no resultado
c.Segment, -- Permite analisar o cliente pelo segmento
SUM(s.TotalAmount) AS Revenue90Days, -- Soma a receita gerada nos últimos 90 dias
COUNT(s.SaleKey) AS SalesCount -- Conta quantas vendas o cliente realizou
FROM gold.DimCustomer c
INNER JOIN gold.FactSales s
ON s.CustomerKey = c.CustomerKey -- Relaciona cada venda ao respectivo cliente
WHERE
CONVERT(DATE, s.SaleDate) >=
CONVERT(DATE, DATEADD(DAY, -90, GETDATE()))
-- Mantém somente vendas dos últimos 90 dias.
-- O CONVERT na coluna SaleDate será um dos pontos avaliados na otimização.
AND UPPER(s.Status) = 'APPROVED'
-- Considera apenas vendas aprovadas.
-- O UPPER aplicado na coluna também será avaliado por adicionar processamento.
GROUP BY
c.CustomerKey,
c.CustomerName,
c.Segment -- Agrupa as vendas para calcular os totais por cliente
ORDER BY
Revenue90Days DESC; -- Mostra primeiro os clientes com maior receita
Investigação
Antes de alterar a consulta, o primeiro passo foi medir seu comportamento utilizando as estatísticas do SQL Server, devemos ligar e desligar as estatisticas como abaixo:
-- Mostra quantas páginas de dados foram lidas durante a consulta
SET STATISTICS IO ON;
-- Mostra o tempo de CPU e o tempo total de execução
SET STATISTICS TIME ON;
-- Executar aqui a consulta original para medir seu comportamento
-- Desliga as estatísticas após o teste
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
Após executar a consulta com STATISTICS IO e STATISTICS TIME habilitados, o SQL Server apresenta na aba "Mensagens" métricas que ajudam a avaliar o custo da consulta. Rows Returned representa a quantidade de linhas retornadas pelo resultado; Logical Reads indica quantas páginas de dados o SQL Server precisou acessar na memória para processar a consulta; e Execution Time mostra o tempo gasto pelo processador (CPU Time) e o tempo total decorrido (Elapsed Time). Os tempos são apresentados em milissegundos (ms).
Rows Returned
A consulta original retornou 16.449 linhas no conjunto analisado.
Logical Reads
FactSales: 6.618 leituras lógicas e DimCustomer: 279 leituras lógicas.
Execution Time
CPU: 484 ms
Tempo decorrido: 796 ms
Problemas encontrados
CONVERT
O uso de CONVERT diretamente sobre SaleDate dificulta o aproveitamento eficiente de índices existentes.
UPPER
Aplicar UPPER durante o filtro adiciona processamento e pode prejudicar estratégias eficientes de busca.
ÍNDICE
A consulta dependia de colunas frequentemente utilizadas nos filtros, relacionamento e agregação.
Ajuste - INDEX
Antes de propor um novo índice, o primeiro passo foi verificar os índices já existentes em gold.FactSales. Isso evita estruturas redundantes e permite avaliar se algum índice atual já atende às colunas utilizadas pela consulta.
SELECT
i.name AS IndexName, -- Nome do índice encontrado
i.type_desc AS IndexType, -- Informa se é clustered ou nonclustered
i.is_unique AS IsUnique, -- Mostra se o índice exige valores únicos
c.name AS ColumnName, -- Coluna que faz parte do índice
ic.key_ordinal AS KeyOrder, -- Ordem da coluna dentro da chave do índice
ic.is_included_column AS IsIncludedColumn -- Indica se a coluna está apenas no INCLUDE
FROM sys.indexes i
INNER JOIN sys.index_columns ic
ON i.object_id = ic.object_id
AND i.index_id = ic.index_id -- Liga o índice às suas colunas
INNER JOIN sys.columns c
ON ic.object_id = c.object_id
AND ic.column_id = c.column_id -- Recupera o nome de cada coluna
WHERE
i.object_id = OBJECT_ID('gold.FactSales') -- Analisa somente a tabela usada no teste
AND i.is_hypothetical = 0 -- Ignora índices hipotéticos do otimizador
ORDER BY
i.name,
ic.is_included_column,
ic.key_ordinal,
ic.index_column_id; -- Organiza o resultado para facilitar a leitura
Caso a verificação confirme que não existe um índice adequado para o padrão de filtro e relacionamento da consulta, a seguinte estratégia pode então ser testada.
CREATE NONCLUSTERED INDEX IX_FactSales_Status_SaleDate_CustomerKey
ON gold.FactSales (
Status, -- Usado no filtro para manter somente vendas APPROVED
SaleDate, -- Usado no filtro dos últimos 90 dias
CustomerKey -- Usado no relacionamento com DimCustomer
)
INCLUDE (
TotalAmount -- Necessário para calcular a receita sem buscar a coluna fora do índice
);
Ajuste - UPPER, CONVERT
Reestruturação da consulta, monitorando a performance.
-- Ativa as métricas para comparar a nova versão com a consulta original
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
-- Calcula a data inicial uma única vez, fora do filtro da tabela
DECLARE @StartDate DATE;
SET @StartDate = DATEADD(DAY, -90, GETDATE());
SELECT
c.CustomerKey, -- Identifica cada cliente de forma única
c.CustomerName, -- Exibe o nome do cliente no resultado
c.Segment, -- Mantém o segmento para análise
SUM(s.TotalAmount) AS Revenue90Days, -- Soma a receita dos últimos 90 dias
COUNT(*) AS SalesCount -- Conta as vendas encontradas para cada cliente
FROM gold.FactSales s
INNER JOIN gold.DimCustomer c
ON c.CustomerKey = s.CustomerKey -- Relaciona a venda ao cliente
WHERE
s.SaleDate >= @StartDate
-- Filtra diretamente pela coluna SaleDate, sem aplicar CONVERT nela
AND s.Status = 'APPROVED'
-- Filtra diretamente pelo status, sem aplicar UPPER na coluna
GROUP BY
c.CustomerKey,
c.CustomerName,
c.Segment -- Consolida receita e quantidade de vendas por cliente
ORDER BY
Revenue90Days DESC; -- Prioriza os clientes que mais geraram receita
GO
-- Desliga as métricas após o teste
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
Rows Returned
A versão otimizada retornou 16.449 linhas no teste realizado.
Logical Reads
FactSales: 477 leituras lógicas e DimCustomer: 279 leituras lógicas.
Execution Time
CPU: 125 ms
Tempo decorrido: 286 ms
Resultado
Foram comparados tempo decorrido, CPU e leituras lógicas no mesmo ambiente de testes. As duas versões retornaram 16.449 linhas, preservando o mesmo resultado para a comparação.
Query original
Query otimizada
Redução do tempo decorrido
FactSales Reads
6.618 → 477
Redução de 92,8%.
CPU Time
484 ms → 125 ms
Redução de 74,2%.
Elapsed Time
796 ms → 286 ms
Redução de 64,1%.
Conclusão
Os testes mostraram que ajustes simples podem fazer bastante diferença. A consulta caiu de 796 ms para 286 ms e as leituras da FactSales foram de 6.618 para 477.
Mais importante do que apenas olhar o tempo foi medir o que o SQL Server estava lendo e processando. Também vale conferir os índices existentes antes de criar um novo. Assim, a otimização deixa de ser tentativa e erro e passa a ser uma decisão baseada nos resultados do próprio banco.
Voltar ao portfólio