В SQL Server я должен форсировать LOOP JOIN в следующем случае?

15

Как правило, я рекомендую не использовать подсказки по всем стандартным причинам. Однако недавно я обнаружил шаблон, в котором я почти всегда нахожу принудительное циклическое соединение для лучшей производительности. На самом деле, я начинаю использовать и рекомендовать его так сильно, что мне хотелось получить второе мнение, чтобы убедиться, что я ничего не пропустил. Вот типичный сценарий (очень специфический код для генерации примера в конце):

--Case 1: NO HINT
SELECT S.*
INTO #Results
FROM #Driver AS D
JOIN SampleTable AS S ON S.ID = D.ID

--Case 2: LOOP JOIN HINT
SELECT S.*
INTO #Results
FROM #Driver AS D
INNER LOOP JOIN SampleTable AS S ON S.ID = D.ID

SampleTable имеет 1 миллион строк, а его PK - ID.
Временная таблица #Driver имеет только один столбец, ID, без индексов и 50K строк.

То, что я постоянно нахожу, является следующим:

Случай 1: НЕТ
СОВЕТА Индексации на
хэш-соединении SampleTable.
Более высокая продолжительность (среднее значение 333 мс).
Более высокая загрузка ЦП (среднее значение 331 мс
).

Случай 2:
Индекс LOOP JOIN HINT Поиск по SampleTable
Loop Join
Меньшая продолжительность (в среднем 204 мс, на 39% меньше)
Меньшая загрузка ЦП (в среднем 206, 38%)
намного выше логическое считывание (160015, в 34 раза больше)

Во-первых, гораздо более высокие показатели чтения во втором случае меня немного напугали, поскольку снижение показателей чтения часто считается достойным показателем производительности. Но чем больше я думаю о том, что на самом деле происходит, это меня не касается. Вот мое мышление:

SampleTable содержится на 4714 страницах, занимает около 36 МБ. Случай 1 сканирует их все, поэтому мы получаем 4714 операций чтения. Кроме того, он должен выполнить 1 миллион хэшей, которые требуют значительных ресурсов процессора и в конечном итоге пропорционально увеличивают время. Это все хеширование, которое, кажется, увеличивает время в случае 1.

Теперь рассмотрим случай 2. Он не выполняет хеширование, а вместо этого выполняет 50000 отдельных поисков, что приводит к увеличению числа операций чтения. Но насколько дорого считывание для сравнения? Можно сказать, что если это физическое чтение, это может быть довольно дорого. Но имейте в виду, 1) только первое чтение данной страницы может быть физическим, и 2) даже в этом случае в случае 1 возникнет та же или более серьезная проблема, поскольку гарантированно попадет на каждую страницу.

Таким образом, учитывая тот факт, что оба случая должны получить доступ к каждой странице хотя бы один раз, кажется, что вопрос в том, что быстрее, 1 миллион хэшей или около 155 000 операций чтения из памяти? Мои тесты, кажется, говорят о последнем, но SQL Server последовательно выбирает первое.

Вопрос

Итак, вернемся к моему вопросу: должен ли я продолжать использовать эту подсказку LOOP JOIN, когда тестирование показывает результаты такого рода, или я что-то упускаю в своем анализе? Я не решаюсь пойти против оптимизатора SQL Server, но мне кажется, что он переключается на использование хеш-соединения намного раньше, чем в подобных случаях.

Обновление 2014-04-28

Я провел дополнительное тестирование и обнаружил, что результаты, которые я получал выше (на виртуальной машине с 2 процессорами), я не мог воспроизвести в других средах (я пробовал на 2 разных физических машинах с 8 и 12 процессорами). Оптимизатор работал намного лучше в последних случаях до такой степени, что не было такой выраженной проблемы. Я предполагаю, что извлеченный урок, который кажется очевидным в ретроспективе, заключается в том, что среда может существенно повлиять на эффективность работы оптимизатора.

Планы выполнения

План исполнения Вариант 1 План 1 План исполнения Вариант 2 введите описание изображения здесь

Код для создания примера кейса

------------------------------------------------------------
-- 1. Create SampleTable with 1,000,000 rows
------------------------------------------------------------    

CREATE TABLE SampleTable
    (  
       ID         INT NOT NULL PRIMARY KEY CLUSTERED
     , Number1    INT NOT NULL
     , Number2    INT NOT NULL
     , Number3    INT NOT NULL
     , Number4    INT NOT NULL
     , Number5    INT NOT NULL
    )

