Ideia central

Query tuning é o processo de identificar e corrigir queries que consomem recursos excessivos (CPU, I/O, memória, locks). O ciclo básico é: medir → identificar gargalo → entender o plano de execução → intervir → validar melhora.

1. Identificar as queries problemáticas

Antes de otimizar qualquer coisa, meça. As DMVs mostram o que está custando mais:

-- Top 10 queries por CPU acumulado
SELECT TOP 10
    qs.total_worker_time / qs.execution_count           AS avg_cpu_us,
    qs.total_logical_reads / qs.execution_count         AS avg_logical_reads,
    qs.total_elapsed_time / qs.execution_count          AS avg_duration_us,
    qs.execution_count,
    SUBSTRING(st.text,
        (qs.statement_start_offset / 2) + 1,
        ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
          ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2) + 1
    ) AS query_text,
    qp.query_plan
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
ORDER BY qs.total_worker_time DESC;

2. Ler o Plano de Execução

O plano de execução mostra como o SQL Server vai (ou foi) executar a query, sendo o diagnóstico mais importante no tuning.

Onde ver:

  • Ctrl+L no SSMS: plano estimado (sem executar)
  • Ctrl+M no SSMS: habilita plano real ao executar
  • Clicar com botão direito em uma query no plan cache → “View Execution Plan”

O que procurar:

Sinal de alerta no planoO que pode indicar
Table Scan / Clustered Index ScanFalta de índice ou query não-SARGable
Key LookupÍndice non-clustered incompleto (falta INCLUDE)
Hash MatchDados não ordenados, pode ser substituído por Merge Join com índice correto
SortDados chegando fora de ordem, índice pode eliminar
Seta larga (muitas linhas)Estimativa incorreta ou muito dado sendo processado
Aviso amarelo no operadorImplicit conversion, falta de estatísticas, spill para disco
Número estimado ≠ realEstatísticas desatualizadas ou parâmetros com sniffing ruim

3. Implicit Conversion: armadilha silenciosa

Comparar colunas com tipos diferentes força o SQL Server a converter os dados, impedindo uso de índices:

-- Ruim: ClienteId é INT, mas passamos string → implicit conversion
WHERE ClienteId = '12345'
 
-- Bom: tipos compatíveis
WHERE ClienteId = 12345
 
-- Ruim: coluna VARCHAR com parâmetro NVARCHAR
WHERE codigo = N'ABC' -- N'' força NVARCHAR
 
-- Verificar implicit conversions no plano: aviso "Type conversion in expression may affect CardinalityEstimate"

4. SARGability: deixar os índices trabalharem

Uma condição é SARGable (Search ARGument Able) quando o SQL Server consegue usar um índice range scan para avaliá-la.

-- NÃO-SARGable: função aplicada na coluna
WHERE YEAR(data_pedido) = 2026
WHERE CAST(id AS VARCHAR) = '100'
WHERE LTRIM(nome) = 'Logan'
 
-- SARGable: coluna nua, comparação direta
WHERE data_pedido >= '2026-01-01' AND data_pedido < '2027-01-01'
WHERE id = 100
WHERE nome = 'Logan'
 
-- NÃO-SARGable: negação com LIKE no início
WHERE produto LIKE '%notebook%'
 
-- SARGable: LIKE com sufixo
WHERE produto LIKE 'notebook%'

5. Estratégias de indexação

Composite index: ordem das colunas importa: Coloque primeiro as colunas de igualdade (=), depois as de range (>, <, BETWEEN):

-- Query: WHERE status = 'ativo' AND data_criacao > '2026-01-01'
-- Índice ideal: status primeiro (igualdade), depois data_criacao (range)
CREATE INDEX idx_status_data ON dbo.pedidos (status, data_criacao);

Covering index com INCLUDE:

-- Query: SELECT id, total, data FROM pedidos WHERE cliente_id = 100
-- Sem INCLUDE: SQL Server faz Key Lookup na tabela para buscar total e data
-- Com INCLUDE: todas as colunas necessárias estão no índice → sem Key Lookup
CREATE INDEX idx_pedidos_cliente
ON dbo.pedidos (cliente_id)
INCLUDE (id, total, data);

Filtered index: índice parcial:

-- Índice apenas para pedidos pendentes (subconjunto menor, muito mais eficiente)
CREATE INDEX idx_pedidos_pendentes
ON dbo.pedidos (data_criacao, cliente_id)
WHERE status = 'pendente';

