Optimiser la sous-requête avec la fonction de fenêtrage

8

Comme mes compétences en réglage des performances ne semblent jamais suffisantes, je me demande toujours s'il y a plus d' optimisation que je peux effectuer contre certaines requêtes. La situation à laquelle cette question se rapporte est une fonction Windowed MAX imbriquée dans une sous-requête.

Les données que je fouille sont une série de transactions sur divers groupes d'ensembles plus importants. J'ai 4 champs d'importance, l'ID unique d'une transaction, l'ID de groupe d'un lot de transactions et les dates associées à la transaction unique ou au groupe de transactions respectif. La plupart du temps, la date de groupe correspond à la date de transaction unique maximale pour un lot, mais il y a des moments où des ajustements manuels passent par notre système et une opération de date unique se produit après la capture de la date de transaction de groupe. Cette modification manuelle n'ajuste pas la date du groupe par conception.

Ce que j'identifie dans cette requête, ce sont les enregistrements où la date unique tombe après la date de groupe. L'exemple de requête suivant crée un équivalent approximatif du scénario my et l'instruction SELECT renvoie les enregistrements que je recherche, mais est-ce que j'aborde cette solution de la manière la plus efficace? Cela prend un certain temps à s'exécuter pendant le chargement de ma table de faits car mon enregistrement compte le nombre dans les 9 chiffres supérieurs, mais surtout mon dédain pour les sous-requêtes me fait me demander s'il y a une meilleure approche ici. Je ne suis pas aussi préoccupé par les indices que je suis convaincu qu'ils sont déjà en place; ce que je recherche, c'est une approche de requête alternative qui permettra d'atteindre la même chose, mais encore plus efficacement. Toute rétroaction est la bienvenue.

CREATE TABLE #Example
(
    UniqueID INT IDENTITY(1,1)
  , GroupID INT
  , GroupDate DATETIME
  , UniqueDate DATETIME
)

