web-dev-qa-db-fra.com

Récupération de n lignes par groupe

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

Par exemple, je pourrais vouloir répertorier les "n" valeurs de commande récente les plus élevées ou les plus basses par client.

Dans les cas plus complexes, le nombre de lignes à répertorier peut varier par groupe (défini par un attribut de l'enregistrement de regroupement/parent). Cette partie est définitivement facultative/pour un crédit supplémentaire et n'a pas pour but de 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 dates et ID de transaction les plus récents dans la table TransactionHistory, pour chaque produit commençant par une lettre de M à R inclus.
  2. Même chose, mais avec n lignes d'historique par produit, où n est cinq fois l'attribut DaysToManufacture Product.
  3. Idem, pour le cas spécial où exactement une ligne d'historique par produit est requise (la seule entrée la plus récente par TransactionDate, tie-break sur TransactionID.
92
Paul White 9

Commençons par le scénario de base.

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

Considérons d'abord l'ensemble complet de Production.TransactionHistory Pour un ProductID particulier:

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

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

Expensive Scan with 'Residual' Predicate

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

Cela fait, notre plan change considérablement et réduit les lectures à seulement 3. Donc, nous améliorons déjà les choses de plus de 250x environ ...

Improved plan

Maintenant que nous avons nivelé le terrain de jeu, regardons les meilleures 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;

Two plans - basic TOP\RowNum

Vous remarquerez que la deuxième requête (TOP) est beaucoup plus simple que la première, à la fois dans la requête et dans le plan. Mais de manière très significative, ils utilisent tous les deux TOP pour limiter le nombre de lignes réellement extraites de l'index. Les coûts ne sont que des estimations et méritent d'être ignorés, mais vous pouvez voir beaucoup de similitudes dans les deux plans, avec la version ROW_NUMBER() faisant un travail minime supplémentaire pour attribuer des numéros et filtrer en conséquence, et les deux requêtes se terminent jusqu'à faire seulement 2 lectures pour faire leur travail. L'optimiseur de requête reconnaît certainement l'idée de filtrer sur un champ ROW_NUMBER(), réalisant qu'il peut utiliser un opérateur Top pour ignorer les lignes qui ne seront pas nécessaires. Ces deux requêtes sont assez bonnes - TOP n'est pas tellement mieux qu'il vaut la peine de changer de code, mais c'est plus simple et probablement plus clair pour les débutants.

Cela fonctionne donc sur un seul produit. Mais nous devons considérer ce qui se passe si nous devons le faire sur plusieurs produits.

Le programmeur itératif va envisager l'idée de parcourir les produits d'intérêt et d'appeler cette requête plusieurs fois, et nous pouvons en fait nous en sortir en écrivant une requête sous cette forme - sans utiliser de curseurs, mais en utilisant APPLY . J'utilise OUTER APPLY, Pensant que nous pourrions vouloir retourner le produit avec NULL, s'il n'y a aucune 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 programmateurs itératifs - Boucle imbriquée, effectuant 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.

APPLY plan

En utilisant ROW_NUMBER(), la méthode consiste à utiliser PARTITION BY Dans la clause OVER, afin de redémarrer la numérotation pour chaque produit. Cela peut ensuite être filtré comme auparavant. Le plan finit par être assez différent. Les lectures logiques sont environ 15% plus faibles sur TransactionHistory, avec un balayage d'index complet 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, les données doivent être recourues pour pouvoir trouver les rownumbers. C'est moins de lectures, mais ce tri bloquant peut être douloureux. En utilisant APPLY, la boucle imbriquée retournera les premières lignes très rapidement, après seulement quelques lectures, mais avec un tri, ROW_NUMBER() ne retournera les lignes qu'après la plupart du travail terminé .

Fait intéressant, si la requête ROW_NUMBER() utilise INNER JOIN Au lieu de LEFT JOIN, Un plan différent apparaît.

ROW_NUMBER() with INNER JOIN

Ce plan utilise une boucle imbriquée, tout comme avec APPLY. Mais il n'y a pas d'opérateur Top, il tire donc toutes les transactions pour chaque produit et utilise beaucoup plus de lectures qu'auparavant - 492 lectures par rapport à TransactionHistory. Il n'y a pas de bonne raison pour ne pas choisir ici l'option Fusionner, donc je suppose que le plan a été considéré comme "assez bon". Pourtant - il ne bloque pas, ce qui est agréable - mais pas aussi agréable que APPLY.

La colonne PARTITION BY Que j'ai utilisée pour ROW_NUMBER() était h.ProductID Dans les deux cas, car j'avais voulu donner au QO la possibilité de produire la valeur RowNum avant de rejoindre le produit table. Si j'utilise p.ProductID, Nous voyons le même plan de forme qu'avec la variation INNER JOIN.

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 un peu moins de 500 lectures par rapport à la table TransactionHistory.

PARTITION BY on p.ProductID instead of h.ProductID

Quoi qu'il en soit - revenons à la question à portée de main ...

Nous avons répondu question 1, avec deux options parmi lesquelles vous pouvez choisir. Personnellement, j'aime l'option APPLY.

Pour étendre ceci pour utiliser un nombre variable (question 2), le 5 Doit juste être modifié en conséquence. Oh, et j'ai ajouté un autre index, afin qu'il y ait un index sur Production.Product.Name Qui comprenait la colonne DaysToManufacture.

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!

Variable rows

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

Une façon d'éviter le bloc est de proposer un plan qui gère le bit ROW_NUMBER() à droite (dans le plan) de la jointure. Nous pouvons persuader cela de se produire en faisant la jointure en dehors du 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é.

Joining outside CTE

Notez le calcul scalaire qui extrait des données de la table produit. Cela fonctionne sur la valeur 5 * p.DaysToManufacture. Cette valeur n'est pas transmise à la branche qui extrait les données de la table TransactionHistory, elle est utilisée dans la jointure de fusion. En tant que résidu.

Sneaky Residual!

Ainsi, la jointure de fusion consomme TOUTES les lignes, pas seulement la première, mais combien sont nécessaires, mais toutes, puis effectue une vérification résiduelle. Ceci est dangereux car 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 scénario APPLY/TOP.

Dans le cas particulier où il s'agit exactement d'une ligne, pour question, nous pouvons évidemment utiliser les mêmes requêtes, mais avec 1 Au lieu de 5. Mais nous avons ensuite 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 tie-break (en utilisant une concaténation qui serait ensuite décomposée), mais nous examinons l'ensemble de l'index, ou nous plongeons produit par produit, et nous n'obtenons pas vraiment une grande amélioration par rapport à ce que nous avions auparavant dans ce scénario.

Mais je dois souligner que nous examinons 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 avons vu que APPLY est fort ici, il peut être plus lent dans certaines situations. Cependant, il bloque rarement, car il a tendance à utiliser des boucles imbriquées, que beaucoup de gens (moi y compris) trouvent très attrayants.

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 particulier que les gens veulent rarement basé sur la complication de la concaténation et du fractionnement. La principale chose à considérer ici est que ces deux options sont toutes deux très fortes.

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

73
Rob Farley

La façon typique de le faire dans SQL Server 2005 et versions ultérieures consiste à utiliser un CTE et des fonctions de fenêtrage. Pour les n premiers par groupe, vous pouvez simplement utiliser ROW_NUMBER() avec une clause PARTITION, et filtrer par rapport à cela dans la requête externe. Ainsi, par exemple, les 5 commandes les plus récentes par client pourraient être affichées de cette façon:

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 également 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 Paul spécifiée, 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 APPLY Et en incorporant l'option supplémentaire que le nombre de lignes pour un client soit dicté par une colonne de la table des clients:

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, afin d'optimiser les performances et d'obtenir le meilleur plan dépendra vraiment des facteurs locaux.

Personnellement, je préfère les solutions CTE et fenêtrage aux CROSS APPLY/TOP car elles séparent mieux la logique et sont plus intuitives (pour moi). En général (dans ce cas et 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, surtout si les index ont changé ou les données ont faussé de manière significative.


Exemples AdventureWorks - sans aucune modification

  1. Répertoriez les cinq dates et ID de transaction les plus récents dans la table TransactionHistory, 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:

enter image description here

Plan CTE/OVER():

enter image description here

Forfait CROSS APPLY:

enter image description here

Le plan CTE semble plus compliqué, mais il est en réalité beaucoup plus efficace. Prêtez peu d'attention aux nombres estimés de% de coût, 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 aussi fait ça sans parallélisme, et ce n'était pas la différence. Métriques d'exécution et plan CTE (le plan CROSS APPLY Est resté le même):

enter image description here

enter image description here

  1. Même chose, mais avec n lignes d'historique par produit, où n est cinq fois l'attribut DaysToManufacture 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 TOP corrélé. On pourrait penser que cela apporterait une certaine efficacité à la solution CROSS APPLY, 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:

enter image description here

Plan CTE/OVER() parallèle:

enter image description here

Plan CTE/OVER() simple thread:

enter image description here

Forfait CROSS APPLY:

enter image description here

  1. Idem, pour le cas spécial où exactement une ligne d'historique par produit est requise (la seule entrée la plus récente par TransactionDate, tie-break sur TransactionID.

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

-- 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:

enter image description here

Plan CTE/OVER() parallèle:

enter image description here

Plan CTE/OVER () simple thread:

enter image description here

Forfait CROSS APPLY:

enter image description here

Les fonctions de fenêtrage ne sont pas toujours la meilleure alternative (essayez COUNT(*) OVER()), et ce ne sont pas les deux seules approches pour résoudre les n lignes par problème de groupe, mais dans ce cas spécifique - étant donné le schéma, les index existants et la distribution des données - le CTE a mieux résisté à tous les comptes significatifs.


Exemples AdventureWorks - avec flexibilité pour ajouter des index

Cependant, si vous ajoutez un index de support, similaire à celui que Paul a mentionné dans un commentaire mais avec les 2e et 3e 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 tout autour, et les mesures seraient inversées pour favoriser l'approche CROSS APPLY Dans les trois cas:

enter image description here

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 pas la clause APPLY ou OVER().

47
Aaron Bertrand

Dans les SGBD, comme MySQL, qui n'ont pas de fonctions de fenêtre ou CROSS APPLY, La façon de procéder serait d'utiliser du SQL standard (89). La voie lente serait un croisement triangulaire avec un agrégat. La manière la plus rapide (mais toujours et probablement pas aussi efficace que d'utiliser l'application croisée 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 contrainte UNIQUE:

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 lignes 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 ;

Remarque: Dans MySQL, au lieu de AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...) on utiliserait AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL-Server a ajouté la syntaxe FETCH / OFFSET Dans la version 2012. Les requêtes ici ont été ajustées avec IN (TOP...) pour fonctionner avec les versions antérieures.

24
ypercubeᵀᴹ

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

Les tests

Pourquoi ne commençons-nous pas par regarder comment les différentes méthodes se sont empilées les unes contre les autres. J'ai fait trois séries de tests:

  1. Le premier ensemble a fonctionné sans modifications 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 requêtes basées sur TransactionDate contre Production.TransactionHistory.
  3. Le troisième ensemble a fait une hypothèse légèrement différente. Étant donné que les trois tests se sont déroulés sur la même liste de produits, que se passerait-il si nous mettions cette liste en cache? Ma méthode utilise un cache en mémoire tandis que les autres méthodes utilisent 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 sur AdventureWorks2012 Sur SQL Server 2012, SP2 (Developer Edition).
  • Pour chaque test, j'ai étiqueté la réponse dont je prenais la requête et de quelle requête particulière il s'agissait.
  • J'ai utilisé l'option "Supprimer les résultats après exécution" des options de requête | Résultats.
  • Veuillez noter que pour les deux premiers ensembles de tests, le RowCounts semble être "désactivé" pour ma méthode. Cela est dû au fait que ma méthode est une implémentation manuelle de ce que fait CROSS APPLY: Elle exécute la requête initiale contre Production.Product Et récupère 161 lignes, qu'elle utilise ensuite pour les requêtes contre Production.TransactionHistory. Par conséquent, les valeurs RowCount pour mes entrées sont toujours 161 de plus que les autres entrées. Dans le troisième ensemble de tests (avec 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 me fier aux 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 ces informations. Et l'intention de ma méthode est de réduire les requêtes à une forme si simple que cela n'aurait pas vraiment d'importance. Il y a une raison supplémentaire pour utiliser Profiler, mais cela sera mentionné plus tard.
  • Plutôt que d'utiliser la construction Name >= N'M' AND Name < N'S', J'ai choisi d'utiliser Name LIKE N'[M-R]%', Et SQL Server les traite de la même manière.

Les resultats

Pas d'index de prise en charge

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

Test 1 Test 1 Results-with no index
Le CTE d'Aaron est clairement le gagnant ici.

Test 2 Test 2 Results-with no index
Le CTE d'Aaron (encore) et la deuxième méthode apply row_number() de Mikael est une seconde proche.

Test Test 3 Results-with no index
Le CTE d'Aaron (encore) est le gagnant.

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

Avec indice de prise en charge (pas de mise en cache)

Pour cet ensemble de tests, j'ai ajouté l'index évident sur TransactionHistory.TransactionDate Car toutes les requêtes sont triées sur ce champ. Je dis "évident" puisque la plupart des autres réponses sont également d'accord sur ce point. Et puisque les requêtes veulent toutes les dates les plus récentes, le champ TransactionDate doit être ordonné DESC, donc j'ai juste saisi l'instruction CREATE INDEX Au bas de la réponse de Mikael et ajouté un FILLFACTOR explicite:

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

Une fois cet indice en place, les résultats changent un peu.

Test 1 Test 1 Results-with supporting index
Cette fois, c'est ma méthode qui s'impose, au moins en termes de lectures logiques. La méthode CROSS APPLY, Précédemment la moins performante pour le test 1, gagne sur la durée et bat même la méthode CTE sur les lectures logiques.

Test 2 Test 2 Results-with supporting index
Cette fois, c'est la première méthode apply row_number() de Mikael qui est la gagnante lorsque l'on regarde les lectures, alors qu'auparavant c'était l'une des pires performances. Et maintenant, ma méthode arrive à une deuxième place très proche lorsque l'on regarde les lectures. En fait, en dehors de la méthode CTE, les autres sont tous assez proches en termes de lectures.

Test Test 3 Results-with supporting index
Ici, le CTE est toujours le gagnant, mais maintenant la différence entre les autres méthodes est à peine perceptible par rapport à la différence drastique qui existait avant la création de l'indice.

Conclusion
L'applicabilité de ma méthode est plus évidente maintenant, bien qu'elle soit moins résistante à ne pas avoir d'index appropriés en place.

Avec l'index de prise en charge ET la 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. Pour être honnête, j'ai créé la table temporaire suivante qui a été utilisée à la place de Product.Product Pour toutes les références dans ces autres méthodes à travers les trois tests. Le champ DaysToManufacture n'est utilisé que dans le test numéro 2, mais il était plus facile d'être cohérent entre 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 Test 1 Results-with supporting index AND caching
Toutes les méthodes semblent bénéficier également de la mise en cache, et ma méthode est toujours en tête.

Test 2 Test 2 Results-with supporting index AND caching
Ici, nous voyons maintenant une différence dans la gamme car ma méthode sort à peine en avance, seulement 2 lectures mieux que la première méthode apply row_number() de Mikael, alors que sans la mise en cache, ma méthode était en retard de 4 lectures.

Test Test 3 Results-with supporting index AND caching
Veuillez voir la mise à jour vers le bas (sous la ligne) . Ici, nous voyons à nouveau une certaine différence. La saveur "paramétrée" de ma méthode est maintenant à peine en tête de 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 qui est affectée négativement par la mise en cache: la méthode CTE d'Aaron (qui était auparavant la meilleure pour le test numéro 3). Mais, je ne vais pas m'attribuer le mérite là où il n'est pas dû, et puisque sans la mise en cache de la méthode CTE d'Aaron est encore plus rapide que ma méthode est ici avec la mise en cache, la meilleure approche pour cette situation particulière semble être la méthode CTE d'Aaron.

Conclusion Veuillez voir la mise à jour vers le bas (en 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 ladite mise en cache présente un certain avantage 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 aussi le DaysToManufacture, basé sur le Name commençant par certaines lettres) du "détail" requêtes (c'est-à-dire obtenir les TransactionIDs et TransactionDates). Le concept consistait à effectuer des requêtes très simples et à ne pas laisser l’optimiseur s’embrouiller lors de leur jonction. De toute évidence, ce n'est pas toujours avantageux car cela interdit également à l'optimiseur d'optimiser. Mais comme nous l'avons vu dans les résultats, selon le type de requête, cette méthode a ses mérites.

Les différences 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 à ProductID dans les trois tests et également au nombre de lignes à retourner dans le test 2 car cela est une fonction de "cinq fois l'attribut DaysToManufacture Product". Cette sous-méthode signifie que chaque ProductID aura 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 pour ProductID. Mais s'il y a peu de variation dans la distribution des données, le coût de génération des plans supplémentaires n'en vaut probablement pas la peine.

  • Paramétré: Soumettez au moins ProductID en tant que @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 du référencement de ProductID en tant 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 d'autres ProductID valeurs, il serait donc bon de savoir si l'utilisation de ce Query Hint aide.

  • Produits de cache: Plutôt que d'interroger la table Production.Product À chaque fois, pour obtenir exactement la même liste, exécutez la requête une fois (et pendant que nous y sommes, filtrez tout ProductIDs qui ne sont même pas dans la table TransactionHistory donc nous n'y gaspillons aucune ressource) et cache cette liste. La liste doit inclure le champ DaysToManufacture. En utilisant cette option, il y a un hit initial légèrement plus élevé sur les lectures logiques pour la première exécution, mais après cela, seule la table TransactionHistory 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 distinctes sans utiliser de CURSEUR et vider chaque jeu de résultats dans une table temporaire ou une variable de table? Clairement, faire la méthode CURSOR/Temp Table se refléterait bien évidemment dans les lectures et écritures. Eh bien, en utilisant SQLCLR :). En créant une procédure stockée SQLCLR, j'ai pu ouvrir un ensemble de résultats et y diffuser essentiellement les résultats de chaque sous-requête, en tant qu'ensemble de résultats continu (et non plusieurs ensembles de résultats). En dehors des informations sur le produit (c'est-à-dire ProductID, Name et DaysToManufacture), aucun des résultats de la sous-requête ne devait être stocké n'importe où (mémoire ou disque) et juste obtenu 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 contre 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, soit en définissant l'option de requête "Inclure le plan d'exécution réel", soit en émettant SET STATISTICS XML ON;.

Pour la mise en cache des informations sur le produit, j'ai utilisé une liste générique readonly static (C'est-à-dire _GlobalProducts Dans le code ci-dessous). Il semble que l'ajout à des collections ne viole pas l'option readonly, donc ce code fonctionne lorsque l'assembly a un PERMISSON_SET De SAFE :), même si cela est contre-intuitif .

Les requêtes générées

Les requêtes produites par cette procédure stockée SQLCLR sont les suivantes:

Information sur le produit

Numéros de test 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 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]%'
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

