Эмулируйте пользовательскую скалярную функцию таким образом, чтобы не предотвратить параллелизм


12

Я пытаюсь увидеть, есть ли способ обмануть SQL Server, чтобы использовать определенный план для запроса.

1. Окружающая среда

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

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go

Теперь для каждого процесса у нас есть параметры, сохраненные в таблице.

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go

2. Тестовые данные

Давайте добавим некоторые тестовые данные:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

3. Получение результатов

Теперь получить результаты эксперимента очень просто @experiment_year/@experiment_month:

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go

План приятный и параллельный:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

план запроса 0

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

4. Проблема

Но, чтобы сделать использование данных более общим, я хочу иметь другую функцию - dbo.f_GetSharedDataBySession(@session_id int). Итак, простым способом было бы создать скалярные функции, переводя @session_id-> @experiment_year/@experiment_month:

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

И теперь мы можем создать нашу функцию:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go

план запроса 1

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

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

Поэтому я попробовал несколько разных подходов, например, используя подзапросы вместо скалярных функций:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go

план запроса 2

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

Или используя cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go

план запроса 3

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

Но я не могу найти способ написать этот запрос так же хорошо, как тот, который использует скалярные функции.

Пара мыслей:

  1. По сути, мне бы хотелось иметь возможность каким-то образом указывать SQL Server предварительно вычислять определенные значения, а затем передавать их как константы.
  2. Что может быть полезным, если бы у нас был некоторый промежуточный намек на материализацию . Я проверил несколько вариантов (TVF с несколькими утверждениями или cte с верхом), но ни один план пока не так хорош, как план со скалярными функциями.
  3. Я знаю о предстоящем улучшении SQL Server 2017 - Froid: оптимизация императивных программ в реляционной базе данных. Однако я не уверен, что это поможет. Хотя было бы неплохо оказаться здесь неправым.

Дополнительная информация

Я использую функцию (а не выбираю данные непосредственно из таблиц), потому что ее гораздо проще использовать во многих различных запросах, которые обычно имеют @session_idпараметр.

Меня попросили сравнить фактическое время выполнения. В этом конкретном случае

  • запрос 0 выполняется в течение ~ 500 мс
  • запрос 1 выполняется в течение ~ 1500 мс
  • запрос 2 выполняется в течение ~ 1500 мс
  • запрос 3 выполняется в течение ~ 2000 мс.

План № 2 имеет поиск индекса вместо поиска, который затем фильтруется по предикатам во вложенных циклах. План № 3 не так уж и плох, но все же выполняет больше работы и работает медленнее, чем план № 0.

Давайте предположим, что dbo.Paramsэто изменяется редко, и обычно имеет около 1-200 строк, не больше, скажем, 2000 когда-либо ожидается. Сейчас это около 10 столбцов, и я не собираюсь добавлять столбцы слишком часто.

Количество строк в Params не фиксировано, поэтому для каждого @session_idбудет ряд. Количество столбцов там не фиксировано, это одна из причин, по которой я не хочу звонить dbo.f_GetSharedData(@experiment_year int, @experiment_month int)откуда угодно , поэтому я могу добавить новый столбец к этому запросу внутри страны. Я был бы рад услышать любые мнения / предложения по этому вопросу, даже если у него есть некоторые ограничения.


План запроса с Froid будет аналогичен плану запроса2 выше, поэтому да, он не приведет вас к решению, которого вы хотите достичь в этом случае.
Картик,

Ответы:


13

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

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

Как указано в вопросе, можно получить параллельный план, но есть два основных варианта, ни один из которых не подходит для ваших нужд:

  1. Коррелированные вложенные циклы объединяются с циклическим распределением потоков на верхнем уровне. Учитывая, что Paramsдля определенного session_idзначения гарантированно может быть одна строка , внутренняя сторона будет работать в одном потоке, даже если она помечена значком параллелизма. Вот почему, очевидно, параллельный план 3 не работает так же хорошо; это на самом деле серийный.

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

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

Тогда возникает естественный вопрос: зачем вообще нужны коррелированные параметры? Почему SQL Server не может просто напрямую искать скалярные значения, предоставляемые, например, подзапросом?

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

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

Теперь у вас могут быть конкретные местные соображения, которые означают, что SESSION_CONTEXTстоит кэшировать текущие значения года и месяца, то есть:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;

Но это попадает в категорию обходных путей.

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

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


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

8

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

  1. Весь план серийный. Это не приемлемо для вас. Это план, который вы получаете по запросу 1.
  2. Соединение цикла выполняется последовательно. Я считаю, что в этом случае внутренняя сторона может работать параллельно, но невозможно передать ей какие-либо предикаты. Таким образом, большая часть работы будет выполняться параллельно, но вы сканируете всю таблицу, а частичное агрегирование намного дороже, чем раньше. Это план, который вы получаете по запросу 2.
  3. Соединение цикла выполняется параллельно. При параллельном соединении вложенных циклов внутренняя сторона цикла выполняется последовательно, но вы можете одновременно иметь до DOP-потоков, работающих на внутренней стороне. Ваш внешний набор результатов будет иметь только одну строку, поэтому ваш параллельный план будет фактически последовательным. Это план, который вы получите по запросу 3.

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

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

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;

