Проблема оптимизации с пользовательской функцией

26

У меня проблема с пониманием того, почему SQL-сервер решает вызывать пользовательскую функцию для каждого значения в таблице, даже если нужно выбрать только одну строку. Реальный SQL намного сложнее, но мне удалось свести проблему к следующему:

select  
    S.GROUPCODE,
    H.ORDERCATEGORY
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT    
    cross apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

Для этого запроса SQL Server решает вызвать функцию GetGroupCode для каждого отдельного значения, существующего в таблице PRODUCT, даже если оценочное и фактическое количество строк, возвращаемых из ORDERLINE, равно 1 (это первичный ключ):

План запроса

Тот же план в проводнике планов, показывающий количество строк:

Планировщик Таблицы:

ORDERLINE: 1.5M rows, primary key: ORDERNUMBER + ORDERLINE + RMPHASE (clustered)
ORDERHDR:  900k rows, primary key: ORDERID (clustered)
PRODUCT:   6655 rows, primary key: PRODUCT (clustered)

Индекс, используемый для сканирования:

create unique nonclustered index PRODUCT_FACTORY on PRODUCT (PRODUCT, FACTORY)

Функция на самом деле немного сложнее, но то же самое происходит с фиктивной функцией из нескольких операторов, например:

create function GetGroupCode (@FACTORY varchar(4))
returns @t table(
    TYPE        varchar(8),
    GROUPCODE   varchar(30)
)
as begin
    insert into @t (TYPE, GROUPCODE) values ('XX', 'YY')
    return
end

Мне удалось «исправить» производительность, заставив SQL-сервер выбрать первый продукт, хотя 1 - это максимум, который можно найти:

select  
    S.GROUPCODE,
    H.ORDERCAT
from    
    ORDERLINE L
    join ORDERHDR H
        on H.ORDERID = M.ORDERID
    cross apply (select top 1 P.FACTORY from PRODUCT P where P.PRODUCT = L.PRODUCT) P
    cross apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

Затем форма плана также изменится, и я ожидаю, что она будет изначально:

План запроса с верхом

Я также считаю, что индекс PRODUCT_FACTORY, меньший, чем кластеризованный индекс PRODUCT_PK, будет иметь влияние, но даже при принудительном запросе на использование PRODUCT_PK план будет таким же, как и оригинальный, с 6655 вызовами функции.

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

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

Изменить: Создать операторы таблицы:

CREATE TABLE dbo.ORDERHDR(
    ORDERID varchar(8) NOT NULL,
    ORDERCATEGORY varchar(2) NULL,
    CONSTRAINT ORDERHDR_PK PRIMARY KEY CLUSTERED (ORDERID)
)

CREATE TABLE dbo.ORDERLINE(
    ORDERNUMBER varchar(16) NOT NULL,
    RMPHASE char(1) NOT NULL,
    ORDERLINE char(2) NOT NULL,
    ORDERID varchar(8) NOT NULL,
    PRODUCT varchar(8) NOT NULL,
    CONSTRAINT ORDERLINE_PK PRIMARY KEY CLUSTERED (ORDERNUMBER,ORDERLINE,RMPHASE)
)

CREATE TABLE dbo.PRODUCT(
    PRODUCT varchar(8) NOT NULL,
    FACTORY varchar(4) NULL,
    CONSTRAINT PRODUCT_PK PRIMARY KEY CLUSTERED (PRODUCT)
)
Джеймс З
источник

Ответы:

30

Есть три основных технических причины, по которым вы получаете план, который вы делаете:

  1. Платформа оптимизатора не имеет реальной поддержки не встроенных функций. Он не делает никаких попыток заглянуть внутрь определения функции, чтобы увидеть, насколько она может быть дорогой, он просто назначает очень небольшую фиксированную стоимость и оценивает, что функция будет выдавать 1 строку выходных данных при каждом ее вызове. Оба эти предположения моделирования очень часто совершенно небезопасны. Ситуация очень незначительно улучшилась в 2014 году с включенным новым оценщиком мощности, поскольку фиксированное предположение из 1 строки заменяется фиксированным предположением из 100 строк. Однако по-прежнему не поддерживается определение стоимости содержимого не встроенных функций.
  2. SQL Server изначально сворачивает объединения и применяет их в едином внутреннем n-арном логическом соединении. Это помогает оптимизатору рассуждать о заказах на присоединение позже. Расширение единого n-арного объединения в порядки кандидатов-кандидатов происходит позже и в значительной степени основано на эвристике. Например, внутренние объединения выполняются до внешних объединений, небольших таблиц и выборочных объединений перед большими таблицами и менее селективными объединениями и т. Д.
  3. Когда SQL Server выполняет оптимизацию на основе затрат, он разделяет усилия на необязательные этапы, чтобы минимизировать шансы затрачивать слишком много времени на оптимизацию недорогих запросов. Существует три основных этапа: поиск 0, поиск 1 и поиск 2. Каждый этап имеет условия входа, а более поздние этапы позволяют проводить больше исследований оптимизатора, чем предыдущие. Ваш запрос подходит для фазы поиска с наименьшей способностью, фаза 0. Там найден план с достаточно низкой стоимостью, что более поздние стадии не вводятся.

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

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

