web-dev-qa-db-fra.com

Le moyen le plus efficace pour récupérer une sous-requête COUNT regroupée par table supérieure?

Étant donné le schéma suivant

CREATE TABLE categories
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    name NVARCHAR(50)
);

CREATE TABLE [group]
(
    id UNIQUEIDENTIFIER PRIMARY KEY
);

CREATE TABLE logger
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    group_id UNIQUEIDENTIFIER,
    uuid CHAR(17)
);

CREATE TABLE data
(
    id UNIQUEIDENTIFIER PRIMARY KEY,
    logger_uuid CHAR(17),
    category_name NVARCHAR(50),
    recorded_on DATETIME
);

Et les règles suivantes

  1. Chaque enregistrement data fait référence à un logger et un category
  2. Chaque logger aura toujours un group
  3. Chaque group peut avoir plusieurs loggers
  4. Je veux seulement compter les données les plus récentes enregistrées

category_name n'est pas unique par ligne, c'est juste un moyen d'associer un enregistrement de données donné sous une catégorie, id n'est vraiment qu'une clé de substitution.

Quelle serait la meilleure façon d'atteindre un jeu de résultats comme

category_id | logger_group_count
--------------------------------
12345          4
67890          2
.....          ...

c'est-à-dire compter le non. de groupes pour chaque catégorie où un enregistreur a enregistré des données?

Comme premier coup de couteau, j'ai trouvé:

SELECT g.id, COUNT(DISTINCT(a.id)) AS logger_group_count 
FROM categories g
  LEFT OUTER JOIN data d ON d.category_name = g.name
  INNER JOIN logger s ON s.uuid = d.logger_uuid
  INNER JOIN group a ON a.id = s.group_id
GROUP BY g.id

Mais est extrêmement lent (~ 45s), data a 400k + enregistrements - voici le plan de requête et voici un violon pour jouer avec.

Je veux m'assurer de tirer le meilleur parti de la requête avant de commencer à regarder d'autres choses, par exemple l'utilisation du matériel, etc. Les coûts Azure SQL peuvent augmenter considérablement (même si vous avez peut-être juste besoin d'un peu plus de jus de votre niveau actuel) .

7
James

Grâce à une excellente réponse de @JoeObbish, j'ai pu mieux comprendre le plan de requête et déterminer où il se débattait et quels index je pouvais utiliser pour l'améliorer. Entre les deux, les messages d'objectif ont un peu changé car j'ai oublié de mentionner que j'avais besoin que cela ne s'applique qu'à la dernière lecture de chaque enregistreur, par exemple si logger_a données enregistrées sous category_x @ 11:50 et category_y @ 11:51 Je voudrais seulement compter cela comme category_y.

Voici le SQL résultant

;WITH logger_data AS (
  SELECT 
    category_name,
    logger_uuid,
    recorded_on,
    RN = ROW_NUMBER() OVER (PARTITION BY logger_uuid ORDER BY recorded_on DESC)
  FROM data
)
SELECT c.id, count(DISTINCT l.group_id) FROM categories c
INNER JOIN logger_data d on d.category_name = c.name
INNER JOIN logger l ON l.uuid = d.logger_uuid
WHERE RN = 1
GROUP BY c.id

Il s'agit cependant d'une requête coûteuse, avec les index suivants appliqués

CREATE CLUSTERED INDEX ix_latest ON "dbo"."data"
(
    logger_uuid,
    recorded_on DESC
)
GO
CREATE CLUSTERED INDEX ix_groups ON "dbo"."logger"
(
    group_id
)

Va de ~ 25s à ~ 3s et pour une table avec ~ 500k lignes. Je suis assez satisfait de cela, je pense qu'il y a probablement plus de place à l'amélioration, mais en l'état, c'est assez bien.

Voici la finale plan , toutes autres suggestions/améliorations sont les bienvenues.

0
James

Vous êtes sur une version plus récente de SQL Server, donc le plan réel vous donne beaucoup d'informations. Voir le signe d'avertissement sur l'opérateur SELECT? Cela signifie que SQL Server a généré un avertissement qui pourrait affecter les performances des requêtes. Vous devriez toujours regarder ceux-ci:

