Por que existem diferenças no plano de execução entre OFFSET… FETCH e o antigo esquema ROW_NUMBER?

15

O novo OFFSET ... FETCHmodelo introduzido no SQL Server 2012 oferece paginação simples e mais rápida. Por que existem diferenças, considerando que as duas formas são semanticamente idênticas e muito comuns?

Alguém poderia assumir que o otimizador reconhece os dois e os otimiza (trivialmente) ao máximo.

Aqui está um caso muito simples em que OFFSET ... FETCHé ~ 2x mais rápido, de acordo com a estimativa de custo.

SELECT * INTO #objects FROM sys.objects

SELECT *
FROM (
    SELECT *, ROW_NUMBER() OVER (ORDER BY object_id) r
    FROM #objects
) x
WHERE r >= 30 AND r < (30 + 10)
    ORDER BY object_id

SELECT *
FROM #objects
ORDER BY object_id
OFFSET 30 ROWS FETCH NEXT 10 ROWS ONLY

offset-fetch.png

Pode-se variar esse caso de teste criando um IC object_idou adicionando filtros, mas é impossível remover todas as diferenças de plano. OFFSET ... FETCHé sempre mais rápido porque faz menos trabalho no tempo de execução.

usr
fonte
Não tenho muita certeza, então coloque isso como comentário, mas acho que é porque você tem a mesma ordem por condição para a numeração de linhas e o conjunto de resultados finais. Como na 2ª condição, o otimizador sabe disso, não precisa classificar os resultados novamente. No primeiro caso, no entanto, é necessário garantir que os resultados da seleção externa sejam classificados, bem como a numeração das linhas no resultado interno. Criando um índice adequado sobre #objects deve resolver a questão
Akash

Respostas:

13

Os exemplos na pergunta não produzem os mesmos resultados (o OFFSETexemplo tem um erro de um por um). Os formulários atualizados abaixo corrigem esse problema, removem a classificação extra para o ROW_NUMBERcaso e usam variáveis ​​para tornar a solução mais geral:

DECLARE 
    @PageSize bigint = 10,
    @PageNumber integer = 3;

WITH Numbered AS
(
    SELECT TOP ((@PageNumber + 1) * @PageSize) 
        o.*,
        rn = ROW_NUMBER() OVER (
            ORDER BY o.[object_id])
    FROM #objects AS o
    ORDER BY 
        o.[object_id]
)
SELECT
    x.name,
    x.[object_id],
    x.principal_id,
    x.[schema_id],
    x.parent_object_id,
    x.[type],
    x.type_desc,
    x.create_date,
    x.modify_date,
    x.is_ms_shipped,
    x.is_published,
    x.is_schema_published
FROM Numbered AS x
WHERE
    x.rn >= @PageNumber * @PageSize
    AND x.rn < ((@PageNumber + 1) * @PageSize)
ORDER BY
    x.[object_id];

SELECT
    o.name,
    o.[object_id],
    o.principal_id,
    o.[schema_id],
    o.parent_object_id,
    o.[type],
    o.type_desc,
    o.create_date,
    o.modify_date,
    o.is_ms_shipped,
    o.is_published,
    o.is_schema_published
FROM #objects AS o
ORDER BY 
    o.[object_id]
    OFFSET @PageNumber * @PageSize - 1 ROWS 
    FETCH NEXT @PageSize ROWS ONLY;

O ROW_NUMBERplano tem um custo estimado de 0,0197935 :

Plano de número de linhas

O OFFSETplano tem um custo estimado de 0,0196955 :

Plano de compensação

Isso representa uma economia de 0,000098 unidades de custo estimado (embora o OFFSETplano exija operadores adicionais se você desejar retornar um número de linha para cada linha). O OFFSETplano ainda será um pouco mais barato, de um modo geral, mas lembre-se de que os custos estimados são exatamente isso - testes reais ainda são necessários. A maior parte do custo em ambos os planos é o custo de todo o tipo de conjunto de entradas; portanto, índices úteis beneficiariam ambas as soluções.

Onde valores literais constantes são usados ​​(por exemplo, OFFSET 30no exemplo original), o otimizador pode usar uma Classificação TopN em vez de uma classificação completa seguida por um Top. Quando as linhas necessárias da Classificação TopN são um literal constante e <= 100 (a soma de OFFSETe FETCH) o mecanismo de execução pode usar um algoritmo de classificação diferente, que pode executar mais rapidamente que a classificação TopN generalizada. Todos os três casos têm características de desempenho diferentes em geral.

