Récupération de n lignes par groupe

88

J'ai souvent besoin de sélectionner un nombre de lignes de chaque groupe dans un jeu de résultats.

Par exemple, je pourrais vouloir lister les 'n' valeurs de commande récentes les plus élevées ou les plus basses par client.

Dans des cas plus complexes, le nombre de lignes à répertorier peut varier par groupe (défini par un attribut de l'enregistrement grouping / parent). Cette partie est définitivement optionnelle / pour un crédit supplémentaire et ne vise pas à dissuader les gens de répondre.

Quelles sont les principales options pour résoudre ces types de problèmes dans SQL Server 2005 et versions ultérieures? Quels sont les principaux avantages et inconvénients de chaque méthode?

Exemples AdventureWorks (pour plus de clarté, facultatif)

  1. Répertoriez les cinq dernières dates de transaction et identifiants de la TransactionHistorytable, pour chaque produit commençant par une lettre de M à R inclus.
  2. Idem encore, mais avec des nlignes d'historique par produit, où nest cinq fois l' DaysToManufactureattribut Product.
  3. Idem, dans le cas particulier où une seule ligne d'historique par produit est requise (entrée unique la plus récente par TransactionDatetie-break activée) TransactionID.
Paul White
la source

Réponses:

71

Commençons par le scénario de base.

Si je veux obtenir un certain nombre de lignes d'une table, j'ai deux options principales: les fonctions de classement; ou TOP.

Tout d’abord, considérons l’ensemble Production.TransactionHistorypour un particulier ProductID:

SELECT h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800;

Cela renvoie 418 lignes et le plan indique qu'il vérifie chaque ligne du tableau à la recherche de cette information - une analyse d'index clusterisé sans restriction, avec un prédicat pour fournir le filtre. 797 lit ici, ce qui est moche.

Scan coûteux avec prédicat 'résiduel'

Soyons donc justes et créons un index qui serait plus utile. Nos conditions appellent une égalité sur ProductID, suivie d'une recherche de la plus récente par TransactionDate. Nous avons besoin du TransactionIDsurfait, alors allons-y avec: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Cela fait, notre plan change de manière significative et ramène le nombre de lectures à seulement 3. Nous améliorons donc déjà les choses de plus de 250 fois ...

Plan amélioré

Maintenant que nous avons nivelé le terrain de jeu, examinons les principales options - fonctions de classement et TOP.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
)
SELECT TransactionID, ProductID, TransactionDate
FROM Numbered
WHERE RowNum <= 5;

SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
ORDER BY TransactionDate DESC;

Deux plans - base TOP \ RowNum

Vous remarquerez que la seconde TOPrequête ( ) est beaucoup plus simple que la première, à la fois dans la requête et dans le plan. Mais très significativement, ils TOPlimitent tous les deux le nombre de lignes extraites de l’index. Les coûts ne sont que des estimations et méritent d'être ignorés, mais vous pouvez voir une grande similitude dans les deux plans, la ROW_NUMBER()version effectuant une infime quantité de travail supplémentaire pour attribuer des nombres et filtrer en conséquence, et les deux requêtes finissent par ne faire que 2 lectures. leur travail. Query Optimizer reconnaît certes l’idée de filtrer sur un ROW_NUMBER()champ, réalisant qu’il peut utiliser un opérateur Top pour ignorer les lignes inutiles. Ces deux requêtes sont assez bonnes - TOPn'est pas tellement mieux que ça vaut la peine de changer de code, mais c'est plus simple et probablement plus clair pour les débutants.

Donc, cela fonctionne sur un seul produit. Mais nous devons examiner ce qui se passe si nous devons le faire pour plusieurs produits.

Le programmeur itératif examinera l’idée de parcourir en boucle les produits qui nous intéressent et d’appeler cette requête plusieurs fois. Nous pouvons même nous en tirer en écrivant une requête sous cette forme - en n’utilisant pas de curseurs, mais en utilisant APPLY. J'utilise OUTER APPLY, en pensant que nous pourrions vouloir renvoyer le produit avec NULL, s'il n'y a pas de transaction pour cela.

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

Le plan pour cela est la méthode des programmeurs itératifs - Boucle imbriquée, faisant une opération Top et Seek (ces 2 lectures que nous avions auparavant) pour chaque produit. Cela donne 4 lectures contre Product et 360 contre TransactionHistory.

APPLIQUER plan

En utilisant ROW_NUMBER(), la méthode est à utiliser PARTITION BYdans la OVERclause, de sorte que nous redémarrions la numérotation pour chaque produit. Cela peut alors être filtré comme avant. Le plan finit par être assez différent. Les lectures logiques sont environ 15% inférieures sur TransactionHistory, une analyse d'index complète étant en cours pour extraire les lignes.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

ROW_NUMBER plan

De manière significative, cependant, ce plan a un opérateur de tri coûteux. La jointure de fusion ne semble pas conserver l'ordre des lignes dans TransactionHistory, il faut recourir aux données pour pouvoir trouver les numéros à venir. C'est moins de lectures, mais cette sorte de blocage pourrait être douloureuse. En utilisant APPLY, la boucle imbriquée renverra les premières lignes très rapidement, après seulement quelques lectures, mais avec un tri, ROW_NUMBER()elle ne renverra que des lignes une fois que la majeure partie du travail sera terminée.

Fait intéressant, si la ROW_NUMBER()requête utilise INNER JOINau lieu de LEFT JOIN, alors un autre plan apparaît.

ROW_NUMBER () avec INNER JOIN

Ce plan utilise une boucle imbriquée, comme avec APPLY. Mais il n'y a pas d'opérateur Top, il extrait donc toutes les transactions pour chaque produit et utilise beaucoup plus de lectures qu'auparavant - 492 lectures contre TransactionHistory. Il n’ya pas de bonne raison pour ne pas choisir l’option Fusionner les jointures ici, donc je suppose que le plan a été considéré comme «assez bon». Toujours - cela ne bloque pas, ce qui est bien - mais pas aussi bien que APPLY.