6. Parameter Sniffing

O SQL Server compila stored procedures com o plano otimizado para os primeiros parâmetros usados. Se os próximos parâmetros têm distribuição muito diferente, o plano pode ser ruim.

-- Diagnóstico: ver quantas vezes o plano foi recompilado e os parâmetros
SELECT qs.execution_count, qp.query_plan
FROM sys.dm_exec_procedure_stats ps
CROSS APPLY sys.dm_exec_query_plan(ps.plan_handle) qp
WHERE OBJECT_NAME(ps.object_id) = 'usp_BuscarPedidos';
 
-- Soluções:
 
-- 1. OPTION (RECOMPILE): recompila para cada execução (custo de CPU, mas plano sempre otimizado)
SELECT * FROM pedidos WHERE cliente_id = @cliente_id
OPTION (RECOMPILE);
 
-- 2. OPTIMIZE FOR: diz ao otimizador para usar um valor específico (ou UNKNOWN)
SELECT * FROM pedidos WHERE cliente_id = @cliente_id
OPTION (OPTIMIZE FOR (@cliente_id = 100));
 
OPTION (OPTIMIZE FOR UNKNOWN); -- usa média estatística, não o valor atual
 
-- 3. Variável local (quebra o sniffing, mas pode gerar plano genérico)
DECLARE @local_id INT = @cliente_id;
SELECT * FROM pedidos WHERE cliente_id = @local_id;

7. Estatísticas

O otimizador usa estatísticas para estimar a cardinalidade (quantas linhas retornar). Estatísticas desatualizadas geram planos ruins.

-- Ver quando estatísticas foram atualizadas
SELECT
    OBJECT_NAME(s.object_id) AS tabela,
    s.name AS estatistica,
    sp.last_updated,
    sp.rows,
    sp.rows_sampled,
    sp.modification_counter
FROM sys.stats s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp
WHERE OBJECT_NAME(s.object_id) = 'pedidos'
ORDER BY sp.last_updated;
 
-- Atualizar com scan completo (mais preciso, mais lento)
UPDATE STATISTICS dbo.pedidos WITH FULLSCAN;
 
-- Habilitar atualização automática de estatísticas (deve estar ON)
ALTER DATABASE MinhaDB SET AUTO_UPDATE_STATISTICS ON;
ALTER DATABASE MinhaDB SET AUTO_UPDATE_STATISTICS_ASYNC ON; -- não bloqueia queries

8. CTEs vs Tabelas Temporárias vs Table Variables

-- CTE: elegante, mas o SQL Server pode não materializar
-- O otimizador pode expandir inline, bom ou ruim dependendo do caso
WITH pedidos_recentes AS (
    SELECT * FROM pedidos WHERE data > DATEADD(DAY, -30, GETDATE())
)
SELECT * FROM pedidos_recentes WHERE status = 'pendente';
 
-- Tabela temporária: materializa em TempDB, tem estatísticas
-- Use quando a CTE é consultada mais de uma vez ou tem muitas linhas
CREATE TABLE #pedidos_recentes (
    id INT, cliente_id INT, total DECIMAL(18,2), status VARCHAR(20)
);
INSERT INTO #pedidos_recentes
SELECT id, cliente_id, total, status FROM pedidos
WHERE data > DATEADD(DAY, -30, GETDATE());
 
CREATE INDEX idx_temp_status ON #pedidos_recentes(status); -- pode indexar!
SELECT * FROM #pedidos_recentes WHERE status = 'pendente';
DROP TABLE #pedidos_recentes;
 
-- Table variable (@tabela): sem estatísticas, sem índices úteis
-- Use apenas para conjuntos muito pequenos (< 100 linhas)

9. Checklist de tuning

  • Plano de execução analisado (scans, lookups, spills)?
  • A query é SARGable (sem funções nas colunas filtradas)?
  • Tipos de dados compatíveis (sem implicit conversion)?
  • Índices adequados existem (teste com sp_BlitzIndex ou missing index DMV)?
  • Estatísticas atualizadas?
  • Parameter sniffing avaliado para SPs?
  • SELECT * eliminado?
  • Transações o mais curtas possível?
  • TempDB não está sendo gargalo (sort spills, version store)?

Conexões

Referências