Numéros de test 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;

Informations sur la transaction

numéros de test 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étrisé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é + OPTIMISER 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 + OPTIMISER INCONNU)

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é + OPTIMISER 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és dans T-SQL. Et il y a la possibilité d'utiliser de la mémoire pour la mise en cache au lieu des tables temporaires, bien que cela doive être fait avec parcimonie et précaution car la mémoire n'est pas automatiquement restituée au système. Cette méthode n'est pas non plus quelque chose qui aidera les requêtes ad hoc, bien qu'il soit possible de la rendre plus flexible que ce que j'ai montré ici simplement en ajoutant des paramètres pour personnaliser plus d'aspects des requêtes en cours d'exécution.


MISE À JOUR

Test supplémentaire
Mes tests originaux qui comprenaient un index de prise en charge sur TransactionHistory utilisaient la définition suivante:

ProductID ASC, TransactionDate DESC

J'avais décidé à l'époque de renoncer à inclure TransactionId DESC À la fin, pensant que bien que cela puisse aider le test numéro 3 (qui spécifie le bris d'égalité sur le TransactionId-- le plus récent "eh bien," la plupart récente "est supposée puisqu'elle n'est pas explicitement indiquée, mais tout le monde semble être d'accord sur cette hypothèse), il n'y aurait probablement pas assez de liens pour faire la différence.

