Емуляція визначеної користувачем скалярної функції таким чином, що не перешкоджає паралелізму


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 with top), але жоден план не такий хороший, як той, який має скалярні функції досі
  3. Я знаю про майбутнє вдосконалення SQL Server 2017 - Froid: Оптимізація імперативних програм у реляційній базі даних. Я не впевнений, що це допоможе. Хоча було б, якби тут було доведено неправильно.

Додаткова інформація

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

Мене попросили порівняти фактичний час виконання. У цьому конкретному випадку

  • запит 0 працює за ~ 500 мс
  • запит 1 працює на ~ 1500 мс
  • запит 2 працює за ~ 1500 мс
  • запит 3 працює для ~ 2000ms.

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

Припустимо, що dbo.Paramsвона змінюється рідко і зазвичай має близько 1-200 рядів, не більше ніж, скажімо, 2000 року колись очікується. Зараз це близько 10 стовпців, і я не очікую занадто часто додавати стовпці.

Кількість рядків у Парамах не фіксована, тому для кожного @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;

Але це відноситься до категорії подолання.

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

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


Спасибі, Павло, чудова відповідь! Я думав про використання, session_contextале вирішую, що це занадто шалена ідея для мене, і я не впевнений, як це буде відповідати моїй сучасній архітектурі. Що б було корисно, можливо, є якийсь натяк, який я міг би використати, щоб оптимізатор знав, що він повинен трактувати результат підзапиту як просту скалярну посилання.
Роман Пекар

8

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

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

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

Ви можете досягти еквівалентної продуктивності запиту, використовуючи скалярні UDF, щоб присвоїти значення повернення локальним змінним і використовувати ці локальні змінні у вашому запиті. Ви можете загортати цей код у збережену процедуру або 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;

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

паралельний план запитів

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

Просто для з'ясування кількох речей: скалярні UDF у T-SQL завжди забороняють паралелізм, а Microsoft не заявляє, що FROID буде доступний у SQL Server 2017.


щодо Froid у SQL 2017 - не впевнений, чому я думав, що він є там. Підтверджено в vNext - brentozar.com/archive/2018/01/…
Роман Пекар

4

Це, швидше за все, можна зробити за допомогою SQLCLR. Однією з переваг скалярних UDF SQLCLR є те, що вони не перешкоджають паралелізму, якщо вони не здійснюють жодного доступу до даних (а іноді їх також потрібно позначати як "детерміновані"). Тож як ви можете використовувати те, що не потребує доступу до даних, коли сама операція вимагає доступу до даних?

Ну тому, що dbo.Paramsочікується , що таблиця:

  1. як правило, ніколи в ньому більше 2000 рядків,
  2. рідко змінюють структуру,
  3. лише (наразі) потрібно мати два INTстовпці

можна кешувати три стовпці - session_id, experiment_year int, experiment_month- у статичну колекцію (наприклад, Словник, можливо), яка заповнюється поза процесом та читається скалярними АДС, які отримують значення experiment_year intта experiment_monthзначення. Що я маю на увазі під "поза процесом", це: ви можете мати повністю окремий скалярний UDF SQLCLR або збережену процедуру, яка може робити доступ до даних та зчитування з dbo.Paramsтаблиці для заповнення статичної колекції. Ця UDF або Збережена процедура буде виконана до використання UDF, які отримують значення "рік" і "місяць", таким чином UDF, які отримують значення "рік" і "місяць", не мають доступу до даних БД.

Процедура UDF або Stored, що читає дані, може спочатку перевірити, чи має колекція 0 записів, а якщо так, то заповнити, ще пропустити. Ви навіть можете відслідковувати час, коли він був заповнений, і якщо минуло X хвилин (або щось подібне), то очистити та повторно заповнити, навіть якщо в колекції є записи. Але пропуск населення допоможе, оскільки його потрібно буде часто виконувати, щоб гарантувати, що воно завжди заповнене для двох основних АДС, щоб отримати значення.

Основна стурбованість викликає те, коли SQL Server вирішує вивантажити Домен додатків з будь-якої причини (або це викликано чимось використанням DBCC FREESYSTEMCACHE('ALL');). Ви не хочете ризикувати, що збірка буде очищена між виконанням "заповнення" АДС або збереженою процедурою та СДС, щоб отримати значення "рік" та "місяць". У такому випадку ви можете перевірити ці два UDF, щоб викинути виняток, якщо колекція порожня, оскільки краще помилитися, ніж успішно надати помилкові результати.

Звичайно, зазначене вище занепокоєння передбачає, що бажання Асамблеї було позначено як SAFE. Якщо збірка може бути позначена як " EXTERNAL_ACCESS," можливо, статичний конструктор виконує метод, який зчитує дані, і заповнює колекцію, так що вам потрібно буде лише виконувати це вручну для оновлення рядків, але вони завжди будуть заповнені (оскільки конструктор статичного класу завжди працює при завантаженні класу, що відбувається кожного разу, коли метод цього класу виконується після перезавантаження або не завантажується Домен додатка). Для цього потрібно використовувати звичайне підключення, а не внутрішнє підключення контексту (яке не доступне статичним конструкторам, отже, і необхідність EXTERNAL_ACCESS).

Зверніть увагу: для того, щоб не потрібно позначати Асамблею як UNSAFE, вам потрібно позначити будь-які змінні статичного класу як readonly. Це означає, щонайменше, колекцію. Це не проблема, оскільки колекції лише для читання можуть додавати або видаляти з них елементи, вони просто не можуть бути ініціалізовані за межами конструктора або початкового завантаження. Відстеження часу завантаження колекції з метою закінчення терміну дії через X хвилин складніше, оскільки static readonly DateTimeзмінна класу не може бути змінена поза конструктором або початковим завантаженням. Щоб обійти це обмеження, вам потрібно використовувати статичну колекцію, доступну лише для читання, яка містить один елемент, який є DateTimeзначенням, щоб його можна було видалити та повторно додати після оновлення.


Не знаю, чому хтось спротив це. Хоча я не дуже загальний, я вважаю, що це може бути застосоване і в моєму випадку. Я вважаю за краще мати чисте рішення SQL, але я обов'язково детальніше ознайомлюсь із цим і спробую перевірити, чи працює
Роман Пекар

@RomanPekar Не впевнений, але там багато людей, які є анти-SQLCLR. І, може, декілька, які є проти мене ;-). Так чи інакше, я не можу подумати, чому це рішення не працює. Я розумію, що віддаю перевагу чистому T-SQL, але не знаю, як це зробити, і якщо немає конкуруючої відповіді, то, можливо, ніхто ще не робить. Я не знаю, чи оптимізували б пам'ять таблиці та вбудовано складені UDF. Крім того, я просто додав абзац з деякими примітками щодо реалізації, які слід пам’ятати.
Соломон Руцький

1
Я ніколи не був повністю переконаний, що використання readonly staticsSQLCLR є безпечним або розумним. Набагато менше я переконаний у тому, щоб потім дурити систему, зробивши той readonlyтип посилання, який ви потім переходите та змінюєте . Дає мені абсолютних воллі тбх.
Пол Білий 9

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

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