Dividir a consulta SQL com muitas junções em outras menores ajuda?

18

Precisamos fazer alguns relatórios todas as noites no nosso SQL Server 2008 R2. O cálculo dos relatórios leva várias horas. Para diminuir o tempo, pré-calculamos uma tabela. Esta tabela é criada com base em JOINining 12 tabelas bastante grandes (dezenas de milhões de linhas).

O cálculo dessa tabela de agregação levou até alguns dias atrás, cerca de 4 horas. Nosso DBA do que dividiu essa junção grande em 3 junções menores (cada uma juntando 4 tabelas). O resultado temporário é salvo em uma tabela temporária toda vez, usada na próxima associação.

O resultado do aprimoramento do DBA é que a tabela de agregação é calculada em 15 minutos. Eu me perguntava como isso é possível. O DBA me disse que é porque o número de dados que o servidor deve processar é menor. Em outras palavras, que na grande junção original, o servidor precisa trabalhar com mais dados do que nas junções menores somadas. No entanto, eu presumiria que o otimizador cuidaria de fazê-lo eficientemente com a grande junção original, dividindo as junções por conta própria e enviando apenas o número de colunas necessárias para as próximas junções.

A outra coisa que ele fez foi criar um índice em uma das tabelas temporárias. No entanto, mais uma vez, eu pensaria que o otimizador criará as tabelas de hash apropriadas, se necessário, e otimizará totalmente o cálculo.

Conversei sobre isso com nosso DBA, mas ele próprio não tinha certeza do que causava a melhoria no tempo de processamento. Ele acabou de mencionar que não culparia o servidor, pois pode ser esmagador calcular tais dados grandes e que é possível que o otimizador tenha dificuldade em prever o melhor plano de execução .... Isso eu entendo, mas gostaria de ter uma resposta mais definitiva sobre exatamente o porquê.

Então, as perguntas são:

  1. O que poderia causar a grande melhoria?

  2. É um procedimento padrão dividir grandes junções em menores?

  3. A quantidade de dados que o servidor precisa processar é realmente menor no caso de várias junções menores?

Aqui está a consulta original:

    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;

A nova divisão se junta após um ótimo trabalho do 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
fonte
3
Uma palavra de aviso - WITH (NOLOCK) é ruim - pode resultar no retorno de dados incorretos. Sugiro tentar WITH (ROWCOMMITTED).
TomTom
11
@TomTom Você quis dizer READCOMMITTED? Eu nunca vi ROWCOMMITTED antes.
precisa saber é o seguinte
4
WITH (NOLOCK) não é mau. Não é apenas a bala mágica que as pessoas parecem pensar que é. Como a maioria das coisas no SQL Server e no desenvolvimento de software em geral, ela tem seu lugar.
Zane
2
Sim, mas como o NOLOCK pode produzir avisos no log e - mais importante - retornar DADOS ERRADOS, considero-o mau. É praticamente utilizável apenas nas tabelas GARANTIDAS para não alterar a chave primária e as chaves selecionadas enquanto a consulta é executada. E sim, eu me lembro, desculpe.
TomTom

Respostas:

11

1 Redução do 'espaço de pesquisa', juntamente com melhores estatísticas para as junções intermediárias / tardias.

Eu tive que lidar com junções de 90 mesas (design do mouse mickey), onde o Query Processor se recusou a criar um plano. A divisão dessa junção em 10 sub-junções de 9 tabelas cada reduziu drasticamente a complexidade de cada junção, que cresce exponencialmente com cada tabela adicional. Além disso, o Query Optimizer agora os trata como 10 planos, gastando (potencialmente) mais tempo no geral (Paul White pode até ter métricas!).

As tabelas de resultados intermediários agora terão novas estatísticas próprias, juntando-se muito melhor em comparação com as estatísticas de uma árvore profunda que se inclina cedo e acaba como Ficção Científica logo depois.

Além disso, você pode forçar as junções mais seletivas primeiro, reduzindo os volumes de dados subindo na árvore. Se você pode estimar a seletividade de seus predicados muito melhor que o Otimizador, por que não forçar a ordem de junção. Pode valer a pena procurar por "Bushy Plans".

2 Na minha opinião, deve ser considerado se a eficiência e o desempenho são importantes

3 Não necessariamente, mas poderia ser se as junções mais seletivas fossem executadas desde o início