Поиск 0 (также известный как фаза обработки транзакций) нацелен на запросы OLTP-типа с низким числом элементов, а в окончательных планах обычно используются объединения вложенных циклов. Что еще более важно, поиск 0 запускает только относительно небольшое подмножество возможностей поисковой оптимизации оптимизатора. Это подмножество не включает в себя перетаскивание дерева запросов вверх по соединению (правилу PullApplyOverJoin) Это именно то, что требуется в тестовом примере для изменения положения UDF, примененного над соединениями, чтобы они были последними в последовательности операций (как это было).

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

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

План на пустых столах с отключенным поиском 0

Не существует поддерживаемого способа избежать выбора плана поиска 0, досрочного прекращения работы оптимизатора или повышения стоимости пользовательских функций (за исключением ограниченных улучшений в модели SQL Server 2014 CE для этого). Это оставляет такие вещи, как руководства по плану, ручное переписывание запросов (включая TOP (1)идею или использование промежуточных временных таблиц) и избегание «чёрных ящиков» с плохой стоимостью (с точки зрения QO), таких как не встроенные функции.

Перезапись CROSS APPLYas OUTER APPLYтакже может работать, поскольку в настоящее время она предотвращает некоторые ранние NULLоперации, связанные с объединением, но вы должны быть осторожны, чтобы сохранить исходную семантику запроса (например, отклонение любых расширенных строк, которые могут быть введены, без возврата оптимизатора обратно к крест применить). Однако вы должны знать, что это поведение не гарантированно останется стабильным, поэтому вам следует помнить о необходимости повторного тестирования любого такого наблюдаемого поведения при каждом исправлении или обновлении SQL Server.

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

Пол Уайт говорит, что GoFundMonica
источник
24

Похоже, это решение, основанное на затратах оптимизатором, но довольно плохое.

Если вы добавите 50000 строк в PRODUCT, оптимизатор сочтет, что сканирование - это слишком много работы, и даст вам план с тремя поисками и одним вызовом UDF.

План, который я получаю для 6655 строк в PRODUCT

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

С 50000 строками в ПРОДУКТЕ я получаю этот план вместо этого.

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

Я предполагаю, что стоимость вызова UDF сильно недооценена.

Один из обходных путей, который отлично работает в этом случае, - это изменить запрос на использование внешнего применения к UDF. Я получаю хороший план независимо от того, сколько строк в таблице PRODUCT.

select  
    S.GROUPCODE,
    H.ORDERCATEGORY
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT    
    outer apply dbo.GetGroupCode (P.FACTORY) S
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01' and
    S.GROUPCODE is not null

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

Лучший обходной путь в вашем случае - это, вероятно, получить необходимые значения во временную таблицу, а затем запросить временную таблицу с перекрестным применением к UDF. Таким образом, вы уверены, что UDF не будет выполнен больше, чем необходимо.

select  
    P.FACTORY,
    H.ORDERCATEGORY
into #T
from    
    ORDERLINE L
    join ORDERHDR H on H.ORDERID = L.ORDERID
    join PRODUCT P  on P.PRODUCT = L.PRODUCT
where   
    L.ORDERNUMBER = 'XXX/YYY-123456' and
    L.RMPHASE = '0' and
    L.ORDERLINE = '01'

select  
    S.GROUPCODE,
    T.ORDERCATEGORY
from #T as T
  cross apply dbo.GetGroupCode (T.FACTORY) S

drop table #T

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

select S.GROUPCODE,
       T.ORDERCATEGORY
from (
     select top(2147483647)
         P.FACTORY,
         H.ORDERCATEGORY
     from    
         ORDERLINE L
         join ORDERHDR H on H.ORDERID = L.ORDERID
         join PRODUCT P  on P.PRODUCT = L.PRODUCT    
     where   
         L.ORDERNUMBER = 'XXX/YYY-123456' and
         L.RMPHASE = '0' and
         L.ORDERLINE = '01'
     ) as T
  cross apply dbo.GetGroupCode (T.FACTORY) S

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

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

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

Еще одно наблюдение заключалось в том, что ваш запрос получает хороший план в SQL Server 2014 с новой оценкой мощности. Это связано с тем, что предполагаемое количество строк для каждого вызова UDF равно 100 вместо 1, как в SQL Server 2012 и ранее. Но он все равно примет решение, основанное на стоимости, между версией сканирования и версией плана поиска. Имея менее 500 (в моем случае 497) строк в PRODUCT, вы получаете сканированную версию плана даже в SQL Server 2014.

Микаэль Эрикссон
источник
2
Чем-то напоминает мне сеанс Адама Маханича в битах SQL: sqlbits.com/Sessions/Event14/…
Джеймс З,