Database
 sql >> Base de données >  >> RDS >> Database

Le problème avec les fonctions et les vues de la fenêtre

Présentation

Depuis leur introduction dans SQL Server 2005, les fonctions de fenêtre comme ROW_NUMBER et RANK se sont avérés extrêmement utiles pour résoudre une grande variété de problèmes T-SQL courants. Dans une tentative de généraliser ces solutions, les concepteurs de bases de données cherchent souvent à les incorporer dans des vues pour favoriser l'encapsulation et la réutilisation du code. Malheureusement, une limitation de l'optimiseur de requête SQL Server signifie souvent que les vues contenant des fonctions de fenêtre ne fonctionnent pas aussi bien que prévu. Cet article présente un exemple illustratif du problème, détaille les raisons et propose un certain nombre de solutions de contournement.

Ce problème peut également se produire dans les tables dérivées, les expressions de table communes et les fonctions en ligne, mais je le vois le plus souvent avec les vues car elles sont intentionnellement écrites pour être plus génériques.

Fonctions de la fenêtre

Les fonctions de fenêtre se distinguent par la présence d'un OVER() clause et existent en trois variétés :

  • Fonctions de la fenêtre de classement
    • ROW_NUMBER
    • RANK
    • DENSE_RANK
    • NTILE
  • Fonctions de fenêtre d'agrégation
    • MIN , MAX , AVG , SUM
    • COUNT , COUNT_BIG
    • CHECKSUM_AGG
    • STDEV , STDEVP , VAR , VARP
  • Fonctions de la fenêtre analytique
    • LAG , LEAD
    • FIRST_VALUE , LAST_VALUE
    • PERCENT_RANK , PERCENTILE_CONT , PERCENTILE_DISC , CUME_DIST

Les fonctions de fenêtre de classement et d'agrégation ont été introduites dans SQL Server 2005 et considérablement étendues dans SQL Server 2012. Les fonctions de fenêtre analytique sont nouvelles pour SQL Server 2012.

Toutes les fonctions de fenêtre répertoriées ci-dessus sont sensibles à la limitation de l'optimiseur détaillée dans cet article.

Exemple

À l'aide de l'exemple de base de données AdventureWorks, la tâche à accomplir consiste à écrire une requête qui renvoie toutes les transactions du produit n° 878 qui se sont produites à la date disponible la plus récente. Il existe toutes sortes de façons d'exprimer cette exigence dans T-SQL, mais nous choisirons d'écrire une requête qui utilise une fonction de fenêtrage. La première étape consiste à rechercher les enregistrements de transaction pour le produit n° 878 et à les classer par ordre décroissant :

SELECT th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER ( ORDER BY th.TransactionDate DESC)FROM Production.TransactionHistory AS thWHERE th.ProductID =878ORDER BY rnk ; 

Les résultats de la requête sont conformes aux attentes, avec six transactions effectuées à la date disponible la plus récente. Le plan d'exécution contient un triangle d'avertissement, nous alertant d'un index manquant :

Comme d'habitude pour les suggestions d'index manquantes, nous devons nous rappeler que la recommandation n'est pas le résultat d'une analyse approfondie de la requête - c'est plutôt une indication que nous devons réfléchir un peu à la façon dont cette requête accède aux données dont elle a besoin.

L'index suggéré serait certainement plus efficace que de parcourir complètement la table, car il permettrait une recherche d'index sur le produit particulier qui nous intéresse. L'index couvrirait également toutes les colonnes nécessaires, mais il n'éviterait pas le tri (par TransactionDate descendant). L'index idéal pour cette requête permettrait une recherche sur ProductID , renvoie les enregistrements sélectionnés à l'envers TransactionDate commande, et couvrir les autres colonnes renvoyées :

CREATE NONCLUSTERED INDEX ixON Production.TransactionHistory (ProductID, TransactionDate DESC)INCLUDE (ReferenceOrderID, Quantity);

Avec cet index en place, le plan d'exécution est beaucoup plus efficace. Le parcours d'index clusterisé a été remplacé par une recherche par plage, et un tri explicite n'est plus nécessaire :

La dernière étape de cette requête consiste à limiter les résultats aux seules lignes classées n°1. Nous ne pouvons pas filtrer directement dans le WHERE clause de notre requête car les fonctions de fenêtre ne peuvent apparaître que dans le SELECT et ORDER BY clauses.