Quanto ao motivo pelo qual o otimizador não transforma automaticamente o ROW_NUMBERpadrão de sintaxe para uso OFFSET, há vários motivos:

  1. É quase impossível escrever uma transformação que corresponda a todos os usos existentes
  2. Ter algumas consultas de paginação transformadas automaticamente e outras não, pode ser confuso
  3. Não OFFSETé garantido que o plano seja melhor em todos os casos

Um exemplo para o terceiro ponto acima ocorre onde o conjunto de paginação é bastante amplo. Pode ser muito mais eficiente procurar as chaves necessárias usando um índice não clusterizado e procurar manualmente o índice clusterizado em comparação com a varredura do índice com OFFSETou ROW_NUMBER. Existem problemas adicionais a serem considerados se o aplicativo de paginação precisar saber quantas linhas ou páginas existem no total. Há outra boa discussão sobre os méritos relativos dos métodos 'busca por chave' e 'compensação' aqui .

No geral, é provavelmente melhor que as pessoas tomem uma decisão informada de alterar suas consultas de paginação para usar OFFSET, se apropriado, após testes completos.

Paul White restabelece Monica
fonte
1
Portanto, o motivo da transformação não ser realizada em casos comuns é provavelmente que era muito difícil encontrar uma troca aceitável de engenharia. Você forneceu boas razões pelas quais esse poderia ter sido o caso .; Devo dizer que esta é uma boa resposta. Muitas idéias e novos pensamentos. Vou deixar a pergunta em aberto um pouco e depois escolher a melhor resposta.
usr
5

Com uma pequena brincadeira na sua consulta, recebo uma estimativa de custo igual (50/50) e estatísticas de IO iguais:

; WITH cte AS
(
    SELECT *, ROW_NUMBER() OVER (ORDER BY object_id) r
    FROM #objects
)
SELECT *
FROM cte
WHERE r >= 30 AND r < 40
ORDER BY r

SELECT *
FROM #objects
ORDER BY object_id
OFFSET 30 ROWS FETCH NEXT 10 ROWS ONLY

Isso evita a classificação adicional que aparece na sua versão, classificando em rvez de object_id.

Mark Storey-Smith
fonte
Obrigado por esse insight. Agora que penso nisso, vi o otimizador não entender a natureza classificada da saída ROW_NUMBER antes. Ele considera que o conjunto não é ordenado por object_id. Ou pelo menos não classificado tanto por re como object_id.
usr
2
@usr o ORDER BY que ROW_NUMBER () usa define como ele atribui os números. Não faz nada para prometer a ordem de saída - isso é separado. Acontece que muitas vezes coincide, mas não é garantido.
Aaron Bertrand
@AaronBertrand Entendo que ROW_NUMBER não ordena a saída. Mas se ROW_NUMBER é ordenado pelas mesmas colunas que a saída, a mesma ordem é garantida, certo? Portanto, o otimizador de consulta pode fazer uso desse fato. Portanto, duas operações de classificação são sempre desnecessárias nesta consulta.
usr
1
@usr, você encontrou um caso de uso comum que o otimizador não considera, mas não é o único caso de uso. Considere os casos em que a ordem dentro de ROW_NUMBER () é essa coluna e outra coisa. Ou quando a ordem externa faz a classificação secundária em outra coluna. Ou quando você deseja pedir por decrescente. Ou por algo completamente diferente. Eu gosto de ordenar pela expressão em rvez da coluna base, apenas porque corresponde ao que eu faria em uma consulta não aninhada e ordenar por uma expressão - eu usaria o alias atribuído à expressão em vez de repetir a expressão.
Aaron Bertrand
4
@usr E para o ponto de Paulo, haverá casos em que você poderá encontrar lacunas na funcionalidade no otimizador. Se eles não forem corrigidos e você souber a melhor maneira de escrever a consulta, use a melhor. Paciente: "Doutor, dói quando eu faço x." Médico: "Não faça x." :-)
Aaron Bertrand
-3

Eles modificaram o otimizador de consulta para incluir esse recurso. Isso significa que implementaram mecanismos especificamente para dar suporte ao comando offset ... fetch. Em outras palavras, para a consulta superior, o SQL Server precisa fazer muito mais trabalho. Assim, a diferença nos planos de consulta.

Brandon leach
fonte