Mais, ensuite, Aaron a retesté avec un index de prise en charge qui incluait TransactionId DESC Et a constaté que la méthode CROSS APPLY Était la gagnante dans les trois tests. C'était différent de mes tests qui indiquaient que la méthode CTE était la meilleure pour le test numéro 3 (lorsqu'aucune mise en cache n'était utilisée, ce qui reflète le test d'Aaron). Il était clair qu'il y avait une variation supplémentaire qui devait être testée.

J'ai supprimé l'index de prise en charge actuel, j'en ai créé un nouveau avec TransactionId et j'ai vidé 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 relancé le test numéro 1 et les résultats étaient les mêmes, comme prévu. J'ai ensuite relancé le test numéro 3 et les résultats ont effectivement changé:

Test 3 Results-with supporting index (with TransactionId DESC)
Les résultats ci-dessus concernent le test standard sans mise en cache. Cette fois, non seulement le CROSS APPLY Bat le CTE (tout comme le test d'Aaron l'a indiqué), mais le proc SQLCLR a pris les devants par 30 lectures (woo hoo).

Test 3 Results-with supporting index (with TransactionId DESC) AND caching
Les résultats ci-dessus sont pour le test avec la mise en cache activée. Cette fois, les performances du CTE ne sont pas dégradées, bien que CROSS APPLY Le bat encore. Cependant, maintenant le proc SQLCLR prend la tête de 23 lectures (woo hoo, encore).

