SQL Server не оптимизирует параллельное объединение слиянием на двух эквивалентно разделенных таблицах

21

Заранее извиняюсь за очень подробный вопрос. Я включил запросы для создания полного набора данных для воспроизведения проблемы, и я использую SQL Server 2012 на 32-ядерном компьютере. Тем не менее, я не думаю, что это специфично для SQL Server 2012, и я установил MAXDOP 10 для этого конкретного примера.

У меня есть две таблицы, которые разделены по той же схеме. Соединяя их вместе в столбце, используемом для разделения, я заметил, что SQL Server не может оптимизировать параллельное объединение слиянием так, как можно было бы ожидать, и поэтому решил вместо этого использовать HASH JOIN. В этом конкретном случае я могу вручную моделировать гораздо более оптимальный параллельный MERGE JOIN, разбивая запрос на 10 непересекающихся диапазонов на основе функции разделения и выполняя каждый из этих запросов одновременно в SSMS. Использование WAITFOR для одновременного запуска их всех приводит к тому, что все запросы выполняются за ~ 40% от общего времени, использованного исходным параллельным HASH JOIN.

Есть ли способ заставить SQL Server самостоятельно выполнять эту оптимизацию в случае таблиц с одинаковым разделением? Я понимаю, что SQL Server, как правило, может потребовать много дополнительной нагрузки для параллельного выполнения MERGE JOIN, но кажется, что в этом случае существует очень естественный метод разделения с минимальными издержками. Возможно, это просто особый случай, когда оптимизатор еще не достаточно умен, чтобы его распознавать?

Вот SQL для настройки упрощенного набора данных для воспроизведения этой проблемы:

/* Create the first test data table */
CREATE TABLE test_transaction_properties 
    ( transactionID INT NOT NULL IDENTITY(1,1)
    , prop1 INT NULL
    , prop2 FLOAT NULL
    )

/* Populate table with pseudo-random data (the specific data doesn't matter too much for this example) */
;WITH E1(N) AS (
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 
    UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
)
, E2(N) AS (SELECT 1 FROM E1 a CROSS JOIN E1 b)
, E4(N) AS (SELECT 1 FROM E2 a CROSS JOIN E2 b)
, E8(N) AS (SELECT 1 FROM E4 a CROSS JOIN E4 b)
INSERT INTO test_transaction_properties WITH (TABLOCK) (prop1, prop2)
SELECT TOP 10000000 (ABS(CAST(CAST(NEWID() AS VARBINARY) AS INT)) % 5) + 1 AS prop1
                , ABS(CAST(CAST(NEWID() AS VARBINARY) AS INT)) * rand() AS prop2
FROM E8

/* Create the second test data table */
CREATE TABLE test_transaction_item_detail
    ( transactionID INT NOT NULL
    , productID INT NOT NULL
    , sales FLOAT NULL
    , units INT NULL
    )

 /* Populate the second table such that each transaction has one or more items
     (again, the specific data doesn't matter too much for this example) */
INSERT INTO test_transaction_item_detail WITH (TABLOCK) (transactionID, productID, sales, units)
SELECT t.transactionID, p.productID, 100 AS sales, 1 AS units
FROM test_transaction_properties t
JOIN (
    SELECT 1 as productRank, 1 as productId
    UNION ALL SELECT 2 as productRank, 12 as productId
    UNION ALL SELECT 3 as productRank, 123 as productId
    UNION ALL SELECT 4 as productRank, 1234 as productId
    UNION ALL SELECT 5 as productRank, 12345 as productId
) p
    ON p.productRank <= t.prop1

/* Divides the transactions evenly into 10 partitions */
CREATE PARTITION FUNCTION [pf_test_transactionId] (INT)
AS RANGE RIGHT
FOR VALUES
(1,1000001,2000001,3000001,4000001,5000001,6000001,7000001,8000001,9000001)

CREATE PARTITION SCHEME [ps_test_transactionId]
AS PARTITION [pf_test_transactionId]
ALL TO ( [PRIMARY] )

/* Apply the same partition scheme to both test data tables */
ALTER TABLE test_transaction_properties
ADD CONSTRAINT PK_test_transaction_properties
PRIMARY KEY (transactionID)
ON ps_test_transactionId (transactionID)

ALTER TABLE test_transaction_item_detail
ADD CONSTRAINT PK_test_transaction_item_detail
PRIMARY KEY (transactionID, productID)
ON ps_test_transactionId (transactionID)