La PARTITION BYcolonne que j'ai utilisée ROW_NUMBER()était h.ProductIDdans les deux cas, car je voulais donner au responsable de la qualité la possibilité de produire la valeur RowNum avant de joindre la table Product. Si je l'utilise p.ProductID, nous voyons le même plan de forme que pour la INNER JOINvariante.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY p.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

Mais l'opérateur Join dit 'Left Outer Join' au lieu de 'Inner Join'. Le nombre de lectures est toujours juste inférieur à 500 lectures dans la table TransactionHistory.

PARTITION BY sur p.ProductID au lieu de h.ProductID

Quoi qu'il en soit - revenons à la question qui nous occupe ...

Nous avons répondu à la question 1 en vous proposant deux options. Personnellement, j'aime bien l' APPLYoption.

Pour pouvoir utiliser un nombre variable ( question 2 ), il 5suffit de modifier le code en conséquence. Oh, et j'ai ajouté un autre index, de sorte qu'il y avait un index Production.Product.Namequi comprenait la DaysToManufacturecolonne.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, p.DaysToManufacture, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5 * DaysToManufacture;

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5 * p.DaysToManufacture) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

Et les deux plans sont presque identiques à ce qu'ils étaient avant!

Rangées variables

Encore une fois, ignorez les coûts estimés - mais j'aime toujours le scénario TOP, car il est tellement plus simple et que le plan n'a pas d'opérateur bloquant. Les lectures sont moins nombreuses sur TransactionHistory en raison du nombre élevé de zéros DaysToManufacture, mais dans la vie réelle, je doute que nous choisissions cette colonne. ;)

Une façon d'éviter le blocage consiste à élaborer un plan qui gère le ROW_NUMBER()bit situé à droite (dans le plan) de la jointure. Nous pouvons persuader que cela se produise en faisant la jointure en dehors de la CTE.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
)
SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM Production.Product p
LEFT JOIN Numbered t ON t.ProductID = p.ProductID
    AND t.RowNum <= 5 * p.DaysToManufacture
WHERE p.Name >= 'M' AND p.Name < 'S';

Le plan ici semble plus simple - il ne bloque pas, mais il y a un danger caché.

Rejoindre l'extérieur du CTE

Notez le calculateur qui extrait les données de la table Product. Ceci est la 5 * p.DaysToManufacturevaleur. Cette valeur n'est pas transmise à la branche qui extrait des données de la table TransactionHistory, elle est utilisée dans la jointure de fusion. En tant que résidu.

Furtif résiduel!

Ainsi, la jointure par fusion consomme TOUTES les lignes, pas seulement la première, mais plusieurs d'entre elles sont nécessaires, puis effectue une vérification résiduelle. Ceci est dangereux lorsque le nombre de transactions augmente. Je ne suis pas fan de ce scénario: les prédicats résiduels dans les jointures de fusion peuvent rapidement dégénérer. Une autre raison pour laquelle je préfère le APPLY/TOPscénario.

Dans le cas particulier où il y a exactement une ligne, pour la question 3 , nous pouvons évidemment utiliser les mêmes requêtes, mais avec 1au lieu de 5. Mais nous avons une option supplémentaire, qui consiste à utiliser des agrégats réguliers.

SELECT ProductID, MAX(TransactionDate)
FROM Production.TransactionHistory
GROUP BY ProductID;

Une requête comme celle-ci serait un début utile, et nous pourrions facilement la modifier pour extraire le TransactionID également à des fins de rupture (en utilisant une concaténation qui serait ensuite décomposée), mais nous examinons soit l'ensemble de l'index, soit nous plongeons produit par produit et nous n'obtenons pas vraiment d'amélioration par rapport à ce que nous avions auparavant dans ce scénario.

Mais je devrais souligner que nous envisageons un scénario particulier ici. Avec des données réelles et une stratégie d'indexation qui peut ne pas être idéale, le kilométrage peut varier considérablement. Malgré le fait que nous ayons constaté que cette situation APPLYest forte ici, cela peut être plus lent dans certaines situations. Il bloque rarement, car il a tendance à utiliser des boucles imbriquées, ce que beaucoup de personnes (moi-même inclus) trouvent très séduisant.

Je n'ai pas essayé d'explorer le parallélisme ici, ni plongé très fort dans la question 3, que je considère comme un cas spécial que les gens veulent rarement, en raison de la complication de la concaténation et de la scission. La principale chose à considérer ici est que ces deux options sont très fortes.

Je préfère APPLY. C'est clair, il utilise bien l'opérateur Top et provoque rarement un blocage.

Rob Farley
la source
45

Pour ce faire, SQL Server 2005 et versions ultérieures utilisent généralement un CTE et des fonctions de fenêtrage. Pour les n premiers par groupe, vous pouvez simplement utiliser ROW_NUMBER()une PARTITIONclause et filtrer contre celle-ci dans la requête externe. Ainsi, par exemple, les 5 commandes les plus récentes par client pourraient être affichées de la manière suivante:

DECLARE @top INT;
SET @top = 5;

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT CustomerID, OrderID, OrderDate
  FROM grp
  WHERE rn <= @top
  ORDER BY CustomerID, OrderDate DESC;

Vous pouvez aussi le faire avec CROSS APPLY:

DECLARE @top INT;
SET @top = 5;

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (@top) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

Avec l'option supplémentaire spécifiée par Paul, supposons que la table Customers comporte une colonne indiquant le nombre de lignes à inclure par client:

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT c.CustomerID, grp.OrderID, grp.OrderDate
  FROM grp 
  INNER JOIN dbo.Customers AS c
  ON grp.CustomerID = c.CustomerID
  AND grp.rn <= c.Number_of_Recent_Orders_to_Show
  ORDER BY c.CustomerID, grp.OrderDate DESC;