A emporter

  1. Il existe différentes options à utiliser. Il est préférable d'en essayer plusieurs car ils ont chacun leurs points forts. Les tests effectués ici montrent une variance assez faible à la fois en lecture et en durée entre les meilleurs et les moins performants de tous les tests (avec un indice de support); la variation des lectures 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 pour emprunter la route SQLCLR. Mais dans AdventureWorks2012, la table Product n'a que 504 lignes et TransactionHistory n'a que 113 443 lignes. La différence de performances entre ces méthodes devient probablement plus prononcée à mesure que le nombre de lignes augmente.

  2. Bien que cette question soit spécifique à l'obtention d'un ensemble particulier de lignes, il ne faut pas oublier que le principal facteur de performance était l'indexation et non le SQL particulier. Un bon indice doit être 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 TESTER. Ne présumez pas. Obtenez des idées de plusieurs personnes et testez autant de scénarios que possible.

21
Solomon Rutzky

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

Un bref récapitulatif des différences et pour être vraiment bref, je ne montrerai que les plans de l'option 2 et j'ai ajouté l'index sur Production.TransactionHistory.

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

La requête row_number() :.

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;

enter image description here

La version apply top:

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';

enter image description here

La principale différence entre ceux-ci est que apply top Filtre sur l'expression supérieure sous la jointure des boucles imbriquées où la version row_number Filtre après la jointure. Cela signifie qu'il y a plus de lectures de Production.TransactionHistory Qu'il n'en faut vraiment.