<Warnings>
<PlanAffectingConvert ConvertIssue="Seek Plan" Expression="[s].[logger_uuid]=CONVERT_IMPLICIT(nchar(17),[d].[uuid],0)" />
<PlanAffectingConvert ConvertIssue="Seek Plan" Expression="CONVERT_IMPLICIT(nvarchar(100),[d].[name],0)=[g].[name]" />
</Warnings>

Il existe deux conversions de types de données causées par votre schéma. Sur la base des avertissements, je soupçonne que le nom est en fait une NVARCHAR(100) et logger_uuid Est une NCHAR(17). Le schéma de table publié dans la question n'est peut-être pas correct. Vous devez comprendre la cause première de ces conversions et y remédier. Certains types de conversions de types de données empêchent les recherches d'index, entraînent des problèmes d'estimation de cardinalité et provoquent d'autres problèmes.

Une autre chose importante à vérifier est les statistiques d'attente. Vous pouvez également les voir dans les détails de l'opérateur SELECT. Voici le XML pour vos statistiques d'attente et le temps passé par la requête:

<WaitStats>
<Wait WaitType="RESOURCE_GOVERNOR_IDLE" WaitTimeMs="49515" WaitCount="3773" />
<Wait WaitType="SOS_SCHEDULER_YIELD" WaitTimeMs="57164" WaitCount="2466" />
</WaitStats>
<QueryTimeStats ElapsedTime="67135" CpuTime="10007" />

Je ne suis pas un gars du cloud, mais il semble que votre requête ne soit pas en mesure d'engager complètement un CP . Cela est probablement lié à votre niveau Azure actuel. La requête n'avait besoin que d'environ 10 secondes de CPU lors de son exécution, mais elle a pris 67 secondes. Je crois que 50 secondes de ce temps ont été consacrées à la limitation et 7 secondes de ce temps vous ont été accordées mais utilisées dans d'autres requêtes qui s'exécutaient simultanément. La mauvaise nouvelle est que la requête est plus lente qu'elle ne pourrait l'être en raison de votre niveau. La bonne nouvelle est que toute réduction du processeur pourrait entraîner une réduction de 5 fois le temps d'exécution. En d'autres termes, si vous pouvez obtenir que la requête utilise 1 seconde de CPU, vous pouvez voir un temps d'exécution d'environ 5 secondes.

Ensuite, vous pouvez consulter la propriété Statistiques de temps réel dans les détails de votre opérateur pour voir où le temps CPU a été dépensé. Votre plan utilise le mode ligne, de sorte que le temps processeur d'un opérateur est la somme du temps passé par cet opérateur ainsi que ses enfants. Il s'agit d'un plan relativement simple, il ne faut donc pas longtemps pour découvrir que l'analyse d'index en cluster sur logger_data Utilise 6527 ms de temps processeur. La jointure en boucle qui l'appelle utilise 10006 ms de temps CPU, donc tout le CPU de votre requête est dépensé à cette étape. Un autre indice que quelque chose ne va pas à cette étape peut être trouvé en regardant l'épaisseur des flèches relatives:

thick arrows

Un grand nombre de lignes sont renvoyées par cet opérateur, il vaut donc la peine de regarder les détails. En regardant le nombre réel de lignes pour l'analyse d'index en cluster, vous pouvez voir que 14088885 lignes ont été renvoyées et 14100798 lignes ont été lues. Cependant, la cardinalité de la table n'est que de 484803 lignes. Intuitivement, cela semble assez inefficace, non? L'analyse d'index clusterisé renvoie bien plus que le nombre de lignes de la table. Un autre plan avec un type de jointure ou une méthode d'accès différent sur la table sera probablement plus efficace.