--Add 1 million rows
;WITH  
    Cte0 AS (SELECT 1 AS C UNION ALL SELECT 1), --2 rows  
    Cte1 AS (SELECT 1 AS C FROM Cte0 AS A, Cte0 AS B),--4 rows  
    Cte2 AS (SELECT 1 AS C FROM Cte1 AS A ,Cte1 AS B),--16 rows 
    Cte3 AS (SELECT 1 AS C FROM Cte2 AS A ,Cte2 AS B),--256 rows 
    Cte4 AS (SELECT 1 AS C FROM Cte3 AS A ,Cte3 AS B),--65536 rows 
    Cte5 AS (SELECT 1 AS C FROM Cte4 AS A ,Cte2 AS B),--1048576 rows 
    FinalCte AS (SELECT  ROW_NUMBER() OVER (ORDER BY C) AS Number FROM   Cte5)
INSERT INTO SampleTable
SELECT Number, Number, Number, Number, Number, Number
FROM  FinalCte
WHERE Number <= 1000000

------------------------------------------------------------
-- Create 2 SPs that join from #Driver to SampleTable.
------------------------------------------------------------    
GO
IF OBJECT_ID('JoinTest_NoHint') IS NOT NULL DROP PROCEDURE JoinTest_NoHint
GO
CREATE PROC JoinTest_NoHint
AS
    SELECT S.*
    INTO #Results
    FROM #Driver AS D
    JOIN SampleTable AS S ON S.ID = D.ID
GO
IF OBJECT_ID('JoinTest_LoopHint') IS NOT NULL DROP PROCEDURE JoinTest_LoopHint
GO
CREATE PROC JoinTest_LoopHint
AS
    SELECT S.*
    INTO #Results
    FROM #Driver AS D
    INNER LOOP JOIN SampleTable AS S ON S.ID = D.ID
GO

------------------------------------------------------------
-- Create driver table with 50K rows
------------------------------------------------------------    
GO
IF OBJECT_ID('tempdb..#Driver') IS NOT NULL DROP TABLE #Driver
SELECT ID
INTO #Driver
FROM SampleTable
WHERE ID % 20 = 0

------------------------------------------------------------
-- Run each test and run Profiler
------------------------------------------------------------    

GO
/*Reg*/  EXEC JoinTest_NoHint
GO
/*Loop*/ EXEC JoinTest_LoopHint


------------------------------------------------------------
-- Results
------------------------------------------------------------    

/*

Duration CPU   Reads    TextData
315      313   4714     /*Reg*/  EXEC JoinTest_NoHint
309      296   4713     /*Reg*/  EXEC JoinTest_NoHint
327      329   4713     /*Reg*/  EXEC JoinTest_NoHint
398      406   4715     /*Reg*/  EXEC JoinTest_NoHint
316      312   4714     /*Reg*/  EXEC JoinTest_NoHint
217      219   160017   /*Loop*/ EXEC JoinTest_LoopHint
211      219   160014   /*Loop*/ EXEC JoinTest_LoopHint
217      219   160013   /*Loop*/ EXEC JoinTest_LoopHint
190      188   160013   /*Loop*/ EXEC JoinTest_LoopHint
187      187   160015   /*Loop*/ EXEC JoinTest_LoopHint

*/
JohnnyM
источник

Ответы:

13

SampleTable содержится на 4714 страницах, занимает около 36 МБ. Случай 1 сканирует их все, поэтому мы получаем 4714 операций чтения. Кроме того, он должен выполнить 1 миллион хэшей, которые требуют значительных ресурсов процессора и в конечном итоге пропорционально увеличивают время. Это все хеширование, которое, кажется, увеличивает время в случае 1.

Для хеш-соединения существует начальная стоимость (создание хеш-таблицы, которая также является блокирующей операцией), но в конечном итоге хеш-соединение имеет самую низкую теоретическую стоимость для строки из трех типов физического соединения, поддерживаемых SQL Server, причем оба условия ввода-вывода и процессора. Hash join действительно вступает в свои права с относительно небольшим входом сборки и большим входом пробника. Тем не менее, ни один тип физического соединения не является «лучшим» во всех сценариях.

Теперь рассмотрим случай 2. Он не выполняет хеширование, а вместо этого выполняет 50000 отдельных поисков, что приводит к увеличению числа операций чтения. Но насколько сравнительно дорогие чтения? Можно сказать, что если это физическое чтение, это может быть довольно дорого. Но имейте в виду, 1) только первое чтение данной страницы может быть физическим, и 2) даже в этом случае в случае 1 возникнет та же или более серьезная проблема, поскольку гарантированно попадет на каждую страницу.

Каждый поиск требует перемещения b-дерева к корню, что вычислительно дорого по сравнению с одним хеш-тестом. Кроме того, общий шаблон ввода-вывода для внутренней стороны соединения с вложенными циклами является случайным по сравнению с последовательным шаблоном доступа входа сканирования на стороне зонда к хэш-соединению. В зависимости от базовой физической подсистемы ввода-вывода последовательное чтение может выполняться быстрее, чем случайное чтение. Кроме того, механизм упреждающего чтения SQL Server лучше работает с последовательным вводом-выводом, выполняя большие операции чтения.