Et encore une fois, en utilisant CROSS APPLYet en incorporant l'option ajoutée, le nombre de lignes d'un client doit être dicté par une colonne de la table customers:

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (c.Number_of_Recent_Orders_to_Show) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

Notez que ceux-ci fonctionneront différemment en fonction de la distribution des données et de la disponibilité des index de prise en charge. L'optimisation des performances et l'obtention du meilleur plan dépendent donc réellement de facteurs locaux.

Personnellement, je préfère les solutions CTE et de fenêtrage au CROSS APPLY/ TOPparce qu'elles séparent mieux la logique et sont plus intuitives (pour moi). En général (dans ce cas comme dans mon expérience générale), l’approche CTE produit des plans plus efficaces (exemples ci-dessous), mais cela ne doit pas être considéré comme une vérité universelle - vous devez toujours tester vos scénarios, en particulier si les index ont changé ou ont été modifiés. les données ont considérablement biaisé.


Exemples AdventureWorks - sans aucune modification

  1. Répertoriez les cinq dernières dates de transaction et identifiants de la TransactionHistorytable, pour chaque produit commençant par une lettre de M à R inclus.
-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= 5;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Comparaison de ces deux métriques d'exécution:

entrez la description de l'image ici

CTE / OVER()plan:

entrez la description de l'image ici

CROSS APPLY plan:

entrez la description de l'image ici

Le plan CTE semble plus compliqué, mais il est en réalité beaucoup plus efficace. Portez peu d’attention sur les chiffres du coût estimé, mais concentrez-vous sur des observations réelles plus importantes , telles que beaucoup moins de lectures et une durée beaucoup plus courte. J'ai également couru ces sans parallélisme, et ce n'était pas la différence. Indicateurs d'exécution et plan CTE (le CROSS APPLYplan est resté le même):

entrez la description de l'image ici

entrez la description de l'image ici

  1. Idem encore, mais avec des nlignes d'historique par produit, où nest cinq fois l' DaysToManufactureattribut Product.

Des changements très mineurs sont nécessaires ici. Pour le CTE, nous pouvons ajouter une colonne à la requête interne et filtrer sur la requête externe; pour le CROSS APPLY, nous pouvons effectuer le calcul à l'intérieur du corrélé TOP. On pourrait penser que cela donnerait une certaine efficacité à la CROSS APPLYsolution, mais cela ne se produit pas dans ce cas. Requêtes:

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, p.DaysToManufacture, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= (5 * DaysToManufacture);

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5 * p.DaysToManufacture) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Résultats d'exécution:

entrez la description de l'image ici

CTE parallèle / OVER()plan:

entrez la description de l'image ici

CTE mono-thread / OVER()plan:

entrez la description de l'image ici

CROSS APPLY plan:

entrez la description de l'image ici

  1. Idem, dans le cas particulier où une seule ligne d'historique par produit est requise (entrée unique la plus récente par TransactionDatetie-break activée) TransactionID.

Encore une fois, des changements mineurs ici. Dans la solution CTE, nous ajoutons TransactionIDà la OVER()clause et modifions le filtre externe en rn = 1. Pour le CROSS APPLY, nous changeons le TOPen TOP (1)et ajoutons TransactionIDà l'intérieur ORDER BY.

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC, TransactionID DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn = 1;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (1) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC, TransactionID DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

Résultats d'exécution:

entrez la description de l'image ici

CTE parallèle / OVER()plan:

entrez la description de l'image ici

Plan CTE / OVER simple filetage:

entrez la description de l'image ici

CROSS APPLY plan:

entrez la description de l'image ici

Les fonctions de fenêtrage ne sont pas toujours la meilleure alternative (ce qui est utile COUNT(*) OVER()), et ce ne sont pas les deux seules approches pour résoudre le problème de n lignes par groupe, mais dans ce cas spécifique - compte tenu du schéma, des index existants et de la distribution des données - le CTE s'en sort mieux avec tous les comptes significatifs.


Exemples AdventureWorks - avec la possibilité d'ajouter des index

Toutefois, si vous ajoutez un index de support, similaire à celui mentionné par Paul dans un commentaire, mais avec les 2ème et 3ème colonnes ordonnées DESC:

CREATE UNIQUE NONCLUSTERED INDEX UQ3 ON Production.TransactionHistory 
  (ProductID, TransactionDate DESC, TransactionID DESC);

Vous obtiendriez en fait des plans beaucoup plus favorables, et les paramètres seraient inversés pour favoriser l' CROSS APPLYapproche dans les trois cas:

entrez la description de l'image ici

S'il s'agissait de mon environnement de production, je serais probablement satisfait de la durée dans ce cas et je ne prendrais pas la peine d'optimiser davantage.


C’était beaucoup plus laid dans SQL Server 2000, qui ne supportait APPLYni la OVER()clause.

Aaron Bertrand
la source
24

Dans les SGBD, comme MySQL, qui n’ont pas de fonctions de fenêtre ou CROSS APPLYqui utilisent cette méthode, vous pouvez utiliser du SQL standard (89). Le chemin le plus lent serait une jointure triangulaire croisée avec un agrégat. Le moyen le plus rapide (mais toujours et probablement pas aussi efficace que d’utiliser cross application ou la fonction row_number) serait ce que j’appelle le "pauvre homme CROSS APPLY" . Il serait intéressant de comparer cette requête avec les autres:

Hypothèse: Orders (CustomerID, OrderDate)a une UNIQUEcontrainte:

DECLARE @top INT;
SET @top = 5;

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (@top) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

Pour le problème supplémentaire des rangées supérieures personnalisées par groupe:

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (c.Number_of_Recent_Orders_to_Show) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

