La division de la requête SQL avec de nombreuses jointures en plus petites aide?

18

Nous devons faire des rapports tous les soirs sur notre SQL Server 2008 R2. Le calcul des rapports prend plusieurs heures. Afin de raccourcir le temps, nous précalculons une table. Ce tableau est créé sur la base de JOINining 12 tables assez grandes (des dizaines de millions de lignes).

Le calcul de cette table d'agrégation a pris jusqu'à il y a quelques jours environ 4 heures. Notre DBA a ensuite divisé cette grande jointure en 3 jointures plus petites (chacune joignant 4 tables). Le résultat temporaire est enregistré à chaque fois dans une table temporaire, qui est utilisée dans la jointure suivante.

Le résultat de l'amélioration DBA est que la table d'agrégation est calculée en 15 minutes. Je me demandais comment c'était possible. DBA m'a dit que c'est parce que le nombre de données que le serveur doit traiter est plus petit. En d'autres termes, dans la grande jointure d'origine, le serveur doit travailler avec plus de données que dans les petites jointures additionnées. Cependant, je suppose que l'optimiseur se chargera de le faire efficacement avec la grande jointure d'origine, en divisant les jointures par elle-même et en envoyant uniquement le nombre de colonnes nécessaires aux jointures suivantes.

Il a également créé un index sur l'une des tables temporaires. Cependant, une fois de plus, je pense que l'optimiseur créera les tables de hachage appropriées si nécessaire et optimisera mieux le calcul.

J'en ai parlé avec notre DBA, mais il n'était lui-même pas sûr de ce qui a entraîné l'amélioration du temps de traitement. Il vient de mentionner qu'il ne blâmerait pas le serveur car il peut être accablant de calculer de telles données volumineuses et qu'il est possible que l'optimiseur ait du mal à prédire le meilleur plan d'exécution .... Je comprends cela, mais j'aimerais avoir une réponse plus précise quant à la raison exacte.

Donc, les questions sont:

  1. Qu'est-ce qui pourrait éventuellement provoquer la grande amélioration?

  2. Est-ce une procédure standard pour diviser les grosses jointures en plus petites?

  3. La quantité de données que le serveur doit traiter est-elle vraiment plus petite en cas de plusieurs jointures plus petites?

Voici la requête d'origine:

    Insert Into FinalResult_Base
SELECT       
    TC.TestCampaignContainerId,
    TC.CategoryId As TestCampaignCategoryId,
    TC.Grade,
    TC.TestCampaignId,    
    T.TestSetId
    ,TL.TestId
    ,TSK.CategoryId
    ,TT.[TestletId]
    ,TL.SectionNo
    ,TL.Difficulty
    ,TestletName = Char(65+TL.SectionNo) + CONVERT(varchar(4),6 - TL.Difficulty) 
    ,TQ.[QuestionId]
    ,TS.StudentId
    ,TS.ClassId
    ,RA.SubjectId
    ,TQ.[QuestionPoints] 
    ,GoodAnswer  = Case When TQ.[QuestionPoints] Is null Then 0
                      When TQ.[QuestionPoints] > 0 Then 1 
                      Else 0 End
    ,WrongAnswer = Case When TQ.[QuestionPoints] = 0 Then 1 
                      When TQ.[QuestionPoints] Is null Then 1
                     Else 0 End
    ,NoAnswer    = Case When TQ.[QuestionPoints] Is null Then 1 Else 0 End
    ,TS.Redizo
    ,TT.ViewCount
    ,TT.SpentTime
    ,TQ.[Position]  
    ,RA.SpecialNeeds        
    ,[Version] = 1 
    ,TestAdaptationId = TA.Id
    ,TaskId = TSK.TaskId
    ,TaskPosition = TT.Position
    ,QuestionRate = Q.Rate
    ,TestQuestionId = TQ.Guid
    ,AnswerType = TT.TestletAnswerTypeId
