Массивы и Списки в SQL ServerИсточник: sql Вячеслава Брылёва, aka Glory.
Часто в форумах можно видеть вопросы вида Как я могу использовать массивы в SQL сервере ? или Почему не работает запрос Это достаточно длинная статья, поэтому если Вы хотите просто узнать, как оперировать списком значений, разделенных запятыми, то выберите одну из 3-х ссылок, в зависимости от версии SQL сервера, которые вы используете: SQL2000, SQL7, SQL6.5. Если предложенные там решения не подходят Вам, тогда вернитесь назаад и начните читать сначала. :-) Если Вы впали в прострацию, увидев размер этой статьи, то Вы с облегчением узнаете, что это одна из тех статей, которые не обязательно читать целиком и по порядку. Если Вы относите себя к "чистым" SQL программистам, который хочет знать ответ на вопрос "Каким образом я могу ....?", то Вы можете найти, что раздел, посвященный производительности, содержит слишком много подробностей. С другой стороны истинные "зубры" SQL, которым интересны показатели производительности, могут найти описания методов немного утомительными и могут предпочесть просмотреть их вскользь. Имееются несколько значений для ключевого поля, определяющие некоторое количество записей таблицы. И необходимо выбрать эти записи. Если запрос составляется в клиентском приложении, то программный код может выглядеть приблизительно так:
где List - это переменная, которая содержит список string значений, разделенных запятыми, например, "9, 12, 27, 39". Однако, при использовании хранимых процедур мы видим, что нет очевидного метода для реализации такого запроса. Некоторые пытаются использовать следующую процедуру:
которую потом пытаются вызвать таким орбразом: EXEC get_product_names '9, 12, 27, 37' но получают следующее сообщение об ошибке:
Это происходит потому, что в этом случае мы уже не составляем SQL запрос динамически, и @ids есть только одно из значений в предложении IN. IN предложение также может интерпретироваться как:
Другие, сталкиваясь с этой проблемой, говорят себе: Вот если бы в T-SQL были бы массивы как в любом другом нормальном языке, то не было бы и проблемы . Но в T-SQL есть массивы. Называются они таблицами и для решения всех задач они являются более естественным выбором, чем массивы. Если все, что Вам нужно, это простой массив с единичными integer значениями в каждой ячейке, то такой массив легко эмулируется с помощью таблицы. Однако, Вы не можете передавать таблицу как параметр в хранимую процедуру. Если в одной хранимой процедуре вызыввется другая хранимая процедура, то проблемы не существует, т.к. можно для обмена данными использовать общую таблицу. Но такой способ невозможен, когда хранимая процедура запускается из клиентского приложения. НО: клиентское приложение может передать в качестве входного параметра строку, которую в SQL Вы можете "распаковать" в таблицу. Большинство методов, описанных в данной статье, используют как раз такой метод. Существует несколько возможных решений задачи, которые могут быть применены в большинстве случаев. В статье предствалены только известные мне методы решения. Вот список этих методов, разделенный на две группы: Хорошие методы:
Методы, которые лучше не использовать:
Вы также можете ознакомиться с кратким обзором методов решения от Anith Sen по адресу http://www.bizdatasolutions.com/tsql/sqlarrays.asp. Хотя его оценки некоторых методов не всегда совпадают с моими. 4. Основные соображения по интерфейсу Прежде чем приступить к рассмотрению методов решения, я хотел бы осветить некотрые общие вопросы. Некоторым эти вопросы могут показаться тривиальными, но я хотел бы донести до Вас очень важный вопрос о производительности, так что потерпите. Большинство представленных в статье методов могут быть оформлены в виде функций, возвращающих таблицу(table-valued functions) или в виде хранимых процедур. Это очень хорошо, т.к. это позволяет Вам выбрать метод решения, написать одну-две функции и затем использовать их в любом месте. Вот, например, таким образом:
В SQL7 Вы правда должны будете использовать вместо пользовательской функцииo хранимую процедуру. Вы можете ознакомиться с подбробностями такого использования здесь Далее, как бы мог выглядеть интерфейс функции charlist_to_table? Входной параметер мог бы быть безразмерного типа, предпочтительно text или ntext, чтобы функция могла оперировать входными данными любого размера. Но некоторые методы решения используют функции T-SQL, которые не поддерживают типы данных text/ntext. В этом случае Вы должны использовать типы данных varchar(8000) или nvarchar(4000). Вы также можете добавить параметер, который будет определять разделитель в переданной строке отличный от запятой, или любые другие необходимые Вам параметры. Вы можете встретить примеры использования таких дополнительных параметров в данной статье. Как бы могла выглядеть таблица с результатами работы функции ? Очевидно, что она должна включать поле, которе будет содержать значения из переданного списка элементов (мы вернемся к этому чуть ниже). Иногда Вам также может понадобиться поле, которое будет содержать порядковый номер элемента в списке. Такое поле легко возможно реализовать в одних методах и очень трудно реализовать в других. Итак, имеется входной список элементов, но какого типа данных эти элементы? Вы можете написать функцию, в которой учитывать все возможные типы данных SQL Server. В данной же статье обсуждаются только списки integer и string элементов, потому что по моим предположениям эти типы данных используются в 99% всех случаев. Если Вы используете список integer элементов, то Вы должны будете преобразовать их каким-то образом из типа string в тип integer, потому что входные данные являются текстовыми. Как именно Вы напишите функцию, возвращающую таблицу integer значений есть дело Вашего вкуса. Возможно Вы предпочтете такой способ:
Если Ваша функция возвращает список string элементов, то Вам может показаться, что нет никаких проблем с конвертацией типов. Но берегитесь! В SQL Server есть две группы текстовых типов днных: 8-битные типы данных char/varchar/text и 16-битные типы данных nchar/nvarchar/ntext для Unicode. Так какой из этих типов данных должна возвращать ваша функция ? Вам может показаться, что использование типа данных nvarchar(4000) для поля таблицы есть наилучшее решение, потому что Вы получите правильные результаты как для 8-битных так и для 16-битных типов данных. Но не торопитесь! Правильный ответ такой: если Вы связываете таблицы по полю типа char/varchar, то Ваша функция должна также возвращать тип данных varchar, а если по полю nchar/nvarchar, то, соответственно, функция должна возвращать nvarchar. И причина в следующем. В данном запросе:
поле tbl.varcharcol будет неявно преобразовано к типу nvarchar и это преобразование предотвратит использование SQL Server-ом любого индекса по полю tbl.varcharcol . А это в свою очередь может очень сильно сказать на производительности. Есть два способа решить эту проблему. Первый - это написать 2 функции: одна будет возвращать тип данных varchar(8000), другая - nvarchar(4000). Если все, что Вам нужно, это готовое решение, чтобы продолжить создание своего программного кода, то данный метод как раз для Вас. Хотя есть более быстрые методы, чем данный, но для практического подтверждения этого вам понадобятся входные данные действительно очень большого размера. Так что главный козырь этого метода - его простота. Всего делов-то - загружаете предложенные примеры функций и вперед и выше. А более быстрые методы требуют для работы наличия вспомогательной таблицы с правильными данными. Это конечно не такая чтобы уж большая проблема, но все же требует наличия еще одного объекта. Ко всему прочему программный код очень легок для понимания, что немаловажно, если у Вас есть опыт работы на C или Visual Basic. Не последнюю роль играет и то, что Вы можете адаптировать предложенный код для использования со входной строкой произвольного формата. Вот пример пользовательской функции в in SQL 2000 для работы со списком integer элементов.
Оба-на, что мы видим? Это ведь не список значений разделенных запятой ? Я заметил, что для списка integer значений запятая как разделитель в действительности не играет никакой роли. Поэтому при написании функции я выбрал вместо запятой пробел в качестве разделителя. Вот код функции iter_intlist_to_table:
Возвращаемая данной функцией таблица состоит их двух полей. number - это значение элемента списка, а listpos - это порядковый номер элемента в списке. Т.к. функция оперирует только числовыми элементами, то Вас может удивить то, что в ней используются символьные типы данных в кодировке Unicode, т.е. ntext и nvarchar. Однако я выяснил, что при использовании данных такого типа можно увеличить производительность функции на 10% по сравнению с использованием 8-битных символьных типов данных. Я точно не знаю почему именно, но предполагаю, что это связано с тем, что функция charindex на внутреннем уровне оперирует данными в Unicode. Поэтому для конвертации 8-битных символных типов данных требуется дополнителное время. Собственно метод итераций представляет из себя 2-хуровневый цикл. Это вызвано тем, что входной парметер у нас типа ntext, а значит он безразмерный. Но не все функции T-SQL принимают text/ntext в качестве парметров (а те функции, которые принимают, все равно не оперируют данными с длиной большей чем у varchar/nvarchar.). Поэтому я разбиваю входную строку на порции по 4000 символов и затем перехожу к основному циклу, в котором используется функция charindex для поиска символа пробела, который был выбран в качестве разделителя. Если Вы предпочитаете в качестве разделителя видеть запятую, то Вы легко можете изменить функцию. Когда внутренний цикл завершается, то в текущей порции входной строки могут еще оставаться какие-то символы, которые нужно присоединить к началу следующей порции. И в самом конце, когда обработана последняя порция, выбирается последний элемент списка. Вы могли заметить, что я использую как функцию datalength так и len. Эти две функции обманчиво похожи друг на друга, но использовать их надо осторожно. Обе они возвращают длину строки, но datalength учитывает хвостовые пробелы, а len нет. Одноко более важно то, что только функция datalength поддерживает типы данных text/ntext. И еще: функция datalength подсчитывает длину строки в байтах, а len - в символах. Поэтому я и делю результат функции datalength на 2, а результат функции len нет. Функция рассчитана на то, что входной параметер @list содержит только number элементы. В противном случае Вы получите сообщение об ошибке конвертации и выполнение бэтча прервется. Если Вы формируете список в клиентском приложении, то Вы должны убедиться втом, что передаете правильные данные. Но такая проверка не является слишком уж трудной задачей.Но что делать, если Вы хотите добавить проверку входных данных? Например, если Вы как DBA не очень то доверяете разработчикам клиентского приложения? Вы можете добавить в функцию проверку является ли @str в действительности числом. Но в случае ошибки Вы не сможете использовать RAISERROR, т.к. это запрещено в пользовательских функциях. Самое лучшее, что Вы можете сделать, это пропустить ошибочный элемент списка. Или добавить NULL в качестве его значения (в этом случае не забудьте разрешить использование NULL в возвращаемой таблице). Если же Вы хотите выдавать сообщение об ошибке, то Вам придется вместо пользовательской функции использовать хранимую процедуру. Вы можете найти пример такой процедуры здесь SQL7. Вот простая функция, которая возвращает таблицу со string элементами списка.
А вот пример использования этой функции:
Эта функция очень похожа на функцию iter_intlist_to_table. Есть только одно различие в интерфейсе - Вы можете задать разделитель элементов в списке. Для разделителя существует значение по-умолчанию, но даже если Вам как раз и нужен такой разделитель, Вы все равно должны использовать DEFAULT при вызове функции. Потому что в T-SQL при вызове пользовательской функции должны быть явно указаны все параметры. В независимости от того, какой разделитель Вы используете, в результатах функции ведущие и хвостовые пробелы будут удалены. Возвращаемая данной функцией таблица состоит их трех полей: listpos , str и nstr . Два последних поля содержат значение одного и того же элемента списка, первое в виде varchar, а второе - nvarchar. При объединении с другими таблицами Вы можете использовать то поле, которое лучше всего подходит Вам в данный момент. Например, для объединения по полю Northwind .. Customers . CustomerID , тип которого nchar(10), в примере было выбрано поле nstr . Не используйте поле nstr для объединения с другой таблицей по полю типа varchar, потому что тем самым вы очень сильно снизить производительность запроса! (Этот вопрос поднимался здесь Основные соображения по интерфейсу.) Вы можете возразить, что сам по себе дополнительный столбец в результатах также влияет на производительность в сторону ее ухудшения. Но при тестировании производителности я не смог обнаружить такого факта. Одно достоинств данного метода состоит в возможности легко его расширить. Предположим, что входные данные выглядят таким образом: "Yes, I want to", "Charlie", "Give it to me" т.е. элементы списка заключены в кавычки и разделитель может также являться частью элемента. Приведенная выше функция не воспринимает подобный формат, но можно легко переписать ее и для работы с таким форматом входных данных. 6. Метод с использованием вспомогательной таблицы Самым быстрым методом извлечения элементов из спсика с разделителем является метод с использованием вспомогательной таблицы с числами. Это очень простая таблица, состоящая из одно поля типа integer column, которая содержит значения 1, 2, 3, ... и тд Вот наиболее легкий способ получить такую таблицу:
От переводчика Т.к. база pubs имеется не на каждом сервере, то вместо таблицы authors можно использовать sysobjects
В первоначальном варианте функции для демонстрации алгоритма работы базовой функции ограничим длину входных данных 7998-ю символами. Я позаимстововал эту функцию с сайта Anith Sen-а.
В отличии от метода итераций, который выглядит более громоздким, но и более промолинейным, предлагаемая функция гораздо компактнее, но не все смогут сразу же ухватить суть этого SQL запроса. (Я столкнулся с такими же трудностями.) Первое, что бросается в глаза, это появление в тексте запроса выражения ',' + @param + ',' ни больше ни меньше как 4 раза. Добавляя в начало и конец входной строки разделитель, получаем то, что первый и последний элементы списка ничем не отличаются от остальных элементов. Далее, рассмотрим условие WHERE нашего запроса. Выражение:
substring(',' + @param + ',', Number, 1) = ',' будет иметь значение TRUE для всех позиций строки, в которых содержится разделитель. А выражение: Number <= len(',' + @param + ',') - 1 просто определяет максимальное используемое нами в запросе число. Рассмотрим теперь формируемое нашим запросом выражение Value . Мы извлекаем из входной строки с помощью функции substring подстроку, начиная со следующего после разделителя символа. Длину извлекаемой подстроки мы определяем из результата функции charindex, с помощью которой мы ищем позицию следующего разделителя. Третий параметер функции charindex мы используем для указания позиции, с которой мы наичнаем поиск этого следующего разделителя. Если следующий разделитель найден, то мы вычитаем из номера его позиции номер позиции текущего разделителя и еще 1 (т.к. сам разделитель в результатах нам не нужен) и получаем длину текущего элемента списка. Вот пример использования этой функции:
Заметьте, что т.к. данная функция не удаляет ведущие и хвостовые пробелы элементов списка, то входная строка не должна и содержать таких пробелов. Данная функция является inline функцией в отличии от функции метода итераций. А inline функция по сути есть макро, поэтому оптимизатор во время построения плана выполнения просто включает текст функции в сам запрос, что делает возможным общую оптимизацию. Я правда не верю в то, что для такой специфической функции можно добиться каких-то преимуществ, сделав ее inline функцией. Функция сама по себе достоточна сложна, и я не смог обнаружить каких-либо значительных преимуществ ее inline варианта при 6.2. Входные данные неограниченного размера Функция inline_split_me ограничивает длину входных данных 7998-ю символами (или 3998-ю для nvarchar). В таком варианте функции невозможно использовать входные данные типа text или ntext, потому что невозможно использовать параметер text/ntext в выражении ',' + @param + ','. Или, что более важно, функция charindex не может оперировать данными text/ntext, длина которых превышает максимально возможную длину для varchar/nvarchar. Однако, это не есть непреодолимое препятствие. Ведь в функции метода итераций мы смогли решить эту проблему разбивая входные данные на порции. Вот функция, в которой использован такой же подход:
Сначала мы разобъем входные данные на порции и поместим их в таблицу-переменную @slices. В процессе такого разбиения мы должны проверять, что в текущей порции разделитель является последним символом. Для этого используем маленький фокус: для поиска разделителя передадим в функцию charindex результат функции reverse над текущей порцией. В итоге мы будем совершенно уверены в том, что все записи в таблице @slices начинаются и заканчиваются раделителем. Вы можете заметить, что если длина входных данных не будет превышать предела для типа nvarchar, то цикл по разбиению на порции не выполнится ни разу, т.к. мы сразу же добавим входные данные в таблицу @slices. После того, как таблица @slices заполнена, мы можем выполнить основное преобразование. Замечу, что для этого нам не нужен цикл по записям таблицы @slices, мы просто связываем ее непосредственно с таблицей Numbers . Использование derived таблицы позволяет нам не повторять сложные вычисления функций substring и charindex, а также дает возможность удалить ведущие и хвостовые пробелы элементов списка. (derived таблица - это таблица, создаваемая непосредственно в ходе выполнения всего запроса. Такие таблицы очень полезны в сложных запросах. Псевдоним таблицы AS x не имеет никакого особого значения, но по правилам SQL синтаксиса любая derived таблица должна иметь псевдоним.) Также как и iter_charlist_to_table, данная функция возвращает таблицу состоящую из 2-х столбцов - varchar и nvarchar.Однако в данном случае тестирование показало что при использовании только поля nvarchar производительность увеличивается на 3-5%. Но т.к. время выполнение измеряется в миллисекундах, то такая разница выглядит не очень существенной. Правда при сравнении производительности методов я использовал вариант функции, возвращающий только поле nvarchar. В таблице с результатами нет поля listpos с номером элемента в списке. В данном методе получить такой номер довольно таки сложно. Один способ - это добавить в таблтцу @slices поле sliceno и поле IDENTITY в таблицу результатов. Тогда при добавлении в конечную таблицу мы можем упорядочить результ по sliceno и Number . Однако, нет полной гаратии того, что значения в поле IDENTITY будут соответсвовать порядку, заданному в ORDER BY. Более надежный способ - это связать таблицу Numbers с собой же, как показано в примере на сайте Anith Sen-а. Теперь Вы наверное ожидаете увидеть вариант функции для работы со списком number элементов, но такого варианта в этой статье нет. Вы можете сами написать такую функцию, используя convert в нужном месте. Но можете поступить и следующим образом:
6.3. Список элементов как поле таблицы До сих пор в статье подразумевалось, что входной параметер представляет собой переданные из клиентского приложения текстовые данные. Но иногда такой список с разделителями может содержаться в поле(полях) таблицы. Например в таблице:
поля sizes и colours могут содержать данные о размерах и цветах курток в виде списка элементов разделенных запятыми. Такая схема данных правда противоречит первой нормальной форме, что в большинстве случаев является результатом ошибок при проектировании. Но, оставляя эту тему в стороне, допустим, что нам нужно извлечить эти списки в отдельные таблицы. Вы, конечно, можете использовать любую из рассмотренных уже функций, но в любом случае Вы должны будете обработать каждую запись исходной таблицы в цикле, потому что невозможно передать поле таблицы в пользовательскую функцию в виде параметра. Возможно Вы уже знаете, что построчная обработка таблицы может быть значительно медленнее чем обработка таблицы как набора. Поэтому идея соединить исходную таблицу непосредственно с таблицей Numbers выглядит более лучшим решением. Я не привожу примера такой обработки непосредственно здесь, но Вы уже видели такой способ в функции duo_text_split_me, где мы применили его к таблице @slices. 7. Массив с элементами фиксированного размера Этот метод, предложенный Steve Kass-ом (SQL Server MVP), основан на идеи, описанной Ken Henderson-ом в его книге The Guru's Guide to Transact-SQL . Основная идея метода состоит в том, что массив определяется как список элементов фиксированного размера, а не как список элементов разделенных запятыми. Преимущество такого массива заключается в том, что разделитель отсутствует во входных данных. Но главное преимущество - это производительность. Данный метод является самым быстрым, из всех представленных в данной статье (но посмотрите приведенные далее в статье обзор потенциальных проблем). Вот пример использования данного метода непосредственно для таблицы:
Каждый элемент "массива" имеет одинаквую длину, которая задается параметром @itemlen. Для извлечения конкретного элемента мы используем функцию substring. Использованная в запросе таблица Numbers аналогична таблице, которую мы использовали в методе со вспомогательной таблицей . Вот функция извлекающая элементы из строки.
Задача выражения в последней строке - разрешить последнему элементу массива быть меньшего размера, чем остальные элементы, в случае, когда хвостовые пробелы были удалены. Вот пример использования функции fixstring_single для списка string элементов:
Данная функция имеет входной параметер типа text и возвращает таблицу с полем типа varchar. Таким образом для Unicode данных Вам нужна функция-близнец, которая будет иметь входной параметер типа ntext и возвращать таблицу с полем типа nvarchar. В других методов, описанных в этой статье, я предлагал возвращать из одной функции одновременно как varchar поле так и nvarchar, но в данной функции такой подход череват большими перегрузками(потому он такой и быстрый). Снова предупрежу Вас - не используйте Unicode функции при соединении по полям типа char/varchar. Это может губительно сказаться на производительности. См. Основные соображения по интерфейсу. Таблица результатов имеет также поле listpos , которое содержит порядковый номер элемента в массиве. Очень удобно то, что это поле есть число из таблицы Numbers . Функция fixstring_single может извлечь из входной строки столько элементов, сколько чисел содержится в таблице Numbers . В данной статье используется таблица с 8000 номеров, что кажется вполне достаточным для большинства клиентских приложений. Если же вы хотите, чтобы функция оперировала большим числом, то просто увелить число номеров в таблице Numbers . Если же Вы хотите, чтобы функция оперировала входными данными любой длины, то Вы можете написать multi-step функцию, которая будет разбивать входную строку на порции. Есть правда другой "финт", который использует Steve Kass. Функция fixstring_multi использует самосоединение таблицы Numbers , превращая таким образом 8000 записей в 64 миллиона:
Эта функция более сложна, чем fixstring_single. Оставляю читателю в качестве домашнего задания возможность разобраться самому, что же происходит в этой функции. Замечу только то, что CROSS JOIN избавляет нас от тяжких трудов по заполнению таблицы Numbers . 7.1. Массив элементов фиксированной длины и Метод итераций Т.к. для метода итераций в этом случае изменяется лишь формат входных данных, а не сам алгоритм, то можно без проблем использовать метод итераций. Если метод со вспомогательной таблицей засталяет Вас нервничать, то тогда вот Вам выход. (Например, если ваша вспомогательная таблица не содержит необходимого числа записей, то Вы получите неправильные результаты.) Однако, выигрыш в производительности метода итераций при использовании входной строки с элементами фиксированной длины и при использовании разделителя не такой уж выдающийся. И он все равно хуже чем у метода со вспомогательной таблицей и входным списком с разделителем. Я не привожу здесь варианта функции для метода итераций с использованием списка элементов фиксированной длины, но в результатах тестирования вы можете увидеть ее оценку. 7.2. Возможные проблемы с производительностью и форматом данных Если Вы взгляните на результаты тестов, то заметите этот метод опередил все другие. Но существует несколько потенциальных проблем, о которых Вы должны всегда помнить, особенно про самую последнюю. Потенциальная проблема номер 1: Строка с элементами фиксированной длины наиболее чувствителена к формату. Один лишний символ в произвольном месте строки может привести к тому, что часть строки справа от этого символа будет "поломана". Если Вы формируете строку в клиентском приложение, то эта проблема не выглядит серьезной, т.к. решается один только раз. Но, однако, если смотреть шире, то мы переходим к Потенциальная проблема номер 2: Стоимость программирования составления строки с элементами фиксированной длины больше чем строки с разделителем. Я не учитывал этот фактор при тестировании производительности, т.к. это сделало бы сам процесс тестирования более сложным. Однако, учитывайте и эти 2 фактора:
Таким образом, даже если мы что-то теряем из-за дополнительных действий на стороне клиента, то эти потери нивелируются выигрышем на стороне сервера. Потенциальная проблема номер 3: Длина передаваемой из клиентского приложения строки больше в случае использования списка с элементами фиксированной длины. Т.е. имеет место больший сетевой траффик. Вот это действительно может быть проблемой для низкоскоростных соединений или перегруженных сетей. Сама по себе проблема проблема не выглядит такой уж серьезной, однако, рассмотрение объема передаваемой информации приводит нас к 4-ая и самая важная потенциальная проблема: Важное значение имеет то, как Вы осуществялете вызов процедуры. Я в своих тестах использовал RPC (remote procedure call) и рекомендую Вам этот способ вызова процедур из клиентского приложения. (Например, если в ADO Вы используете adCmdStoredProcedure для свойства CommandType объекта Command object, то Вы используете RPC.) Другой способ вызвать хранимую процедуру - это пакетное выполнение команд с помощью EXEC, т.е. так, как Вы делеает это в Query Analyzer. Я выяснил, что начинаная с некоторой длины входных данных этот метод становится несколько медленнее чем использование списка с разделителем и вспомогательной таблицей(см. функцию duo_text_split_me). Фактически при вызове через RPC альтернативная версия функции fixstring_multi оказалась несколько быстрее, чем приведенная чуть выше функция. Но при использовании пакетного выполнения команд, время отклика было несколько секунд, что очень далеко от приемлимого. Почему так происходит - я не знаю. Но размер входной строки определенно играет какую-то роль. Я не проверял точно, при какой длине входных данных эффект имеет место быть, но по приблизительным данным где-то в районе 8000, т.е. в районе максимально размера типа данных varchar. В последние годы на роль стандарта для обмена данными был выдвинут XML. Также как и HTML, XML возник на основе SGML стандарта, поэтому чисто внешне XML и HTML очень похожи. Но есть очень важные различия. В отличии от HTML, XML чувствителен в регистру и одна единственная ошибка в XML документе делает его полностью неправильным. Главное предназначение XML не в отображении, но передаче структурированных данных.В Microsoft SQL Server 2000 была добавлена широкая поддержка XML, из которой нас больше всего интересует функция OPENXML, которая может извлекать данные из XML строки и возвращать их в виде таблицы. Это дает нам возможность представить список значений в виде XML строки и затем в T-SQL извлечеть эти значения с помощью OPENXML. Но не будем столь прямолинейны - это совершенно неправильный способ. И дело не производительности(по-настоящему медленные методы будут показаны ниже), она как раз вполне приемлима для большинства случаев. Просто дело в том, что XML слишком сложен для решения поставленной задачи. Кроме, конечно, случая если у Вас уже имеется готовый XML документ. Но строить XML строку только для моделирования массива - это по-моему самоубийство. Но если Вам нужно передать на SQL Server массив структурированных данных (может быть даже многоуровневый), чтобы добавить эти данные в одну или несколько таблиц, то XML в этом случае действительно выход. Но давайте же все-таки рассмотрим случай с простым списком. Вот пример того, как могла бы выглядеть функция get_product_names с применением XML:
Здесь важно отметить то, что при вызове sp_xml_prepraredocument SQL Server создает внутреннее представление переданной XML строки. Переменная @idoc есть указатель на это представление, который мы используем при выполнении OPENXML, которая представляет из себя табличную функцию (хотя в Books Online используется другое определение - rowset provider ). Перед выходом из нашей процедуры мы вызываем sp_xml_removedocument для того, чтобы освободить память, выделенную для XML документа. Если этого не делать, то в результате утечки памяти в один прекрасный момент запуск процедуры sp_xml_preparedocument закончится ошибкой. (Между прочим, т.к. хорошим тоном является проверка возвращаемого процедурой статуса завершения, то не пытайтесь проверять таким образом sp_xml_removedocument. Она все время возвращает 1. Это просто баг этой процедуры. Проверяйте лучше @@error.) При составлении XML строки будьте внимательны, т.к. такие символы как <, & and " в XML являются метасимволами, т.е. Вы должны кодировать их приблизительно также как и в HTML. Тоже самое касается и не-ASCII данных в случае, если вы не используете Unicode. Помните также, что XML очень требователен к наличию пары для каждого тэга. Поэтому Вам для составления XML строки лучше воспользоваться какой-нибудь библиотечной функцией. Это в принципе не моя область, но кажется MS XML предлагает какие-то методы для этого, например, класс XmlTextWriter в .Net Framework. Вот где XML действительно показывает все свою мощь, так это в случае когда Вам нужно добавить в SQL Server большой набор данных, например, заказы и их содержание. До появления поддержки XML в SQL Server наилучшим вариантом был запуск хранимой процедуры для каждой добавляемой записи. (Это всегда была операция bulk-copy, но все же это было неудобное решение.) С XML же Вы можете составить XML документ, содержащий все данные, и написать хранимую процедуру, вызывающую один раз sp_xml_prepredocument и затем дважды OPENXML - первый раз для добавления заказов, второй раз для добавления содержания заказов. Замена тысяч вызовов через сеть хранимых процедур на один - это действительно большой выигрыш в производительности. По причине того, что статья и так получается довольно длинной, я не привожу здесь пример функции для такого рода задачи. Лучше предоставлю возможность решить эту задачу Вам. Если Вы совершенно не знакомы с XML и тем более с использованием XML в SQL Server, то Вы можете прочитать пару книжек на эту тему. Или поискать кой-какую полезную информацию на сайте SQL Server MVP Bryant Likes http://www.sqlxml.org/. Примечение: те, кто знает XML, увидели, что в предложенном выше примере использовался attribute-centred XML. При составлении XML строки можно также использовать element-centred XML или оба сразу. Я не привожу примера для element-centred XML, т.к. разница между ними лишь в разборе элементов списка, но никак не в производительности. Мне попадался на глаза способ решения нашей задачи, в котором передаваемая в процедуру строка с разделителем с помощью функции replace преобразовывалась в XML строку для того, чтобы дальше использоваться в OPENXML. По-моему, это очень плохая идея. И вот почему:
Для списка number элементов этот метод из-за своей простоты может казаться обманчиво хорошим:
Этот пример очень похож на тот клиентский код, котрый был дан в начале статьи. Фактически, этот метод всего лишь вариант формирования SQL команды на стороне клиента и поэтому он имеет такие же недостатки, которые мы сейчас и рассмотрим. Сначала рассмотрим использование данного метода для списка string элементов и увидим, что в этом случае он уже не кажется таким привлекательным:
Вложенные кавычки делают вызов процедуры на основе этого метода сложноватым. Ну так каковы же недостатки этого метода?
Более подробно о динамическом SQL можно прочитать в моей статье The Curse and Blessings of Dynamic SQL. Часто, когда люди в форумах спрашивают о методах работы со списком с разделителем, то динамический SQL предлагается в качестве решения. Однако лично меня такое топорное решение раздражает, особенно когда существуют другие красивые и простые методы по превращению списка в таблицу. Поэтому если кто-то советует Вам использовать этот метод, не следуйте этому совету. А если Вы сами имеете привычку давать такие советы, то, пожалуйста, прекратите это делать. Есть одно исключение: в версии SQL Server 6.5 все другие методы могут оперировать только списком типа varchar(255), поэтому динамический SQL в этой версии есть возможно единственное жизнеспособное решение. 10. Фокус с использованием UNION SQL Server MVP Steve Kass предложил такой метод:
Идея заключатеся в преобразовании списка в запрос SELECT с помощью оператора UNION ALL. (UNION ALL используется потому, что в отличии от UNION он не удаляет повторяющиеся значения.) Затем мы используем динамический SQL для выполнения этого запроса и добавления данных во временную таблицу, имя которой задается входным параметром. Т.к. в динамическом SQL используется только временная таблица, то проблемы с правами доступа пользователя в данном случае нет. Вот пример использования :
Этот метод вполне бы мог получить награду за непритязательность, но рассмотрим его более внимательно. Данный метод не может оперировать входными данными с длиной больше, чем ~3000. Вы, конечно, можете написать версию, которая принимает ntext параметер и разбивает его на порции. Но я не задавался такой целью, т.к. при тестировании производительности этот метод оказался значительно медленнее метода с OPENXML, который в свою очередь медленнее метода итераций и метода со вспомогательной таблицей. Никакими другими специфическими достоинствами данный метод также не обладает. Anith Sen предложил простой метод, который просто преобразует список в набор INSERT запросов statements:
Этот метод имеет такие же проблемы, как и вариант с UNION запросом. В тестах на производительность он вел себя даже хуже последнего. 11. Действительно медленные методы В разделе Вопрос/Ответ одного из SQL журналов предлагалось следующее решение:
Возможно, оно напомнит Вам метод с использованием вспомогательной таблицы. Добавление запятых в начало и конец входной строки позволяет нам использовать функцию charindex для поиска ",ALFKI," и т.д. (Заметьте, что в данном случае входная строка не должна содержать внутренних пробелов.) Автор данного метода в своей статье заявил, что метод большой производительностью не отличатеся, т.к. использование поля таблицы в выражении исключает использование индекса по этому полю, приводя к сканированию таблицы. Но это только начало. Сканирование таблицы при тестировании занимало меньше 100 миллисекунд, если таблица полностью находилась в кэше. А этот метод отрабатывал за 42 секунды, даже для самого маленького тестового набора из 15 элементов общей длиной в 200 символов! Варианты выражения WHERE могут еще такими:
Вариант с использованием patindex также выполняется 42 секунды. Вариант с использованием LIKE на одной из тестовых машин был фактически в 4 раза быстрее, но точно также медленным как с использованием charindex и patindex на других машинах. Как не прискорбно, но это все равно в 100 раз медленнее метода с динамическим SQL и в 200 раз медленнее самого быстрого метода. Если Вы работаете с SQL Server 7, то у Вас нет возможности использовать пользовательские функции или XML. Поэтому в процедуре Вы можете использовать метод итераций или вспомогательную таблицу, для списка с разделителем или списка элементов фиксированной длины. Здесь я привожу пример для метода итераций. Вы можете легко адаптирвать его к двум другим методам. 12.1. Процедура для работы со списком string элементов Данная процедура очень похожа на приведенную выше функцию iter_charlist_to_table. Но вместо возврата таблиицы-переменной процедура заполняет временную таблицу #strings:
Вот пример использования этой процедуры:
Функция, разбирающая список string элементов, возвращала таблицу из двух полей - varchar и nvarchar. В случае с процедурой нет веской причины делать тоже самое. Пользователь процедуры сам определяет тип данных, с которыми он работает. По той же причине процедура не создает поле listpos . Если Вы хотите знать номер элемента в списке, то Вы можете добавить поле IDENTITY в таблицу. В предыдущих версиях этой статьи я предлагал Вам передавать имя временной таблицы как параметер и затем использовать динамический SQL для добавленя данных в таблицу. Однако, это был плохой совет. При тестировании я обнаружил, что затраты на выполнение каждого динамического запроса на INSERT настолько велики, что не могут быть приемлимыми. Есть еще одна потенциальная проблема производительности в предложенном выше методе. Все зависит от того, как Вы используете его. Обычно при каждом вызове создается новая временная таблица. В этом случае процедура charlist_to_table_sp будет перекомпилироваться при каждом вызове. В большинстве случаев такая перекомпиляция не будет являться проблемой. Фактически, при тестировании я выяснил, что эта процедура несколько быстрее соответсвующей функции по преобразованию строки в таблицу, несмотря на перекомпиляцию. Однако, в загруженной системе при массовых вызовах Вы можете столкнуться с блокировками компиляции, как показано в KB article 263889. Избежать этого можно, если использовать постоянную таблицу, которая может выглядеть примерно так:
Для заполнения поля spid Вы можете использовать глобальную переменную @@spid - идентификатор соединения. Заполняющая такую таблицу процедура может в одной из первых строк содержать запрос: DELETE stringarray WHERE spid = @@spid и пользователь должен помнить о необходимости использовать поле spid в запросах. Например:
Ещё одной альтернативой может быть создание временной таблицы в самой процедуре. Пользователь для получения результата может в этом случае воспользоваться конструкцией INSERT EXEC. Более подробно с такой методикой, а также ее недостатками, можно ознакомиться в моей статье How to share data between stored procedures. 12.2. Экстравагантная процедура для списка integer значений Методику, предложенную в предыдущем разделе, можно с успехом применить и для списка integer элементов. Но то, что предложено ниже, не является точной копией функции iter_intlist_to_table, т.к. во избежание ошибок преобразования типов, здесь делается проверка на то, являются ли элементы списка в действительности числами. И для пущей важности процедура различает и то, какого знака числа, например, +98 или -83. Если список содержит элементы не являющиеся числами, то процедура выводит предупреждение. Процедура заполняет временную таблицу, в которой имеется поле listpos . Значение в этом поле сообщит нам о пропущенных элементах списка, не прошедших проверку.
Вот пример использования процедуры:
Проверка элемента списка происходит в подпроцедуре insert_str_to_number. В большинстве случаев достаточно быдет проверять лишь следующие условия @str NOT LIKE '%[^0-9]%' AND len(@str) BETWEEN 1 AND 9 т.е. проверять, что @str содержит только цифры и их количество не превышает 9 (т.к. 10-ти значные числа мы интерпретируем как числа со знаком). Вы можете догадаться, что такая экстравагантность влияет на производительность, и в действительности эта процедура работает на 50% и более медленнее, чем соответствующая функция. Тем не менее, для многих случаев, такое время выполнения является приемлимым. И еще одно замечание о выводе предупреждения с помощью RAISERROR: в ADO это предупреждение очень трудно или вообще невозможно перехватить в клиентском приложении. Если вы поменяете severity с 10 на 11, то предупреждение будет считаться уже ошибкой, что вызовет соответсвующую реакцию в клиентском приложении. При использовании SQL 6.5 Вы гораздо больше ограничены в выборе метода решения. Длина данных типа varchar ограничена 255 символами и функция substring не работает с text. Поэтому Вы не можете разбить входные данные на порции или извлечь элементы фиксированной длины. Вы все же можете использовать процедуры, приведенные для версии SQL 7. Но т.к. длина входной строки не может превышать 255 символов, то риск того, что рано или поздно Вы все же упретесь в этот предел, слишком велик, чтобы так просто сбрасывать его со счетов. Поэтому, единственным методом, являющимся достаточно здравым решением для версии 6.5, остается динамический SQL. В этом случае, как показано в данном ниже примере, Вы можете использовать тип данных text для передачи длинного списка:
14. Результаты тестов на производительность Тестирование производительности СУБД не простая задача. Существует много факторов, влияющих на производительность. В своих тестах я проверял производительность разбора одной входной строки в таблицу (в методах, где это возможно) а также производительность извлечения данных из таблицы на основе переданного списка. Операция простого извлечения элементов из списка проверяет собственно сам метод, но при использовании метода для извлечения данных из другой таблицы(таблиц) большое влияние начинают оказывать свойства этой таблицы(таблиц), такие как индексы, статистика и тд. Возможно, Вы найдете, что методы, объявленные мной как быстрые, создают медленный план выполнения при использовании в запросе. Другая проблема заключается в том, каким образом Вы запускаете SQL запросы на выполнение. Для функций, оперирующих со строками конечной длины, я заметил, что существует значительная разница между RPC вызовами и командными пакетами(command-text batches). (Все результаты тестов были получены с использованием RPC.) И еще одна проблема заключается в том, как поведет себя метод при общей загруженности системы. Для исключения такого влияния я все тесты проводил на специально выделенных под это машинах. Таким образом, воспринимайте эти результаты такими, какие они есть. Они, безусловно, служат для определения безнадежно плохих методов. Но при самостоятельном использовании Вы можете выяснить, что некоторые из плохих методов имеют прекрасные показатели производителности. Если Вас волнует вопрос производителности в контексте конкретной задачи, то Вы, возможно, проведете свое исследование производительности. При написании этой статьи я протестировал очень много методов, для того чтобы включить полученные результаты в данный текст. Некоторые методы имели лишь незначительные отличия друг от друга. Например, возвращали varchar вместо nvarchar. Но я не зaдавался целью запомнить полученные результаты для каждой вариации. В конечном счете, я получил данные тестов для 16 случаев. Из них я выбрал 9 и включил их в основной список результатов тестов. Еще несколько результатов дано в разделе специальных наблюдений где я подметил некоторые особенности. Полный перечень результатов для всех 16 методов, полученный на 3-х тестовых машинах, можно увидеть здесь. Каждому методу я присвоил имя, которое я для удобства буду использовть дальше по ходу статьи для указания на метод. Все эти методы были представлены выше. Для большинства из них были преведены программные тексты, но некоторые были только упомянуты. Вот основные девять соперников:
А вот оставшиеся семь соперников:
14.3. Каким образом проводилось тестирование Данную таблицу я использовал во всех тестах.
Таблица содержала 202985 записей, средняя длина поля word составляла 9.7 символа, размер самого длинного значения этого поля составлял 31 символ. Значения в поле wordno в действительности были уникальными. Отсутствие уникального индекса по этому полю есть результат моей невнимательности при создании таблицы. Ошибка была обнаружена после окончания тестирования. Порядок следования значений в поле wordno никак не связан с порядком значений в поле word . Поле guid служило целям простого увеличения размеров таблицы, а также моделировало наличие неидексированного поля. Для каждого теста из таблицы usrdictwords случайным образом формировался список с запятой в качестве разделителя. Для списка string и integer элементов использовались поля word и wordno соответственно. К каждому элементу списка случайным образом добавлось от 0 до 3 пробелов. Это делалось для того, чтобы тестирование проверяло также и то, что метод вообще возвращает правильные результаты. Скрипт формирования входного списка заполнял его до предварително заданного количества элементов. Таким образом, списки integer элементов хоть и имели меньшую общую длину, но содержали такое же число элементов, как и списки string элементов. Я использовал списки пяти различных размеров:
Все методы тестировались со списками малого и среднего размеров. Для списков большого размера и выше я исключил из тестирования UNION, INSERT, REALSLOW и SLOW$LIKE, первые 2 потому, что они не могут опрерировать входными данными такого размера, последние два потому, что они слишком медленны для того, чтобы я смог дождаться результатов. Для списка очень большого размера я исключил из тестирования TBLNUM$IL потому, что он не мог оперерировать входными данными такого размера. Для списка огромного размера я исключил из тестирования FIX$SINGLE потому, что он не мог опрерировать входными данными такого размера и ITER$EXEC, EXEC$A и EXEC$B потому, что они слишком медленны. Замечу еще, что данные выше размеры списков были взяты из единожды сгененерированного тестового набора. Для многих методов входная строка приводилась к конкретному специфическому для метода формату, поэтому ее длина могла оказаться как меньше (при удалении вложенных пробелов) так и больше (XML или список элементов с фиксированной длиной). Но действительное число элементов для всех методов остовалось таким же. Я тестировал две операции со списками:
Вот пример типичной процедуры операции UNPACK:
Процедура запускает таймер, выполняет запрос, добавляет результат в таблицу тестовой базы, останавливает таймер и возвращает данные в тестовый скрипт, который осуществляет проверку правильности этих данных. Запрос SELECT INTO был выбран в качестве механизма для добавления данных в таблицу, т.к. в этом случае в лог пишется меньше информации, чем для запроса INSERT. Включение операции по возврату данных в общее время выполнения запроса означало бы, что пропускная способность сети могла бы сильно повлиять на результаты тестирования. Побочным эффектом использования SELECT INTO является перекомпиляция процедуры при каждомом запуске на строке с последним запросом SELECT, поэтому этот запрос также выполняется после остановки таймера. Я также привожу пример процедуры для тестирования операции JOIN, чтобы отметить одну важную деталь операции соединения таблиц:
Отмечу здесь то, что при соединении таблиц по string полю также извлекается поле guid , по которому нет никакого индекса. Это вынуждает SQL Server обращаться непосредственно к страницам данных таблицы. (В противном случае в запросе использовался бы покрывающий индекс по полю word .) Это заставляет планировщик делать выбор между использованием некластерного индекса по полю word с bookmark lookups-ми и сканированием таблицы. Для большинства методов использовалось четыре процедуры: UNPACK-Str, UNPACK-Int, JOIN-Str и JOIN-int. Тексты всех процедур доступны для скачивания в виде zip файла. Тестовый скрипт был написан на Perl, для соединения с SQL сервером использовалась DB-Library (потому что эту клиентскую библиотеку я знаю лучше всего). Использование DB-Library накладывает некоторые ограничения: в тестовой процедуре нельзя использовать параметры типа ntext и параметры типа varchar длиной больше 255 символов. Поэтому во всех процедурах в качестве входного параметра используется text. Скрипт выполняет установку параметров соединения с помощью набора SET команд аналогично тому, как это делается при коннекте через OLE DB или ODBC. Выполняется также SET NOCOUNT ON. Текст тестового скрипта доступен для скачивания в виде zip файла. При тестировании тестовый скрипт создавал список string и integer элементов, как это описано выше и затем передавал этот список всем тестовым процедурам в текущей тестовой базе. Для генератора случайных чисел использовалось фиксированное еачальное число(seed), поэтому при повторных запусках скрипта генерировались такие же тестовые наборы. Это дало мне возможность тестировать каждый метод отдельно от других, в тоже время используя одинаковые тестовые наборы для всех методов. (Это удобно для выяснения преимуществ одного метода без повторных запусков остальных методов.) Для тестового набора одного размера тестирование проводилось 100 раз, за исключением методов REALSLOW и SLOW$LIKE, для которых проводилось только 2 теста по причине того, что эти методы чересчур медленные. Тестирование проводилось на трех разных машинах:
Тестовый скрипт всегда запускался на KESAMETSA. К ABA0163 и JAMIE4K коннект осуществлялся через корпоративную VPN. Все машины во время тестирования не были в общем загружены другими задачами. Для представления результатов тестов в самой статье использются результаты полученные только для JAMIE4K. Я считаю, что эти цифры более показательны по следующим двум причинам: 1) эта машина является единственным настоящим сервером и поэтому больше похожа на production машину. 2) Т.к. у JAMIE4K более медленные CPUs, то измерение времени выполнения тестов на ней более точное. (Время в SQL сервере вычисляется с точностью до 3.33 миллисекунд, поэтому время исполнения меньше 50мс невозможно измерить достоверно.) Т.к. тестовая таблица достаточно мала, чтобы поместиться в кэш при выполненни теста, то это приводит к большему использованию CPU. Поэтому для большинства методов ABA0163 была в 2 раза быстрее чем JAMIE4K и в 4 раза быстрее чем KESAMETSA. Но есть одно исключение из этого правила, а именно то, что JAMIE4K привыполнении тестов могли использоваться все четыре процессора. Ниже я представляю среднее время выполнения в миллисекундах каждого из девяти основных соперников на машине JAMIE4K. Результаты всех 16 методов на каждой машине, включая минимальное, среднее и максимальное время выполнения, стандартное отклонение и коэффициет распределения, можно увидеть по следующим ссылкам JAMIE4K, ABA0163 и KESAMETSA.
На основе эти данных можно сделать одно заключение - методы REALSLOW и SLOW$LIKE не пригодны для использования. (Причина 4-х кратного превосходства SLOW$LIKE над REALSLOW в тесте JOIN-Str кроется в том, что по некоторым причинам SQL сервер не мог использовать все четыре процессора для метода REALSLOW в этом тесте. Сравните результаты для ABA0163 и KESAMETSA.) Для других семи методов результаты очень похожи между собой, хотя метод EXEC$A немного выделяется. Таким образом, для работы с входной строкой малого размера все эти методы пригодны. Я еще раз позволю себе подчеркнуть, что точность типа данных datetime в SQL сервере для таких промежутков времени не позволяет достоверно измерить время выполнения. На быстрых машинах ABA0163 и KESAMETSA для всех методов, кроме REALSLOW и SLOW$LIKE, измеренная продолжительность как минимум одного теста равнялась 0 мс. Тестовый набор среднего размера
Результаты методов REALSLOW и SLOW$LIKE выглядят еще более смехотворными. Теперь Вы понимаете, почему я не тестировал эти методы для строк большого размера. Оставшиеся семь методов все еще показывают результаты, которые большинство людей сочло бы вполне приемлимыми. Однако, можно увидеть, что методы EXEC$A и UNION начинают отставать от других пяти. Метод UNION имеет ограничение на размер входного параметра и поэтому не может быть проверен для строк большого размера, если мы не будем разбивать их на порции. Но такой целью я как раз и не задавался и именно по причине результатов тестов. Глядя на эти цифры, не верится, что метод UNION сможет составить серьезную конкуренцию другим методам. Опять замечу, что на двух других машинах для некоторых методов по-прежнему имело место нулевое время выполнения тестов при нескольких запусках. Это говорит о том, что данные все еще слишком малы для проведения достоверных измерений. Тестовый набор большого размера
Результаты уже начинают различаться, что дает нам возможность более достоверно оценить производительность того или иного метода. При операции разбора строки метод FIX$SINGLE по-прежнему также быстр, как и для тестового набора малой длины. Но следует прежде всего обратить внимание на метод с использованием динамического SQL, т.е. на EXEC$A. 2? секунды на извлечение string элементов против 350 мс у других методов делают данный метод неприемлимым для использования. Тестовый набор очень большого размера
При тестовом наборе такого размера диамический SQL начинает все больше выходить из под контроля. Т.к. данный тестовый набор является максимально возможным для метода FIX$SINGLE (из-за того, что в таблице Numbers, только 8000 записей, мы не сможем оперировать набор огромной длины), то давайте подведем некоторые промежуточные итоги тестирования производительости. Очень четко можно увидеть, что использование элементов фиксированного размера и их извлечение с помощью вспомогательной таблицы дают наименьшее время выполнения. Так же можно заметить, что более простой метод fixstring_single быстрее более сложного fixstring_multi. Последний правда позволяет использовать теоритически неограниченные по размеру входные данны. Но с другой стороны, Вы можете добавить записи в таблицу Numbers , чтобы увеличть количество обрабатываемых функцией fixstring_single элементов списка. Правда, строка из 8000 элементов это достаточно большой набор. Существуют, однако, две проблемы, которые могут существенно понизить производительность метода с использованием элементов фиксированной длины. Во-первых, это зависит от того, как именно был использован этот метод. В своих тестах я осуществлял вызов всех процедур через RPC, что является стандартным способом для приложений. Выяснилось, что если вместо этого использовать пакетное выполнение с помощью команды EXEC, то при привышении длины входного параметра некоторого значения производительность этих методов (и только их) падала. FIX$SINGLE был все еще быстрее всех , но TBLNUM обгонял FIX$MULTI и выходил на 2-ое место. Вторая проблема становится видна, если мы проанализируем разницу времени выполнения операций JOIN и UNPACK:
О чем говорят эти цифры, я точно судить не возьмусь. Они могут говорить о том, что в FIX$SINGLE время собственно извлечения данных из списка больше. И значит , что при работе с какими-то другими таблицами какие-то другие методы могут оказаться быстрее. (Но причиной этого тогда будет другой план выполнения запроса.) С другой стороны, хоть XML и является в данном случае самым медленным методом, но у него и наименьшая разница между временем выолнения разных операций. Это может означать, что операции по извлечению элементов списка и по извлечению данных из таблицы перекрываются и мы, таким образом, можем вообще для себя не разделять эти операции. Если производительность является для Вас ключевым вопросом, то Вы должны самостоятельно провести дополнительные исследования в своей конкретной задаче. Об этом я упомянул в Общем предупреждении. И еще не мешало бы напомнить, что производительность - это еще не все. Если Вам больше по душе списки с разделителем, то Вы можете использовать для работы с ними ту же вспомогательную таблицу при вполне хорошей производительности. И если Вы вообще не хотите использовать дополнительные таблицы, то еще остается метод итераций. 0.5 секунды на извлечение 1980-ти элементов из строки длиной в 24000 символов не заставляют тревожиться о производительности. Тестовый набор огромного размера
Для входных данных такого размера трудно ожидать, что на таком оборудовании время выполнения будет измеряться долями секунды. В данной ниже таблице показано, как возрастает время выполнения в зависимости от размера тестового набора. Приведены наименьшие и наибольшие значения для каждого метода:
Разумно предположить, что время выполнения для строки некоторой длины определяется линейны уравнением: t(s) = y + k·s Где s - это размер строки, y - некоторая начальная задержка и k - постоянная, значение которой близко к 1. Величины y и k конечно же различаются для разных методов. На основе этой таблицы можно сделать два вывода: 1) похоже, что чем быстрее метод, тем меньше коэффициент (k). Т.е. даже при возрастании размера входных данных более быстрый метод сохраняет свою позицию. 2) динамический SQL не подтверждает предположение о том, что значение k близко к 1, похоже, что оно скорее ближе к 2 (если мы считаем уравнение по-прежнему линейным). Это означает что при 2-хкратном увеличении размера входных данных, время выполнения динамического SQL возрастет в 4 раза. Помнится я говорил что-то подобное выше, не так ли? Примечание: данное линейное уравнение справедливо до тех пор, пока нет ограничения на используемые ресурсы. Как я заметил раньше, для метода с использованием элементов с фиксированной длиной замечено падение производительности при достижении длины входных данных определенной величины при запуске в пакетном режиме. В этой главе я обсуждаю некоторые наблюдения для методов, не вошедших в список основных соперников. В тестировании участвовали также процедурные версии для метода итераций. Я не включил эти результаты в основной список, потому что тестируемые процедуры сильно отличались друг от друга:
Сначала посмотрим на результаты работы процедур со списком string элементов:
Можно видеть, что процедуры в действительности быстрее функций, если мы используем их только для разбора строки. Это удивляет, особенно в свете того, что процедуры перекомпилируются при каждом вызове. Я вижу две возможных причины этого - или временные таблицы в общем быстрее чем таблицы-переменные или процедуры в общем быстрее multi-step функций. Но если мы попытаемся использовать возвращаемые результаты для извлечения данных из временной таблицы, то процедуры начинают проигрывать функциям при большом и очень большом размере тестового набора. Это наводит на мысль, что соединение со временной таблицей есть более дорогая операция чем соединение с таблицей-переменной. Этим может объясняться и разница в планах выполнения (вспомните о наличии в этом случае выбора между операциями table scan и index seek + bookmark lookup). В любом случае для тестового набора огромного размера использование временной таблицы дает выигрыш а также наименьшую разницу между операциями UNPACK и JOIN. Невзирая на эти результаты, лично мне больше нравятся функции, т.к. их можно использовать непосредственно в запросе. Поэтому в этой статье так много и говорится о функциях. И пару слов об ITER$EXEC... Идея заключалась в передаче имени таблицы, в которую динамическим запросом добавлялись элементы из списка:
Такой способ выглядит элегантно, но взгляните на данную выше таблицу с результатами и увидите, что это дорогостоящий способ. По-моему слишком дорогостоящий, чтобы отстаивать возможность его применения. Причина этого в том, что для каждого элемента списка SQL Server должен проверить INSERT запрос и построить план его выполнения. Это еще один пример того, что неправильное использование динамического SQL приводит к потере производительности. (Верно и обратное - т.е. правильное использование динамического SQL ведет к повышению производительности.) Но все-таки в отличии EXEC$A при использовании ITER$EXEC коеффициент (k) близок к 1 в нашем уравнении. А что у нас получается для экстравагантной процедуры для списка integer элементов?
Не удивительно, что наша попытка использовать нечто феерическое отразилась на производительности. Однако все не так трагично, как например для маленького динамического SQL в ITER$EXEC. И если Вы вернетесь к основным результатам тестов, то увидите, что эта процедура быстрее, чем XML. Но что более примечательно, так эта разница значений delta для тестовых наборов большого и очень большого размеров. Эта разница не может быть объяснена только экстравагантностью метода. Возможная причина опять же может заключаться в разных планах выполнения. Т.к. тестовая таблица имеет кластерный индекс по полю типа integer, то можно предположить, что нет альтернативы в вопросе выбора стратегии выполнения запроса. Но у SQL сервера остается еще выбор одного из трех способов соединения таблиц. Я не исследовал, как именно происходит выполнение, но об этом предупредил заранее. Вы можете самостоятельно получить более точные данные. Хотел бы подчеркнуть, что имеющаяся разница между процедурой и функцией относится к методу итераций, который выполняет большее количество запросов INSERT, чем какой-либо другой метод. Я бы не рискнул сделать такие же предположения и для других методов. В тестировании участвовали две функцииn по извлечению элемента из строки с разделителем с помощью вспомогательной таблицы: multi-step функция TBLNUM и inline функця TBLNUM$IL(с ограничением длины строки в 7998 символов). Вот результаты тестов для входной строки большого размера:
Может сложится мнение, что multi-step функции в действительности быстрее inline функции, но на самом деле это частично обман зрения. Multi-step функция использует тип данных nvarchar, а inline функция - varchar (по-другому не получается оперировать тестовым набором нужного размера). Если обе функции используют тип данных varchar, то inline функция быстрее приблизительно на 5%. Но т.к. для тестового набора такого размера время выполнения в любом случае среднее, то эти рассуждения носят скорее общепозновательный характер. Хотел отметить, что разница(или если угодно совпадение) времени выполнения inline и multi-step функций зависит от входных данных. Я не проводил отдельного исследования для элементов фиксированной длины, для которых выводы могут получиться другими. EXEC$B не является на самом деле методом. EXEC$B есть повторное выполнение точно такого же динамического SQL. Его поведение при тестировании было вызывающим. На этот раз, я представляю результаты тестов на всех машинах:
При сравнении этих результатов выполнения на JAMIE4K с результатаим других методов можно заметить, что только FIX$SINGLE может сравниться по производительности с EXEC$B, да и то невсегда. Для тестового набора большого размера EXEC$B опережает его для списка string элементов и идет вровень с методом FIX$SINGLE для списка integer элементов. Но т.к. все равно EXEC$B остается дорогостоящим методом (т.к. требует выполнение начального запроса EXEC$A для размещения плана выполнения в кэше), то EXEC$B не может составить действительной конкуренции другим методам. Для того, чтобы "обогнать" метод XML для тестового набора очень большого размера Вы должны запустить EXEC$B с одним и тем же SQL запросом раз 20. Или больше, если хотите "обогнать" ITER или TBLNUM. Теперь взглянем на результаты работы со списком integer элементов на ABA0163 и KESAMETSA и конкретно на результаты для тествого набора среднего размера. Неужели Земля стала вращаться в обратную сторону ??? Нет, просто попробуйте повторить запуск раз 100 и Вы еще и не такие результаты увидите. Т.к. у нас есть кластерный индекс по полю типа integer, то не трудно догадаться, что используется другой план выполнения. ...Но почему же планировщику не приходит в голову мысль использовать и некластерный индекс, когда это возможно? Если Вы хотите провести самостоятельное тестирование(возможно даже и своих собственных методов), то Вы можете скачать файл arraylist.zip, который содержит тестовый скрипт, тестовые процедуры и функции для различных методов и таблицу с номерами (размер файла около 30 KB). В файле README.HTML можно найти инструкции по применению. Результаты тестов можно получить в отдельном файле usrdictwords.zip (его размер около 6 MB). |