Note: En MySQL, AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)on utiliserait plutôt AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL-Server a ajouté la FETCH / OFFSETsyntaxe dans la version 2012. Les requêtes ici ont été ajustées IN (TOP...)pour fonctionner avec les versions précédentes.

ypercubeᵀᴹ
la source
21

J'ai adopté une approche légèrement différente, principalement pour voir comment cette technique se comparerait aux autres, car avoir des options, c'est bien, non?

Le test

Pourquoi ne commençons-nous pas par regarder simplement comment les différentes méthodes se superposent. J'ai fait trois séries de tests:

  1. Le premier jeu a fonctionné sans modification de base de données
  2. Le deuxième ensemble s'est exécuté après la création d'un index pour prendre en charge les TransactionDaterequêtes basées sur Production.TransactionHistory.
  3. La troisième série faisait une hypothèse légèrement différente. Étant donné que les trois tests ont été exécutés sur la même liste de produits, si nous avions mis cette liste en cache? Ma méthode utilise un cache en mémoire alors que les autres méthodes utilisaient une table temporaire équivalente. L'index de prise en charge créé pour le deuxième ensemble de tests existe toujours pour cet ensemble de tests.

Détails de test supplémentaires:

  • Les tests ont été exécutés AdventureWorks2012sur SQL Server 2012, SP2 (Developer Edition).
  • Pour chaque test, j'ai indiqué la réponse de la requête et la requête particulière.
  • J'ai utilisé l'option "Supprimer les résultats après l'exécution" dans Options de requête | Résultats.
  • Veuillez noter que pour les deux premiers ensembles de tests, le résultat RowCountssemble être "désactivé" pour ma méthode. Ceci est dû au fait que ma méthode est une implémentation manuelle de ce qui CROSS APPLYest en train de se faire: elle exécute la requête initiale Production.Productet récupère 161 lignes, qu’elle utilise ensuite pour les requêtes Production.TransactionHistory. Par conséquent, les RowCountvaleurs de mes entrées sont toujours 161 supérieures à celles des autres entrées. Dans le troisième ensemble de tests (avec la mise en cache), le nombre de lignes est le même pour toutes les méthodes.
  • J'ai utilisé SQL Server Profiler pour capturer les statistiques au lieu de compter sur les plans d'exécution. Aaron et Mikael ont déjà fait un excellent travail en montrant les plans de leurs requêtes et il n’est pas nécessaire de reproduire cette information. Et le but de ma méthode est de réduire les requêtes à une forme si simple que cela n'aurait pas vraiment d'importance. Il existe une raison supplémentaire d'utiliser Profiler, mais nous le mentionnerons plus tard.
  • Plutôt que d'utiliser la Name >= N'M' AND Name < N'S'construction, j'ai choisi d'utiliser Name LIKE N'[M-R]%', et SQL Server les traite de la même manière.

Les resultats

Aucun indice de soutien

Ceci est essentiellement AdventureWorks2012 prêt à l'emploi. Dans tous les cas, ma méthode est clairement meilleure que certaines des autres, mais jamais aussi bonne que les 1 ou 2 meilleures méthodes.

Test 1 Résultats du test 1 - sans index
Aaron's CTE est clairement le gagnant ici.

Test 2 Test 2 Résultats-sans index
Le CTE d'Aaron (encore) et la seconde apply row_number()méthode de Mikael sont proches.

Test 3 Test 3 Résultats-sans index
Aaron (CTE) (encore) est le gagnant.

Conclusion
Quand il n'y a pas d'indice de support TransactionDate, ma méthode est meilleure que de faire un standard CROSS APPLY, mais quand même, utiliser la méthode CTE est clairement la voie à suivre.

Avec index de prise en charge (pas de cache)

Pour cet ensemble de tests, j'ai ajouté l'index évident, TransactionHistory.TransactionDatecar toutes les requêtes sont triées sur ce champ. Je dis "évident" car la plupart des autres réponses sont également d'accord sur ce point. Et comme les requêtes veulent toutes les dates les plus récentes, le TransactionDatechamp doit être commandé DESC. J'ai donc saisi la CREATE INDEXdéclaration au bas de la réponse de Mikael et ajouté un mot explicite FILLFACTOR:

CREATE INDEX [IX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC)
    WITH (FILLFACTOR = 100);

Une fois que cet index est en place, les résultats changent un peu.

Test 1 Résultats du test 1 - avec index associé
Cette fois, c’est ma méthode qui me précède, du moins en termes de lectures logiques. La CROSS APPLYméthode, auparavant la moins performante du test 1, gagne sur la durée et bat même la méthode CTE sur les lectures logiques.

Test 2 Résultats du test 2 - avec index associé
Cette fois, c'est la première apply row_number()méthode de Mikael qui a remporté le meilleur prix lors de la lecture de Reads, alors qu'elle était auparavant l'une des moins performantes. Et maintenant, ma méthode arrive à la deuxième place très proche quand on regarde Reads. En fait, en dehors de la méthode CTE, les autres sont assez proches en termes de lectures.

Test 3 Test 3 Résultats-avec index de soutien
Ici, le CTE est toujours le gagnant, mais maintenant la différence entre les autres méthodes est à peine perceptible comparée à la différence radicale qui existait avant la création de l'indice.

Conclusion
L'applicabilité de ma méthode est plus apparente maintenant, bien qu'elle résiste moins bien à la mise en place d'index appropriés.

Avec index de prise en charge ET mise en cache

Pour cet ensemble de tests, j'ai utilisé la mise en cache parce que, pourquoi pas? Ma méthode permet d'utiliser la mise en cache en mémoire à laquelle les autres méthodes ne peuvent pas accéder. Donc, pour être juste, j'ai créé la table temporaire suivante qui a été utilisée à la place de Product.Producttoutes les références de ces autres méthodes au cours des trois tests. Le DaysToManufacturechamp n'est utilisé que dans le test n ° 2, mais il était plus facile d'être cohérent dans les scripts SQL pour utiliser la même table et cela ne faisait pas de mal de l'avoir là.

