Pourquoi y a-t-il des différences de plan d'exécution entre OFFSET… FETCH et l'ancien schéma ROW_NUMBER?

15

Le nouveau OFFSET ... FETCHmodèle introduit avec SQL Server 2012 offre une pagination simple et plus rapide. Pourquoi y a-t-il des différences si l'on considère que les deux formes sont sémantiquement identiques et très communes?

On pourrait supposer que l'optimiseur reconnaît les deux et les optimise (trivialement) au maximum.

Voici un cas très simple où OFFSET ... FETCHest ~ 2x plus rapide selon l'estimation des coûts.

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

On peut faire varier ce cas de test en créant un CI sur object_idou en ajoutant des filtres mais il est impossible de supprimer toutes les différences de plan. OFFSET ... FETCHest toujours plus rapide car il fait moins de travail au moment de l'exécution.

usr
la source
Pas très sûr, donc mettez-le en commentaire, mais je suppose que c'est parce que vous avez le même ordre par condition pour la numérotation des lignes et le jeu de résultats final. Puisque dans la 2ème condition, l'optimiseur le sait, il n'a pas besoin de trier à nouveau les résultats. Dans le premier cas cependant, il doit s'assurer que les résultats de la sélection externe sont triés ainsi que la numérotation des lignes dans le résultat interne. La création d'un index approprié sur #objects devrait résoudre le problème
Akash

Réponses:

13

Les exemples de la question ne produisent pas tout à fait les mêmes résultats (l' OFFSETexemple a une erreur de coup par coup). Les formulaires mis à jour ci-dessous corrigent ce problème, suppriment le tri supplémentaire pour le ROW_NUMBERcas et utilisent des variables pour rendre la solution plus générale:

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;

Le ROW_NUMBERplan a un coût estimé à 0,0197935 :

Plan de numéro de ligne

Le OFFSETplan a un coût estimé à 0,0196955 :

Plan de compensation

Cela représente une économie de 0,000098 unité de coût estimée (bien que le OFFSETplan nécessite des opérateurs supplémentaires si vous souhaitez renvoyer un numéro de ligne pour chaque ligne). Le OFFSETplan sera toujours légèrement moins cher, de manière générale, mais n'oubliez pas que les coûts estimés sont exactement cela - de vrais tests sont toujours nécessaires. La majeure partie du coût dans les deux plans est le coût du type complet de l'ensemble d'entrée, donc des index utiles bénéficieraient aux deux solutions.

Lorsque des valeurs littérales constantes sont utilisées (par exemple OFFSET 30dans l'exemple d'origine), l'optimiseur peut utiliser un tri TopN au lieu d'un tri complet suivi d'un Top. Lorsque les lignes nécessaires du tri TopN sont un littéral constant et <= 100 (la somme de OFFSETet FETCH), le moteur d'exécution peut utiliser un algorithme de tri différent qui peut fonctionner plus rapidement que le tri TopN généralisé. Les trois cas ont globalement des caractéristiques de performance différentes.

Quant à savoir pourquoi l'optimiseur ne transforme pas automatiquement le ROW_NUMBERmodèle de syntaxe à utiliser OFFSET, il existe un certain nombre de raisons:

  1. Il est presque impossible d'écrire une transformation qui correspondrait à toutes les utilisations existantes
  2. La transformation automatique de certaines requêtes de pagination et pas d'autres pourrait être source de confusion
  3. Le OFFSETplan n'est pas garanti d'être meilleur dans tous les cas

Un exemple pour le troisième point ci-dessus se produit lorsque l'ensemble de pagination est assez large. Il peut être beaucoup plus efficace de rechercher les clés nécessaires à l' aide d'un index non cluster et de rechercher manuellement l'index cluster par rapport à l'analyse de l'index avec OFFSETou ROW_NUMBER. Il y a d' autres problèmes à considérer si l'application de pagination a besoin de savoir combien de lignes ou de pages il y a au total. Il y a une autre bonne discussion des mérites relatifs des méthodes de «recherche de clé» et de «compensation» ici .

Dans l'ensemble, il est probablement préférable que les utilisateurs prennent une décision éclairée de modifier leurs requêtes de pagination à utiliser OFFSET, le cas échéant, après des tests approfondis.

Paul White 9
la source
1
La raison pour laquelle la transformation n'est pas effectuée dans les cas courants est probablement qu'il était trop difficile de trouver un compromis acceptable en ingénierie. Vous avez fourni de bonnes raisons pour lesquelles cela aurait pu être le cas .; Je dois dire que c'est une bonne réponse. Beaucoup de perspectives et de nouvelles pensées. Je vais laisser la question ouverte un peu, puis choisir la meilleure réponse.
usr
5

Avec un léger tripotage de votre requête, j'obtiens une estimation de coût égale (50/50) et des statistiques d'E / S égales:

; 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

Cela évite le tri supplémentaire qui apparaît dans votre version en triant au rlieu de object_id.

Mark Storey-Smith
la source
Merci pour cette perspicacité. Maintenant que j'y pense, j'ai vu l'optimiseur ne pas comprendre la nature triée de la sortie ROW_NUMBER auparavant. Il considère que l'ensemble n'est pas ordonné par object_id. Ou du moins pas trié à la fois par r et object_id.
usr
2
@usr le ORDER BY que ROW_NUMBER () utilise définit comment il attribue les nombres. Il ne fait rien pour promettre l'ordre de sortie - c'est séparé. Il se trouve que cela coïncide souvent, mais ce n'est pas garanti.
Aaron Bertrand
@AaronBertrand Je comprends que ROW_NUMBER ne commande pas la sortie. Mais si ROW_NUMBER est ordonné par les mêmes colonnes que la sortie, alors le même ordre est garanti, non? L'optimiseur de requêtes pourrait donc utiliser ce fait. Ainsi, deux opérations de tri sont toujours inutiles dans cette requête.
usr
1
@usr vous avez rencontré un cas d'utilisation courant que l'optimiseur ne prend pas en compte, mais ce n'est pas le seul cas d'utilisation. Considérez les cas où l'ordre à l'intérieur de ROW_NUMBER () est cette colonne et autre chose. Ou lorsque l'ordre externe par effectue un tri secondaire sur une autre colonne. Ou lorsque vous souhaitez commander en ordre décroissant. Ou par autre chose. J'aime classer par l'expression rau lieu de la colonne de base, ne serait-ce que parce qu'elle correspond à ce que je ferais dans une requête non imbriquée et classer par une expression - j'utiliserais l'alias attribué à l'expression au lieu de répéter l'expression.
Aaron Bertrand
4
@usr Et au point de Paul, il y aura des cas où vous pouvez trouver des lacunes dans les fonctionnalités de l'optimiseur. S'ils ne vont pas être corrigés et que vous connaissez une meilleure façon d'écrire la requête, utilisez la meilleure façon. Patient: "Docteur, ça fait mal quand je fais x." Docteur: "Ne fais pas x." :-)
Aaron Bertrand
-3

Ils ont modifié l'optimiseur de requête pour ajouter cette fonctionnalité. Cela signifie qu'ils ont implémenté des mécanismes spécifiquement pour prendre en charge la commande offset ... fetch. En d'autres termes, pour la requête supérieure, SQL Server doit faire beaucoup plus de travail. D'où la différence dans les plans de requête.

Brandon leach
la source