Nous pouvons contourner cette restriction en utilisant une table dérivée, une expression de table commune, une fonction ou une vue. À cette occasion, nous utiliserons une expression de tableau commune (c'est-à-dire une vue en ligne) :

WITH ClasséTransactions AS( SELECT th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER ( ORDER BY th.TransactionDate DESC) FROM Production.TransactionHistory AS th WHERE th.ProductID =878 )SELECT TransactionID, ReferenceOrderID, TransactionDate, QuantityFROM ClasséTransactionsWHERE rnk =1 ;

Le plan d'exécution est le même qu'avant, avec un filtre supplémentaire pour ne renvoyer que les lignes classées n° 1 :

La requête renvoie les six lignes de rang égal attendues :

Généraliser la requête

Il s'avère que notre requête est très utile, donc la décision est prise de la généraliser et de stocker la définition dans une vue. Pour que cela fonctionne pour n'importe quel produit, nous devons faire deux choses :renvoyer le ProductID depuis la vue, et partitionnez la fonction de classement par produit :

CREATE VIEW dbo.MostRecentTransactionsPerProductWITH SCHEMABINDINGASSELECT sq1.ProductID, sq1.TransactionID, sq1.ReferenceOrderID, sq1.TransactionDate, sq1.QuantityFROM ( SELECT th.ProductID, th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER (PARTITION BY th.ProductID ORDER BY th.TransactionDate DESC) FROM Production.TransactionHistory AS th) AS sq1WHERE sq1.rnk =1;

La sélection de toutes les lignes de la vue entraîne le plan d'exécution suivant et des résultats corrects :

Nous pouvons maintenant trouver les transactions les plus récentes pour le produit 878 avec une requête beaucoup plus simple sur la vue :

SELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS mrt WHERE mrt.ProductID =878 ;

Nous nous attendons à ce que le plan d'exécution de cette nouvelle requête soit exactement le même qu'avant la création de la vue. L'optimiseur de requête doit pouvoir pousser le filtre spécifié dans WHERE clause vers le bas dans la vue, ce qui entraîne une recherche d'index.

Nous devons cependant nous arrêter et réfléchir un peu à ce stade. L'optimiseur de requête ne peut produire que des plans d'exécution qui sont garantis pour produire les mêmes résultats que la spécification de requête logique - est-il sûr de pousser notre WHERE clause dans la vue ?PARTITION BY clause de la fonction de fenêtre dans la vue. Le raisonnement est que l'élimination de groupes complets (partitions) de la fonction de fenêtre n'affectera pas le classement des lignes renvoyées par la requête. La question est, l'optimiseur de requête SQL Server le sait-il ? La réponse dépend de la version de SQL Server que nous utilisons.

Plan d'exécution SQL Server 2005

Un coup d'œil aux propriétés du filtre dans ce plan montre qu'il applique deux prédicats :

Le ProductID = 878 Le prédicat n'a pas été poussé vers le bas dans la vue, ce qui donne un plan qui analyse notre index, classant chaque ligne du tableau avant de filtrer le produit n° 878 et les lignes classées n° 1.

L'optimiseur de requête SQL Server 2005 ne peut pas pousser les prédicats appropriés au-delà d'une fonction de fenêtre dans une étendue de requête inférieure (vue, expression de table commune, fonction en ligne ou table dérivée). Cette limitation s'applique à toutes les versions de SQL Server 2005.

Plan d'exécution SQL Server 2008+

Voici le plan d'exécution pour la même requête sur SQL Server 2008 ou version ultérieure :

Le ProductID Le prédicat a été poussé avec succès au-delà des opérateurs de classement, remplaçant le balayage d'index par la recherche d'index efficace.

L'optimiseur de requêtes 2008 inclut une nouvelle règle de simplification SelOnSeqPrj (sélectionner sur le projet de séquence) qui est capable de pousser les prédicats de portée extérieure sûrs au-delà des fonctions de fenêtre. Pour produire le plan le moins efficace pour cette requête dans SQL Server 2008 ou version ultérieure, nous devons temporairement désactiver cette fonctionnalité d'optimisation de requête :

SELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS mrt WHERE mrt.ProductID =878OPTION (QUERYRULEOFF SelOnSeqPrj);

Malheureusement, le SelOnSeqPrj la règle de simplification ne fonctionne que lorsque le prédicat effectue une comparaison avec une constante . Pour cette raison, la requête suivante produit le plan sous-optimal sur SQL Server 2008 et versions ultérieures :

DÉCLARER @ProductID INT =878 ; SELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS mrt WHERE mrt.ProductID =@ProductID ;

Le problème peut toujours se produire même lorsque le prédicat utilise une valeur constante. SQL Server peut décider de paramétrer automatiquement les requêtes triviales (celles pour lesquelles il existe un meilleur plan évident). Si le paramétrage automatique réussit, l'optimiseur voit un paramètre au lieu d'une constante, et le SelOnSeqPrj la règle n'est pas appliquée.

Pour les requêtes où le paramétrage automatique n'est pas tenté (ou lorsqu'il est déterminé qu'il n'est pas sûr), l'optimisation peut toujours échouer, si l'option de base de données pour FORCED PARAMETERIZATION est sur. Notre requête de test (avec la valeur constante 878) n'est pas sûre pour le paramétrage automatique, mais le paramètre de paramétrage forcé l'emporte, ce qui entraîne un plan inefficace :