CREATE TABLE #Products
(
    ProductID INT NOT NULL PRIMARY KEY,
    Name NVARCHAR(50) NOT NULL,
    DaysToManufacture INT NOT NULL
);

INSERT INTO #Products (ProductID, Name, DaysToManufacture)
    SELECT  p.ProductID, p.Name, p.DaysToManufacture
    FROM    Production.Product p
    WHERE   p.Name >= N'M' AND p.Name < N'S'
    AND    EXISTS (
                    SELECT  *
                    FROM    Production.TransactionHistory th
                    WHERE   th.ProductID = p.ProductID
                );

ALTER TABLE #Products REBUILD WITH (FILLFACTOR = 100);

Test 1 Résultats du test 1 - avec prise en charge de l'index ET de la mise en cache
Toutes les méthodes semblent bénéficier également de la mise en cache, et ma méthode est toujours en avance.

Test 2 Résultats du test 2 - avec prise en charge de l'index ET de la mise en cache
Ici, nous voyons maintenant une différence dans la gamme puisque ma méthode est à peine avancée, seulement 2 lectures sont meilleures que la première apply row_number()méthode de Mikael , alors que sans la mise en cache, ma méthode était en retard de 4 lectures.

Test 3 Résultats du test 3 - avec prise en charge de l'index ET de la mise en cache
S'il vous plaît voir mise à jour vers le bas (en dessous de la ligne) . Ici encore, nous voyons une différence. La version "paramétrée" de ma méthode est maintenant à peine en avance par 2 lectures par rapport à la méthode CROSS APPLY d'Aaron (sans mise en cache, elles étaient égales). Mais ce qui est vraiment étrange, c’est que, pour la première fois, nous voyons une méthode affectée négativement par la mise en cache: la méthode CTE d’Aaron (qui était auparavant la meilleure pour le test n ° 3). Mais, je ne vais pas prendre le crédit là où ce n’est pas dû, et puisque sans la méthode CTE d’Aaron est toujours plus rapide que ma méthode est ici, la meilleure approche pour cette situation semble être la méthode CTE d’Aaron.

Conclusion S'il vous plaît voir la mise à jour vers le bas (au-dessous de la ligne) Les
situations qui utilisent de manière répétée les résultats d'une requête secondaire peuvent souvent (mais pas toujours) bénéficier de la mise en cache de ces résultats. Mais lorsque la mise en cache est un avantage, l’utilisation de la mémoire pour cette mise en cache présente certains avantages par rapport à l’utilisation de tables temporaires.

La méthode

Généralement

J'ai séparé la requête "en-tête" (c'est-à-dire obtenir le ProductIDs, et dans un cas également le DaysToManufacture, en Namepartant du début avec certaines lettres) des requêtes "en détail" (c'est-à-dire obtenir les TransactionIDs et TransactionDates). Le concept consistait à effectuer des requêtes très simples et à ne pas laisser confondre l'optimiseur lors de leur JOINDRE. Clairement, cela n’est pas toujours avantageux car cela empêche également l’optimiseur d’optimiser. Mais comme nous l'avons vu dans les résultats, cette méthode a ses avantages, selon le type de requête.

La différence entre les différentes saveurs de cette méthode sont:

  • Constantes: Soumettez toutes les valeurs remplaçables en tant que constantes en ligne au lieu d'être des paramètres. Cela ferait référence ProductIDdans les trois tests ainsi que le nombre de lignes à renvoyer dans le test 2 car il est fonction de "cinq fois l' DaysToManufactureattribut du produit". Cette sous-méthode signifie que chacun ProductIDaura son propre plan d'exécution, ce qui peut être bénéfique s'il existe une grande variation dans la distribution des données ProductID. Mais si la distribution des données varie peu, le coût de la génération des plans supplémentaires ne vaudra probablement pas la peine.

  • Paramétrisé: Soumettez au moins ProductIDle @ProductID, permettant la mise en cache et la réutilisation du plan d'exécution. Il existe une option de test supplémentaire pour traiter également le nombre variable de lignes à renvoyer pour le test 2 en tant que paramètre.

  • Optimiser inconnu: lors de la référence en ProductIDtant que @ProductID, s'il existe une grande variation de la distribution des données, il est possible de mettre en cache un plan qui a un effet négatif sur les autres ProductIDvaleurs. Il serait donc bon de savoir si l'utilisation de cette astuce de requête en aide.

  • Cache Products: Plutôt que d’interroger la Production.Producttable à chaque fois, mais seulement pour obtenir la même liste, lancez la requête une fois (et pendant que nous y sommes, filtrez tous les ProductIDs qui ne sont même pas dans la TransactionHistorytable pour ne pas gaspiller ressources là-bas) et cache cette liste. La liste devrait inclure le DaysToManufacturechamp. Avec cette option, le nombre de tentatives de lecture logique lors de la première exécution est légèrement plus important au départ, mais après cela, seule la TransactionHistorytable est interrogée.

Plus précisément

Ok, mais alors, euh, comment est-il possible d'émettre toutes les sous-requêtes en tant que requêtes séparées sans utiliser un CURSEUR et de vider chaque jeu de résultats dans une table ou une variable de table temporaire? Manifestement, la méthode CURSOR / Temp Table aurait des conséquences manifestes dans les lectures et les écritures. Eh bien, en utilisant SQLCLR :). En créant une procédure stockée SQLCLR, j'ai pu ouvrir un jeu de résultats et essentiellement lui transférer les résultats de chaque sous-requête, sous la forme d'un jeu de résultats continu (et non de plusieurs jeux de résultats). En dehors des informations sur le produit (c'est ProductID-à- dire Name, etDaysToManufacture), aucun des résultats de la sous-requête n'a dû être stocké nulle part (mémoire ou disque) et vient d'être transmis en tant que jeu de résultats principal de la procédure stockée SQLCLR. Cela m'a permis de faire une requête simple pour obtenir les informations sur le produit, puis de les parcourir, en émettant des requêtes très simples TransactionHistory.

