Comment obtenir plus rapidement les totaux cumulés des lignes récentes?

8

Je suis en train de concevoir une table de transactions. J'ai réalisé que le calcul des totaux cumulés pour chaque ligne sera nécessaire et que les performances pourraient être lentes. J'ai donc créé une table avec 1 million de lignes à des fins de test.

CREATE TABLE [dbo].[Table_1](
    [seq] [int] IDENTITY(1,1) NOT NULL,
    [value] [bigint] NOT NULL,
 CONSTRAINT [PK_Table_1] PRIMARY KEY CLUSTERED 
(
    [seq] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
GO

Et j'ai essayé d'obtenir 10 lignes récentes et ses totaux cumulés, mais cela a pris environ 10 secondes.

--1st attempt
SELECT TOP 10 seq
    ,value
    ,sum(value) OVER (ORDER BY seq) total
FROM Table_1
ORDER BY seq DESC

--(10 rows affected)
--Table 'Worktable'. Scan count 1000001, logical reads 8461526, physical reads 2, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
--Table 'Table_1'. Scan count 1, logical reads 2608, physical reads 516, read-ahead reads 2617, 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.
--
--(1 row affected)
--
-- SQL Server Execution Times:
--   CPU time = 8483 ms,  elapsed time = 9786 ms.

Plan d'exécution de la 1ère tentative

Je soupçonnais TOPla raison de la lenteur des performances du plan, j'ai donc changé la requête comme ceci, et cela a pris environ 1 à 2 secondes. Mais je pense que c'est encore lent pour la production et je me demande si cela peut encore être amélioré.

--2nd attempt
SELECT *
    ,(
        SELECT SUM(value)
        FROM Table_1
        WHERE seq <= t.seq
        ) total
FROM (
    SELECT TOP 10 seq
        ,value
    FROM Table_1
    ORDER BY seq DESC
    ) t
ORDER BY seq DESC

--(10 rows affected)
--Table 'Table_1'. Scan count 11, logical reads 26083, physical reads 1, read-ahead reads 443, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
--
--(1 row affected)
--
-- SQL Server Execution Times:
--   CPU time = 1422 ms,  elapsed time = 1621 ms.

Plan d'exécution de la 2e tentative

Mes questions sont:

  • Pourquoi la requête de la 1ère tentative est plus lente que la 2ème?
  • Comment puis-je encore améliorer les performances? Je peux également modifier les schémas.

Juste pour être clair, les deux requêtes renvoient le même résultat que ci-dessous.

résultats

user2652379
la source
1
Je n'utilise généralement pas de fonctions de fenêtre, mais je me souviens avoir lu des articles utiles à leur sujet. Jetez un coup d'œil à une introduction aux fonctions de fenêtre T-SQL , en particulier à la partie Améliorations des agrégats de fenêtres en 2012 . Cela vous donne peut-être quelques réponses. ... et un autre article du même excellent auteur T-SQL Window Functions and Performance
Denis Rubashkin
Avez-vous essayé de mettre un index value?
Jacob H

Réponses:

5

Je recommande de tester avec un peu plus de données pour avoir une meilleure idée de ce qui se passe et voir comment les différentes approches fonctionnent. J'ai chargé 16 millions de lignes dans une table avec la même structure. Vous pouvez trouver le code pour remplir le tableau au bas de cette réponse.

L'approche suivante prend 19 secondes sur ma machine:

SELECT TOP (10) seq
    ,value
    ,sum(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING) total
FROM dbo.[Table_1_BIG]
ORDER BY seq DESC;

Plan réel ici . La plupart du temps est consacré au calcul de la somme et au tri. De façon inquiétante, le plan de requête effectue presque tout le travail pour l'ensemble des résultats et filtre les 10 lignes que vous avez demandées à la fin. Le temps d'exécution de cette requête évolue avec la taille de la table plutôt qu'avec la taille de l'ensemble de résultats.

Cette option prend 23 secondes sur ma machine:

SELECT *
    ,(
        SELECT SUM(value)
        FROM dbo.[Table_1_BIG]
        WHERE seq <= t.seq
        ) total
FROM (
    SELECT TOP (10) seq
        ,value
    FROM dbo.[Table_1_BIG]
    ORDER BY seq DESC
    ) t
ORDER BY seq DESC;

Plan réel ici . Cette approche évolue avec le nombre de lignes demandées et la taille de la table. Près de 160 millions de lignes sont lues dans le tableau:

Bonjour

Pour obtenir des résultats corrects, vous devez additionner les lignes de l'ensemble du tableau. Idéalement, vous ne devriez effectuer cette sommation qu'une seule fois. Il est possible de le faire si vous changez la façon dont vous abordez le problème. Vous pouvez calculer la somme pour l'ensemble du tableau, puis soustraire un total cumulé des lignes de l'ensemble de résultats. Cela vous permet de trouver la somme pour la Nème ligne. Une façon de procéder:

SELECT TOP (10) seq
,value
, [value]
    - SUM([value]) OVER (ORDER BY seq DESC ROWS UNBOUNDED PRECEDING)
    + (SELECT SUM([value]) FROM dbo.[Table_1_BIG]) AS total
FROM dbo.[Table_1_BIG]
ORDER BY seq DESC;

Plan réel ici . La nouvelle requête s'exécute en 644 ms sur ma machine. Le tableau est analysé une fois pour obtenir le total complet, puis une ligne supplémentaire est lue pour chaque ligne du jeu de résultats. Il n'y a pas de tri et presque tout le temps est consacré au calcul de la somme dans la partie parallèle du plan:

assez bien

Si vous souhaitez que cette requête soit encore plus rapide, il vous suffit d'optimiser la partie qui calcule la somme complète. La requête ci-dessus effectue une analyse d'index en cluster. L'index cluster comprend toutes les colonnes, mais vous n'avez besoin que de la [value]colonne. Une option consiste à créer un index non cluster sur cette colonne. Une autre option consiste à créer un index columnstore non cluster sur cette colonne. Les deux amélioreront les performances. Si vous êtes sur Enterprise, une excellente option consiste à créer une vue indexée comme celle-ci:

CREATE OR ALTER VIEW dbo.Table_1_BIG__SUM
WITH SCHEMABINDING
AS
SELECT SUM([value]) SUM_VALUE
, COUNT_BIG(*) FOR_U
FROM dbo.[Table_1_BIG];

GO

CREATE UNIQUE CLUSTERED INDEX CI ON dbo.Table_1_BIG__SUM (SUM_VALUE);

Cette vue renvoie une seule ligne, donc elle ne prend presque pas d'espace. Il y aura une pénalité lors de l'exécution de DML, mais cela ne devrait pas être très différent de la maintenance d'index. Avec la vue indexée en jeu, la requête prend désormais 0 ms:

entrez la description de l'image ici

Plan réel ici . La meilleure partie de cette approche est que le temps d'exécution n'est pas modifié par la taille de la table. La seule chose qui compte est le nombre de lignes renvoyées. Par exemple, si vous obtenez les 10000 premières lignes, la requête prend maintenant 18 ms pour s'exécuter.

Code pour remplir la table:

DROP TABLE IF EXISTS dbo.[Table_1_BIG];

CREATE TABLE dbo.[Table_1_BIG] (
    [seq] [int] NOT NULL,
    [value] [bigint] NOT NULL
);

DROP TABLE IF EXISTS #t;
CREATE TABLE #t (ID BIGINT);

INSERT INTO #t WITH (TABLOCK)
SELECT TOP (4000) -1 + ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
FROM master..spt_values t1
CROSS JOIN master..spt_values t2
OPTION (MAXDOP 1);

INSERT INTO dbo.[Table_1_BIG] WITH (TABLOCK)
SELECT t1.ID * 4000 + t2.ID, 8 * t2.ID + t1.ID
FROM (SELECT TOP (4000) ID FROM #t) t1
CROSS JOIN #t t2;

ALTER TABLE dbo.[Table_1_BIG]
ADD CONSTRAINT [PK_Table_1] PRIMARY KEY ([seq]);
Joe Obbish
la source
4

Différence dans les deux premières approches

Le premier plan passe environ 7 des 10 secondes dans l'opérateur de bobine de fenêtre, c'est donc la principale raison pour laquelle il est si lent. Cela crée beaucoup d'E / S dans tempdb pour créer cela. Mes statistiques d'E / S et de temps ressemblent à ceci:

Table 'Worktable'. Scan count 1000001, logical reads 8461526
Table 'Table_1'. Scan count 1, logical reads 2609
Table 'Worktable'. Scan count 0, logical reads 0

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

Le deuxième plan est capable d'éviter la bobine, et donc la table de travail entièrement. Il récupère simplement les 10 premières lignes de l'index clusterisé, puis une boucle imbriquée se joint à l'agrégation (somme) issue d'un balayage d'index cluster séparé. La face intérieure finit toujours par lire la table entière, mais la table est très dense, donc cela est raisonnablement efficace avec un million de lignes.

Table 'Table_1'. Scan count 11, logical reads 26093
 SQL Server Execution Times:
   CPU time = 1563 ms,  elapsed time = 1671 ms.

Amélioration des performances

Columnstore

Si vous voulez vraiment l'approche du «reporting en ligne», columnstore est probablement votre meilleure option.

ALTER TABLE [dbo].[Table_1] DROP CONSTRAINT [PK_Table_1];

CREATE CLUSTERED COLUMNSTORE INDEX [PK_Table_1] ON dbo.Table_1;

Ensuite, cette requête est ridiculement rapide:

SELECT TOP 10
    seq, 
    value, 
    SUM(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING)
FROM dbo.Table_1
ORDER BY seq DESC;

Voici les statistiques de ma machine:

Table 'Table_1'. Scan count 4, logical reads 0, physical reads 0, read-ahead reads 0, lob logical reads 3319
Table 'Table_1'. Segment reads 1, segment skipped 0.
Table 'Worktable'. Scan count 0, logical reads 0

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

Vous n'allez probablement pas battre cela (à moins que vous ne soyez vraiment intelligent - gentil, Joe). Columnstore est extrêmement bon pour analyser et agréger de grandes quantités de données.

Utiliser ROWplutôt que l' RANGEoption de fonction de fenêtre

Vous pouvez obtenir des performances très similaires à votre deuxième requête avec cette approche, qui a été mentionnée dans une autre réponse, et que j'ai utilisée dans l'exemple columnstore ci-dessus ( plan d'exécution ):

SELECT TOP 10
    seq, 
    value, 
    SUM(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING)
FROM dbo.Table_1
ORDER BY seq DESC;

Il en résulte moins de lectures que votre deuxième approche et aucune activité tempdb par rapport à votre première approche car le spool de fenêtre se produit en mémoire :

... RANGE utilise un spool sur disque, tandis que ROWS utilise un spool en mémoire

Malheureusement, l'exécution est à peu près la même que votre deuxième approche.

Table 'Worktable'. Scan count 0, logical reads 0
Table 'Table_1'. Scan count 1, logical reads 2609
Table 'Worktable'. Scan count 0, logical reads 0

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

Solution basée sur un schéma: totaux cumulés asynchrones

Puisque vous êtes ouvert à d'autres idées, vous pouvez envisager de mettre à jour le "total cumulé" de manière asynchrone. Vous pouvez périodiquement prendre les résultats d'une de ces requêtes et les charger dans une table "totaux". Vous feriez donc quelque chose comme ceci:

CREATE TABLE [dbo].[Table_1_Totals]
(
    [seq] [int] NOT NULL,
    [running_total] [bigint] NOT NULL,
    CONSTRAINT [PK_Table_1_Totals] PRIMARY KEY CLUSTERED ([seq])
);

Chargez-le tous les jours / heures / peu importe (cela a pris environ 2 secondes sur ma machine avec des rangées de 1 mm et pourrait être optimisé):

INSERT INTO dbo.Table_1_Totals
SELECT
    seq, 
    SUM(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING) as total
FROM dbo.Table_1 t
WHERE NOT EXISTS (
            SELECT NULL 
            FROM dbo.Table_1_Totals t2
            WHERE t.seq = t2.seq)
ORDER BY seq DESC;

Ensuite, votre requête de rapport est très efficace:

SELECT TOP 10
    t.seq, 
    t.value, 
    t2.running_total
FROM dbo.Table_1 t
    INNER JOIN dbo.Table_1_Totals t2
        ON t.seq = t2.seq
ORDER BY seq DESC;

Voici les statistiques de lecture:

Table 'Table_1'. Scan count 0, logical reads 35
Table 'Table_1_Totals'. Scan count 1, logical reads 3

Solution basée sur un schéma: totaux en ligne avec contraintes

Une solution vraiment intéressante à cela est traitée en détail dans cette réponse à la question: Écrire un schéma bancaire simple: Comment dois-je garder mes soldes en synchronisation avec l'historique de leurs transactions?

L'approche de base serait de suivre le total cumulé actuel en ligne avec le total cumulé précédent et le numéro de séquence. Ensuite, vous pouvez utiliser des contraintes pour valider que les totaux cumulés sont toujours corrects et à jour.

Nous remercions Paul White d' avoir fourni un exemple d'implémentation du schéma dans ce Q&R:

CREATE TABLE dbo.Table_1
(
    seq integer IDENTITY(1,1) NOT NULL,
    val bigint NOT NULL,
    total bigint NOT NULL,

    prev_seq integer NULL,
    prev_total bigint NULL,

    CONSTRAINT [PK_Table_1] 
        PRIMARY KEY CLUSTERED (seq ASC),

    CONSTRAINT [UQ dbo.Table_1 seq, total]
        UNIQUE (seq, total),

    CONSTRAINT [UQ dbo.Table_1 prev_seq]
        UNIQUE (prev_seq),

    CONSTRAINT [FK dbo.Table_1 previous seq and total]
        FOREIGN KEY (prev_seq, prev_total) 
        REFERENCES dbo.Table_1 (seq, total),

    CONSTRAINT [CK dbo.Table_1 total = prev_total + val]
        CHECK (total = ISNULL(prev_total, 0) + val),

    CONSTRAINT [CK dbo.Table_1 denormalized columns all null or all not null]
        CHECK 
        (
            (prev_seq IS NOT NULL AND prev_total IS NOT NULL)
            OR
            (prev_seq IS NULL AND prev_total IS NULL)
        )
);
Josh Darnell
la source
2

Lorsqu'il s'agit d'un si petit sous-ensemble de lignes renvoyées, la jointure triangulaire est une bonne option. Cependant, lorsque vous utilisez des fonctions de fenêtre, vous disposez de plus d'options qui peuvent augmenter leurs performances. L'option par défaut pour l'option de fenêtre est RANGE, mais l'option optimale est ROWS. Sachez que la différence n'est pas seulement dans la performance, mais aussi dans les résultats lorsque les liens sont impliqués.

Le code suivant est légèrement plus rapide que ceux que vous avez présentés.

SELECT TOP 10 seq
    ,value
    ,sum(value) OVER (ORDER BY seq ROWS UNBOUNDED PRECEDING) total
FROM Table_1
ORDER BY seq DESC
Luis Cazares
la source
Merci d'avoir dit ROWS. Je l'ai essayé mais je ne peux pas dire que c'est plus rapide que ma 2ème requête. Le résultat a étéCPU time = 1438 ms, elapsed time = 1537 ms.
user2652379
Mais ce n'est que sur cette option. Votre deuxième requête ne s'adapte pas bien. Essayez de renvoyer plus de lignes et la différence devient assez évidente.
Luis Cazares
Peut-être en dehors du t-sql? Je peux changer de schéma.
user2652379