Теперь мы наконец готовы воспроизвести неоптимальный запрос!

/* This query produces a HASH JOIN using 20 threads without the MAXDOP hint,
    and the same behavior holds in that case.
    For simplicity here, I have limited it to 10 threads. */
SELECT COUNT(*)
FROM test_transaction_item_detail i
JOIN test_transaction_properties t
    ON t.transactionID = i.transactionID
OPTION (MAXDOP 10)

введите описание изображения здесь

введите описание изображения здесь

Однако использование одного потока для обработки каждого раздела (пример для первого раздела ниже) приведет к гораздо более эффективному плану. Я проверил это, выполнив запрос, подобный приведенному ниже, для каждого из 10 разделов в один и тот же момент, и все 10 были завершены всего за 1 секунду:

SELECT COUNT(*)
FROM test_transaction_item_detail i
INNER MERGE JOIN test_transaction_properties t
    ON t.transactionID = i.transactionID
WHERE t.transactionID BETWEEN 1 AND 1000000
OPTION (MAXDOP 1)

введите описание изображения здесь введите описание изображения здесь

Джефф Паттерсон
источник

Ответы:

18

Вы правы, что оптимизатор SQL Server предпочитает не создавать параллельные MERGEпланы соединения (эта альтернатива стоит очень дорого). Параллель MERGEвсегда требует перераспределения обменов на обоих входах соединения, и что более важно, это требует, чтобы порядок строк сохранялся на этих обменах.

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

Эти проблемы можно обойти, запустив несколько экземпляров всего запроса в одном потоке каждый, причем каждый поток обрабатывает исключительный диапазон данных. Однако это не стратегия, которую оптимизатор изначально рассматривает. Как таковая, исходная модель SQL Server для параллелизма разбивает запрос при обменах и запускает сегменты плана, образованные этими разбиениями, в нескольких потоках.

Существуют способы достижения выполнения целых планов запросов в нескольких потоках по исключительным диапазонам наборов данных, но они требуют хитрости, которой не все будут довольны (и не будут поддерживаться Microsoft или гарантированно работать в будущем). Один из таких подходов состоит в том, чтобы перебрать разделы разделенной таблицы и дать каждому потоку задачу создания промежуточного итога. Результатом является SUMколичество строк, возвращаемых каждым независимым потоком:

Получить номера разделов достаточно просто из метаданных:

DECLARE @P AS TABLE
(
    partition_number integer PRIMARY KEY
);

INSERT @P (partition_number)
SELECT
    p.partition_number
FROM sys.partitions AS p 
WHERE 
    p.[object_id] = OBJECT_ID(N'test_transaction_properties', N'U')
    AND p.index_id = 1;

Затем мы используем эти числа для управления взаимосвязанным join ( APPLY) и $PARTITIONфункцию для ограничения каждого потока текущим номером раздела:

SELECT
    row_count = SUM(Subtotals.cnt)
FROM @P AS p
CROSS APPLY
(
    SELECT
        cnt = COUNT_BIG(*)
    FROM dbo.test_transaction_item_detail AS i
    JOIN dbo.test_transaction_properties AS t ON
        t.transactionID = i.transactionID
    WHERE 
        $PARTITION.pf_test_transactionId(t.transactionID) = p.partition_number
        AND $PARTITION.pf_test_transactionId(i.transactionID) = p.partition_number
) AS SubTotals;

План запроса показывает MERGEобъединение, выполняемое для каждой строки в таблице @P. Свойства сканирования кластеризованного индекса подтверждают, что на каждой итерации обрабатывается только один раздел:

Применить серийный план

К сожалению, это приводит только к последовательной последовательной обработке разделов. На предоставленном вами наборе данных мой 4-ядерный (с гиперзадачей до 8) ноутбук возвращает правильный результат за 7 секунд со всеми данными в памяти.

Чтобы подпланы MERGEработали одновременно, нам нужен параллельный план, в котором идентификаторы разделов распределяются по доступным потокам ( MAXDOP), и каждый MERGEподплан выполняется в одном потоке, используя данные в одном разделе. К сожалению, оптимизатор часто выбирает параллельное MERGEпо соображениям стоимости, и не существует документированного способа принудительного параллельного плана. Существует недокументированный (и не поддерживаемый) способ, использующий флаг трассировки 8649 :

SELECT
    row_count = SUM(Subtotals.cnt)