Et c'est pourquoi j'ai dû utiliser SQL Server Profiler pour capturer les statistiques. La procédure stockée SQLCLR n'a pas renvoyé de plan d'exécution, en définissant l'option de requête "Inclure le plan d'exécution réel" ou en émettant SET STATISTICS XML ON;.

Pour la mise en cache des informations sur le produit, j'ai utilisé une readonly staticliste générique (c'est- _GlobalProductsà- dire dans le code ci-dessous). Il semble que l'ajout de collections ne viole pas l' readonlyoption. Par conséquent, ce code fonctionne lorsque l'assembly a un PERMISSON_SETde SAFE:), même si cela est contre-intuitif.

Les requêtes générées

Les requêtes générées par cette procédure stockée SQLCLR sont les suivantes:

Information sur le produit

Test numéros 1 et 3 (pas de mise en cache)

SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
FROM   Production.Product prod1
WHERE  prod1.Name LIKE N'[M-R]%';

Test numéro 2 (pas de cache)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Test numéros 1, 2 et 3 (mise en cache)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
    AND    EXISTS (
                SELECT *
                FROM Production.TransactionHistory th
                WHERE th.ProductID = prod1.ProductID
                  )
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Information de transaction

Test N ° 1 et 2 (Constantes)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC;

Numéros de test 1 et 2 (paramétrés)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

Numéros de test 1 et 2 (paramétrés + OPTIMISÉ INCONNU)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Test numéro 2 (paramétré les deux)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

Test numéro 2 (paramétré les deux + OPTIMIZE UNKNOWN)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Test numéro 3 (constantes)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC, th.TransactionID DESC;

Test numéro 3 (paramétré)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
;

Test numéro 3 (paramétré + OPTIMISÉ INCONNU)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

Le code

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

public class ObligatoryClassName
{
    private class ProductInfo
    {
        public int ProductID;
        public string Name;
        public int DaysToManufacture;

        public ProductInfo(int ProductID, string Name, int DaysToManufacture)
        {
            this.ProductID = ProductID;
            this.Name = Name;
            this.DaysToManufacture = DaysToManufacture;

            return;
        }
    }

    private static readonly List<ProductInfo> _GlobalProducts = new List<ProductInfo>();

    private static void PopulateGlobalProducts(SqlBoolean PrintQuery)
    {
        if (_GlobalProducts.Count > 0)
        {
            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(String.Concat("I already haz ", _GlobalProducts.Count,
                            " entries :)"));
            }

            return;
        }

        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;
        _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
     AND    EXISTS (
                     SELECT *
                     FROM Production.TransactionHistory th
                     WHERE th.ProductID = prod1.ProductID
                   )
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";

        SqlDataReader _Reader = null;

        try
        {
            _Connection.Open();

            _Reader = _Command.ExecuteReader();

            while (_Reader.Read())
            {
                _GlobalProducts.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                    _Reader.GetInt32(2)));
            }
        }
        catch
        {
            throw;
        }
        finally
        {
            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }

        return;
    }


    [Microsoft.SqlServer.Server.SqlProcedure]
    public static void GetTopRowsPerGroup(SqlByte TestNumber,
        SqlByte ParameterizeProductID, SqlBoolean OptimizeForUnknown,
        SqlBoolean UseSequentialAccess, SqlBoolean CacheProducts, SqlBoolean PrintQueries)
    {
        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;

        List<ProductInfo> _Products = null;
        SqlDataReader _Reader = null;

        int _RowsToGet = 5; // default value is for Test Number 1
        string _OrderByTransactionID = "";
        string _OptimizeForUnknown = "";
        CommandBehavior _CmdBehavior = CommandBehavior.Default;

        if (OptimizeForUnknown.IsTrue)
        {
            _OptimizeForUnknown = "OPTION (OPTIMIZE FOR (@ProductID UNKNOWN))";
        }

        if (UseSequentialAccess.IsTrue)
        {
            _CmdBehavior = CommandBehavior.SequentialAccess;
        }

        if (CacheProducts.IsTrue)
        {
            PopulateGlobalProducts(PrintQueries);
        }
        else
        {
            _Products = new List<ProductInfo>();
        }


        if (TestNumber.Value == 2)
        {
            _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";
        }
        else
        {
            _Command.CommandText = @"
     SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
     FROM   Production.Product prod1
     WHERE  prod1.Name LIKE N'[M-R]%';
";
            if (TestNumber.Value == 3)
            {
                _RowsToGet = 1;
                _OrderByTransactionID = ", th.TransactionID DESC";
            }
        }

        try
        {
            _Connection.Open();

            // Populate Product list for this run if not using the Product Cache
            if (!CacheProducts.IsTrue)
            {
                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _Products.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                  _Reader.GetInt32(2)));
                }

                _Reader.Close();

                if (PrintQueries.IsTrue)
                {
                    SqlContext.Pipe.Send(_Command.CommandText);
                }
            }
            else
            {
                _Products = _GlobalProducts;
            }

            SqlDataRecord _ResultRow = new SqlDataRecord(
                new SqlMetaData[]{
                    new SqlMetaData("ProductID", SqlDbType.Int),
                    new SqlMetaData("Name", SqlDbType.NVarChar, 50),
                    new SqlMetaData("TransactionID", SqlDbType.Int),
                    new SqlMetaData("TransactionDate", SqlDbType.DateTime)
                });

            SqlParameter _ProductID = new SqlParameter("@ProductID", SqlDbType.Int);
            _Command.Parameters.Add(_ProductID);
            SqlParameter _RowsToReturn = new SqlParameter("@RowsToReturn", SqlDbType.Int);
            _Command.Parameters.Add(_RowsToReturn);

            SqlContext.Pipe.SendResultsStart(_ResultRow);

            for (int _Row = 0; _Row < _Products.Count; _Row++)
            {
                // Tests 1 and 3 use previously set static values for _RowsToGet
                if (TestNumber.Value == 2)
                {
                    if (_Products[_Row].DaysToManufacture == 0)
                    {
                        continue; // no use in issuing SELECT TOP (0) query
                    }

                    _RowsToGet = (5 * _Products[_Row].DaysToManufacture);
                }

                _ResultRow.SetInt32(0, _Products[_Row].ProductID);
                _ResultRow.SetString(1, _Products[_Row].Name);

                switch (ParameterizeProductID.Value)
                {
                    case 0x01:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC{2}
   {1};
", _RowsToGet, _OptimizeForUnknown, _OrderByTransactionID);

                        _ProductID.Value = _Products[_Row].ProductID;
                        break;
                    case 0x02:
                        _Command.CommandText = String.Format(@"
   SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC
   {0};
", _OptimizeForUnknown);

                        _ProductID.Value = _Products[_Row].ProductID;
                        _RowsToReturn.Value = _RowsToGet;
                        break;
                    default:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = {1}
   ORDER BY th.TransactionDate DESC{2};
", _RowsToGet, _Products[_Row].ProductID, _OrderByTransactionID);
                        break;
                }


                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _ResultRow.SetInt32(2, _Reader.GetInt32(0));
                    _ResultRow.SetDateTime(3, _Reader.GetDateTime(1));

                    SqlContext.Pipe.SendResultsRow(_ResultRow);
                }
                _Reader.Close();
            }

        }
        catch
        {
            throw;
        }
        finally
        {
            if (SqlContext.Pipe.IsSendingResults)
            {
                SqlContext.Pipe.SendResultsEnd();
            }

            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQueries.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }


    }
}