Pourquoi SQL Server a-t-il lu et renvoyé autant de lignes? L'index cluster est sur le côté intérieur d'une boucle imbriquée. Il y a 38 lignes renvoyées par le côté extérieur de la boucle (l'analyse sur la table logger) donc l'analyse sur logger_data S'exécute 38 fois. 484803 * 38 = 18422514, ce qui est assez proche du nombre de lignes lues. Alors pourquoi SQL Server a-t-il choisi un tel plan qui semble si inefficace? Il estime même qu'il fera 57 scans de la table, donc sans doute le plan que vous avez obtenu était plus efficace qu'il ne le pensait.

Vous vous demandez peut-être pourquoi votre plan contient un opérateur TOP. SQL Server a introduit un objectif de ligne à lors de la création d'un plan de requête pour votre requête. Cela peut être plus détaillé que vous le souhaitez, mais la version courte est que SQL Server n'a pas toujours besoin de renvoyer toutes les lignes d'une analyse d'index en cluster. Parfois, il peut s'arrêter tôt s'il n'a besoin que d'un nombre fixe de lignes et qu'il trouve ces lignes avant d'atteindre la fin de l'analyse. Un scan n'est pas aussi cher s'il peut s'arrêter tôt, le coût de l'opérateur est donc actualisé par une formule lorsqu'un objectif de ligne est présent. En d'autres termes, SQL Server s'attend à analyser l'index en cluster 57 fois, mais il pense qu'il trouvera très rapidement la seule ligne dont il a besoin. Il n'a besoin que d'une seule ligne à partir de chaque analyse en raison de la présence de l'opérateur TOP.

Vous pouvez accélérer votre requête en encourageant l'optimiseur de requêtes à choisir un plan qui ne balaye pas la table logger_data 38 fois. Cela peut être aussi simple que d'éliminer les conversions de types de données. Cela pourrait permettre à SQL Server d'effectuer une recherche d'index au lieu d'une analyse. Sinon, corrigez les conversions et créez un index de couverture pour le logger_data:

CREATE INDEX IX ON logger_data (category_name, logger_uuid);

L'optimiseur de requêtes choisit un plan basé sur le coût. L'ajout de cet index rend peu probable l'obtention du plan lent qui effectue de nombreux scans sur logger_data car il sera moins cher d'accéder à la table via une recherche d'index au lieu d'un scan d'index en cluster.

Si vous ne pouvez pas ajouter l'index, vous pouvez envisager d'ajouter un indice de requête pour désactiver l'introduction d'objectifs de ligne: USE HINT('DISABLE_OPTIMIZER_ROWGOAL')). Vous ne devez le faire que si vous vous sentez à l'aise avec le concept d'objectifs de ligne et les comprenez. L'ajout de cet indice devrait entraîner un plan différent, mais je ne peux pas dire à quel point ce sera efficace.

8
Joe Obbish

Commencez par vous assurer que chaque table a toutes les clés candidates déclarées et que les clés étrangères sont appliquées:

CREATE TABLE dbo.categories
(
    id uniqueidentifier NOT NULL
        CONSTRAINT [UQ dbo.categories id]
        UNIQUE NONCLUSTERED,
    [name] nvarchar(50) NOT NULL 
        CONSTRAINT [PK dbo.categories name]
        PRIMARY KEY CLUSTERED
);

-- Choose a better name for this table
CREATE TABLE dbo.[group]
(
    id uniqueidentifier NOT NULL
        CONSTRAINT [PK dbo.group id]
        PRIMARY KEY CLUSTERED
);

CREATE TABLE dbo.logger
(
    id uniqueidentifier 
        CONSTRAINT [UQ dbo.logger id]
        UNIQUE NONCLUSTERED,
    group_id uniqueidentifier NOT NULL
        CONSTRAINT [FK dbo.group id]
        FOREIGN KEY (group_id)
        REFERENCES [dbo].[group] (id),
    uuid char(17) NOT NULL
        CONSTRAINT [PK dbo.logger uuid]
        PRIMARY KEY CLUSTERED
);

CREATE TABLE dbo.logger_data
(
    id uniqueidentifier 
        CONSTRAINT [PK dbo.logger_data id]
        PRIMARY KEY NONCLUSTERED,
    logger_uuid char(17) NOT NULL
        CONSTRAINT [FK dbo.logger_data uuid]
        FOREIGN KEY (logger_uuid)
        REFERENCES dbo.logger (uuid),
    category_name nvarchar(50) NOT NULL
        CONSTRAINT [dbo.logger_data name]
        FOREIGN KEY (category_name)
        REFERENCES dbo.categories ([name]),
    recorded_on datetime NOT NULL,

    INDEX [dbo.logger_data logger_uuid recorded_on] 
        CLUSTERED (logger_uuid, recorded_on)
);