John Alan
fonte
3
+1 Obrigado. Especialmente para a descrição da sua experiência. É muito verdade ao dizer isso "Se você pode estimar a seletividade de seus predicados muito melhor que o Otimizador, por que não forçar a ordem de junção".
Ondrej Peterka
2
Na verdade, é uma pergunta muito válida. A junção de 90 mesas pode ser coagida a produzir um plano usando a opção 'Forçar ordem'. Não importava que a ordem fosse provavelmente aleatória e abaixo do ideal, basta reduzir o espaço de pesquisa para ajudar o Otimizador a criar um plano em alguns segundos (sem a dica de que o tempo limite passaria após 20 segundos).
John Alan
6
  1. O otimizador do SQLServer geralmente faz um bom trabalho. No entanto, seu objetivo não é gerar o melhor plano possível, mas encontrar o plano que é bom o suficiente rapidamente. Para uma consulta específica com muitas junções, isso pode causar um desempenho muito ruim. Uma boa indicação desse caso é uma grande diferença entre o número estimado e real de linhas no plano de execução real. Além disso, tenho certeza de que o plano de execução da consulta inicial mostrará muitos 'junção de loops aninhados', que é mais lento que 'junção de mesclagem'. O último requer que as duas entradas sejam classificadas usando a mesma chave, que é cara, e geralmente o otimizador descarta essa opção. Armazenando resultados em uma tabela temporária e adicionando índices apropriados como você fez - meu palpite - na escolha de um algoritmo melhor para novas junções (observação: siga as práticas recomendadas preenchendo a tabela temporária primeiro, e adicionando índices depois). Além disso, o SQLServer gera e mantém estatísticas para tabelas temporárias, o que também ajuda a escolher o índice adequado.
  2. Não posso dizer que existe um padrão sobre o uso de tabelas temporárias quando o número de junções é maior que algum número fixo, mas é definitivamente uma opção que pode melhorar o desempenho. Isso não acontece com frequência, mas eu tive problemas semelhantes (e soluções semelhantes) algumas vezes. Como alternativa, você pode tentar descobrir o melhor plano de execução, armazenar e forçar a reutilização, mas isso levará uma quantidade enorme de tempo (não há 100% de garantia de sucesso). Outra observação lateral - no caso de o conjunto de resultados armazenado na tabela temporária ser relativamente pequeno (por exemplo, 10 mil registros), a variável da tabela tem um desempenho melhor que a tabela temporária.
  3. Detesto dizer 'isso depende', mas provavelmente é a minha resposta para sua terceira pergunta. O otimizador precisa fornecer resultados rapidamente; você não quer que ele gaste horas tentando descobrir o melhor plano; cada junção adiciona trabalho extra e, às vezes, o otimizador 'fica confuso'.
a1ex07
fonte
3
+1 obrigado pela confirmação e explicação. O que você escreveu faz sentido.
Ondrej Peterka
4

Bem, deixe-me começar dizendo que você trabalha com dados pequenos - 10ns de milhões não são grandes. O último projeto DWH que tive 400 milhões de linhas adicionadas à tabela de fatos. POR DIA. Armazenamento por 5 anos.

O problema é hardware, parcialmente. Como junções grandes podem usar muito espaço temporário e há apenas muita RAM, no momento em que você transborda para o disco, as coisas ficam muito mais lentas. Como tal, pode fazer sentido dividir o trabalho em partes menores simplesmente porque, enquanto o SQL vive em um mundo de conjuntos e não se importa com o tamanho, o servidor no qual você executa não é infinito. Estou bastante acostumado a obter erros de falta de espaço em um tempdb de 64GB durante algumas operações.

Caso contrário, enquanto os staitsics estiverem em ordem, o otimizador de consultas não ficará sobrecarregado. Realmente não se importa com o tamanho da tabela - ela funciona com estatísticas que realmente não crescem. O QUE DISSE: Se você realmente tem uma tabela GRANDE (número de bilhões de dígitos com dois dígitos), elas podem ser um pouco grosseiras.

Há também uma questão de bloqueio - a menos que você programe muito bem que a junção grande possa bloquear a tabela por horas. Atualmente, estou executando operações de cópia de 200 gb e as estou dividindo em partes por uma chave comercial (efetivamente em loop) que mantém os bloqueios muito mais curtos.

No final, trabalhamos com hardware limitado.

TomTom
fonte
11
+1 obrigado pela sua resposta. É bom dizer que depende do HW. Temos apenas 32 GB de RAM, o que provavelmente não é suficiente.
Ondrej Peterka
2
Fico um pouco frustrado toda vez que leio respostas como essa - mesmo algumas dezenas de milhões de linhas criam carga de CPU em nosso servidor de banco de dados por horas. Talvez o número de dimensões seja alto, mas 30 dimensões parecem não ser um número muito grande. Eu acho que o número muito alto de linhas que você pode processar são de um modelo simples. Pior ainda: todos os dados se encaixam na RAM. E ainda leva horas.
Flaschenpost
11
30 dimensões é MUITO - você tem certeza de que o modelo está otimizado adequadamente em uma estrela? Alguns erros, por exemplo, que custam CPU - na consulta OP estão usando GUIDs como chaves primárias (identificador exclusivo). Eu também os amo - como índice exclusivo, a chave primária é um campo de ID, torna toda a comparação mais rápida e o índice mais nawwox (4 ou 8 bytes, não 18). Truques como esse economizam uma tonelada de CPU.
TomTom