Les requêtes de test

Il n'y a pas assez de place pour poster les tests ici, donc je vais trouver un autre endroit.

La conclusion

Pour certains scénarios, SQLCLR peut être utilisé pour manipuler certains aspects des requêtes qui ne peuvent pas être effectuées dans T-SQL. Et il est possible d'utiliser la mémoire pour la mise en cache au lieu de tables temporaires, bien que cela doive être fait avec parcimonie et prudence, car la mémoire n'est pas automatiquement restituée au système. Cette méthode ne facilite pas non plus les requêtes ad hoc, mais il est possible de la rendre plus flexible que ce que j'ai montré ici simplement en ajoutant des paramètres permettant de personnaliser davantage d'aspects des requêtes en cours d'exécution.


MISE À JOUR

Test supplémentaire
Mes tests d'origine comprenant un index de prise en charge TransactionHistoryutilisaient la définition suivante:

ProductID ASC, TransactionDate DESC

J'avais décidé à l'époque de renoncer, y compris TransactionId DESCà la fin, en pensant que cela pourrait aider le test n ° 3 (qui spécifie le départage des TransactionIdégalités sur le plus récent - bien, "le plus récent" est supposé puisque non explicitement indiqué, mais tout le monde semble d'accord sur cette hypothèse), il n'y aurait probablement pas assez de liens pour faire une différence.

Mais, ensuite, Aaron a réessayé avec un index de support qui incluait TransactionId DESCet a trouvé que la CROSS APPLYméthode était la gagnante des trois tests. Ceci était différent de mes tests qui indiquaient que la méthode CTE était la meilleure pour le test n ° 3 (lorsqu'aucune mise en cache n'est utilisée, ce qui reflète le test d'Aaron). Il était clair qu'il y avait une autre variation à tester.

J'ai supprimé l'index actuel de prise en charge, créé un nouvel index avec TransactionIdet effacé le cache du plan (juste pour être sûr):

DROP INDEX [IX_TransactionHistoryX] ON Production.TransactionHistory;

CREATE UNIQUE INDEX [UIX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC, TransactionID DESC)
    WITH (FILLFACTOR = 100);

DBCC FREEPROCCACHE WITH NO_INFOMSGS;

J'ai refait le test n ° 1 et les résultats étaient les mêmes, comme prévu. J'ai ensuite refait le test numéro 3 et les résultats ont bien changé:

Résultats du test 3 - avec index de prise en charge (avec TransactionId DESC)
Les résultats ci-dessus concernent le test standard sans mise en cache. Cette fois, non seulement le temps est CROSS APPLYbattu par le CTE (exactement comme le test d'Aaron l'a indiqué), mais le processus SQLCLR a pris l'avantage par 30 lectures (woo hoo).

Résultats du test 3 - avec index de prise en charge (avec TransactionId DESC) ET mise en cache
Les résultats ci-dessus concernent le test avec la mise en cache activée. Cette fois, la performance du CTE n'est pas dégradée, même si CROSS APPLYelle bat encore. Cependant, maintenant, le processus SQLCLR prend la tête de 23 lectures (woo hoo, encore).

À emporter

  1. Il existe différentes options à utiliser. Il vaut mieux en essayer plusieurs car ils ont chacun leurs points forts. Les tests effectués ici montrent une assez petite variance dans les lectures et la durée entre les meilleurs et les moins performants parmi tous les tests (avec un indice complémentaire); La variation en lecture est d'environ 350 et la durée est de 55 ms. Bien que le processus SQLCLR ait remporté tous les tests sauf un (en termes de lectures), enregistrer seulement quelques lectures ne vaut généralement pas le coût de maintenance associé à la route SQLCLR. Mais dans AdventureWorks2012, la Producttable ne comporte que 504 lignes et TransactionHistoryne contient que 113 443 lignes. La différence de performance entre ces méthodes devient probablement plus prononcée à mesure que le nombre de lignes augmente.

  2. Bien que cette question concerne spécifiquement l’obtention d’un ensemble particulier de lignes, il ne faut pas oublier que le facteur le plus important en termes de performances est l’indexation et non le code SQL particulier. Un bon index doit être mis en place avant de déterminer quelle méthode est vraiment la meilleure.

  3. La leçon la plus importante trouvée ici ne concerne pas CROSS APPLY vs CTE vs SQLCLR: il s'agit de TESTING. Ne présume pas. Obtenez des idées de plusieurs personnes et testez autant de scénarios que possible.

Salomon Rutzky
la source
2
Voir ma réponse à la réponse de Mikael pour connaître la raison des lectures logiques supplémentaires associées à apply.
Paul White
18

APPLY TOPou ROW_NUMBER()? Que pourrait-il y avoir de plus à dire à ce sujet?

Une brève récapitulation des différences et pour être vraiment bref, je ne montrerai que les plans de l’option 2 et j’ai ajouté l’indice Production.TransactionHistory.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate)

La row_number()requête :.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         P.DaysToManufacture,
         row_number() over(partition by P.ProductID order by T.TransactionDate desc) as rn
  from Production.Product as P
    inner join Production.TransactionHistory as T
      on P.ProductID = T.ProductID
  where P.Name >= N'M' and
        P.Name < N'S'
)
select C.TransactionID,
       C.TransactionDate