S'il n'existait qu'un moyen de pousser les opérateurs chargés d'énumérer les lignes vers la branche inférieure avant la jointure, la version row_number Pourrait faire mieux.

Entrez donc la version apply row_number().

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';

enter image description here

Comme vous pouvez le voir, apply row_number() est à peu près la même chose que apply top Mais légèrement plus compliqué. Le temps d'exécution est également à peu près identique 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 en fait une différence de lecture. Celui pour lequel je n'ai pas d'explication *.

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 j'y suis, je ferais aussi bien d'ajouter une deuxième version de row_number() qui, dans certains cas, pourrait être la voie à suivre. Dans certains cas, vous vous attendez à ce que vous ayez réellement besoin de la plupart des lignes de Production.TransactionHistory Car ici vous obtenez une jointure de fusion entre Production.Product Et le Production.TransactionHistory Énuméré.

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;

enter image description here

Pour obtenir la forme ci-dessus sans opérateur de tri, vous devez également modifier l'index de prise en charge par ordre de TransactionDate décroissant.

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

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

18
Mikael Eriksson

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 ce qui suit:

;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 retourner différents nombres de lignes, vous pouvez utiliser une table distincte. Disons en utilisant des critères géographiques tels que l'état:

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

Pour y parvenir où les valeurs peuvent être différentes, vous devez joindre votre CTE à la table State similaire à ceci:

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