ALTER DATABASE AdventureWorksSET PARAMETERIZATION FORCED;GOSELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS mrt WHERE mrt.ProductID =878;GOALTER DATABASE AdventureWorksSET PARAMETERIZATION SIMPLE;

Solution SQL Server 2008+

Pour permettre à l'optimiseur de "voir" une valeur constante pour la requête qui référence une variable ou un paramètre local, nous pouvons ajouter une OPTION (RECOMPILE) indice de requête :

DÉCLARER @ProductID INT =878 ; SELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS mrt WHERE mrt.ProductID =@ProductIDOPTION (RECOMPILE);

Remarque : Le plan d'exécution de pré-exécution ("estimé") affiche toujours un parcours d'index car la valeur de la variable n'est pas encore définie. Lorsque la requête est exécutée , cependant, le plan d'exécution affiche le plan de recherche d'index souhaité :

Le SelOnSeqPrj la règle n'existe pas dans SQL Server 2005, donc OPTION (RECOMPILE) ne peut pas aider là-bas. Au cas où vous vous poseriez la question, le OPTION (RECOMPILE) la solution de contournement entraîne une recherche même si l'option de base de données pour le paramétrage forcé est activée.

Solution n° 1 pour toutes les versions

Dans certains cas, il est possible de remplacer la vue problématique, l'expression de table commune ou la table dérivée par une fonction table en ligne paramétrée :

CREATE FUNCTION dbo.MostRecentTransactionsForProduct( @ProductID entier) RETURNS TABLEWITH SCHEMABINDING ASRETURN SELECT sq1.ProductID, sq1.TransactionID, sq1.ReferenceOrderID, sq1.TransactionDate, sq1.Quantity FROM ( SELECT th.ProductID, th.TransactionID, th. ReferenceOrderID, th.TransactionDate, th.Quantity, rnk =RANK() OVER ( PARTITION BY th.ProductID ORDER BY th.TransactionDate DESC) FROM Production.TransactionHistory AS th WHERE th.ProductID =@ProductID ) AS sq1 WHERE sq1.rnk =1 ;

Cette fonction place explicitement le ProductID prédicat dans la même portée que la fonction de fenêtre, en évitant la limitation de l'optimiseur. Écrit pour utiliser la fonction en ligne, notre exemple de requête devient :

SELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsForProduct(878) AS mrt ;

Cela produit le plan de recherche d'index souhaité sur toutes les versions de SQL Server qui prennent en charge les fonctions de fenêtre. Cette solution de contournement produit une recherche même lorsque le prédicat fait référence à un paramètre ou à une variable locale - OPTION (RECOMPILE) n'est pas obligatoire.PARTITION BY maintenant redondante clause, et de ne plus retourner le ProductID colonne. J'ai laissé la définition identique à la vue qu'elle a remplacée pour illustrer plus clairement la cause des différences de plan d'exécution.

Solution n° 2 pour toutes les versions

La deuxième solution de contournement s'applique uniquement aux fonctions de fenêtre de classement qui sont filtrées pour renvoyer les lignes numérotées ou classées #1 (en utilisant ROW_NUMBER , RANK , ou DENSE_RANK ). C'est cependant un usage très courant, il convient donc de le mentionner.

Un avantage supplémentaire est que cette solution de contournement peut produire des plans qui sont encore plus efficaces que les plans de recherche d'index vus précédemment. Pour rappel, le meilleur plan précédent ressemblait à ceci :

Ce plan d'exécution se classe 1 918 lignes même s'il ne renvoie finalement que 6 . Nous pouvons améliorer ce plan d'exécution en utilisant la fonction window dans un ORDER BY clause au lieu de classer les lignes puis de filtrer pour le rang 1 :

SELECT TOP (1) WITH TIES th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.QuantityFROM Production.TransactionHistory AS thWHERE th.ProductID =878ORDER BY RANK() OVER ( ORDER BY th.TransactionDate DESC); 