FROM 
    [TestQuestion] TQ WITH (NOLOCK)
    Join [TestTask] TT WITH (NOLOCK)            On TT.Guid = TQ.TestTaskId
    Join [Question] Q WITH (NOLOCK)         On TQ.QuestionId =  Q.QuestionId
    Join [Testlet] TL WITH (NOLOCK)         On TT.TestletId  = TL.Guid 
    Join [Test]     T WITH (NOLOCK)         On TL.TestId     =  T.Guid
    Join [TestSet] TS WITH (NOLOCK)         On T.TestSetId   = TS.Guid 
    Join [RoleAssignment] RA WITH (NOLOCK)  On TS.StudentId  = RA.PersonId And RA.RoleId = 1
    Join [Task] TSK WITH (NOLOCK)       On TSK.TaskId = TT.TaskId
    Join [Category] C WITH (NOLOCK)     On C.CategoryId = TSK.CategoryId
    Join [TimeWindow] TW WITH (NOLOCK)      On TW.Id = TS.TimeWindowId 
    Join [TestAdaptation] TA WITH (NOLOCK)  On TA.Id = TW.TestAdaptationId
    Join [TestCampaign] TC WITH (NOLOCK)        On TC.TestCampaignId = TA.TestCampaignId 
WHERE
    T.TestTypeId = 1    -- eliminuji ankety 
    And t.ProcessedOn is not null -- ne vsechny, jen dokoncene
    And TL.ShownOn is not null
    And TS.Redizo not in (999999999, 111111119)
END;

Les nouvelles jointures fractionnées après un excellent travail DBA:

    SELECT       
    TC.TestCampaignContainerId,
    TC.CategoryId As TestCampaignCategoryId,
    TC.Grade,
    TC.TestCampaignId,    
    T.TestSetId
    ,TL.TestId
    ,TL.SectionNo
    ,TL.Difficulty
    ,TestletName = Char(65+TL.SectionNo) + CONVERT(varchar(4),6 - TL.Difficulty) -- prevod na A5, B4, B5 ...
    ,TS.StudentId
    ,TS.ClassId
    ,TS.Redizo
    ,[Version] = 1 -- ? 
    ,TestAdaptationId = TA.Id
    ,TL.Guid AS TLGuid
    ,TS.TimeWindowId