FROM @P AS p
CROSS APPLY
(
    SELECT
        cnt = COUNT_BIG(*)
    FROM dbo.test_transaction_item_detail AS i
    JOIN dbo.test_transaction_properties AS t ON
        t.transactionID = i.transactionID
    WHERE 
        $PARTITION.pf_test_transactionId(t.transactionID) = p.partition_number
        AND $PARTITION.pf_test_transactionId(i.transactionID) = p.partition_number
) AS SubTotals
OPTION (QUERYTRACEON 8649);

Теперь план запроса показывает, какие номера разделов @Pраспределены между потоками в циклическом порядке. Каждый поток запускает внутреннюю сторону объединения вложенных циклов для одного раздела, достигая нашей цели одновременной обработки непересекающихся данных. Тот же самый результат теперь возвращается через 3 секунды на моих 8 гиперядрах, при этом все восемь используются на 100%.

Параллельное ПРИМЕНЕНИЕ

Я не рекомендую вам обязательно использовать эту технику - см. Мои предыдущие предупреждения - но она действительно решает ваш вопрос.

См моей статьи Улучшения секционированной таблица Регистрации производительности для получения более подробной информации.

Columnstore

Поскольку вы используете SQL Server 2012 (и предполагаете, что это Enterprise), у вас также есть возможность использовать индекс columnstore. Это показывает потенциал хеш-соединений в пакетном режиме, когда доступно достаточно памяти:

CREATE NONCLUSTERED COLUMNSTORE INDEX cs 
ON dbo.test_transaction_properties (transactionID);

CREATE NONCLUSTERED COLUMNSTORE INDEX cs 
ON dbo.test_transaction_item_detail (transactionID);

С этими индексами на месте запроса ...

SELECT
    COUNT_BIG(*)
FROM dbo.test_transaction_properties AS ttp
JOIN dbo.test_transaction_item_detail AS ttid ON
    ttid.transactionID = ttp.transactionID;

... приводит к следующему плану выполнения из оптимизатора без хитрости:

Columnstore план 1

Исправьте результаты за 2 секунды , но устранение обработки в режиме строк для скалярного агрегата помогает еще больше:

SELECT
    COUNT_BIG(*)
FROM dbo.test_transaction_properties AS ttp
JOIN dbo.test_transaction_item_detail AS ttid ON
    ttid.transactionID = ttp.transactionID
GROUP BY
    ttp.transactionID % 1;

Оптимизированный колоночный магазин

Оптимизированный запрос к хранилищу столбцов выполняется за 851 мс .

Джефф Паттерсон создал отчет об ошибках в Partition Wise Joins, но он был закрыт, так как не исправит.

Пол Уайт говорит, что GoFundMonica
источник
5
Отличный опыт обучения здесь. Спасибо. +1
Эдвард Дортланд,
1
Спасибо, Пол! Здесь отличная информация, и она, безусловно, подробно рассматривает этот вопрос.
Джефф Паттерсон
2
Спасибо, Пол! Здесь отличная информация, и она, безусловно, подробно рассматривает этот вопрос. Мы находимся в смешанной среде SQL 2008/2012, но я рассмотрю дальнейшее изучение хранилища столбцов. Конечно, я все еще хотел бы, чтобы SQL Server мог эффективно использовать параллельное объединение слиянием - и гораздо более низкие требования к памяти - в моем случае использования :) Я подал следующую проблему Connect на тот случай, если кто-нибудь захочет взглянуть и прокомментировать или проголосуйте за него: connect.microsoft.com/SQLServer/feedback/details/759266/…
Джефф Паттерсон
0

Способ заставить оптимизатор работать так, как вы думаете, лучше - через подсказки запросов.

В этом случае, OPTION (MERGE JOIN)

Или вы можете пойти всю свинью и использовать USE PLAN

podiluska
источник
Я бы не стал делать это лично: подсказка будет полезна только для текущего объема данных и их распространения.
2012 года
Интересно то, что использование OPTION (MERGE JOIN) приводит к гораздо худшему плану. Оптимизатор не достаточно умен, чтобы понять, что MERGE JOIN может быть защищен функцией секционирования, и применение этой подсказки заставляет запрос занимать ~ 46 секунд. Очень расстраивает!
@gbn, что, по-видимому, почему оптимизатор идет на хеш-соединение в первую очередь?
@gpatterson Как раздражает! :)
Что произойдет, если вы принудительно создадите разбиение через объединение (т. Е. Ваш короткий запрос объединится с другими аналогичными запросами)?