CREATE CLUSTERED INDEX [CX_1] ON [#Example]
(
    [UniqueID] ASC
)


SET NOCOUNT ON

--Populate some test data
DECLARE @i INT = 0, @j INT = 5, @UniqueDate DATETIME, @GroupDate DATETIME

WHILE @i < 10000
BEGIN

    IF((@i + @j)%173 = 0)
    BEGIN
        SET @UniqueDate = GETDATE()+@i+5
    END
    ELSE
    BEGIN
        SET @UniqueDate = GETDATE()+@i
    END

    SET @GroupDate = GETDATE()+(@j-1)

    INSERT INTO #Example (GroupID, GroupDate, UniqueDate)
    VALUES (@j, @GroupDate, @UniqueDate)

    SET @i = @i + 1

    IF (@i % 5 = 0)
    BEGIN
        SET @j = @j+5
    END
END
SET NOCOUNT OFF

CREATE NONCLUSTERED INDEX [IX_2_4_3] ON [#Example]
(
    [GroupID] ASC,
    [UniqueDate] ASC,
    [GroupDate] ASC
)
INCLUDE ([UniqueID])

-- Identify any UniqueDates that are greater than the GroupDate within their GroupID
SELECT UniqueID
     , GroupID
     , GroupDate
     , UniqueDate
FROM (
    SELECT UniqueID
         , GroupID
         , GroupDate
         , UniqueDate
         , MAX(UniqueDate) OVER (PARTITION BY GroupID) AS maxUniqueDate
    FROM #Example
    ) calc_maxUD
WHERE maxUniqueDate > GroupDate
    AND maxUniqueDate = UniqueDate

DROP TABLE #Example

dbfiddle ici

John Eisbrener
la source
2
Si vous souhaitez optimiser les performances d'une requête, les index de votre table constituent une partie importante de la question.
Daniel Hutmacher
@DanielHutmacher Je suis entièrement d'accord, même si je ne vais pas vider de schéma pour ma zone DWH et Staging, c'est donc le mieux que je puisse faire avec raison.
John Eisbrener

Réponses:

9

Je suppose qu'il n'y a pas d'index, car vous n'en avez fourni aucun.

Dès le départ, l'index suivant éliminera un opérateur de tri dans votre plan, qui autrement consommerait potentiellement beaucoup de mémoire:

CREATE INDEX IX ON #Example (GroupID, UniqueDate) INCLUDE (UniqueID, GroupDate);

La sous-requête n'est pas un problème de performances dans ce cas. Si quoi que ce soit, je chercherais des moyens d'éliminer la fonction de fenêtre (MAX ... OVER) pour éviter la construction Nested Loop et Table Spool.

Avec le même index, la requête suivante peut à première vue sembler moins efficace, et elle passe de deux à trois analyses sur la table de base, mais elle élimine un grand nombre de lectures en interne car elle manque d'opérateurs de spoule. Je suppose qu'il fonctionnera toujours mieux, en particulier si vous avez suffisamment de cœurs de processeur et de performances d'E / S sur votre serveur:

SELECT e.UniqueID
     , e.GroupID
     , e.GroupDate
     , e.UniqueDate
FROM (
    SELECT GroupID, MAX(UniqueDate) AS maxUniqueDate
    FROM #Example
    GROUP BY GroupID) AS agg
INNER JOIN #Example AS e ON agg.GroupID=e.GroupID
WHERE agg.maxUniqueDate > e.GroupDate
    AND agg.maxUniqueDate = e.UniqueDate
OPTION (MERGE JOIN);

(Remarque: j'ai ajouté un MERGE JOINindice de requête, mais cela devrait probablement se produire automatiquement si vos statistiques sont en ordre. La meilleure pratique consiste à laisser des indices comme ceux-ci si vous le pouvez.)

Daniel Hutmacher
la source
6
Il est plus laid, mais le plan d'exécution est plus jolie. C'est la magie des langages déclaratifs comme T-SQL.
Daniel Hutmacher
11

Lorsque et si vous êtes en mesure de mettre à niveau de SQL Server 2012 vers SQL Server 2016, vous pourrez peut-être profiter des performances nettement améliorées (en particulier pour les agrégats de fenêtres sans cadre) fournies par le nouvel opérateur d'agrégation de fenêtres en mode batch.

Presque tous les grands scénarios de traitement de données fonctionnent mieux avec le stockage columnstore qu'avec rowstore. Même sans passer au magasin de colonnes pour vos tables de base, vous pouvez toujours profiter des avantages de la nouvelle exécution de l'opérateur 2016 et du mode de traitement par lots en créant un index filtré vide non clustered columnstore sur l'une des tables de base, ou par une jointure externe redondante à un magasin de colonnes organisé table.

En utilisant la deuxième option, la requête devient:

-- Just to get batch mode processing and the window aggregate operator
CREATE TABLE #Dummy (a integer NOT NULL, INDEX DummyCC CLUSTERED COLUMNSTORE);

-- Identify any UniqueDates that are greater than the GroupDate within their GroupID
SELECT
    calc_maxUD.UniqueID,
    calc_maxUD.GroupID,
    calc_maxUD.GroupDate,
    calc_maxUD.UniqueDate
FROM 
(
    SELECT
        E.UniqueID,
        E.GroupID,
        E.GroupDate,
        E.UniqueDate,
        maxUniqueDate = MAX(UniqueDate) OVER (
            PARTITION BY GroupID)
    FROM #Example AS E
    LEFT JOIN #Dummy AS D -- The only change to the original query
        ON 1 = 0
) AS calc_maxUD
WHERE 
    calc_maxUD.maxUniqueDate > calc_maxUD.GroupDate
    AND calc_maxUD.maxUniqueDate = calc_maxUD.UniqueDate;

db <> violon

Notez que la seule modification apportée à la requête d'origine consiste à créer une table temporaire vide et à ajouter la jointure gauche. Le plan d'exécution est le suivant:

plan d'agrégation de fenêtres en mode batch

(58 row(s) affected)
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0
Table '#Example'. Scan count 1, logical reads 40, physical reads 0, read-ahead reads 0

Pour plus d'informations et d'options, consultez l'excellente série d'Itzik Ben-Gan, Ce que vous devez savoir sur l'opérateur d'agrégation de fenêtres en mode batch dans SQL Server 2016 (en trois parties).

Paul White 9
la source
7

Je vais juste jeter la vieille croix Appliquer là-bas:

SELECT e.*
    FROM #Example AS e
    CROSS APPLY ( SELECT TOP 1 e2.UniqueDate AS maxUniqueDate
                    FROM #Example AS e2
                    WHERE e2.GroupID = e.GroupID 
                    ORDER BY e2.UniqueDate DESC
                    ) AS ca
    WHERE ca.maxUniqueDate > e.GroupDate
        AND ca.maxUniqueDate = e.UniqueDate;

Avec quelques index, ça marche plutôt bien.

CREATE CLUSTERED INDEX cx_whatever ON #Example (GroupID)

CREATE UNIQUE NONCLUSTERED INDEX ix_whatever ON #Example (GroupID, UniqueDate DESC, GroupDate)

Le temps des statistiques et io ressemblent à ceci (votre requête est le premier résultat)

Table 'Worktable'. Scan count 3, logical reads 28004, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table '#Example'. Scan count 1, logical reads 51, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 15 ms,  elapsed time = 20 ms.

Table '#Example'. Scan count 10001, logical reads 21336, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 16 ms,  elapsed time = 11 ms.

Les plans de requête sont ici (encore une fois, le vôtre est le premier):

https://www.brentozar.com/pastetheplan/?id=BJYJvqAal

Pourquoi je préfère cette version? J'évite les bobines. Si ceux-ci commencent à se répandre sur le disque, ça va devenir laid.

Mais vous voudrez peut-être essayer aussi.

SELECT e.*
    FROM #Example AS e
    CROSS APPLY ( SELECT e2.UniqueDate AS maxUniqueDate
                    FROM #Example AS e2
                    WHERE e2.GroupID = e.GroupID 
                    ) AS ca
    WHERE ca.maxUniqueDate > e.GroupDate
        AND ca.maxUniqueDate = e.UniqueDate;

S'il s'agit d'un grand fichier DW, vous préférerez peut-être la jointure par hachage et le filtrage des lignes dans la jointure plutôt qu'à la fin de la TOP 1requête en tant qu'opérateur de filtre.

Le plan est ici: https://www.brentozar.com/pastetheplan/?id=BkUF55ATx

Stats temps et io ici:

Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table '#Example'. Scan count 2, logical reads 84, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 16 ms,  elapsed time = 5 ms.

J'espère que cela t'aides!

Une modification, basée sur l'idée de @ ypercube, et un nouvel index.

CREATE NONCLUSTERED INDEX ix_meh ON #Example (UniqueDate,GroupDate) INCLUDE (UniqueID,GroupID);

WITH t1 AS 
(
    SELECT DISTINCT
    e.GroupID ,
    MAX(UniqueDate) AS MaxUniqueDate
    FROM #Example AS e
    GROUP BY e.GroupID
)
SELECT *
FROM #Example AS e
CROSS APPLY (
SELECT *
FROM t1
    WHERE t1.MaxUniqueDate > e.GroupDate
        AND t1.MaxUniqueDate = e.UniqueDate
        AND t1.GroupID = e.GroupID
) ca

Voici le temps des statistiques et io:

Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table '#Example'. Scan count 2, logical reads 91, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 0 ms,  elapsed time = 4 ms.

Voici le plan:

https://www.brentozar.com/pastetheplan/?id=SJv8foR6g

Erik Darling
la source
Semble que mon exemple était un peu trop propre, car il existe des scénarios où je peux avoir plusieurs dates uniques supérieures à la date de groupe dans mon environnement réel. Cette condition invalidera votre deuxième requête Cross Apply, mais les autres approches fonctionnent toutes les deux sans problème. Merci pour encore plus d'options!
John Eisbrener du
4

Je regarderais top with ties

Si GroupDateest le même par GroupIdalors:

select top 1 with ties 
   UniqueID
 , GroupID
 , GroupDate
 , UniqueDate
from #Example
where UniqueDate > GroupDate
order by row_number() over (partition by GroupId order by UniqueDate desc)

Sinon: utilisation top with tiesdans une expression de table commune

with cte as (
  select top 1 with ties 
      UniqueID
    , GroupID
    , GroupDate
    , UniqueDate
  from #Example
  order by row_number() over (partition by GroupId order by UniqueDate desc)
)
select *
from cte
where UniqueDate > GroupDate

dbfiddle: http://dbfiddle.uk/?rdbms=sqlserver_2016&fiddle=c058994c2f5f3d99b212f06e1dae9fd3

Requête d'origine

Table 'Worktable'. Scan count 3, logical reads 28001, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table '#Example____________________________________________________________________________________________________________0000000000CB'. Scan count 1, logical reads 43, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 31 ms,  elapsed time = 31 ms.

vs top with tiesdans une expression de table commune

Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table '#Example____________________________________________________________________________________________________________0000000000CB'. Scan count 1, logical reads 43, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 16 ms,  elapsed time = 15 ms.
SqlZim
la source
4

J'ai donc fait une analyse des différentes approches publiées jusqu'à présent, et dans mon environnement, il semble que l'approche de Daniel l'emporte systématiquement sur les temps d'exécution. Étonnamment (pour moi), la troisième approche CROSS APPLY de sp_BlitzErik n'était pas si loin derrière. Voici les sorties si quelqu'un est intéressé, mais merci à TON pour toutes les approches alternatives. J'ai appris plus en fouillant dans les réponses à cette question que je n'en ai depuis longtemps!

Windowed Function - baseline metric

(10406 row(s) affected)
Table 'DateDim'. Scan count 9, logical reads 791, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'TableFact'. Scan count 9, logical reads 140181, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 89815, logical reads 42553550, physical reads 0, read-ahead reads 84586, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table01Dim'. Scan count 9, logical reads 7688, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table02Dim'. Scan count 9, logical reads 7819, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 87753 ms,  elapsed time = 13031 ms.
Warning: Null value is eliminated by an aggregate or other SET operation.


Basic Aggregated Subquery - Daniel Hutmacher

(10406 row(s) affected)
Table 'DateDim'. Scan count 18, logical reads 1194, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'TableFact'. Scan count 18, logical reads 280362, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Workfile'. Scan count 48, logical reads 82408, physical reads 9629, read-ahead reads 72779, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 89791, logical reads 6861425, physical reads 0, read-ahead reads 14565, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table01Dim'. Scan count 9, logical reads 7688, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table02Dim'. Scan count 18, logical reads 15726, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 40527 ms,  elapsed time = 6182 ms.
Warning: Null value is eliminated by an aggregate or other SET operation.


CROSS APPLY Operation A - sp_BlitzErik

(10406 row(s) affected)
Table 'DateDim'. Scan count 9, logical reads 6199331, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'TableFact'. Scan count 3099273, logical reads 12844012, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table01Dim'. Scan count 3109676, logical reads 9350502, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table02Dim'. Scan count 3109676, logical reads 9482456, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 132632 ms,  elapsed time = 20955 ms.


CROSS APPLY Operation C - sp_BlitzErik

(10406 row(s) affected)
Table 'DateDim'. Scan count 18, logical reads 1194, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'TableFact'. Scan count 18, logical reads 280362, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Workfile'. Scan count 56, logical reads 92800, physical reads 10872, read-ahead reads 81928, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 89791, logical reads 6861425, physical reads 0, read-ahead reads 14563, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table01Dim'. Scan count 18, logical reads 15376, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table02Dim'. Scan count 18, logical reads 15726, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 46082 ms,  elapsed time = 6804 ms.
Warning: Null value is eliminated by an aggregate or other SET operation.


TOP 1 WITH TIES - B - SqlZim

(10406 row(s) affected)
Table 'DateDim'. Scan count 9, logical reads 791, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'TableFact'. Scan count 9, logical reads 140181, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Worktable'. Scan count 89791, logical reads 6866304, physical reads 0, read-ahead reads 93468, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table01Dim'. Scan count 9, logical reads 7688, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Table02Dim'. Scan count 9, logical reads 7835, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 79406 ms,  elapsed time = 15852 ms.
John Eisbrener
la source
Je regardais juste comment les options publiées se cumuleraient si je donnais un exemple à 100 000 lignes et ajoutais les suggestions d'index de tout le monde. Cela semble également assez représentatif de vos résultats réels. Il semble que ma version des top with tiesboucles avec autant de lignes. dbfiddle.uk/…
SqlZim