Параметризация запросов. Ваш лучший друг? Часть 1/3.Источник: sqlcmd
Добрый день, уважаемые читатели блога sqlCMD.ru. Читая ваши вопросы и отзывы, приходящие в изрядном количестве после каждой новой публикации, автор получает богатый материал по поиску тех SQL-вопросов, которые вполне заслуживают отдельной статьи, если не цикла. Если просьба "описать тему X" повторяется регулярно, и если по ней же систематически задают вопросы слушатели SQL-курсов читаемых тем же автором, сомнений нет - "тема животрепещет", как выражается один из заезженных журналистских штампов. Одну из таких "горячих", с точки зрения автора, тем мы с вами сегодня и разберем. Ну, то есть как - разберем, но ровно наполовину. А все потому, что вопрос параметризации запросов (а речь, как вы уже догадались, пойдет именно о ней) зело обширен, многогранен и полон внутренних противоречий. Сложная в общем тема, чего уж там скрывать. А потому, для лучшего и постепенного восприятия материала будет разумно выбрать формат именно цикла статей, и в начальной стадии (то есть в рамках статьи текущей) рассмотреть "светлые стороны" указанного непростого процесса. Стороны же отрицательные, как и борьба с оными, остаются для будущих статей цикла. Итак, план нашей учебы будет таков:
Приступим. Общая идея параметризации.Как известно, все в этом мире вообще и в IT-индустрии в частности начинается с идеи. Начнем наше повествование с нее же - как вообще зародилась мысль что запросы можно параметризировать и кому/зачем это было надо? Для понимания этой самой идеи давайте смоделируем несложную табличку:
Тут, надо полагать, никаких комментариев не требуется: табличка из трех строк и трех же колонок, по первой из которых организован кластерный и уникальный индекс, а по последней не-кластерный и не-уникальный. Вопрос может вызвать разве что строка устанавливающая опцию PARAMETERIZATION на уровне базы данных, но как раз эту опцию мы, в числе прочего, будем подробно разбирать по ходу развития нашего сюжета. На текущий момент достаточно знать, что возможных значений у этой опции ровно две -SIMPLE и FORCED. Или, в переводе, параметризация простая и она же принудительная. Намного чаще (автор полагает, что никак не меньше 90% всех рабочих систем) эта опция находится в значении SIMPLE, что вообще является значением по умолчанию для нее. Так что если вы не меняли обсуждаемую опцию у системной базы model то, строго говоря, эта строка скрипта является лишней - наша тестовая база ~DB~ и без того будет в режиме простой параметризации, но для надежности все-таки оставьте ее. Вот с этой табличкой мы и будем ставить различные эксперименты первый из которых имеет вид:
А вот этот код уже заслуживает пары комментариев. Начинаем мы с того, что командой DBCC полностью очищаем кэш планов (plan cache). Для наших опытов это очень хорошо, ибо исследуемая нами параметризация запросов имеет прямое отношение к кэшированию последних, и мы хотим наблюдать за данным процессом каждый раз с "чистого листа". Несложно догадаться, что идея запустить ту же команду на промышленной системе с которой работают реальные пользователи будет "не очень", мягко говоря. Но ведь у вас (как и у автора) для "полевых" испытаний и тестов предусмотрены не менее тестовые же стенды, не так ли? Вот на этих стендах данная команда очень даже уместна. Следующие пять SELECT-ов обозначают некоторую нагрузку на тестовую таблицу T1 и, надо думать, ведут к генерации некоего числа планов, которые непременно будут "складированы" в том участке RAM компьютера, что и называется кэш планов. Несложно заметить, что у части из этих SELECT-ов значение литерала с которым сопоставляется третья колонка таблицы совпадает, а у некоторых - отличается. Предусмотрен и такой литерал (zz), что вернет нам пустой резалт-сет. Если вы полагаете, что уже уверенно владеете вопросом параметризации попробуйте предсказать - сколько из этих пяти запросов получится различных планов? Сравнить свой ответ с правильным вы сможете через пару абзацев. Наконец, завершающий SELECT обсуждаемого скрипта является центральным в исследовании параметризации в частности да и вообще всего, что касается кэширования планов. Наша "рабочая лошадка" - динамическое административное представлениеsys.dm_exec_cached_plans. Это последнее содержит ровно по одной строке для каждого плана попавшего в кэш и "кушающего" там некоторый объем RAM. Мы хотим видеть только те из этих строк, что имеют отношение к нашей тест-таблице T1, а поэтому отбрасываем все запросы не имеющие в своем тексте ссылок на первую колонку нашей таблицы. Так же мы отбрасываем план представляющий сам финальный SELECT (вы же не думаете, что выборка из sys.dm_exec_cached_plans происходит "как-то вот эдак", вообще без всяких планов?). Если вам кажется, что фильтр LIKE '%ColID%' уже отсечет все лишнее и последнее AND-условие является избыточным, то еще раз внимательно прочтите текст запроса последнего SELECT-а - а не упоминается ли и в нем тоже колонка ColID? Хорошо, со строками что вернет нам завершающий SELECT мы разобрались: одна строка - один закэшированный план имеющий какое-либо отношение к таблице T1. Теперь давайте по колонкам того же SELECT-а:
Экономя бумажно/экранное пространство, и с этой целью не показывая каждый раз в отрывках кода этот довольно многословный SELECT сообщающий нам о состоянии кэша, автор будет просто ссылаться на него как на "запрос кэша", подразумевая именно показанный и только что подробно разобранный SELECT со всеми его колонками, фильтрами по колонке ColID и т.д. Выполнение всего последнего скрипта со всеми его командами дает нам такой результат (показан лишь резалт-сет от "запроса кэша", колонки plan_handle и query_plan опущены): objtype usecounts size_in_bytes text Adhoc 1 24576 SELECT ColID, Col2, Col3 FROM T1 WHERE Col3='zz' Adhoc 3 24576 SELECT ColID, Col2, Col3 FROM T1 WHERE Col3='ab' Adhoc 1 24576 SELECT ColID, Col2, Col3 FROM T1 WHERE Col3='ef' Итак, если на вопрос поставленный чуть выше "сколько из этих пяти запросов получится планов" вы ответили "три" то были совершенно правы именно три независимых плана находятся в данный момент в кэше. То, что планы именно обособлены показывают различные значения их идентификаторов (проанализируйте опущенную в показе последнего резалт-сета колонку plan_handle). Два из трех планов были использованы по разу, один - три раза. То, что один из планов не возвращает ни одной строки на факте его кэширования не сказывается совершенно - пустой результат это результат ничем не хуже (а по многим факторам намного лучше), чем результат из миллиона строк. Тексты запросов (колонка text) всех трех планов имеют совпадение с текстом оригинальных запросов буквально буква-в-букву. Каждый из планов занимает в памяти 24.5 килобайт. Если вам кажется, что для столь примитивных запросов это довольно-таки внушительный объем - так и есть. Держать в памяти планы то еще удовольствие, как "сжигатели RAM" это просто идеальные объекты. Разумеется, планы запросов состоящие не из одной строки (как у нас), а вольготно разместившие свой код на паре экранов потребуют в разы большего пространства памяти. И, как вы уже поняли, любое изменение строкового литерала сравниваемого с колонкой Col3 будет приводить к генерации нового плана, его кэшированию и... прощайте очередные 24.5Kb "оперативки". Не складывается ли у вас неодолимого ощущения что описанный процесс "нарезки" памяти 24-х килобайтовыми кусками можно несколько оптимизировать? Что если так: кэшируем один план, без конкретного значения литерала с которым следует сопоставить третью колонку, а затем вызываем постоянно только его, указывая каждый раз нужный нам литерал? А что - это идея! Процесс замены литеральных (константных) значений в тексте исходного запроса параметрами с целью увеличения шансов соотнести его и уже существующий план исполнения, и, как следствие, избежать генерации и кэширования нового плана называется параметризацией
Параметризацию можно проводить вручную, поясняя SQL Server "вот тут у нас параметр, а вот тут - нет", а можно пустить дело на самотек и надеяться, что оптимизатор запросов сервера сам разберется с вопросом "кто тут что". Этот второй подход называют автоматической параметризацией. Последующий материал статьи рассматривает и ручной, и автоматический варианты (а их еще, по каждому направлению, больше одного под-вида), но сейчас мы быстро посмотрим автоматическую параметризацию в действии. Для этого выключим мешающий ей проявить себя индекс:
Чем "провинился" ни в чем не виноватый индекс iT1 и почему автоматическая параметризация его "невзлюбила" мы выясним в соответствующем разделе далее, пока просто примем этот факт как данность. С выключенным индексом выполним тот же самый скрипт, полностью и с самого начала. На этот раз результаты будут такими:
На первый взгляд никаких кардинальных изменений не произошло, как было три плана, так они и остались, только еще зачем-то четвертый добавили... А между тем изменения носят самый драматический характер, но для их понимания нужно обратиться к опущенной в двух последних резалт-сетах колонке query_plan. Если мы взяв для определенности план построенный для литерала abщелкнем по его плану-ссылке в первом случае (когда индекс был включен, а авто-параметризация еще не работала) то увидим вот что: То есть вполне себе обычный, полноценный execution plan. А вот что мы видим после такого же щелчка во втором случае, после отключения индекса: То есть ядро всей функциональности плана, а именно сканирование кластерного индекса - как "корова языком". Остался формальный SELECT "вываливающий" клиенту готовый резалт-сет, вот только непонятно - а кто его готовить будет? Все становится гораздо яснее, если мы учтем, что те три строки последнего резалт-сета что помечены как Adhoc не являются планами. По крайней мере, они не являются полноценными планами, это уж определенно. А являются они, на самом деле, лишь оболочками реальных планов (так называемые shell plans), или, если хотите, ссылками на план реальный. То, что такая связь между shell-планом и планом реальным существует мы убедимся в самом ближайшем будущем, а пока давайте попробуем представить, как "рассуждал" оптимизатор запросов выполняя наш экспериментальный скрипт в случае с отключенным индексом:
Думаю, вы уже начали улавливать те преимущества, что можно извлечь из запросов успешно прошедшего процедуру параметризации. Однако такие "успешные" запросы предлагают нам, помимо "пряников" явных, и еще один скрытый плюс. Давайте рассмотрим вот такие две команды, а точнее два пакета команд по одной в каждой:
Разница, как легко видеть, минимальнейшая и сводится к регистру букв ключевого слова. Как знают читатели статьи Регистрозависим ли язык T-SQL? с точки зрения "чистого синтаксиса" мы имеем полное право писать SELECT, select и даже SeLeCt - это все едино. Вот только "едино" все это с точки зрения парсера запросов. Оптимизатор же запросов имеет на сей счет отдельное мнение. Посмотрим на кэширование двух приведенных команд (а точнее их планов) при успешной и неуспешной их параметризации. Сначала включим индекс iT1
и, тем самым, сделаем параметризацию невозможной. В этом случае наш "запрос кэша" сообщает что: objtype usecounts plan_handle text Adhoc 1 0x06000F00EF1BA90340416988010000000000000000000000 select ColID, Col2, Col3 FROM T1 WHERE Col3='ab' Adhoc 1 0x06000F008D91381E4001D887010000000000000000000000 SELECT ColID, Col2, Col3 FROM T1 WHERE Col3='ab' Видите? Это два разных запроса. И для них сгенерированы два отдельных (см. колонку plan_handle), хоть и абсолютно идентичных, как подсказывает здравый смысл, плана. Теперь снова выключим индекс iT1,
дав таким образом "добро" на параметризацию тех же запросов, и повторим эксперимент: objtype usecounts plan_handle text Adhoc 1 0x06000F00EF1BA90340814A88010000000000000000000000 select ColID, Col2, Col3 FROM T1 WHERE Col3='ab' Adhoc 1 0x06000F008D91381E4061D682010000000000000000000000 SELECT ColID, Col2, Col3 FROM T1 WHERE Col3='ab' Prepared 2 0x06000F00A9D9A71140213F84010000000000000000000000 (@1 varchar(8000))SELECT ... WHERE [Col3]=@1 А вот теперь это два shell-плана и один план полноценный. Счетчик usecounts сомнений не оставляет - оба запроса есть лишь две ссылки на один и тот же план. Сами как думаете, что проще "родить": еще один shell к имеющемуся плану уже готовому к исполнению или еще один полноценный план? Однако - не впадайте в эйфорию. Упомянутая чуть выше нормализация текста запроса (а именно она и дает нам обсуждаемый в настоящий момент скрытый плюс) много лучше чем ничего, но она отнюдь не всесильна. Стоит поменять две тестовых команды на
то есть изменить регистр не ключевого слова, а объекта (таблицы T1 в данном случае) и даже включенная параметризация помочь не в силах: objtype usecounts plan_handle text Adhoc 1 0x06000F007316270340818785010000000000000000000000 SELECT ... FROM t1 WHERE Col3='ab' Prepared 1 0x06000F0063D416354041AA85010000000000000000000000 (@1 varchar(8000))SELECT ... FROM [t1] WHERE [Col3]=@1 Adhoc 1 0x06000F008D91381E40417A85010000000000000000000000 SELECT ... FROM T1 WHERE Col3='ab' Prepared 1 0x06000F00A9D9A7114061A187010000000000000000000000 (@1 varchar(8000))SELECT ... FROM [T1] WHERE [Col3]=@1 Да, вы все поняли правильно: две ссылки, но каждая для своего полноценного плана. А это все потому, что SELECT, как его не "перекручивай" селектом и останется. А вот T1 и t1, как это снова известно читателям статьи упомянутой чуть выше, могут означать как один и тот же объект, так и два разных. Стало быть, запрос успешно прошедший параметризацию предохраняет вас (ну не вас лично, а ресурсы того сервера за который вы отвечаете) от падения производительности системы за счет снятия с нее "дурацкой" нагрузки вроде "выштамповки" двух (и хорошо если только двух, а не двух сотен) абсолютно идентичных планов для двух совершенно функционально идентичных запросов. Да, предохранение это весьма ограничено по своим масштабам и подчас "лажает", но от пары-тройки вовремя не прожатых SQL-программистом SHIFT-ов все-таки убережет. Конечно, рассматривайте этот плюс как приятное, но совершенно не главное преимущество параметризации. Как указывал автор в заключении ко все той же статье Регистрозависим ли язык T-SQL? стабильность в стилистике (именно стилистике) кода по прежнему является лучшей защитой от множества напастей и от описанной только что в том числе. Итак, мы описали (причем весьма кратко, уж поверьте ) общую идею стоящую за процессом параметризации запросов и те выгоды на которые мы можем рассчитывать от ее реализации. Можно было бы к вопросам той самой реализации и переходить, тем более что вариантов тут не один, и не два и даже краткое их описание потребует от автора усидчивости. Однако, прежде давайте выясним вопрос куда как более простой: а как нам вообще можно со стопроцентной уверенностью сказать - был данный запрос параметризирован оптимизатором или нет? Если вы полагаете что это "и так понятно", то вспомните, что "дьявол - в нюансах" и обязательно загляните в следующий раздел. Установление факта параметризации запроса.Как и любая большая тема вопрос параметризации тоже полон мифов, преданий и неправильных советов. Дабы предостеречь читателя от таких вредных советов и показать ему, что "не все йогурты одинаково полезны", рассмотрим сначала способы неверные, но широко разошедшиеся по сетевой и бумажной SQL-литературе. Способ 1, ошибочный.Данный способ советует нам сделать следующее:
Иными словами мы пытаемся обнаружить у итогового SELECT-а вот что: На первый взгляд все законно - параметр, имя его, значение... Вот только сделан снимок экрана для плана запроса
в тот момент, когда индекс на таблице T1 включен. А значит никакой параметризации и быть не может, в принципе. Одним словом - мимо. Способ 2, снова ошибочный.Теперь нам советуют в том же окне свойств оператора SELECT смотреть на то из них, что имеет название Statement. Точнее смотреть следует не на факт его присутствия в окне (он там будет всегда), а на значение этого свойства. Рассматриваемый способ говорит нам о том, что если в значении этого свойства литералы заменены на параметры - параметризация уже имело место. Можно и чуть упростить этот способ: значение того же свойства выводится в заголовке панели отображающей сам план: В данном случае доказательство представляется вообще "убойным" - литерал явно заменен параметром! Не знаю удивитесь вы или нет, но и этот снимок экрана сделан для того же запроса и тех же условий включенного индекса. То есть - снова мимо, без сомнений. На ваш законный вопрос "а зачем нам так изощренно голову морочат, подсовывая свойства и операторы доказывающие то, чего не было?" автор может дать такое объяснение. Как обычно, в жизни все несколько сложнее чем это описывается даже в самых сложных и подробных статьях вроде той, что вы читаете в данную минуту. Так и тут. Текст запроса не сразу поступает на обработку процессору запросов (query processor, QP). Процессор запросов - крупный компонент SQL Server, состоящий из двух главных под-компонентов: оптимизатора запросов и исполнителя запросов.
Так вот, сначала текст запроса пред-обрабатывается иным компонентом, а именно компонентом языковой обработки и исполнения (language processing and execution, LPE). И за формальную (начальную) параметризацию запроса отвечает именно LPE. То есть это именно его обязанности найти такие "точки" запроса, где литералы могут быть по, хотя бы, формальным признакам заменены на параметры. После такой замены LPE "обращается" к QP и "говорит" последнему: вот смотри, оригинальный запрос был таким, однако имеется возможность переписать его вот эдак, с параметрами - выбирай! После чего QP возможно (только возможно!) утвердит параметризированную версию запроса, если посчитает такую замену безопасной (подробнее об этом в дальнейших разделах статьи). А возможно и скажет параметризации "нет" и примется строить план для оригинального текста, с литералами. И "фишка" в том, что оба просмотренных нами совета смотрят на деятельность компонента LPE! Который честно говорит: запрос имеет потенциал к параметризации. Да иметь-то он его имеет, а будет ли тот потенциал реализован - вот наше главное беспокойство. И как раз о реализации потенциала оба способа нам не говорят ни-че-го. Способ 3, верный.В этом способе нам снова нужен актуальный план, а в этом плане нам нужен тот его оператор для которого литерал, чью возможную замену на параметр мы хотим исследовать, является предикатом. В случае нашего тест-запроса выбор невелик, у нас ровно один оператор выполняющий реальную работу - Clustered Index Scan. Обнаружив таковой нужно "завесить" над ним мышиный курсор, а в появившейся подсказке прочесть раздел Predicate. Вариант того же самого: открыть окно свойств того же оператора и посмотреть значение одноименного свойства. Вот как это выглядит в нашем случае, слева подсказка когда индекс включен (а параметризация наоборот), справа - когда он выключен: Вот теперь все "честно": справа параметризация была, слева ничего подобного не было. Способ 4, верный и более удобный.Мы уже знаем из вводной, "идейной" части, что при случившейся параметризации (более технически корректно - случившейся авто-параметризации; у параметризации ручной свои правила) мы получаем не один, а два плана: "недо-план" и "нормальный" план. Однако вне зависимости от "нормальности" каждый объект кэша должен иметь свой идентификатор и в качестве такового выступает его ID, или "хэндл". Помните про колонку plan_handle нашего "запроса кэша"? Это он и есть. Такой хэндл, повторю, будет у абсолютно любого плана в кэше без исключений. НО! Наш "недо-план", очевидно и судя по всему, должен иметь в своем тексте ссылку на хэндл нормального, параметризированного плана, логично? Иначе как и кому этот shell-план реальную работу "отфутболит"? А если такой ссылки нет? А вот тогда никакой это не shell-план, а план полноценный и параметризации не предполагающий. Одним словом:
Опять же, опробуем совет на практике. Если открыть из резалт-сета "запроса кэша" план для инструкции
в тот момент когда индекс включен, то мы увидим самый обычный план, без всякого намека на XML-атрибуты ParameterizedPlanHandle. Если сделать тоже самое для отключенного индекса, то мы увидим: ... <Statements> <StmtSimple StatementText="SELECT ColID, Col2, Col3 FROM T1 WHERE Col3='ab' ... ParameterizedPlanHandle="0x06000F00A9D9A71140E1A385010000000000000000000000" ... /> </Statements> ... Что характерно - приведен практически весь план, целиком! Убраны пара строк с родительскими тегами и пяток атрибутов. Да, shell-планы они такие... Способ 5, верный и еще более удобный.Мы можем усовершенствовать наш "запрос кэша" таким образом, что бы тут же подтверждать/отвергать факт параметризации для каждого запроса в кэше, без поиска по XML-тексту. Фактически, это ничто иное, как автоматизация предыдущего способа и программное "вытягивание" значений атрибута ParameterizedPlanHandle. Вот наша усовершенствованная версия запроса:
Как видите, к имеющимся и подробно нами рассмотренными ранее шести колонкам добавляются еще две:
Давайте вновь запустим наш тестовый скрипт но на этот раз с усовершенствованным "запросом кэша". В случае включенного индекса/выключенной параметризации мы наблюдаем (избранные колонки резалт-сета): text parameterized_plan_handle parameterized_text SELECT...WHERE Col3='zz' NULL (@1 varchar(8000))SELECT...WHERE [Col3]=@1 SELECT...WHERE Col3='ab' NULL (@1 varchar(8000))SELECT...WHERE [Col3]=@1 SELECT...WHERE Col3='ef' NULL (@1 varchar(8000))SELECT...WHERE [Col3]=@1 Обратите внимание, что наличие некоего текста в колонке parameterized_text не говорит нам ни о чем (разве что о потенциале данного запроса к параметризации), значение NULL в колонке parameterized_plan_handle говорит нам обо всем. Помните о "разделении работ" между LPE и QP! Включив же параметризацию через отключение индекса мы увидим: text parameterized_plan_handle parameterized_text SELECT...WHERE Col3='zz' 0x06000F00A9D9A7114041D983010000000000000000000000 (@1 varchar(8000))SELECT...WHERE [Col3]=@1 SELECT...WHERE Col3='ab' 0x06000F00A9D9A7114041D983010000000000000000000000 (@1 varchar(8000))SELECT...WHERE [Col3]=@1 SELECT...WHERE Col3='ef' 0x06000F00A9D9A7114041D983010000000000000000000000 (@1 varchar(8000))SELECT...WHERE [Col3]=@1 (@1 varchar(8000))SELECT...WHERE [Col3]=@1 NULL NULL Вполне логично, что значение колонки parameterized_text для shell-планов полностью совпадает с колонкой text соответствующего им "полноценного" плана. Небезынтересно будет взглянуть и на другой набор избранных колонок последнего резалт-сета: plan_handle text parameterized_plan_handle 0x06000F007C6BB70840019C85010000000000000000000000 SELECT...WHERE Col3='zz' 0x06000F00A9D9A7114041D983010000000000000000000000 0x06000F00B83F933140E19D85010000000000000000000000 SELECT...WHERE Col3='ab' 0x06000F00A9D9A7114041D983010000000000000000000000 0x06000F00D78B910340215584010000000000000000000000 SELECT...WHERE Col3='ef' 0x06000F00A9D9A7114041D983010000000000000000000000 0x06000F00A9D9A7114041D983010000000000000000000000 (@1 varchar(8000))... NULL Как видите - все четыре плана являются самостоятельными объектами кэша, у них свои ID/хэндлы (колонка plan_handle). Однако все три shell-плана дружно посылают нас к хэндлу единственного в данной четверке полноценного плана (колонка parameterized_plan_handle). И лишь теперь, вооруженные четкими критериями подтверждения успешности процесса параметризации запросов, мы можем двинутся по тернистому и разветвленному пути реализации этого самого процесса. |