INTO
    [#FinalResult_Base_1]
FROM 
    [TestSet] [TS] WITH (NOLOCK)
    JOIN [Test] [T] WITH (NOLOCK) 
        ON [T].[TestSetId] = [TS].[Guid] AND [TS].[Redizo] NOT IN (999999999, 111111119) AND [T].[TestTypeId] = 1 AND [T].[ProcessedOn] IS NOT NULL
    JOIN [Testlet] [TL] WITH (NOLOCK)
        ON [TL].[TestId] = [T].[Guid] AND [TL].[ShownOn] IS NOT NULL
    JOIN [TimeWindow] [TW] WITH (NOLOCK)
        ON [TW].[Id] = [TS].[TimeWindowId] AND [TW].[IsActive] = 1
    JOIN [TestAdaptation] [TA] WITH (NOLOCK)
        ON [TA].[Id] = [TW].[TestAdaptationId] AND [TA].[IsActive] = 1
    JOIN [TestCampaign] [TC] WITH (NOLOCK)
        ON [TC].[TestCampaignId] = [TA].[TestCampaignId] AND [TC].[IsActive] = 1
    JOIN [TestCampaignContainer] [TCC] WITH (NOLOCK)
        ON [TCC].[TestCampaignContainerId] = [TC].[TestCampaignContainerId] AND [TCC].[IsActive] = 1
    ;

 SELECT       
    FR1.TestCampaignContainerId,
    FR1.TestCampaignCategoryId,
    FR1.Grade,
    FR1.TestCampaignId,    
    FR1.TestSetId
    ,FR1.TestId
    ,TSK.CategoryId AS [TaskCategoryId]
    ,TT.[TestletId]
    ,FR1.SectionNo
    ,FR1.Difficulty
    ,TestletName = Char(65+FR1.SectionNo) + CONVERT(varchar(4),6 - FR1.Difficulty) -- prevod na A5, B4, B5 ...
    ,FR1.StudentId
    ,FR1.ClassId
    ,FR1.Redizo
    ,TT.ViewCount
    ,TT.SpentTime
    ,[Version] = 1 -- ? 
    ,FR1.TestAdaptationId
    ,TaskId = TSK.TaskId
    ,TaskPosition = TT.Position
    ,AnswerType = TT.TestletAnswerTypeId
    ,TT.Guid AS TTGuid

INTO
    [#FinalResult_Base_2]
FROM 
    #FinalResult_Base_1 FR1
    JOIN [TestTask] [TT] WITH (NOLOCK)
        ON [TT].[TestletId] = [FR1].[TLGuid] 
    JOIN [Task] [TSK] WITH (NOLOCK)
        ON [TSK].[TaskId] = [TT].[TaskId] AND [TSK].[IsActive] = 1
    JOIN [Category] [C] WITH (NOLOCK)
        ON [C].[CategoryId] = [TSK].[CategoryId]AND [C].[IsActive] = 1
    ;    

DROP TABLE [#FinalResult_Base_1]

CREATE NONCLUSTERED INDEX [#IX_FR_Student_Class]
ON [dbo].[#FinalResult_Base_2] ([StudentId],[ClassId])
INCLUDE ([TTGuid])

SELECT       
    FR2.TestCampaignContainerId,
    FR2.TestCampaignCategoryId,
    FR2.Grade,
    FR2.TestCampaignId,    
    FR2.TestSetId
    ,FR2.TestId
    ,FR2.[TaskCategoryId]
    ,FR2.[TestletId]
    ,FR2.SectionNo
    ,FR2.Difficulty
    ,FR2.TestletName
    ,TQ.[QuestionId]
    ,FR2.StudentId
    ,FR2.ClassId
    ,RA.SubjectId
    ,TQ.[QuestionPoints] -- 1+ good, 0 wrong, null no answer
    ,GoodAnswer  = Case When TQ.[QuestionPoints] Is null Then 0
                      When TQ.[QuestionPoints] > 0 Then 1 -- cookie
                      Else 0 End
    ,WrongAnswer = Case When TQ.[QuestionPoints] = 0 Then 1 
                      When TQ.[QuestionPoints] Is null Then 1
                     Else 0 End
    ,NoAnswer    = Case When TQ.[QuestionPoints] Is null Then 1 Else 0 End
    ,FR2.Redizo
    ,FR2.ViewCount
    ,FR2.SpentTime
    ,TQ.[Position] AS [QuestionPosition]  
    ,RA.SpecialNeeds -- identifikace SVP        
    ,[Version] = 1 -- ? 
    ,FR2.TestAdaptationId
    ,FR2.TaskId
    ,FR2.TaskPosition
    ,QuestionRate = Q.Rate
    ,TestQuestionId = TQ.Guid
    ,FR2.AnswerType
INTO
    [#FinalResult_Base]
FROM 
    [#FinalResult_Base_2] FR2
    JOIN [TestQuestion] [TQ] WITH (NOLOCK)
        ON [TQ].[TestTaskId] = [FR2].[TTGuid]
    JOIN [Question] [Q] WITH (NOLOCK)
        ON [Q].[QuestionId] = [TQ].[QuestionId] AND [Q].[IsActive] = 1

    JOIN [RoleAssignment] [RA] WITH (NOLOCK)
        ON [RA].[PersonId] = [FR2].[StudentId]
        AND [RA].[ClassId] = [FR2].[ClassId] AND [RA].[IsActive] = 1 AND [RA].[RoleId] = 1

    drop table #FinalResult_Base_2;

    truncate table [dbo].[FinalResult_Base];
    insert into [dbo].[FinalResult_Base] select * from #FinalResult_Base;

    drop table #FinalResult_Base;
Ondrej Peterka
la source
3
Un mot d'avertissement - AVEC (NOLOCK) est mauvais - peut entraîner le retour de mauvaises données. Je suggère d'essayer AVEC (ROWCOMMITTED).
TomTom
1
@TomTom Vouliez-vous dire READCOMMITTED? Je n'ai jamais vu ROWCOMMITTED auparavant.
ypercubeᵀᴹ
4
AVEC (NOLOCK) n'est pas mal. Ce n'est tout simplement pas la solution miracle que les gens semblent penser. Comme la plupart des choses dans SQL Server et le développement de logiciels en général, il a sa place.
Zane
2
Oui, mais étant donné que NOLOCK peut produire des avertissements dans le journal et - plus important - renvoyer des DONNÉES erronées, je le considère comme mauvais. Il est à peu près utilisable uniquement sur les tables GARANTIES de ne pas modifier la clé primaire et les clés sélectionnées pendant l'exécution de la requête. Et oui, je veux dire READCOMMMITED, désolé.
TomTom

Réponses:

11

1 Réduction de «l'espace de recherche», associée à de meilleures statistiques pour les jointures intermédiaires / tardives.

J'ai dû faire face à des jointures de 90 tables (conception de mickey mouse) où le processeur de requêtes a même refusé de créer un plan. Briser une telle jointure en 10 sous-jointures de 9 tables chacune, a considérablement réduit la complexité de chaque jointure, qui croît de façon exponentielle avec chaque table supplémentaire. De plus, l'Optimiseur de requête les traite désormais comme 10 plans, passant (potentiellement) plus de temps dans l'ensemble (Paul White peut même avoir des mesures!).

Les tableaux de résultats intermédiaires auront désormais leurs propres statistiques, rejoignant ainsi beaucoup mieux que les statistiques d'un arbre profond qui se faussent tôt et finissent par devenir Science Fiction peu de temps après.

De plus, vous pouvez forcer les jointures les plus sélectives en premier, en réduisant les volumes de données remontant dans l'arborescence. Si vous pouvez estimer la sélectivité de vos prédicats bien mieux que l'Optimiseur, pourquoi ne pas forcer l'ordre de jointure. Peut-être vaut-il la peine de chercher des «plans broussailleux».

2 Il convient, à mon avis, de considérer si l'efficacité et les performances sont importantes

3 Pas nécessairement, mais il se pourrait que les jointures les plus sélectives soient exécutées tôt

John Alan
la source
3
+1 Merci. Surtout pour la description de votre expérience. C'est très vrai en disant ceci "Si vous pouvez estimer la sélectivité de vos prédicats beaucoup mieux que l'Optimiseur, pourquoi ne pas forcer l'ordre de jointure."
Ondrej Peterka
2
C'est une question très valable en fait. La jointure de 90 tables pourrait être forcée de produire un plan simplement en utilisant l'option «Forcer l'ordre». Peu importait que la commande soit probablement aléatoire et sous-optimale, il suffisait de réduire l'espace de recherche pour aider l'Optimiseur à créer un plan en quelques secondes (sans l'indication, le délai expirerait après 20 secondes).
John Alan
6
  1. L'optimiseur SQLServer fait généralement du bon travail. Cependant, son objectif n'est pas de générer le meilleur plan possible, mais de trouver rapidement le plan qui est assez bon. Pour une requête particulière avec plusieurs jointures, les performances peuvent être très faibles. Une bonne indication d'un tel cas est une grande différence entre le nombre estimé et réel de lignes dans le plan d'exécution réel. En outre, je suis presque sûr que le plan d'exécution de la requête initiale affichera de nombreuses «jointures de boucles imbriquées», ce qui est plus lent que «fusionner la jointure». Ce dernier nécessite que les deux entrées soient triées à l'aide de la même clé, ce qui est coûteux, et l'optimiseur rejette généralement une telle option. Stocker les résultats dans une table temporaire et ajouter des index appropriés comme vous l'avez fait - mon avis - en choisissant un meilleur algorithme pour les jointures ultérieures (note latérale - vous suivez les meilleures pratiques en remplissant d'abord la table temporaire, et en ajoutant des index après). De plus, SQLServer génère et conserve des statistiques pour les tables temporaires, ce qui aide également à choisir l'index approprié.
  2. Je ne peux pas dire qu'il existe une norme sur l'utilisation de tables temporaires lorsque le nombre de jointures est supérieur à un certain nombre fixe, mais c'est certainement une option qui peut améliorer les performances. Cela n'arrive pas souvent, mais j'ai eu plusieurs fois des problèmes similaires (et une solution similaire). Alternativement, vous pouvez essayer de trouver vous-même le meilleur plan d'exécution, le stocker et le forcer à le réutiliser, mais cela prendra énormément de temps (pas de garantie à 100% que vous réussirez). Une autre note secondaire - dans le cas où l'ensemble de résultats stocké dans une table temporaire est relativement petit (disons environ 10k enregistrements), la variable de table fonctionne mieux que la table temporaire.
  3. Je déteste dire "ça dépend", mais c'est probablement ma réponse à votre troisième question. L'optimiseur doit donner des résultats rapidement; vous ne voulez pas qu'il passe des heures à essayer de trouver le meilleur plan; chaque jointure ajoute du travail supplémentaire, et parfois l'optimiseur «devient confus».
a1ex07
la source
3
+1 merci pour la confirmation et l'explication. Ce que vous avez écrit est logique.
Ondrej Peterka
4

Eh bien, permettez-moi de commencer par dire que vous travaillez sur de petites données - 10 millions de millions ne sont pas importants. Le dernier projet DWH j'avais 400 millions de lignes ajoutées à la table de faits. PAR JOUR. Stockage pendant 5 ans.

Le problème vient du matériel, en partie. Comme les grosses jointures peuvent utiliser BEAUCOUP d'espace temporaire et qu'il n'y a que peu de RAM, au moment où vous débordez dans le disque, les choses deviennent beaucoup plus lentes. En tant que tel, il peut être judicieux de diviser le travail en parties plus petites simplement parce que, tandis que SQL vit dans un monde d'ensembles et ne se soucie pas de la taille, le serveur sur lequel vous exécutez n'est pas infini. J'ai l'habitude de sortir des erreurs d'espace dans un tempdb de 64 Go pendant certaines opérations.

Sinon, tant que les staitsics sont en ordre, l'optimiseur de requête n'est pas dépassé. Il ne se soucie pas vraiment de la taille du tableau - il fonctionne selon des statistiques qui ne se développent vraiment pas. CELA A DIT: Si vous avez vraiment une grande table (nombre de milliards de chiffres à deux chiffres), alors elles peuvent être un peu grossières.

Il y a aussi une question de verrouillage - à moins que vous ne programmiez si bien que la grande jointure puisse verrouiller la table pendant des heures. Je fais des opérations de copie de 200 Go en ce moment, et je les divise en plus petites parties par une clé d'entreprise (en boucle en fait) qui maintient les verrous beaucoup plus courts.

À la fin, nous travaillons avec un matériel limité.

TomTom
la source
1
+1 merci pour votre réponse. Il est bon de dire que cela dépend du matériel. Nous n'avons que 32 Go de RAM, ce qui n'est probablement pas suffisant.
Ondrej Peterka
2
Je suis un peu frustré chaque fois que je lis des réponses comme ça - même quelques dizaines de millions de lignes créent une charge CPU sur notre serveur de base de données pendant des heures. Peut-être que le nombre de dimensions est élevé, mais 30 dimensions ne semblent pas trop nombreuses. Je pense que le nombre très élevé de lignes que vous pouvez traiter provient d'un modèle simple. Pire encore: l'ensemble des données tient dans la RAM. Et cela prend encore des heures.
flaschenpost
1
30 dimensions, c'est BEAUCOUP - êtes-vous sûr que le modèle est correctement optimisé en étoile? Certaines erreurs, par exemple, qui coûtent de la CPU - sur la requête OP utilise les GUID comme clés primaires (uniqueidentifier). Je les aime aussi - comme index unique, la clé primaire est un champ ID, rend la comparaison plus rapide et l'index plus nawwox (4 ou 8 octets, pas 18). Des trucs comme ça économisent une tonne de CPU.
TomTom