Розщеплення SQL запиту з багатьма об'єднаннями на менші допомагає?


18

Нам потрібно щовечора робити звітність на нашому SQL Server 2008 R2. Розрахунок звітів займає кілька годин. Щоб скоротити час, ми попередньо підраховуємо таблицю. Ця таблиця створена на основі СПІЛКУВАННЯ 12 досить великих (десятки мільйонів рядів) таблиць.

Розрахунок цієї таблиці агрегації займав до декількох днів тому, приблизно, 4 години. Наша DBA, ніж розділити цю велику приєднатися до 3 менших приєднань (кожен об'єднує 4 таблиці). Тимчасовий результат щоразу зберігається у тимчасовій таблиці, яка використовується при наступному з'єднанні.

Результатом розширення DBA є те, що таблиця агрегації обчислюється за 15 хвилин. Я задумався, як це можливо. DBA сказав мені, що це тому, що кількість даних, які повинен обробляти сервер, менша. Іншими словами, що у великому оригіналі приєднання сервер повинен працювати з більшою кількістю даних, ніж у підсумовуванні менших об'єднань. Однак я б припустив, що оптимізатор подбає про те, щоб зробити це ефективно з оригінальним великим з'єднанням, розділяючи з'єднання самостійно і надсилаючи лише кількість стовпців, необхідних для наступних з'єднань.

Інша річ, яку він зробив, це те, що він створив індекс на одній із тимчасових таблиць. Однак ще раз я думаю, що оптимізатор створить необхідні хеш-таблиці, якщо це потрібно, і взагалі краще оптимізує обчислення.

Я говорив про це з нашою DBA, але він був не впевнений у тому, що спричинило покращення часу обробки. Він щойно зазначив, що він не буде звинувачувати сервер, оскільки це може бути непосильним для обчислення таких великих даних, і можливо, що оптимізатору важко передбачити найкращий план виконання ... Це я розумію, але я хотів би отримати більш чітку відповідь щодо того, чому саме.

Отже, питання:

  1. Що, можливо, може спричинити велике поліпшення?

  2. Це стандартна процедура розділення великих приєднань на менші?

  3. Чи об'єм даних, який сервер повинен обробити, дійсно менший у випадку кількох менших приєднань?

Ось оригінальний запит:

    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;

Новий розколений приєднується після великої роботи 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;

3
Слово попередження - З (НОЛОК) Зло - може призвести до повернення поганих даних. Я пропоную спробувати З (ЗАРАЗУМЕНО).
TomTom

1
@TomTom Ви мали на увазі READCOMMITTED? Я ніколи раніше не бачив ПОВЕРНЕНОГО.
ypercubeᵀᴹ

4
З (НОЛОК) - це не зло. Це просто не чарівна куля, яку люди, здається, вважають, що це. Як і більшість речей у SQL Server та розробці програмного забезпечення загалом, воно має своє місце.
Зейн

2
Так, але враховуючи, що NOLOCK може створювати попередження у журналі та - що ще важливіше - повертати WRONG DATA, я вважаю це злом. Це в значній мірі корисний лише для таблиць. ГАРАНТУЄМО не змінювати первинний ключ та вибрані клавіші під час виконання запиту. І так, я і ПОТРІБНО ВИГОТОВЛЯЮ, вибачте.
TomTom

Відповіді:


11

1 Скорочення "пошукового простору" у поєднанні з кращою статистикою проміжних / пізніх приєднань.

Мені доводилося стикатися з об'єднаннями на 90 таблиць (дизайн миші-міккі), де процесор запитів відмовився навіть створювати план. Розбивши таке з'єднання на 10 підрозділів по 9 таблиць кожна, різко знизилася складність кожного з'єднання, яке зростає експоненціально з кожною додатковою таблицею. Плюс Оптимізатор запитів тепер розглядає їх як 10 планів, витрачаючи (потенційно) більше часу в цілому (Пол Уайт може навіть мати показники!).

Проміжні таблиці результатів тепер матимуть свіжу власну статистику, таким чином, приєднуючись набагато краще порівняно зі статистикою глибокого дерева, яке на початку перекошується та стає незабаром науковою фантастикою.

Крім того, ви можете змусити спочатку найбільш виборчих приєднатися, скорочуючи обсяги даних, рухаючись вгору по дереву. Якщо ви можете оцінити вибірковість своїх предикатів набагато краще, ніж Оптимізатор, чому б не примусити замовити приєднання. Можливо, варто пошукати "Буйні плани".

2 На мій погляд, це слід враховувати, якщо ефективність та ефективність важливі

3 Не обов'язково, але це може бути, якщо найбільш вибіркові приєднання виконуються на початку


3
+1 Дякую Спеціально для опису свого досвіду. Дуже вірно сказати це "Якщо ви можете оцінити вибірковість своїх предикатів набагато краще, ніж Оптимізатор, чому б не примусити замовити приєднання".
Ondrej Peterka

2
Насправді це дуже вагоме питання. Приєднання до 90-столових таблиць можна примусити створити план просто за допомогою опції "Формування замовлення". Неважливо, що порядок був, ймовірно, випадковим і неоптимальним, достатньо було просто зменшити пошуковий простір, щоб оптимізатор міг створити план протягом декількох секунд (без підказки він вичерпається через 20 секунд).
Джон Алан

6
  1. Зазвичай оптимізатор SQLServer робить хорошу роботу. Однак його мета полягає не в тому, щоб створити найкращий можливий план, а знайти план, який є досить хорошим. Для конкретного запиту з багатьма об'єднаннями він може спричинити дуже низьку ефективність. Хорошим показником такого випадку є велика різниця між передбачуваною та фактичною кількістю рядків у фактичному плані виконання. Крім того, я майже впевнений, що план виконання початкового запиту покаже багато "вкладених циклів приєднання", що повільніше, ніж "об'єднання об'єднань". Останнє вимагає сортування обох входів за допомогою одного і того ж ключа, що є дорогим, і зазвичай оптимізатор відкидає таку опцію. Зберігання результатів у тимчасовій таблиці та додавання належних індексів, як ви робили результати - моя здогадка - при виборі кращого алгоритму для подальшого приєднання (бічна примітка - ви слідуєте кращим практикам, спочатку заповнюючи таблицю темп, та додавання індексів після). Крім того, SQLServer створює та зберігає статистику для тимчасових таблиць, що також допомагає вибрати правильний індекс.
  2. Я не можу сказати, що існує стандарт використання тимчасових таблиць, коли кількість об'єднань перевищує деякий фіксований номер, але це, безумовно, варіант, який може підвищити продуктивність. Це трапляється не часто, але у мене були кілька разів подібні проблеми (і подібне рішення). Крім того, ви можете спробувати самостійно розібрати найкращий план виконання, зберігати його та змусити його повторно використовувати, але це займе величезну кількість часу (не на 100% гарантовано, що вам це вдасться). Ще одна бічна примітка - у випадку, якщо набір результатів, який зберігається у тимчасовій таблиці, порівняно невеликий (скажімо, близько 10 к записів), змінна таблиці працює краще, ніж таблиця темп.
  3. Я ненавиджу сказати "це залежить", але це, мабуть, моя відповідь на ваше третє питання. Оптимізатор повинен швидко давати результати; ви не хочете, щоб він витрачав години, намагаючись з’ясувати найкращий план; кожен приєднання додає додаткової роботи, а іноді оптимізатор «заплутається».

3
+1 дякую за підтвердження та пояснення. Те, що ви написали, має сенс.
Ondrej Peterka

4

Ну, почніть з того, що ви працюєте над невеликими даними - 10 мільйонів мільйонів не великі. В останньому проекті DWH у мене було додано 400 мільйонів рядків до таблиці фактів. НА ДЕНЬ. Зберігання протягом 5 років.

Проблема - апаратне забезпечення, частково. Оскільки великі об'єднання можуть використовувати багато тимчасового простору, а оперативної пам’яті лише стільки, щоразу, коли ви переповнюєтесь на диск, речі стають набагато повільнішими. Таким чином, можливо, має сенс розділити роботу на менші частини просто тому, що SQL живе у світі безлічі і не піклується про розміри, сервер, на якому ви працюєте, не є нескінченним. Я досить звик виходити з космічних помилок в 64gb tempdb під час деяких операцій.

В іншому випадку, поки статистика є в порядку, оптимізатор запитів не перевантажений. Не дуже важливо, наскільки велика таблиця - це працює за статистикою, яка насправді не росте. ЦЕ Сказав: Якщо у вас дійсно велика таблиця (двозначна мільярдна кількість рядків), вони можуть бути трохи грубими.

Існує також питання блокування - якщо ви не запрограмуєте, що велике з'єднання може зафіксувати стіл годинами. Наразі я виконую операції з копіюванням на 200 ГБ, і я розбиваю їх на smllerparty за допомогою бізнес-ключа (ефективно циклічного), який тримає блоки набагато коротше.

Зрештою, ми працюємо з обмеженим обладнанням.


1
+1 спасибі за вашу відповідь. Можна сказати, що це залежить від HW. У нас всього 32 ГБ оперативної пам’яті, що, мабуть, недостатньо.
Ondrej Peterka

2
Мене трохи засмучує кожен раз, коли я читаю подібні відповіді - навіть кілька десятків мільйонів рядків створюють завантаження процесора на нашому сервері баз годин. Можливо, кількість розмірів велика, але 30 розмірів здаються не надто великою кількістю. Я думаю, що дуже велика кількість рядків, які ви можете обробити, є з простої моделі. Ще гірше: цілі дані вписуються в оперативну пам’ять. А ще займає години.
flaschenpost

1
30 розмірів - ЛОТ - ви впевнені, що модель належним чином оптимізована під зірку? Деякі помилки, наприклад, що вартість CPU - у запиті OP, використовується GUID в якості первинних ключів (унікальний ідентифікатор). Я теж їх люблю - як унікальний індекс, первинний ключ - це поле ідентифікатора, робить все порівняння швидше, а індекс більш nawwox (4 або 8 байт, а не 18). Такі прийоми заощаджують TON CPU.
TomTom
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.