J'ai également ajouté un index cluster non unique à logger_data sur logger_uuid, recorded_on.

Remarquez ensuite que la tâche la plus importante de votre plan d'exécution est l'analyse des 484 836 lignes du tableau de données. Étant donné que vous n'êtes intéressé que par la lecture la plus récente d'un enregistreur particulier et qu'il n'y a actuellement que 48 enregistreurs, il est plus efficace de remplacer cette analyse complète par 48 recherches singleton:

SELECT 
    category_id = C.id, 
    logger_group_count = COUNT_BIG(DISTINCT L.group_id)
FROM dbo.logger AS L
CROSS APPLY 
(
    -- Latest reading per logger
    SELECT TOP (1) 
        LD.recorded_on,
        LD.category_name
    FROM  dbo.logger_data AS LD
    WHERE LD.logger_uuid = L.uuid
    ORDER BY 
        LD.recorded_on DESC
) AS LDT1
JOIN dbo.categories AS C
    ON C.[name] = LDT1.category_name
GROUP BY
    C.id
ORDER BY
    C.id;

Le plan d'exécution est le suivant:

Estimated plan

dbfiddle

Vous devez également corriger votre instance de 2017 RTM à la dernière mise à jour cumulative.

4
Paul White 9

Pourquoi avez-vous besoin de rejoindre le groupe?

Pourquoi les catégories g?

SELECT c.id, COUNT(DISTINCT(s.group_id)) AS logger_group_count 
FROM categories c
JOIN data d 
  ON d.category_name = c.name
JOIN logger s 
  ON s.uuid = d.logger_uuid
GROUP BY c.id  

J'espère que dans la vraie vie, vous déclarez les clés étrangères.

Vous devez avoir un index sur chacune de ces colonnes de jointure.

0
paparazzo

Les domaines problématiques sont:

  1. Improper data type: Si le type de données est INT, cela signifie moins de page de données et pas de index fragmentation, si c'est NewSequentialID cela signifie more data page et no index fragmentation, avec UNIQUEIDENTIFIER vous obtenez les deux problèmes. Le type de données INT est donc le choix idéal.
  2. Data type and length of both column should be same in relationship column : par exemple, a.category_name = g.NAME Logger_data L'analyse d'index clusterisé dans le plan suggère que la longueur des deux colonnes doit être de 50 ou 100, afin qu'Optimizer n'ait pas à passer du temps à faire Convert_Implicit Encore mieux, la relation doit être définie avec un type de données int comme CategoryID int`.
  3. Si cette requête est très importante et fréquemment utilisée, vous pouvez penser à Denormalization, dans votre exemple, je ne peux pas dire comment?

Essayez la requête ci-dessous,

    SELECT g.id
    ,sum(CASE 
            WHEN rn = 1
                THEN 1
            ELSE 0
            END)
FROM categories g
INNER JOIN (
    SELECT d.category_name
        ,ROW_NUMBER() OVER (
            PARTITION BY d.category_name
            ,s.group_id ORDER BY s.group_id
            ) rn
    FROM data d
    INNER JOIN logger s ON s.uuid = d.logger_uuid
        --INNER JOIN [group] a ON a.id = s.group_id
    ) a ON a.category_name = g.NAME
GROUP BY g.id

J'aime @Paparazzi idée donc je l'ai incorporé.

Je pense que le plan est meilleur que le vôtre. Avec la correction et l'indexation ci-dessus, il fonctionnera encore mieux.

vous devez corriger ici,

ROW_NUMBER()over(partition by d.category_name,a.id order by s.group_id )rn 

order by s.group_id , ça devrait être order by DateOrIDcolumn desc qui donne le dernier enregistrement. avec votre échantillon, je ne suis pas en mesure de savoir comment trouver le dernier enregistrement.

Notez également partition by d.category_name cela aurait dû être partition by d.CatgoryID.

0
KumarHarsh