Cette requête illustre bien l'utilisation d'une fonction de fenêtre dans le ORDER BY clause, mais nous pouvons faire encore mieux, en éliminant complètement la fonction de fenêtre :

SELECT TOP (1) WITH TIES th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.QuantityFROM Production.TransactionHistory AS thWHERE th.ProductID =878ORDER BY th.TransactionDate DESC ;

Ce plan ne lit que 7 lignes de la table pour renvoyer le même ensemble de résultats à 6 lignes. Pourquoi 7 rangées ? L'opérateur Top s'exécute dans WITH TIES mod :

Il continue à demander une ligne à la fois à partir de son sous-arbre jusqu'à ce que TransactionDate change. La septième ligne est requise pour que le Top soit sûr qu'aucune autre ligne de valeur égale ne sera qualifiée.

Nous pouvons étendre la logique de la requête ci-dessus pour remplacer la définition de vue problématique :

ALTER VIEW dbo.MostRecentTransactionsPerProductWITH SCHEMABINDINGASSELECT p.ProductID, Classé1.TransactionID, Classé1.ReferenceOrderID, Classé1.TransactionDate, Classé1.QuantityFROM -- Liste des ID de produit (SELECT ProductID FROM Production.Product) AS pCROSS APPLY( -- Renvoie le rang #1 résultats pour chaque identifiant de produit SELECT TOP (1) WITH TIES th.TransactionID, th.ReferenceOrderID, th.TransactionDate, th.Quantity FROM Production.TransactionHistory AS th WHERE th.ProductID =p.ProductID ORDER BY th.TransactionDate DESC) AS Classé1 ;

La vue utilise maintenant un CROSS APPLY pour combiner les résultats de notre ORDER BY optimisé requête pour chaque produit. Notre requête de test est inchangée :

DECLARE @ProductID entier ; SET @ProductID =878 ; SELECT mrt.ProductID, mrt.TransactionID, mrt.ReferenceOrderID, mrt.TransactionDate, mrt.QuantityFROM dbo.MostRecentTransactionsPerProduct AS mrt WHERE mrt.ProductID =@ProductID ;

Les plans de pré- et post-exécution affichent une recherche d'index sans avoir besoin d'une OPTION (RECOMPILE) indice de requête. Ce qui suit est un plan post-exécution ("réel") :

Si la vue avait utilisé ROW_NUMBER au lieu de RANK , la vue de remplacement aurait simplement omis le WITH TIES clause sur le TOP (1) . Bien entendu, la nouvelle vue pourrait également être écrite sous la forme d'une fonction table paramétrée en ligne.

On pourrait dire que le plan de recherche d'index original avec le rnk = 1 Le prédicat pourrait également être optimisé pour ne tester que 7 lignes. Après tout, l'optimiseur doit savoir que les classements sont produits par l'opérateur Sequence Project dans un ordre croissant strict, de sorte que l'exécution peut se terminer dès qu'une ligne avec un rang supérieur à un est vue. Cependant, l'optimiseur ne contient pas cette logique aujourd'hui.

Réflexions finales

Les gens sont souvent déçus par les performances des vues qui intègrent des fonctions de fenêtrage. La raison peut souvent être attribuée à la limitation de l'optimiseur décrite dans cet article (ou peut-être parce que le concepteur de la vue n'a pas compris que les prédicats appliqués à la vue doivent apparaître dans le PARTITION BY clause à pousser en toute sécurité).

Je tiens à souligner que cette limitation ne s'applique pas uniquement aux vues, et qu'elle n'est pas non plus limitée à ROW_NUMBER , RANK , et DENSE_RANK . Vous devez être conscient de cette limitation lorsque vous utilisez une fonction avec un OVER clause dans une vue, une expression de table commune, une table dérivée ou une fonction table en ligne.

Les utilisateurs de SQL Server 2005 qui rencontrent ce problème sont confrontés au choix de réécrire la vue en tant que fonction de table en ligne paramétrée ou d'utiliser le APPLY technique (le cas échéant).

Les utilisateurs de SQL Server 2008 ont la possibilité supplémentaire d'utiliser une OPTION (RECOMPILE) indice de requête si le problème peut être résolu en permettant à l'optimiseur de voir une constante au lieu d'une variable ou d'une référence de paramètre. N'oubliez pas de vérifier les plans de post-exécution lorsque vous utilisez cet indice :le plan de pré-exécution ne peut généralement pas afficher le plan optimal.