Таким образом, учитывая тот факт, что оба случая должны получить доступ к каждой странице хотя бы один раз, кажется, что вопрос в том, что быстрее, 1 миллион хэшей или около 155 000 операций чтения из памяти? Мои тесты, кажется, говорят о последнем, но SQL Server последовательно выбирает первое.

Оптимизатор запросов SQL Server делает ряд предположений. Один из них заключается в том, что первый доступ к странице, сделанной запросом, приведет к физическому вводу-выводу («предположение о холодном кэше»). Вероятность того, что последующее чтение придет со страницы, уже прочитанной в памяти по тому же запросу, моделируется, но это не более чем обоснованное предположение.

Причина, по которой модель оптимизатора работает таким образом, заключается в том, что обычно лучше оптимизировать для худшего случая (необходим физический ввод-вывод). Многие недостатки могут быть покрыты параллелизмом и работой в памяти. Планы запросов, которые оптимизатор будет создавать, если предположить, что все данные находятся в памяти, могут работать очень плохо, если это предположение окажется неверным.

План, составленный с использованием предположения о холодном кэше, может работать не так хорошо, как если бы вместо этого предполагался теплый кэш, но его производительность в худшем случае обычно будет выше.

Должен ли я продолжать использовать этот совет LOOP JOIN, когда тестирование показывает результаты такого рода, или я что-то упустил в своем анализе? Я не решаюсь пойти против оптимизатора SQL Server, но мне кажется, что он переключается на использование хеш-соединения намного раньше, чем в подобных случаях.

Вы должны быть очень осторожны в этом по двум причинам. Во-первых, подсказки объединения также молча заставляют физический порядок соединения соответствовать письменному порядку запроса (как если бы вы также указали OPTION (FORCE ORDER). Это серьезно ограничивает альтернативы, доступные оптимизатору, и не всегда может быть тем, что вам нужно). OPTION (LOOP JOIN)Вынуждает вложенные циклы. присоединяется к запросу, но не применяет письменный порядок объединения.

Во-вторых, вы предполагаете, что размер набора данных останется небольшим, и большинство логических операций чтения будут происходить из кэша. Если эти предположения станут недействительными (возможно, со временем), производительность ухудшится. Встроенный оптимизатор запросов очень хорошо реагирует на меняющиеся обстоятельства; удаление этой свободы - это то, о чем вы должны подумать.

В целом, если нет веских причин для принудительного объединения циклов, я бы этого не допустил. Планы по умолчанию обычно довольно близки к оптимальным и имеют тенденцию быть более устойчивыми перед лицом меняющихся обстоятельств.

Пол Уайт восстановил Монику
источник
Спасибо, Пол. Отличный детальный анализ. Основываясь на дальнейшем тестировании, которое я провел, я думаю, что происходит то, что образованные догадки оптимизатора постоянно отключаются для этого конкретного примера, когда размер временной таблицы составляет от 5К до 100К. Учитывая тот факт, что наши требования гарантируют, что временная таблица будет <50K, мне это кажется безопасным. Мне любопытно, вы все еще избежали бы какой-либо подсказки присоединения, зная это?
JohnnyM
1
@JohnnyM Подсказки существуют по причине. Их можно использовать там, где у вас есть веские причины для этого. Тем не менее, я редко использую подсказки соединения из-за подразумеваемого FORCE ORDER. В нечетном случае я использую подсказку о присоединении, которую я часто добавляю OPTION (FORCE ORDER)с комментарием, чтобы объяснить, почему.
Пол Уайт восстановил Монику
0

50000 строк, соединенных с таблицей из миллиона строк, кажется много для любой таблицы без индекса.

Трудно сказать вам точно, что делать в этом случае, поскольку он настолько изолирован от проблемы, которую вы на самом деле пытаетесь решить. Я, конечно, надеюсь, что это не общий шаблон в вашем коде, где вы объединяетесь со многими неиндексированными временными таблицами со значительным количеством строк.

Взяв пример только для того, что он говорит, почему бы просто не поместить индекс в #Driver? Действительно ли D.ID уникален? Если это так, то это семантически эквивалентно выражению EXISTS, которое, по крайней мере, сообщит SQL Server, что вы не хотите продолжать поиск в S повторяющихся значений D:

SELECT S.*
INTO #Results
FROM SampleTable S
WHERE EXISTS (SELECT * #Driver D WHERE S.ID = D.ID);

Короче говоря, для этого шаблона я бы не использовал подсказку LOOP. Я бы просто не использовал этот шаблон. Я бы сделал одно из следующих действий в порядке приоритетности, если это возможно:

  • Если возможно, используйте CTE вместо временной таблицы для #Driver
  • Используйте уникальный некластеризованный индекс для #Driver для ID, если он уникален (при условии, что вы используете #Driver единственный раз, и вам не нужны никакие данные из самой таблицы - если вам действительно нужны данные из этой таблицы, вы вполне может сделать его кластерным индексом)
Дейв Маркл
источник