from C
where C.rn <= 5 * C.DaysToManufacture;

entrez la description de l'image ici

La apply topversion:

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select top(cast(5 * P.DaysToManufacture as bigint))
                T.TransactionID,
                T.TransactionDate
              from Production.TransactionHistory as T
              where P.ProductID = T.ProductID
              order by T.TransactionDate desc
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

entrez la description de l'image ici

La principale différence entre ceux- apply topci réside dans le fait que les filtres sur l'expression supérieure en dessous des boucles imbriquées se joignent aux row_numberfiltres de version après la jointure. Cela signifie qu'il y a plus de lectures Production.TransactionHistoryqu'il n'est vraiment nécessaire.

S'il n'existait qu'un moyen de pousser les opérateurs responsables de l'énumération des lignes vers la branche inférieure avant la jointure, alors la row_numberversion pourrait faire mieux.

Alors entrez la apply row_number()version.

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select T.TransactionID,
                     T.TransactionDate
              from (
                   select T.TransactionID,
                          T.TransactionDate,
                          row_number() over(order by T.TransactionDate desc) as rn
                   from Production.TransactionHistory as T
                   where P.ProductID = T.ProductID
                   ) as T
              where T.rn <= cast(5 * P.DaysToManufacture as bigint)
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

entrez la description de l'image ici

Comme vous pouvez le voir, apply row_number()c'est à peu près la même chose apply topque légèrement plus compliqué. Le temps d'exécution est également à peu près le même ou un peu plus lent.

Alors pourquoi ai-je pris la peine de trouver une réponse qui ne soit pas meilleure que celle que nous avons déjà? Eh bien, vous avez encore une chose à essayer dans le monde réel et il y a une différence entre les lectures. Un que je n'ai pas d'explication pour *.

APPLY - ROW_NUMBER
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 230, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

APPLY - TOP
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 268, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Pendant que je suis au courant, je pourrais aussi bien présenter une deuxième row_number()version qui, dans certains cas, pourrait être la solution. Dans certains cas, vous auriez besoin de la plupart des lignes Production.TransactionHistorycar ici, vous obtenez une jointure de fusion entre Production.Productl'énumération et celle énumérée Production.TransactionHistory.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         T.ProductID,
         row_number() over(partition by T.ProductID order by T.TransactionDate desc) as rn
  from Production.TransactionHistory as T
)
select C.TransactionID,
       C.TransactionDate
from C
 inner join Production.Product as P
      on P.ProductID = C.ProductID
where P.Name >= N'M' and
      P.Name < N'S' and
      C.rn <= 5 * P.DaysToManufacture;

entrez la description de l'image ici

Pour obtenir la forme ci-dessus sans opérateur de tri, vous devez également modifier l'ordre de support par ordre TransactionDatedécroissant.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate desc)

* Edit: les lectures logiques supplémentaires sont dues au prélecture de boucles imbriquées utilisé avec apply-top. Vous pouvez le désactiver avec la TF non conforme (8744) (et / ou 9115 sur les versions ultérieures) pour obtenir le même nombre de lectures logiques. La prélecture peut être un avantage de la solution apply-top dans les bonnes circonstances. - Paul White

Mikael Eriksson
la source
11

J'utilise généralement une combinaison de CTE et de fonctions de fenêtrage. Vous pouvez obtenir cette réponse en utilisant quelque chose comme ceci:

;WITH GiveMeCounts
AS (
    SELECT CustomerID
        ,OrderDate
        ,TotalAmt

        ,ROW_NUMBER() OVER (
            PARTITION BY CustomerID ORDER BY 
            --You can change the following field or sort order to whatever you'd like to order by.
            TotalAmt desc
            ) AS MySeqNum
    )
SELECT CustomerID, OrderDate, TotalAmt
FROM GiveMeCounts
--Set n per group here
where MySeqNum <= 10

Pour la partie crédit supplémentaire, où différents groupes peuvent vouloir renvoyer différents nombres de lignes, vous pouvez utiliser un tableau séparé. Disons en utilisant des critères géographiques tels que l'état:

+-------+-----------+
| State | MaxSeqnum |
+-------+-----------+
| AK    |        10 |
| NY    |         5 |
| NC    |        23 |
+-------+-----------+

Afin d’atteindre cet objectif lorsque les valeurs peuvent être différentes, vous devez associer votre CTE à la table State similaire à celle-ci:

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
Kris Gruttemeyer
la source