Скалярные пользовательские функции были перемещены за пределы запроса, который вы хотите иметь право на параллелизм. План запроса, который я получаю, кажется, тот, который вы хотите:

план параллельных запросов

Оба подхода имеют недостатки, если вам нужно использовать этот набор результатов в других запросах. Вы не можете напрямую присоединиться к хранимой процедуре. Вы должны сохранить результаты во временную таблицу, которая имеет свой собственный набор проблем. Вы можете присоединиться к MS-TVF, но в SQL Server 2016 вы можете столкнуться с проблемами оценки количества элементов. SQL Server 2017 предлагает чередованное выполнение для MS-TVF, которое может полностью решить проблему.

Просто чтобы прояснить несколько вещей: Скалярные пользовательские функции T-SQL всегда запрещают параллелизм, и Microsoft не говорит, что FROID будет доступен в SQL Server 2017.


Что касается Froid в SQL 2017 - не уверен, почему я думал, что это там. Это подтверждается в vNext - brentozar.com/archive/2018/01/…
Роман Пекар

4

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

Ну, потому что dbo.Paramsтаблица должна:

  1. обычно в нем никогда не бывает более 2000 строк,
  2. редко меняют структуру,
  3. только (в настоящее время) нужно иметь два INTстолбца

это возможно кэшировать три колонки - session_id, experiment_year int, experiment_month- в коллекцию статической (например, словарь, возможно), заполняемый вне процесса и читать скалярную UDF , которые получают experiment_year intи experiment_monthзначение. Я имею в виду «вне процесса»: у вас может быть совершенно отдельный SQLCLR Scalar UDF или хранимая процедура, которая может осуществлять доступ к данным и выполнять чтение из dbo.Paramsтаблицы для заполнения статической коллекции. Эта UDF или хранимая процедура будет выполняться перед использованием UDF, которые получают значения «year» и «month», таким образом, UDF, которые получают значения «year» и «month», не осуществляют никакого доступа к данным БД.

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

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

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

Обратите внимание: чтобы не требовалось пометить сборку как UNSAFE, необходимо пометить любые переменные статического класса как readonly. Это означает, по крайней мере, коллекцию. Это не проблема, поскольку в коллекциях, доступных только для чтения, элементы могут быть добавлены или удалены из них, они просто не могут быть инициализированы вне конструктора или начальной загрузки. Отслеживать время загрузки коллекции с целью ее истечения через X минут сложнее, поскольку static readonly DateTimeпеременная класса не может быть изменена вне конструктора или начальной загрузки. Чтобы обойти это ограничение, вам нужно использовать статическую коллекцию, доступную только для чтения, которая содержит один элемент, DateTimeзначение которого можно удалить и повторно добавить при обновлении.


Не знаю, почему кто-то отверг это. Хотя это и не очень общее, я думаю, что это может быть применимо в моем текущем случае. Я бы предпочел иметь решение на чистом SQL, но я определенно посмотрю на это и попробую посмотреть, работает ли
Роман Пекар

@RomanPekar Не уверен, но есть много людей, которые против SQLCLR. И, может быть, несколько против меня ;-). В любом случае, я не могу понять, почему это решение не сработает. Я понимаю предпочтение чистого T-SQL, но я не знаю, как это сделать, и если нет конкурирующего ответа, то, возможно, никто другой не сделает. Я не знаю, будут ли таблицы с оптимизированной памятью и скомпилированные UDF лучше работать здесь. Кроме того, я просто добавил параграф с некоторыми примечаниями по реализации, чтобы иметь в виду.
Соломон Руцкий

1
Я никогда не был полностью убежден, что использование readonly staticsв SQLCLR безопасно или разумно. Гораздо меньше я убежден в том, что тогда нужно будет обмануть систему, сделав этот readonlyссылочный тип, который вы затем измените . Дает мне абсолютную волю.
Пол Уайт 9

@PaulWhite Понятно, и я вспоминаю это в личной беседе много лет назад. Учитывая общую природу доменов приложений (и, следовательно, staticобъектов) в SQL Server, да, существует риск возникновения гонки. Вот почему я сначала определил по ОП, что эти данные минимальны и стабильны, и почему я квалифицировал этот подход как требующий «редко меняющихся», и дал средство обновления при необходимости. В этом случае я не вижу большого риска. Несколько лет назад я обнаружил пост о возможности обновлять коллекции только для чтения как разработанные (в C # нет обсуждения re: SQLCLR). Постараюсь найти это.
Соломон Руцкий

2
Нет необходимости, вы никак не сможете мне помочь с этим, если не считать официальной документации по SQL Server, в которой говорится, что все в порядке, чего, я уверен, не существует.
Пол Уайт 9
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.