Текст
                    Предисловие Тобиаса Тернстрема,
ведущего менеджера программ команды разработчиков ядра
Microsoft SOL Server
Microsoft
Microsoft® SQL Server® 2012
Высокопроизводительный код T-SQL
Оконные функции
Ицик Бен-Ган
ft SolidQ
«.РУССКАЯ РЕДАКЦИЯ

Квартету. -Qi
Microsoft Microsoft® SQL Server 2012 High-Performance T-SQL Using Window Functions Itzik Ben-Gan
Microsoft* SQL Server* 2012 Высокопроизводительный код T-SQL Оконные функции Ицик Бен-Ган ИНШИ РЕДАКЦИЯ /Ывг 2013
УДК 004.65 + 004.438 T-SQL ББК 32.973.26-018 Б46 Бен-Ган, И. Б46 Microsoft SQL Server 2012. Высокопроизводительный код T-SQL Оконные функции.: Пер. с англ. — М.: Издательство «Русская редакция» ; СПб.: БХВ- Петербург, 2013. — 256 стр.: ил. ISBN 978-5-7502-0416-8 («Русская редакция») ISBN 978-5-9775-0901-5 («БХВ-Петербург») Эта книга — подробное руководство по применению оконных функций в SQL Server, а также в стандарте SQL, в том числе по использованию элементов, которые пока не реализованы в SQL Server. Здесь описаны принципы работы с окнами в SQL Server и работа различных оконных функций (ранжирования, ана- литики, агрегирования и смещения), а также функции упорядоченных наборов. Подробно освещен вопрос оптимизации оконных функций в SQL Server 2012 для достижения максимальной производительности. Отдельная глава посвящена тща- тельному анализу примеров практического применения оконных функций. Книга состоит из пяти глав; адресована разработчикам для SQL Server и адми- нистраторам баз данных, а также всем, кому необходимо создавать запросы и раз- рабатывать код с использованием T-SQL. УДК 004.65 + 004.438 T-SQL ББК 32.973.26-018 Microsoft, а также содержание списка, расположенного по адресу: http://wx£iw.microsoft.com/about/legal/en/ us/IntellectualProperty/Trademarks/EN-US.aspx являются товарными знаками или охраняемыми товарными знаками корпорации Microsoft в США и/или других странах. Все другие товарные знаки являются собствен- ностью соответствующих фирм. Все адреса, названия компаний, организаций и продуктов, а также имена лиц, используемые в примерах, вымышлены и не имеют никакого отношения к реальным компаниям, организациям, продуктам и лицам. ©2012, Translation Russian Edition Publishers. Authorized Russian translation of the English edition of Microsoft® SQL Server ® 2012 High-Performance T-SQL Using Window Functions, ISBN 978-0-7356-5836-3 © Itzik Ben-Gan. This translation is published and sold by permission of O’Reilly Media, Inc., which owns or controls all rights to publish and sell the same. © 2012, перевод ООО «Издательство «Русская редакция». Авторизованный перевод с английского на русский язык произведения Microsoft® SQL Server ® 2012 High-Performance T-SQL Using Window Functions, ISBN 978-0-7356-5836-3 © Itzik Ben-Gan. Этот перевод оригинального издания публикуется и продается с разрешения O’Reilly Media, Inc., которая владеет или распоряжается всеми правами на его публикацию и продажу. © 2013, оформление и подготовка к изданию, ООО «Издательство «Русская редакция», издательство «БХВ-Петербург». ISBN 978-0-7356-5836-3 (англ.) ISBN 978-5-7502-0416-8 («Русская редакция») ISBN 978-5-9775-0901-5 («БХВ-Петербург»)
Оглавление Предисловие....................................................IX Об этой книге..................................................XI Благодарности................................................XIII Техническая поддержка..........................................XV Глава 1. Окна в SQL Server......................................1 Основы оконных функций.......................................2 Описание оконных функций...................................2 Два типа программирования: наборы и курсоры с итеративным проходом...................................................6 Недостатки альтернатив оконным функциям...................12 Краткий обзор решений с использованием оконных функций......17 Элементы оконных функций....................................22 Секционирование...........................................22 Упорядочение..............................................24 Кадрирование..............................................25 Элементы запросов, поддерживающие оконные функции...........26 Логическая обработка запросов.............................26 Предложения, поддерживающие оконные функции...............28 В обход ограничений.......................................31 Возможность создания дополнительных фильтров................34 Повторное использование определений окон....................34 Резюме......................................................36 Глава 2. Более подробные сведения об оконных функциях..........37 Оконные функции агрегирования...............................37 Оконные функции агрегирования.............................37 Поддерживаемые элементы...................................38 Дополнительные варианты фильтрации........................54
VI Оглавление Ключевое слово DISTINCT в функциях агрегирования......56 Вложенные агрегаты....................................59 Функции ранжирования.....................................64 Поддерживаемые оконные элементы.......................64 Функция ROW NUMBER....................................64 Функция NTILE.........................................70 Функции RANK и DENSERANK..............................74 Аналитические функции....................................75 Поддерживаемые оконные элементы.......................76 Функции распределения рангов..........................76 Функции обратного распределения.......................79 Функции смещения.........................................82 Поддерживаемые оконные элементы.......................83 Функции LAG и LEAD....................................83 Функции FIRST_VALUE, LASTVALUE и NTH_VALUE............85 Резюме...................................................89 Глава 3. Функции для работы с упорядоченными наборами......90 Функции гипотетического набора...........................91 Функция RANK..........................................91 Функция DENSE_RANK....................................94 Функция PERCENT_RANK..................................95 Функция CUME_DIST.....................................96 Обобщенное решение....................................97 Функции обратного распределения.........................100 Функции смещения........................................105 Конкатенация строк......................................110 Заключение..............................................112 Глава 4. Оптимизация оконных функций......................113 Тестовые данные.........................................113 Рекомендации по индексированию..........................116 РОС-индекс...........................................116 Обратный просмотр....................................118 Индексы columnstore..................................120 Функции ранжирования....................................121 Функция ROW_NUMBER...................................121 Функция NTILE........................................123 Функции RANK и DENSE RANK............................124
Оглавление VII Улучшение параллелизма за счет использования APPLY..........125 Функции агрегирования и смещения............................129 Без упорядочения и кадрирования...........................129 С упорядочением и кадрированием...........................132 Аналитические функции.......................................142 Функции распределения рангов..............................142 Функции обратного распределения...........................143 Резюме......................................................146 Глава 5. Решения на основе Т-SQL с использованием оконных функций................................................148 Вспомогательные виртуальные таблицы чисел...................148 Последовательности значений даты и времени..................152 Последовательности ключей...................................154 Обновление столбца с заполнением уникальными значениями...154 Получение блока значений последовательности...............156 Разбиение на страницы.......................................159 Удаление дубликатов.........................................162 Сведение....................................................165 Выбор первых п элементов в группе...........................169 Моды........................................................172 Вычисление нарастающих итогов...............................177 Основанное на наборах решение с использованием оконных функций...........................................180 Основанные на наборах решения с использованием вложенных запросов и соединений...........................181 Решения с использованием курсора..........................182 Решения на основе CLR.....................................184 Вложенные итерации........................................186 Многострочное обновление с переменными....................188 Измерение производительности..............................190 Максимальное количество параллельных интервалов.............192 Традиционное решение на основе наборов....................194 Решения с использованием курсора..........................197 Решения на основе оконных функций.........................199 Измерение производительности..............................202 Упаковка интервалов.........................................203 Традиционное решение на основе наборов....................206 Решения на основе оконных функций.........................207
VIII Оглавление Пробелы и островки........................................217 Пробелы.................................................218 Островки................................................220 Медианы...................................................227 Условные агрегаты.........................................230 Сортировка иерархий.......................................232 Резюме....................................................237 Об авторе....................................................238
Предисловие SQL — очень интересный язык программирования. Встречи с клиентами мне все время напоминают двойственную природу этого языка с точки зре- ния сложности. Многие люди при знакомстве с SQL видят его как простой язык программирования с четырьмя базовыми глаголами: SELECT, INSERT, UPDATE и DELETE. Большинство никогда не идет дальше этого миниму- ма. Небольшая часть осваивает фильтрацию строк в запросе с помощью предложения WHERE и иногда использует JOIN. Однако те, кто поработал больше с SQL и изучил его декларативную, реляционную и основанную на наборах модели, узнают этот богатый язык программирования и будут снова и снова к нему возвращаться за новыми открытиями. Одним и самых фундаментальных расширений языка SQL в Microsoft SQL Server 2005 стало добавление оконных функций с такими синтаксиче- скими конструкциями, как предложение OVER и набор новых функций, из- вестных как функции ранжирования (ROW_NUMBER, RANK и других). Это добавление позволило решать стандартные проблемы более простым, интуитивно понятным и отличающимся более высокой производительно- стью способом, чем раньше. Всего несколько лет спустя самым популяр- ным обращением клиентов к Microsoft была просьба расширить поддержку оконных функций — добавить новые функции и, что более важно, добавить реализацию кадров. Под давлением всех этих обращений от самых разных клиентов в Microsoft решили продолжить работу над расширением оконных функций в SQL Server 2012. Сегодня, разговаривая с клиентами о новой функциональности языка в SQL Server 2012, я всегда рекомендую потратить чуть больше времени на изучение оконных функций и освоить новые измерения, которые эти функ- ции привносят в язык SQL. Я счастлив, что вы читаете эту книгу и тратите свое драгоценное время на изучение этой богатейшей функциональности. Я уверен, что работа с SQL Server 2012 и чтение этой книги позволит вам стать намного более эффективным пользователем SQL Server и решать про- стые и сложные задачи значительно быстрее. Пользуйтесь на здоровье! Тобиас Тернстрем, Ведущий менеджер программу команда разработчиков ядра Microsoft SQL Server
Об этой книге Для меня оконные функции — самая глубокая функциональность как стан- дартного языка SQL, так и его диалекта для Microsoft SQL Server — T-SQL. Они позволяют выполнять вычисления, используя наборы строк, с удиви- тельной гибкостью, прозрачностью и эффективностью. Структура окон- ных функций совершенно потрясающая и лишена многих недостатков тра- диционных решений. Диапазон задач, решаемых с применением оконных функций, огромен, и они стоят того, чтобы потратить время на их изучение. В SQL Server оконные функции впервые появились в версии SQL Server 2005. В SQL Server 2012 поддержка была значительно расширена за счет совер- шенствования существующих и добавления новых функций. В этой книге рассказывается как о поддержке оконных функций в SQL Server, так и под- держке этих функций в стандарте SQL, в том числе об элементах, которые пока не реализованы в SQL Server. Кому адресована эта книга Эта книга адресована разработчикам SQL Server и администраторам баз данных — всем тем, кому нужно писать запросы и разрабатывать код с ис- пользованием Т-SQL. Книга предполагает наличие полугодичного или го- дичного опыта написания и отладки запросов на T-SQL. Структура книги В книге рассказывается как о логике работы оконных функций, так и об их оптимизации и практических аспектах их использования. Логике работы и структуре оконных функций посвящены первые три главы. Первая глава объясняет принципы работы с окнами в SQL Server, во второй приводит- ся перечень и описание различных оконных функций, а в третьей расска- зывается о функциях упорядоченных наборов. Четвертая глава посвящена оптимизации оконных функций в SQL Server 2012. И, наконец, в пятой и по- следней главе описываются примеры практического применения оконных функций. Глава 1 содержит важную информацию о принципах использования окон в стандарте SQL. В ней описывается структура и типы оконных функций, а также элементы определения окон, такие как секционирование, упорядоче- ние и кадрирование.В главе 2 рассказывается о назначении, целях и подроб-
XII Об этой книге ностях работы различных оконных функций. В ней описаны оконные функ- ции агрегирования, ранжирования, смещения и аналитики. В главе 3 вы найдете информацию о поддержке стандартом SQL функ- ций упорядоченного набора, в том числе функции гипотетического набора, обратного распределения и других. В главе также рассказывается, как реа- лизовать аналогичные вычисления другими средствами SQL Server. Глава 4 посвящена оптимизации оконных функций в SQL Server 2012. В ней представлены рекомендации по оптимизации производительности, объясняется, как работает параллелизм и как его улучшить, обсуждается но- вый итератор Window Spool и другие темы. В главе 5 представлены примеры практического применения оконных функций для решения самых популярных бизнес-задач. Системные требования Оконные функции являются частью базового ядра Microsoft SQL Server 2012, поэтому все редакции этой СУБД поддерживают оконные функции. Для вы- полнения кода примеров этой книги нужен сервер SQL Server 2012 (любая редакция) с установленной тестовой базой данных. Если у вас нет полно- ценного экземпляра SQL Server 2012, можете воспользоваться пробной вер- сией. Подробнее см. сайт http://www.microsoft.com/sql. Подробнее о требова- ниях к оборудованию и ПО см. Электронную документацию по SQL Server по адресу http://msdn.microsoft.com/en-us/library/ms143506(v=sql. 110).aspx. Примеры кода Исходные тексты примеров, тестовые данные, список опечаток, дополни- тельные ресурсы и многое другое вы можете загрузить с веб-сайта, посвя- щенного этой книге (http://www.insidetsql.com). На этом веб-сайте перейдите в раздел Books и выберите страницу нужной книги. На странице книги вы найдете ссылку для загрузки сжатого файла с исходными кодами этой книги, в том числе с файлом TSQL2012.sql, кото- рый позволяет создать и наполнить данными используемую в это книге базу данных TSQL2012.
Благодарности В создании этой книги прямо или косвенно участвовало много людей, и все они заслуживают благодарности и признания. Спасибо Лилах (Lilach) за то, что вносит смысл во все, что я делаю, за то, что терпит меня и помогает редактировать текст. Хочу поблагодарить своих родителей Милу и Габи, а также брата Мики и сестру Ину за постоянную поддержку и снисходительное отношение к мое- му отсутствию. Благодарю членов команды разработчиков Microsoft SQL Server: Тобиаса Тернстрема (Tobias Ternstrom), Любора Коллара (Lubor Kollar), Умачандару Джайячандран (Umachandar Jayachandran), Марку Фридману (Marc Fried- man), Милану Стоику (Milan Stojic) и многим другим. Я знаю, что этто было непросто реализовать поддержку оконных функций в SQL Server. Спасибо за ваши огромные усилия и время, потраченное на встречи со мной и на от- веты на мои сообщения электронной почты, на ответы на мои вопросы, а также разъяснения. Хочу сказать огромное «спасибо» коллективам редакций O’Reilly и MSPress. Кен Джонс (Ken Jones), ты потратил на Ицика больше всего време- ни из всех, и это было истинное наслаждение работать с тобой. Также спаси- бо Бену Райяну (Ben Ryan), Кристен Борг (Kristen Borg), Кертису Филипсу (Curtis Philips) и Роджеру Лебланку (Roger LeBlanc). Благодарю Адама Маканика (Adam Machanic) за то, что согласился быть техническим редактором этой книги. В мире немного людей, которые раз- бираются в разработке для SQL Server так, как ты. И даже помыслить не мог на эту роль в этой книге кого-то другого. Спасибо Q2, Q3 и Q4. Как замечательно делиться идеями с людьми, ко- торые понимают SQL так же хорошо, как ты сам, и к тому же являются от- личными друзьями и относятся к жизни с радостью и удовольствием. Я чув- ствую, что могу делиться с ними абсолютно всем, не заботясь о границах и последствиях. Огромное спасибо за критику черновика книги. Спасибо моей компании SolidQ, которая служит источником вдохно- вения для меня последние десять лет. Это замечательно работать в ком- пании, которая растет такими темпами. Сотрудники этой компании для меня не просто коллеги, а партнеры, друзья и практически вторая семья. Огромное спасибо Фердандо Дж. Герреро (Fernando G. Guerrero), Дугласу Макдауэллу (Douglas McDowell), Херберту Альберту (Herbert Albert),
XIV Благодарности Дежан Сарка (Dejan Sarka), Джанлука Холцу (Gianluca Hotz), Джеанн Ривз (Jeanne Reeves), Глену Маккоину (Glenn McCoin), Фрицу Лехницу (Fritz Lechnitz), Эрику Ван Сольдту (Eric Van Soldt), Джоель Бадд (Joelle Budd), Йену Тейлору (Jan Taylor), Мерилин Темплтон (Marilyn Templeton), Берри Уокеру (Berry Walker), Альберто Мартину (Alberto Martin), Лорене Хименес (Lorena Jimenez), Рону Толмейджу (Ron Talmage), Энди Келли (Andy Kelly), Рушабу Мета (Rushabh Mehta), Эладио Ринцину (Eladio Rincyn), Эрику Веерману (Erik Veerman), Йохану Ричарду Уеймиру (Johan Richard Waymire), Карлу Рабелеру (Carl Rabeler), Крису Рендаллу (Chris Randall), Олен (Ahlen), Раулю Иллису (Raoul Illyes), Питеру Ларссону (Peter Larsson), Питеру Майерсу (Peter Myers), Полу Терли (Paul Turley) и многим другим. Благодарю членов редакции журнала «SQL Server Pro»: Меган Келлер (Megan Keller), Лейвон Питерс (Lavon Peters), Мишель Крокет (Michele Crockett), Майка Оти (Mike Otey) и очень многих других. Мои статьи пу- бликуются в журнале уже больше десяти лет, и я благодарен возможности поделиться своими знаниями с читателями журнала. Особая благодарность MVP-специалистам по SQL Server Алехандро Меса (Alejandro Mesa), Элланду Соммаркоу (Erland Sommarskog), Аарону Бертрану (Aaron Bertrand), Полу Уайту (Paul White) и многим другим, а также ведущему MVP Саймону Тиену (Simon Tien). Это замечательная про- грамма, и я горжусь участием в ней. Уровень профессионализма этой груп- пы совершенно потрясающий, и мне всегда приятно, когда мы собираемся за пивом для обмена идеями или новостями. Я уверен: в том, что Microsoft решила в SQL Server 2012 реализовать более полную поддержку оконных функций, есть огромная заслуга MVP-специалистов по SQL Server и всего сообщества пользователей SQL Server. Это замечательно, что общими уси- лиями нам удалось добиться таких заметных и важных результатов. Наконец, хочу поблагодарить своих слушателей: преподавание SQL дает мне энергию жить. Это моя страсть. Спасибо за возможность реализовывать свое призвание и за замечательные вопросы, которые позволяют открывать новые нюансы.
Техническая поддержка Мы постарались сделать все от нас зависящее, чтобы и сама книга, и допол- нительные материалы не содержали ошибок. Издательство Microsoft Press постоянно обновляет список исправлений и дополнений к своим книгам и публикует их на странице http://go.microsoft.com/FWLink/?Linkid=246707. Если вы обнаружите ошибку, не указанную на этом сайте, можете там же со- общить о ней. Если все же у вас возникнут вопросы, обращайтесь в издательство Microsoft Press по адресу mspinput@microsoft.com. Учтите, что по указанным почтовым адресам техническая поддержка не предоставляется. Нас интересует ваше мнение Издательство Microsoft Press стремится к полному удовлетворению своих чи- тателей, поэтому нам очень важно ваше мнение. Пожалуйста, сообщите нам свое мнение о книге на сайте http://www.microsoft.com/leaming/booksurvey. Опрос очень короткий, и мы внимательно изучаем каждое ваше замеча- ние и предложение. Заранее большое вам спасибо за ваши замечания и пред- ложения! Если вы захотите поделиться своими предложениями, комментариями или идеями, которые возникли в процессе чтения этой книги, а также если у вас есть вопросы, на которые вы не нашли ответов на перечисленных выше сайтах, обращайтесь непосредственно к автору по адресу itzik@SolidQ.com. Оставайтесь на связи Давайте не терять друг друга из виду! Мы теперь доступны в Твиттере: http//twitter. сот/МicrosoftPress.
Глава 1 Окна в SQL Server Оконными (window functions) называются функции, которые применяются к наборам строк и определяются посредством предложения OVER. В основ- ном они используются для аналитических задач, позволяя вычислять на- растающие итоги и скользящие средние, определять пробелы и островки в данных, а также выполнять многие другие вычисления. Эти функции осно- ваны на глубоком принципе языка SQL (обоих стандартов — ISO и ANSI) — принципе работы с окнами (windowing). Основа этого принципа — возмож- ность выполнять различные вычисления с набором, или окном, строк и воз- вращать одно значение. Оконные функции позволяют решать многие зада- чи, связанные с запросом данных, позволяя выражать вычисления в рамках наборов намного проще, интуитивно понятнее и эффективнее. В истории поддержки стандарта оконных функций в Microsoft SQL Server есть две ключевые точки: в SQL Server 2005 была реализована поддержка стандартной функциональности, а в SQL Server 2012 поддержка оконных функций была расширена. Отсутствует поддержка некоторой стандартной функциональности, но с улучшениями в SQL Server 2012, поддержку окон- ных функций можно считать достаточно обширной. В этой книге я расскажу как о реализации этой функциональности в SQL Server, так и о стандартной функциональности, которая в этом сервере отсутствует. Каждый раз, упо- миная новую функциональность, я буду указывать, поддерживается ли она в SQL Server, а также в какой версии появилась эта поддержка. С момента появления поддержки оконных функций в SQL Server 2005 я обнаружил, что все чаще использую эти функции для совершенствова- ния своих решений. Я методично заменяю старые решения, в которых при- меняются классические, традиционные конструкции языка, более новыми оконными функциями. А результаты обычно удается получить проще и бо- лее эффективно. Это настолько удобно, что в большинстве своих решений, требующих запроса данных, я теперь использую оконные функции. Также стандартные SQL-системы и реляционные системы управления базами дан- ных (РСУБД) все больше движутся в сторону аналитических решений, и оконные функции являются важной частью этой тенденции. Поэтому мне кажется, что оконным функциям принадлежит будущее в области запроса
2 Глава 1 Окна в SQL Server данных средствами SQL Server, а время, затраченное на их изучение, не про- падет зря. В этой книге подробно рассказывается об оконных функциях, их опти- мизации и о решениях для получения данных на их основе. В этой главе я расскажу о принципах работы оконных функций. Вы узнаете об основах оконных функций, вкратце ознакомитесь с основанными на них решениями, узнаете об элементах спецификации окон, об элементах, поддерживающих оконные функции, а также увидите, как в стандартах предусматривается по- вторно использовать определения оконных функций. Основы оконных функций Прежде чем знакомиться с особенностями оконных функций, полезно по- нимать контекст и историю этих функций. В этом разделе вы узнаете о том, откуда взялись эти функции. Здесь рассказывается о разнице между под- ходами, основанными на наборах и курсорах с итеративным проходом, для решения задач запроса данных и о том, как оконные функции заполняют пробел между этими двумя подходами. Наконец, в этом разделе вы узнаете о недостатках и альтернативах оконных функций и почему эти функции часто предпочтительнее альтернатив. Надо помнить, что хотя оконные функции и эффективно решают многие задачи, в некоторых ситуация лучше исполь- зовать другие варианты. В главе 4 рассказывается о деталях оптимизации оконных функций и объясняется, когда они обеспечивают оптимальное вы- полнение вычислительных задач, а когда нет. Описание оконных функций Оконная функция применяется к набору строк. Окно — стандартный термин SQL, служащий для описания контекста в котором работает функция. Для указания окна в SQL используется предложение OVER. Вот пример запроса: USE TSQL2012; SELECT orderid. orderdate, val, RANKO OVER(ORDER BY val DESC) AS rnk FROM Sales.OrderValues ORDER BY rnk; А это сокращенный результат выполнения этого запроса: orderid orderdate val rnk 10865 2008-02-02 00 00:00.000 16387.50 1 10981 2008-03-27 00 00:00.000 15810.00 2 11030 2008-04-17 00 00:00.000 12615.05 3 10889 2008-02-16 00 00:00.000 11380.00 4 10417 2007-01-16 00 00:00.000 11188.40 5 10817 2008-01-06 00 00:00.000 10952.85 6
Окна в SQL Server Глава 1 3 10897 2008-02-19 00:00:00.000 10835.24 7 10479 2007-03-19 00:00:00.000 10495.60 8 10540 2007-05-19 00:00:00.000 10191.70 9 10691 2007-10-03 00:00:00.000 10164.80 10 | с| Примечание Подробнее о примере базы данных TSQL2012 и сопутствующих мате- риалах см. «Об этой книге». Предложение OVER определяет окно, или точный набор строк по отно- шению к текущей строке, указание об упорядочении (если нужно) и дру- гие элементы. Отсутствуют элементы, которые ограничивают набор строк в окне — как в данном примере, потому что набор строк окна является оконча- тельным набором для запроса. | - L| Примечание Если быть более точным, то окно представляет собой набор строк, или отношение, предоставляемые как входные данные этапа обработки логического за- проса, в котором определено это окно. Но пока такое определение не совсем понятно. Поэтому для упрощения будем говорить об окончательном наборе результатов запро- са — более подробное объяснение я дам чуть позже. Упорядочение естественным образом необходимо для целей ранжирова- ния. В данном примере оно основано на столбце val и обеспечивает ранжи- рование по убыванию. В примере мы применили функцию RANK. Эта функция рассчитывает ранг текущей строки в определенном наборе строк в соответствии с поряд- ком сортировки. При сортировке по убыванию, как в данном случае, ранг строки определяется на единицу больше числа строк в соответствующем на- боре, у которых место в сортировке выше, чем у текущей строки. Выберем одну из строк в результатах примера запроса, например с рангом 5. Этот ранг определен как 5, потому что в соответствии с заданным порядком сортиров- ки (по убыванию val) в окончательном наборе результатов есть четыре стро- ки, у которых значение атрибута valбольше текущего значения (И 188,40), а ранг определяется, как число этих строк плюс один. Но самый важный нюанс заключается в том, что предложение OVER определяет окно функции по отношению к текущей строке. Это верно по от- ношению ко всем строкам набора результатов запроса. Иначе говоря, в каж- дой строке предложение OVER определяет окно независимо от остальных строк. Это очень важная концепция, и требуется определенное время на ее осознание. Освоив ее, вы приблизитесь к настоящему пониманию принци- пов работы с окнами, а также поймете всю ее глубину. Если пока вам это говорит немного, до времени не беспокойтесь — я вывалил все это скопом перед вами только для затравки. Поддержка оконных функций в SQL описана в стандарте SQL: 1999, где они назвались «OLAP functions». С того времени в каждой новой редакции поддержка оконных функций только укреплялась. Я имею в виду редакции SQL:2003, SQL:2008 и SQL:2011. В последнем стандарте SQL предусмотре-
4 Глава 1 Окна в SQL Server на очень широкая поддержка оконных функций — нужно ли других доказа- тельств, что в комитете стандартизации верят в них и, по-видимому, стан- дарт будет расширяться за счет увеличения числа оконных функций и со- путствующей функциональности. | - Примечание Документы стандартов можно приобрести в организации ISO или ANSI. Например, по следующему URL-адресу можно приобрести документ фонда ANSI с описанием стандарта SQL:2011, в котором описаны конструкции языка: http://webstore. ansi.org/RecordDetail.aspx?sku=ISO%2flEC+9075-2%3a2011. В стандарте SQL предусмотрена поддержка нескольких типов оконных функции: агрегатные, ранжирующие, аналитические (или распределения) и сдвига. Но надо не забывать, что окна — это принцип, поэтому в следующих редакциях стандарта могут появиться новые типы. В агрегатных оконных функциях вы найдете привычные функции агре- гирования, такие как SUM, COUNT, MIN, МАХ и другие, однако вы, скорее всего, привыкли к использованию их в контексте групп запросов. Функция агрегирования должна работать на наборе, который определен групповым запросом или определением окна. В SQL Server 2005 была реализована ча- стичная поддержка оконных функций, а в SQL Server 2012 эта функцио- нальность была расширена. Реализованы следующие функции ранжирования: RANK, DENSE_ RANK, ROW NUMBER и NTILE. В стандарте первая и вторая пары функ- ций относятся к разным категориям, и позже я объясню, почему. Я предпо- читаю объединять эту четверку функций в одну категорию для простоты — точно так же, как это делается в официальной документации по SQL Server. В SQL Server 2005 эти четыре функции ранжирования уже обладают полной функциональностью. К аналитическим функциям относятся PERCENT_RANK, CUME_DIST, PERCENTILE_CONT и PERCENTILE DISC. Поддержка этих функций появилась в SQL Server 2012. К функциям сдвига относятся LAG, LEAD, FIRST_VALUE, LAST- VALUE и NTH_VALUE. Поддержка эти функций также появилась в SQL Server 2012. Поддержки функции NTH_VALUE в SQL Server нет, и SQL Ser- ver 2012 не исключение. Ниже рассказывается о назначении, целях и особенностях работы раз- личных функций. При освоении любой новой идеи, устройства или инструмента приходит- ся преодолевать определенный барьер, даже если новика проще в установке и использовании. Новое всегда дается нелегко. Поэтому если вы не знакомы с оконными функциями и анализируете, стоит ли тратить время на их изуче- ние и применение, вот вам несколько аргументов «за»: Оконные функции позволяют решать множество задач извлечения дан- ных. Я бы сказал, что эту возможность сложно переоценить. Как я уже говорил, сейчас я использую оконные функции в большинстве ситуаций,
Окна в SQL Server Глава 1 5 когда нужно запрашивать данные. После рассказа о принципах работы и оптимизации функций в главе 5 показаны примеры практического их применения. Но чтобы вы уже сейчас понимали, как их можно использо- вать, скажу, что средствами оконных функций можно решать следующие задачи: □ Разбиение на страницы. □ Устранение дублирования данных. □ Возвращение первых п строк в каждой группе. □ Вычисление нарастающих итогов. □ Выполнение операций в интервалах, например в интервалах упаковки, а также вычисление максимального числа параллельных сеансов. □ Нахождение пробелов и островков. □ Вычисление процентилей. □ Вычисление режима распределения. □ Иерархии сортировки. □ Сведение. □ Определение новизны. Я занимаюсь созданием SQL-запросов уже почти двадцать лет и на протя- жении последних нескольких лет активно использую оконные функции. Могу сказать, что на освоение оконных функций требуется определенное время, но во многих случая оконные функции оказываются проще и бо- лее «интуитивными», чем обычные методы. Оконные функции хорошо поддаются оптимизации. Почему это проис- ходит, вы узнаете в последующих главах. Декларативный язык и оптимизация Вас наверное удивит, почему в таком декларативном языке, как SQL, где вы просто заявляете, что хотите получить, а не описываете, как получить ис- комое, две формы одного и того же запроса — одна с оконными функциями, а другая без — дают разную производительность. Как так происходит, что в одной реализации SQL, такой как SQL Server, с собственным диалектом Т-SQL, СУБД не всегда «догадывается», что две формы практически иден- тичны, и не создает одинаковые планы выполнения. Для этого есть несколько причин. Для начала, оптимизатор SQL Server не идеален. Не поймите меня неправильно — оптимизатор SQL Server это настоящее чудо, если говорить о том, какие сложные задачи он решает. Но в нем невозможно реализовать все возможные правила. Это во-первых, а во-вторых, у оптимизатора есть очень ограниченное время иа выполнение оптимизации — он мог бы тратить больше времени на оптимизацию, по надо понимать, что это время должно компенсироваться ускорением вы- полнения запроса. Иногда ситуация доходит до абсурда: с одной стороны план, не учитывающий все возможные планы, но обеспечивающий выпол- нение запроса за несколько секунд, создается за несколько миллисекунд,
6 Глава 1 Окна в SQL Server а с другой — на определенно плана, учитывающего все варианты и обеспе- чивающего сокращение времени выполнения запроса на несколько секунд, может потребоваться год или даже больше. Как видите, с практической точки зрения у оптимизатора очень ограниченное время на оптимизацию. На основании определенных параметров, в числе которых размер исполь- зуемых в запросе таблиц, SQL Server определяет два значения: первое — стоимость удовлетворительного плана, а другое — максимально возможное время, которое можно потратить на оптимизацию. При достижении любого из этих значений SQL Server использует наилучший определенный на тот момент времени план выполнения. Структура оконных функций такова, что они часто лучше поддаются оптимизации, чем другие методы решения тех же задач. Из всего сказанного вы должны вынести следующее: чтобы перейти на использование оконных функций, нужно приложить определенные осознан- ные усилия, потому что это новая концепция, к которой надо привыкнуть. Но после этого использовать оконные функции становится просто и интуи- тивно попятно — вспомните о том, каким сложным вам казалось освоение гаджетов, без которых вы сейчас не представляете себе свою жизнь. Два типа программирования: наборы и курсоры с итеративным проходом Часто решения на основе Т-SQL для запроса данных делят на два вида: осно- ванные на наборах пли на курсорах с итеративным проходом. Разработчики на Т-SQL соглашаются, что нужно использовать первый подход, но курсоры все еще используются во многих решениях. В связи с эти возникает несколь- ко интересных вопросов. Почему наборы считаются предпочтительнее? И если они рекомендованы к использованию, то почему многие разработчики используют итеративный подход? Что мешает людям использовать реко- мендуемый подход? Чтобы разобраться в этом, нужно понять основы Т-SQL, что на самом деле представляет собой основанный на наборах подход. Если это сделать, то вы поймете, что для большинства людей наборы недостаточно интуи- тивно понятны, а логику итераций понять легче. Все дело в том, что разрыв между итеративным и основанным на наборах типами мышления довольно велик. Его можно сократить, но это непросто. Именно на этом этапе важную роль могут сыграть оконные функции. Я считаю их замечательным инстру- ментом, способным закрыть разрыв между этими двумя подходами и обе- спечить более гладкий переход к мышлению в терминах наборов. Поэтому сначала объясню, что представляет собой основанный на набо- рах подход к решению задач получения данных средствами T-SQL. T-SQL является диалектом стандартного языка SQL (стандартов как ISO, так и ANSI). SQL основан (или является попыткой реализации) на базе ре- ляционной модели, которая представляет собой математическую модель
Окна в SQL Server Глава 1 7 управления данными, изначально сформулированными и предложенными Е. Ф. Коддом в конце 1960-х. Реляционная модель основана на двух ма- тематических принципах: теории множеств и логике предикатов. Многие аспекты компьютерных вычислений основаны на интуиции, при этом они очень быстро меняются — так быстро, что иногда кажется, что ты сам по- хож на кота, который гоняется за своим хвостом. Реляционная модель яв- ляется островом в мире компьютерных вычислений, потому что основана на существенно более надежном основании — на математике. Некоторые считают математику истиной в последней инстанции. Строгие математи- ческие основания обеспечивают надежность и стабильность реляционной модели. Она развивается, но не так быстро, как другие области компью- терных вычислений. Вот уже несколько десятилетий реляционная модель оставалась незыблемой и сейчас она лежит в основе ведущих платформ баз данных, которые называют реляционными системами управления базами данных (РСУБД). SQL представляет собой попытку создания языка, основанного на реля- ционной модели. SQL неидеален и, честно говоря, в ряде нюансов отклоня- ется от реляционной модели, но вместе с тем он предоставляет достаточно средств, чтобы человек, понимающий реляционную модель, мог использо- вать реляционные возможности средствами SQL. Этот язык — бесспорный ведущий де-факто язык современных РСУБД. Однако, как я уже говорил, реляционное мышление во многих отноше- ниях не интуитивно понятно. Отчасти сложность научиться мыслить в ре- ляционных терминах заключается в фундаментальной разнице между ите- ративным и основанном на наборах подходах. Это особенно сложно дается тем, кто привык работать с процедурными языками программирования, в которых взаимодействие с данными в файлах осуществляется последова- тельно, как демонстрирует следующий псевдокод: open file fetch first record while not end of file begin process record fetch next record end Данные в файлах (или, если быть точнее, в файлах с индексированным последовательным доступом, или ISAM-файлах) хранятся в определенном порядке. И вы гарантировано можете получать записи из файла именно в таком порядке. Также записи можно получать по одной за раз. Поэтому вы привыкаете, что доступ к данным осуществляется именно так: по порядку и по одной записи за раз. Это похоже на работу с курсором в Т-SQL. По этой причине для разработчиков с навыками процедурного программирования использование курсоров или других итеративных механизмов соответству- ет их опыту и представляется логичным способом работы с данными.
8 Глава 1 Окна в SQL Server Реляционный, основанный на наборах подход к работе с данными фун- даментально отличается от подобных навыков. Чтобы лучше понять его, начнем с определения набора, или множества, принадлежащего создателю теории множеств Георгу Кантору: «Под “множеством” мы понимаем соеди- нение в некое целое М определённых хорошо различимых предметов m на- шего созерцания или нашего мышления (которые будут называться «эле- ментами» множества М)» (по книге Джозефа В. Добена [Joseph W. Dauben] «Georg Cantor» (Princeton University Press, 1990). Это очень емкое определение набора — чтобы сформулировать эту мысль мне бы пришлось потратить многие страницы. Но для целей нашей дискус- сии я сосредоточусь на двух аспектах — один сформулирован в определении явно, а второй — неявно. Целое Заметьте, как используется термин целое. Набор надо восприни- мать и работать с ним как с единым целым. К набору надо относиться как к единому целому, не различая отдельных его элементов. При итератив- ной обработке этот принцип нарушается, потому что с записями файла или курсора работают последовательно и по одной. Таблица в SQL пред- ставляет (хотя и не совсем удачно) реляцию в терминах реляционной модели. При взаимодействии с таблицами с использованием основанных на наборах запросов вы работаете с таблицами как целым в отличие от работы с отдельными строками (кортежами отношений) — как в смысле формулировки декларативного запроса SQL, так и в смысле мысленного отношения к этой операции. Такой способ мышления очень многим дает- ся нелегко. Порядок Заметьте, что нигде в определении набора не упоминается по- рядок элементов. Этому есть серьезная причина — среди элементов на- бора нет никакого порядка. Это еще одна особенность, на привыкание к которой требуется время. В файлах и курсорах элементы всегда рас- положены в определенном порядке, и при получении записей по одной за раз можно рассчитывать на сохранение такого порядка. В таблице нет никакого порядка строк, потому что таблица представляет собой набор. Те, кто не понимают этого, часто путают логический уровень модели дан- ных и язык с физическим уровнем реализации. Они предполагают, что если в таблице есть определенный индекс, неявно гарантируется, что при запросе таблицы доступ к данным всегда будет осуществляться в порядке индекса. Иногда даже на этом предположении строят логику решения. Ясно, что SQL Server не гарантирует такого порядка. Например, един- ственный способ гарантировать определенный порядок строк в результа- те запроса — добавить в запрос предложение представления ORDER BY. И если добавить такое предложение, то нужно понимать, что результат не будет реляционным, потому что он гарантировано упорядочен. Если вам нужно создавать запросы SQL и вы хотите понимать этот язык, вам нужно мыслить в терминах наборов. В такой ситуации оконные функ-
Окна в SQL Server Глава 1 9 ции могут поспособствовать заполнению пробела между итеративным (одна строка за раз в определенном порядке) и основанным на наборах типами мышления (отношение к набору как к единому целому при отсутствии по- рядка). Переходу от старого к новому мышлению способствует оригиналь- ная структура оконных функций. Кстати оконные функции поддерживают предложение ORDER BY, кото- рое используется в ситуациях, когда нужно задать порядок. Но указание по- рядка в такой функции не означает, что она нарушает реляционные принци- пы. Входные данные запроса являются реляционными, то есть без всякого упорядочения, впрочем, как и выходные данные, в которых тоже нет гаран- тии порядка. В условиях операции задано упорядочение — только поэтому в результирующем отношении присутствует атрибут результата. Нет гаран- тии, что результирующие строки будут возвращены оконной функцией в том же порядке. В сущности, в разных оконных функциях в одном запросе могут задаваться разные варианты упорядочения. Подобное упорядочение не име- ет ничего общего, по крайней мере концептуально, с упорядочением в пред- ставлении в запросе. На рис. 1-1 я пытаюсь показать, что как входные, так и выходные данные оконной функции являются реляционными, несмотря на то, что в определении оконной функции задано упорядочивание. Овалами и разным порядком строк во входных и выходных данных я пытаюсь выразить тот факт, что порядок строк не имеет значения. значения, по которым выполняется упорядочение (orderifl, prderdate, val) [10417, 2007-01-16 00:00:00.000,11188.40)4 f (11030,2008-04-17 00:00:00.000,12615.05) \ ( (10981, 2008-03-27 00:00:00.000,15810.00) 1 \ (10865,2008-02-02 00:00:00.000,16387.50) J (10889, 2008-02-16 00:00:00.000,11380.00) У SELECT orderid, orderdate, val, RANK() OVER(ORDER BY val DESC) AS mk from Sales.Ordervalues; Набор результатов (orderid, orderdate, val, mk) (10889, 2008-02-16 00:00:00.000,11380.00, 4) (10417, 2007-01-16 00:00:00.000,11188.40, 5) (10981, 2008-03-27 00:00:00.000,15810.00, 2) (10865, 2008-02-02 00:00:00.000, 16387.50,1) (11030, 2008-04-17 00:00:00.000,12615.05, 3) Рис. 1-1. Входные и выходные данные запроса при использовании оконной функции
10 Глава 1 Окна в SQL Server Есть еще одна особенность оконных функций, которая помогает посте- пенно перейти от итеративного мышления к мышлению в терминах набо- ров. Иногда при объяснении новой темы учителям приходится прибегать к определенной доли «лжи». Представьте, что вы преподаватель, который понимает, что сознание студентов пока не готово к освоению определенной идеи, если ее сразу излагать в полном объеме. Иногда лучше начать объяс- нение идеи в более простых и не совсем корректных терминах — такой под- ход позволяет подготовить студентов к освоению материала. Какое-то время спустя, когда студенты «созреют» для понимания «правды», можно перехо- дить к более глубокому и более корректному изложению материала. Именно такая ситуация возникает с пониманием того, как в принципе вы- числяются оконные функции. Есть базовый вариант объяснения этого мате- риала, он не совсем корректный, но позволяет добиться нужного результата! В этом варианте используется подход, основанный на обработке по одной строке их упорядоченного списка. В дальнейшем используется глубокий, более принципиально корректный способ объяснения этого принципа, но перед его изложением надо подготовить читателя. Во втором способе ис- пользуется подход на основе наборов. Чтобы понять, что я имею в виду, посмотрите на следующий запрос: SELECT orderid, orderdate, val, RANKO OVER(ORDER BY val DESC) AS rnk FROM Sales.OrderValues; Вот сокращенный результат выполнения этого запроса (учтите, что нет никаких гарантий именно такого порядка строк): orderid orderdate val rnk 10865 2008-02-02 00:00:00.000 16387.50 1 10981 2008-03-27 00:00:00.000 15810.00 2 11030 2008-04-17 00:00:00.000 12615.05 3 10889 2008-02-16 00:00:00.000 11380.00 4 10417 2007-01-16 00:00:00.000 11188.40 5 Вот пример базового понимания того, как вычисляются значения ранга в псевдокоде: сортировать строки по значению val итеративный проход строк for each строка if текущая строка является первой в секции, вернуть 1 if значение val равно предыдущему значению val, вернуть предыдущий ранг else вернуть текущее число обработанных строк Рис. 1-2 иллюстрирует такой способ понимания.
Окна в SQL Server Глава 1 11 orderid orderdate val rnk 10865 2008-02-02 00:00:00.000 16387 50 1-^ 10981 2008-03-27 00:00:00.000 15810.00 2«< 11030 2008-04-17 00:00:00.000 12615 05 3*< 10889 2008-02-16 00:00:00.000 11380.00 10417 2007-01-16 00:00:00.000 11188.40 5*> Рис. 1-2. Базовое понимание вычисления значений ранга Хотя такой способ мышления дает правильный результат, он не совсем корректный. Честно говоря, моя задача усложняется еще и тем, что такой процесс очень похож на то, как вычисление ранга физически реализовано в SQL Server. Но на данном этапе моя цель не физическая реализация, а концептуальный уровень — язык и логическая модель. Под «неправильным мышлением» я имею в виду, что концептуально, с точки зрения языка, вы- числение мыслится иначе — в терминах наборов, а не итераций. Помните, что язык никак не связан с конкретной физической реализацией в ядре СУБД. Задача физического уровня — определить, как выполнить логиче- ский запрос, и максимально быстро вернуть правильный результат. А теперь я попытаюсь объяснить, что подразумеваю под более глубоким, правильным пониманием трактовки оконных функций в языке. Функция логически определяет для каждой строки в результирующем наборе запро- са отдельное, независимое окно. В отсутствие ограничений в определении окна вначале каждое окно состоит из набора всех строк результирующе- го набора запроса. В определение окна можно добавлять дополнительные элементы (например, секционирование, кадрирование и т. п., — об этом я расскажу чуть позже), которые дополнительно ограничивают набор строк в каждом окне. На рис. 1-3 приведена графическая иллюстрация этого принципа в применении к нашей ситуации с функцией RANK. orderid orderdate val rnk 10865 2008-02-02 00:00 00 000 16387.50"|tf*-l 10981 2008-03-27 00:00:00.000 15810 00 H.2 11030 2008-04-17 00:00:00.000 12615.05 ?--3 10889 2008-02-16 00:00:00.000 11380.00 ij- 4 10417 2007-01-16 00 00:00.000 11188 4оЛ1. 5 Рис. 1-3. Глубокое понимание вычисления значений ранга Концептуально, с точки зрения каждой оконной функции и строки в ре- зультирующем наборе запроса предложение OVER создает для них отдель- ные окна. В нашем запросе мы никак не ограничивали определение окна, а только задали упорядочение. Поэтому в нашем случае все окна состоят из всех строк в результирующем наборе. И они сосуществуют в одно время. И в каждом ранг рассчитывается как увеличенное на единицу число строк, у которых значение атрибута val больше, чем у текущего значения. Вы наверное понимаете, что многим проще думать в базовых терминах, то есть считать, что данные упорядочены и процесс итеративно проходит
12 Глава 1 Окна в SQL Server по строкам, по одной за раз. И это нормально, что вы начинаете с оконных функций, потому что вам нужно писать запросы правильно, по крайней мере простые. Со временем вы можете постепенно переходить к более глубоко- му пониманию концептуального построения оконных функций и начинать мыслить в терминах наборов. Недостатки альтернатив оконным функциям У оконных функций есть ряд преимуществ перед альтернативными, более традиционными способами выполнения аналогичных задач, например перед групповыми, вложенными и другими запросам. Сейчас я приведу несколько простых примеров. Есть другие отличия, помимо тех, которые я здесь де- монстрирую, но о них пока рано еще говорить. Я начну с традиционных групповых запросов. Они дают новую информа- цию в виде агрегатов, но кое-что при этом теряется — детали. При группировке данных вы вынуждены применять все вычисления в контексте группы. Но что, если нужно выполнить вычисления, которые предусматривают использование как детальных данных, так и агрегатов. Представьте, что надо запросить представление Sales.OrderValues с зака- зами и вычислить размер каждого заказа как процент от общей суммы по клиенту, а также разницу со средним размером заказа для соответствующего клиента. Текущий размер заказа является подробной информацией, а общие и средние значения представляют собой агрегаты. Если сгруппировать дан- ные по клиентам, теряется доступ к значениям отдельных заказов. Один из способов решения этой задачи средствами групповых запросов заключается в создании запроса, который группирует данные по клиентам, определении табличного выражения на основе этого запроса, а затем соединение таблич- ного выражения с базовой таблицей для сопоставления подробных данных с агрегатами. Вот выражение, реализующее эту логику: WITH Aggregates AS ( SELECT custid. SUM(val) AS sumval, AVG(val) AS avgval FROM Sales.Ordervalues GROUP BY custid ) SELECT 0.orderid, 0.custid, O.val. CAST(100. * O.val / A.sumval AS NUMERIC(5, 2)) AS pctcust, O.val - A.avgval AS diffcust FROM Sales.OrderValues AS 0 JOIN Aggregates AS A ON 0.custid = A.custid; Вот сокращенный результат выполнения этого запроса: orderid custid val pctcust diffcust 1 10835 845.80 19.79 133.633334
Окна в SQL Server Глава 1 13 10643 1 814.50 19.06 102.333334 10952 1 471.20 11.03 -240.966666 10692 1 878.00 20.55 165.833334 11011 1 933.50 21.85 221.333334 10702 1 330.00 7.72 -382.166666 10625 2 479.75 34.20 129.012500 10759 2 320.00 22.81 -30.737500 10926 2 514.40 36.67 163.662500 10308 2 88.80 6.33 -261.937500 А теперь представьте себе, что также надо вычислить размер заказа как процент от общей суммы заказов, а также как отклонение от среднего по всем клиентам. Для решения этой задачи придется добавить еще одно таб- личное выражение: WITH OustAggregates AS ( SELECT custid, SUM(val) AS sumval, AVG(val) AS avgval FROM Sales.OrderValues GROUP BY custid ), GrandAggregates AS ( SELECT SUM(val) AS sumval, AVG(val) AS avgval FROM Sales.OrderValues ) SELECT 0.orderid, 0.custid, O.val, CAST(100. * O.val / CA.sumval AS NUMERIC(5, 2)) AS pctcust, O.val - CA.avgval AS diffcust, CAST(100. * O.val / GA.sumval AS NUMERIC(5, 2)) AS pctall, O.val - GA.avgval AS diffall FROM Sales.OrderValues AS 0 JOIN CustAggregates AS CA ON 0.custid = CA.custid CROSS JOIN GrandAggregates AS GA; Вот результат выполнения этого запроса: orderid custid val pctcust diffcust pctall diffall 10835 1 845.80 19.79 133.633334 0.07 -679.252072 10643 1 814.50 19.06 102.333334 0.06 -710.552072 10952 1 471.20 11.03 -240.966666 0.04 -1053.852072 10692 1 878.00 20.55 165.833334 0.07 -647.052072 11011 1 933.50 21.85 221.333334 0.07 -591.552072 10702 1 330.00 7.72 -382.166666 0.03 -1195.052072 10625 2 479.75 34.20 129.012500 0.04 -1045.302072
14 Глава 1 Окна в SQL Server 10759 2 320.00 22.81 -30.737500 0.03 -1205.052072 10926 2 514.40 36.67 163.662500 0.04 -1010.652072 10308 2 88.80 6.33 -261.937500 0.01 -1436.252072 Как видите, запрос становится все сложнее и сложнее, включая все боль- ше табличных выражений и операций соединения. Иначе задачу можно решить, воспользовавшись отдельными вложен- ными запросами для каждого вычисления. Вот альтернативное решение на основе вложенных запросов: -- Вложенные запросы с подробными данными и агрегатами по отдельным клиентам SELECT orderid, custid. val, CAST(1OO. * val / (SELECT SUM(02.val) FROM Sales.OrderValues AS 02 WHERE 02.custid = 01.custid) AS NUMERIC(5, 2)) AS pctcust, val - (SELECT AVG(02.val) FROM Sales.OrderValues AS 02 WHERE 02.custid = 01.custid) AS diffcust FROM Sales.OrderValues AS 01; -- Вложенные запросы с подробными данными и агрегатами по всем и отдельным клиентам SELECT orderid, custid, val, CAST(100. * val / (SELECT SUM(02.val) FROM Sales.OrderValues AS 02 WHERE 02.custid = 01.custid) AS NUMERIC(5, 2)) AS pctcust, val - (SELECT AVG(02.val) FROM Sales.OrderValues AS 02 WHERE 02.custid = 01.custid) AS diffcust, CAST(100. * val / (SELECT SUM(02.val) FROM Sales.OrderValues AS 02) AS NUMERIC(5, 2)) AS pctall, val - (SELECT AVG(02.val) FROM Sales.OrderValues AS 02) AS diffall FROM Sales.OrderValues AS 01; С таким подходом есть две основные проблемы. Во-первых, код слишком пространен и сложен. А, во-вторых, в настоящее время оптимизатор SQL Server не распознает случаи, когда нескольким вложенным запросам нужен доступ к одному и тому же набору строк, поэтому в каждом вложенном за- просе будет выполняться отдельный доступ к данным. Это означает, что чем больше вложенных запросов, тем больше раз будут читаться одни и те же данные. В отличие от предыдущей проблемы, это проблема не языка, а кон- кретной реализации вложенных запросов в SQL Server.
16 Глава 1 Окна в SQL Server заторе SQL Server предусмотрена логика обнаружения нескольких функций с одинаковым определением окна. Обнаружив такие функции, SQL Server читает необходимые данные только раз (независимо от типа операции чте- ния). Например, в последнем запросе SQL Server обратится раз к данным, чтобы вычислить первые две функции (сумму и среднее, секционированные по custid}, и еще раз — чтобы вычислить последние две функции (несекцио- нированные сумму и среднее). Я продемонстрирую этот принцип оптимиза- ции в главе 4. Другое преимущество оконных функций перед вложенными запросами заключается в том, что исходное окно до применения ограничений является результирующим набором запроса. Это означает, что это результирующий набор после применения табличных операторов (например, соединений), фильтров, групп и т. п. Этот результирующий набор — следствие того, на какой фазе логической обработки запроса вычисляются оконные функции. (Далее в этой главе я остановлюсь на этом подробнее.) С другой стороны, вложенный запрос начинает работу с нуля, а не с набора результатов внеш- него запроса. Это означает, что если нужно, чтобы вложенный запрос рабо- тал с тем же набором, что и внешний запрос, в нем придется повторить все те же конструкции, что и во внешнем запросе. В качестве примера представьте, что нужно вычислить процент от суммы и отклонение от среднего только для заказов за 2007 год. При использовании оконных функций достаточно просто добавить в запрос один фильтр: SELECT orderid, custid, val. CAST(100. * val / SUM(val) OVER(PARTITION BY custid) AS NUMERIC(5, 2)) AS pctcust, val - AVG(val) OVER(PARTITION BY custid) AS diffcust, CAST(100. * val / SUM(val) 0VER() AS NUMERIC(5, 2)) AS pctall, val - AVG(val) OVER() AS diffall FROM Sales.OrderValues WHERE orderdate >= '20070101' AND orderdate < '20080101': Исходной точкой всех оконных функций является набор после примене- ния этого фильтра. А вот в решении с вложенными запросами приходится начинать все сначала — фильтр придется повторить во всех вложенных за- просах: SELECT orderid, custid. val. CASTdOO. * val / (SELECT SUM(02.val) FROM Sales.OrderValues AS 02 WHERE 02.custid = 01.custid AND orderdate >= '20070101' AND orderdate < '20080101') AS NUMERIC(5, 2)) AS pctcust, val - (SELECT AVG(02.val)
Окна в SQL Server Глава 1 17 FROM Sales.OrderValues AS 02 WHERE 02.custid = 01.custid AND orderdate >= '20070101' AND orderdate < '20080101') AS diffcust, CAST(100. * val / (SELECT SUM(02.val) FROM Sales.OrderValues AS 02 WHERE orderdate >= '20070101' AND orderdate < '20080101') AS NUMERIC(5, 2)) AS pctall, val - (SELECT AVG(02.val) FROM Sales.OrderValues AS 02 WHERE orderdate >= '20070101' AND orderdate < '20080101') AS diffall FROM Sales.OrderValues AS 01 WHERE orderdate >= '20070101' AND orderdate < '20080101'; Ясно, что можно воспользоваться обходными решениями, например пред- варительно определить обобщенное табличное выражение (СТЕ) на основе запроса, выполняющего фильтрацию, после чего сослаться на это СТЕ из внешнего запроса и вложенных запросов. Однако я хочу сказать, что с окон- ными функциями не нужно никаких ухищрений, потому что они работают на основе результата запроса. Подробнее об этой особенности конструкции оконных функций я расскажу в разделе «Элементы запросов, поддерживаю- щие оконные функции». Как говорилось ранее, оконные функции хорошо поддаются оптимиза- ции, а альтернативные решения часто практически не поддаются оптимиза- ции. Естественно, что есть прямо противоположные ситуации. Я расскажу об оптимизации оконных функций в главе 4, а в главе 5 предоставлю много примеров их эффективного использования. Краткий обзор решений с использованием оконных функций Первые четыре главы книги рассказывают об оконных функциях и их опти- мизации. Материал очень технический, и хотя я считаю его очень интерес- ным, я понимаю, почему некоторые находят его скучным. Многим намного интереснее читать о применении этих функций к решению практических задач — этому посвящена последняя статья. Увидев оконные функции в дей- ствии, вы по-настоящему поймете их ценность. Но как убедить вас потра- тить время на изучение технических деталей и потерпеть до конца, оставив «на сладкое» самую интересную часть. Что, если я прямо сейчас вкратце по- кажу решения задач с применением оконных функций? Я буду решать задачу запроса данных в таблице, содержащей в одном из столбцов последовательность значений, и определения диапазонов последо- вательных значений. Эта задача называется задачей обнаружения остров- 2 Зак.601
18 Глава 1 Окна в SQL Server ков. Последовательность может быть числовой, временной (что случается чаще) или относиться к другому типу данных, который поддерживает пол- ное упорядочение. Последовательность может содержать уникальные зна- чения или разрешать дубликаты. Под интервалом подразумевается любой фиксированный интервал, который соответствует типу столбца (например, целое «1», целое «7», временной интервал в один день, в две недели 2 и т. д.). В главе 5 я рассмотрю разные варианты этой задачи. Здесь я покажу простой случай, который позволит понять, как это работает, — числовая последова- тельность с целым «1» в качестве интервала. Следующий код генерирует пример данных для этой задачи: SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID(‘dbo.Т1’, ‘U’) IS NOT NULL DROP TABLE dbO.TI; GO CREATE TABLE dbo.TI ( coll INT NOT NULL CONSTRAINT PK_T1 PRIMARY KEY ); INSERT INTO dbo.TI(coll) VALUES(2), (3),(11),(12).(13),(27),(33),(34),(35),(42); GO Как видите, в последовательности значений в столбце coll таблицы Т1 есть пробелы. Задача состоит в определении диапазонов с последователь- ными значениями (их еще называют островками) и возвращении начала и конца каждого островка. Вот как должен выглядеть результат: start_range end_range 2 3 11 13 27 27 33 35 42 42 Если вам непонятна практическая ценность этой задачи, скажу, что есть много примеров применения в производственной среде. Вот несколько при- меров: создание отчетов о доступности, определение периодов активности (например, продаж), определение последовательных периодов в которых выполняется определенный критерий (например, периоды, когда стоимость акции была выше или ниже определенного уровня), определение диапазонов используемых автомобильных номерных знаков и т. п. Я специально силь- но упростил пример, чтобы можно было сосредоточиться на используемых
Окна в SQL Server Глава 1 19 приемах. Для решения более сложных задач нужно лишь немного изменить решение в этой простой ситуации. Поэтому считайте это задачкой на сообра- зительность — создайте эффективное, основанное на наборах решение этой задачи. Для начала попытайтесь создать решение, которое просто решает задачу. После этого заполните таблицу серьезным количеством столбцов, например 10 млн, и попытайтесь снова применить свою методику. Оцените производительность. Только после этого посмотрите на мои решения. Прежде чем показать решение на основе оконных функций, я приведу одно из многих решений, в котором применяются традиционные конструк- ции языка. А именно, я воспользуюсь вложенными запросами. Чтобы по- нять стратегию первого решения, посмотрите на значения в последователь- ности Tl.coll, в которую я добавил концептуальный атрибут, которого не было раньше и который я считаю идентификатором группы: coll grp 2 а 3 а 11 b 12 b 13 b 27 с 33 d 34 d 35 d 42 е Атрибута grp пока еще нет. С точки зрения концепции это значение, ко- торое уникально идентифицирует островок. Это означает, что он един у всех членов одного островка и разный у разных островков. Если удастся вычис- лить такой идентификатор группы, после этого можно выполнить группи- ровку по атрибуту grp и вернуть минимальное и максимальное значения coll в каждой группе (островке). Один из способов получения такого иден- тификатора с использованием традиционных конструкций языка — найти для каждого значения coll минимальное значение coll, которое больше или равно текущему значению и у которого нет последующего значения. В качестве примера в соответствии с этой логикой попытайтесь опреде- лить по отношению к значению 2, какое значение в coll больше или равно 2 и размещается перед отсутствующим значением? Это 3. А теперь попробуйте сделать то же самое со значением 3. Вы также получите 3. Поэтому 3 явля- ется идентификатором островка, который начинается с 2 и заканчивается 3. В островке, который начинается с 11 и заканчивается 13, идентификатором группы всех членов является 13. Как видите, идентификатором группы для островка фактически является последний член островка. Вот код Т-SQL, в котором реализован такой подход:
20 Глава 1 Окна в SQL Server SELECT coll, (SELECT MIN(B.coH) FROM dbo.TI AS В WHERE B.C011 >= A.coll -- является ли текущая строка последней в этой группе? AND NOT EXISTS (SELECT * FROM dbo.TI AS C WHERE C.coH = B.coH + 1)) AS grp FROM dbo.TI AS A; Этот запрос дает следующий результат: coll grp 2 3 3 3 11 13 12 13 13 13 27 27 33 35 34 35 35 35 42 42 Следующий этап довольно прямолинеен: определите табличное выраже- ние на основе последнего запроса, а во внешнем запросе выполните группи- ровку по идентификатору группы и верните минимальное и максимальное значения coll в каждой группе: SELECT MIN(col1) AS start_range, MAX(col1) AS end_range FROM (SELECT coll, (SELECT MIN(B.coll) FROM dbo.TI AS В WHERE B.coH >= A.C011 AND NOT EXISTS (SELECT * FROM dbo.TI AS C WHERE C.coH = B.coH + 1)) AS grp FROM dbo.TI AS A) AS D GROUP BY grp; В таком решении есть две проблемы. Во-первых, сложно понять логику решения. Во-вторых, оно работает катастрофически медленно. Я пока не хочу анализировать все планы выполнения — мы еще много будем обсуж- дать их в этой книге, но я могу сказать, что для каждой строки в таблице SQL Server выполняет почти две полных операции чтения данных. А теперь
Окна в SQL Server Глава 1 21 подумайте, сколько работы придется проделать для таблицы с 10 млн строк. Общее число строк, которые нужно обсчитать, невероятно велико. Следующее решение также вычисляет идентификаторы групп, но при этом используются оконные функции. Первый шаг в решении — использо- вание функции ROW_NUMBER для вычисления номеров строк на основе упорядочения по coll. Подробные сведения о функции ROW NUMBER я предоставлю попозже, а сейчас будет достаточно сказать, что она вычисляет уникальные идентификаторы в секции, начиная с 1 и с шагом 1 в соответ- ствии с заданным упорядочением. Таким образом, следующий запрос возвратит значения coll и числа строк на основе упорядочения по coll: SELECT coll, ROW_NUMBER() OVER(ORDER BY coll) AS rownum FROM dbo.TI; coll rownum 2 3 11 12 13 27 33 34 35 42 1 2 3 4 5 6 7 8 9 10 А теперь посмотрим внимательно на эти две последовательности. Первая (coll) содержит пробелы, а вторая (rownum) — нет. Зная это, попытайтесь прикинуть, каково уникальное отношение между двумя последовательно- стями в контексте островка. В рамках островка члены обеих последователь- ностей увеличиваются на фиксированную величину. Поэтому разница меж- ду членами последовательности фиксирована. В следующем островке coll увеличивается более чем на 1, а шаг увеличения rownum остается равным 1, поэтому разница между членами увеличивается. Иначе говоря, разница между членами константа и уникальна в каждом островке. Выполните сле- дующий запрос, который определяет эту разницу: SELECT coll, coll - ROW_NUMBER() OVER(ORDER BY coll) AS diff FROM dbo.TI; coll diff 2 1 3 1 11 8
Окна в SQL Server 22 Глава 1 12 8 13 8 27 21 33 26 34 26 35 26 42 32 Видно, что эта разница удовлетворяет двум требованиям к нашему идентификатору групп, поэтому ее можно использовать в этом качестве. Остальное выглядит так же, как и в предыдущем решении, а именно: нужно сгруппировать строки по идентификатору группы и вернуть максимальное и минимальное значения coll в каждой группе: SELECT MIN(col1) AS start_range, MAX(col1) AS end_range FROM (SELECT coll, -- разница является константой и уникальна в рамках островка coll - ROW_NUMBER() OVER(ORDER BY coll) AS grp FROM dbo.TI) AS D GROUP BY grp; Полюбуйтесь, насколько лаконично и просто выглядит это решение. Конечно, всегда лучше добавить пару-тройку комментариев, чтобы те, кто видит решение в первый раз, могли лучше понять его. Это решение очень эффективно. Усилия по назначению номеров строкам пренебрежительно мало по сравнению с объемом работы, выполняемым в предыдущем решении. Здесь используется только одна операция упорядо- ченного чтения индекса столбца coll и итератор, который циклически уве- личивает значение счетчика. В тесте производительности, который я вы- полнил с 10 млн строк, выполнение этого запроса заняло 10 секунд. Другие решения выполнялись намного дольше. Я надеюсь, что этого короткого примера использования оконных функ- ций достаточно, чтобы заинтриговать вас и показать их мощь. А теперь вер- немся к изучению технических деталей оконных функций. Далее в книге у вас будет возможность увидеть больше примеров. Элементы оконных функций Определение оконной функции указывается в предложении OVER, которое состоит из нескольких элементов. Три ключевых элемента — секционирова- ние, упорядочение и кадрирование. Не все оконные функции поддерживают все эти элементы. По мере описания каждого из элементов я буду указывать оконные функции, которые его поддерживают. Секционирование Элемент секционирования реализован как предложение PARTITION BY и поддерживается всеми оконными функциями. Он ограничивает текущее
Окна в SQL Server Глава 1 23 окно только теми строками результирующего набора запроса, у которых те же значения в столбцах секционирования, что и в текущей строке. Если, к примеру, в функции присутствует предложение PARTITION BY и значение custid в текущей строке равно 1, окно, связанное с текущей строкой, обе- спечит выбор из результирующего набора всех строк, у которых значение custid равно 1. Если значение custid текущей строки равно 2, в окно войдут все строки с custid равным 2. Если предложение PARTITION BY отсутствует, окно ничем не ограничи- вается. Можно относиться к этому по другому и считать, что явно секциони- рование не задано, а секционирование по умолчанию предусматривает, что весь результирующий набор запроса является одной секцией. Если это не очевидно, то я замечу, что разные функции в одном запросе могут использовать разные определения секционирования. Посмотрите на листинг 1-1. Листинг 1 -1. Запрос с двумя функциями RANK SELECT custid, orderid, val, RANKO OVER(ORDER BY val DESC) AS rnk_all, RANKO OVER(PARTITION BY custid ORDER BY val DESC) AS rnk_cust FROM Sales.OrderValues; Заметьте, что первая функция RANK (которая создает атрибут mk_alt) полагается на секционирование по умолчанию, а во второй — (она созда- ет rnk_cust) используется явное секционирование по custid. Рис. 1-4 иллю- стрирует секции, созданные в этом примере на основе трех результатов вы- числений в запросе: одного результата вычисления значения mk_all и двух результатов — rnk_cust. custid orderid val rnk_all rnk_cust 1 ; 1 1 jr : 1 10692 878 00 440 2 : • 1 10835 845.80 457 з • 1 10643 814.50 469 4 ►: : 1 10952 471.20 615 5 : 1 10702 330.00 686 6 : • 2 10926 514.40 592 1 • :2 10625 479.75 608 2 : !2 10759 320.00 691 з ►: 2 10308 88.80 797 Рис. 1-4. Секционирование окон Стрелки показывают от результирующих значений функций на секции окон, которые были использованы для вычисления этих значений.
24 Глава 1 Окна в SQL Server Упорядочение Элемент упорядочения определяет упорядочения вычисления в секции, если это необходимо. В стандартном SQL все функции поддерживают эле- мент упорядочения. Что касается SQL Server, то поначалу он не поддержи- вал элемент упорядочения в агрегатных функциях — поддерживалось толь- ко секционирование. Поддержка упорядочения в агрегатах появилась в SQL Server 2012. Интересно, что элемент упорядочения имел разное значение в разных категориях функций. В функциях ранжирования упорядочение интуитивно понятно. Например, при использовании упорядочения по убыванию функ- ция RANK возвращает значение на единицу больше числа строк в соответ- ствующей секции со значением упорядочения большим, чем это значение в вашей строке. При использовании упорядочения по возрастанию функция возвращает значение на единицу больше числа строк, чем в упорядочении со значениями, по которым выполняется упорядочение и которые меньше, чем значение в вашей строке. Рис. 1-5 иллюстрирует вычисления рангов из ли- стинга 1-1 — на этот раз включая интерпретацию элемента упорядочения. custid orderid val custid orderid val rnk_all rn 1 2 2 2 2 11011 10692 10835 10643 10952 10702 10926 10625 10759 10308 933.50 878.00 845.80 814.50 471.20 330.00 514.40 479.75 320.00 88.80 63 34 71 65 73 10865 10981 11030 10889 10417 16387.50 15810.00 12615.05 11380.00 11188.40 419' 440 457 469 615 686 592 608 691 797 2 10529 10994 10901 10338 11011 10926 10625 10759 10308 50 83 35 55 11011 10692 10835 10643 10952 10702 946.00 940.50 934.50 934.50 933.50 custid orderid val 933.50 878.00 845.80 814.50 471.20 330.00 custid orderid val 514.40 479.75 320.00 88.80 418 строк, у которых val больше 933,50 3 строки, у которых val больше 814,50 2 строки, у которых val больше 320,00 1 Рис. 1 -5. Упорядочение окон Рис. 1-5 представляет окна только трех операций вычисления ранга. Ясно, что их много больше — 1660, если быть совсем точным. Дело в том, что задействовано 830 строк, и в каждой строке выполняются две операции вы- числения ранга. Самое интересное то, что концептуально это выглядит так, как если бы все эти окна сосуществовали одновременно.
Окна в SQL Server Глава 1 25 В агрегатных оконных функциях упорядочение имеет немного другой смысл, чем в ранжирующих оконных функциях. В агрегатах — вразрез с ожидаемым поведением — упорядочение не имеет ничего общего с примене- нием агрегирования — вместо этого элемент упорядочения дает смысл пара- метрам кадрирования, о которых я расскажу вкратце. Иначе говоря, элемент упорядочения служит в качестве вспомогательного элемента, ограничиваю- щего окно. Кадрирование Кадрирование по сути является еще одним фильтром, а не ограничителем строк в секции. Оно применяется как к агрегатным функциям, так и к трем функциям сдвига: FIRST_VALUE, LAST_VALUE и NTH VALUE. Этот эле- мент поддержки окон можно считать определяющим две точки секции теку- щей строки на основе данного упорядочения, обеспечивающий кадрирова- ние строк, к которым будет применяться данное вычисление. К параметрам кадрирования в стандарте относится ROWS или RANGE, который определяет начальный и конечный строки кадра, а также параметр исключения оконного кадра. В SQL Server 2012 появилась поддержка ка- дрирования с полной реализацией параметра ROWS и частичной — параме- тра RANGE, но реализация исключения кадров из окна отсутствует. Параметр ROWS позволяет задать две точки в кадре как смещение (число строк) относительно текущей строки. Параметр RANGE более динамичен и определяет смещения как разницу между значением точки кадра и значени- ем в текущей строке. Параметр исключения кадров из окна определяет, что делать с текущей строкой и ее «братьями» при наличии связей. Это объясне- ние кажется туманным и недостаточным, но пока я не хочу углубляться в де- тали. Далее эти подробностей будет достаточно. На данный момент я просто хочу сформулировать принцип и предоставить простой пример. Вот пример запроса представления EmpOrders, в котором вычисляется количество на- растающим итогом для каждого сотрудника по каждому месяцу: SELECT empid, ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS runqty FROM Sales.EmpOrders; Заметьте, что в оконной функции применяется агрегат SUM по атрибу- ту qty, окно секционируется по empid, строки секции упорядочиваются по ordermonth, а затем секция кадрируется от неограниченной предшествую- щей (нет ограничений снизу) и до текущей строки. Иначе говоря, результат содержит сумму всех предыдущих строк в кадре, включая текущую строку. Этот запрос дает следующий результат (сокращено):
26 Глава 1 Окна в SQL Server empid ordermonth qty run_qty 1 2006-07-01 00:00:00.000 121 121 1 2006-08-01 00:00:00.000 247 368 1 2006-09-01 00:00:00.000 255 623 1 2006-10-01 00:00:00.000 143 766 1 2006-11-01 00:00:00.000 318 1084 2 2006-07-01 00:00:00.000 50 50 2 2006-08-01 00:00:00.000 94 144 2 2006-09-01 00:00:00.000 137 281 2 2006-10-01 00:00:00.000 248 529 2 2006-11-01 00:00:00.000 237 766 Заметьте, что определение окна читать так же просто, как если бы оно было написано на обычном человеческом языке. Более подробно о параме- трах кадрирования мы поговорим в главе 2. Элементы запросов, поддерживающие оконные функции Оконные функции поддерживаются не во всех элементах запросов, а только в предложениях SELECT и ORDER BY. Чтобы вы поняли причину такого ограничения, я сначала объясню принцип, который называется логической обработкой запроса. После этого я вернусь к инструкциям, которые поддер- живают оконные функции, и в конце объясню, как обойти это ограничение в других предложениях. Логическая обработка запросов Логическая обработка запросов описывает принципы оценки запроса SELECT в соответствии с логической системой языка. Она описывает про- цесс, состоящий из нескольких этапов, или фаз, которые начинаются вход- ными таблицами запроса и заканчиваются результирующим набором за- проса. Заметьте, что под логической обработкой запросов я подразумеваю концепцию оценки запроса, которая не обязательно совпадает с физическим процессом обработки запроса сервером SQL Server. В рамках оптимизации SQL Server может сокращать путь, менять порядок некоторых этапов и де- лать все, что ему заблагорассудится. Но все это только при условии, что он возвращает тот же результат, который должен получиться при логической обработке запроса при декларативном его определении. Каждый этап логической обработки запроса работает с одной или не- сколькими таблицами (наборами строк), которые являются входными дан- ными, и возвращает в качестве результата таблицу. Результирующая табли- ца одного этапа становится входной для следующего этапа.
Окна в SQL Server Глава 1 27 На рис. 1-6 представлена схема логической обработки запроса в SQL Server 2012. Есть ли HAVING? Есть ли WHERE? Есть ли ТОР? Есть ли GROUP BY? Есть ли ORDER BY? Есть ли другой табличный оператор?; 1-J1 Декартово произведена Есть ли DISTINCT? 5-2 Удаление дубликатов Начало выполнения Есть ли первый табличный ^^оператор?^^ Каков тип оператора? 1-J2 Фильтр ON 1-J3 Добавление внешних _______строк_______ 1-РЗ Агрегирование 5 Начало выполнения SELECT 5-1 Вычисление выражений 1-А1 Применение табличного выражения 1-Р1 Группировка 1-А2 Добавление внешних _______строк 1-Р2 Разнесение Набор 7-Ь Фильтр Конец Упорядочение Группировка 2 Фильтрация строк 4 Фильтрация групп _0конные /функции Рис. 1-6. Логическая обработка запросов
28 Глава 1 Окна в SQL Server Заметьте, что при написании запроса предложение SELECT всегда пи- шется первым, но в процессе логической обработки оно находится прак- тически в самом конце — непосредственно перед обработкой предложения ORDER BY. Логической обработке запросов можно посвятить целую книгу, но для нашей цели достаточно более лаконичного изложения. Для целей нашей дискуссии важно заметить порядок, в которой обрабатываются разные пред- ложения. Следующий список представляет этот порядок (фазы, в которых разрешены оконные функции, выделены полужирным). 1. FROM 2. WHERE 3. GROUP BY 4. HAVING 5. SELECT 5-1. Вычисление выражений 5-2. Удаление дубликатов 6. ORDER BY 7. OFFSET-FETCH/TOP Понимание процедуры и порядка логической обработки запросов позво- ляет понять, почему использование оконные функции разрешили только в определенных приложениях. Предложения, поддерживающие оконные функции Как видно на рис. 1-6, напрямую оконные функции поддерживают только предложения SELECT и ORDER BY. Причина ограничения заключается в том, чтобы в начале работы с окном избежать неоднозначности при работе с (почти) финальным результирующим набором запроса. Если разрешить оконные функции на этапах, предшествующих этапу SELECT, начальные окна этих этапов могут отличаться от окна этапа SELECT и, поэтому, в не- которых формах запроса будет очень сложно определить правильный ре- зультат. Я попытаюсь продемонстрировать эту неоднозначность на примере. Сначала выполните следующий код, чтобы создать таблицу Т1 и наполнить ее данными: SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID('dbo.TI'U') IS NOT NULL DROP TABLE dbo.TI; GO CREATE TABLE dbo.TI ( coll VARCHAR(IO) NOT NULL CONSTRAINT PK_T1 PRIMARY KEY
Окна в SQL Server Глава 1 29 INSERT INTO dbo.TI(coll) VALUES('A'),(’В’).( С’).( D’).( E’).( F’); Допустим, что оконные функции разрешены на этапах, предшествующих SELECT, например на этапе WHERE. Посмотрите на следующий запрос и по- пытайтесь определить, какие значения coll должны содержаться в результате: SELECT coll FROM dbo.TI WHERE coll > 'В' AND ROW_NUMBER() OVER(ORDER BY coll) <= 3; Прежде чем говорить, что это очевидно, что это должны быть значения С, D и Е, вспомните о принципе «все сразу» в SQL. Этот принцип подразуме- вает, что с точки зрения концепции все выражения одного логического этапа выполняются одновременно. Это значит, что порядок следования выраже- ний не должен влиять на результат. Если так, то следующий запрос должен быть семантически эквивалентен такому: SELECT coll FROM dbo.TI WHERE ROW.NUMBERO OVER(ORDER BY coll) <= 3 AND coll > 'B'; Сможете ли вы на этот раз определить, какое выражение правильное? Это С, D и Е или только С? Это пример неоднозначности, о которой я говорил. Разрешение исполь- зовать оконные функции только в предложениях SELECT и ORDER BY по- зволяет избавиться от этой неоднозначности. При анализе рис. 1-6 вы могли заметить, что на этапе SELECT оконные функции поддерживает шаг 5-1 (Вычисление выражений) и он выполняет перед шагом 5-2 (Удаление дубликатов). Если вы спросите, почему так важ- но знать такие детали, я продемонстрирую, зачем это нужно. Вот вопрос, возвращающий атрибуты empid и country всех сотрудников из таблицы сотрудников Employees: SELECT empid, country FROM HR.Employees; empid country 1 2 3 4 5 6 7 USA USA USA USA UK UK UK
Окна в SQL Server 30 Глава 1 8 USA 9 UK А теперь посмотрите на следующий запрос и попытайтесь до выполнения запроса определить, каким будет результат: SELECT DISTINCT country, ROW_NUMBER() OVER(ORDER BY country) AS rownum FROM HR.Employees; Некоторые будут ожидать такой результат: country rownum UK 1 USA 2 Но на самом деле вы получите это: country rownum UK 1 UK 2 UK 3 UK 4 USA 5 USA 6 USA 7 USA 8 USA 9 А теперь вспомните, что в этом запросе функция ROW NUMBER вычис- ляется на шаге 5-1, на котором вычисляются выражения списка SELECT, — до удаления дубликатов на шаге 5-2. Функция ROW_NUMBER назначает девять уникальных номеров строк, содержащих информацию о сотрудни- ках, поэтому предложению DISTINCT нечего удалять. Когда вы осознаете, что причина в порядке логической обработки запро- са разных элементов, вы можете подумать о решении. Например, можно создать табличное выражение, основанное на запросе, которое просто воз- вращает уникальные страны, и назначать номера строк внешним запросом после удаления дубликатов: WITH EmpCountries AS ( SELECT DISTINCT country FROM HR.Employees ) SELECT country, ROW_NUMBER() OVER(ORDER BY country) AS rownum FROM EmpCountries; Можете ли вы представить себе другие способы решения задачи, по край- ней мере проще, чем это?
Окна в SQL Server Глава 1 31 Тот факт, что оконные функции оцениваются на этапе SELECT или ORDER BY означает, что окно, определенное для вычисления, — до при- менения последующих ограничений — является промежуточной формой строк, полученной после всех предшествующих фаз, то есть после примене- ния FROM со всеми табличными операторами (например, соединениями), а также фильтрации с применением WHERE, группировки и фильтрации групп. Такой запрос можно считать примером: SELECT O.empid, SUM(OD.qty) AS qty, RANKO OVER(ORDER BY SUM(OD.qty) DESC) AS rnk FROM Sales.Orders AS 0 JOIN Sales.OrderDetails AS OD ON 0.orderid = OD.orderid WHERE 0.orderdate >= ‘2007010T AND 0.orderdate < ‘2008010T GROUP BY O.empid; empid qty rnk 4 5273 1 3 4436 2 1 3877 3 8 2843 4 2 2604 5 7 2292 6 6 1738 7 5 1471 8 9 955 9 Сначала вычисляется предложение FROM, после чего выполняется со- единение. Затем фильтр оставляет только строки, относящиеся к 2007 году. После этого оставшиеся строки группируются по идентификатору сотруд- ника. Только после этого вычисляются выражения в списке SELECT, в чис- ле которых функция RANK, которая вычисляется с использование упоря- дочения по убыванию общего количества. Если бы в списке SELECT были другие оконные функции, в них в качестве исходной точки использовался этот же набор результатов. Вспомните, что ранее при обсуждении альтерна- тив оконным функциям (например, вложенных запросов) мы говорили, что они начинают просмотр данных с нуля, то есть нужно повторять всю логи- ку внешнего запроса в каждом вложенном запросе, что сильно увеличивает объем кода. В обход ограничений Я объяснил, почему запретили использование оконных функций на этапах логической обработки запроса, предшествующих предложению SELECT. Но
32 Глава 1 Окна в SQL Server что, если нужно выполнять фильтрацию или группировку на основе вычис- лений, выполненных в оконных функциях? Решение заключается в исполь- зовании табличного выражения, такого как СТЕ или производная таблица. Заставьте запрос вызывать оконную функцию в его списке SELECT, назна- чив выражению псевдоним. Определите на основе этого запроса табличное выражение, после чего сошлитесь на него в запросе по псевдониму. Вот пример, демонстрирующий, как можно фильтровать на основе ре- зультатов оконной функции с использованием СТЕ: WITH С AS ( SELECT orderid, orderdate, val, RANKO OVER(ORDER BY val DESC) AS rnk FROM Sales.OrderValues ) SELECT * FROM C WHERE rnk <= 5; orderid orderdate val rnk 10865 2008-02-02 00:00:00.000 16387.50 1 10981 2008-03-27 00:00:00.000 15810.00 2 11030 2008-04-17 00:00:00.000 12615.05 3 10889 2008-02-16 00:00:00.000 11380.00 4 10417 2007-01-16 00:00:00.000 11188.40 5 В инструкциях, изменяющих данные, оконные функции полностью за- прещены, потому что в этих инструкциях не поддерживаются предложения SELECT и ORDER BY. Но есть случаи, когда оконные функции нужны в из- меняющих данные инструкциях. Табличные выражения позволяют решить и эту проблему, потому что Т-SQL позволяет менять данные через таблич- ные выражения. Продемонстрирую это поведение на примере UPDATE. Сначала выполните следующий код, чтобы создать таблицу Т1 со столбцами coll и со12 и наполнить ее данными: SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID('dbo.TI', 'U') IS NOT NULL DROP TABLE dbo.TI; GO CREATE TABLE dbo.TI ( coll INT NULL, 0012 VARCHAR(IO) NOT NULL
Окна в SQL Server Глава 1 33 INSERT INTO dbo.TI(col2) VALUES(’C’). (’A’), (’В’), (’A’), ('С ), ('В'); Значения столбца col2 определены явно, a coll был заполнен значениями NULL. Представьте, что эта таблица иллюстрирует ситуацию с проблемами с качеством данных. В этой таблице не создан ключ, поэтому невозможно уникально идентифицировать строки. Вы хотите назначить уникальные значения в столбце coll для всех строк. Вы подумали, что удобно было бы использовать функцию ROW_NUMBER в инструкции UPDATE следую- щим образом: UPDATE dbo.TI SET coll = ROOUMBERO OVER(ORDER BY col2); Но, как вы помните, в такой инструкции это запрещено. Обходной способ заключается в создании запроса по отношению к Т1, который возвращает coll, и выражения, основанного на функции ROW_NUMBER (назовем ее rownum); определите табличное выражение, основанное на этом запросе, и, наконец, примените инструкцию UPDATE к СТЕ для присвоения значения rownum столбцу coll: WITH С AS ( SELECT coll, со12, ROW_NUMBER() OVER(ORDER BY col2) AS rownum FROM dbo.TI ) UPDATE C SET coll = rownum; Получите данные из Т1 — вы увидите, что все строки получили уникаль- ное значение в столбце coll: SELECT coll, со12 FROM dbo.TI; coll col2 5 С 1 A 3 В 2 A 6 С 4 В
34 Глава 1 Окна в SQL Server Возможность создания дополнительных фильтров Я показал, как в Т-SQL можно прибегнуть к обходному решению и косвен- ным образом использовать оконные функции в элементах, которые не под- держивают их напрямую. Это обходное решение основано на применении табличного выражения в форме СТЕ или производной таблицы. Приятно иметь дополнительный вариант, но в табличном выражении используется дополнительный уровень запроса и все немного усложняется. Приведенные мной примеры просты, но как насчет длинных и сложных запросов. Возможно ли более простое решение без этого дополнительного уровня? Если говорить об оконных функциях, то в SQL Server на текущий мо- мент нет другого решения. Вместе с тем, интересно посмотреть, как другие справляются с этой проблемой. Например, в Teradata создали фильтрующее предложение, которое называется QUALIFY и принципиально вычисляется после предложения SELECT. Это означает, что в нем можно напрямую об- ращаться к оконным функциям, как в следующем примере: SELECT orderid, orderdate, val FROM Sales.OrderValues QUALIFY RANKO OVER(ORDER BY val DESC) <= 5; Более того, можно ссылаться на псевдонимы столбцов, определенных в списке SELECT, так: SELECT orderid, orderdate, val, RANKO OVER(ORDER BY val DESC) AS rnk FROM Sales.OrderValues QUALIFY rnk <= 5; Предложения QUALIFY нет в стандартном SQL — оно поддерживается только в продуктах Teradata. Но оно кажется очень интересным решением, и было бы неплохо, если бы и стандарт, и в SQL Server удовлетворили такую потребность. Повторное использование определений окон Представьте, что вам нужно вызвать несколько оконных функций в одном запросе, при этом часть определения окна (или все определение) у несколь- ких функций совпадает. Если указать определение окна во всех функциях, код может сильно увеличиться в объеме, как в этом примере: SELECT empid, ordermonth, qty, SUM(qty) OVER (PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run_sum_qty, AVG(qty) OVER (PARTITION BY empid ORDER BY ordermonth
Окна в SQL Server Глава 1 35 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run_avg_qty, MIN(qty) OVER (PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run_min_qty, MAX(qty) OVER (PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS run_max_qty FROM Sales.EmpOrders; В стандартном SQL есть решение этой проблемы в виде предложения, которое называется WINDOW и позволяет присваивать имя определению окна или его части. После этого это имя можно использовать в других опре- делениях окон, используемых в оконных функциях или даже определениях имен других окон. С точки зрения концепции это предложение вычисляется после предложения HAVING и до предложения SELECT. SQL Server пока не поддерживает предложение WINDOW. В стандарт- ном SQL можно сократить предыдущий запрос с использованием предло- жения WINDOW так: SELECT empid, ordermonth, qty, SUM(qty) OVER W1 AS run_sum_qty, AVG(qty) OVER W1 AS run_avg_qty, MIN(qty) OVER W1 AS run_min_qty, MAX(qty) OVER W1 AS run_max_qty FROM Sales.EmpOrders WINDOW W1 AS ( PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ); Как видите, разница ощутима. В данном случае предложение WINDOW присваивает имя W1 полному определению окна с параметрами секциониро- вания, упорядочения и кадрирования. После этого W1 используется в каче- стве определения окна во всех четырех функциях. Предложение WINDOW довольно сложное. Как уже говорилось, не обязательно присваивать имя полному определению окна — можно назначать имя только части опреде- ления. В таком случае определение окна содержит смесь именованных ча- стей и явно заданных параметров. Кстати сказать, описание предложения WINDOW в стандарте SQL занимает целых десять страниц! И разобраться в них не так-то просто. Было бы замечательно, если бы в SQL Server добавили поддержку это- го предложения, особенно теперь, когда расширилась поддержка оконных функций и пользователям придется писать длинные определения окон.
36 Глава 1 Окна в SQL Server Резюме В этой главе рассказано о принципе работы с окнами в SQL. Предоставлены базовые сведения об оконных функциях и объяснено, почему их использу- ют. Далее приведены небольшие примеры использования оконных функций для решения практической задачи по нахождению непрерывных диапазо- нов в последовательностях — она еще называется обнаружением островков. Далее рассказывается о конструкции оконных функций, а также об элемен- тах определений оконных функций: секционирования, упорядочения и ка- дрирования. В конце главы объясняется, как в стандартном SQL решается задача повторного использования полного или частичного определения окна. В главе 3 оконные функции описаны еще подробнее.
Глава 2 Более подробные сведения об оконных функциях В этой главе мы поближе познакомимся с оконными функциями, подробнее останавливаясь на каждой из них. Основное внимание мы уделим логиче- ским аспектам этих функций. Подробнее об оптимизации см. главу 4. Основная причина разделения обсуждения функций на два уровня в разных главах заключается в том, что SQL работает только с логическим уровнем. А так как Microsoft SQL Server реализует эти функции на основе стандарта, обсуждение логической стороны функций будет интересно чи- тателям, которые используют СУБД отличные от SQL Server. Глава 4 по- священа оптимизации функций, а именно физическому уровню, который сильно отличается от платформы к платформе и интересен в основном поль- зователям, работающим с SQL Server. Эта глава разбита по категориям функций: функции агрегирования, функ- ции ранжирования, аналитические функции и функции сдвига. В каждой ка- тегории функций я сначала объясню, какие элементы оконных функций под- держиваются категорией, после чего объясню особенности каждой функции. Если функция появилась или была существенно расширена в SQL Server 2012, я обычно рассказываю об альтернативных решениях, существовавших до SQL Server 2012, или даю ссылку на раздел, где обсуждались такие альтернативы. Оконные функции агрегирования В этом разделе рассказывается об оконных функциях агрегирования. Сначала я объясню, как принцип окон работает в этих функциях, после чего подробно расскажу об элементах, поддерживаемых в определении оконных функций агрегирования и их назначении. После этого я остановлюсь над более специализированными возможностями, такими как идеи по поводу фильтрации, обработка тех или иных агрегатов, в том числе вложенных. Оконные функции агрегирования Оконные функции агрегирования представляют собой то же, что и агрегат- ные функции группировки, но вместо применения к группам в групповых
38 Глава 2 Более подробные сведения об оконных функциях запросах они применяются к окнам, определяемых в предложении OVER. Функция агрегирования должна применяться к наборам строк, и ее не долж- но интересовать, какой механизм языка применен для определения набора. Поддерживаемые элементы В стандартном SQL оконные функции агрегирования поддерживают три элемента: секционирование, упорядочение и кадрирование. Обобщенная форма оконной функции агрегирования выглядит так: имя_функции(<аргументь/>) OVER( [ предложение секционирования окна> ] [ Предложение упорядочения окна> [ Предложение кадрирования окна> ] ] ) Задача этих трех элементов — фильтровать строки в окне. В SQL Server 2005 появилась поддержка элемента секционирования, в том числе агрегатов CLR-агрегатов (Common Language Runtime). В SQL Server 2012 появились возможности упорядочения и кадрирования, но поддержка CLR-агрегатов пока не появилась. Если к функции не применять никаких ограничений — то есть, когда в скобках предложения OVER пусто, окно состоит из всех строк в результи- рующем наборе базового запроса. Точнее, начальное окно состоит из набора строк в виртуальной таблице, предоставленной в качестве входных данных на логической фазе обработки запроса, где находится оконная функция. Это означает, что если оконная функция присутствует в списке SELECT запро- са, на фазу 5-1 в качестве входных данных поступает виртуальная таблица (см. рис. 1-6 в главе 1). Эта фаза наступает после обработки предложений FROM, WHERE, GROUP BY и HAVING и до удаления дублирующихся строк, если задано предложение DISTINCT (фаза 5-2). Но это начальное окно до применения ограничений. В следующих разделах рассказывается, как далее сократить окно. Секционирование Элемент секционирования позволяет ограничить окно только строками, у ко- торых те же атрибуты секционирования, что и в текущей строке. Одни счита- ют элемент секционирования похожим на группировку, другие — на взаимос- вязанные вложенные запросы, но он отличается от того и от другого. В отли- чие от группировки секционирование применяется к одному окну функции и может отличаться у разных функций одного запроса. В отличие от связан- ных вложенных запросов, секционирование фильтрует строки виртуальных таблиц, предоставленных в качестве входных данных на фазе SELECT, что отличается от создания свежего представления данных и необходимости по- вторить все конструкции, которые присутствуют во внешнем запросе. В качестве примера секционирования следующий запрос вызывает две функции агрегирования SUM — одна без секционирования, а вторая с сек- ционированием по custid:
Более подробные сведения об оконных функциях Глава 2 39 USE TSQL2012; SELECT orderid, custid, val, SUM(val) OVER() AS sumall, SUM(val) OVER(PARTITION BY custid) AS sumcust FROM Sales.OrderValues AS 01: orderid custid val sumall sumcust 10643 1 814.50 1265793.22 4273.00 10692 1 878.00 1265793.22 4273.00 10702 1 330.00 1265793.22 4273.00 10835 1 845.80 1265793.22 4273.00 10952 1 471.20 1265793.22 4273.00 11011 1 933.50 1265793.22 4273.00 10926 2 514.40 1265793.22 1402.95 10759 2 320.00 1265793.22 1402.95 10625 2 479.75 1265793.22 1402.95 10308 2 88.80 1265793.22 1402.95 Первая функция вычисляет для всех строк общую сумму val (атрибут sumall), а вторая — общую сумму val для каждого клиента (атрибут sumcust). На рис. 2-1 развернуты три произвольных суммы и иллюстрируются окна, используемые для их вычисления. orderid custid val sumall sumcust 10643 1 814.50; 1265793.22^4273.00 10692 1 878 00г 1265793.22; 4273.00 10702 1 330.00; 1265793.221 4273 00 10835 1 845.80; 1265793.22; 4273.00 10952 1 471.201 1265793.22! 4273.00 11011 1 933.50; 1265793.221 4273.00 10926 2 514.40! 1265793.22 1402.95 10759 2 320 00; 1265793.22 1402.95 10625 2 479.75J 1265793.22 1402.95 10308 2 88.80 1 1265793.22 1402.95 Рис. 2-1. Первый пример секционирования Заметьте, что в случае атрибута sumall, вычисленного для заказа 10692, соответствующее окно состоит со всех строк результирующего набора базо- вого запроса, потому что явно не указан элемент секционирования. Поэтому общая вызываемая сумма val для этой строки 1 265 793,22, как и для всех остальных строк. Что касается атрибута sumcust, то вычисляющая его окон- ная функция секционирована по custid, поэтому строки с другими значения- ми custid содержат другие, несвязанные подмножества своих соответствую- щих окон. Именно такая ситуация с двумя развернутыми заказами: 10643 и 10926. Первый принадлежит клиенту 1, поэтому соответствующее окно
40 Глава 2 Более подробные сведения об оконных функциях состоит из строк с идентификатором клиента custid 1 и дает в сумме 4273,00. Второй принадлежит клиенту 2, поэтому соответствующее окно состоит из строк с идентификатором клиента custid 2 и дает в сумме 1402,95. Во втором примере секционирования элементы подробностей и оконные функции агрегирования для вычисления размера текущего заказа в процен- тах от общей суммы по всей базе, а также от общей сумме по конкретному клиенту: SELECT orderid, custid, val. CAST(100. * val / SUM(val) OVER() AS NUMERIC(5, 2)) AS pctall, CAST(100. * val / SUM(val) OVER(PARTITION BY custid) AS NUMERIC(5, 2)) AS pctcust FROM Sales.OrderValues AS 01; orderid custid val pctall pctcust 10643 1 814.50 0.06 19.06 10692 1 878.00 0.07 20.55 10702 1 330.00 0.03 7.72 10835 1 845.80 0.07 19.79 10952 1 471.20 0.04 11.03 11011 1 933.50 0.07 21.85 10926 2 514.40 0.04 36.67 10759 2 320.00 0.03 22.81 10625 2 479.75 0.04 34.20 10308 2 88.80 0.01 6.33 Рис. 2-2 иллюстрирует секции, использованные в трех вычислениях, ко- торые для наглядности развернуты. Рис. 2-2. Второй пример секционирования
Более подробные сведения об оконных функциях Глава 2 41 На рисунке также сделана попытка отобразить мысль о том, что с точки зрения концепции все окна сосуществуют в одно время. Каждый прямоуголь- ник показывает окно одной функции для одного конкретного заказа. Самый большой прямоугольник в самом низу — пример окна, сгенерированного для одного из заказов, когда внутри скобок предложения OVER пусто. Два меньших прямоугольника представляют окна двух заказов, при этом пред- ложение OVER содержит строку PARTITION BY custid. Прямоугольник на- верху относится к заказу со значением 1 атрибута custid, а прямоугольник внизу — со значением 2. Упорядочение и кадрирование Кадрирование — еще один параметр, позволяющий дополнительно ограни- чивать состав строк в секции окна. Элемент упорядочения играет разные роли в оконных функциях агрегатов с одной стороны и в функциях ранжи- рования, в аналитических функциях и функция смещения с другой. В функ- циях агрегирования упорядочение всего лишь вносит определенный смысл в параметр кадрирования. При наличии определения упорядочения кадри- рование определяет две границы в секции окна, так что фильтр проходят только строки между этими двумя границами. Ранее я приводил общую форму оконной функции агрегирования. Напомню ее еще раз: имя_функции(<аргументь/>) OVER( [ предложение секционирования окна> ] [ Предложение упорядочения окна> [ Предложение кадрирования окна> ] ] ) Предложение оконного кадра может содержать три компонента: <единицы_оконного_кадра> <экстент_оконного_кадра> [ <исключение_оконного_ кадра> ] В единицах оконного кадра указывается ROWS или RANGE. Первый ва- риант означает, чтоб границы, или конечные точки, кадра могут выражаться как смещение в виде числа строк или разницы от текущей строки. Последнее означает, что смещения более динамичны и выражаются как логическая раз- ница со значением атрибута упорядочения текущей строки (и только ее). Эта часть станет понятнее в примерах, которые я приведу чуть позже. В экстенте оконного кадра указываются смещения границ по отношению к текущей строке. В SQL Server 2012 реализован параметр ROWS со всеми связанными па- раметрами экстента оконного кадра функции, а также параметр RANGE с частичной реализацией связанных параметров экстента оконного кадра. Наконец, исключение оконной функции позволяет указать, надо ли ис- ключать текущую строку, ее «сотоварищей» или и ее, и других. Исключения оконных функций в SQL Server 2012 не реализованы. Параметр ROWS экстента оконного кадра Я начну с примеров исполь- зования предложения ROWS. Как уже говорилось, использование ROWS в
42 Глава 2 Более подробные сведения об оконных функциях качестве единицы оконного кадра означает, что вы указываете границы кадра как смещения в виде числа строк от текущей строки. Стандартное предло- жение ROWS поддерживает следующие параметры — все они реализованы в SQL Server 2012: ROWS BETWEEN UNBOUNDED PRECEDING | < n> PRECEDING | < n> FOLLOWING | CURRENT ROW AND UNBOUNDED FOLLOWING | < n> PRECEDING I < n> FOLLOWING | CURRENT ROW Эти параметры самоочевидны, но на тот случай, если это не так, я вкрат- це объясню их. В качестве нижней границы кадра значение UNBOUNDED PRECEDING означает, что нет никакой нижней границы; <n> PRECEDING и <n> FOLLOWING означает соответственно число строк перед и после текущей, a CURRENT ROW означает, что начальным является текущая строка. Что касается верхней границы, то здесь параметры очень похожи, за ис- ключением того, что если не нужно ограничения сверху, нужно указывать UNBOUNDED FOLLOWING. В качестве примера посмотрите на следующий кадр: PARTITION BY custid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW Оконный кадр, создаваемый для каждой строки, содержит все строки от первого месяца до текущей строки. Заметьте, что вы можете использовать ROWS UNBOUNDED PRECEDING как в качестве команды для выбора строк от первой до текущей. Но если вообще опустить часть экстента окон- ного кадра, оставив только части секционирования и упорядочения, то по умолчанию вы получите немного другой результат. Мы поговорим об этом позже при обсуждении параметра RANGE. В качестве первого примера использования параметра ROWS приведу следующий запрос представления Sales.EmpOrders (после запроса приво- дится сокращенный результат выполнения): SELECT empid, ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS runqty FROM Sales.EmpOrders;
Более подробные сведения об оконных функциях Глава 2 43 empid ordermonth qty runqty 1 2006-07-01 00:00:00.000 121 121 1 2006-08-01 00:00:00.000 247 368 1 2006-09-01 00:00:00.000 255 623 1 2006-10-01 00:00:00.000 143 766 1 2006-11-01 00:00:00.000 318 1084 2 2006-07-01 00:00:00.000 50 50 2 2006-08-01 00:00:00.000 94 144 2 2006-09-01 00:00:00.000 137 281 2 2006-10-01 00:00:00.000 248 529 2 2006-11-01 00:00:00.000 237 766 В этом запросе описанное выше определение кадра используется для вы- числения нарастающим итогом количества для каждого сотрудника и меся- ца. Помните, что можно использовать более лаконичную форму кадра без потери смысла: SELECT empid, ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth ROWS UNBOUNDED PRECEDING) AS runqty FROM Sales.EmpOrders; Рис. 2-3 иллюстрирует строки, стрелками показаны соответствующие им кадры. empid ordermonth qty runqty 1 2006-07-01 00:00:00 000 121411 121 1 2006-08-01 00:00:00.000 247 I 368 1 2006-09-01 00:00:00.000 255 1 623 1 2006-10-01 00:00:00 000 143 ’ 766 1 2006-11-01 00:00:00.000 318 >1084 2 2006-07-01 00:00:00.000 50 41 50 2 2006-08-01 00:00:00.000 94 I 144 2 2006-09-01 00:00:00.000 137 I 281 2 2006-10-01 00:00:00 000 248 ’ 529 2 2006-11-01 00:00:00.000 237 •766 Рис. 2-3. Пример кадра: ROWS UNBOUNDED PRECEDING В качестве второго примера использования параметра ROWS создадим три оконные функции с тремя определениями кадра: SELECT empid, ordermonth, MAX(qty) OVER(PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN 1 PRECEDING
44 Глава 2 Более подробные сведения об оконных функциях AND 1 PRECEDING) AS prvqty, qty AS curqty, MAX(qty) OVER(PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN 1 FOLLOWING AND 1 FOLLOWING) AS nxtqty, AVG(qty) OVER(PARTITION BY empid ORDER BY ordermonth ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS avgqty FROM Sales.EmpOrders; empid ordermonth prvqty curqty nxtqty avgqty 1 2006-07-01 00:00:00.000 NULL 121 247 184 1 2006-08-01 00:00:00.000 121 247 255 207 1 2006-09-01 00:00:00.000 247 255 143 215 1 2006-10-01 00:00:00.000 255 143 318 238 1 2006-11-01 00:00:00.000 143 318 536 332 1 2008-01-01 00:00:00.000 583 397 566 515 1 2008-02-01 00:00:00.000 397 566 467 476 1 2008-03-01 00:00:00.000 566 467 586 539 2008-04-01 00:00:00.000 467 586 299 450 1 2008-05-01 00:00:00.000 586 299 NULL 442 При вычислении атрибута prvqty определяется кадр, состоящий из строк между 1 предыдущей и 1 предыдущей. Это означает, что кадр содержит только предыдущую строку в секции. Агрегат МАХ, применяемый здесь к атрибуту qty, излишен, потому что в кадре будет максимум одна строка. Максимальное значение qty будет значение qty в этой строке или NULL. Если в кадре строк нет (то есть, если текущая строка является первой в сек- ции). На рис. 2-4 показаны кадры, соответствующие каждой строке и содер- жащие не более одной строки. empid ordermonth prvqty curqty nxtqty avgqty 1 2006-07-01 00:00:00.000 NULi< „121 247 184 1 2006-08-01 00:00:00 000 121^ „247 255 207 1 2006-09-01 00:00:00.000 247^ „255 143 215 1 2006-10-01 00:00.00.000 255^ „143 318 238 1 2006-11-0100:00:00 000 143^ 318 536 332 1 2008-01-01 00 00:00.000 583^397 566 515 1 2008-02-01 00:00:00.000 397^ „566 467 476 1 2008-03-01 00:00:00 000 566^ л467 586 539 1 2008-04-01 00 00:00.000 467^ „586 299 450 1 2008-05-01 00:00:00.000 586^ 299 NULL 442 Рис. 2-4. Пример кадра: ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING
Более подробные сведения об оконных функциях Глава 2 45 Заметьте, что у первой строки в секции нет соответствующей предыду- щей, поэтому значение pruqty в первой строке секции равно NULL. Аналогично при вычислении атрибута nxtqty определяется кадр, состоящий из строк между 1 предыдущей и 1 предыдущей, то есть имеется в виду только следующая строка. Агрегат MAX(qty) возвращает значение qty из предыдущей строки. На рис. 2-4 показаны кадры, соответствующие каждой строке. empid ordermonth prvqty ! curqty nxtqty avgqty 1 2006-07-01 00:00:00.000 NULL 121 /247 184 1 2006-08-01 00:00:00.000 121 247*" /255 207 1 2006-09-01 00:00:00.000 247 255z /143 215 1 2006-10-01 00:00:00.000 255 143z /318 238 1 2006-11-01 00:00:00.000 143 318^ /536 332 1 2008-01-01 00:00:00.000 583 397 /566 515 1 2008-02-01 00:00:00.000 397 566z /467 476 1 2008-03-01 00:00:00.000 566 467z /586 539 1 2008-04-01 00:00:00.000 467 586Z /299 450 1 2008-05-01 00:00:00.000 586 299^ /NULL 442 Рис. 2-5. Пример кадра: ROWS BETWEEN 1 FOLLOWING AND 1 FOLLOWING Так как за последней строкой в секции никаких других строк нет, значе- ние nxtqty в последней строке секции равно NULL. | ~^-с| Примечание Далее в этой главе, в разделе, посвященном функциям смещения, вы увидите более компактные способы получения значения одной строки, отстоящей от текущей на определенное значение смещения. В частности, вы увидите, как получить значение из предыдущей строки с помощью функции LAG и из следующей строки — с помощью функции LEAD. При вычислении атрибута avgqty определяется кадр, состоящий из строк между 1 предыдущей и 1 следующей, то есть численность строк в кадре мо- жет достигать трех. На рис. 2-6 в качестве примера показаны кадры, соот- ветствующие двум строкам. empid ordermonth prvqty curqty nxtqty avgqty 1 2006-07-01 00:00:00.000 NULL 121 247 184 1 2006-08-01 00:00:00.000 121 247“ £255 207 1 2006-09-01 00:00:00 000 247 255 -215 1 2006-10-01 00:00:00.000 255 143_J 5318 238 1 2006-11-01 00:00:00.000 143 318 536 332 1 2008-01-01 00:00:00.000 583 397“ £566 515 1 2008-02-01 00:00:00 000 397 566 -476 1 2008-03-01 00:00:00 000 566 467_J 5 586 539 1 2008-04-01 00:00:00.000 467 586 299 450 1 2008-05-01 00:00:00 000 586 299 NULL 442 Рис. 2-6. Пример кадра: ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING Как и предыдущих примерах, нет строки, предшествующей первой стро- ке, и строки, последующей за последней. Функция AVG корректно делит сумму на количество строк в кадре.
46 Глава 2 Более подробные сведения об оконных функциях В совокупности элементы секционирования и упорядочения в представ- лении EmpOrders уникальны. Это означает, что одна комбинация значений empid и ordermonth в рамках представления не повторяется. А это в свою очередь означает, что три использованных в нашем запросе вычисления яв- ляются детерминистическими, то есть одному определенному состоянию входных данных запроса соответствует только один правильный результат. Однако ситуация меняется, если комбинация элементов секционирова- ния и упорядочения не уникальна. Тогда вычисления с параметром ROWS не являются детерминистическими. Продемонстрирую это поведение на примере. Выполните код листинга 2-1, чтобы создать и наполнить данными таблицу Т1. Листинг 2-1. DDL и данные для таблицы Т1 SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID(’dbo.TI’, ’U’) IS NOT NULL DROP TABLE dbo.TI; GO CREATE TABLE dbo.TI ( keyed INT NOT NULL CONSTRAINT PK_T1 PRIMARY KEY, coll VARCHAR(IO) NOT NULL ); INSERT INTO dbo.TI VALUES (2, ’A'),(3, ’A’). (5, B),(7, B).(11, ’B'), (13, ’C’),(17, ’C’),(19, ’C’),(23, C); Посмотрите на следующий запрос и результат его работы: SELECT keyed, coll, COUNT(*) OVER(ORDER BY coll ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ent FROM dbo.TI; keycol coll ent 2 A 1 3 A 2 5 В 3 7 В 4 11 В 5 13 С 6 17 С 7 19 С 8 23 С 9
Более подробные сведения об оконных функциях Глава 2 47 Заметьте, что разным строкам с одинаковым секционированием (в дан- ном случае неприменимо) и упорядочением назначаются разные порядко- вые номера. Причина в том, что упорядочение равноправных строк (то есть строк с одинаковыми параметрами секционирования и упорядочения) вы- полняется произвольно, иначе говоря, определяется реализацией. В SQL Server оно просто зависит от оптимизации. Например, если я создам сле- дующий индекс: CREATE UNIQUE INDEX idx_col1D_keycol ON dbo.T1(col1 DESC, keyed); то при повторном выполнении запроса я получу следующий результат: keycol coll ent 3 А 1 2 А 2 5 В 3 11 В 4 7 В 5 23 С 6 19 С 7 17 С 8 13 С 9 С технической точки зрения, то есть что касается стандарта, то оба ре- зультата абсолютно правильны. Если нужно обеспечить детерминистический результат, нужно обеспе- чить, чтобы комбинации элементов упорядочения и секционирования были уникальны. Для этого нужно добавить в определения упорядочения допол- нительное условие, например столбец основного ключа: SELECT keycol, coll, COUNT(*) OVER(ORDER BY coll, keycol ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ent FROM dbo.TI; keycol coll ent 2 A 1 3 A 2 5 В 3 7 В 4 11 В 5 13 С 6 17 С 7 19 С 8 23 С 9
48 Глава 2 Более подробные сведения об оконных функциях Теперь запрос стал детерминистическим, то есть существует только один правильный результат. Параметр RANGE экстента оконного кадра Стандартный SQL также по- зволяет определять экстент оконного кадра с использованием параметра RANGE. Вот перечень возможностей для верхней и нижней границ, или ко- нечных точек, в кадре: RANGE BETWEEN UNBOUNDED PRECEDING | <val> PRECEDING | <val> FOLLOWING | CURRENT ROW AND UNBOUNDED FOLLOWING | <val> PRECEDING | <val> FOLLOWING | CURRENT ROW Этот параметр предназначен для более динамического назначения верх- ней и нижней границ — как логической разницы между текущим порядко- вым значением строки и значением границы. О разнице можно говорить так: «Дайте мне общее количество за последние три периода активности». Или так: «Дайте мне общее количество за период, начинающийся за два меся- ца до текущего периода и оканчивающийся текущим периодом». Первый запрос ориентирован на ROWS, а второй — представлен в стиле RANGE. (Вскоре мы поговорим об этом примере подробнее.) В SQL Server 2012 поддержка RANGE реализована не полностью. Сейчас поддерживаются только определители границ кадра UNBOUNDED и CURRENT ROW. Отсутствует поддержка временного типа INTERVAL, ко- торый в связке с параметром RANGE мог бы существенно повысить гиб- кость определения кадра. Вот пример: следующий запрос определяет кадр с началом за два месяца до текущего и текущим месяцем в качестве конца кадра (этот запрос невозможно выполнить в SQL Server 2012). SELECT empid, ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth RANGE BETWEEN INTERVAL ’2' MONTH PRECEDING AND CURRENT ROW) AS sum3month FROM Sales.EmpOrders; Это отличается от использования ROWS BETWEEN 2 PRECEDING AND CURRENT ROW, даже если месяц заказа является уникальным для каждого сотрудника. Представьте себе, что сотрудник может не заключать никаких сделок в определенные месяцы. При использовании ROWS кадр просто начинается за две строки до текущей, что может охватывать период больший, чем два месяца до текущего. При наличии RANGE кадр более ди- намичен и может начинаться за два месяца до текущего, независимо от того,
Более подробные сведения об оконных функциях Глава 2 49 сколько строк попадает в этот период. Рис. 2-7 иллюстрирует кадры, соот- ветствующие некоторым строкам в базовом запросе. empid ordermonth qty sum3month 9 2006-07-01 294 4 294 9 2006-10-01 256 1 256 9 2006-12-01 25 281 9 2007-01-01 74 I 99 9 2007-03-01 137 1 211 9 2007-04-01 52 I 189 9 2007-05-01 8 I 197 9 2007-06-01 161 I 221 9 2007-07-01 4 I 173 9 2007-08-01 98 263 Рис. 2-7. Пример кадра: RANGE INTERVAL '2' MONTH PRECEDING Заметьте, что число строк в различных кадрах бывает 1, 2 и 3. Так про- исходит потому, что некоторые сотрудники в некоторые месяцы могут не заключать сделок. Как и ROWS, параметр RANGE также поддерживает лаконичную форму выражения желаемого. Если не указать верхнюю границу, предполагается, что это CURRENT ROW (то есть текущая строка). Поэтому в нашем приме- ре вместо RANGE BETWEEN INTERVAL ’2’ MONTH PRECEDING AND CURRENT ROW мы можем использовать только RANGE INTERVAL '2' MONTH PRECEDING. Но, как я уже сказал, этот запрос не будет работать в SQL Server 2012 из-за неполной поддержки параметра RANGE и отсутствия поддержки типа INTERVAL. На данный момент приходится пользоваться альтернативными методами. Решить задачу можно с использованием суще- ствующих оконных функций, но решения эти непростые. Другой вариант — воспользоваться традиционными конструкциями, например вложенными запросами, как в этом примере: SELECT empid, ordermonth, qty, (SELECT SUM(qty) FROM Sales.EmpOrders AS 02 WHERE 02.empid = 01.empid AND 02.ordermonth BETWEEN DATEADD(month, -2, 01.ordermonth) AND 01.ordermonth) AS sum3month FROM Sales.EmpOrders AS 01; Как говорилось, SQL Server 2012 не поддерживает параметр RANGE с UNBOUNDED и CURRENT ROW в качестве границ кадра. Например, оконная функция в следующем запросе вычисляет нарастающий итог с на- чала работы сотрудника до текущего месяца: SELECT empid, ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth 3 3ak.6O1
50 Глава 2 Более подробные сведения об оконных функциях RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS runqty FROM Sales.EmpOrders; empid ordermonth qty runqty 1 2006-07-01 00:00:00.000 121 121 1 2006-08-01 00:00:00.000 247 368 1 2006-09-01 00:00:00.000 255 623 1 2006-10-01 00:00:00.000 143 766 1 2006-11-01 00:00:00.000 318 1084 2 2006-07-01 00:00:00.000 50 50 2 2006-08-01 00:00:00.000 94 144 2 2006-09-01 00:00:00.000 137 281 2 2006-10-01 00:00:00.000 248 529 2 2006-11-01 00:00:00.000 237 766 На рис. 2-8 показаны кадры, соответствующие каждой строке базового запроса. empid ordermonth qty runqty 1 2006-07-01 00:00:00.000 121111 121 1 2006-08-01 00:00:00.000 247 1 368 1 2006-09-01 00:00:00.000 255 I 623 1 2006-10-01 00:00:00.000 143 766 1 2006-11-01 00:00:00.000 318 1084 2 2006-07-01 00:00:00.000 50 *11 50 2 2006-08-01 00:00:00.000 94 1 144 2 2006-09-01 00:00:00.000 137 1 281 2 2006-10-01 00:00:00.000 248 529 2 2006-11-01 00:00:00.000 237 766 Рис. 2-8. Пример кадра: RANGE UNBOUNDED PRECEDING Как вы помните, если не указать верхнюю границу, по умолчанию под- разумевается текущая строка (CURRENT ROW). Поэтому вместо исполь- зования RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, можно применить более короткую форму RANGE UNBOUNDED PRECEDING: SELECT empid, ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth RANGE UNBOUNDED PRECEDING) AS runqty FROM Sales.EmpOrders;
Более подробные сведения об оконных функциях Глава 2 51 Этот оконный кадр также выбирается по умолчанию, если задать упоря- дочение окна без явного определения экстента оконного кадра. Таким обра- зом, следующий запрос логически эквивалентен последним двум: SELECT empid. ordermonth, qty, SUM(qty) OVER(PARTITION BY empid ORDER BY ordermonth) AS runqty FROM Sales.EmpOrders; Получается существенное сокращение объема кода. Если вы внимательно следили за примерами использования ROWS п RANGE, то сейчас вправе спросить, есть ли какая-либо разница между ними при использовании в качестве границ кадра только UNBOUNDED и CURRENT ROW. Например, при сравнении рис. 2-3 (на котором показаны кадры, определенные с применением ROWS UNBOUNDED PRECEDING) и рис. 2-8 (кадры, определенные с применением RANGE UNBOUNDED PRECEDING) кажутся идентичными. Действительно, эти два определения экстента оконного кадра имеют одинаковое логическое значение при уни- кальной комбинации элементов секционирования и упорядочения. В запро- се представления EmpOrders с empid в качестве элемента секционирования и ordermonth в качестве элемента упорядочения эта комбинация уникальна. Поэтому в этом случае два параметра эквивалентны. Разница возникает при неуникальности сочетания элементов секционирования и упорядочения, то есть возможны связи. Для демонстрации разницы я воспользуюсь таблицей Т1, которую мы создали и наполнили с помощью кода в листинге 2-1. Напомню, что при ис- пользовании параметра ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (или ROWS UNBOUNDED PRECEDING) кадр за- канчивается текущей строкой и не содержит последующих строк: SELECT keycol, coll, COUNT(*) OVER(ORDER BY coll ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ent FROM dbo.TI; keycol coll ent 2 A 1 3 A 2 5 В 3 7 В 4 11 В 5 13 С 6 17 С 7 19 С 8 23 С 9
52 Глава 2 Более подробные сведения об оконных функциях Вот похожий запрос, но здесь вместо ROWS используется RANGE: SELECT keycol, coll, COUNT(*) OVER(ORDER BY coll RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ent FROM dbo.TI; keycol coll ent 2 3 5 7 11 13 17 19 23 А 2 А 2 В 5 В 5 В 5 С 9 С 9 С 9 С 9 При использовании RANGE и указании в качестве верхней границы CURRENT_ROW по умолчанию включаются сопутствующие строки. Хотя параметр CURRENT ROW и означает «текущая строка», это в реальности означает текущее значение упорядочения. На понятийном уровне, выражен- ное в виде предиката это означает <строка_окна>.месяц_заказа <= <теку- щаястрока >.месяц_заказа. Исключения оконных кадров В стандарте SQL оконные функции под- держивают параметр, который называется исключения оконных кадров и входит в определение кадра. Этот параметр определяет, нужно ли включать текущую строку и ее спутников при наличии связей в значение элемента упорядочения. SQL Server 2012 не поддерживает этот параметр. Стандарт поддерживает четыре возможности исключения оконных кадров: EXCLUDE CURRENT ROW Исключает текущую строку. EXCLUDE GROUP Исключает текущую строку и сопутствующие ей строки. EXCLUDE TIES Оставить текущую, но исключить сопутствующие строки. EXCLUDE NO OTHERS (по умолчанию) Не исключать никаких дру- гих строк. Для демонстрации исключения оконных кадров я воспользуюсь табли- цей Т1, созданной и наполненной данными ранее в листинге 2-1. Далее при- водятся четыре запроса с разными вариантами исключения оконных кадров, за которыми следует ожидаемый результат (в соответствии с моей интер- претацией стандарта, потому что этот код не поддерживается SQL Server 2012 или любой другой известной мне СУБД):
Более подробные сведения об оконных функциях Глава 2 53 - - EXCLUDE NO OTHERS (не исключаем строки) SELECT keycol, coll, COUNT(*) OVER(ORDER BY coll ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE NO OTHERS) AS ent FROM dbo.TI; keycol coll ent 2 A 1 3 A 2 5 В 3 7 В 4 11 В 5 13 С 6 17 С 7 19 С 8 23 С 9 -- EXCLUDE CURRENT ROW (исключить текущую строку) SELECT keycol, coll, COUNT(*) OVER(ORDER BY coll ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE CURRENT ROW) AS ent FROM dbo.TI; keycol coll ent 2 A 0 3 A 1 5 В 2 7 В 3 11 В 4 13 С 5 17 С 6 19 С 7 23 С 8 -- EXCLUDE GROUP (исключаем текущую строку и строки, -- расположенные с ней на одном уровне) SELECT keycol. coll, COUNT(*) OVER(ORDER BY coll ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE GROUP) AS ent
54 Глава 2 Более подробные сведения об оконных функциях FROM dbo.TI; keycol coll ent 2 A 3 A 5 В 7 В 11 В 13 С 17 С 19 С 23 С 0 0 2 2 2 5 5 5 5 -- EXCLUDE TIES (оставляем текущую строку и удаляем строки, -- расположенные с ней на одном уровне) SELECT keycol, coll, COUNT(*) OVER(ORDER BY coll ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW EXCLUDE TIES) AS ent FROM dbo.TI; keycol coll ent 2 А 3 А 5 В 7 В 11 В 13 С 17 С 19 С 23 С 1 1 3 3 3 6 6 6 6 Дополнительные варианты фильтрации Как вы помните, различные элементы определения окна (секционирование, упорядочение и кадрирование) по сути являются различными вариантами фильтрации. Существуют другие потребности в фильтрации, которые эти определения не в состоянии удовлетворить. Некоторые из этих потребно- стей удается удовлетворить с помощью предложения FILTER, которое не было реализовано в SQL Server 2012. Есть также попытки решить эту про- блему за счет внесения предложений по расширению стандарта, которые, я надеюсь, так или иначе появятся в стандарте и SQL Server.
Более подробные сведения об оконных функциях Глава 2 55 Начну с предложения FILTER. В стандарте определено, что в функци- ях агрегирования это предложение должно позволяет фильтровать набор строк, к которому применяется агрегирование, с использованием предиката. Формат этого предложения выглядит так: <функция агрегирований(<входное выражение>) FILTER (WHERE <условие поиска>) В качестве примера приведу запрос, вычисляющий разницу между теку- щим количеством и среднемесячным количеством для сотрудника до теку- щей даты (не месяца текущей строки): SELECT empid, ordermonth, qty. qty - AVG(qty) FILTER (WHERE ordermonth <= DATEADD(month, -3, CURRENT, TIMESTAMP)) OVER(PARTITION BY empid) AS diff FROM Sales.EmpOrders; SQL Server 2012 пока не поддерживает предложение FILTER. Честно го- воря, я не знаю СУБД, которая поддерживала его. Если вам нужна такая возможность, существует довольно простое альтернативное решение — ис- пользовать в качестве входных данных для функции агрегирования выра- жение CASE: <функция агрегирования>(СА8Е WHEN <условие поиска> THEN <входное выражение> END) Вот полный запрос, который решает ту же задачу: SELECT empid, ordermonth, qty, qty - AVG(CASE WHEN ordermonth <= DATEADD(month, -3, CURRENT_TIMESTAMP) THEN qty END) OVER(PARTITION BY empid) AS diff FROM Sales.EmpOrders; Чего все еще не хватает в стандарте (начиная с версии SQL 2008) и SQL Server 2012, так это возможности ссылаться на элементы текущей строки для целей фильтрации. Это можно было бы применять в предложении FILTER, в альтернативном решении с использованием выражения CASE, а также в других случаях, в которых нужна фильтрация. Для демонстрации этой потребности представьте на секундочку, что на элемент текущей строки можно ссылаться с помощью префикса $current_ row. А теперь представим себе, что нужно написать запрос представления Sales.OrderValues, который бы вычислял для каждого заказа разницу между значением текущего заказа и средним значением для определенного сотруд- ника для всех клиентов кроме того клиента, которому принадлежит этот за- каз. Эта задача решается следующим запросом с предложением FILTER: SELECT orderid, orderdate, empid, custid, val, val - AVG(val)
56 Глава 2 Более подробные сведения об оконных функциях FILTER (WHERE custid о $current_row.custid) 0VER(PARTITION BY empid) AS diff FROM Sales.OrderValues; В качестве альтернативы можно воспользоваться выражением CASE: SELECT orderid, orderdate, empid, custid, val, val - AVG(CASE WHEN custid <> $current_row.custid THEN val END) OVER(PARTITION BY empid) AS diff FROM Sales.OrderValues; Еще раз напомню, что это всего лишь мои выдумки для иллюстрации того, чего не хватает в стандарте языка, поэтому, как говорится, «не пытай- тесь повторить это у себя дома». Предложение по улучшению Есть очень интересные предложения по расширению стандарта для удовлет- ворения этой и других потребностей. Один из примеров — возможность, которую авторы называют сравнительные оконные функции. Подробнее о предложении можно узнать из блога Тома Кайта по адресу http://tkyte. blogspot.сот/2009/11/comparative-window-functions.html. А сам документ с предложением вы найдете здесь: http://asktom. oracle.com/pls/asktom/z?p jurl=ASKTOM%2Edownload_file%3Fp _file% 3D7575682831744048130&p_cat=comparativewindow_fns_proposal.pdf. Принцип сравнительных оконных функций выглядит интересно. Он до- вольно простой и позволяет удовлетворить потребность ссылаться на элемен- ты из текущей строки. Но что действительно заставит вас пошевелить мозга- ми, так этого исключительно крутое предложение по расширению стандарта, которое называется «распознавание паттернов в строках» и решает задачу об- ращения к элементам из текущей строки, а также многие другие задачи. Паттерны в последовательностях строк определяются с применением семантики, похожей на регулярные выражения. Этот механизм может при- меняться для определения табличного выражения, а также для фильтра- ции строк в определении окна. Он также может использоваться в техноло- гиях потоковой передачи данных, например с Streaminsight в SQL Server, а также в запросах, которые работают с неперемещаемыми данными. Вот ссылка на предоставленный для всеобщего доступа документ: http://www. softwareworkshop.com/h2/SQL-RPR-review-paper.pdf Прежде чем читать этот документ, я предлагаю освободить голову от лишних мыслей и хорошенько взбодриться кофе. Это непросто чтение, но идея исключительно интересна и я надеюсь, что она пробьет себе путь в стандарт SQL и будет использоваться не только для данных в движении, но и для неактивных данных. Ключевое слово DISTINCT в функциях агрегирования SQL Server 2012 не поддерживает параметр DISTINCT в оконных функци- ях агрегирования. Представьте, что вам нужно запрашивать представление
Более подробные сведения об оконных функциях Глава 2 57 Sales.OrderValues и получить для каждого заказа число конкретных клиен- тов, с которыми работал текущий сотрудник с начала и до текущей даты. Вам нужно выполнить такой запрос: SELECT empid, orderdate, orderid, val, COUNT(DISTINCT custid) OVER(PARTITION BY empid ORDER BY orderdate) AS numcusts FROM Sales.OrderValues; Но поскольку этот запрос не поддерживается, нужно искать обходное решение. Один из вариантов — прибегнуть к помощи функции ROW_ NUMBER. Я расскажу о ней подробнее чуть попозже, а пока достаточно будет сказать, что она возвращает уникальное целое значение для каждой строки секции, начиная с единицы и с шагом 1, в соответствии с определени- ем упорядочения в окне. С помощью функции ROW NUMBER можно на- значить строкам номера, секционированные по empid и custid и упорядочен- ные по orderdate. Это означает, что строки с номером 1 относятся к первому случаю работы сотрудника с данным клиентом при упорядочении заказов по датам. Используя выражение CASE можно вернуть значение custid, только если номер строки равен 1, а в противном случае вернуть NULL. Вот запрос, реализующий описанную логику, с результатом его работы: SELECT empid, orderdate, orderid, custid, val, CASE WHEN ROW_NUMBER() OVER(PARTITION BY empid, custid ORDER BY orderdate) = 1 THEN custid END AS distinct_custid FROM Sales.OrderValues; empid orderdate orderid custid val distinct_custid 1 2006-07-17 00:00:00.000 10258 20 1614.88 20 1 2006-08-01 00:00:00.000 10270 87 1376.00 87 1 2006-08-07 00:00:00.000 10275 49 291.84 49 1 2006-08-20 00:00:00.000 10285 63 1743.36 63 1 2006-08-28 00:00:00.000 10292 81 1296.00 81 1 2006-08-29 00:00:00.000 10293 80 848.70 80 1 2006-09-12 00:00:00.000 10304 80 954.40 NULL 1 2006-09-16 00:00:00.000 10306 69 498.50 69 1 2006-09-20 00:00:00.000 10311 18 268.80 18 1 2006-09-25 00:00:00.000 10314 65 2094.30 65 1 2006-09-27 00:00:00.000 10316 65 2835.00 NULL 1 2006-10-09 00:00:00.000 10325 39 1497.00 39 1 2006-10-29 00:00:00.000 10340 9 2436.18 9 1 2006-11-11 00:00:00.000 10351 20 5398.73 NULL 1 2006-11-19 00:00:00.000 10357 46 1167.68 46
58 Глава 2 Более подробные сведения об оконных функциях 1 2006-11-22 00:00:00.000 10361 63 2046.24 NULL 1 2006-11-26 00:00:00.000 10364 19 950.00 19 1 2006-12-03 00:00:00.000 10371 41 72.96 41 1 2006-12-05 00:00:00.000 10374 91 459.00 91 1 2006-12-09 00:00:00.000 10377 72 863.60 72 1 2006-12-09 00:00:00.000 10376 51 399.00 51 1 2006-12-17 00:00:00.000 10385 75 691.20 75 1 2006-12-18 00:00:00.000 10387 70 1058.40 70 1 2006-12-25 00:00:00.000 10393 71 2556.95 71 1 2006-12-25 00:00:00.000 10394 36 442.00 36 1 2006-12-27 00:00:00.000 10396 25 1903.80 25 1 2007-01-01 00:00:00.000 10400 19 3063.00 NULL 1 2007-01-01 00:00:00.000 10401 65 3868.60 NULL Заметьте, что для каждого сотрудника возвращается только первое зна- чение custid при условии упорядочения по дате, а для последующих зна- чений возвращаются NULL. Следующий шаг заключается в определении обобщенного табличного значения (СТЕ) на основе предыдущего запроса, а затем применении агрегирования текущего числа строк к результату вы- ражения CASE: WITH С AS ( SELECT empid, orderdate, orderid, custid. val, CASE WHEN ROW_NUMBER() OVER(PARTITION BY empid, custid ORDER BY orderdate) = 1 THEN custid END AS distinct_custid FROM Sales.OrderValues ) SELECT empid, orderdate, orderid, val, COUNT(distinct_custid) OVER(PARTITION BY empid ORDER BY orderdate) AS numcusts FROM C; empid orderdate orderid val numcusts 1 2006-07-17 00:00:00.000 10258 1614.88 1 1 2006-08-01 00:00:00.000 10270 1376.00 2 1 2006-08-07 00:00:00.000 10275 291.84 3 1 2006-08-20 00:00:00.000 10285 1743.36 4 1 2006-08-28 00:00:00.000 10292 1296.00 5 1 2006-08-29 00:00:00.000 10293 848.70 6 1 2006-09-12 00:00:00.000 10304 954.40 6 1 2006-09-16 00:00:00.000 10306 498.50 7
Более подробные сведения об оконных функциях Глава 2 59 1 2006-09-20 00:00 00.000 10311 268.80 8 1 2006-09-25 00:00 00.000 10314 2094.30 9 1 2006-09-27 00:00 00.000 10316 2835.00 9 1 2006-10-09 00:00 00.000 10325 1497.00 10 1 2006-10-29 00:00 00.000 10340 2436.18 11 1 2006-11-11 00:00 00.000 10351 5398.73 11 1 2006-11-19 00:00 00.000 10357 1167.68 12 1 2006-11-22 00:00 00.000 10361 2046.24 12 1 2006-11-26 00:00 00.000 10364 950.00 13 1 2006-12-03 00:00 00.000 10371 72.96 14 1 2006-12-05 00:00 00.000 10374 459.00 15 1 2006-12-09 00:00 00.000 10377 863.60 17 1 2006-12-09 00:00 00.000 10376 399.00 17 1 2006-12-17 00:00 00.000 10385 691.20 18 1 2006-12-18 00:00 00.000 10387 1058.40 19 1 2006-12-25 00:00 00.000 10393 2556.95 21 1 2006-12-25 00:00 00.000 10394 442.00 21 1 2006-12-27 00:00 00.000 10396 1903.80 22 1 2007-01-01 00:00 00.000 10400 3063.00 22 1 2007-01-01 00:00 00.000 10401 3868.60 22 Вложенные агрегаты На данный момент вы знаете, что есть групповые и оконные агрегаты. Как уже говорилось, функции при этом используются одинаковые, но контекст разный. Групповые агрегаты работают на основе групп строк, определенных предложением GROUP BY и возвращают одно значение на группу. Оконные агрегаты действуют на основе окон строк и возвращают одно значение для каждой строки в базовом запросе. Вспомните рассказ о логической обработ- ке запросов в главе 1. Напомню порядок, в котором в соответствии с концеп- цией должны обрабатываться различные предложения запросов: 1. FROM 2. WHERE 3. GROUP BY 4. HAVING 5. SELECT 6. ORDER BY Групповые агрегаты используются, когда запрос является групповым, и они разрешены в фазах, которые обрабатываются после определения групп, а именно, начиная с фазы 4 и далее. Помните, что в результате запроса каж- дая группа представлена только одной строкой. Оконные агрегаты разреше- ны, начиная с фазы 5 и последующих, потому что они работают на основе строк базового запроса — после фазы HAVING.
60 Глава 2 Более подробные сведения об оконных функциях Агрегаты двух типов — даже при том, что они используют те же имена функций и логику вычислений — работают в разных контекстах. Вернусь к важному моменту, который я хотел отметить в этом разделе: что, если нужно просуммировать значение, сгруппированное по идентификатору сотрудни- ка, и одновременно агрегировать все эти суммы по всем сотрудникам? Это совершенно легальный, но на первый взгляд странный подход — при- менять оконный агрегат к окну, содержащему строки с атрибутами, получен- ными с применением групповых агрегатов. Я сказал «странный», потому что на первый взгляд выражение SUM(SUM(val)) в запросе выглядит неумест- ным. Но оно имеет право на существование. Посмотрите на запрос, который решает поставленную задачу: SELECT empid, SWf(val) AS emptotal, Sl/#(val) / SUM(Sl/#(val)) OVER() * 100. AS pct FROM Sales.OrderValues GROUP BY empid; empid emptotal pct 3 202812.88 16.022500 6 73913.15 5.839200 9 77308.08 6.107400 7 124568.24 9.841100 1 192107.65 15.176800 4 232890.87 18.398800 2 166537.76 13.156700 5 68792.30 5.434700 8 126862.29 10.022300 Чтобы различать два типа агрегатов, групповые агрегаты SUM выделя- ются курсивом, а оконные SUM — полужирным начертанием. Групповой агрегат SUM(val) вычисляет общую сумму цены всех заказов для каждого сотрудника. Это означает, что в результате базового запроса есть строка для каждого сотрудника с этой общей суммой. После этого оконный агрегат вы- числяет сумму сумм для отдельных сотрудников, иначе говоря вычисляет итоговую сумму, и делит групповой агрегат на оконный, чтобы вычислить в процентах долю каждого сотрудника и итоговой цифре. Будет проще увидеть логику вложенных агрегатов, если анализ запроса разбить да два этапа. На первом вычисляется групповой агрегат: SELECT empid, SUM(val) AS emptotal FROM Sales.OrderValues GROUP BY empid;
Более подробные сведения об оконных функциях Глава 2 61 empid emptotal 3 202812.88 6 73913.15 9 77308.08 7 124568.24 1 192107.65 4 232890.87 2 166537.76 5 68792.30 8 126862.29 Этот результат можно считать начальной точкой для дальнейшего окон- ной агрегации. Таким образом, можно применить агрегат SUM к выражению, представленному псевдонимом emptotal. К сожалению нельзя применить его непосредственно к псевдониму по причинам, изложенным в главе 1 (пом- ните принцип «все сразу»?). Но его можно применить к базовому выраже- нию так: SUM(SUM(val)) OVER(...), и можно считать, что это SUM(emptotal) OVER(...). Таким образом получаем следующее: SELECT empid, SUM(val) AS emptotal, SUM(val) / SUM(SUM(val)) OVER() * 100. AS pct FROM Sales.OrderValues GROUP BY empid; Заметьте, что сложностей прямого вложения можно избежать за счет ис- пользования табличных выражений, таких как СТЕ. СТЕ можно определить на основе запроса, вычисляющего групповой агрегат, а во внешнем запросе вычисляется оконный агрегат, примерно так: WITH С AS ( SELECT empid, SUM(val) AS emptotal FROM Sales.OrderValues GROUP BY empid ) SELECT empid, emptotal, emptotal / SUM(emptotal) OVER() * 100. AS pct FROM C; Посмотрим на другой пример сложностей, связанных с оконными и груп- повыми функциями. Следующая задача является вариацией запроса, при- веденного ранее в этой главе. Надо создать запрос таблицы Sales.Orders, воз- вращающий для каждого сотрудника точные даты заказов и точные имена клиентов, с которыми работал текущий сотрудник с начала и до текущей даты. Первая попытка реализации:
62 Глава 2 Более подробные сведения об оконных функциях WITH С AS ( SELECT empid, orderdate, CASE WHEN ROW_NUMBER() OVER(PARTITION BY empid, custid ORDER BY orderdate) = 1 THEN custid END AS distinct_custid FROM Sales.Orders ) SELECT empid, orderdate, COUNT(distinct_custid) OVER(PARTITION BY empid ORDER BY orderdate) AS numcusts FROM C GROUP BY empid, orderdate; Но при выполнении запроса вы получаете следующую ошибку: Msg 8120, Level 16, State 1. Line 12 Column 'C.distinct_custid’ is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause. Внешняя функция COUNT является не групповыми, а оконным агрега- том. Таким образом она может работать только на основе элементов, кото- рые действительны, только если определяются самостоятельно, то есть не как входные данные для оконного агрегата. Теперь на секундочку забудьте об оконном агрегате и скажите, корректен ли следующий запрос (для крат- кости определение СТЕ опущено)? SELECT empid, orderdate, distinct_custid FROM C GROUP BY empid, orderdate; Ясно, что ответ отрицательный. Атрибут distinct_custid в списке SELECT неверен, потому что не содержится ни в агрегирующей функции, ни в пред- ложении GROUP BY, и примерно об этом говорится в сообщении об ошиб- ке. Что вам нужно сделать, так это применить оконный агрегат SUM с ка- дром, реализующим принцип нарастающего итога, к групповому агрегату COUNT, который считает конкретные вхождения: WITH С AS ( SELECT empid, orderdate, CASE WHEN ROW_NUMBER() OVER(PARTITION BY empid. custid ORDER BY orderdate) = 1 THEN custid END AS distinct_custid FROM Sales.Orders
Более подробные сведения об оконных функциях Глава 2 63 ) SELECT empid, orderdate, SUM(COUNT(distinct_custid)) OVER(PARTITION BY empid ORDER BY orderdate) AS numcusts FROM C GROUP BY empid, orderdate; empid orderdate numcusts 1 2006-07-17 00:00:00.000 1 1 2006-08-01 00:00:00.000 2 1 2006-08-07 00:00:00.000 3 1 2006-08-20 00:00:00.000 4 1 2006-08-28 00:00:00.000 5 1 2006-08-29 00:00:00.000 6 1 2006-09-12 00:00:00.000 6 1 2006-09-16 00:00:00.000 7 1 2006-09-20 00:00:00.000 8 1 2006-09-25 00:00:00.000 9 1 2006-09-27 00:00:00.000 9 1 2006-10-09 00:00:00.000 10 1 2006-10-29 00:00:00.000 11 1 2006-11-11 00:00:00.000 11 1 2006-11-19 00:00:00.000 12 1 2006-11-22 00:00:00.000 12 1 2006-11-26 00:00:00.000 13 1 2006-12-03 00:00:00.000 14 1 2006-12-05 00:00:00.000 15 1 2006-12-09 00:00:00.000 17 1 2006-12-17 00:00:00.000 18 1 2006-12-18 00:00:00.000 19 1 2006-12-25 00:00:00.000 21 1 2006-12-27 00:00:00.000 22 1 2007-01-01 00:00:00.000 22 Ясно, что это не единственный способ получения нужного результата, но моей задачей было проиллюстрировать принцип вложения групповых агре- гатов в оконные. Как вы помните, в соответствии с порядком логической об- работки запросов оконные функции обрабатываются на этапе SELECT или ORDER BY, то есть после GROUP BY. По этой причине групповые агрегаты видны в качестве входных выражений оконных агрегатов. Также вспомните, что если код становится сложным для понимания, всегда можно задейство- вать табличные выражения, чтобы избежать прямого сложения функций и повысить читабельность кода.
64 Глава 2 Более подробные сведения об оконных функциях Функции ранжирования Стандарт поддерживает четыре оконные функции, которые служат для ранжирования. Это ROW_NUMBER, NTILE, RANK и DENSE RANK. В стандарте первые две считаются относящимися к одной категории, а вто- рые две — ко второй. Это связано с различиями в отношении детерминизма. Подробнее я расскажу в процессе рассказа об отдельных функциях. Функции ранжирования появились еще в SQL Server 2005. Тем не менее я покажу альтернативные, основанные на наборах методы для получения того же результата. Я сделаю это по двум причинам: во-первых, это может быть интересным, а во-вторых, я верю, что это помогает лучше понять нюан- сы работы функций. Тем не менее имейте в виду, что на практике я настоя- тельно рекомендую придерживаться оконных функций, потому что они и проще, и намного эффективнее, чем альтернативные решения. Подробнее об оптимизации мы поговорим в главе 4. Поддерживаемые оконные элементы Все четыре функции ранжирования поддерживают необязательное предло- жение секционирования и обязательное предложение упорядочения окна. Если предложение секционирования окна отсутствует, весь результирую- щий набор базового запроса (вспомните о входных данных этапа SELECT) считается одной секцией. Что касается предложения упорядочения окна, то оно обеспечивает упорядочение при вычислениях. Понятно, что ранжирова- ние строк без определения упорядочения вряд ли имеет смысл. В ранжиру- ющих оконных функциях упорядочение служит другой цели по сравнению с функциями, поддерживающими кадрирование, такими как агрегирующие оконные функции. В первом случае упорядочение имеет логический смысл для самих вычислений, а во втором — упорядочение связано с кадрировани- ем, то есть служит целям фильтрации. Функция ROW-NUMBER Эта функция вычисляет последовательные номера строк, начиная с 1, в со- ответствующей секции окна и в соответствии с заданным упорядочением окна. Посмотрите на пример запроса в листинге 2-2. Листинг 2-2. Запрос с функцией ROW_NUMBER SELECT orderid, val, R0W_NUMBER() OVER(ORDER BY orderid) AS rownum FROM Sales.OrderValues; Вот сокращенный результат выполнения этого запроса: orderid val rownum 10248 440.00 1
Более подробные сведения об оконных функциях Глава 2 65 10249 1863.40 2 10250 1552.60 3 10251 654.06 4 10252 3597.90 5 10253 1444.80 6 10254 556.62 7 10255 2490.50 8 10256 517.80 9 10257 1119.90 10 Этот запрос кажется тривиальным, но здесь есть несколько моментов, о которых стоит сказать. Так как в запросе нет предложения представления ORDER BY, упорядоче- ние представления не гарантируется. Поэтому упорядочение представления здесь нужно считать произвольным. На практике SQL Server оптимизирует запрос с учетом отсутствия предложения ORDER BY, поэтому строки мо- гут возвращаться в любом порядке. Если нужно гарантировать упорядоче- ние представления, нужно не забыть добавить предложение представления ORDER BY. Если нужно, чтобы упорядочение представления выполнялось на основе номера строки, можно использовать псевдоним, назначенный в про- цессе вычисления предложения представления ORDER BY примерно так: SELECT orderid, val, ROW_NUMBER() OVER(ORDER BY orderid) AS rownum FROM Sales.OrderValues ORDER BY rownum; Считайте вычисление номеров строк генерацией еще одного атрибута в результирующем наборе запроса. Естественно, если хочется, можно по- лучить упорядочение представления, которое отличается от упорядочения окна, как в следующем запросе: SELECT orderid, val, ROW_NUMBER() OVER(ORDER BY orderid) AS rownum FROM Sales.OrderValues ORDER BY val DESC; orderid val rownum 10865 16387.50 618 10981 15810.00 734 11030 12615.05 783 10889 11380.00 642 10417 11188.40 170 10817 10952.85 570 10897 10835.24 650 10479 10495.60 232
66 Глава 2 Более подробные сведения об оконных функциях 10540 10191.70 293 10691 10164.80 444 Можно использовать оконный агрегат COUNT для создания операции, которая логически эквивалентна функции ROW_NUMBER. Допустим, WPO — определение секционирования и упорядочения окна, примененное в функции ROW_NUMBER. Тогда ROW_NUMBER OVER WPO эквива- лентно COUNTC) OVER(WPO ROWS UNBOUNDED PRECEDING). Например, следующее эквивалентно запросу из листинга 2-2: SELECT orderid, val, COUNT(*) OVER(ORDER BY orderid ROWS UNBOUNDED PRECEDING) AS rownum FROM Sales.OrderValues; Как я говорил, это хорошая тренировка — попытаться и создать альтерна- тивные варианты вместо использования оконных функций, и не важно, что эти варианты сложнее и менее эффективные. Если уж мы говорим о функ- ции ROW NUMBER, то вот основанная на наборах стандартная альтерна- тива запросу в листинге 2-2, в которой оконные функции не используются: SELECT orderid, val, (SELECT COUNT(*) FROM Sales.OrderValues AS 02 WHERE 02.orderid <= 01.orderid) AS rownum FROM Sales.OrderValues AS 01; В этом решении используется агрегат COUNT во вложенном запросе для определения, у скольких строк значение упорядочения (в нашем слу- чае orderid) меньше или равно текущему. Это просто, если у вас уникальное упорядочение, основанное на одном атрибуте. Но все сильно усложняется, если упорядочение неуникально, что я и продемонстрирую при обсуждении детерминизма. Детерминизм Если упорядочение окна уникально, как в запросе из листинга 2-2, вычисле- ние ROW NUMBER является детерминистическим. Это означает, что у за- проса есть только одни правильный результат, то есть если не менять входные данные, вы гарантировано будете получать повторяющиеся результаты. Но если упорядочение окна не уникально, вычисление становится недетермини- стическим. Функция ROW NUMBER генерирует уникальные номера строк в рамках секции, даже для строк с одинаковыми значениями в атрибутах упо- рядочения окна. В качестве примера посмотрите на следующий запрос: SELECT orderid, orderdate, val, ROW_NUMBER() OVER(ORDER BY orderdate DESC) AS rownum FROM Sales.OrderValues;
Более подробные сведения об оконных функциях Глава 2 67 orderid orderdate val rownum 11074 2008-05-06 00:00:00.000 232.09 1 11075 2008-05-06 00:00:00.000 498.10 2 11076 2008-05-06 00:00:00.000 792.75 3 11077 2008-05-06 00:00:00.000 1255.72 4 11070 2008-05-05 00:00:00.000 1629.98 5 11071 2008-05-05 00:00:00.000 484.50 6 11072 2008-05-05 00:00:00.000 5218.00 7 11073 2008-05-05 00:00:00.000 300.00 8 11067 2008-05-04 00:00:00.000 86.85 9 11068 2008-05-04 00:00:00.000 2027.08 10 Так как атрибут orderdate не уникальный, упорядочение строк с оди- наковым значением orderdate следует считать произвольным. В принципе существует более одного корректного результата этого запроса. В качестве примера возьмем четыре строки с датой заказа 2008-05-06. Любой порядок строк с номерами от 1 до 4 считается правильным. Поэтому если вы выпол- ните запрос снова, то в принципе можете получить другой порядок — сейчас не будем оценивать вероятность такого события, обусловленную особенно- стями реализации SQL Server (оптимизация). Если нужно гарантировать повторяемость результатов, нужно сделать запрос детерминистическим. Это можно сделать, добавив дополнительный параметр в определение упорядочения окна, чтобы обеспечить уникальность в рамках секции. К примеру в следующем запросе уникальность упорядоче- ния в окне достигается за счет добавления в список ordend DESC: SELECT orderid, orderdate, val, ROW_NUMBER() OVER(ORDER BY orderdate DESC, orderid DESC) AS rownum FROM Sales.OrderValues; orderid orderdate val rownum 11077 2008-05-06 00:00:00.000 1255.72 1 11076 2008-05-06 00:00:00.000 792.75 2 11075 2008-05-06 00:00:00.000 498.10 3 11074 2008-05-06 00:00:00.000 232.09 4 11073 2008-05-05 00:00:00.000 300.00 5 11072 2008-05-05 00:00:00.000 5218.00 6 11071 2008-05-05 00:00:00.000 484.50 7 11070 2008-05-05 00:00:00.000 1629.98 8 11069 2008-05-04 00:00:00.000 360.00 9 11068 2008-05-04 00:00:00.000 2027.08 10
68 Глава 2 Более подробные сведения об оконных функциях При использовании оконных функций детерминизм вычисления но- меров страниц реализуется просто. Сделать то же, не прибегая к оконным функция сложнее, но вполне реально: SELECT orderdate, orderid, val, (SELECT COUNT(*) FROM Sales.OrderValues AS 02 WHERE 02.orderdate >= 01.orderdate AND (02.orderdate > 01.orderdate OR 02.orderid >= 01.orderid)) AS rownum FROM Sales.OrderValues AS 01; Но вернемся к функции ROWNUMBER: как мы выдели, ее можно ис- пользовать для создания недетерминистических вычислений при исполь- зовании неуникального упорядочения. Таким образом, недетерминизм раз- решен, но странно то, что он не разрешен полностью. Я имею в виду то, что предложение ORDER BY обязательно. Но что, если вы хотите просто полу- чить в секции уникальные номера строк не обязательно в каком-то опреде- ленном порядке? Можно создать такой запрос: SELECT orderid, orderdate, val, ROW_NUMBER() OVER() AS rownum FROM Sales.OrderValues; Но, как уже говорилось, предложение ORDER BY в функциях ранжиро- вания является обязательным: Msg 4112, Level 15, State 1, Line 2 The function 'ROW_NUMBER' must have an OVER clause with ORDER BY. Вы можете поумничать и определить константу в списке ORDER BY: SELECT orderid, orderdate, val, ROW_NUMBER() OVER(ORDER BY NULL) AS rownum FROM Sales.OrderValues; Ho SQL Server вернет такую ошибку: Msg 5309, Level 16, State 1, Line 2 Windowed functions and NEXT VALUE FOR functions do not support constants as ORDER BY clause expressions. Однако решение есть, и я вскоре его приведу. Предложение OVER и последовательности Вам наверное интересно, что делает функция NEXT VALUE FOR в со- общении об ошибке при попытке применить константу в предложении OVER. Это связано с расширенной поддержкой последовательностей в SQL Server 2012 по сравнению со стандартом SQL. Последовательность — это объект базы данных, который служит для автогенерации номеров, ко-
Более подробные сведения об оконных функциях Глава 2 69 торые часто используются как ключи. Вот пример кода создания объекта- последовательности dbo.Seql: CREATE SEQUENCE dbo.Seql AS INT START WITH 1 INCREMENT BY 1; Для получения очередного значения последовательности применяется функция NEXT VALUE FOR. Вот пример: SELECT NEXT VALUE FOR dbo.Seql; Эту функцию можно вызывать в запросе, возвращающем несколько строк: SELECT orderid, orderdate, val, NEXT VALUE FOR dbo.Seql AS seqval FROM Sales.OrderValues; Это стандартный код. В SQL Server 2012 возможности функции NEXT VALUE FOR расширены и позволяют определять упорядочение в предло- жении OVER подобно тому, как это делается в оконных функциях. Таким образом можно гарантировать, что значения последовательности отражают желаемое упорядочение. Вот пример использования расширенной функ- ции NEXT VALUE FOR: SELECT orderid, orderdate, val, NEXT VALUE FOR dbo.Seql OVER(ORDER BY orderdate, orderid) AS seqval FROM Sales.OrderValues; Аналогичный принцип детерминизма применим к предложению OVER в функции NEXT VALUE FOR, как это происходит в оконных функциях. Итак, нет прямого способа назначить номера строкам без упорядочения, но, по-видимому, SQL Server не смущает вложенный запрос, возвращающий константу в качестве элемента упорядочения окна. Вот пример: SELECT orderid, orderdate, val, ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM Sales.OrderValues; orderid orderdate val rownum 10248 2006-07-04 00:00:00.000 440.00 1 10249 2006-07-05 00:00:00.000 1863.40 2 10250 2006-07-08 00:00:00.000 1552.60 3 10251 2006-07-08 00:00:00.000 654.06 4 10252 2006-07-09 00:00:00.000 3597.90 5 10253 2006-07-10 00:00:00.000 1444.80 6 10254 2006-07-11 00:00:00.000 556.62 7 10255 2006-07-12 00:00:00.000 2490.50 8 10256 2006-07-15 00:00:00.000 517.80 9 10257 2006-07-16 00:00:00.000 1119.90 10
70 Глава 2 Более подробные сведения об оконных функциях Я расскажу подробнее об этой форме в главе 4, когда буду рассказывать об оптимизации оконных функций. Функция NTILE Эта функция позволяет разбивать строки в секции окна на примерно равные по размеру подгруппы (tiles) в соответствии с заданным числом подгрупп и упорядочением окна. Допустим, что нужно разбить строки представления OrderValues на 10 подгрупп одинакового размера на основе упорядочения по val. В представлении 830 строк, поэтому требуется 10 подгрупп, размер каждой будет составлять 83 (830 деленное на 10). Поэтому первым 83 стро- кам (одной десятой части), упорядоченным по val, будет назначен номер группы 1, следующим 83 строкам — номер подгруппы 2 и т. д. Вот запрос, вычисляющий номера как строк, так и подгрупп: SELECT orderid, val, ROW_NUMBER() OVER(ORDER NTILE(IO) OVER(ORDER BY BY val) AS rownum, val) AS tile FROM Sales.OrderValues; orderid val rownum ti 10782 12.50 1 1 10807 18.40 2 1 10586 23.80 3 1 10767 28.00 4 1 10898 30.00 5 1 10708 180.40 78 1 10476 180.48 79 1 10313 182.40 80 1 10810 187.00 81 1 11065 189.42 82 1 10496 190.00 83 1 10793 191.10 84 2 10428 192.00 85 2 10520 200.00 86 2 11040 200.00 87 2 11043 210.00 88 2 10417 11188.40 826 10 10889 11380.00 827 10 11030 12615.05 828 10 10981 15810.00 829 10 10865 16387.50 830 10 1е
72 Глава 2 Более подробные сведения об оконных функциях NTILE(100) OVER(ORDER BY val, orderid) AS tile FROM Sales.OrderValues; orderid val rownum tih 10782 12.50 1 1 10807 18.40 2 1 10586 23.80 3 1 10767 28.00 4 1 10898 30.00 5 1 10900 33.75 6 1 10883 36.00 7 1 11051 36.00 8 1 10815 40.00 9 1 10674 45.00 10 2 11057 45.00 11 2 10271 48.00 12 2 10602 48.75 13 2 10422 49.80 14 2 10738 52.35 15 2 10754 55.20 16 2 10631 55.80 17 2 10620 57.50 18 2 10963 57.80 19 3 10816 8446.45 814 98 10353 8593.28 815 99 10514 8623.45 816 99 11032 8902.50 817 99 10424 9194.56 818 99 10372 9210.90 819 99 10515 9921.30 820 99 10691 10164.80 821 99 10540 10191.70 822 99 10479 10495.60 823 100 10897 10835.24 824 100 10817 10952.85 825 100 10417 11188.40 826 100 10889 11380.00 827 100 11030 12615.05 828 100 10981 15810.00 829 100 10865 16387.50 830 100 Следуя привычному методу, попытаемся создать альтернативные реше- ния, заменяющие функцию NTILE и не содержащие оконных функций. Я покажу один способ решения задачи. Для начала, вот код, который вычис- ляет число подгрупп по заданным размерности, числу подгрупп и числу строк:
Более подробные сведения об оконных функциях Глава 2 73 DECLARE @cnt AS INT = 830, @numtiles AS INT = 100, @rownum AS INT = 42; WITH C1 AS ( SELECT @cnt / @numtiles AS basetilesize, @cnt / @numtiles + 1 AS extendedtilesize, @cnt % @numtiles AS remainder ), C2 AS ( SELECT *, extendedtilesize * remainder AS cutoff row FROM C1 ) SELECT CASE WHEN @rownum <= cutoffrow THEN (@rownum - 1) / extendedtilesize + 1 ELSE remainder + ((@rownum - cutoff row) - 1) / basetilesize + 1 END AS tile FROM C2; Вычисление вполне очевидно. Для входных данных код возвращает 5 в качестве числа подгрупп. Затем применим эту процедуру к строкам представления OrderValues. Используйте агрегат COUNT, чтобы получить размерность результирующе- го набора, а не входные данные ©ent, а также примените описанную ранее логику для вычисления номеров строк без использования оконных функ- ций вместо входных данных ©rownum: DECLARE @numtiles AS INT = 100; WITH C1 AS ( SELECT COUNT(*) / @numtiles AS basetilesize, COUNT(*) / @numtiles + 1 AS extendedtilesize, COUNT(*) % @numtiles AS remainder FROM Sales.OrderValues ). C2 AS ( SELECT *, extendedtilesize * remainder AS cutoff row FROM C1 ), C3 AS ( SELECT 01.orderid, 01.val,
74 Глава 2 Более подробные сведения об оконных функциях (SELECT COUNT(*) FROM Sales.OrderValues AS 02 WHERE 02.val <= 01.val AND (02.val < 01.val OR 02.orderid <= 01.orderid)) AS rownum FROM Sales.OrderValues AS 01 ) SELECT C3.*. CASE WHEN C3.rownum <= C2.cutoff row THEN (C3.rownum - 1) / C2.extendedtilesize + 1 ELSE C2. remainder + ((C3. rownum - C2.cutoff row) - 1) / C2.basetilesize + 1 END AS tile FROM C3 CROSS JOIN C2; Как обычно, не пытайтесь повторить это в производственной среде! Это пример предназначен для обучения, а его производительность в SQL Server ужасна по сравнению с функцией NTILE. Функции RANK и DENSE.RANK Функции RANK и DENSE RANK похожи на ROW NUMBER, но в отличие от нее они не создают уникальные значения в оконной секции. При упорядо- чении окна по возрастанию «Обычный ранг» RANK вычисляется как единица плюс число строк со значением, по которому выполняется упорядочение, мень- шим, чем текущее значение в секции. «Плотный ранг» DENSE_RANK вычис- ляется как единица плюс число уникальных строк со значением, по которому выполняется упорядочение, меньшим, чем текущее значение в секции. При упорядочении окна по убыванию RANK вычисляется как единица плюс число строк со значением, по которому выполняется упорядочение, большим, чем те- кущее значение в секции. DENSE_RANK вычисляется как единица плюс чис- ло уникальных строк со значением, по которому выполняется упорядочение, большим, чем текущее значение в секции. Вот пример запроса, вычисляющего номера строк, ранги и «уплотненные» ранги. При этом используется секциони- рование окна по умолчанию и упорядочение по orderdate DESC: SELECT orderid. orderdate, val, ROW_NUMBER() OVER(ORDER BY orderdate DESC) AS rownum, RANKO OVER(ORDER BY orderdate DESC) AS rnk, DENSE_RANK() OVER(ORDER BY orderdate DESC) AS drnk FROM Sales.OrderValues; orderid orderdate val rownum rnk drnk 11077 2008-05-06 00:00:00.000 232.09 1 1 1 11076 2008-05-06 00:00:00.000 498.10 2 1 1 11075 2008-05-06 00:00:00.000 792.75 3 1 1 11074 2008-05-06 00:00:00.000 1255.72 4 1 1
Более подробные сведения об оконных функциях Глава 2 75 11073 2008-05-05 00:00:00.000 1629.98 5 5 2 11072 2008-05-05 00:00:00.000 484.50 6 5 2 11071 2008-05-05 00:00:00.000 5218.00 7 5 2 11070 2008-05-05 00:00:00.000 300.00 8 5 2 11069 2008-05-04 00:00:00.000 86.85 9 9 3 11068 2008-05-04 00:00:00.000 2027.08 10 9 3 Атрибут orderdate не уникален. Но заметьте, что при этом номера строк уни- кальны. Значения RANK и DENSE_RANK не уникальны. Все строки с одной датой заказа, например 2008-05-05, получили одинаковый «неплотный» ранг 5 и «плотный» ранг 2. Ранг 5 означает, что есть четыре строки с большими (бо- лее поздними) датами заказов (упорядочение ведется по убыванию), а «плот- ный» ранг 2 означает, что есть одна более поздняя уникальная дата. Альтернативное решение, заменяющее RANK и DENSERANK и не ис- пользующие оконные функции, создается просто: SELECT orderid, orderdate, val, (SELECT COUNT(*) FROM Sales.OrderValues AS 02 WHERE 02.orderdate > 01.orderdate) + 1 AS rnk, (SELECT COUNT(DISTINCT orderdate) FROM Sales.OrderValues AS 02 WHERE 02. orderdate > 01. orderdate) + 1 AS drnk FROM Sales.OrderValues AS 01; Для вычисления ранга определяется число строк с большим значением, по которому ведется упорядочение, (как вы помните, упорядочение ведет- ся по убыванию) и к нему добавляется единица. Для вычисления плотного ранга нужно пересчитать уникальные большие значения, по которым ведет- ся упорядочение, и добавить к полученному числу единицу. Детерминизм Как вы уже сами, наверное, поняли, что как RANK, так и DENSE RANK детерминистичны по определению. При одном значении упорядочения — независимо от его уникальности — возвращается одно и то же значение ран- га. Вообще говоря, эти две функции обычно интересны, если упорядочение неуникально. Если упорядочение уникально, они дают те же результаты, что и функция ROW_NUMBER. Аналитические функции Аналитические оконные функции, или функции распределения (distribution function), предоставляют информацию о распределении данных и использу- ются в основном для статистического анализа. В SQL Server 2012 появилась поддержка двух аналитических оконных функций: распределения рангов и обратного распределения. К функциям распределения рангов относятся
76 Глава 2 Более подробные сведения об оконных функциях PERCENTRANK и CUMEDIST. Есть также две функции обратного рас- пределения: PERCENTILE CONT and PERCENTILE DISC. В своих примерах я буду использовать таблицу с названием Scores, в ко- торой хранятся результаты экзаменов студентов. Выполните следующий код, чтобы увидеть содержимое таблицы Scores: SELECT * FROM Stats.Scores; testid studentid score Test ABC Student A 95 Test ABC Student В 80 Test ABC Student C 55 Test ABC Student D 55 Test ABC Student E 50 Test ABC Student F 80 Test ABC Student G 95 Test ABC Student H 65 Test ABC Student I 75 Test XYZ Student A 95 Test XYZ Student В '80 Test XYZ Student C 55 Test XYZ Student D 55 Test XYZ Student E 50 Test XYZ Student F 80 Test XYZ Student G 95 Test XYZ Student H 65 Test XYZ Student I 75 Test XYZ Student J 95 Поддерживаемые оконные элементы Оконные функции распределения рангов поддерживают необязательное предложение секционирования и обязательное предложение упорядочения окна. Оконные функции обратного распределения поддерживают необяза- тельное предложение секционирования окна. В функциях обратного распре- деления также важно упорядочение, но оно не входит в определение окна, но для этого применяется отдельное предложение WITHIN GROUP, о котором я расскажу, когда буду объяснять особенности работы этих функций. Функции распределения рангов В соответствии со стандартом SQL, аналитические функции вычисляют от- носительный ранг строки в секции окна, выраженный как дробное число от нуля до единицы, которое большинство воспринимает как процент. Две раз- новидности — PERCENT RANK и CUME_DIST — выполняют вычисления немного по-разному.
Более подробные сведения об оконных функциях Глава 2 77 Допустим, rk это RANK строки, в котором используется то же определе- ние окна, что и в определении окна в аналитической функции. Также допустим, что пг — число строк в секции окна. Еще представим, что пр — число строк, которое предшествует или нахо- дится на одном уровне с текущей строкой (то же самое, что минимальное значение rk, которое больше, чем текущее значение rk за вычетом единицы или пг, если текущее значение rk является максимальным). Тогда PERCENT_RANK вычисляется так: (rk - 1) / (пг - 1). A CUME_ DIST рассчитывается так: пр/пг. Запрос в листинге 2-3 вычисляет как про- центильный ранг, так и интегральное распределение результатов студентов, секционированных по testid и упорядоченных по score. Листинг 2-3. Запрос с PERCENT.RANK и CUME.DIST SELECT testid, studentid, score, PERCENT_RANK() OVER(PARTITION BY testid ORDER BY score) AS percentrank, CUME_DIST() OVER(PARTITION BY testid ORDER BY score) AS cumedist FROM Stats.Scores; Here is the tabular output resulting from this query: testid studentid score percentrank cumedist Test АВС Student E 50 0.000 0.111 Test АВС Student C 55 0.125 0.333 Test АВС Student D 55 0.125 0.333 Test АВС Student H 65 0.375 0.444 Test АВС Student I 75 0.500 0.556 Test АВС Student F 80 0.625 0.778 Test АВС Student В 80 0.625 0.778 Test АВС Student A 95 0.875 1.000 Test АВС Student G 95 0.875 1.000 Test XYZ Student E 50 0.000 0.100 Test XYZ Student C 55 0.111 0.300 Test XYZ Student D 55 0.111 0.300 Test XYZ Student H 65 0.333 0.400 Test XYZ Student I 75 0.444 0.500 Test XYZ Student В 80 0.556 0.700 Test XYZ Student F 80 0.556 0.700 Test XYZ Student G 95 0.778 1.000 Test XYZ Student J 95 0.778 1.000 Test XYZ Student A 95 0.778 1.000 Для удобства чтения результат запроса отформатирован. Тем, кто не знаком со статистическим анализом, будет сложно понять смысл этих вычислений. Грубо говоря, процентильный ранг в данном при- мере можно считать долей студентов, у которых результаты меньше текуще- го результата, а интегральное распределение — долей студентов, у которых
78 Глава 2 Более подробные сведения об оконных функциях результаты меньше или равны текущему результату Просто помните, что при вычислении этих функций, в первом случае делителем является (пг - 1), а во втором — пг. До SQL Server 2012 вычисление процентильного ранга выполнялось до- вольно прямолинейно, потому что rk можно вычислить с помощью оконной функции RANK, а пг — с помощью агрегирующей оконной функции COUNT, обе эти функции имеются в SQL Server, начиная с SQL Server 2005. С вычис- лением интегрального распределения сложнее, потому что для обсчета теку- щей строки требуется значение rk, связанное с другой строкой. В результате вычисления мы должны получать минимальный rk, который больше теку- щего rk, или пг, если текущий rk является максимальным. Решить задачу можно с помощью связанного вложенного запроса. Вот запрос (его можно выполнять в SQL Server 2005 и последующих вер- сиях), вычисляющий как процентильный ранг, так и интегральное распре- деление: WITH С AS ( SELECT testid, studentid, score, RANKO OVER(PARTITION BY testid ORDER BY score) AS rk, COUNTO) OVER(PARTITION BY testid) AS nr FROM Stats.Scores ) SELECT testid, studentid. score, 1.0 * (rk - 1) / (nr - 1) AS percentrank, 1.0 * (SELECT C0ALESCE(MIN(C2.rk) - 1, C1.nr) FROM C AS C2 WHERE C2.rk > C1.rk) / nr AS cumedist FROM C AS Cl; Причина использования числового значения «1.0» во второй части в том, чтобы принудительно выполнить неявное преобразование целочисленных операндов в числовые, потому что в противном случае мы получим цело- численное деление. Вот еще один пример — запрос вычисляет процентильный ранг и инте- гральное распределение числа заказов у сотрудников: SELECT empid, COUNT(*) AS numorders, PERCENT_RANK() OVER(ORDER BY COUNT(*)) AS percentrank, CUME_DIST() OVER(ORDER BY COUNT(*)) AS cumedist FROM Sales.Orders GROUP BY empid: empid numorders percentrank cumedist 5 42 0.000 0.111 9 43 0.125 0.222
Более подробные сведения об оконных функциях Глава 2, 79 6 67 0.250 0.333 7 72 0.375 0.444 2 96 0.500 0.556 8 104 0.625 0.667 1 123 0.750 0.778 3 127 0.875 0.889 4 156 1.000 1.000 Обратите внимание на совмещение функций групповых агрегатов с окон- ными функциями распределения рангов — это очень похоже на ранее обсуж- даемое совмещение функций групповых агрегатов и агрегирующих оконных функций. Функции обратного распределения Функции обратного распределения, более известные под именем проценти- лей, выполняют вычисление, которое можно считать обратным к функциям распределения рангов. Как вы помните, функции распределения рангов вы- числяют относительный ранг текущей строки в секции окна, который вы- ражается числом от 0 до 1 (процентом). Функции обратного распределения принимают в качестве входных данных процент и возвращают значение из группы (или интерполированное значение), соответствующее этому про- центу. Грубо говоря, если на вход поступает процент р и упорядочение в группе основано на ordcol, возвращенный процентиль является значением ordcol, для которого доля значений ordcol, которые меньше процентиля, рав- на р. Наверное самый известный процентиль — 0,5 (50-й процентиль), более известрный как медиана. К примеру, если группа состоит из значений 2, 3, 7, 1759, 43112609, то процентиль 0,5 для нее равен 7. Вспомните, что функции распределения рангов являются оконными, и это имеет серьезный смысл. У строк в одной секции могут быть различные про- центильные ранги. Но функции обратного распределения принимают только одно входное значение процента и одно определение упорядочения в группе и вычисляют по одному результату на группу. Поэтому с точки зрения кон- струкции разумнее использовать их как групповые функции, то есть приме- нять их к группам в контексте групповых запросов. Можно делать так: SELECT grouped, PERCENTILE_FUNCTION(O. 5) WITHIN GROUP(ORDER BY ordcol) AS median FROM T1 GROUP BY groupcol; Обратите внимание на предложение WITHIN GROUP, содержащее определение упорядочения — это не оконная функция. Стандарт SQL определяет функции обратного распределения как отно- сящиеся к типу так называемых функций упорядоченного набора, которые представляют собой один из типов функций агрегирования и которые мож- но использовать в качестве групповых функций агрегирования. Однако в
80 Глава 2 Более подробные сведения об оконных функциях SQL Server 2012 функции обратного распределения реализованы как окон- ные, которые вычисляют одно и тот же результирующее значение для всех строк в одной секции. Версия с группировкой не была реализована. В этом разделе я расскажу о поддерживаемых функциях обратного распре- деления и предоставлю ряд примеров использования их как оконных функ- ций. Однако из-за того, что чаще требуется вычисление по группам, я отложу описание этой части, в том числе альтернативных способов, до главы 3. Существует два основных варианта функций обратного распределения: PERCENTILE DISC и PERCENTILE CONT. Функция PERCENTILE DISC (DISC означает «discrete distribution model», то есть «модель дискретного распределения») возвращает пер- вое значение в группе, интегральное распределение которого (см. описа- ние функции CUME DIST выше) больше или равно входному значению, при этом предполагается, что группа трактуется как секция окна с тем же упорядочением, которое определено в группе. Посмотрите на запрос в ли- стинге 2-3 в предыдущем разделе, где вычисляется процентильный ранг и интегральное распределение результатов экзаменов. В данном случае функция PERCENTILE_DISC(0.5) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) вернет результат 75 для теста «Test АВС», по- тому что этот результат относится к интегральному распределению 0,556, а это первое значение, большее или равное входному числу 0,5. Ниже показан приведенный ранее результат, в котором специально выделена эта строка: testid studentid score percentrank cumedist Test ABC Student E 50 0.000 0 111 Test ABC Student C 55 0.125 0.333 Test ABC Student D 55 0.125 0.333 Test ABC Student H 65 0.375 0.444 Test ABC Student I 75 0.500 0.556 Test ABC Student F 80 0.625 0.778 Test ABC Student В 80 0.625 0.778 Test ABC Student A 95 0.875 1.000 Test ABC Student G 95 0.875 1.000 Test XYZ Student E 50 0.000 0.100 Test XYZ Student C 55 0.111 0.300 Test XYZ Student D 55 0.111 0.300 Test XYZ Student H 65 0.333 0.400 Test XYZ Student I 75 0.444 0.500 Test XYZ Student В 80 0.556 0.700 Test XYZ Student F 80 0.556 0.700 Test XYZ Student G 95 0.778 1.000 Test XYZ Student J 95 0.778 1.000 Test XYZ Student A 95 0.778 1.000
Более подробные сведения об оконных функциях Глава 2 81 Работу функции PERCENTILECONT (CONT означает «continuous distribution model», «модель непрерывного распределения») объяснить сложнее. Пусть у нас сеть функция PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score). Допустим, что n — число строк в группе. Пусть а равно @pct*(n - 1), при этом i — целая, а/ — дробная часть а. Допустим, что rowO и row1 — строки с номерами FLOOR(a) и CEILING(a) (нумерация начинается с нуля). Здесь предполагается, что номера строк вычисляются на основе того же секционирования и упорядочения окна, что используется при группировке и упорядочении, что и в функции PERCENTILE_CONT. Тогда PERCENTILE_CONT вычисляется как rowO.score + f * (row1.score - rowO.score). Это интерполяция значений в двух строках в предположении непрерывного распределения (основанного на дробной части а). Подумаем о простом, житейском примере: как вычислить медиану, когда число строк четное. Значения интерполируются в предположении непре- рывного распределения. Полученное в результате интерполяции значение попадает точно между двумя средними точками, то есть оно является сред- ним этих двух средних точек. Вот пример вычисления медианы результатов экзаменов, в котором обе функции распределения используются как оконные: DECLARE @pct AS FLOAT =0.5; SELECT testid, score, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc, PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont FROM Stats.Scores; testid score percentiledisc percentilecont Test ABC 50 75 75 Test ABC 55 75 75 Test ABC 55 75 75 Test ABC 65 75 75 Test ABC 75 75 75 Test ABC 80 75 75 Test ABC 80 75 75 Test ABC 95 75 75 Test ABC 95 75 75 Test XYZ 50 75 77.5 Test XYZ 55 75 77.5 Test XYZ 55 75 77.5 Test XYZ 65 75 77.5 4 3ak 601
82 Глава 2 Более подробные сведения об оконных функциях Test XYZ 75 75 77.5 Test XYZ 80 75 77.5 Test XYZ 80 75 77.5 Test XYZ 95 75 77.5 Test XYZ 95 75 77.5 Test XYZ 95 75 77.5 Вот еще один пример вычисления десятого процентиля (0,1): DECLARE @pct AS FLOAT =0.1; SELECT testid, score, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc, PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont FROM Stats.Scores; testid score percentiledisc percentilecont Test АВС 50 50 54 Test АВС 55 50 54 Test АВС 55 50 54 Test АВС 65 50 54 Test АВС 75 50 54 Test АВС 80 50 54 Test АВС 80 50 54 Test АВС 95 50 54 Test АВС 95 50 54 Test XYZ 50 50 54.5 Test XYZ 55 50 54.5 Test XYZ 55 50 54.5 Test XYZ 65 50 54.5 Test XYZ 75 50 54.5 Test XYZ 80 50 54.5 Test XYZ 80 50 54.5 Test XYZ 95 50 54.5 Test XYZ 95 50 54.5 Test XYZ 95 50 54.5 Как уже говорилось, я предоставлю более подробную информацию о функциях обратного распределения в главе 3, в том числе альтернативные варианты, в рассказе о функциях упорядоченного набора. Функции смещения Оконные функции смещения делятся на две категории. Первая категория — функции, смещение которых указывается по отношению к текущей строке.
Более подробные сведения об оконных функциях Гла?а2 83 Это LAG и LEAD. В функциях второй категории смещение указывается по отношению к началу или концу оконного кадра. Сюда относятся функции FIRSTVALUE, LAST VALUE и NTHVALUE. SQL Server 2012 поддер- живает LAG, LEAD, FIRST VALUE и LAST_VALUE и не поддерживает NTH_VALUE. Поддерживаемые оконные элементы Функции первой категории (LAG и LEAD) поддерживают предложение секционирования, а также упорядочения окна. Ясно, что вторая часть вно- сит смысл в смещение. Функции из второй категории (FIRST_VALUE, LAST_VALUE и NTH_VALUE) помимо предложения секционирования и упорядочения окна поддерживают предложение оконного кадра. Функции LAG и LEAD Функции LAG и LEAD позволяют возвращать выражение значения из стро- ки в секции окна, которая находится на заданном смещении перед (LAG) или после (LEAD) текущей строки. Смещение по умолчанию — «1», оно применяется, если смещение не указать. Например, следующий запрос возвращает текущую стоимость для каж- дого клиентского заказа, а также стоимости предыдущего и последующего заказов этого же клиента: SELECT custid, orderdate, orderid, val, LAG(val) 0VER(PARTITION BY custid ORDER BY orderdate, orderid) AS prevval, LEAD(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS nextval FROM Sales.OrderValues; custid orderdate orderid val prevval nextval 1 2007-08-25 10643 814.50 NULL 878.00 1 2007-10-03 10692 878.00 814.50 330.00 1 2007-10-13 10702 330.00 878.00 845.80 1 2008-01-15 10835 845.80 330.00 471.20 1 2008-03-16 10952 471.20 845.80 933.50 1 2008-04-09 11011 933.50 471.20 NULL 2 2006-09-18 10308 88.80 NULL 479.75 2 2007-08-08 10625 479.75 88.80 320.00 2 2007-11-28 10759 320.00 479.75 514.40 2 2008-03-04 10926 514.40 320.00 NULL 3 2006-11-27 10365 403.20 NULL 749.06 3 2007-04-15 10507 749.06 403.20 1940.85 3 2007-05-13 10535 1940.85 749.06 2082.00 3 2007-06-19 10573 2082.00 1940.85 813.37 3 2007-09-22 10677 813.37 2082.00 375.50
Более подробные сведения об оконных функциях 84 Глава 2 3 2007-09-25 10682 375.50 813.37 660.00 3 2008-01-28 10856 660.00 375.50 NULL Так как мы явно не задали смещение, по умолчанию предполагается сме- щение в единицу. Так как данные в функции секционируются по custid, по- иск строк выполняется только в рамках той же секции, содержащей данные одного клиента. Что касается упорядочения окон, то понятия «предыду- щий» и «следующий» определяются упорядочением по orderdate и orderid в качестве дополнительного параметра. Заметьте, что в результатах запроса LAG возвращает NULL для первой строки оконной секции, потому что пе- ред первой строкой других строк нет; аналогично LEAD возвращает NULL для последней строки. Если нужно смещение, отличное от единицы, нужно указать его после входного выражения значения, как в этом запросе: SELECT custid, orderdate, orderid, LAG(val, 3) OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS prev3val FROM Sales.OrderValues; custid orderdate orderid prev3val 1 2007-08-25 10643 NULL 1 2007-10-03 10692 NULL 1 2007-10-13 10702 NULL 1 2008-01-15 10835 814.50 1 2008-03-16 10952 878.00 1 2008-04-09 11011 330.00 2 2006-09-18 10308 NULL 2 2007-08-08 10625 NULL 2 2007-11-28 10759 NULL 2 2008-03-04 10926 88.80 3 2006-11-27 10365 NULL 3 2007-04-15 10507 NULL 3 2007-05-13 10535 NULL 3 2007-06-19 10573 403.20 3 2007-09-22 10677 749.06 3 2007-09-25 10682 1940.85 3 2008-01-28 10856 2082.00 Как говорилось, LAG и LEAD по умолчанию возвращают NULL, если по заданному смещению нет строки. Если нужно возвращать другое значе- ние, можно указать его в качестве третьего аргумента функции. Например, LAG(val, 3,0.00) возвращает «0.00», если по смещению 3 перед текущей стро- кой строки вообще нет.
Более подробные сведения об оконных функциях Глава 2 85 Для реализации подобного поведения в LAG и LEAD на версии SQL Server, предшествующей SQL Server 2012, можно применить следующий подход: Напишите запрос, который возвращает номера строк с требуемыми па- раметрами секционирования и упорядочения, и создайте на его основе табличное выражение. Соедините множественные табличные выражения так, чтобы они пред- ставляли текущую, предыдущую и следующую строки. В предикате соединения сопоставьте столбцы секционирования различ- ных экземпляров (текущего с предыдущим или последующим). Также в предикате соединения вычислите разницу между числом строк текуще- го и предыдущего или следующего экземпляра, а затем отфильтруйте на основе значения смещения, которое требуется в ваших вычислениях. Вот запрос, реализующий этот подход и возвращающий для каждого за- каза значения текущего, предыдущего и следующего заказа клиента: WITH OrdersRN AS ( SELECT custid, orderdate, orderid, val, ROW_NUMBER() OVER(ORDER BY custid, orderdate, orderid) AS rn FROM Sales.OrderValues ) SELECT C.custid, C.orderdate, C.orderid, C.val, P.val AS prevval, N.val AS nextval FROM OrdersRN AS C LEFT OUTER JOIN OrdersRN AS P ON C.custid = P.custid AND C. rn = P. rn + 1 LEFT OUTER JOIN OrdersRN AS N ON C.custid = N.custid AND C. rn = N. rn - 1; Ясно, что решить эту задачу можно также с помощью простых вложен- ных запросов. Функции FIRST_VALUE, LAST.VALUE и NTH_VALUE В предыдущем разделе я рассказал о функциях смещения LAG и LEAD, ко- торые позволяют задавать смещение относительно текущей строки. Этот раздел посвящен функциям, которые позволяют определять смещение от- носительно начала или конца оконного кадра. Это функции FIRST_VALUE, LAST VALUE и NTH_VALUE, причем последняя не реализована в SQL Server 2012. Напомню, что LAG и LEAD поддерживают предложения секционирова- ния и упорядочение, но не поддерживают предложение кадрирования окна.
86 Глава 2 Более подробные сведения об оконных функциях Это разумно, если смещение указывается относительно текущей строки. Но в функциях, в которых смещение указывается по отношению к началу или концу окна, кадрирование имеет смысл. Функции FIRSTVALUE и LAST- VALUE возвращают запрошенное выражение значения соответственно из первой и последней строки в кадре. Вот запрос, демонстрирующий, как воз- вращать с каждым заказом клиента текущее значение этого заказа, а также значения первого и последнего заказа: SELECT custid, orderdate, orderid, val, FIRST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS val.firstorder, LAST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS val_lastorder FROM Sales.OrderValues: custid orderdate orderid val val_firstorder val.lastorder 1 2007-08-25 10643 814.50 814.50 933.50 1 2007-10-03 10692 878.00 814.50 933.50 1 2007-10-13 10702 330.00 814.50 933.50 1 2008-01-15 10835 845.80 814.50 933.50 1 2008-03-16 10952 471.20 814.50 933.50 1 2008-04-09 11011 933.50 814.50 933.50 2 2006-09-18 10308 88.80 88.80 514.40 2 2007-08-08 10625 479.75 88.80 514.40 2 2007-11-28 10759 320.00 88.80 514.40 2 2008-03-04 10926 514.40 88.80 514.40 3 2006-11-27 10365 403.20 403.20 660.00 3 2007-04-15 10507 749.06 403.20 660.00 3 2007-05-13 10535 1940.85 403.20 660.00 3 2007-06-19 10573 2082.00 403.20 660.00 3 2007-09-22 10677 813.37 403.20 660.00 3 2007-09-25 10682 375.50 403.20 660.00 3 2008-01-28 10856 660.00 403.20 660.00 С технической точки зрения нам нужны значения из первой и последней строки секции. С FIRST VALUE просто, потому что можно использовать кадрирование по умолчанию. Как вы помните, если поддерживается кадри- рование и не указать предложение кадрирования окна, по умолчанию бу- дет применяться RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Ho c LAST_VALUE кадрирование по умолчанию беспо- лезно, потому что последней является текущая строка. Поэтому в этом при- мере используется явное определение кадра с UNBOUNDED FOLLOWING в качестве нижней границы кадра.
Более подробные сведения об оконных функциях Глава 2 87 Обычно не возвращают первое или последнее значение вместе со всеми подробностями строк, как в предыдущем примере — в вычислениях обычно работают с одной цифрой и значением, возвращенным оконной функцией. В следующем примере запрос возвращает, вместе с каждым клиентским за- казом, стоимость текущего заказа, а также разницу между ней и стоимостью первого и последнего заказа клиента: SELECT custid, orderdate, orderid, val. val - FIRST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS difffirst, val - LAST,VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS difflast FROM Sales OrderValues; custid orderdate orderid val difffirst difflast 1 2007-08-25 10643 814.50 0.00 -119.00 1 2007-10-03 10692 878.00 63.50 -55.50 1 2007-10-13 10702 330.00 -484.50 -603.50 1 2008-01-15 10835 845.80 31.30 -87.70 1 2008-03-16 10952 471.20 -343.30 -462.30 1 2008-04-09 11011 933.50 119.00 0.00 2 2006-09-18 10308 88.80 0.00 -425.60 2 2007-08-08 10625 479.75 390.95 -34.65 2 2007-11-28 10759 320.00 231.20 -194.40 2 2008-03-04 10926 514.40 425.60 0.00 3 2006-11-27 10365 403.20 0.00 -256.80 3 2007-04-15 10507 749.06 345.86 89.06 3 2007-05-13 10535 1940.8 1537.65 1280.85 3 2007-06-19 10573 2082.0 1678.80 1422.00 3 2007-09-22 10677 813.37 410.17 153.37 3 2007-09-25 10682 375.50 -27.70 -284.50 3 2008-01-28 10856 660.00 256.80 0.00 Как я говорил, стандартная функция NTH_VALUE не реализована в SQL Server 2012. Эта функция позволяет запрашивать выражение значения, кото- рое находится на заданном смещении, выраженном в числе строк, от первой или последней строки в оконном кадре. Смещение задается во втором входном значении после выражения значения и ключевого слова FROM FIRST или FROM LAST, которое указывает, от какой строки отсчитывать смещение — от первой или последней. Например, следующее выражение возвращает зна- чение из третьей строки, если считать от самой нижней в секции: NTH_VALUE(val, 3) FROM LAST OVERSOWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
88 Глава 2 Более подробные сведения об оконных функциях Представим, что нам надо обеспечить функциональность, которую реа- лизуют функции FIRST VALUE, LAST VALUE и NTH VALUE, в версии, предшествующей SQL Server 2012. Для этого можно использовать такие конструкции, как обобщенные табличные выражения (СТЕ), функцию ROW_NUMBER и выражение CASE, группировку и соединение, следую- щим образом: WITH OrdersRN AS ( SELECT custid, val, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS rna, R0W_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rnd FROM Sales.OrderValues ), Agg AS ( SELECT custid, MAX(CASE WHEN rna = 1 THEN val END) AS firstorderval, MAX(CASE WHEN rnd = 1 THEN val END) AS lastorderval, MAX(CASE WHEN rna = 3 THEN val END) AS thirdorderval FROM OrdersRN GROUP BY custid ) SELECT 0.custid. 0.orderdate, 0.orderid, O.val, A.firstorderval, A.lastorderval, A.thirdorderval FROM Sales.OrderValues AS 0 JOIN Agg AS A ON 0.custid = A.custid ORDER BY custid, orderdate, orderid; custid orderdate orderid val firstorderval lastorderval thirdorderval 1 2007-08-25 10643 814.50 814.50 933.50 330.00 1 2007-10-03 10692 878.00 814.50 933.50 330.00 1 2007-10-13 10702 330.00 814.50 933.50 330.00 1 2008-01-15 10835 845.80 814.50 933.50 330.00 1 2008-03-16 10952 471.20 814.50 933.50 330.00 1 2008-04-09 11011 933.50 814.50 933.50 330.00 2 2006-09-18 10308 88.80 88.80 514.40 320.00 2 2007-08-08 10625 479.75 88.80 514.40 320.00 2 2007-11-28 10759 320.00 88.80 514.40 320.00 2 2008-03-04 10926 514.40 88.80 514.40 320.00 3 2006-11-27 10365 403.20 403.20 660.00 1940.85 3 2007-04-15 10507 749.06 403.20 660.00 1940.85 3 2007-05-13 10535 1940.85 403.20 660.00 1940.85
Более подробные сведения об оконных функциях Глава 2 89 3 2007-06-19 10573 2082.00 403.20 660.00 1940.85 3 2007-09-22 10677 813.37 403.20 660.00 1940.85 3 2007-09-25 10682 375.50 403.20 660.00 1940.85 3 2008-01-28 10856 660.00 403.20 660.00 1940.85 В первом СТЕ по имени OrdersRN определяются номера строк как воз- растающем, так и убывающем порядке для отметки позиций строк по отно- шению к первой и последней строке в секции. Во втором СТЕ по имени Agg используется выражение CASE для фильтрации только нужных номеров строк, группировки данных по элементу секционирования (custid) и приме- нения агрегата к результату выражения CASE, чтобы вернуть запрошенное значение для каждой группы. Наконец во внешнем запросе результат груп- пового запроса соединяется с исходной таблицей для сопоставления детали- зованной информации с агрегатами. Резюме В этой главе мы обсудили подробности работы оконных функций, сосре- доточившись на их логических аспектах. Я рассказал о функциональности, описанной в стандарте SQL, и указал, какую часть из нее поддерживает SQL Server 2012. В тех случаях, когда SQL Server 2012 не поддерживает ту или иную функцию, я показал альтернативные варианты реализации с использо- ванием поддерживаемых средств.
Глава 3 Функции для работы с упорядоченными наборами Вам когда-нибудь требовалось соединить (конкатенировать) элементы группы в определенном порядке в одну строку? Для решения именно та- кой задачи применяются функции для работы с упорядоченными набора- ми. Функция упорядоченного набора (ordered set function) — это особый вид агрегирующей функции. От обычных функций (таких как SUM, MIN, MAX и прочих) ее отличает значимость порядка при вычислениях, например по- рядок элементов при конкатенации. В этой главе я расскажу о функциях упорядоченного набора и опишу способы решения задач с их использованием. Так как они еще не поддержи- ваются в Microsoft SQL Server, я покажу как смоделировать их средствами SQL Server 2012. Функции упорядоченного набора используются в групповых запросах аналогично обычным функциям набора. Что касается синтаксиса, в стандар- те SQL определено предложение WITHIN GROUP, в котором можно задать правило упорядочивания, например, так: <функция_упорядоченного_набора> WITHIN GROUP ( ORDER BY <список_параметров_ упорядочения> ) В стандарте SQL определены два типа функций упорядоченного набо- ра, с именами необычными, но, тем не менее, отражающими назначение: функции гипотетического набора и функции обратного распределения. Я объясню, почему они так называются, когда буду рассказывать о ключевых характеристиках каждого типа. Но прежде чем приступить к подробному опи- санию, я хочу заметить, что концепция функций упорядоченного набора не ограничивается только этими двумя типами, заданными в стандарте — она может быть расширена до любой агрегатной функции, для вычисления ко- торой важно упорядочение. В качестве примера замечу, что в агрегате конкатенации строк можно предоставить пользователю возможность задать алфавитный порядок по убыванию или по возрастанию или использовать для упорядочения внеш- ний ключ. Было бы здорово, если бы в будущем SQL Server поддержал эту
Функции для работы с упорядоченными наборами Глава 3 91 концепцию и в пользовательских агрегатных функциях в общеязыковой среде выполнения (CLR). И если в пользовательских агрегатных функци- ях важно упорядочение в результатах вычисления, Microsoft стоило бы ис- пользовать стандартный синтаксис с использованием инструкции WITHIN GROUP. Я начну эту главу с описания стандартных функций упорядоченного набора и существующих им альтернатив в SQL Server. Затем я опишу до- полнительные вычисления, которые удовлетворяют концепции, но отсут- ствуют в стандарте, и, наконец, я расскажу о решениях, поддерживаемых SQL Server. Функции гипотетического набора К функциям гипотетического набора относят функции ранжирования и распределения рангов, с ними вы уже знакомы как с оконными функция- ми, но они применяются к группам для ввода значений в гипотетической форме. Я уверен, это описание вам пока ни о чем не говорит, но скоро вы все поймете. Существует две функции ранжирования упорядоченного набора: RANK и DENSE_RANK. Также существует две функции распределения рангов упо- рядоченного набора: PERCENT RANK и CUME DIST У оконных функ- ций и функций упорядоченного набора существуют различия в способе упо- рядочивания. У первых, упорядочевание производится в рамках окна, а у последних — в пределах группы. При использовании в оконной функции по- рядковое значение текущей строки вычисляется относительно порядковых значений в оконной секции. При использовании в функции упорядоченного набора входное значение рассчитывается в соответствии с упорядоченны- ми значениями в группе. Когда функция упорядоченного набора получает на вход значение, вы спрашиваете «Каков был бы результат вычисления функции для данного значения, если бы я добавил его в набор в качестве элемента?» Заметьте, что «Каков был бы» используется здесь чисто гипоте- тически. Эта одна из тем, которые легче объяснить на конкретном примере. Я нач- ну с функции RANK. Функция RANK Пусть у нас есть следующий запрос с использованием оконной функции RANK, результат показан ниже в сокращенном виде: USE TSQL2012; SELECT custid, val, RANKO OVER(PARTITION BY custid ORDER BY val) AS rnk FROM Sales.OrderValues;
92 Глава 3 Функции для работы с упорядоченными наборами custid val rnk 1 330.00 1 1 471.20 2 1 814.50 3 1 845.80 4 1 878.00 5 1 933.50 6 2 88.80 1 2 320.00 2 2 479.75 3 2 514.40 4 3 375.50 1 3 403.20 2 3 660.00 3 3 749.06 4 3 813.37 5 3 1940.85 6 3 2082.00 7 4 191.10 1 4 228.00 2 4 282.00 3 4 319.20 4 4 390.00 5 4 407.70 6 4 480.00 7 4 491.50 8 4 899.00 9 4 1477.00 10 4 1641.00 11 4 2142.90 12 4 4441.25 13 Функция ранжирует заказы каждого клиента по размеру заказа. Вы може- те объяснить, почему, допустим, у строк с рангом 5 именно этот ранг? В гла- ве 2 при упорядочении по возрастанию функция RANK вычисляет величину на единицу большую количества строк в окне разбиения с упорядочиванием, значения которых меньше текущего. Возьмем, например, клиента 3. У строки, получившей ранг 3 для клиента 5, значение упорядочения равно 813,37. Ее ранг определен как 5, потому что есть еще четыре строки в том же разбиении со зна- чениями упорядочение меньшими, чем 813,37 (375,50,403,20,660,00 и 749,06). Теперь предположим, что нужно выполнить анализ «что, если» и узнать ответ на вопрос: «Каков будет ранг входного значения @val в каждой группе клиентов с учетом других значений в столбце ш/»? Это равносильно тому, что вы бы сделали следующее:
Функции для работы с упорядоченными наборами Глава 3 93 1. Посчитали каждую группу клиентов оконной секцией с упорядочением окна по столбцу val. 2. Добавили каждую секцию по строке со значением ©val. 3. Вычислили оконную функцию RANK для этой строки в каждой секции. 4. Вернули только эту строку для каждого разбиения. Допустим, к примеру, что входное значение ©val равно 1000,00. Каков бу- дет ранг этого значения в каждой группе клиентов по отношению к другим значениям в столбце val при упорядочивании по возрастанию? Результат будет на единицу превосходить количество строк в каждой группе клиен- тов, имеющих значения менее чем 1000,00. Например. Для клиента 3 мы по- лучим ранг 6, потому что есть пять строк в столбце val, значения которых меньше, чем 1000,00 (375,50, 403,20, 660,00, 749,06 и 813,37). Стандартизированная форма записи функции упорядоченного набора RANK выглядит следующим образом: RANK(<Bxo^Hb/e данные>) WITHIN GROUP ( ORDER BY <список сортировки> ) А вот как ее можно использовать в качестве функции группового агре- гирования, чтобы решить задачу (не забывайте, что этот синтаксис не под- держивается в SQL Server 2012): DECLARE @val AS NUMERIC(12, 2) = 1000.00; SELECT custid, RANK(@val) WITHIN GROUP(ORDER BY val) AS rnk FROM Sales.OrderValues GROUP BY custid; custid rnk 1 7 2 5 3 6 4 10 5 7 6 8 7 6 8 3 9 9 10 7 На этом этапе принцип работы функций упорядоченного набора должен выглядеть для вас более понятным. В последнем примере я показал, как пользоваться стандартной функцией упорядоченного набора RANK, но как было уже сказано, SQL Server не под- держивает данный синтаксис. Но для таких вычислений можно легко обой-
94 Глава 3 Функции для работы с упорядоченными наборами тись без встроенной функции. Для этого задействуем выражение CASE, ко- торое возвращает некоторую константу, когда значение упорядочения мень- ше входного, и NULL в противном случае (это поведение по умолчанию, если отсутствует приложение ELSE). Применяем агрегирование COUNT к результату выражения CASE и прибавляем единицу. Вот готовый запрос: DECLARE @val AS NUMERIC(12, 2) = 1000.00; SELECT custid. COUNT(CASE WHEN val < @val THEN 1 END) + 1 AS rnk FROM Sales.OrderValues GROUP BY custid; Функция DENSE.RANK Вспомните, что DENSE RANK. как оконная функция схожа с RANK, только она возвращает значение на единицу большее, чем количество не- повторяющихся значений, по которым выполняется упорядочение, (вместо количества строк) в разбиении, которые не превосходят текущее значение. Аналогично при использовании ее в качестве функции упорядоченного на- бора для входного значения @val функция DENSE_ RANK вернет значение, на единицу превосходящее количество неповторяющихся упорядоченных значений в группе, меньших, чем @vaL Вот как должен выглядеть код в соот- ветствии со стандартом (опять же, не поддерживается в SQL Server 2012): DECLARE @val AS NUMERIC(12, 2) = 1000 00; SELECT custid, DENSE_RANK(@val) WITHIN GROUP(ORDER BY val) AS densernk FROM Sales.OrderValues GROUP BY custid; custid densernk 1 2 3 4 5 6 7 8 9 10 7 5 6 10 7 8 6 3 8 7 В качестве альтернативного решения с использованием имеющейся в SQL Server функциональности можно использовать прием, примененный
Функции для работы с упорядоченными наборами Глава 3 95 в RANK. Только вместо возврата константы в случае, когда упорядочивае- мое значение меньше ©val, нужно возвращать val и применять предложение DISTINCT к выражению агрегации примерно так: DECLARE @val AS NUMERIC(12, 2) = 1000.00; SELECT custid, COUNT(DISTINCT CASE WHEN val < @val THEN val END) + 1 AS densernk FROM Sales.OrderValues GROUP BY custid; Функция PERCENT_RANK Функции распределения рангов, а именно PERCENT RANK и CUME_ DIST, также поддерживаются стандартом как функции гипотетического набора. В этом разделе я расскажу о PERCENT RANK, а в следующем — о CUME DIST. Напомню, что в качестве оконной функции PERCENT RANK вычис- ляет относительный ранг строки в секции окна и представляет его в виде значения из диапазона от нуля до единицы (процента). Ранг вычисляется по следующему алгоритму: Пусть rk — ранг строки в соответствии с тем же определением окна, что и в определении окна функции распределения. Пусть пг — количество строк в секции окна. Тогда PERCENT_RANK рассчитывается по формуле: (rk - l)/(nr- 1). А теперь будем мыслить в терминах функций гипотетического набора. Допустим, что для определенного входного значения необходимо узнать его процентиль в каждой группе при условии его (входного значения) до- бавления во все группы. Возьмем, к примеру, таблицу Scores со значениями результатов тестов. Для входного результата теста (назовем его ©score), не- обходимо узнать величину его процентиля в каждом тесте при условии до- бавления этого входного значения в результаты всех тестов. В соответствии со стандартом SQL функция упорядоченного набора PERCENT RANK ис- пользуется в качестве агрегирующей функции: DECLARE @score AS TINYINT = 80; SELECT testid, PERCENT_RANK(@score) WITHIN GROUP(ORDER BY score) AS pctrank FROM Stats.Scores GROUP BY testid; testid pctrank Test ABC 0.556 Test XYZ 0.500
96 Глава 3 Функции для работы с упорядоченными наборами Чтобы получить величину процентиля как результат функции гипотети- ческого набора в SQL Server, потребуется самому реализовать эту функцио- нальность. Один из вариантов — сгенерировать rk и пг с помощью агрегиро- вания COUNT а затем высчитать величину процентиля как (rk - 1 )/(лгг - 1). Чтобы вычислить rk, нужно посчитать количество результатов теста, ко- торые ниже входного результата. Чтобы высчитать пг, необходимо просто посчитать количество строк и прибавить единицу (чтобы входное значение учитывалось в составе группы). Вот окончательный результат: DECLARE @score AS TINYINT = 80; WITH C AS ( SELECT testid, COUNT(CASE WHEN score < @score THEN 1 END) + 1 AS rk, COUNT(*) + 1 AS nr FROM Stats.Scores GROUP BY testid ) SELECT testid, 1.0 * (rk - 1) / (nr - 1) AS pctrank FROM C; Функция CUME.DIST Вычисление функции CUME DIST схоже c PERCENT RANK. Как окон- ная функция она рассчитывается следующим образом: Допустим, что пг — количество строк в секции окна. Пусть пр — количество строк, которые превосходят или равны текущей строке. Результат CUME_DIST будет вычислен следующим образом: пр/пг. В качестве функции гипотетического набора CUME DIST возвращает результат накопительной функции распределения для входного значения, если это значение добавить во все группы. Вот пример «стандартного» ис- пользования CUME DIST в качестве функции упорядоченного набора применительно к нашей задаче с таблицей Scores: DECLARE @score AS TINYINT = 80; SELECT testid, CUME_DIST(@score) WITHIN GROUP(ORDER BY score) AS cumedist FROM Stats.Scores GROUP BY testid; testid cumedist Test ABC 0.800 Test XYZ 0.727
Функции для работы с упорядоченными наборами Глава 3 97 Что касается версии для SQL Server, то она очень похожа на вариант, ко- торый вы использовали в качестве альтернативной реализации функции PERCENT_RANK. Мы вычисляли пр как количество строк в группе, резуль- таты в которой ниже, чем входное значение, плюс единица, которая соответ- ствует входному значению. Мы вычисляли пг как количество строк в группе плюс единица, которая соответствует входному значению. И, наконец, высчи- тывали накопительное распределение как пр/пг. Вот готовое решение: DECLARE @score AS TINYINT = 80; WITH C AS ( SELECT testid, COUNT(CASE WHEN score <= @score THEN 1 END) + 1 AS np, COUNT(*) + 1 AS nr FROM Stats.Scores GROUP BY testid ) SELECT testid, 1.0 * np / nr AS cumedist FROM C; Обобщенное решение Так как в SQL Server 2012 отсутствует поддержка функций гипотетическо- го набора, я привел альтернативные способы, которые позволяют получить нужные результаты. Для различных вычислений я приводил очень разные решения. В этой главе я покажу более универсальное решение. У всех четырех неподдерживаемых функций гипотетического набора есть парные оконные функции. Так, SQL Server 2012 поддерживает окон- ные функции RANK, DENSE RANK, PERCENT RANK и CUMEDIST. Вспомните, что функция гипотетического набора должна возвращать для входного значения результат, который соответствующая оконная функция вернет, если входное значение добавить в набор. Помня об этом, можно создать решение, которое будет работать одинаково для всех вычислений. Возможно, такое универсальное решение будет не так хорошо оптимизиро- ваться, как более специализированные, но на него интересно взглянуть. Вот последовательность универсального решения: 1. Объединение существующего набора и входного значения. 2. Применение оконной функции. 3. Фильтрация строки с входным значением для получения результата. Вот код решения: SELECT <partition_col>. wf AS osf FROM <partitions_table> AS P CROSS APPLY (SELECT <window_function>() OVER(ORDER BY <ord_col>) AS wf, return_flag
98 Глава 3 Функции для работы с упорядоченными наборами FROM (SELECT <ord_col>, О AS return_flag FROM <details_table> AS D WHERE D.<partition_col> = P.<partition_col> UNION ALL SELECT @input_val, 1) AS D) AS A WHERE return_flag = 1; Внешний запрос обращается к таблице содержащей неповторяющиеся значения в секции. Далее с помощью оператора CROSS APPLY код обраба- тывает каждую секцию отдельно. Самая глубоко вложенная производная та- блица Uотвечает за объединение строк текущей секции, которые обозначены значением return_Jlag 0, со строкой, созданной на основе входного значения и обозначенной здесь значением return_Jlag 7. Затем запрос вычисляет на основе таблицы U оконную функцию, генерируя унаследованную таблицу А. Наконец, внешний запрос отфильтровывает только строки со значением return_Jlag 1. Это строки, для которых в каждой секции выполнено вычис- ление с учетом входного значения, то есть на основе гипотетического набора вычислений. Если данная обобщенная форма вам до сих пор не ясна, попробуйте по- нять логику на конкретных примерах. Этот запрос обращается к таблице Customers (за данными секций) и представлению Sales.OrderValues (за под- робными данными). Он вычисляет RANK и DENSE_RANK как гипотети- ческий набор вычислений для входного значения ©val с секционирующим элементом custid и упорядочивающим элементом vak DECLARE @val AS NUMERIC(12, 2) = 1000.00; SELECT custid, rnk, densernk FROM Sales.Customers AS P CROSS APPLY (SELECT RANKO OVER(ORDER BY val) AS rnk, DENSE_RANK() OVER(ORDER BY val) AS densernk, return_flag FROM (SELECT val, 0 AS return_flag FROM Sales.OrderValues AS D WHERE D.custid = P.custid UNION ALL SELECT @val, 1) AS U) AS A WHERE return_flag = 1; custid rnk densernk 1 7 7
Функции для работы с упорядоченными наборами Глава 3 99 2 5 5 3 6 6 4 10 10 5 7 7 6 8 8 7 6 6 8 3 3 9 9 8 11 9 9 Похожим образом следующий код обращается к таблицам Tests (секции) и Scores (подробности). Он высчитывает PERCENT RANK и CUME DIST как набор гипотетических вычислений для входного значения ©score, с testid в качестве секционирующего элемента и score в качестве упорядочивающего элемента: DECLARE @score AS TINYINT = 80; SELECT testid. pctrank, cumedist FROM Stats.Tests AS P CROSS APPLY (SELECT PERCENT_RANK() OVER(ORDER BY score) AS pctrank, CUME_DIST() OVER(ORDER BY score) AS cumedist. return_flag FROM (SELECT score, 0 AS return_flag FROM Stats.Scores AS D WHERE D.testid = P.testid UNION ALL SELECT @score, 1) AS U) AS A WHERE return_flag = 1; testid pctrank cumedist Test ABC 0.555555555555556 0.8 Test XYZ 0.5 0.727272727272727 Конечно, существуют другие способы обобщения решения для гипотети- ческого набора вычислений. Здесь я привел только один из методов. Надо заметить, что этот метод возвращает строки из таблицы секций, даже если в таблице подробностей нет соответствующих строк. Если они вам не нужны, необходимо добавить исключающую их логику, например добавив предикат NOT EXISTS. В приведенном примере для исключения клиентов, у которых нет заказов, из запроса, вычисляющего RANK и DENSE RANK для гипотетического набора, можно использовать следующий код:
100 Глава 3 Функции для работы с упорядоченными наборами DECLARE @val AS NUMERIC(12, 2) = 1000.00; SELECT custid, rnk, densernk FROM Sales.Customers AS P CROSS APPLY (SELECT RANKO OVER(ORDER BY val) AS rnk, DENSE_RANK() OVER(ORDER BY val) AS densernk. return_flag FROM (SELECT val, 0 AS return_flag FROM Sales.OrderValues AS WHERE D.custid = P.custid UNION ALL SELECT @val, 1) AS U) AS A WHERE return_flag = 1 AND EXISTS (SELECT * FROM Sales.OrderValues AS WHERE D.custid = P.custid); Этот запрос возвращает 89, а не 91 строк, потому что только 89 из 91 суще- ствующих клиентов разместили заказы. Функции обратного распределения Функции обратного распределения (inverse distribution functions) произ- водят вычисления, которые можно представить себе как операцию, обрат- ную выполняемой функциям распределения рангов PERCENT_RANK и CUME DIST. Функции распределения рангов вычисляют ранг значения относительно других значений в секции или группе как число в диапазоне от 0 до 1 (процент). Функции обратного распределения выполняют практи- чески обратное. Получая на вход процент ©pct, они возвращают значение из секции или группы, которому этот процент соответствует. Проще говоря, они возвращают значение, по отношению к которому ©pct процентов зна- чений являются меньше этого значения. Если вам все еще непонятен смысл этого предложения, все станет яснее на примерах. Функции обратного рас- пределения больше известны как процентили. В стандарте существует два варианта функций обратного распределения: PERCENTILE DISC, которая возвращает существующее значение из сово- купности, используя модель дискретного распределения, и PERCENTILE- CONT, которая возвращает интерполированное значение с использованием модели непрерывного распределения. Я объяснял особенности этих двух расчетов в главе 2. Напомню вкратце, что PERCENTILE DISC возвраща- ет первое значение в группе, накопительное распределение которой больше или равно входного значения. Функция PERCENTILE CONT определяет две строки, между которыми попадает входное значение процента, и вычис-
Функции для работы с упорядоченными наборами Глава 3 101 ляет интерполяцию двух упорядоченных значений с использованием моде- ли непрерывного распределения. SQL Server 2012 поддерживает только оконные версии этих функций, о которых подробно говорилось в главе 2. Более естественные версии функ- ции упорядоченного набора, которые можно было бы использовать в груп- повых запросах, не поддерживаются. Но я покажу альтернативные варианты для версий функций упорядоченного набора для SQL Server 2012 и для бо- лее ранних версий SQL Server. Напомню запрос данных из таблицы Scores, которые вычисляет 50-й про- центиль (медиану) тестовых значений, используя оба варианта функции, в том числе и оконный: DECLARE @pct AS FLOAT = 0.5; SELECT testid, score, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc, PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont FROM Stats.Scores; testid score percentiledisc percentilecont Test АВС 50 75 75 Test АВС 55 75 75 Test АВС 55 75 75 Test АВС 65 75 75 Test АВС 75 75 75 Test АВС 80 75 75 Test АВС 80 75 75 Test АВС 95 75 75 Test АВС 95 75 75 Test XYZ 50 75 77.5 Test XYZ 55 75 77.5 Test XYZ 55 75 77.5 Test XYZ 65 75 77.5 Test XYZ 75 75 77.5 Test XYZ 80 75 77.5 Test XYZ 80 75 77.5 Test XYZ 95 75 77.5 Test XYZ 95 75 77.5 Test XYZ 95 75 77.5 Заметьте, что один и тот же результат вычисления процентиля просто по- вторяется во всех членах одной секции (в данном случае это test} и в нашей ситуации это ненужная и избыточная информация. Возвращать процентили надо только раз для каждой группы. В соответствии со стандартом, чтобы
102 Глава 3 Функции для работы с упорядоченными наборами получить такой результат, мы должны использовать варианты функций с упорядоченным набором в групповом запросе, примерно так: DECLARE @pct AS FLOAT = 0.5; SELECT testid, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) AS percentiledisc, PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) AS percentilecont FROM Stats.Scores GROUP BY testid; Но эти версии не реализованы в SQL Server 2012, так что для получения такого же результата нам понадобятся альтернативные методы. Так как существует реализация оконных версий этих функций, одним из простых методов достижения результата является использование параметра DISTINCT, примерно так: DECLARE @pct AS FLOAT =0.5; SELECT DISTINCT testid, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc, PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont FROM Stats.Scores; testid percentiledisc percentilecont Test ABC 75 75 Test XYZ 75 77.5 Другой способ — присвоить уникальные номера строкам в каждой сек- ции, а затем отфильтровать лишь строки с номером 1, вот так: DECLARE @pct AS FLOAT =0.5; WITH C AS ( SELECT testid, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc, PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY (SELECT NULL)) AS rownum FROM Stats.Scores ) SELECT testid, percentiledisc, percentilecont FROM C WHERE rownum = 1;
Функции для работы с упорядоченными наборами Глава 3 103 Еще вариант — использовать ТОР (1) WITH TIES, с упорядочением по одинаковым номерам строк, в результате также получим только строки с но- мером строки 1: DECLARE @pct AS FLOAT =0.5; SELECT TOP (1) WITH TIES testid, PERCENTILE_DISC(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc. PERCENTILE_CONT(@pct) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont FROM Stats.Scores ORDER BY ROW_NUMBER() OVER(PARTITION BY testid ORDER BY (SELECT NULL)); Заметьте, что даже если последний прием был достаточно оригинальным и стимулирующим логическое мышление, он не так же эффективен, как предыдущий. Если вам необходимо посчитать процентили в версиях, предшествующих SQL Server 2012, потребуется самостоятельно реализовать логику вычисле- ний. В PERCENTILE DISC нужно вернуть первое значение в группе, на- копительное распределение которой больше или равно значению входного процента. Для вычисления накопительного распределения каждого значе- ния необходимо знать, сколько строк предшествовало или равно этому зна- чению (пр) и сколько строк содержится в каждой группе (пг). Тогда накопи- тельное распределение будет равно пр пг. Обычно при вычислении пр нужно вернуть значение на единицу меньшее минимального ранга, превышающего текущее значение. Для этого могут по- надобиться ресурсоемкие вложенные запросы и функция RANK. Благодаря Адаму Маканику (Adam Machanic), получить тот же результат можно мень- шими усилиями. Когда существование одинаковых строк исключается (то есть упорядочение уникально), для всех строк функция ROW NUMBER возвращает число равное пр. Когда одинаковые строки возможны (упоря- дочение неуникально) функция возвращает число равное пр для одной из строк, а для всех остальных строк — значение, меньшее пр. Так как мы с вами говорим о равных строках, по определению в случаях, когда номер строки меньше чем пр, значение сортировки такое же, как и в случае, когда номер строки равен пр. Благодаря этому факту, функция ROW NUMBER подхо- дит для наших нужд очень специфического представления пр. Что до вычис- ления пг, то можно использовать простую оконную функцию COUNT. Вот код, который реализует данную логику: DECLARE @pct AS FLOAT =0.5; WITH C AS ( SELECT testid, score, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score) AS np,
104 Глава 3 Функции для работы с упорядоченными наборами COUNT(*) OVER(PARTITION BY testid) AS nr FROM Stats.Scores ) SELECT testid, MIN(score) AS percentiledisc FROM C WHERE 1.0 * np/nr>= @pct GROUP BY testid; testid percentiledisc Test ABC 75 Test XYZ 75 Что касается альтернативы для функции PERCENTILE CONT в верси- ях, предшествующих Server 2012, кратко напомню описание логики вычис- лений в главе 2: Используем функцию PERCENTILE_CONTf@pc0 WITHIN GROUP (ORDER BY score). Пусть n — количество строк в группе. Пусть а равно ©pct * (п - 1) и пусть i — целая, а/ — дробная часть а. Пусть rowO и row 1 это строки, чьи номера при нумерации, начиная с нуля, попадают в диапазон от FLOOR(«) до CEILING(«). Здесь предпо- лагается, что номера строк определяются с такого же окна секциониро- вания и упорядочения, чтоб и группировка и упорядочение в функции PERCENTILECONT. Функция PERCENTILE CONT вычисляется как rowO.score + f * (row1. score - rowO.score). Это интерполяция значений в двух строках в предполо- жении непрерывного распределения (на основании дробной части а). Следующий код реализует эту логику: DECLARE @pct AS FLOAT = 0.5; WITH C1 AS ( SELECT testid, score, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score) - 1 AS rownum, @pct * (COUNT(*) OVER(PARTITION BY testid) - 1) AS a FROM Stats.Scores ), C2 AS ( SELECT testid, score, a-FLOOR(a) AS factor FROM C1 WHERE rownum IN (FLOOR(a), CEILING(a)) ) SELECT testid, MIN(score) + factor * (MAX(score) - MIN(score)) AS
Функции для работы с упорядоченными наборами Глава 3 105 percentilecont FROM С2 GROUP BY testid, factor; testid percentilecont Test ABC 75 Test XYZ 77.5 Функции смещения В стандарте SQL не определены версии для упорядоченного набора для функций FIRSTVALUE, LAST VALUE и NTHVALUE, вернее в нем опре- делены только оконные версии, которые и реализованы в SQL Server 2012. В качестве примера, следующий запрос возвращает с каждым заказом теку- щий размер заказа, а также значения первого и последнего заказов клиента: SELECT custid, orderdate, orderid, val, FIRST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS val_firstorder, LAST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS val_lastorder FROM Sales.OrderValues; custid orderdate orderid val val_firstorder val_lastorder 1 2007-08-25 10643 814.50 814.50 933.50 1 2007-10-03 10692 878.00 814.50 933.50 1 2007-10-13 10702 330.00 814.50 933.50 1 2008-01-15 10835 845.80 814.50 933.50 1 2008-03-16 10952 471.20 814.50 933.50 1 2008-04-09 11011 933.50 814.50 933.50 2 2006-09-18 10308 88.80 88.80 514.40 2 2007-08-08 10625 479.75 88.80 514.40 2 2007-11-28 10759 320.00 88.80 514.40 2 2008-03-04 10926 514.40 88.80 514.40 3 2006-11-27 10365 403.20 403.20 660.00 3 2007-04-15 10507 749.06 403.20 660.00 3 2007-05-13 10535 1940.85 403.20 660.00 3 2007-06-19 10573 2082.00 403.20 660.00 3 2007-09-22 10677 813.37 403.20 660.00 3 2007-09-25 10682 375.50 403.20 660.00 3 2008-01-28 10856 660.00 403.20 660.00
106 Глава 3 Функции для работы с упорядоченными наборами Заметьте, что во всех строках одного и того же клиента информация дублируется. Часто именно это и требуется, если необходимо в одном вы- ражении получить и подробную информацию, а также первое, последнее и n-ое значение из секции. Но что, если нужно не это? Что если нужны только первое, последнее и n-ое значения только в одном экземпляре для каждой группы? Если требуется этот вариант, почему бы не использовать варианты функ- ций с групповым агрегированием и упорядочением набора. В конце концов для определенной группы строк каждая из этих функций возвращает только одно значение. Это правда, что оконные версии этих функций поддержи- вают предложение оконного кадра, так что у каждой строки секции может быть свой кадр, поэтому результат будет другим. Но часто бывает нужно просто применить операцию к целой секции или группе. Вам может прийти в голову использовать функции упорядоченного на- бора FIRST VALUE и LAST VALUE в качестве более гибких версий функ- ций MIN и МАХ соответственно. Они более гибкие в том смысле, что функ- ции MIN и МАХ используют входное значение как упорядочивающий эле- мент, так и как выражение, которое необходимо возвратить, кроме того они не поддерживают множественные упорядочивающие элементы. Функции FIRST VALUE и LAST VALUE позволяют вам вернуть один элемент как выражение значения на основе элементе упорядочения или других элемен- тах. Так почему бы не реализовать поддержку этих функций с групповым агрегированием и упорядочением набора? Я надеюсь, это когда-то случится, а пока придется использовать альтер- нативные методы. Один из методов похож на тот, который я показывал в примере с функциями обратного распределения, и заключается он в вызо- ве оконных версий функций и вычислении уникальных номеров строк для каждой секции. Затем остается только отфильтровать результат, оставив только строки с номером равным единице: WITH С AS ( SELECT custid, FIRST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS val_firstorder, LAST_VALUE(val) OVER(PARTITION BY custid ORDER BY orderdate, orderid ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS val.lastorder, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY (SELECT NULL)) AS rownum FROM Sales.OrderValues ) SELECT custid, val_firstorder, val_lastorder FROM C WHERE rownum = 1;
Функции для работы с упорядоченными наборами Глава 3 107 custid val_firstorder val_lastorder 1 814.50 933.50 2 88.80 514.40 3 403.20 660.00 4 480.00 491.50 5 1488.80 1835.70 6 149.00 858.00 7 1176.00 730.00 8 982.00 224.00 9 88.50 792.75 10 1832.80 525.00 Но функции FIRST_VALUE и LAST_VALUE (оконные версии) до- ступны только в SQL Server 2012. Вдобавок, функции NTHVALUE нет ни в одной версии SQL Server, включая SQL Server 2012. Есть несколько способов реализовать эту функциональность в предыдущих версиях SQL Server, используя только функции ROW_NUMBER. Вычисляя номер стро- ки по возрастанию и отфильтровывая только строки с номером 1, получим эквивалент FIRST VALUE, а фильтруя строки по номеру п — эквивалент NTH VALUE FROM FIRST. Схожим образом, используя номера строк в убывающем порядке, получим эквивалент LAST_VALUE и NTH VALUE FROM LAST. Вот пример реализации такой логики, возвращающий первое, последнее и третье значение заказов для каждого клиента с упорядочением по orderdate и orderid'. WITH OrdersRN AS ( SELECT custid, val, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate, orderid) AS rna, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rnd FROM Sales.OrderValues ) SELECT custid, MAX(CASE WHEN rna = 1 THEN val END) AS firstorderval, MAX(CASE WHEN rnd = 1 THEN val END) AS lastorderval, MAX(CASE WHEN rna = 3 THEN val END) AS thirdorderval FROM OrdersRN GROUP BY custid; custid firstorderval lastorderval thirdorderval 1 814.50 933.50 330.00 2 88.80 514.40 320.00
108 Глава 3 Функции для работы с упорядоченными наборами 3 403.20 660.00 1940.85 4 480.00 491.50 407.70 5 1488.80 1835.70 2222.40 6 149.00 858.00 330.00 7 1176.00 730.00 7390.20 8 982.00 224.00 224.00 9 88.50 792.75 1549.60 10 1832.80 525.00 966.80 Есть еще один прием для вычислений первого и последнего значений, основанный на принципе параллельной сортировки. Идея состоит в том, что- бы сгенерировать одну строку, конкатенирующую сначала упорядочивающие элементы (в нашем случае orderdate и orderid}, а затем все элементы, которые необходимо возвратить. Затем с помощью агрегирования MIN или МАХ по- лучают строку, содержащую первое или последнее значение. Уловка состоит в том, чтобы при преобразовании исходных значений в строки, отформатиро- вать их таким образом, чтобы сохранить исходный порядок. В нашем случае, это означает преобразование значений orderdate в строку CHAR(8) с исполь- зованием стиля 112, который генерирует дату в формате YYYYMMDD (здесь YYYY — год, ММ — месяц, a DD — пень). Что до значений orderid, которые являются положительными целыми, их нужно преобразовать в формат фик- сированного размера с ведущими пробелами или нулями. Следующий запрос демонстрирует первый шаг решения, генерирующий конкатенированные строки: SELECT custid, CONVERT(CHAR(8). orderdate, 112) + STR(orderid, 10) + STR(val, 14, 2) COLLATE Latin1_General_BIN2 AS s FROM Sales.OrderValues; custid s 85 20060704 10248 440.00 79 20060705 10249 1863.40 34 20060708 10250 1552.60 84 20060708 10251 654.06 76 20060709 10252 3597.90 34 20060710 10253 1444.80 14 20060711 10254 556.62 68 20060712 10255 2490.50 88 20060715 10256 517.80 35 20060716 10257 1119.90
Функции для работы с упорядоченными наборами Глава 3 109 Обратите внимание на использование бинарной сортировки, которая по- могает немного повысить скорость сравнения. На втором шаге, определя- ется обобщенное табличное выражение (СТЕ), основанное на предыдущем запросе. Затем, во внешнем запросе, к строке применяется агрегирование MIN и МАХ, из результата выделяется часть, представляющая значение, и приводится обратно к исходному типу. Вот полное решение и сокращенный результат его работы: WITH С AS ( SELECT custid, CONVERT(CHAR(8), orderdate, 112) + STR(orderid, 10) + STR(val, 14, 2) COLLATE Latin1_General_BIN2 AS s FROM Sales.OrderValues ) SELECT custid, CAST(SUBSTRING(MIN(s), 19, 14) AS NUMERIC(12, 2)) AS firstorderval, CAST(SUBSTRING(MAX(s), 19, 14) AS NUMERIC(12, 2)) AS lastorderval FROM C GROUP BY custid; custid firstorderval lastorderval 1 814.50 933.50 2 88.80 514.40 3 403.20 660.00 4 480.00 491.50 5 1488.80 1835.70 6 149.00 858.00 7 1176.00 730.00 8 982.00 224.00 9 88.50 792.75 10 1832.80 525.00 Заметьте, я использовал тот факт, что целые значения orderid являются неотрицательными. Если вы работаете с числовым упорядочивающим эле- ментом, который поддерживает отрицательные значения, придется доба- вить логику, которая обеспечит корректную сортировку. Это сложно, но не невозможно. Например, предположим, что значения orderid могут быть от- рицательными. Чтобы при сортировке отрицательные числа следовали пе- ред положительными, можно добавить ноль в строке перед отрицательным числом и единицу перед неотрицательным. Далее, чтобы обеспечить, чтобы отрицательные значения сортировались верно (например, чтобы -2 следо- вало перед -1), можно добавить к значению 2147483648 (модуль минималь-
110 Глава 3 Функции для работы с упорядоченными наборами но возможного отрицательного целого -2147483648) перед его преобразова- нием в строку символов. Вот как будет выглядеть пример полностью: WITH С AS ( SELECT custid, CONVERT(CHAR(8), orderdate, 112) + CASE SIGN(orderid) WHEN -1 THEN ’O’ ELSE ’1’ END -- отрицательные величины сортируются до неотрицательных + STR(CASE SIGN(orderid) WHEN -1 THEN 2147483648 -- к отрицательным величинам добавляем abs(minnegative) ELSE О END + orderid, 10) + STR(val, 14, 2) COLLATE Latin1_General_BIN2 AS ssanpoca FROM Sales.OrderValues ) SELECT custid, CAST(SUBSTRING(MIN(s), 20, 14) AS NUMERIC(12, 2)) AS firstorderval, CAST(SUBSTRING(MAX(s), 20, 14) AS NUMERIC(12, 2)) AS lastorderval FROM C GROUP BY custid; Когда будете использовать этот код в производственном приложении, не забудьте снабдить его подробными комментариями, потому что код совсем не тривиален. Конкатенация строк Как уже говорилось, в стандарте определены только два типа функций упо- рядоченного набора: функции гипотетического набора (RANK, DENSE- RANK, PERCENT RANK и CUME DIST) и функции обратного распре- деления (PERCENTILE DISC и PERCENTILE CONT). Как я уже демон- стрировал на примере функций сдвига, нет причины, по которой эта концеп- ция не работала бы и для других функций. Основная идея состоит в том, что если это агрегирующая функция, результат вычислений которой зависит от порядка следования элементов, это возможный кандидат на функцию упо- рядоченного набора. Возьмем такой классический пример как конкатенация строки. К сожалению, на настоящий момент не существует встроенной агре- гирующей функции конкатенации строки, которая бы соединяла группу строк. Но допустим, что такая функция существует. Конечно же, у вас может возникнуть необходимость в конкатенации группы строк в некотором по- рядке, поэтому имеет смысл реализовать такую функцию как функцию упо- рядоченного набора с предложением WITHIN GROUP, которое позволяет задать параметры упорядочения.
Функции для работы с упорядоченными наборами Глава 3 111 В Oracle, например, такая функция реализована (называется LISTAGG) как функция упорядоченного набора. Итак, чтобы обратиться к таблице с именем Sales.Orders и вернуть для каждого клиента строку со значениями orderid конкатенированными в порядке orderid, используйте следующий код: SELECT custid, LISTAGG(оrderid, ',') WITHIN GROUP(ORDER BY orderid) AS custorders FROM Sales.Orders GROUP BY custid; custid custorders 1 10643,10692,10702,10835,10952,11011 2 10308,10625,10759,10926 3 10365,10507,10535,10573,10677,10682,10856 4 10355,10383,10453,10558,10707,10741,10743,10768,10793,10864,10920,1 0953,11016 5 10278,10280,10384,10444,10445,10524,10572,10626,10654,10672,10689,1 0733,10778,... 6 10501,10509,10582,10614,10853,10956,11058 7 10265,10297,10360,10436,10449,10559,10566,10584,10628,10679,10826 8 10326,10801,10970 9 10331,10340,10362,10470,10511,10525,10663,10715,10730,10732,10755,1 0827,10871,... 11 10289,10471,10484,10538,10539,10578,10599,10943,10947,11023 В SQL Server разработчики прибегают к самым разным альтернативным решениям, чтобы получить конкатенацию строк в определенном порядке. Один из наиболее эффективных приемов основывается на обработке XML с использованием параметра FOR XML в режиме PATH, примерно так: SELECT custid, COALESCE( STUFF( (SELECT + CAST(orderid AS VARCHAR(10)) AS [textQ] FROM Sales.Orders AS 0 WHERE 0.custid = C.custid ORDER BY orderid FOR XML PATH(”), TYPE). value('. ', 'VARCHAR(MAX) ’), 1, 1, ”), ' ’) AS custorders FROM Sales.Customers AS C; Расположенный на самом нижнем уровне вложения связанный вложен- ный запрос отфильтровывает только значения orderid из таблицы Orders (псевдоним О), которые связаны с текущим клиентом из таблицы Customers
112 Глава 3 Функции для работы с упорядоченными наборами (псевдоним С). Используя предложение FOR XML РАТН(”), можно объе- динить все значения одну строку XML. Использование пустой строки как входного значения в режиме PATH означает, что инкапсулирующие эле- менты не нужны, поэтому мы получаем конкатенацию значений без всяких тегов. Так как вложенный запрос содержит ORDER BY orderid, значения orderid в строке будут упорядочены. Заметьте, что упорядочивать можно по любому признаку — не обязательно по значениям, которые конкатени- руются. Приведенный код также добавляет запятую в качестве разделителя перед каждым значением orderid, а затем функция STUFF удаляет первую запятую. И наконец, функция COALESCE преобразует результат NULL в пустую строку. Итак, мы видим, что существует возможность получить в SQL Server конкатенацию строк в определенном порядке, но выглядит это не очень изящно. Заключение Функции упорядоченного набора это агрегирующие функции, результат вычисления которых зависит от упорядочения. В стандарте определено не- сколько специализированных функций, но принцип является общим и мо- жет применяться ко всем видам вычислений агрегатов. Я привел несколько примеров, выходящих за пределы поддерживаемого стандарта, — это функ- ции смещения и конкатенация строк. SQL Server 2012 не поддерживает функции упорядоченного набора данных, но я привел альтернативные ме- тоды для получения аналогичной функциональности. Я очень надеюсь, что в будущем мы увидим в SQL Server поддержку таких функций — возможно, они будут реализовывать стандартное предложение WITHIN GROUP и бу- дут доступны через пользовательские CLR-функции агрегирования, учиты- вающие упорядочение.
Глава 4 Оптимизация оконных функций Эта глава посвящена оптимизации оконных функций в Microsoft SQL Server 2012. Здесь предполагается, что вы знакомы с анализом графических планов выполнения запросов и базовыми итераторами, такими как Index Scan, Index Seek, Sort, Nested Loops, Parallelism, Compute Scalar, Filter, Stream Aggregate и другими. В начале главы приводятся данные, которые будут использоваться в при- мерах кода. Далее приводятся рекомендации по индексированию для под- держки работы самых разных оконных функций. После этого я расскажу об оптимизации ранжирующих оконных функций, а затем об усовершенство- вании параллельного выполнения оконных функций в целом. После этого речь пойдет об оптимизации функций агрегирования и смещения, сначала «без», а затем «с» параметрами упорядочения окон и кадров. Вы узнаете о новом операторе Window Spool и о том, как он работает. В последней части главы рассказывается об оптимизации аналитических функций. | W| Примечание Я хотел бы поблагодарить Марка Фридмана (Marc Friedman), Ума- чандара Джайячандрана (Umachandar Jayachandran), Тобиаса Тернстрома (Tobias Ternstrom) и Милана Стоика (Milan Stojic) из команды разработчиков SQL Server за то, что помогли мне разобраться с оптимизацией оконных функций. Огромное спасибо! Тестовые данные В большинстве примеров в этой главе используются таблицы Accounts и Transactions, которые содержат информацию о банковских счетах и транзак- циях на этих счетах. При пополнении счета сумма транзакции положитель- ная, а при снятии средств со счета — отрицательная. Выполните следующий код, чтобы создать таблицы Accounts и Transactions в тестовой базе данных TSQL2012: SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID('dbo.Transactions', ’U’) IS NOT NULL DROP TABLE dbo. Transactions; 5 3ak.6O1
114 Глава 4 Оптимизация оконных функций IF OBJECT_ID(’dbo.Accounts', 'U') IS NOT NULL DROP TABLE dbo.Accounts; CREATE TABLE dbo.Accounts ( actid INT NOT NULL, actname VARCHAR(50) NOT NULL, CONSTRAINT PK.Accounts PRIMARY KEY(actid) ); CREATE TABLE dbo.Transactions ( actid INT NOT NULL, tranid INT NOT NULL, val MONEY NOT NULL, CONSTRAINT PK_Transactions PRIMARY KEY(actid, tranid), CONSTRAINT FK_Transactions_Accounts FOREIGN KEY(actid) REFERENCES dbo.Accounts(actid) ); В примерах и измерениях производительности в этой главе предполага- ется, что таблицы наполнены большим объемом данных. Но если нужен не- большой набор данных, просто для тестирования логики решений, можно заполнить таблицы, выполнив следующий код: INSERT INTO dbo.Accounts(actid, actname) VALUES (1, ’account Г), (2, ’account 2’), (3, ’account 3’); INSERT INTO dbo.Transactions(actid, tranid, val) VALUES (1, 1, 4.00), (1, 2, -2.00), (1, 3, 5.00), (1, 4, 2.00), (1, 5, 1.00), (1, 6, 3.00), (1, 7, -4.00), (1, 8, -1.00), (1, 9, -2.00), (1, 10, -3.00), (2, 1, 2.00), (2, 2, 1.00), (2, 3, 5.00), (2, 4, 1.00), (2, 5, -5.00), (2, 6, 4.00), (2, 7, 2.00),
Оптимизация оконных функций Глава 4 115 (2, 8, -4.00), (2, 9, -5.00), (2, 10, 4.00), (3, 1, -3.00), (3, 2, 3.00), (3, 3, -2.00), (3, 4, 1.00), (3, 5. 4.00), (3, 6, -1.00), (3, 7, 5.00). (3, 8. 3.00), (3, 9, 5.00), (3, 10, -3.00): Что касается создания объемных тестовых данных, сначала выполни- те следующий код, чтобы создать вспомогательную функцию по имени GetNums (подробнее о ней я расскажу в главе 5), которая генерирует после- довательность целых чисел из заданного диапазона: IF OBJECT_ID('dbo.GetNums', ’IF’) IS NOT NULL DROP FUNCTION dbo.GetNums: GO CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE AS RETURN WITH LO AS (SELECT c FROM (VALUES(1),(1)) AS D(c)), L1 AS (SELECT 1 AS c FROM LO AS A CROSS JOIN LO AS B), L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B), L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B), L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B), L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B), Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM L5) SELECT @low + rownum - 1 AS n FROM Nums ORDER BY rownum OFFSET 0 ROWS FETCH FIRST @high - @low + 1 ROWS ONLY; GO А затем следующим кодом создайте в таблице Accounts 100 счетов, а в та- блице Transactions 20 тыс. транзакций на каждом счете — всего 2 млн транз- акций: DECLARE @num_partitions AS INT = 100, @rows_per_partition AS INT = 20000: TRUNCATE TABLE dbo.Transactions:
116 Глава 4 Оптимизация оконных функций DELETE FROM dbo.Accounts; INSERT INTO dbo.Accounts WITH (TABLOCK) (actid, actname) SELECT n AS actid, 'account ’ + CAST(n AS VARCHAR(10)) AS actname FROM dbo.GetNums(1, @num_partitions) AS P; INSERT INTO dbo.Transactions WITH (TABLOCK) (actid, tranid, val) SELECT NP.n, RPP.n, (ABS(CHECKSUM(NEWID())%2)*2-1) * (1 + ABS(CHECKSUM(NEWID())%5)) FROM dbo.GetNums(1, @num_partitions) AS NP CROSS JOIN dbo.GetNums(1, @rows_per_partition) AS RPP; Вы можете поменять число секций (счетов) и строк на секцию (транзак- ций на счет), но учтите: именно такие цифры я использую в качестве вход- ных данных в своих тестах. Рекомендации по индексированию Итераторы планов, которые вычисляют результат оконной функции, будут описаны чуть позже в этой главе. А сейчас достаточно сказать, что им нужно, чтобы входные строки хранились с разбиением по столбцам секционирования (если имеется предложение секционирования), а затем по столбцам упорядо- чения (при условии, что есть предложение упорядочения окна). Если нет ин- декса, содержащего информацию в нужном порядке, потребуется выполнить сортировку, прежде чем оконные итераторы смогут приступить к работе. РОС-индекс Общие рекомендации по поддержке оконных функций укладываются в кон- цепцию, которую я называю РОС — по начальным буквам слов partitioning (секционирование), ordering (упорядочение) и covering (покрытие). Иногда ее называют РОСо. Ключами РОС-индекса должны быть столбцы оконных секций, за которыми следуют столбцы упорядочения, в конце индекс дол- жен включать остальные столбцы, которые используются в запросах. Это включение можно выполнить с помощью предложения явного включения INCLUDE в некластеризованном индексе или средствами кластеризации самого индекса — в последнем случае в конечные строки нужно включить все столбцы дерева. В отсутствие РОС-индекса в плане появляется итератор Sort, который может создавать серьезную нагрузку, если объем входных данных велик. Сложность сортировки пропорциональна произведению N * LOG(N), ко- торое растет быстрее линейной функции. Это означает, что с ростом числа строк каждая последующая строка стоит дороже, чем предыдущая. Возьмем для примера значения 1000 * LOG(IOOO) = 3000 и 10000 * LOG( 10000) = 40000. Это означает, что при увеличении числа строк на порядок объем ра- боты увеличивается в 13 раз и ситуация ухудшается с увеличением числа строк. В качестве примера посмотрите на следующий запрос:
Оптимизация оконных функций Глава 4 117 SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса показан на рис. 4-1. Query 1: Query cost (relative to the bitch) : Ю0Я SELECT ictid, tranid, vil. ROW NUMBER() OVER (PARTITION BY ictid ORDER BY vil) AS rownum FROM dbo .Trinsictions; SELECT Cost: О Я Sequence Project (Compute Sellir) Cost: О Я Segment Cost: О Я Pirillelism (Gither Streims) Cost: 12 Я Cost: 82 Я Clustered Index Scin (Clustered) [Trinsictions]. [PK_Trinsictions] Рис. 4-1. План с итератором Sort В данный момент никакого РОС-индекса нет. Просмотр кластеризованного индекса выполняется без требования упорядочения (свойство Ordered рав- но False), после чего для сортировки данных используется дорогой итератор Sort. На моей системе при наличии «горячего» кеша на выполнение этого запроса ушло четыре секунды, при этом результаты отбрасывались. (Чтобы отбросить результаты, в контекстном меню Query Options выберите Grid в разделе Results и установите флажок Discard Results After Execution.) Затем создайте РОС-индекс таким кодом: CREATE INDEX idx_actid_val_i_tranid ON dbo.Transactions(actid /* P */, val /* 0 */) INCLUDE(tranid /* C */); Как видите, первая часть содержит столбцы оконных секций (в нашем случае actid), за которыми следуют столбцы упорядочения окна (в нашем случае val), а затем указаны остальные столбцы, используемые в запросе (в данном случае tranid). Повторно выполните следующий запрос SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса показан на рис. 4-2. Query 1: Query cost (relitive to the bitch): 100Я SELECT ictid, tranid, vil. ROWJUMBERQ OVER (PAR TITION BY ictid ORDER BY vil) AS rownum FROM dbo Trinsictions; SELECT Cost: О Я Sequence Project (Compute Sellir) ---~1 Index Scin (NonClustered) [Trinsictions] . [idx_ictid_vil_i_trinid] Cost: 97 Я Рис. 4-2. План без итератора Sort Итератор Sort исчез. План выполняет упорядоченный просмотр РОС- индекса, решая задачу упорядочения для итераторов, которые вычисляют результат оконной функции. На этот раз запрос выполнялся две секунды, несмотря на то, что использовался последовательный план, что отличает- ся от предыдущего параллельного плана с сортировкой. В больших наборах разница больше.
118 Глава 4 Оптимизация оконных функций Если в запросе присутствуют фильтры равенства, например WHERE coll = 5 AND со12 = ’АВС’, все потребности в фильтрации можно удовлетворить с помощью того же индекса, разместив фильтруемые столбцы первыми в спи- ске ключей индекса. Это можно назвать FPOC-индексом, где FPO соответ- ствует списку ключей, а С — списку включения. Если в запросе много оконных функций, то если у них одинаковое опре- деление окна, они могут использовать одни и те же упорядоченные данные без необходимости каждый раз добавлять итератор Sort. Заметьте также, что при указании множественных оконных функций с разным упорядочением окон (и, возможно, упорядочением представления), порядок их указания в списке SELECT может влиять на число сортировок, выполняемых в плане. Обратный просмотр Страницы на каждом уровне индекса, включая конечные, объединены дву- направленным связанным списком, поэтому с технической точки зрения индекс может просматриваться по порядку вперед или по порядку назад. Когда строки нужны в порядке, обратно предусмотренному индексом, часто в оптимизаторе есть логика для выполнения упорядоченного обратного про- смотра. Но есть серьезные особенности обратного просмотра и использова- ния его для вычисления оконных функций, о которых надо знать и которые могут влиять на решения при работе с этими функциями. Первая особенность заключается в том, что в прямых просмотрах для по- вышения эффективности может использоваться параллелизм, а в обратных — нет. В настоящий момент параллельные обратные просмотры просто не реализованы в ядре базы данных. Чтобы увидеть применение параллелизма в прямых просмотрах, выполните следующий запрос и посмотрите план вы- полнения: SELECT actid, tranid, val, ROW_NUMBER() OVER(ORDER BY actid, val) AS rownum FROM dbo.Transactions WHERE tranid < 1000; На рис. 4-3 показан план запроса, где видно использование параллелизма. Querj 1: Query cost (relitive to the bitch). Ю0М SELECT ictid. tnnid. *il. ROWJWMBERO OVER (ORDER BY ictid. vil) AS rownuei FROM dbo.Transactions WHERE tnnid < 1000; SELECT Cost: 0 * Pi rill elism (Cither St reins) Cost: 16 « Index Scin (Nonclustered) [Tnnsictions] . [idx_ictid_vel_i_trinid] Cost: 84 К Рис. 4-3. Параллельный план Далее выполните следующий запрос, где направление столбцов упорядо- чения окна изменено на обратное: SELECT actid, tranid, val, ROW_NUMBER() OVER(ORDER BY actid DESC, val DESC) AS rownum
Оптимизация оконных функций Глава 4 119 FROM dbo.Transactions WHERE tranid < 1000; План этого запроса показан на рис. 4-4. Query 1: Query cost (relitive to the bitch): 100% SE L E СT ictid. trinid, vil, R0WJUMBERO 0VER(0RDER BY ictid DESC, vil DESC) AS rownum FROM dbo Trinsictions WHERE t SELECT Cost: 0 % N Sequence Project (Compute Sell ar) Cost: 11 % Segment Cost: 3 % Index Scan (NonClustered) [Transactions] . [idx_actid_val_i_tranid] Cost: 87 % Рис. 4-4. Последовательный план Оптимизатор решил не использовать упорядоченный просмотр исполь- зованного ранее индекса в обратном порядке, поэтому план стал последова- тельным. Вы, наверное, заметили, что в последних двух запросах есть только предло- жения упорядочения окна, но нет предложения секционирования. Тем не ме- нее, созданный ранее индекс удовлетворяет рекомендациям по РОС, но часть, обозначенная буквой «Р» здесь просто не работает. Это случайность, что я не включил в примеры предложение секционирования окна. И здесь мы сталки- ваемся со вторым интересным аспектом оптимизации оконных функций. Оказывается, что если в функции есть предложение секционирования окна, чтобы выполнялся упорядоченный просмотр индекса и не выполня- лась сортировка, значения секционирования должны читаться в порядке по возрастанию, несмотря на то, что на это нет никакой логической причины. Есть исключение из этого правила, но я вернусь к этому позже. Посмотрите на следующий запрос, который мы уже использовали в пред- ыдущем примере: SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса приведен на рис. 4-2, где видно, что РОС-индекс чи- тается по порядку, а сортировка отсутствует. Попытаемся выполнить похожий запрос, но на этот раз порядок столбцов упорядочения обратный: SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val DESC) AS rownum FROM dbo.Transactions; План этого запроса приведен на рис. 4-5, и в нем есть итератор Sort. Query 1: Query cost (relitive to the bitch): 100% SELECT ictid. trinid. vil. ROWJUMBERQ OVER(PARTITION BY ictid ORDER BY vil DESC) AS rownum FROM dbo.Trinsictions; SELECT Cost: 0 % Sequence Project (Compute Scilir) Cost: 0 % Segment Cost: 0 % (Cither Streims) Cost: 18 % [Trinsictions] . [idx_ictid_vil_i_trinid] Index Scin (NonClustered) Рис. 4-5. План с итератором Sort при упорядочении по убыванию
120 Глава 4 Оптимизация оконных функций Индекс из предыдущего примера используется и здесь, потому что он по- крывает запрос, но упорядочение здесь не используется. В этом можно убе- диться, посмотрев на свойство Ordered итератора Index Scan — его значение равно False, а в предыдущем случае значение Ordered было равно True. Это недостаток оптимизации. Порядок, в котором просматриваются конкретные столбцы секционирования, не должен иметь значения. А важно здесь то, что значения в каждой секции должны сканироваться точно в порядке, задан- ном в предложении упорядочения окна. Поэтому чтение индекса в обратном порядке должно предоставить значения для оконной функции в правильном порядке. Но, к сожалению, оптимизатор этого не понимает. Есть два индекса, которые могут избавить от необходимости сортировки: один содержит список ключей {actid, val DESC), а другой точные указания по обратному просмотру {actid DESC, val), причем у обоих одинаковый спи- сок включения {tranid). В первом случае будет использоваться упорядочен- ный прямой, а во втором — упорядоченный обратный просмотр. Но намного интереснее то, что происходит, если добавить предложение представления, которое требует упорядочить строки по столбцу упорядоче- ния в порядке убывания (спасибо за подсказку Бреду Шульцу (Brad Schulz). Вдруг итераторы, вычисляющие оконную функцию, хотят получать секцио- нированные значения в порядке убывания и для этого используют упоря- дочение индекса. Поэтому простое добавление предложения представления ORDER BY с tranid DESC в наш последний запрос устраняет необходимость в итераторе Sort. Вот скорректированный запрос: SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val DESC) AS rownum FROM dbo.Transactions ORDER BY actid DESC; План этого запроса показан на рис. 4-6. Query 1: Query cost (relitive to the bitch): 100% SELECT ictid, tranid, vil, ROWJUMBERQ OVER(PARTITION BY ictid ORDER BY vil DESC) AS rownum FROM dbo .Trinsictions 0 SELECT Cost: 0 % WraraSequenc. Pro)ect (Compute Sellir) Cost: 2 % Index Scin (NonClustered) [Trinsictions] . [idx_ictid_vil_i_trinid] Cost: 97 % Рис. 4-6. План без итератора Sort при упорядочении по убыванию Заметьте, что итератор Sort исчез. План выполняет упорядоченный об- ратный просмотр индекса. Как вы помните, в отличие от прямого просмо- тра, обратный просмотр не поддается распараллеливанию. Тем не менее, это замечательно узнать, что добавление в запрос предложения представления ORDER BY позволяет повысить производительность! Индексы columnstore Индексы columnstore впервые появились в SQL Server 2012. Они группи- руют и хранят данные по столбцам (в отличие от строк, которые использу-
Оптимизация оконных функций Глава 4 121 ются в обычных индексах) В них удается добиться высокого уровня сжатия за счет использования технологии, которая называется VertiPaq. В запросах определенного типа, особенно в хранилищах данных, индексы columnstore позволяют значительно повысить производительность по сравнению с обычными индексами. Повышение производительности достигается за счет сжатия (сокращение ввода/вывода) и новой пакетной обработки данных, которой нет в традиционных вычислениях с использованием строк. Индексы columnstore позволяют повысить эффективность запросов, в которых используется фильтрация, группировка и соединения типа «звез- да». Однако индексы columnstore не обеспечивают ускорение вычисления оконных функций. Некторые запросы с оконными функциями могут рабо- тать быстрее (например, из-за обусловленного сжатием сокращения числа операций ввода/вывода), однако вычисление итераторов, которые исполь- зуются в оконных функциях, все равно выполняется построчно. Иначе гово- ря, чтобы добиться высокой производительности оконных функций, лучше сосредоточиться на создании традиционных РОС-индексов, которые позво- лят избежать сортировки данных. Функции ранжирования Этот раздел посвящен оптимизации функций ранжирования: ROW_ NUMBER, NTILE, RANK и DENSE RANK. Итераторы, вычисляющие функции ранжирования, работают со строками, по одной секции за раз и в порядке, заданном в предложении упорядочения окна. Поэтому если нужно избежать сортировки, надо соблюдать описанные ранее рекомендации РОС. В своих примерах я предполагаю, что созданный в предыдущем разделе ин- декс idx_actid_val_i_tranid существует. Если у вас его нет, создайте этот ин- декс, чтобы получать результаты, максимально близкие к моим. Два итератора, которые помогают вычислять функции ранжирования — Segment и Sequence Project. Segment служит для пересылки одного сегмента строк за раз в следующий итератор. В нем есть свойство Group By, которое определяет список выражений, по которым надо сегментировать. Результатом его работы в каждой строке является флаг, который называется Seg- znenriV (где N представляет определенное число в выражении, например Seg- ment1004} и указывает, является ли строка первой в сегменте. Итератор Sequence Project отвечает собственно за вычисление функции ранжирования. Оценивая флаги, созданные предыдущими итераторами Segment, он сбрасывает, сохраняет или увеличивает значение ранга, получен- ного на основе предыдущей строки. Результатом работы итератора Sequence Project со значением ранжирования является значение по имени ExpressionN(и здесь W представляет определенное число выражения, например ЕхргЮОЗ). Функция ROW-NUMBER Для описания оптимизации функции ROW NUMBER я воспользуюсь сле- дующим запросом:
122 Глава 4 Оптимизация оконных функций SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса показан на рис. 4-7. Query 1: Query cost (relitive to the bitch): Ю0Я SELECT ictid. trinid. vil. ROWJUMBERQ OVER(PARTITION BY ictid ORDER BY vil) AS rownum FROM dbo .Trinsictions; SELECT Cost: О Я Sequence Project (Compute Scilir) Cost: 2 Я Segment Cost: 1 Я Index Scin (NonClustered) [Trinsictions] . [idx_ictid_vil_i_trinid] Cost: 97 Я Рис. 4-7. План запроса с функцией ROW_NUMBER Так как есть РОС-индекс, просмотр осуществляется упорядоченно. Как вы помните, в отсутствие такого индекса в плане появляется дорогой ите- ратор Sort. Далее итератор Segment формирует группы строк, основываясь на столбце секционирования actid, и создает флаг (SegmentN), указывающий начало новой секции. Там, где SegmentN указывает на начало новой секции, итератор Sequence Project генерирует номер строки (и называет это ExprN), в других местах он увеличивает предыдущее значение на единицу. У функций ранжирования есть интересная особенность упорядоче- ния окна, которая может в некоторых случаях становиться препятствием. Предложение упорядочения окна в функциях ранжирования является обя- зательным и не может быть основано на константе. Обычно это не проблема, потому что обычно ранжирование создается на основе определенных требо- ваний по упорядочению, которые соответствуют некоторым атрибутами и выражениям таблицы. Однако иногда просто нужно создавать уникальные значения без определенного порядка. Вы можете сказать, что если упорядо- чение не имеет значения, можно задать произвольный атрибут для упоря- дочения. Но тогда надо помнить, что если РОС-индекса нет, план будет со- держать итератор Sort, или будет вынужден просматривать упорядоченный индекс, если он существует. Нужно разрешить чтение данных, которое не обязательно должно выполняться в порядке, заданном в индексе, что, воз- можно, позволит повысить производительность, а также хочется избежать сортировки. Как уже говорилось, предложение упорядочения обязательно и SQL Server не разрешит упорядочение по константе, например такое как ORDER BY NULL. Но удивительно то, что если передать выражение с вложенным за- просом, возвращающим константу, например ORDER BY (SELECT NULL), SQL Server его примет. Но раскрыв выражение, оптимизатор понимает, что упорядочение одинаково для всех строк, и удаляет требование по упорядо- чению из входных данных. Вот запрос, демонстрирующий этот метод. SELECT actid, tranid, val, ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM dbo.Transactions;
Оптимизация оконных функций Глава 4 123 План этого запроса показан на рис. 4-8. Query 1: Query cost (relitive to the bitch): Ю0Я SELECT ictid. tranid. vil д ROWJUMBERQ OVER(ORDER BY (SELECT HULL)) AS rowium FROM dbo.Translations; SELECT Cost: О Я Sequence Project (Compute Sellir) Cost: г я Segment Cost: 1 Я Index Scin (NonClustered) [Translations] . [idx_ictid_vil_i_trinid] Cost: 97 Я Index Scan (NonClustered) Scan a nonclustered index, entirely or only a range. Ordered False Object [TSQL2012].[dbo].[Transactions]. [ i dx_a cti d_va I _i_tra n i d ] Рис. 4-8. План запроса с функцией ROW_NUMBER с произвольным упорядочением Посмотрите свойства итератора Index Scan и убедитесь, что значение свойства Ordered равно False, то есть от итератора не требуется возвращать данные в порядке ключей индекса. Функция NTILE Напомню обсуждение NTILE в главе 2: принцип работы этой функции осно- ван на двух элементах — номер строки и число строк в секции. Если они известны, можно применять формулу для вычисления номера подгруппы. Из предыдущего раздела вы уже знаете, как вычисляется и оптимизируется номер строки. Сложность здесь заключается в вычислении числа строк в со- ответствующей секции. Я сказал «сложность», потому что для этого недо- статочно одного прохода данных. Причина в том, что число строк в секции нужно знать для каждой строки, а это число нельзя узнать, пока не просмо- трены все строки секции. Чтобы посмотреть, как оптимизатор решает эту задачу, посмотрим на следующий запрос: SELECT actid, tranid, val, NTILE(IOO) OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса показан на рис. 4-9. Qu.rj 1: Qu.Г, colt (r.litiv. to th. bitch) : MOX SELECT ictId. tnnid. <11. UTILE (100) OVERCPAPTITJDH BY ictid ORDER BY vil) AS rownum FROM dbo.Trinxictionx; SELECT Cost: О Я Tibi. Spool (Lizjr Spool) Coxt: г Я «.M e w Saownt P*r*l1.l1xm Ind.x Scin (NonCluxt.r.d) r *? n „ (R.pxrtltion Str.imx) CTnnxictlonx] . [1dx_ictid_¥il_i_trin1d] ’ Cost: И Я Cost. U Я k.it.d Loopx (Ihn.r loin) Coxt: О Я (Luj Spool) Coxt: О Я (Liz; Spool) Coxt: О Я Рис. 4-9. План функции NTILE
124 Глава 4 Оптимизация оконных функций Оптимизатор решает задачу следующим образом: Читает строки из РОС-индекса, если он есть. (В нашем случае он суще- ствует.) Сегментирует строки по элементу секционирования (в нашем случае actid). Сохраняет строки секции по одной за раз в рабочей таблице (в плане представлено верхним итератором Table Spool). Дважды читает временную таблицу (итераторы Table Spool в нижней ча- сти плана) — один раз для вычисления числа строк итератором Stream Aggregate и второй для получения подробной информации о строках. Соединяет агрегат и строку с детальной информацией, чтобы получить число строк и подробные сведения в одной целевой строке. Снова сегментирует строки по элементу секционирования (в нашем слу- чае actid). Использует итератор Sequence Project для вычисления номера подгруппы. Заметьте, что итератор Table Spool представляет рабочую таблицу в temp- db. Хотя его доля в плане кажется небольшой, он довольно ресурсоемкий. Чтобы вы поняли насколько, скажу, что запрос с функцией ROW_NUMBER выполняется на моей машине две секунды, а с функцией NTILE — 45 секунд. Далее в этой главе при обсуждении функций агрегирования без упорядоче- ния и кадрирования я расскажу, как избежать такого ресурсоемкого созда- ния промежуточных таблиц. Функции RANK и DENSE.RANK Функции RANK и DENSE_RANK выполняют вычисления очень похоже на ROW NUMBER за тем исключением, что они чувствительны к связям в значениях, используемых для упорядочения. Как вы помните, RANK воз- вращает число, на единицу большее, чем число строк с меньшим значением значения, по которому выполняется упорядочение, a DENSE_RANK вы- числяется как единица плюс число уникальных строк со значением упоря- дочения меньшим, чем текущее значение. Поэтому помимо флага сегмен- тирования, который обозначает начало новой секции, оператору Sequence Project также нужно знать, не изменилось ли значение упорядочения. В приведенном ранее плане для функции ROW_NUMBER был один итера- тор Segment, сгруппированный по элементу секционирования. Планы для RANK и DENSE_RANK похожи, но им нужен второй итератор Segment с группировкой по элементам и секционирования, и упорядочения. Вот пример, вызывающий функцию RANK: SELECT actid, tranid, val, RANKO OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса показан на рис. 4-10.
Оптимизация оконных функций Глава 4 125 Query 1: Query cost (relitive to the bitch) : Ю0Я SELECT ictid. trinid, vil. RANKO OVERCPARTITIDN BY ictid ORDER BY vil) AS rownum FROM dbo.Tnnsictlons; IM Sequence Project (Compute Scilir) Cost: 2 К Segment Cost: 1 К Segment Cost: 1 К Index Scin (NonClustered) [Trinsictions] . [idx_ictid_vil_i_tnnid] Cost: 97 К Рис. 4-10. План для функции RANK Первый итератор Segment группируется по actid, возвращая флаг Seg- ment 1004, второй тоже группируется по actid, val и возвращает флаг Seg- ment1005. Если Segment1004 указывает, что строка является первой в секции, Sequence Project возвращает единицу В противном случае Segment1005 ука- зывает, что значение упорядочения изменилось, a Sequence Project возвраща- ет соответствующий номер строки. Если значение упорядочения не измени- лось, Sequence Project возвращает значение, равное предыдущему рангу. Функция DENSE RANK вычисляется похожим образом. Вот пример за- проса: SELECT actid, tranid, val, DENSE_RANK() OVER(PARTITION BY actid ORDER BY val) AS rownum FROM dbo.Transactions; План этого запроса показан на рис. 4-11. Query 1: Query cost (relitive to the bitch): 100Я SELECT ictid. trinid, vil. DENSE RANK() OVER(PARTITION BY ictid ORDER BY vil) AS rownum FROM dbo Trinsictions; SELECT Cost: 0 К (Compute Scilir) Cost: 2 « Рис. 4-11. План для функции DENSE_RANK Segment Cost: 1 К Index Scin (NonClustered) [Trinsictions] . [idx_ictid_vil_i_tnnid] Cost: 97 Я Основное отличие состоит в том, что вычисляет итератор Sequence Pro- ject. Если Segment1005 указывает, что значение упорядочения изменилось, Sequence Project добавляет единицу к предыдущему «плотному» рангу. Из-за того, что планы для RANK и DENSE_RANK так похожи на план для ROW_NUMBER, производительность ведет себя аналогичным образом. На моем компьютере каждый из трех запросов выполняется две секунды. Улучшение параллелизма за счет использования APPLY В этом разделе описана методика, которую я перенял у Адама Маканика (Adam Machanic), технического редактора этой книги, и которая позволяет улучшить, иногда значительно, параллельные операции при оптимизации запросов с оконными функциями. Прежде чем приступить к описанию методики, замечу, что я выполнял приведенные в этой книге примеры в системе с восемью логическими про- цессорами. При выборе плана — параллельного или последовательного — среди других вещей SQL Server учитывает и число процессоров. Поэтому если в вашей системе меньше восьми логических процессоров, вы можете не получить параллельные планы, как у меня.
126 Глава 4 Оптимизация оконных функций [~Т?| Примечание Если для целей тестирования вы хотите имитировать среду с разными числом процессоров, есть несколько способов решения этой задачи. Один из вари- антов — воспользоваться параметром запуска -Рп, где п — число планировщиков, которые надо запустить при старте SQL Server. Допустим, у вас на машине четыре ло- гических процессора и вы запустили службу SQL Server с параметром -Р8. SQL Server запустится с восемью планировщиками, а оптимизатор будет делать планы на основе этого числа, как если бы он работал в среде с восемью логическими процессорами. Уровень параллелизма в параллельных планах обычно будет равен восьми. О втором способе я узнал у Эладио Ринкона (Eladio Rincon). Можно использовать не- документированную команду DBCC, которая называется DBCC OPTIMIZER_WHATIF. В качестве первого аргумента я указал 1, а в качестве второго задал число процессоров, которое оптимизатор будет использовать при создании планов. Например, команда DBCC OPTIMIZER_WHATIF(1,8) заставляет оптимизатор при создании плана считать, что в системе восемь процессов. Имейте в виду, что эта команда не меняет число пла- нировщиков, запускаемых вместе с SQL Server, поэтому уровень параллелизма не по- меняется и останется равным реальному числу планировщиков. Но оптимизатор будет создавать план так, как будто в на машине восемь процессоров. Может потребоваться также добавить OPTION(RECOMPILE), чтобы заставить SQL Server после выполнения этой команды создать новый план. Допустим, для какого-то запроса SQL Server обычно генерирует последовательный план при наличии четырех процессоров и параллельный — при наличиии восьми про- цессоров. На данный момент на вашей машине четыре процессора. При использова- нии параметра запуска -Р8 сервер SQL Server создаст параллельный план с уровнем параллелизма 8. Если задать команду DBCC OPTIMIZER_WHATIF(1, 8), SQL Server сгенерирует параллельный план с уровнем параллелизма 4. Параметр запуска оказы- вает влияние на весь экземпляр, а команда DBCC — только на текущий сеанс. В лю- бом случае помните, что эти параметры не задокументированы официально и могут использоваться только для тестовых целей. Вернемся к параллельному методу с использованием APPLY; он обычно полезен, если используется предложение секционирования окна, а встро- енный параллелизм не дает оптимального результата или попросту не ис- пользуется. Хороший пример, когда встроенные параллельные вычисления оконной функции не всегда оптимальны, тот, в котором используются ите- раторы Sort. В качестве примера посмотрите на такой запрос: SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val) AS rownumasc, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY val DESC) AS rownumdesc FROM dbo.Transactions; На моем компьютере выполнение этого кода заняло семь секунд. План этого запроса показан на рис. 4-12. tjuarj 1. Quarj cost (ralativa to th* batch) U>0* SELECT actid. tranid. vil, POWJiUHBERQ OVER (РАЯТТП0Н BY actid ORPER BY val) AS rownumsc. ROW MUMBER() OVERCPARTITION BY actid ORDER BY val DESC) AS rowrwatdasc FRtH dbo Trins Рис. 4-12. План без использования APPLY
Оптимизация оконных функций Глава 4 *127 Так как две функции ROW_NUMBER вызываются с разными определе- ниями окон, они не могут использовать РОС-индексы, даже если бы те су- ществовали. Только одна функция может использовать РОС-индекс, а дру- гой для упорядочения данных придется довольствоваться итератором Sort. Так как здесь используется сортировка, а число строк довольно большое, оптимизатор решил использовать параллельный план. В параллельных планах выполнения оконных функций приходится сек- ционировать строки по тем же элементам, что и элементы секционирования окна, если итераторы Segment и Sequence Project находятся в параллель- ной зоне. Если посмотреть на свойства итератора Parallelism (Redistribute Streams), мы увидим, что в нем используется секционирование по хешу и строки разбиваются по actid. Этот итератор перераспределяет строки ис- ходных потоков, используемых для параллельного чтения данных для це- левых потоков, которые собственно вычисляют результат первой оконной функции. Затем строки сортируются в соответствии с определением упоря- дочения во второй оконной функции. Итератор Parallelism (Gather Streams) отвечает за сбор потоков. Наконец, вычисляется результат второй оконной функции. В таком плане есть несколько узких мест. Повторное секционирование потоков Перенос данных между потока- ми является дорогой операцией. В данном случае было бы даже лучше, если бы ядро СУБД использовала последовательный просмотр и только потом напрямую распределяла бы потоки. Сортировка Сейчас количество строк, обрабатываемых потоком опре- деляется уровнем параллелизма. Например, в запросе с уровнем паралле- лизма 8 каждый поток обрабатывает примерно 250 тыс. строк. С другой стороны, если поток будет обрабатывать только строки, относящиеся к одному счету, то речь будет идти о 20 тыс. строк на одну сортировку. (Как вы помните, у нас 100 счетов, на каждом из которых 20 тыс. транзакций.) Это делает существующие сортировки примерно на 20% менее эффектив- ными, чем они могли бы быть: (((20000 * log(20000)) * 100) / ((250000 * log(250000)) * 8)). Вторые итераторы Segment и Sequence Project Они находятся в по- следовательной зоне. Хотя это не самые дорогие итераторы, они не бес- платны, а закон Амдала никто не отменял. (Этот закон говорит, что об- щая производительность параллельного алгоритма ограничивается по- следовательными участками.) От всех этих узких мест избавляет использование параллельного метода с использованием APPLY, который реализуется следующим образом: 1. Запрос таблицы, в которой хранятся конкретные значения секциониро- вания (в данном случае это Accounts). 2. Использование оператора APPLY для применения к каждому из остав- шихся строк логики исходного запроса (в нашем случае по отношению к
128 Глава 4 Оптимизация оконных функций Transactions) с фильтрацией по уникальным значениям, по которым вы- полняется секционирование. В качестве примера перепишем предыдущий запрос (листинг 4-1). Листинг 4-1. Метод с использованием APPLY SELECT С.actid, А.* FROM dbo.Accounts AS C CROSS APPLY (SELECT tranid, val, ROW_NUMBER() OVER(ORDER BY val) AS rownumasc, ROW_NUMBER() OVER(ORDER BY val DESC) AS rownumdesc FROM dbo.Transactions AS T WHERE T.actid = C.actid) AS A; Заметьте, что из-за того, что производная таблица А содержит строки только одной секции, в определении окна отсутствует предложение секцио- нирования. На моей машине этот запрос выполняется три секунды — менее чем за половину времени выполнения предыдущего запроса. План нового запроса показан на рис. 4-13. Qu*rj 1- Qu* г, cost (r«1itiv« to th* bitch) WO* SELECT C ictid. A.* FROM dbo Account» AS C CROSS APPLY (SELECT trinid. vil. BOW-HUHBEttO OVERCPARTITIO* BY ictid ORDER BY vil) AS rownum tc, R0WJUM8ERQ OVEPCPARTITIOM BY icti. Рис. 4-13. План с использованием APPLY План начинается с чтения кластеризованного индекса таблицы Ac- counts. Затем итератор обмена Parallelism (Distribute Streams) использу- ются для распределения строки на несколько потоков, используя для сек- ционирования механизм циклического обслуживания (следующий пакет в следующий поток). Поэтому каждому потоку в нижней части итератора соединения Nested Loops приходится работать только на подмножестве строк одной секции, но без описанных ранее узких мест. При этом при- ходится жертвовать числом операций поиска в индексе (и соответству- ющих операций логического чтения), которые необходимы для выпол- нения запроса. Если плотность столбцов секционирования очень низка (например, 200 тыс. секций по 10 строк в каждой), возникает большое число операций поиска и эффективность использования APPLY больше недостаточна. В других примерах в этой главе я использую метод с APPLY и рекомен- дую применять его, если не удается добиться оптимальных результатов от встроенных механизмов параллельной обработки оконных функций.
Оптимизация оконных функций Глава 4 129 Функции агрегирования и смещения Оптимизация этих функций значительно отличается от того, применимо ли упорядочение и кадрирование. Поэтому я расскажу о двух случаях от- дельно, а начну с функций агрегирования без параметров упорядочения и кадрирования. Без упорядочения и кадрирования В функциях агрегирования, где отсутствуют упорядочение и кадрирование, кадром является вся секция. Например, посмотрите на следующий запрос: SELECT actid, tranid, val, MAX(val) OVER(PARTITION BY actid) AS mx FROM dbo.Transactions; В каждой транзакции запрос требует вернуть элементы подробных дан- ных {actid, tranid и val), а также максимальное значение для текущего счета. Элементы как подробностей, так и агрегатов должны возвращаться в той же целевой строке. Как уже говорилось в разговоре о функции NTILE, в этом случае недостаточно одного просмотра данных. При чтении строк подроб- ностей вы не знаете результат агрегирования, пока не завершите чтение секции. Оптимизатор решает эту задачу путем размещения строк каждой секции в рабочей таблице в tempdb, а затем двумя операциями чтения вре- менных данных: первый раз для вычисления агрегата, а второй — для чтения строк подробностей. План этого запроса показан на рис. 4-14. Querjf 1: Query cost (relative to the bitch): Ю0К SELECT ictid. tranid, MAX(vil) OVER (PAP TITION BY ictid) AS пи FPO4 dbo Trinsictions; Рис. 4-14. План агрегирования окна при наличии только агрегирования План выполняет следующие операции: Читает строки РОС-индекса. Сегментирует строки по элементу секционирования {actid), В каждый проход сохраняет строки одной секции в рабочей таблице. (Этот шаг представлен в плане верхним итератором Table Spool.) Читает временную таблицу дважды (представлено двумя итераторами Table Spool внизу плана) — первый раз для вычисления агрегата МАХ с
130 Глава 4 Оптимизация оконных функций применением итератора Stream Aggregate, а второй для получения строк подробных данных. Соединяет строки агрегата и подробностей для размещения этих данных в одной строке в целевой таблице. При использовании временных данных они размещаются не в оптими- зированной рабочей таблице в памяти, а в БД tempdb на диске. Запись и чтение во временные таблицы очень ресурсоемко. На моем компьютере вы- полнение этого кода заняло десять секунд. Если нужно дополнительно фильтровать строки на основе полученных из оконной функции результатов, предложение WHERE в запросе исполь- зовать нельзя. Нужно определить табличное выражение на основе исходно- го запроса и только затем выполнять фильтрацию во внешнем запросе, на- пример так: WITH С AS ( SELECT actid, tranid, val, MAX(val) OVER(PARTITION BY actid) AS mx FROM dbo.Transactions ) SELECT actid, tranid, val FROM C WHERE val = mx; План этого запроса показан на рис. 4-15. Qu* г, 1: Que г, cost (r.lxfiv* to th* b»tch) 100» WITH C AS I SELECT xctid. tnnid. v>1. MAX(vil) OVER (PARTITION BY actid) AS mx FROM dbo Transactions ) SELECT actid. tranid. »1 FROM C WHERE val = mx. Рис. 4-15. План агрегирования окна при наличии только агрегирования и дополнительной фильтрации По сравнению с предыдущим планом в этом перед сбором потоков до- бавился итератор Filter. На моем компьютере выполнение этого кода заняло 12 секунд. Так как создание временных таблиц на диске очень ресурсоемкая опера- ция, добиться намного лучшей производительности можно, если задейство- вать групповые запросы для вычисления агрегата с дальнейшим соединени- ем с базовой таблицей, например так:
Оптимизация оконных функций Глава 4 131 WITH Aggs AS ( SELECT actid, MAX(val) AS mx FROM dbo.Transactions GROUP BY actid ) SELECT T.actid, T.tranid, T.val, A.mx FROM dbo.Transactions AS T JOIN Aggs AS A ON T.actid = A.actid; План этого запроса показан на рис. 4-16. Qucrj 1: Quirj cost (relitive to the bitch): 10 OK WITH Aggs AS ( SELECT ictid. MAX(vil) AS mx FROM dbo Trinsictions CROUP BY ictid ) SELECT T.ictid. T.trinid. T»1. A.mx FROM dbo Trinsictions AS T JOIN Aggs AS SELECT Cost. 0 « t; Hish Mitch (Inner Join) Cost: 25 К В. К В 14 Streim Aggregite Pirillelism Streim Aggregite '—“ Index Scin (NonClustered) (Aggregite) (Repirtition Streims) (Aggregite) [Trinsictions] . [idx_ictid_vil_i_tnnid] Cost: О К Cost: О К Cost 2 » Cost 30 « Pirillelism Index Scin (NonClustered) (Repirtition Streims) [Trinsictions] . [idx_ictid_vil_i_trinid] [T] Cost: 14 * Cost: JO * Рис. 4-16. План при использовании группового агрегата Заметьте, что индекс покрытия запроса читается дважды: один раз для вычисления агрегата, а второй — для получения подробных данных, а ре- зультаты соединяются итератором соединения Hash. Временных таблиц не создается, и запрос выполняется за две секунды. Далее создаем на основе полученного агрегата фильтр: WITH Aggs AS ( SELECT actid, MAX(val) AS mx FROM dbo.Transactions GROUP BY actid ) SELECT T.actid, T.tranid, T.val FROM dbo.Transactions AS T JOIN Aggs AS A ON T.actid = A.actid AND T.val = A.mx; План этого запроса показан на рис. 4-17. Qucrj 1: Quer^ cost (relitive to th* bitch): 100* WITH Aggs AS ( SELECT ictid. MAX(vil) AS mx FROM dbo Trinsictions GROUP BY ictid ) SELECT T.ictid. T.trinid. T.vil FROM dbo Trinsictions AS T JOIN Aggs AS A ON [Trinsictions] . [idx_ictid_vil_i_tnnid] [T] Cost: 15 » Рис. 4-17. План при использовании группового агрегата и фильтра
132 Глава 4 Оптимизация оконных функций Теперь для сопоставления строк подробных данных с каждым агрегатом группы счетов используется итератор соединения Nested Loops. Запрос вы- полняется менее чем за секунду. С упорядочением и кадрированием Оконные функции агрегирования и смещения с упорядочением и кадриро- ванием — новинка SQL Server 2012, поэтому для их оптимизации использу- ются новые и усовершенствованные итераторы, в частности новый замеча- тельный итератор Window Spool и усовершенствованный итератор Stream Aggregate. Мы обсудим три случая оптимизации с упорядочением и кадриро- ванием: с экстентом оконного кадра с нижней границей UNBOUNDED PRECEDING, с развертыванием всех строк кадра и с вычислением двух на- копительных значений. UNBOUNDED PRECEDING: быстрый путь При использовании экстента оконного кадра с UNBOUNDED PRECEDING в качестве нижней границы, оптимизатор применяет сильно оптимизиро- ванную стратегию. Я называю это быстрым путем. Но я расскажу об этом чуть позже. Сначала я поведаю о роли итераторов Window Spool и Stream Aggregate. Вообще говоря, эти два итератора реализованы как один итера- тор, но в плане представлены как два разных итератора. Задача итератора Window Spool — развернуть каждую исходную строку в соответствующие строки кадра — по крайней мере именно это происходит в худшем сценарии. Итератор генерирует атрибут, определяющий кадр окна и присваивает ему имя WindowCountN. Итератор Stream Aggregate группирует строки по WindowCountN и вычисляет агрегат. Теперь возникает проблема, где получить элементы подробностей строк после группировки данных. Для это- го текущая строка всегда добавляется в Window Spool, а в итераторе Stream Aggregate есть логика возвращения элементов подробностей из этой строки. Как я говорил, каждая исходная строка развертывается до соответствую- щих строк кадра только в случае худшего сценария, и расскажу об этом попо- зже. В этом разделе я хочу обсудить специальную оптимизацию в случаях, ког- да нижней границей оконного окна является UNBOUNDED PRECEDING. В этом случае вместо развертывания каждой строки до соответствующих строк кадра с последующей группировкой и агрегацией в двух указанных ите- раторах закодирована логика, предусматривающая только накопление значе- ний. Поэтому каждой исходной строке в итераторе Window Spool отвечает две строки — одна с уже накопленной информацией, а другая с текущей строкой. (Как вы помните это нужно для элементов подробностей.) В качестве примера посмотрите на следующий запрос: SELECT actid, tranid, >/al, SUM(val) OVER(PARTITION BY actid ORDER BY tranid
Оптимизация оконных функций Глава 4 133 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance FROM dbo.Transactions; План показан на рис. 4-18. Qu.r, 1. Qutrjr coxt (r.litiv. to th* bitch): LOOM SELECT ictid. tranid. vil. SUMfril) OVEPffAPTITIOH BY let id ORDER BY tnnid BOWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS bl line. FROM dbo Transactions; 2000000 4000000 2000000 Рис. 4-18. План с использованием параметра ROWS Числа под стрелками означают число строк. Строки считываются из РОС-индекса в соответствующем порядке. После этого итераторы Segment и Sequence Project вычисляют число строк (назовем это RowNumberN). Это число строк используется для фильтрации строк в правом кадре. Наш случай простой, но представьте себе более сложные ситуации (например, с ROWS BETWEEN 5 PRECEDING AND 2 FOLLOWING). После этого другой ите- ратор Segment сегментирует данные по actid для вычисления оконной функ- ции агрегирования. После этого итераторы Window Spool и Stream Aggregate продолжают накопление значений в каждом сегменте. Вспомните, что та- блица Transactions содержит два миллиона строк. Именно это число строк поступает в итератор Window Spool, а также из итератора Stream Aggregate. Как объяснялось ранее, итератор Window Spool генерирует две строки для каждой исходной строки в нашем специально оптимизированном случае с UNBOUNDED PRECEDING — одну для уже накопленного значения и вто- рую для текущей строки для получения элементов подробностей. Поэтому вы видите, что из итератора Window Spool в итератор Stream Aggregate по- ступает поток из четырех миллионов строк. Также, при соответствующих условиях (об этом я расскажу чуть позже) итератор Window Spool использует высоко оптимизированную рабочую та- блицу в памяти без обычной дополнительной нагрузки, которая присутству- ет при использовании рабочих таблиц в tempdb и состоит из операций ввода/ вывода, обычных и краткосрочных блокировок и т. п. В нашем запросе ис- пользовалась рабочая таблица в памяти, кроме того параметр UNBOUNDED PRECEDING не требовал разворачивания всех строк кадров. В совокуп- ности эти два метода оптимизации позволили выполнить запрос на моей машине всего за девять секунд, при этом было выполнено 6208 операций логического чтения. Это совсем неплохо в сравнении с любыми другими на- дежными методами вычисления нарастающих итогов. (Подробнее о нарас- тающих итогах см. главу 5.) Ряд обстоятельств не позволят итератору Window Spool использовать ра- бочую таблицу в памяти, что заставит работать с намного более «дорогой» рабочей таблице на диске со сбалансированным деревом, проиндексирован- ным по номерам строк. Подробно об этих обстоятельствах я расскажу в сле- дующем разделе, а также о том, как проверять, какой тип рабочей таблицы
134 Глава 4 Оптимизация оконных функций используется. А пока я хотел бы напомнить, что одно из этих обстоятельств состоит в том, что SQL Server не может заранее определить число строк в ка- дре. В частности это происходит при использовании единиц оконного кадра RANGE вместо ROWS. Как вы помните из главы 2, при использовании RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW кадр определенного столбца может включать дополнительные строки, предшествующие теку- щей. Это происходит, когда значения, по которым выполняется упорядоче- ние, не уникальны в рамках секции. В настоящее время оптимизатор не про- веряет на предмет уникальности — в этом случае с технической точки зре- ния он мог бы преобразовать параметр RANGE в эквивалентный параметр ROWS. Поэтому по умолчанию выбирается рабочая таблица на диске. Это приводит к значительному снижению производительности по сравнению с использованием параметра ROWS. Следующий запрос эквивалентен предыдущему, но я заменил параметр ROWS на RANGE: SELECT actid, tranid, val. SUM(val) OVER(PARTITION BY actid ORDER BY tranid RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance FROM dbo.Transactions; План этого запроса показан на рис. 4-19. Querj 1. Quer, cost (relitive to the bitch): ШОК SELECT ictid. trurid. vil, SUM (vil) OVER (PARTITION BY ictid ORDER BY trinid RANCE BETWEEN UNBOUNDED PRECEDING AMD CURRENT ROW) AS bilince FROM dbo Trir Streim Aggregite (Aggregite) Cost: 9 К Window Spool Clustered Index Scin (Clustered) [Trinsictions] . [PK_Trinsictions] Cost 51 К 2000000 2000000 4000000 Рис. 4-19. План с использованием параметра RANGE По плану нельзя определить, что используется рабочая таблица на диске. В сущности он выглядит, как предыдущий план (если не считать итератор Sequence Project), причем между итераторами передается такое же число строк. Параметр STATISTICS IO — один из способов сказать, что использу- ется рабочая таблица на диске. При использовании параметра ROWS он ука- зывал нулевое число операций чтения по отношению к таблице 'Worktable', потому что она находится в памяти. При использовании параметра RANGE выполняются миллионы операций чтения. Трассировка показывает в общем зачете 18 063 511 операций логического чтения и 5800 операций записи. Это приводит к увеличению времени выполнения до 60 секунд с девяти секунд при использовании ROWS. Неприятный момент заключается в том, что если указать предложение упорядочения окна без явного предложения оконного кадра, по умолча- нию будет использоваться параметр RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, как в следующем запросе:
Оптимизация оконных функций Глава 4 135 SELECT actid. tranid, val, SUM(val) OVER(PARTITION BY actid ORDER BY tranid) AS balance FROM dbo.Transactions; Очень большая вероятность того, что многие будут полагать, что по умолчанию используется ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, не осознавая, что это по сути означает RANGE. Это приведет к значительному падению производительности, не говоря уже о дубликатах. Я надеюсь, что в будущем, по крайней мере при наличии уни- кальности значений в каждой секции, в этом варианте с возможностью бы- строго выполнения оптимизатор будет преобразовывать параметр RANGE в ROWS. Используя рассказанное ранее, можно улучшить параллельное выполне- ние запроса с RANGE с применением APPLY: SELECT С.actid, А.* FROM dbo.Accounts AS C CROSS APPLY (SELECT tranid, val, SUM(val) OVER(ORDER BY tranid RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance FROM dbo.Transactions AS T WHERE T.actid = C.actid) AS A; Для этого запроса создается параллельный план, выполняющийся 21 се- кунду — треть времени выполнения запроса в отсутствие APPLY. Но это все равно намного медленнее, чем в версии с ROWS. Поэтому можно считать рекомендацией использовать параметр ROWS, где это только возможно — естественно при условии уникальности и принципиальной эквивалентно- сти двух вариантов. Развертывание всех строк кадров В предыдущем разделе я описал быстрый способ, когда нижней границей кадра является UNBOUNDED PRECEDING. В этом случае SQL Server не разворачивает все строки кадра для каждой строки, а только накапливает значения. Как уже говорилось, итератор Window Spool дает только две стро- ки на каждую исходную строку: одна с накопленными на данный момент значениями и вторая — с базовой строкой для работы с элементами подроб- ностей. Если нижняя граница не UNBOUNDED PRECEDING, быстрый вари- ант не работает. В таких случаях оптимизатор будет выбирать между двумя стратегиями. В первой, о которой я сейчас расскажу, заключается в развер- тывании всех строк кадров для каждой исходной строки. Другая стратегия, о которой мы поговорим чуть позже, заключается в вычислении двух совокуп- ных значений — CumulativeBottom и CumulativeTop — и получении результата на их основе.
136 Глава 4 Оптимизация оконных функций Для применения второй стратегии нужно, чтобы агрегат был накопи- тельным, или кумулятивным, (SUM, COUNT, COUNT BIG, AVG, STDEV, STDEVP, VAR или VARP), кроме того она оправдана, если в кадре больше четырех строк. Если агрегат не кумулятивный (MIN, MAX, FIRST VALUE, LAST VALUE или CHECKSUM AGG) или если в кадре четыре или мень- ше строк, применяется первая стратегия (в которой все строки кадров разво- рачиваются для каждой исходной строки). [ с| Примечание В ядре СУБД LAG и LEAD преобразуются в функцию LAST_VALUE с одной строкой, поэтому я не буду раздельно обсуждать LAG и LEAD. Например, LAG(x, 6) OVER(ORDER BY у) преобразуется в LAST_VALUE(x) OVER(ORDER BY у ROWS BETWEEN 6 PRECEDING AND 6 PRECEDING). Посмотрите на следующий пример: SELECT actid. tranid, val, SUM(val) OVER(PARTITION BY actid ORDER BY tranid ROWS BETWEEN 5 PRECEDING AND 2 PRECEDING) AS sumval FROM dbo.Transactions; План этого запроса показан на рис. 4-20. На его выполнение ушло 14 се- кунд. Qutr, 1: Qu. г, coxt (r.litiva to tho bitch) 100* SELECT ictid. tnnid. vil. SUM(vil) OVER (PARTITION BY ictid ORDER BY tnnid ROWS BETWEEN 5 PRECEDING AND i PRECEDING) AS xumviT FROM dbo Trinxictionx; Рис. 4-20. План с развертыванием всех строк кадра В запросе применяется кумулятивный агрегат (SUM), но в кадре только четыре строки, поэтому развертываются все строки кадров. Четыре строки в каждом кадре плюс текущая строка, нужная для элементов подробностей, дадут после итератора Window Spool пять строк для каждой исходной стро- ки. Поэтому в плане видно, что итератор Window Spool генерирует почти 10 млн выходных строк из 2 млн исходных строк. Кадры в первых нескольких строках каждой секции содержат меньше четырех строк, поэтому план по- казывает, что итератор Window Spool генерирует чуть менее 10 млн строк. Итератор Window Spool должен знать, какую целевую строку хранить в рабочей таблице для каждой исходной строки, а также должен сгенери- ровать идентификатор кадра в целевых строках, чтобы у итератора Stream Aggregate было по чему группировать строки. Для определения, какие строки создавать в каждом кадре, план начина- ется с вычисления числа строк для каждой исходной строки (с примене- нием сначала итератора Segment, а затем итератора Sequence Project). При определении числа строк используются те же параметры секционирования и упорядочения, что и в исходной оконной функции. В плане использует-
Оптимизация оконных функций Глава 4 137 ся итератор Compute Scalar для вычисления двух значений для каждой ис- ходной строки — BottomRowNumberN и TopRowNumberN. Предполагается, что эти номера строк ограничивают кадр. Представьте, к примеру, что но- мер текущей строки — 10. Соответствующие номера границ кадра таковы: TopRowNumberN = 10 - 5 = 5 и BottomRowNumber = 10 - 2 = 8. Рабочая та- блица, созданная итератором Window Spool, будет индексироваться по этим номерам строк. Поэтому если в рабочей таблице уже есть строки с номерами от 5 до 8, они будут запрошены и добавлены в рабочую таблицу, связанную с новым кадром. Если каких-то строк не хватает, план будет запрашивать больше строк и размещать их в рабочей таблице, пока не будет достигнута последняя строка. Для каждой исходной строки итератор Window Spool ге- нерирует атрибут по имени WindowCountN, который идентифицирует кадр. Именно по этому атрибуту итератор Stream Aggregate группирует строки. Помимо вычисления нужного агрегата, итератор Aggregate вычисляет число строк в кадре, а затем следующий итератор Compute Scalar возвраща- ет NULL, если кадр пустой. Если число строк в кадре равно четырем или меньше, все строки кадра бу- дут развернуты независимо о используемой оконной функции. Аналогичным образом обрабатываются кадры 2 PRECEDING AND 1 FOLLOWING, 2 FOLLOWING AND 5 FOLLOWING и т. д. Если текущая строка является границей кадра, в плане не нужно вы- числять верхний и нижний номера строки. Будет вычислен только один номер границы в дополнение к существующему RowNumberN. Например, для кадра 3 PRECEDING AND CURRENT ROW будет вычисляться толь- ко TopRowNumberN (RowNumberN - 3), а для кадра CURRENT ROW AND 3 FOLLOWING — BottomRowNumberN (RowNumberN + 3). Другая граница будет просто RowNumberN. Если оконная функция не является накопительной (MIN, MAX, FIRST_ VALUE, LAST_VALUE или CHECKSUM AGG), разворачиваются все стро- ки кадра независимо от числа строк в кадре. Посмотрите на следующий при- мер: SELECT actid, tranid, val, MAX(val) OVER(PARTITION BY actid ORDER BY tranid ROWS BETWEEN 100 PRECEDING AND 2 PRECEDING) AS maxval FROM dbo.Transactions; План этого запроса показан на рис. 4-21. Qu.r, 1: Qutr, colt (rolitivo to th. bitch): Ю0Я SELECT ictid. trinid. vil. MAX(vil) OVER (РАЯ TITIOH BY ictid ORDER BY trinid ROWS BETWEH Ю0 PRECEDING AHO 2 PRECEOIHC) AS mixvil FROM dbo Triniiction»; Рис. 4-21. План агрегата МАХ
138 Глава 4 Оптимизация оконных функций Для агрегата МАХ развертываются все строки кадра. Это 99 строк на кадр, умножьте это число на число строк в таблице и вы получите приличное число строк, возвращаемых итератором Window Spool (примерно 200 млн строк). Этот запрос выполнялся 75 секунд. Как видите, SQL Server решил применить параллельный план. Я уже объяснял, какие проблемы возможны при использовании стандартных ме- ханизмов параллелизма в оконных функциях, и предложил вместо этого ис- пользовать параллельную методику с применением APPLY. Вот параллель- ная версия с APPLY: SELECT С.actid. А.* FROM dbo.Accounts AS C CROSS APPLY (SELECT tranid. val, MAX(val) OVER(ORDER BY tranid ROWS BETWEEN 100 PRECEDING AND 2 PRECEDING) AS maxval FROM dbo.Transactions AS T WHERE T.actid = C.actid) AS A; На моей машине выполнение этого запроса заняло 31 секунду. В итераторе Window Spool теперь используется новая оптимизированная рабочая таблица в памяти. Однако при возникновении следующих условий у итератора не будет выбора и придется вернуться к намного более медлен- ной рабочей таблице на диске со всеми ее издержками (блокировками и до- полнительными операциями ввода/вывода): а Если расстояние между двумя крайними точками текущей, верхней и нижней границы превышает 10 тыс. и Если не удается вычислить число строк в кадре, например при использо- вании RANGE. в При использовании LAG или LEAD, где смещение задано как выраже- ние. Есть ряд приемов, которые позволяют проверить, какой тип рабочих та- блиц использует SQL Server — в памяти или на диске. Первый прием заклю- чается в использовании параметра STATISTICS IO, а второй — в примене- нии расширенного события, предназначенного именно для этой цели. При использовании параметра STATISTICS IO можно быть уверенным, что если число операций чтения рабочей таблицы равно нулю, она разме- щается в памяти, в противном случае она размещается на диске. В качестве примера приведу код, который включает параметр STATISTICS IO ON и выполняет два запроса с применением оконной функции МАХ: SET STATISTICS 10 ON: SELECT actid. tranid, val. MAX(va]) OVER(PARTITION BY actid ORDER BY tranid
Оптимизация оконных функций Глава 4 139 ROWS BETWEEN 9999 PRECEDING AND 9999 PRECEDING) AS maxval FROM dbo.Transactions; SELECT actid, tranid, val, MAX(val) OVER(PARTITION BY actid ORDER BY tranid ROWS BETWEEN 10000 PRECEDING AND 10000 PRECEDING) AS maxval FROM dbo.Transactions; В запросе используется следующий кадр: ROWS BETWEEN 9999 PRECEDING AND 9999 PRECEDING Расстояние в строках между конечными точками (как вы помните, теку- щая строка также считается) равно 10 000, поэтому может использоваться рабочая таблица в памяти. Этот запрос выполняется за шесть секунд. Во втором запросе используется следующий кадр: ROWS BETWEEN 10000 PRECEDING AND 10000 PRECEDING На этот раз расстояние между конечными точками равно 10 001, поэто- му используется рабочая таблица на диске. Этот запрос выполнился за 33 секунды. Вот результат вывода STATISTICS Ю для двух запросов: - 9999 PRECEDING AND 9999 PRECEDING, 6 seconds Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read- ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0. Table 'Transactions'. Scan count 1, logical reads 6208, physical reads 0. read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0. - 10000 PRECEDING AND 10000 PRECEDING, 33 seconds Table 'Worktable'. Scan count 2000100, logical reads 12086700. physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0. Table 'Transactions'. Scan count 1, logical reads 6208, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0. Заметьте, что в первом запросе сообщается о нуле операций чтения, а во втором запросе — 12 086 700 операций. Прежде чем перейти к описанию второй методики выполните следую- щий код, чтобы выключить параметр STATISTICS Ю: SET STATISTICS 10 OFF;
140 Глава 4 Оптимизация оконных функций Второй способ выяснить, размещалась ли рабочая таблица на диске, за- ключается в использовании расширенного события window_spool_ondisk_ warning. Выполните следующий код, чтобы создать сеанс события, исполь- зуя асинхронный целевой файл, и запустить сеанс: CREATE EVENT SESSION xe_window_spool ON SERVER ADD EVENT sqlserver.window_spool_ondisk_warning ( ACTION (sqlserver.plan.handle, sqlserver.sql_text) ) ADD TARGET packageO.asynchronous.!ile_target ( SET FILENAME - N’c:\temp\xe_xe_window_spool.xel', metadatafile = N'c:\temp\xe_xe_window_spool.xem' ); ALTER EVENT SESSION xe.window.spool ON SERVER STATE = START; Повторно выполните приведенные ранее запросы, после чего откройте файл c:\temp\xe_xe_window_spool.xel из консоли SQL Server Management Studio (SSMS). Вы найдете в нем информацию о запросах, в которых рабочие таблицы размещались на диске, а также описатель плана и текст запроса. По завершении надо выполнить следующий код очистки: DROP EVENT SESSION xe.window.spool ON SERVER; Вычисление двух накопительных значений Если оконная функция является накопительной (SUM, COUNT, COUNT_ BIG, AVG, STDEV, STDEVP, VAR или VARP) и в кадре более четырех строк, оптимизатор использует специализированную стратегию, которая не пред- усматривает развертывание всех строк кадра. В ней предусматривается вы- числение двух накопительных значений, после чего из них выводится ре- зультат. В качестве примера посмотрите на такой запрос: SELECT actid, tranid, val, SUM(val) OVER(PARTITION BY actid ORDER BY tranid ROWS BETWEEN 100 PRECEDING AND 2 PRECEDING) AS sumval FROM dbo.Transactions; План этого запроса показан на рис. 4-22. Этот запрос выполнялся 14 се- кунд. Оптимизатор решил применить параллельный план. В плане использу- ется параллельный просмотр РОС-индекса итератором обмена, который по- вторно выполняет секционирование по элементу, по которому выполняется секционирование окна (в нашем случае это actid). После этого в плане приме- няется последовательность итераторов (Segment, Sequence Project, Compute Scalar, Segment, Window Spool и Stream Aggregate) для вычисления накопи- тельных нижних агрегированных значений SUM и COUNT (мы назовем их CumulatweBottomSum и CumulativeBottomCount). Для вычисления накопитель-
Оптимизация оконных функций Глава 4 141 ных нижних агрегатов отбираются строки с начала секции и до строки с номе- ром на два меньше номера текущей строки. Это именно тот способ вычисления накопительных агрегатов, о котором я рассказывал в разделе «UNBOUNDED PRECEDING: быстрый путь». Таким образом, видно, что итератор Window Spool генерирует только две строки для каждой исходной строки — строку с накопленными значениями и строку с элементами подробностей. Query 1: Query cost (relative to the batch): 100» SELECT actid. tranid. val. SUH(val) OVER (FAB TITION BY actid ORDER BY tranid ROWS BETWEEN 100 PRECEDING AND 2 PRECEDING) AS suwvtl FROM dbo Transactions; Рис. 4-22. План вычисления двух накопительных значений После этого в плане применяется другая последовательность итераторов (Segment, Sequence Project, Compute Scalar, Segment, Window Spool и Stream Aggregate) для вычисления накопительных верхних агрегированных значе- ний SUM и COUNT (мы назовем их CumulativeTopSum и CumulativeTopCount). Для вычисления накопительных верхних агрегатов отбираются строки с на- чала секции и до строки с номером на 101 меньше номера текущей строки. После этого итератор Compute Scalar вычисляет SUM для кадра как CumulativeBottomSum - CumulativeTopSum и COUNT — как CumulativeBot- tomCount - CumulativeTopCount. Наконец последний итератор Compute Sca- lar определяет число строк в оконном кадре, и если оно равно нулю, возвра- щает NULL. Как я уже говорил, в моей системе этот запрос выполнялся 14 секунд. При этом использовался встроенный механизм параллельной обработки оконных функций. Можно также воспользоваться методикой с APPLY, как показано здесь: SELECT С.actid, А.* FROM dbo.Accounts AS С CROSS APPLY (SELECT tranid, val, SUM(val) OVER(ORDER BY tranid
142 Глава 4 Оптимизация оконных функций ROWS BETWEEN 100 PRECEDING AND 2 PRECEDING) AS sumval FROM dbo.Transactions AS T WHERE T.actid = C.actid) AS A: На моей системе это позволило сократить время выполнения до восьми секунд. Аналитические функции В этом разделе рассказывается об оптимизации аналитических функций. Я начну с функций распределения рангов, после чего расскажу о функциях обратного распределения. Если вы не помните логику этих функций, пере- читайте соответствующий раздел в главе 2. Функции распределения рангов Функции распределения рангов это PERCENT RANK и CUME DIST. Как вы помните, функция PERCENT RANK вычисляется как (rk - 1) / (пг - 1), где rk — ранг строки, а пг — число строк в секции. Вычисление строк в секции предусматривает использование итератора Table Spool, о чем говорилось ранее в этой главе. Для вычисления ранга применяется итератор Sequence Project. План, вычисляющий PERCENT RANK, просто включает оба итератора. В качестве примера посмотрите на такой запрос: SELECT testid, studentid, score. PERCENT_RANK() 0VER(PARTITION BY testid ORDER BY score) AS percentrank FROM Stats.Scores; План этого запроса показан на рис. 4-23. Query 1- Query cost (relative to the bttch) : МОЯ SELECT testid. studentid. score. PEPCENTJ?AHK О 0\€B (PARTITION BY testid OBDEB BY score) AS percent rink FC OH Sttts Scores; SELECT Cost. О Я Я . . Index Sc*n (BonClustered) „ [Scores] [idx_nc_testid_score] ’st Cost; 68 % Nested Loops •' 1 " (Inner Join) Cost; О Я in _ a Stream Aggregate Table Spool (Aggregate) (Lazy Spool) Cost: О Я Cost; О Я Table Spool (Lazy Spool) Cost. О Я Рис. 4-23. План для функции PERCENT_RANK В первой части данные считываются и сегментируются по testid. Затем одна по одной строки секции записываются во временную таблицу, ко- торая затем считывается дважды — сначала для вычисления числа строк (пг), а затем для получения строк подробных данных. После этого строки подробных данных их агрегаты соединяются. Затем итераторы Segment и Sequence Project используются для вычисления ранга (rk). Наконец, итера-
Оптимизация оконных функций Глава 4 143 тор Compute Scalar вычисляет результат функции PERCENT RANK как (гк - 1)/(пг-1). Что касается CUME_DIST, то эта функция вычисляет пр/пг, где пг — то же, что и раньше (число строк в секции), а пр — число строк, которые пред- шествуют или находятся на одном уровне с текущей строкой. В качестве примера посмотрите на такой запрос: SELECT testid, studentid, score, CUME_DIST() OVER(PARTITION BY testid ORDER BY score) AS cumedist FROM Stats.Scores; План этого запроса показан на рис. 4-24. Qu.r, 1. Qu«r, cost (r.litlva to th* bitch) - МОЯ SELECT t«*tid. rtud«nt1d. score. CUHE_DIST() OVERCPARTITION BY testid ORDER BY score) AS cumedist FROM Stets.Scores ® , 1 Nested Loops —1 Streim Aggregite Title Spool (loner Join) (Aggregite) (Liz, Spool) a Title Spool (Lil, Spool) Cost. О Я Рис. 4-24. План выполнения функции CUMEJDIST Первая часть, которая вычисляет пг, совпадает с планом PERCENT_ RANK. Вторая часть чуть сложнее. Для вычисления пр сервер SQL Server должен забежать вперед текущей строки. Также в плане есть два итерато- ра Segment — первый сегментирует строки по элементу сегментирования (testid), а второй сегментирует по элементам сегментирования и упорядоче- ния (testid и score). Однако вместо итератора Sequence Project используют- ся новые итераторы Window Spool и Stream Aggregate iterators в «быстром» режиме для вычисления числа строк, которые предшествуют или находятся на одном уровне с текущей строкой. Наконец итератор Compute Scalar вы- числяет значение CUME DIST как пр/пг. Функции обратного распределения Оптимизация функций обратного распределения PERCENTILE CONT и PERCENTILE_DISC сложнее, чем функций распределения рангов. Я начну с PERCENTILE_DISC. Посмотрите на следующий запрос: SELECT testid, score, PERCENTILE_DISC(O.5) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentiledisc FROM Stats.Scores; План этого запроса показан на рис. 4-25.
144 Глава 4 Оптимизация оконных функций 0М«гу 1: Query cost (relitive to the bitch): Ю0Я SELECT test-id, score. PERCENTILE DI$C(0.5) WITHIN GROUP(ORDER BY score) OVER (PARTITION BY testid) AS percent-» ledisc FROM Stits Scores; ч ₽ 4 _ _ m Coapute Scilir Coepute Scilar Sequence Project Segment Cost: О К Cost: О К <C07U? S5\Ur> CoS: О К Cost: О Я Nested Loops (Inner loin) Cost: 8 Я a Table Spool (Lazy Spool) Cost: 1 Я Segment Cost: 1 Я Index Scan (NonClustered) [Scores] . [idx_nc_testid_score] Cost: 78 Я Stream Aggregate (Aggregate) Cost: О Я a Table Spool (Lazy Spool) Cost: О Я Nested Loops (Inner loin) Cost; О Я Stream Aggregate (Aggregate) Cost: О Я a Table Spool (Lazy Spool) Cost; О Я Table Spool (Lazy Spool) Cost: О Я Table Spool (Lazy Spool) Cost: О Я Рис. 4-25. План для функции PERCENTILEJDISC План выполняет следующие операции: Первый набор из восьми итераторов в нижней правой части рис. 4-25 от- вечает за вычисление числа строк для каждой строки в соответствующей секции testid. В плане это число называет PartitionSizeN. Последующие итераторы Segment и Sequence Project вычисляют номер строки в секции testid при упорядочении по оценкам. В плане этот номер называется RowNumberN. Первый итератор Compute Scalar вычисляет номер строки, содержащей процентиль данной секции. При этом применяется такое выржение (упро- щено): CeilingTargetRowN = ceiling(@pct * PartitionSize 1013), где @pct яв- ляется входным значением процента для функции (в нашем случае 0,5). Второй итератор Compute Scalar вычисляет выражение, которое назы- вается PartialSumN. Это выражение возвращает нужный процентиль, если номер текущего столбца {RowNumberN) равен значению MIN(1, CeilingTargetRowN), в противном случае возвращается NULL. Проще го- воря, PartialSumN принимает полезное значение, только если является нужным процентилем, а в противном случае возвращает NULL. В последней части надо извлечь из каждой секции не равный NULL про- центиль (PartialSumN) и связать его со строками подробных данных. Для этого в плане снова используется итератор Table Spool. План сегментирует данные по testid и сохраняется строки текущей секции в спуле — по одной секции за раз. Затем план два раза считывает спул — сначала для получе- ние не равных NULL процентилей с помощью агрегата МАХ( PartialSumN) (результат называется PercentileResultN), а затем для получения подробных данных. После этого план соединяет подробные данные и агрегаты.
Оптимизация оконных функций Глава 4 145 и Последняя часть — проверка размера секции. Если он нулевой, возвраща- ется NULL, а в противном случае — PercentileResultN. Что касается функции PERCENTILE_CONT, для обсуждения плана я воспользуюсь следующим запросом: SELECT testid, score, PERCENTILE_CONT(O.5) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS percentilecont FROM Stats.Scores; План этого запроса показан на рис. 4-26. Омег, 1. Quer, cost (relitiv» to the bitch). LOO Ж SELECT test-id. score. PERCENTILE_CONT(O.5) WITHIN GPOUP(ORDER BY score) OVER (PARTITION BY testid) AS percentilecont FROM Stits Scores. 1,1. u Tible Spool *“ . Index Scin (NonClustered) (Liz, Spool) _ ** . _ [Scores) [idx_nc_testid_score] Cost: 1 Я Cost: 78 Я Nested Loops (Inner loin) Cost: 8 Я ® . BI „ 1 Nested Loops "1 Strein Aggregite Tible Spool (Inner loin) (Aggregite) (Liz, Spool) Cost: О К Cost: О Я Cost: О Я a _ in a Nested Loops *' Streim Aggregite Tible Spool (Inner loin) (Aggregite) (Liz, Spool) Cost: О Я Cost 0 * Cost: О Я a Tible Spool (Liz, Spool) Cost: О Я Tible Spool (Liz, Spool) Cost: 0 * Рис. 4-26. План для функции PERCENTILE.CONT Как видите, общая структура плана похожа на функцию PERCENTILE_ DISC. Однако есть ряд различий. Одно отличие заключается в итераторах Compute Scalar, которые находятся справа, сразу за вычислением числа строк, а второе отличие — второй итератор Stream Aggregate. Я начну с ите- раторов Compute Scalar: Первый итератор Compute Scalar вычисляется номер целевой строки, включая дробную часть: TargetRowN = 1 + ©pct * (PartitionSizeN - 1). Второй итератор Compute Scalar вычисляет пол и потолок для Target- RowN, присваивая им соответственно имена FloorTargetRowN и Ceiling- TargetRowN. Третий итератор Compute Scalar вычисляет выражение, которое назы- вается PartialSumN. Если интерполяция не требуется, PartialSumN возвра- щает значение процентиля, если текущая строка является целевой, или О в противном случае. Если интерполяция нужна, PartialSumN возвращает часть интерполированного значения, если текущая строка является по- лом или потолком для целевой строки. В противном случае возвращает- 6 Зак.601
146 Глава 4 Оптимизация оконных функций ся ноль. Полная процедура вычисления PartialSumN довольно сложна, но на случай, если у вас хватит терпения покопаться в ней, привожу ее здесь (в немного упрощенном виде): CASE - - Если интерполяция не нужна: - - возвращаем текущий процентиль, если строка явлется целевой, - - в противном случае возвращаем О WHEN CeilingTargetRowN = FloorTargetRowN AND CeilingTargetRowN = TargetRowN THEN CASE WHEN RowNumberN = TargetRowN THEN score ELSE 0 END - - Если интерполяция нужна: возвращаем часть интерполированного значения, если текущая строка является полом или потолком для целевой строки ELSE CASE WHEN RowNumberN = FloorTargetRowN THEN score * (CeilingTargetRowN - TargetRowN) ELSE CASE WHEN RowNumberN = CeilingTargetRowN THEN score * (TargetRowN - FloorTargetRowN) ELSE 0 END END END Другое отличие плана от PERCENTILE_DISC заключается в том, что в итераторе Stream Aggregate используется агрегат SUM, а не МАХ. Причина в том, что на этот раз может потребоваться более одного элемента, а также в том, что может возникнуть необходимость суммировать части, используе- мые для вычисления интерполированного значения. Резюме В этой главе я рассказал об оптимизации оконных функций SQL Server. Мне пришлось представить много подробной информации, но я надеюсь, что вы в ней не утонули. Особый интерес представляют новый оптимизированный итератор Window Spool iterator и улучшенный итератор Stream Aggregate, а также оптимизированные таблицы в памяти, которые в этих итераторах ис-
Оптимизация оконных функций Глава 4 147 пользуются. Остались некоторые странности в оптимизации, в частности те, что относятся к по-видимому ненужным сортировкам, но я надеюсь, что си- туация улучшится в будущем. Достичь совершенства невозможно, но нужно к нему стремиться. В любом случае, в сравнении с другими методами полу- чения аналогичных результатов, SQL Server обеспечивает очень эффектив- ную работу оконных функций. В следующей главе мы поговорим о практическом использовании окон- ных функций и в некоторых случаях сравним полученные решения с более традиционными методами, чтобы увидеть, насколько более эффективны но- вые функции и функциональность.
Глава 5 Решения на основе T-SQL с использованием оконных функций Первые четыре главы этой книги подробно рассказывают об оконных функ- циях, в том числе логике их работы и оптимизации. В этой, последней главе я расскажу, как решать широкий диапазон задач с помощью оконных функ- ций. Это может показаться странным, но в большинстве решений использу- ется функция ROW NUMBER — она оказалась самой популярной из всех оконных функций. В этой главе рассказывается о следующих решениях: вспомогательная виртуальная таблица чисел, последовательности значений даты и времени, последовательности ключей, разбиение на страницы, удаление дубликатов, сведение, выбор первых п элементов в группе, моды, нарастающие итоги, максимальное количество параллельных интервалов, упаковка интервалов, нахождение пробелов и островков, медианы, условные агрегаты и сортиров- ка иерархий. | ^с| Примечание В этой главе приведены только примеры решений, чтобы продемон- стрировать практическую полезность оконных функций. Скорее всего вы найдете другие применения оконных функций, которые позволяют элегантнее и эффективнее решать задачи. Вспомогательные виртуальные таблицы чисел Вспомогательные таблицы номеров содержат последовательности номеров, которые нужны для работы запросов. У таких таблиц чисел есть много при- менений, например генерация последовательности значений дат и времени и разбиение раздельных списков значений. Обычно рекомендуется хранить такую таблицу в базе данных постоянно, заполнив ее нужными числами и используя их по мере необходимости. Однако в некоторых средах невоз- можно создавать и наполнять новые таблицы, поэтому приходится их соз- давать в логике запроса. Для создания больших последовательностей целых чисел, эффективно используя логику запроса, можно применить перекрестные соединения.
Решения на основе Т-SQL с использованием оконных функций Глава 5 149 Можно начать с запроса, который генерирует результирующий набор из двух строк с использованием конструктора табличного выражения: SELECT с FROM (VALUES(1),(1)) AS D(c); Этот запрос дает следующий результат: С 1 1 Далее на основе этого запроса надо определить обобщенное табличное выражение (СТЕ) — назовем его L0 для уровня «0», после чего выполнить перекрестное соединение двух экземпляров СТЕ, чтобы получить четыре строки: WITH LO AS(SELECT с FROM (VALUES( 1), (1)) AS D(c)) SELECT 1 AS c FROM LO AS A CROSS JOIN LO AS B; c 1 1 1 1 Аналогично можно определить СТЕ (назовем его £7 для уровня «7»), по- сле чего выполнить перекрестное соединение двух экземпляров СТЕ, чтобы получить на этот раз 16 строк: WITH LO AS (SELECT c FROM (VALUES(1),(1)) AS D(c)), L1 AS (SELECT 1 AS c FROM LO AS A CROSS JOIN LO AS B) SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B; c 1 1 1 1 1 1 1 1 1 1 1
150 Глава 5 Решения на основе Т-SQL с использованием оконных функций 1 1 1 1 1 Теперь можно добавлять СТЕ, в каждом из которых выполняется пере- крестное соединение двух последних СТЕ и увеличение по экспоненте чис- ла строк. При наличии L уровней (начиная с нуля) общее число полученных строк равно 2Л2Л£ (то есть 2 в степени 2 в степени £). Например, для пяти уровней число строк равно 4 294 967 296. Поэтому при пяти уровнях СТЕ помимо нулевого уровня этот метод дает более 5 млрд строк. Вам вряд ли потребуется столько строк в таблице чисел, но использование параметра OFFSET/FETCH в Microsoft SQL Server 2012 или ТОР в предыдущих вер- сиях SQL Server позволяет ограничить сверху число строк на основе вводи- мой пользователем информации. Применяя ROW_NUMBER с ORDER BY (SELECT NULL) можно генерировать конкретные числа, не беспокоясь о затратах на сортировку. Суммируя все сказанное: чтобы сгенерировать по- следовательность чисел в диапазоне от ©low до ©high, в SQL Server 2012 можно использовать следующий код: WITH LO AS (SELECT с FROM (VALUES(1),(1)) AS D(c)), L1 AS (SELECT 1 AS c FROM LO AS A CROSS JOIN LO AS B), L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B), L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B), L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B), L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B), Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM L5) SELECT @low + rownum - 1 AS n FROM Nums ORDER BY rownum OFFSET 0 ROWS FETCH FIRST @high - @low + 1 ROWS ONLY; Красота такого подхода в том, что оптимизатор SQL Server «догадывает- ся», что не нужно генерировать больше строк, чем ©high - ©low + 1, поэто- му обработчик запросов прекращает работу при достижении этого числа. Поэтому если вам нужна последовательность только 10 чисел, после гене- рации десяти штук процесс будет останавливаться. Если вы не хотите по- вторять этот код каждый раз, когда нужна последовательность чисел, можно инкапсулировать его в функцию, возвращающую табличное значение. USE TSQL2012; IF OBJECT_ID(’dbo.GetNums’, 'IF') IS NOT NULL DROP FUNCTION dbo.GetNums; GO CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE
Решения на основе Т-SQL с использованием оконных функций Глава 5 151 AS RETURN WITH LO AS (SELECT c FROM (VALUES(1), (D) AS D(c)), L1 AS (SELECT 1 AS c FROM LO AS A CROSS JOIN LO AS B), L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B), L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B), L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B), L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B), Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM L5) SELECT @low + rownum - 1 AS n FROM Nums ORDER BY rownum OFFSET 0 ROWS FETCH FIRST @high - @low + 1 ROWS ONLY; GO Параметр OFFSET/FETCH появился SQL Server 2012. Если нужно определить такую функцию в более ранних версиях SQL Server, нужно ис- пользовать параметр ТОР, например так: IF OBJECT_ID(’dbo.GetNums', ’IF’) IS NOT NULL DROP FUNCTION dbo.GetNums; GO CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE AS RETURN WITH LO AS (SELECT c FROM (VALUES(1),(1)) AS D(c)), L1 AS (SELECT 1 AS c FROM LO AS A CROSS JOIN LO AS B), L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B), L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B), L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B), L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B), Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM L5) SELECT TOP(@high - @low + 1) @low + rownum - 1 AS n FROM Nums ORDER BY rownum; GO Эти две функции оптимизируются одинаково, поэтому ориентируясь на производительность, нельзя выбрать, какая из них лучше. Если вам важна совместимость с версиями, предшествующими SQL Server 2012, следует вы- брать версию с ТОР. Надо еще понимать, что в отличие от OFFSET-FETCH параметр ТОР не является стандартным, поэтому если важно соответствие кода стандартам, возможно потребуется использовать ТОР в системах с SQL Server 2012.
152 Глава 5 Решения на основе Т-SQL с использованием оконных функций В качестве примера использования функции GetNums приведем код, ге- нерирующий последовательность чисел из диапазона от И до 20: SELECT n FROM dbo.GetNums(11, 20); n 11 12 13 14 15 16 17 18 19 20 Чтобы понять, как быстро работает этот метод, я протестировал его на средней оснащенности ноутбуке, выбрав в диалоговом окне Query Options параметр Discard Results After Execution (Отбросить результаты после вы- полнения). Следующему запросу понадобилось всего шесть секунд, чтобы сгенерировать последовательность 10 млн символов: SELECT n FROM dbo.GetNums(1, 10000000); Недостаток этой функции состоит в том, что планы запросов, в которых она используется, сложны и в них непросто разобраться. Дополнительно ситуация усложняется, когда нужно несколько последовательностей. Естественно, планы запросов с использованием реальных таблиц дают на- много более простые планы. В этой главе вы увидите еще не одно решение, в котором используется функция GetNums. Последовательности значений даты и времени В самых различных ситуациях работы с данными требуется генерировать последовательности дат и времени между заданными на входе точками нача- ла ©start и ©end и с заднным интервалом (например, 1 день, 12 часов и т. п.). За примерами таких ситуаций не нужно далеко ходить — наполнение раз- мерности времени в хранилище данных, планирование запуска приложений и т. п. Эффективным средством решения этой задачи является описанная в предыдущем разделе функция GetNums. На вход поступают начальные и ко- нечные дата и время — ©start и ©end, и с применением функции DATEDIFF вычисляется, сколько интервалов нужной величины помещается в заданный диапазон. Далее вызывается функция GetNums со следующими входными данными: ©low — «О», a ©high равно вычисленной на предыдущем этапе раз-
Решения на основе Т-SQL с использованием оконных функций Глава 5 153 нице. Наконец для получения результирующих даты и времени к ©start до- бавляется умноженный на п временной интервал. Вот пример генерации последовательности дат в диапазоне с 1 по 12 фев- раля 2012 года: DECLARE ©start AS DATE = ’20120201’, @end AS DATE = ’20120212’; SELECT DATEADD(day, n, ©start) AS dt FROM dbo.GetNums(0, DATEDIFF(day, ©start, @end)) AS Nums; dt 2012-02-01 2012-02-02 2012-02-03 2012-02-04 2012-02-05 2012-02-06 2012-02-07 2012-02-08 2012-02-09 2012-02-10 2012-02-11 2012-02-12 Если интервал является кратным определенной единице времени, на- пример 12 часов, используйте эту единицу (в данном случае час) при вы- числении разницы между ©start и ©end, и разделите результат на 12, чтобы получить ©high, а затем умножьте и на 12, чтобы получить число часов, ко- торые нужно добавить к ©start для вычисления результирующих значений даты и времени. В качестве примера, следующий код генерирует последова- тельность значений даты и времени между 12 и 18 февраля 2012 года с 12- часовым интервалом между значениями последовательности: DECLARE ©start AS DATETIME2 = '2012-02-12 00:00:00.0000000’, @end AS DATETIME2 = ’2012-02-18 12:00:00.0000000’, SELECT DATEADD(hour, n*12, ©start) AS dt FROM dbo.GetNums(0, DATEDIFF(hour, ©start, @end)/12) AS Nums; dt 2012-02-12 00:00:00.0000000 2012-02-12 12:00:00.0000000 2012-02-13 00:00:00.0000000
154 Глава 5 Решения на основе Т-SQL с использованием оконных функций 2012-02-13 12:00:00.0000000 2012-02-14 00:00:00.0000000 2012-02-14 12:00:00.0000000 2012-02-15 00:00:00.0000000 2012-02-15 12:00:00.0000000 2012-02-16 00:00:00.0000000 2012-02-16 12:00:00.0000000 2012-02-17 00:00:00.0000000 2012-02-17 12:00:00.0000000 2012-02-18 00:00:00.0000000 2012-02-18 12:00:00.0000000 Последовательности ключей В самых разных ситуациях может потребоваться генерировать последова- тельности уникальных целых ключей при обновлении или вставке данных в таблицу В SQL Server 2012 появилась поддержка объектов последователь- ности, что позволяет решить часть подобных задач. Однако объектов по- следовательности нет в более ранних версиях SQL Server. Кроме того SQL Server не сможет откатить генерацию последовательных значений, если транзакция, в которой они генерируются, потерпит сбой, — иначе говоря, могут возникать пробелы в последовательности значений. (Такая же ситуа- ция с IDENTITY.) Если нужно, чтобы гарантировано не было никаких про- белов в последовательности сгенерированных ключей, объекты последова- тельности использовать нельзя. В этом разделе я покажу, как решить ряд за- дач, в которых нужны последовательности значений, не прибегая к объектам последовательности. Обновление столбца с заполнением уникальными значениями В первом сценарии решается вопрос качества данных. Выполните следую- щий код, чтобы создать и заполнить таблицу по имени MyOrders, которую мы будем использовать в качестве примера данных: IF OBJECT_ID('Sales.MyOrders', ’IT) IS NOT NULL DROP TABLE Sales.MyOrders: GO SELECT 0 AS orderid, custid, empid, orderdate INTO Sales.MyOrders FROM Sales.Orders; SELECT * FROM Sales.MyOrders; orderid custid empid orderdate 0 85 2006-07-04 00:00:00.000 5
Решения на основе Т-SQL с использованием оконных функций Глава 5 155 0 79 6 2006-07-05 00:00:00.000 0 34 4 2006-07-08 00:00:00.000 0 84 3 2006-07-08 00:00:00.000 0 76 4 2006-07-09 00:00:00.000 0 34 3 2006-07-10 00:00:00.000 0 14 5 2006-07-11 00:00:00.000 0 68 9 2006-07-12 00:00:00.000 0 88 3 2006-07-15 00:00:00.000 0 35 4 2006-07-16 00:00:00.000 Представьте, что из-за проблем с качеством данных в атрибуте orderid та- блицы MyOrders значения неуникальны. Вам нужно обновить все строки, добавив уникальные целые значения в произвольном порядке, начиная с единицы. Для решения этой задачи можно определить СТЕ на основе за- проса MyOrders, возвращающего атрибут orderid, а также значение ROW_ NUMBER. Если нет требования упорядочивать при вычислении номеров строк, можно использовать (SELECT NULL) в предложении упорядочения окна. Затем во внешнем запросе СТЕ применим инструкцию UPDATE, ко- торая присваивает orderid результат ROW NUMBER: WITH С AS ( SELECT orderid, ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM Sales.MyOrders ) UPDATE C SET orderid = rownum; Если запросить таблицу MyOrders после обновления, можно увидеть, что значения orderid теперь уникальны: SELECT * FROM Sales.MyOrders; orderid custid empid orderdate 1 85 5 2006-07-04 00:00:00.000 2 79 6 2006-07-05 00:00:00.000 3 34 4 2006-07-08 00:00:00.000 4 84 3 2006-07-08 00:00:00.000 5 76 4 2006-07-09 00:00:00.000 6 34 3 2006-07-10 00:00:00.000 7 14 5 2006-07-11 00:00:00.000 8 68 9 2006-07-12 00:00:00.000 9 88 3 2006-07-15 00:00:00.000 10 35 4 2006-07-16 00:00:00.000
156 Глава 5 Решения на основе Т-SQL с использованием оконных функций В данный момент хорошо бы создать ограничение основного ключа, что- бы обеспечить уникальность в таблице. Получение блока значений последовательности Допустим вам нужен механизм, гарантировано создающий последователь- ности без пробелов. Нельзя полагаться на столбец идентификационного свойства или объект последовательности, потому что оба механизма не ис- ключают пробелов, которые образуются когда, к примеру, происходит сбой значения последовательности или значение не фиксируется. Один из стан- дартных способов реализации механизма создания последовательности, ко- торый гарантирует отсутствие пробелов, — хранить последнее использован- ное значение в таблице и каждый раз, когда нужно новое значение, инкре- ментировать хранимое значение и использовать его в качестве нового. Например, следующий код создает таблицу по имени MySequence и раз- мещает в ней одну строку со значением «О» в столбце val\ IF OBJECT_ID('dbo.MySequence', ’U’) IS NOT NULL DROP TABLE dbo.MySequence; CREATE TABLE dbo.MySequence(val INT); INSERT INTO dbo.MySequence VALUES(O); Когда нужно сгенерировать и использовать новое значение последова- тельности, можно применить такую хранимую процедуру: IF OBJECT_ID('dbo.GetSequence', ’P’) IS NOT NULL DROP PROC dbo.GetSequence; GO CREATE PROC dbo.GetSequence @val AS INT OUTPUT AS UPDATE dbo.MySequence SET @val = val += 1; GO Эта процедура обновляет строку в MySequence, увеличивая текущее зна- чение на единицу, и сохраняет результат в выходном параметре ©vol. Каждый раз, когда требуется новое значение последовательности, достаточно выпол- нить эту процедуру и взять готовое значение из выходного параметра: DECLARE @key AS INT; EXEC dbo.GetSequence @val = @key OUTPUT; SELECT @key; Если выполнить этот код дважды (естественно, в одной транзакции), вы получите значение 1, а затем 2. Представьте себе, что иногда нужно выделить целый блок последователь- ных значений, например при вставке в таблицу нескольких строк. Прежде всего нужно изменить процедуру, предусмотрев в ней дополнительный входной параметр (назовем его ©п), который указывает размер блока. После
Решения на основе Т-SQL с использованием оконных функций Глава 5 157 этого процедура может инкрементировать столбец val в MySequence на ве- личину @п и возвращать новое значение нового блока в качестве выходного параметра. Вот измененное определение процедуры: ALTER PROC dbo.GetSequence @val AS INT OUTPUT, @n AS INT = 1 AS UPDATE dbo.MySequence SET @val = val + 1, val += @n; GO Вам все равно придется решать, как связать конкретные значения по- следовательности в блоке со строками результирующего набора запроса. Допустим, следующий запрос, возвращающий клиентов из Великобритании, представляет набор, который вам нужно вставить в целевую таблицу: SELECT custid FROM Sales.Customers WHERE country = N'UK'; custid 4 11 16 19 38 53 72 От вас требуется сгенерировать суррогатные ключи для этих клиентов и, в конечном итоге, вставить записи о клиентах в размерность клиентов своего хранилища данных. Можно разместить этот результирующий набор в табличной переменной вместе с результатом функции ROW_NUMBER, ко- торая сгенерирует уникальные целые значения, начинающиеся с единицы. (Назовем этот столбец rownum.) Затем можно разместить число задейство- ванных строк из функции ©©rowcount в локальную переменную (назовем ее @гс). Затем можно вызвать процедуру, передав ей @гс в качестве размера блока, и получить первый ключ в блоке, а затем разместить его в локальной переменной (назовем ее ©firstkey). Наконец, можно запросить табличную переменную и вычислить конкретные значения последовательность с помо- щью выражения ©firstkey + rownum - 1. Вот код Т-SQL всего решения: DECLARE @firstkey AS INT, @rc AS INT; DECLARE @CustsStage AS TABLE
158 Глава 5 Решения на основе Т-SQL с использованием оконных функций ( custid INT, rownum INT ); INSERT INTO @CustsStage(custid, rownum) SELECT custid, ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) FROM Sales.Customers WHERE country = N’UK’; SET @rc = @@rowcount; EXEC dbo.GetSequence @val = @firstkey OUTPUT, @n = @rc; SELECT custid, @firstkey + rownum - 1 AS keycol FROM @CustsStage; custid keycol 4 3 11 4 16 5 19 6 38 7 53 8 72 9 Естественно, последняя часть должна вставлять результаты этого запро- са в целевую таблицу Также заметьте, что я использую выражение ORDER BY (SELECT NULL) в инструкции упорядочения окна в функции ROW_ NUMBER, чтобы получить произвольный порядок номеров строк. Если значения последовательности надо назначать в определенном порядке (на- пример, при упорядочении по custid}, измените соответствующим образом инструкцию упорядочения окна. Затем выполните похожий процесс, но на этот раз используйте в качестве источника клиентов из Франции: DECLARE @firstkey AS INT, @rc AS INT; DECLARE @CustsStage AS TABLE ( custid INT, rownum INT ); INSERT INTO @CustsStage(custid, rownum) SELECT custid, ROW_NUMBER() OVER(ORDER BY (SELECT NULL))
Решения на основе Т-SQL с использованием оконных функций Глава 5 159 FROM Sales.Customers WHERE country = N'France'; SET @rc = @@rowcount; EXEC dbo.GetSequence @val = @firstkey OUTPUT, @n = @rc; SELECT custid, @firstkey + rownum - 1 AS keycol FROM @CustsStage; custid keycol 7 10 9 11 18 12 23 13 26 14 40 15 41 16 57 17 74 18 84 19 85 20 Обратите внимание, что сгенерированные последовательные значения продолжают предыдущий блок. По завершении надо выполнить следующий код очистки: IF OBJECT_ID('dbo.GetSequence', 'P') IS NOT NULL DROP PROC dbo.GetSequence; IF OBJECT_ID('dbo.MySequence', 'U') IS NOT NULL DROP TABLE dbo.MySequence; Разбиение на страницы Потребность в разбиении на страницы очень часто требуется в приложени- ях. Обычно нужно предоставлять пользователю за раз только определенную часть строк из результирующего набора запроса, чтобы она умещалась на веб-странице, графическом интерфейсе или на экране. Для разбиения на страницы можно применить функцию ROW NUMBER. Номера получен- ным в результате строкам присваиваются в нужном порядке, после чего от- фильтровывается правильный диапазон номеров строк на основе заданного номера и размера страниц. Для оптимизации производительности жела- тельно определить номера, полученные на основе элементов упорядочения окна, в качестве ключей индекса, а также включить в этот индекс остальные атрибуты, которые используются в запросах, для обеспечения покрытия за- просов. Допустим, к примеру, что нужно обеспечить разбиение на страницы за- казов из таблицы Sales.Orders по атрибутам orderdate и упорядочение по
160 Глава 5 Решения на основе Т-SQL с использованием оконных функций orderid (с самых давних до самых новых) и вернуть в результирующем набо- ре атрибуты orderid, orderdate, custid и empid. В соответствии с изложенными выше рекомендациями создаем следующий индекс: CREATE UNIQUE INDEX idx_od_oid_i_cid_eid ON Sales.Orders(orderdate, orderid) INCLUDE(custid, empid); Затем, получив на вход номер и размер страницы, используем следую- щий код для фильтрации нужной страницы строк. Например, следующий код возвращает третью страницу при размере страницы равном 25 строкам, то есть нам нужны строки с 51 по 75: DECLARE @pagenum AS INT = 3, @pagesize AS INT = 25; WITH C AS ( SELECT ROW_NUMBER() OVER( ORDER BY orderdate, orderid ) AS rownum, orderid, orderdate, custid, empid FROM Sales.Orders ) SELECT orderid, orderdate, custid, empid FROM C WHERE rownum BETWEEN (@pagenum - 1) * @pagesize + 1 AND @pagenum * @pagesize ORDER BY rownum: orderid orderdate custid empid 10298 2006-09-05 00:00:00.000 37 6 10299 2006-09-06 00:00:00.000 67 4 10300 2006-09-09 00:00:00.000 49 2 10301 2006-09-09 00:00:00.000 86 8 10302 2006-09-10 00:00:00.000 76 4 10303 2006-09-11 00:00:00.000 30 7 10304 2006-09-12 00:00:00.000 80 1 10305 2006-09-13 00:00:00.000 55 8 10306 2006-09-16 00:00:00.000 69 1 10307 2006-09-17 00:00:00.000 48 2 10308 2006-09-18 00:00:00.000 2 7 10309 2006-09-19 00:00:00.000 37 3 10310 2006-09-20 00:00:00.000 77 8 10311 2006-09-20 00:00:00.000 18 1 10312 2006-09-23 00:00:00.000 86 2 10313 2006-09-24 00:00:00.000 63 2 10314 2006-09-25 00:00:00.000 65 1
Решения на основе Т-SQL с использованием оконных функций Глава 5 161 10315 2006-09-26 00:00:00.000 38 4 10316 2006-09-27 00:00:00.000 65 1 10317 2006-09-30 00:00:00.000 48 6 10318 2006-10-01 00:00:00.000 38 8 10319 2006-10-02 00:00:00.000 80 7 10320 2006-10-03 00:00:00.000 87 5 10321 2006-10-03 00:00:00.000 38 3 10322 2006-10-04 00:00:00.000 58 7 На рис. 5-1 показан план выполнения этого запроса. Qu«ry 1. Query cost (relitive to the bitch) 100® WITH C AS ( SELECT ROWJilJMBEBO 0Ю( OCDEB BY orderdite. orderid ) AS rownum. orderid. orderdite. custid. empid FPCM Siler Orders ) SELECT orderid. Sequence Project (Compute Scilir) Cost: 0 К Actual Number of Rows 25 Estimated Number of Rows 9 Estimated Row Size 35 В Estimated Data Size 315 В Index Scin (NonClustered) [Orders]. [idx_od_oid_1_cid_eid] Cost: 87 Ж Actual Number of Rows_____________75 Estimated Number of Rows__________100 Estimated Row Size 27 В Estimated Data Size 2700 В Рис. 5-1. План выполнения запроса с функцией ROW_NUMBER Заметьте, что из-за того, что индекс обеспечил поддержку при вычисле- нии ROW_NUMBER, серверу SQL Server не потребовалось просматривать все строки таблицы. Вместо этого он просмотрел только первые 75 строк индекса и отфильтровал строки с номерами с 51 по 75. Легко понять, что в отсутствие такого индекса SQL Server пришлось бы просмотреть и отсорти- ровать все строки, назначить им номера, а затем отфильтровать. Так что в данной ситуации индексирование оказывается как нельзя более кстати. Описанную методику, основанную на номерах строк, можно применять, начиная с версии SQL Server 2005. В SQL Server 2012 есть альтернативное решение, в котором используется новый параметр OFFSET/FETCH. Этот параметр похож на ТОР за исключением того, что предусмотрен стандар- том, поддерживает пропуски строк и является частью предложения ORDER BY. Вот код фильтрации для получения нужной страницы строк с исполь- зованием параметра OFFSET/FETCH, в качестве входных данных служат номер и размер страницы: DECLARE @pagenum AS INT = 3, @pagesize AS INT = 25; SELECT orderid, orderdate, custid, empid FROM Sales.Orders ORDER BY orderdate, orderid OFFSET (@pagenum - 1) * @pagesize ROWS FETCH NEXT @pagesize ROWS ONLY; План этого запроса показан на рис. 5-2.
162 Глава 5 Решения на основе Т-SQL с использованием оконных функций Query 1: Query cost (relative to the bitch) Ю0И SELECT orderid. orderdite, custid. empid FROM Siles.Orders ORDER BY orderdite. Рис. 5-2. План выполнения запроса с параметром OFFSET/FETCH Заметьте, что план выполнения оптимизируется аналогично тому, как это происходит методике с использованием номеров строк — в том смысле, что SQL Server просматривает только первые 75 строк индекса и отфильтровы- вает только последние 25. В результате число операций чтения в этих двух случаях примерно одинаково. По завершении надо выполнить следующий код очистки: DROP INDEX idx_od_oid_i_cid_eid ON Sales.Orders; Удаление дубликатов Потребность в устранении дубликатов из данных встречается очень часто, особенно при решении проблем с качеством данных в средах, где дублирова- ние возникло из-за отсутствия ограничений, которые могли бы обеспечить уникальность данных. Для демонстрации подготовим с помощью следую- щего кода пример данных с дублирующимися заказами в таблице по имени MyOrders: IF OBJECT_ID(’Sales.MyOrders’) IS NOT NULL DROP TABLE Sales.MyOrders; GO SELECT * INTO Sales.MyOrders FROM Sales.Orders UNION ALL SELECT * FROM Sales.Orders UNION ALL SELECT * FROM Sales.Orders; Представьте, что вам нужно устранить дублирование данных, оставив только по одному экземпляру с уникальным значением ordered. Дублирую-
Решения на основе Т-SQL с использованием оконных функций Глава 5 163 щиеся номера отмечаются с помощью функции ROW NUMBER с секцио- нированием по предположительно уникальному значению (в нашем случае orderid) и с использованием произвольного упорядочения, если вам неваж- но, какую строку оставить, а какую удалить. Вот код, в котором функция ROW_NUMBER отмечает дубликаты: SELECT orderid, ROW_NUMBER() OVER(PARTITION BY orderid ORDER BY (SELECT NULL)) AS n FROM Sales.MyOrders; orderid n 10248 1 10248 2 10248 3 10249 1 10249 2 10249 3 10250 1 10250 2 10250 3 Затем нужно рассмотреть разные варианты в зависимости от количе- ства строк, которые нужно удалить, процента размерности таблицы, какое это количество составляет, активности производственной среды и других обстоятельств. При небольшом числе удаляемых строк обычно достаточно использовать операцию удаления с полным протоколированием, в которой удаляются все экземпляры, у которых номер строки больше единицы: WITH С AS ( SELECT orderid, ROW_NUMBER() OVER(PARTITION BY orderid ORDER BY (SELECT NULL)) AS n FROM Sales.MyOrders ) DELETE FROM C WHERE n > 1; Но если число удаляемых строк большое — особенно когда оно состав- ляет большую долю строк таблицы, удаление с полной записью операции в журнале будет слишком медленным. В этом случае стоит подумать об ис- пользовании операции неполного протоколирования, такой как SELECT INTO, для копирования уникальных строк (с номером «1») в другую табли- цу. После этого оригинальная таблица удаляется, затем новой таблице при- сваивается имя удаленной таблицы, воссоздаются ограничения, индексы и триггеры. Вот код законченного решения:
164 Глава 5 Решения на основе Т-SQL с использованием оконных функций WITH С AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY orderid ORDER BY (SELECT NULL)) AS n FROM Sales.MyOrders ) SELECT orderid, custid. empid. orderdate, requireddate, shippeddate, shipperid. freight, shipname, shipaddress, shipcity, shipregion, shippostalcode, shipcountry INTO Sales.OrdersTmp FROM C WHERE n = 1; DROP TABLE Sales.MyOrders; EXEC sp_rename 1 Sales.OrdersTmp’, ‘MyOrders’; -- воссоздание индексов, ограничений, триггеров Для простоты я не добавил сюда никакого контроля транзакций, но нуж- но всегда помнить, что с данными могут одновременно работать несколь- ко пользователей. При реализации этого метода в производственной среде нужно соблюдать следующую последовательность: 1. Открыть транзакцию. 2. Получить блокировку таблицы. 3. Выполнить инструкцию SELECT INTO. 4. Удалить и переименовать объекты. 5. Воссоздать индексы, ограничения и триггеры. 6. Зафиксировать транзакцию. Есть еще один вариант, о котором я узнал от Хавьера Л ориа (Javier Loria), — отфильтровать только уникальные или только неуникальные строки. Вычисляются обе функции — ROW NUMBER и RANK — на основе упо- рядочения по ordered, примерно так: SELECT orderid, ROW_NUMBER() OVER(ORDER BY orderid) AS rownum, RANKO OVER(ORDER BY orderid) AS rnk FROM Sales.MyOrders: orderid rownum rnk 10248 1 1 10248 2 1 10248 3 1 10249 4 4 10249 5 4 10249 6 4
Решения на основе Т-SQL с использованием оконных функций Глава 5 165 10250 7 7 10250 8 7 10250 9 7 Заметьте, что в результатах только в одной строке для каждого уникаль- ного значения в orderid совпадают номер и ранг строки. К примеру, если надо удалить небольшую часть данных, можно инкапсулировать предыдущий за- прос в определение СТЕ, а во внешнем запросе выполнить инструкцию уда- ления строк, у которых разные номер строки и ранг: WITH С AS ( SELECT orderid, ROW_NUMBER() OVER(ORDER BY orderid) AS rownum, RANKO OVER(ORDER BY orderid) AS rnk FROM Sales.MyOrders ) DELETE FROM C WHERE rownum <> rnk; Приведенные решения не являются единственно возможными. В частно- сти, есть сценарии, в которые предпочтительнее разбить крупную операцию удаления на пакеты с помощью параметра ТОР. Но здесь я сосредоточился на решениях с использованием оконных функций. По завершении надо выполнить следующий код очистки: IF OBJECT_ID('Sales.MyOrders') IS NOT NULL DROP TABLE Sales.MyOrders; Сведение Сведение (pivoting) является методикой агрегирования и поворота данных из строк в столбцы. При сведении нужно определить три элемента: элемент, который нужно видеть в строках (элемент группировки), элемент, который нужно видеть в столбцах (элемент распределения) и элемент, который нуж- но видеть в разделе данных (элемент агрегирования). В качестве примера представьте, что нужно получить представление Sales.OrderValues и вернуть по строке на каждый год, по строке на каждый месяц и общую сумму всех сумм заказов для пересечений годов и месяцев. В этом запросе элементом группировки по строкам является YEAR(orderdate), элементом распределения по столбцам является MONTH (orderdate), уни- кальными значениями распределения являются 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11 и 12, а элемент данных, или агрегации, является SUM(val). Для получения сведения нужно прежде всего подготовить табличное вы- ражение, такое как СТЕ, которое возвращает только три элемента, задей- ствованные в задаче сведения. Затем во внешней инструкции запрашива- ется табличное выражение и используется оператор для обработки логики сведения (выходные данные выровнены):
166 Глава 5 Решения на основе Т-SQL с использованием оконных функций WITH С AS ( SELECT YEAR(orderdate) AS orderyear, MONTH(orderdate) AS ordermonth, val FROM Sales.OrderValues ) SELECT * FROM C PIVOT(SUM(val) FOR ordermonth IN ([1],[2],[3],[4],[5],[6],[7],[8],[9],[10],[11],[12])) AS P; orderyear 1 2 3 4 5 6 2007 61258.08 38483.64 38547. 23 53032.95 53781. 30 36362.82 2008 94222.12 99415.29 104854 • . 18 123798.70 18333. 64 NULL 2006 NULL NULL NULL NULL NULL NULL orderyear 7 8 9 10 11 12 2007 51020.86 47287.68 55629. 27 66749.23 43533.80 71398.44 2008 NULL NULL NULL NULL NULL NULL 2006 27861.90 25485.28 26381. 40 37515.73 45600.05 45239.63 В данном случае все три элемента сведения известны, в том числе уни- кальные значения в элементе распределения (месяцы). Но есть случаи све- дения, когда элемент распределения заранее не существует и его требует- ся вычислить. Например, представьте запрос, который должен вернуть для каждого клиента идентификаторы самих последних пяти заказов. Хочется видеть идентификаторы клиентов в строках и идентификаторы заказов в виде данных, но у идентификаторов заказов нет ничего общего с клиентами, которых можно было бы использовать в качестве элемента распределения. Решение заключается в использовании функции ROW NUMBER, кото- рая присваивает порядковые номера в секциях клиентов с использованием нужного упорядочения — в нашем случае по orderdate DESC и orderid DESC. Затем атрибут, содержащий номер строки, может использоваться как эле- мент распределения, и порядковые номера могут вычисляться как значения распределения. Поэтому для начала приведу код, который генерирует номера строк кли- ентских заказов с самого последнего до самого старого: SELECT custid, val, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rownum FROM Sales.OrderValues; custid val rownum 1 933.50 1
Решения на основе Т-SQL с использованием оконных функций Глава 5 167 1 471.20 2 1 845.80 3 1 330.00 4 1 878.00 5 1 814.50 6 2 514.40 1 2 320.00 2 2 479.75 3 2 88.80 4 3 660.00 1 3 375.50 2 3 813.37 3 3 2082.00 4 3 1940.85 5 3 749.06 6 3 403.20 7 Теперь можно определить выражение СТЕ на основе этого запроса, а за- тем во внешнем запросе организовать логику сведения с использованием rownum в качестве элемента распределения: WITH С AS ( SELECT custid, val, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rownum FROM Sales.OrderValues ) SELECT * FROM C PIV0T(MAX(val) FOR rownum IN ([1],[2],[3],[4],[5])) AS P; custid 1 2 3 4 5 1 933.50 471.20 845.80 330.00 878.00 2 514.40 320.00 479.75 88.80 NULL 3 660.00 375.50 813.37 2082.00 1940.85 4 491.50 4441.25 390.00 282.00 191.10 5 1835.70 709.55 1096.20 2048.21 1064.50 6 858.00 677.00 625.00 464.00 330.00 7 730.00 660.00 450.00 593.75 1761.00 8 224.00 3026.85 982.00 NULL NULL 9 792.75 360.00 1788.63 917.00 1979.23 10 525.00 1309.50 877.73 1014.00 717.50
168 Глава 5 Решения на основе Т-SQL с использованием оконных функций Если для каждого клиента нужно конкатенировать в одну строку иденти- фикаторы последних пяти заказов, можно воспользоваться появившейся в SQL Server 2012 функцией CONCAT: WITH С AS ( SELECT custid. CAST(orderid AS VARCHAR(11)) AS sorderid, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rownum FROM Sales.OrderValues ) SELECT custid, C0NCAT([1], ’.'+[2]. ,,’+[3], ','+[4], +[5]) AS orderids FROM C PIVOT(MAX(sorderid) FOR rownum IN ([1],[2],[3],[4], [5])) AS P; custid orderids 1 11011.10952.10835.10702,10692 2 10926,10759,10625.10308 3 10856,10682.10677.10573,10535 4 11016, 10953,10920,10864,10793 5 10924. 10875,10866,10857,10837 6 11058,10956,10853.10614 10582 7 10826,10679.10628,10584.10566 8 10970,10801.10326 9 11076,10940,10932.10876,10871 10 11048. 11045,11027,10982,10975 Функция CONCAT автоматически заменяет NULL на пустую строку. Для получения такого же результата в более ранних версиях SQL Server придет- ся воспользоваться оператором конкатенации «+» и функцией COALESCE, которая заменит значения NULL на пустую строку: WITH С AS SELECT custid, CAST(orderid AS VARCHAR(11)) AS sorderid, ROW_NUMBER() 0VER(PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rownum FROM Sales.OrderValues ) SELECT custid, [1] + COALESCED, ’+[2]. ”) + COALESCE^, '+[3]. ”) + COALESCE(•,'+[4]. ’’) + COALESCEC. +[5]. ”) AS orderids FROM C PIVOT(MAX(sorderid) FOR rownum IN ([1 ].[2].[3],[4],[5])) AS P;
Решения на основе Т-SQL с использованием оконных функций Глава 5 169 Выбор первых п элементов в группе Такая очень часто встречающая задача предусматривает фильтрацию опре- деленного числа строк из каждой группы или секции на основе определен- ного упорядочения. Запрос таблицы Sales.Orders, возвращающий три самых последних заказа для каждого клиента — пример задачи выбора первых не- скольких элементов в группе. В данном случае элементом секционирования является custid, упорядочение выполняется по orderdate DESC, orderid DESC (самые последние заказы), а Nравно 3. Старый параметр ТОР и более новый OFFSET/FETCH поддерживают указание параметров фильтрации и упоря- дочения, но не поддерживают предложение секционирования. Представьте, как было бы здорово, если бы они позволяли в определении фильтра задать предложение секционирования и упорядочения, например так: SELECT ТОР (3) OVER( PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) custid, orderdate, orderid, empid FROM Sales.Orders; К сожалению, такой синтаксис не поддерживается, и задачу придется ре- шать другими способами. | _ Е| Примечание Я направил в службу поддержки Microsoft запрос на реализацию под- держки синтаксиса TOP OVER. Мой запрос размещен по адресу: http://connect.microsoft. com/SQLServer/feedback/details/254390/over-clauseenhancement-request-top-over. Независимо от используемого решения, общие рекомендации по индек- сированию укладываются в концепцию РОС (см. главу 4), названную по на- чальным буквам слов partitioning (секционирование), ordering (упорядоче- ние) и covering (покрытие). Список ключей индексов определяется на осно- ве столбцов секционирования (в нашем случае это custid}, за которыми сле- дуют столбцы упорядочения (в нашем случае orderdate DESC, orderid DESC), и для целей покрытия он включает остальные столбцы, которые содержатся в запросе. Ясно, что если индекс является кластеризованным, в любом слу- чае покрываются все столбцы таблицы, поэтому не нужно волноваться о ча- сти «С» РОС-индекса. Вот код, который генерирует РОС-индекс для нашей задачи в предположении, что empid — единственный столбец, который оста- лось вернуть, помимо custid, orderdate и orderid'. CREATE UNIQUE INDEX idx_cid_odD_oidD_i_empid ON Sales.Orders(custid, orderdate DESC, orderid DESC) INCLUDE(empid); При наличии РОС-индекса решать задачу можно двумя способами: с помощью функции ROW_NUMBER или оператора APPLY и параметра OFFSET/FETCH или ТОР. Выбор способа зависит от плотности столбца
170 Глава 5 Решения на основе Т-SQL с использованием оконных функций секционирования (в нашем случае custid). При низкой плотности, то есть большом числе уникальных клиентов с небольшим числом заказов, больше подходит решение на основе ROW NUMBER. Номера строкам присваива- ются с применением тех же требований по секционированию и упорядоче- нию, что и в запросе, после чего отфильтровываются только строки с номе- рами, меньшими или равными числу строк, которые надо получить в каждой группе. Вот полное решение, реализующее этот подход: WITH С AS ( SELECT custid, orderdate, orderid. empid, ROW_NUMBER() OVER( PARTITION BY custid ORDER BY orderdate DESC, orderid DESC) AS rownum FROM Sales.Orders ) SELECT * FROM C WHERE rownum <= 3 ORDER BY custid, rownum; На рис. 5-3 показан план выполнения этого запроса. Query 1: Query cost (relitive to the bitch) Ю0К WITH C AS ( SELECT custid, orderdite. orderid. enpid. ROWJWMBERQ OVER( PARTITION BY custid ORDER BY orderdite DESC. orderid DESC) . SELECT Cost: О К Sequence Project (Compute Sellir) Cost: 1 К Index Scin (NonClustered) [Orders] . [idx_cid_odD_oidD_i_enpid] Cost: 92 К Рис. 5-3. План выполнения запроса при низкой плотности Высокая эффективность этого способа при низкой плотности столбца сек- ционирования (как вы помните, речь идет о большом числе уникальных кли- ентов, у каждого из которых небольшое число заказов) обусловлена тем, что план предусматривает только один упорядоченный просмотр РОС-индекса. В таком случае не нужно выполнять операцию поиска в индексе для каждо- го уникального значения секционирования (клиента). Однако при высокой плотности столбца секционирования (небольшое число уникальных кли- ентов, у каждого их которых много заказов) план, предусматривающий по- иск по индексу для каждого клиента, становится предпочтительнее полного просмотра конечной страницы индекса. Чтобы получить такой план, надо запрашивать таблицу, которая содержит уникальные значения, по которым выполняется секционирование, (в нашем случае Sales.Customers) и приме- нить оператор APPLY, чтобы инициировать запрос с параметром OFFSET/ FETCH или ТОР для каждого клиента: SELECT С.custid, А.* FROM Sales.Customers AS С CROSS APPLY (SELECT orderdate, orderid. empid
Решения на основе Т-SQL с использованием оконных функций Глава 5 171 FROM Sales.Orders AS О WHERE 0.custid = C.custid ORDER BY orderdate DESC, orderid DESC OFFSET 0 ROWS FETCH FIRST 3 ROWS ONLY) AS A; План этого запроса показан на рис. 5-4. Query 1: Query cost (relative to the bitch): Ю0Й SELECT C. custid. A.* FROM Siles.Customers AS C CROSS APPLY (SELECT orderdite, orderid, empid FROM Siles.Orders AS 0 WHER Cost: 81 Й Рис. 5-4. План выполнения запроса при высокой плотности Обратите внимание, что в плане индекс таблицы Customers просматривает- ся для получения идентификаторов всех клиентов. Затем для каждого клиен- та в плане предусмотрена операция поиска в РОС-индексе (переход на начало секции текущего клиента в концевой странице индекса), а затем на концевой странице индекса считываются три строки с самыми последними заказами. Как вы помните, параметр OFFSET/FETCH появился в SQL Server 2012. В более ранних версиях SQL Server вместо него можно задействовать пара- метр ТОР: SELECT С.custid, А. * FROM Sales.Customers AS С CROSS APPLY (SELECT TOP (3) orderdate, orderid, empid FROM Sales.Orders AS 0 WHERE 0.custid = C.custid ORDER BY orderdate DESC, orderid DESC) AS A; Заметьте, что для повышения производительности в обоих способах ис- пользуется РОС-индекс. Если этого индекса нет или вы не можете или не хотите его создавать, есть третий способ, который обычно обеспечивает бо- лее высокую производительность, чем предыдущие два. Но этот способ при- меним, только если W равно единице. А сейчас удалим РОС-индекс: DROP INDEX idx_cid_odD_oidD_i_empid ON Sales.Orders; В третьем способ реализована методика, которую можно назвать парал- лельной сортировкой. Я представил эту методику в главе 3 при обсуждении функций смещения. Идея заключается в том, чтобы создать в каждой секции по строке, в которую конкатенируются сначала упорядочивающие элементы, а затем все элементы, которые необходимо возвратить. Важно использовать именно конкатенацию, в результате которой получается строка, которая со-
172 Глава 5 Решения на основе Т-SQL с использованием оконных функций ртируется так же, как должна выполняться сортировка по элементам упо- рядочения. В нашем случае, упорядочение выполняется по orderdate DESC и orderid DESC. Первый элемент является датой. Чтобы получить строковое представле- ние даты, которая сортируется так же, как исходная дата, нужно перевести дату в форму YYYYMMDD, где YYYY — год, ММ — месяц и DD — день. Для этого воспользуемся функцией CONVERT со стилем 112. Что касается элемента ordered, то это положительное целое. Чтобы символьное строко- вое представление числа сортировалось так же, как исходное целое, нужно отформатировать его как строку фиксированной ширины с ведущими про- белами или нулями. Отформатировать целую величину как строку фикси- рованной длины можно с помощью функции STR. Решение предусматривает группировку строк по столбцу секционирования и вычисления верхней (максимальной) конкатенированной строки в группе. Верхняя строка содержит конкатенированные элементы строки, которую нужно возвратить. Затем на основе последнего запроса нужно создать СТЕ- выражение. Затем во внешнем запросе используем функции SUBSTRING для извлечения отдельных ранее конкатенированных элементов и преобразо- вания их в исходные типы. Вот как выглядит готовое решение: WITH С AS ( SELECT custid, MAX(CONVERT(CHAR(8), orderdate, 112) + STR(orderid, 10) + STR(empid, 10) COLLATE Latin1_General_BIN2) AS mx FROM Sales.Orders GROUP BY custid ) SELECT custid, CAST(SUBSTRING(mx, 1, 8) AS DATETIME) AS orderdate, CAST(SUBSTRING(mx, 9, 10) AS INT) AS custid, CAST(SUBSTRING(mx, 19, 10) AS INT) AS empid FROM C; Запрос не особо симпатичный, но план содержит только один просмотр данных и этот способ обычно производительнее других способов, если РОС- индекса нет. Помните, что если вы можете себе позволить такой индекс, этот способ вам не нужен — лучше применять описанные выше два способа, вы- бирая их в зависимости плотности столбца секционирования. Моды Мода — статистическое значение во множестве наблюдений, которое встре- чается наиболее часто. Возьмем, к примеру, таблицу Sales.Orders, которая содержит информацию о заказах. Каждый заказ был инициирован опреде-
Решения на основе Т-SQL с использованием оконных функций Глава 5 173 ленным клиентом и обслуживался каким-то сотрудником. Допустим, вы хо- тите узнать, какой сотрудник обслужил наибольшее число заказов опреде- ленного клиента. Такой сотрудник будет модой, потому что он чаще всего встречается в заказах этого клиента. Естественно, возможно дублирование, если несколько сотрудников обра- ботали наибольшее и равное число заказов определенного клиента. В зави- симости от цели можно вернуть все дубликаты, либо разбить их. Я расскажу об обоих случаях. Если нужно разбить дубликаты и в качестве отличитель- ного признака взять больший идентификатор сотрудника, тогда в качестве моды будет выбран сотрудник с максимальным идентификатором. Принцип индексации здесь простой — надо создать индекс на основе (custid, empidy. CREATE INDEX idx_custid_empid ON Sales.Orders(custid. empid); Я начну с решения, в котором используется функция ROW NUMBER. Прежде всего надо сгруппировать заказы по custid и empid, а затем вернуть число заказов в каждой группе: SELECT custid, empid, COUNT(*) AS ent FROM Sales.Orders GROUP BY custid, empid; custid empid ent 1 1 2 3 1 1 4 1 3 5 1 4 9 1 3 10 1 2 11 1 1 14 1 1 15 1 1 17 1 2 Затем надо добавить вычисление с применением ROW_NUMBER, результаты которого надо секционировать по custid и упорядочить по COUNT(*) DESC, empid DESC. У каждого клиента строке с наибольшим числом (в случае совпадений, с наибольшим идентификатором сотрудника) присваивается номер «1»: SELECT custid, empid, COUNT(*) AS ent, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY COUNT(*) DESC, empid DESC) AS rn FROM Sales.Orders GROUP BY custid, empid;
174 Глава 5 Решения на основе Т-SQL с использованием оконных функций custid empid ent rn 1 1 1 1 2 2 2 3 3 3 3 4 1 6 3 3 7 4 3 7 4 1 2 2 1 1 2 1 1 3 2 1 1 1 2 3 4 1 2 3 1 2 3 4 Наконец, надо отобрать только строки с номером «1» с помощью СТЕ: WITH С AS ( SELECT custid, empid, COUNT(*) AS ent, ROW_NUMBER() OVER(PARTITION BY custid ORDER BY COUNT(*) DESC, empid DESC) AS rn FROM Sales.Orders GROUP BY custid, empid ) SELECT custid, empid, ent FROM C WHERE rn = 1; custid empid ent 1 4 2 3 3 3 4 4 5 3 6 9 7 4 8 4 9 4 10 3 2 2 3 4 6 3 3 2 4 4 Так как для разрешения совпадений в определении упорядочения окна содержится empid DESC, нужно вернуть только по одной строке для каждого клиента. Если разрешать совпадения не нужно, вместо ROW_NUMBER ис- пользуем функцию RANK и удаляем empid из предложения упорядочения окна:
Решения на основе Т-SQL с использованием оконных функций Глава 5 175 WITH С AS ( SELECT custid, empid, COUNT(*) AS ent, RANKO 0VER(PARTITION BY custid ORDER BY COUNT(*) DESC) AS rn FROM Sales.Orders GROUP BY custid, empid ) SELECT custid, empid, ent FROM C WHERE rn = 1; custid empid ent 1 1 2 1 4 2 2 3 2 3 3 3 4 4 4 5 3 6 6 9 3 7 4 3 8 4 2 9 4 4 10 3 4 11 6 2 114 2 11 3 2 Как вы помните, в отличие от ROW_NUMBER функция RANK не разли- чает дубликаты. Это означает, что при одинаковом значении упорядочения - в нашем случае это COUNT(*) — мы получим одинаковый ранг. Поэтому все строки с наибольшим (и равным) числом заказов получат ранг «1» и попа- дут в результаты. Заметьте, что в нашем случае есть два сотрудника с иден- тификаторами 1 и 4, которые обработали наибольшее число заказов клиента 1 — по два заказа каждый, поэтому они указаны в результатах оба. Наверное вы уже поняли, что задача вычисления моды похожа на пред- ыдущую задачу выбора первых п элементов в группе. Также вспомните, что помимо решения, основанного на оконных функциях, мы использовали па- раллельную сортировку. Но этот прием работает только, если п равно едини- це, и в нашем случае это означает, что нужно разрешать совпадения. Чтобы реализовать принцип параллельной сортировки в этом случае, нужно создать конкатенированную строку, первая часть которой будет со- держать число заказов, а вторая — идентификатор сотрудника:
176 Глава 5 Решения на основе Т-SQL с использованием оконных функций SELECT custid, STR(COUNT(*), 10) + STR(empid, 10) COLLATE Latin1_General_BIN2 AS cntemp FROM Sales.Orders GROUP BY custid, empid; custid cntemp 1 3 4 5 9 10 11 14 15 17 2 1 1 1 3 1 4 1 3 1 2 1 1 1 1 1 1 1 2 1 Заметьте, что число заказов я преобразовал в сегменты с фиксированной длиной, а в идентификатор сотрудника добавил ведущие пробелы, чтобы строки сортировались точно в том же порядке, что и исходные целые значе- ния. Преобразование в двоичную сортировку обеспечивает более эффектив- ное сравнение строк. Далее на основе этого запроса надо определить СТЕ, а затем во внеш- нем запросе сгруппировать строки по клиенту и определить максимальную (первую) строку в группе. В конце надо разбить строку на компоненты и привести их к исходным типам: WITH С AS ( SELECT custid, STR(COUNT(*), 10) + STR(empid, 10) COLLATE Latin1_General_BIN2 AS cntemp FROM Sales.Orders GROUP BY custid, empid ) SELECT custid, CAST(SUBSTRING(MAX(cntemp), 11, 10) AS INT) AS empid, CAST(SUBSTRING(MAX(cntemp), 1, 10) AS INT) AS ent FROM C GROUP BY custid; custid empid ent 1 2 4 3 2 2
Решения на основе Т-SQL с использованием оконных функций Глава 5 177 3 4 5 6 7 8 9 10 4 3 9 4 4 4 3 3 4 6 3 3 2 4 4 Как говорилось в разделе, посвященном получении первых значений в группе, в решении на основе оконных функций обеспечивает хорошую про- изводительность, если есть индекс, поэтому нет смысла использовать более сложное решение с параллельной сортировкой. Но если индекса нет, это толь- ко что описанное решение обеспечивает более высокую производительность. По завершении надо выполнить следующий код очистки: DROP INDEX idx_custid_empid ON Sales.Orders; Вычисление нарастающих итогов Это еще одна часто встречающаяся задача. Основной принцип заключается в накоплении значений одного атрибута (агрегируемого элемента) на основе упорядочения по другому атрибуту или атрибутам (элемент упорядочения), возможно при наличии секций строк, определенных на основе еще одного атрибута или атрибутов (элемент секционирования). В жизни существует много примеров вычисления нарастающих итогов, например вычисление остатков на банковских счетах, отслеживание наличия товаров на складе или текущих цифр продаж и т. п. До SQL Server 2012 решения, основанные на наборах и используемые для вычисления нарастающих итогов, были исключительно ресурсоемкими. Поэтому люди обычно обращались к итеративным решениями, которые ра- ботали небыстро, но в некоторых ситуациях все-таки быстрее, чем решения на основе наборов. Благодаря расширению поддержки оконных функций в SQL Server 2012, нарастающие итоги можно вычислять, используя простой основанный на наборах код, производительность которого намного выше, чем в старых решениях на основе Т-SQL — как основанных на наборах, так и итеративных. Я мог бы показать новое решение и перейти к следую- щему разделу, но чтобы вы по-настоящему поняли масштаб изменений, я опишу старые способы и сравню их производительность с новым подходом. Естественно, вы вправе прочитать только первую часть, описывающую но- вый подход, и пропустить остальную часть раздела. Для демонстрации разных решений я воспользуюсь остатками на счетах. Вот код, который создает и наполняет таблицу Transactions небольшим объ- емом тестовых данных: 7 Зак 601
178 Глава 5 Решения на основе Т-SQL с использованием оконных функций SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID('dbo.Transactions’, ’U’) IS NOT NULL DROP TABLE dbo. Transactions; CREATE TABLE dbo.Transactions ( actid INT NOT NULL, -- столбец секционирования tranid INT NOT NULL, -- столбец упорядочения val MONEY NOT NULL, -- мера CONSTRAINT PK_Transactions PRIMARY KEY(actid, tranid) ); GO -- небольшой набор тестовых данных INSERT INTO dbo.Transactions(actid, tranid, val) VALUES (1, 1, 4.00), (1, 2, -2.00), (1, 3, 5.00), (1, 4, 2.00), (1, 5, 1.00), (1, 6, 3.00), (1, 7, -4.00), (1, 8, -1.00), (1, 9, -2.00), (1, 10, -3.00), (2, 1, 2.00), (2, 2, 1.00), (2, 3, 5.00), (2, 4, 1.00), (2, 5, -5.00), (2, 6, 4.00), (2, 7, 2.00), (2, 8, -4.00), (2, 9, -5.00), (2, 10, 4.00), (3, 1, -3.00), (3, 2, 3.00), (3, 3, -2.00), (3, 4, 1.00), (3, 5, 4.00), (3, 6, -1.00), (3, 7, 5.00), (3, 8, 3.00), (3, 9, 5.00), (3, 10, -3.00);
Решения на основе Т-SQL с использованием оконных функций Глава 5 179 Каждая строка таблицы представляет банковскую операцию на счете. Депозиты отмечаются как транзакции с положительным значением в столб- це val, а снятие средств — как отрицательное значение транзакции. Наша за- дача — вычислить остаток на счете в каждый момент времени путем аккуму- лирования сумм операций в строке val при упорядочении по столбцу tranid, причем это нужно сделать для каждого счета отдельно. Желаемый результат должен выглядеть так: actid tranid val balance 1 1 4.00 4.00 1 2 -2.00 2.00 1 3 5.00 7.00 1 4 2.00 9.00 1 5 1.00 10.00 1 6 3.00 13.00 1 7 -4.00 9.00 1 8 -1.00 8.00 1 9 -2.00 6.00 1 10 -3.00 3.00 2 1 2.00 2.00 2 2 1.00 3.00 2 3 5.00 8.00 2 4 1.00 9.00 2 5 -5.00 4.00 2 6 4.00 8.00 2 7 2.00 10.00 2 8 -4.00 6.00 2 9 -5.00 1.00 2 10 4.00 5.00 3 1 -3.00 -3.00 3 2 3.00 0.00 3 3 -2.00 -2.00 3 4 1.00 -1.00 3 5 4.00 3.00 3 6 -1.00 2.00 3 7 5.00 7.00 3 8 3.00 10.00 3 9 5.00 15.00 3 10 -3.00 12.00 Для тестирования обоих решений нужен больший объем данных. Это можно сделать с помощью такого запроса: DECLARE @num_partitions AS INT = 10, @rows_per_partition AS INT = 10000;
180 Глава 5 Решения на основе Т-SQL с использованием оконных функций TRUNCATE TABLE dbo.Transactions; INSERT INTO dbo.Transactions WITH (TABLOCK) (actid, tranid, val) SELECT NP.n, RPP.n, (ABS(CHECKSUM(NEWID())%2)*2-1) * (1 + ABS(CHECKSUM(NEWID())%5)) FROM dbo.GetNums(1, @num_partitions) AS NP CROSS JOIN dbo.GetNums(1, @rows_per_partition) AS RPP; Можете задать свои входные данные, чтобы изменить число секций (сче- тов) и строк (транзакций) в секции. Основанное на наборах решение с использованием оконных функций Я начну рассказ с решения на основе наборов, в котором используется окон- ная функция агрегирования SUM. Определение окна здесь довольно нагляд- но: нужно секционировать окно по actid, упорядочить по tranid и фильтром отобрать строки в кадре с крайней нижней (UNBOUNDED PRECEDING) до текущей. Вот соответствующий запрос: SELECT actid, tranid, val, SUM(val) OVER(PARTITION BY actid ORDER BY tranid ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS balance FROM dbo.Transactions; Этот код не только простой и прямолинейный — он и выполняется бы- стро. План этого запроса показан на рис. 5-5. QMtrj 1: Query cost (rtlitive to the bitch): Ю0К SELECT ictid. trinid. vil. SUM(vil) OVER (PARTITION BY ictid ORDER BY trinid ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS biUnce FPCH dbo.Trinsic Streim Aggregite (Aggregite) Cost 9 « Window Spool Cost: 36 « <□ Segment Sequence Project , (Compute Scilir) f Clustered Index Scin (Clustered) [Trinsictions] . [PK_Trinsicfions] Cost. 51 X Рис. 5-5. План выполнения запроса с оконными функциями В таблице есть кластеризованный индекс, который отвечает требовани- ям РОС и пригоден для использования оконными функциями. В частно- сти, список ключей индекса основан на элементе секционирования {actid), за которым следует элемент упорядочения {tranid), также для обеспечения покрытия индекс включает все остальные столбцы в запросе {val). План со- держит упорядоченный просмотр, за которым следует вычисление номера строки для внутренних нужд, а затем — оконного агрегата. Так как есть РОС- индекс, оптимизатору не нужно добавлять в план оператор сортировки. Это очень эффективный план. К тому же он линейно масштабируется. Позже, когда я покажу результаты сравнения производительности, вы увидите, на- сколько эффективнее этот способ по сравнению со старыми решениями.
Решения на основе Т-SQL с использованием оконных функций Глава 5 181 Основанные на наборах решения с использованием вложенных запросов и соединений В традиционных основанных на наборах решениях для вычисления нарас- тающих итогов до SQL Server 2012 использовались либо вложенные запро- сы, либо соединения. При использовании вложенного запроса нарастающие итоги вычисляются путем фильтрации всех строк с тем же значением actid, что и во внешней строке, и значением tranid, которое меньше или равно зна- чения во внешней строке. Затем к отфильтрованным строкам применяется агрегирование. Вот соответствующий запрос: SELECT actid, tranid, val. (SELECT SUM(T2.val) FROM dbo.Transactions AS T2 WHERE T2.actid = T1.actid AND T2.tranid <= T1.tranid) AS balance FROM dbo.Transactions AS T1; Аналогичный подход можно реализовать с применением соединений. Используется тот же предикат, что и в предложении WHERE вложенного запроса в предложении ON соединения. В этом случае для N-ой транзакции одного и того же счета А в экземпляре, обозначенном как Т1, вы будете нахо- дить N соответствий в экземпляре Т2, при этом номера транзакций пробега- ют от 1 до N. В результате сопоставления строки в Т1 повторяются, поэтому нужно сгруппировать строки по всем элементам с Т1, чтобы получить ин- формацию о текущей транзакции и применить агрегирование к атрибуту val из Т2 для вычисления нарастающего итога. Готовый запрос выглядит при- мерно так: SELECT Т1.actid, Т1.tranid, Tl.val, SUM(T2.val) AS balance FROM dbo.Transactions AS T1 JOIN dbo.Transactions AS T2 ON T2.actid = T1.actid AND T2.tranid <= T1.tranid GROUP BY T1.actid, T1.tranid, Tl.val; На рис. 5-6 приведены планы обоих решений. Заметьте, что в обоих случаях в экземпляре Т1 выполняется полный про- смотр кластеризованного индекса. Затем для каждой строки в плане преду- смотрена операция поиска в индексе начала раздела текущего счета на ко- нечной странице индекса, при этом считываются все транзакции, в которых Т2.tranid меньше или равно Т1.tranid. Точка, где происходит агрегирование строк, немного отличается в планах, но число считанных строк одинаково.
182 Глава 5 Решения на основе Т-SQL с использованием оконных функций Que г; 1: Querj cost (r.litiv. to the bitch) : 31* - Set-Bised Solution Using Subqueries SELECT ictid. tnnid. xil. (SELECT SUM(T2 xil) FROM dbo Tnnsictions AS Ti WHERE TZ.ictid Tl.ictid AMD T2.tnnid о Tl (Cither Streims) Cost: D X (Inner loin) Clustered Index Scin (Clustered) [Tnnsictions] . [PK_Trinsictions] [Tl] Streim Aggregite ’ Clustered Index Seek (Clustered) (Aggregite) [Tnnsictions] . [PK_Tnnsictions] [T2] Quer, 2: Querj; cost (relitive to the bitch): 69* SELECT Tl.ictid. Tl.tnnid. Tl vil. SUM(TZ.vil) AS bilince FROM dbo. Tnnsictions AS Tl 10Д dbo Trinsictions AS T2 ON Ti.ictid . Tl.ictid AHD Ti.trinid <= Tl.ti (Aggregite) Cost: 0 X (Repirtition Streims) Cost. 0 * Streim Aggregite (Aggregite) Clustered Index Scin (Clustered) [Tnnsictions] . [PK_Tnnsictions] [Tl] Cost: 0 X Nested Loops (Inne Clustered Index Seek (Clustered) Рис. 5-6. Планы выполнения запросов с использованием вложенных запросов и соединений Чтобы понять, сколько строк просматривается, надо учесть число эле- ментов данных. Пусть р — число секций (счетов), а г — число строк в секции (транзакции). Тогда число строк в таблице примерно равно рг, если считать, что транзакции распределены по счетам равномерно. Таким образом, при- веденный в верхней части просмотр охватывает рг строк. Но больше всего нас интересует происходящее в итераторе Nested Loops. В каждой секции план предусматривает чтение 1 + 2 + ... + г строк, что в сумме составляет (г + т2) / 2. Общее количество обрабатываемых в планах строк составляет рг + p(r + т2) / 2. Это означает, что число операций в плане растет в квадрате с увеличением размера секции, то есть если увеличить размер секции в f раз, объем работы увеличится примерно в f2 раз. Это плохо. Для примера 100 строкам соответствует 10 тыс. строк, а тысяче строк соответствует миллион и т. д. Проще говоря это приводит к сильному замедлению выполнения за- просов при немаленьком размере секции, потому что квадратичная функция растет очень быстро. Подобные решения работают удовлетворительно при нескольких десятках строк на секцию, но не больше. Решения с использованием курсора Решения на основе курсора реализуются «в лоб». Объявляется курсор на основе запроса, упорядочивающего данные по actid и tranid. После этого вы- полняется итеративный проход записей курсора. При обнаружении нового счета сбрасывается переменная, содержащая агрегат. В каждой итерации в переменную добавляется сумма новой транзакции, после этого строка сохра- няется в табличной переменной с информацией о текущей транзакции плюс текущее значение нарастающего итога. После итеративного прохода возвра- щается результат из табличной переменной. Вот код законченного решения: DECLARE @Result AS TABLE ( actid INT,
Решения на основе Т-SQL с использованием оконных функций Глава 5 183 tranid INT, val MONEY, balance MONEY ); DECLARE @actid AS INT, @prvactid AS INT, @tranid AS INT, @val AS MONEY, @balance AS MONEY; DECLARE C CURSOR FAST.FORWARD FOR SELECT actid, tranid, val FROM dbo.Transactions ORDER BY actid, tranid; OPEN C FETCH NEXT FROM C INTO @actid, @tranid, @val; SELECT @prvactid = @actid, ^balance = 0; WHILE @@fetch_status = 0 BEGIN IF @actid <> @prvactid SELECT @prvactid = @actid, @balance = 0; SET @balance = @balance + @val; INSERT INTO @Result VALUES(@actid, @tranid, @val, @balance); FETCH NEXT FROM C INTO @actid, @tranid, @val; END CLOSE C; DEALLOCATE C; SELECT * FROM @Result; План запроса с использованием курсора показан на рис. 5-7. Query 1: Query cost (relative to the bitch) : 100Й SELECT ictid. trinid. vil FROM dbo. Trinsictions ORDER BY ictid. trinid; Clustered Index Scin (Clustered) [Trinsictions]. [PK_Trinsictions] Cost: 100 « Рис. 5-7. План запроса с использованием курсора
184 Глава 5 Решения на основе Т-SQL с использованием оконных функций Этот план масштабируется линейно, потому что данные из индекса про- сматриваются только раз в определенном порядке. Также у каждой операции получения строки из курсора примерно одинаковая стоимость в расчете на каждую строку Если принять нагрузку, создаваемую при обработке одной строки курсора, равной о, стоимость этого решения можно оценить как рг + pro (как вы помните, р — это число секций, а г — число строк в секции). Так что, если увеличить число строк на секцию в f раз, нагрузка на систе- му составит prf + prfo, то есть будет расти линейно. Стоимость обработки в расчете на строку высока, но из-за линейного характера масштабирования, с определенного размера секции это решение будет демонстрировать лучшую масштабируемость, чем решения на основе вложенных запросов и соеди- нений из-за квадратичного масштабирования этих решений. Проведенное мной измерение производительности показало, что число, когда решение с курсором работает быстрее, равно нескольким сотням строк на секцию. Несмотря на выигрыш в производительности, обеспечиваемый решения- ми на основе курсора, в общем случае их надо избегать, потому что они не являются реляционными. Решения на основе CLR Одно возможное решение на основе CLR (Common Language Runtime) по сути является одной из форм решения с использованием курсора. Разница в том, что вместо использования курсора Т-SQL, который тратит много ресур- сов на получение очередной строки и выполнение итерации, применяются итерации .NET SQLDataReader и .NET, которые работают намного быстрее. Одна из особенностей CLR которая делает этот вариант быстрее, заключа- ется в том, что результирующая строка во временной таблице не нужна — результаты пересылаются напрямую вызывающему процессу. Логика реше- ния на основе CLR похожа на логику решения с использованием курсора и Т-SQL. Вот код .NET, определяющий хранимую процедуру решения: using System; using System.Data; using System.Data.SqlClient; using System.Data.SqlTypes; using Microsoft.SqlServer.Server; public partial class StoredProcedures { [Microsoft.SqlServer.Server.SqlProcedure] public static void AccountBalances() { using (SqlConnection conn = new SqlConnection(“context connection=true;”)) SqlCommand comm = new SqlCommand();
Решения на основе Т-SQL с использованием оконных функций Глава 5 185 comm.Connection = conn; comm.CommandText = + "SELECT actid, tranid, val “ + “FROM dbo.Transactions “ + "ORDER BY actid, tranid;"; SqlMetaData[] columns = new SqlMetaData[4]; columns[0] = new SqlMetaData(“actid" , SqlDbType.Int): columns[1] = new SqlMetaData(“tranid" , SqlDbType.Int): columns[2] = new SqlMetaData(“val" , SqlDbType.Money); columns[3] = new SqlMetaData(“balance", SqlDbType.Money); SqlDataRecord record = new SqlDataRecord(columns); SqlContext.Pipe.SendResultsStart(record); conn.0pen(); SqlDataReader reader = comm.ExecuteReader(); Sqllnt32 prvactid = 0; SqlMoney balance = 0; while (reader. ReadO) { Sqllnt32 actid = reader.GetSqlInt32(0); SqlMoney val = reader.GetSqlMoney(2); if (actid == prvactid) { balance += val; else { balance = val; prvactid = actid; record.SetSqlInt32(0, reader.GetSqlInt32(0)); record.SetSqlInt32(1, reader.GetSqlInt32(1)); record.SetSqlMoney(2, val); record.SetSqlMoney(3, balance); SqlContext.Pipe.SendResultsRow(record): }
186 Глава 5 Решения на основе Т-SQL с использованием оконных функций SqlContext.Pipe.SendResultsEnd(); } } }; Чтобы иметь возможность выполнить эту хранимую процедуру в SQL Server, сначала надо на основе этого кода построить сборку по име- ни AccountBalances и развернуть в базе данных TSQL2012. Если вы не знакомы с развертыванием сборок в SQL Server, можете почитать раздел «Развертывание объектов базы данных CLR» в электронной документа- ции по SQL Server по адресу http://technet.microsoft.com/ru-ru/libraiy/ms 345099(SQL.110).aspx. Если вы назвали сборку AccountBalances, а путь к файлу сборки — С:\ AccountBalances\AccountBalances.dll, загрузить сборку в базу данных и за- регистрировать хранимую процедуру можно следующим кодом: CREATE ASSEMBLY AccountBalances FROM 'C:\AccountBalances\AccountBalances. dll'; GO CREATE PROCEDURE dbo.AccountBalances AS EXTERNAL NAME AccountBalances.StoredProcedures.AccountBalances; После развертывания сборки и регистрации процедуры можно ее выпол- нить следующим кодом: EXEC dbo.AccountBalances; Как я уже говорил, SQLDataReader является всего лишь еще одной фор- мой курсора, но в этой версии затраты на чтение строк значительно меньше, чем при использовании традиционного курсора в Т-SQL. Также в .NET ите- рации выполняются намного быстрее, чем в Т-SQL. Таким образом, решения на основе CLR тоже масштабируются линейно. Тестирование показало, что производительность этого решения становится выше производительности решений с использованием подзапросов и соединений, когда число строк в секции переваливает через 15. По завершении надо выполнить следующий код очистки: DROP PROCEDURE dbo.AccountBalances; DROP ASSEMBLY AccountBalances; Вложенные итерации До этого момента я показывал итеративные решения и решения на основе наборов. Следующее решение основано на вложенных итерациях, которые являются гибридом итеративного и основанного на наборах подходов. Идея заключается в том, чтобы предварительно скопировать строки из таблицы- источника (в нашем случае это банковские счета) во временную таблицу
Решения на основе Т-SQL с использованием оконных функций Глава 5 187 вместе с новым атрибутом по имени rownum, который вычисляется с ис- пользованием функции ROW NUMBER. Номера строк секционируются по actid и упорядочиваются по tranid, поэтому первой транзакции в каждом банковском счете назначается номер 1, второй транзакции — 2 и т. д. Затем во временной таблице создается кластеризованный индекс со списком клю- чей (rownum, actid). Затем используется рекурсивное выражение СТЕ или специально созданный цикл для обработки по одной строке за итерацию во всех счетах. Затем нарастающий итог вычисляется путем суммирования значения, соответствующего текущей строке, со значением, связанным с предыдущей строкой. Вот реализация этой логики с использованием рекурсивного СТЕ: SELECT actid, tranid, val, ROW_NUMBER() OVER(PARTITION BY actid ORDER BY tranid) AS rownum INTO #Transactions FROM dbo.Transactions; CREATE UNIQUE CLUSTERED INDEX idx_rownum_actid ON «Transactions(rownum, actid); WITH C AS ( SELECT 1 AS rownum, actid, tranid, val, val AS sumqty FROM «Transactions WHERE rownum = 1 UNION ALL SELECT PRV. rownum + 1, PRV.actid, PRV.tranid, CUR.val, PRV.sumqty + CUR.val FROM C AS PRV JOIN «Transactions AS CUR ON CUR.rownum = PRV.rownum + 1 AND CUR.actid = PRV.actid ) SELECT actid, tranid, val, sumqty FROM C OPTION (MAXRECURSION 0); DROP TABLE «Transactions; А это реализация с использованием явного цикла: SELECT ROW_NUMBER() OVER(PARTITION BY actid ORDER BY tranid) AS rownum, actid, tranid, val, CAST(val AS BIGINT) AS sumqty INTO «Transactions FROM dbo.Transactions; CREATE UNIQUE CLUSTERED INDEX idx_rownum_actid ON «Transactions(rownum, actid);
188 Глава 5 Решения на основе Т-SQL с использованием оконных функций DECLARE @rownum AS INT; SET @rownum = 1: WHILE 1 = 1 BEGIN SET @rownum = @rownum + 1; UPDATE CUR SET sumqty = PRV.sumqty + CUR.val FROM «Transactions AS CUR JOIN «Transactions AS PRV ON CUR.rownum = @rownum AND PRV.rownum = @rownum - 1 AND CUR.actid = PRV.actid; IF @@rowcount = 0 BREAK; END SELECT actid, tranid, val, sumqty FROM «Transactions; DROP TABLE «Transactions; Это решение обеспечивает хорошую производительность, когда есть большое число секций с небольшим числом строк в секциях. Тогда число итераций небольшое, а основная работа выполняется основанной на набо- рах частью решения, которая соединяет строки, связанные с одним номером строки, со строками, связанными с предыдущим номером строки. Многострочное обновление с переменными Показанные до этого момента приемы вычисления нарастающих итогов гарантированно дают правильный результат. Описываемая в этом разделе методика неоднозначна, потому что основана на наблюдаемом, а не задо- кументированном поведении системы, кроме того она противоречит прин- ципам релятивности. Высокая ее привлекательность обусловлена большой скоростью работы. В этом способе используется инструкция UPDATE с переменными. Инструкция UPDATE может присваивать переменным выражения на осно- ве значения столбца, а также присваивать значениям в столбцах выражение с переменной. Решение начинается с создания временной таблицы по имени Transactions с атрибутами actid, tranid, val и balance и кластеризованного ин- декса со списком ключей {actid, tranid). Затем временная таблица наполня- ется всеми строками из исходной БД Transactions, причем в столбец balance всех строк вводится значение 0,00. Затем вызывается инструкция UPDATE с переменными, связанными с временной таблицей, для вычисления на- растающих итогов и вставки вычисленного значения в столбец balance.
Решения на основе Т-SQL с использованием оконных функций Глава 5 189 Используются переменные ©prevaccount и ©prevbalance, а значение в столб- це balance вычисляется с применением следующего выражения: SET @prevbalance = balance = CASE WHEN actid = @prevaccount THEN @prevbalance + val ELSE val END Выражение CASE проверяет, не совпадают ли идентификаторы текущего и предыдущего счетов, и, если они равны, возвращает сумму предыдущего и текущего значений в столбце balance. Если идентификаторы счетов разные, возвращается сумма текущей транзакции. Далее результат выражения CASE вставляется в столбец balance и присваивается переменной ©prevbalance. В отдельном выражении переменной ©prevaccount присваивается иденти- фикатор текущего счета. После выражения UPDATE решение представляет строки из временной таблицы и удаляет последнюю. Вот код законченного решения: CREATE TABLE «Transactions ( actid INT. tranid INT, val MONEY, balance MONEY ); CREATE CLUSTERED INDEX idx_actid_tranid ON «Transactions(actid, tranid); INSERT INTO «Transactions WITH (TABLOCK) (actid, tranid, val, balance) SELECT actid, tranid, val, 0.00 FROM dbo.Transactions ORDER BY actid, tranid; DECLARE @prevaccount AS INT, @prevbalance AS MONEY; UPDATE «Transactions SET @prevbalance = balance = CASE WHEN actid = @prevaccount THEN @prevbalance + val ELSE val END, @prevaccount = actid FROM «Transactions WITH(INDEX(1), TABLOCKX) OPTION (MAXDOP 1); SELECT * FROM «Transactions; DROP TABLE «Transactions;
190 Глава 5 Решения на основе Т-SQL с использованием оконных функций План этого решения показан на рис. 5-8. Первая часть представлена ин- струкцией INSERT, вторая — UPDATE, а третья —SELECT. Qu*ry 1: Query cost (rtlativ* to the bitch) 45» INSERT INTO •Transactions WITH (TABLOCK) (actid, tranid, val, balance) SELECT actid, tranid, val, 0.00 FROM dbo.Transactions ORDER BY ac Clustered Index Insert [•Transactions] . [idx_actid_tranid] Cost: 88 » Idj, * 1 Clustered Index Scan (Clustered) [Transactions].[PK_Transactions] Cost; 11 « Query 2: Query cost (relative to the batch): 49» UPDATE •Transactions SET Bprevbalance = balance = CASE WHEN actid = 8prevaccount THEN tprevbalance + val ELSE val END, Oprevaccount = ac Clustered Index Update [•Transactions].[idx_actid_tranid] Cost: 87 » Clustered Index Scan (Clustered) [•Transactions].[idx_actid_tranid] Cost: 13 » Query 3: Query cost (relative to the batch): 6» SELECT * FROM »Transactions; И ..________________. S prT ’s...." Clustered Index Scan (Clustered) [•Transactions].[idx_actid_tranid] >st: U * Cost 100 К Рис. 5-8. План выполнения решения с использование инструкции UPDATE с переменными В этом решении предполагается, что при оптимизации выполнения UPDATE всегда будет выполняться упорядоченный просмотр кластеризо- ванного индекса, и в решении предусмотрен ряд подсказок, чтобы предот- вратить обстоятельства, которые могут помешать этому, например паралле- лизм. Проблема в том, что нет никакой официальной гарантии, что опти- мизатор всегда будет посматривать в порядке кластеризованного индекса. Нельзя полагаться на особенности физических вычислений, когда нужно обеспечить логическую корректность кода, если только в коде нет логиче- ских элементов, которые по определению могут гарантировать такое поведе- ние. В данном коде нет никаких логических особенностей, которые могли бы гарантировать именно такое поведение. Естественно выбор, использовать или нет этот способ, лежит целиком на вашей совести. Я считаю, что безот- ветственно использовать ее, даже если вы тысячи раз проверяли ее и «вроде бы все работает, как надо». К счастью, в SQL Server 2012 этот выбор становится практически ненуж- ным. При наличии исключительно эффективного решения с использовани- ем оконных функций агрегирования не приходится задумываться о других решениях. Измерение производительности Я провел измерение и сравнение производительности различных методик. Результаты приведены на рис. 5-9 и 5-10.
Решения на основе Т-SQL с использованием оконных функций Глава 5 191 Измерение производительности вычисления нарастающих итогов Рис. 5-9. Измерение производительности вычисления нарастающих итогов, часть 1 Измерение производительности вычисления нарастающих итогов Рис. 5-10. Измерение производительности вычисления нарастающих итогов, часть 2 Я разбил результаты на два графика из-за того, что способ с использова- нием вложенного запроса или соединения настолько медленнее остальных, что мне пришлось использовать для него другой масштаб. В любом случае, обратите внимание, что большинство решений демонстрируют линейную зависимость объема работы от размера секции, и только решение на осно- ве вложенного запроса или соединения показывают квадратичную зависи- мость. Также ясно видно, насколько эффективнее новое решение на основе оконной функции агрегирования. Решение на основе UPDATE с перемен- ными тоже очень быстрое, но по описанным уже причинам я не рекомендую
192 Глава 5 Решения на основе Т-SQL с использованием оконных функций его использовать. Решение с использованием CLR также вполне быстрое, но в нем нужно писать весь этот код .NET и разворачивать сборку в базе дан- ных. Как ни посмотри, а основанное на наборах решение с использованием оконных агрегатов остается самым предпочтительным. Максимальное количество параллельных интервалов Представьте себе набор интервалов представляющих такие вещи, как сеан- сы, проекты, звонки и т. п. Существует классическая задача, известная как определение максимального количества параллельных интервалов, в ко- торой нужно определить максимальное число интервалов, которые были в действии одновременно. В качестве примера представьте, что вам дали та- блицу Sessions, которая содержит данные о пользовательских сеансах раз- личных приложений. Ваша задача — написать решение, которое вычисляет для каждого приложения максимальное число сеансов, активных в один мо- мент времени. Если сеанс завершается ровно в тот же момент, когда начина- ется другой, их не следует считать параллельными. Вот код создания таблицы Sessions и нескольких индексов, которые нам понадобятся в дальнейшем: SET NOCOUNT ON: USE TSQL2012; IF OBJECT_ID('dbo.Sessions', 'U') IS NOT NULL DROP TABLE dbo.Sessions; CREATE TABLE dbo.Sessions ( keycol INT NOT NULL, арр VARCHAR(10) NOT NULL, usr VARCHAR(10) NOT NULL, host VARCHAR(IO) NOT NULL, starttime DATETIME NOT NULL, endtime DATETIME NOT NULL, CONSTRAINT PK_Sessions PRIMARY KEY(keycol), CHECK(endtime > starttime) ); GO CREATE UNIQUE INDEX idx_nc_app_st_et ON dbo.Sessions(app, starttime, keycol) INCLUDE(endtime); CREATE UNIQUE INDEX idx_nc_app_et_st ON dbo.Sessions(app, endtime, keycol) INCLUDE(starttime); Следующим кодом наполните таблицу Sessions небольшим набором те- стовых данных для проверки корректности готового решения:
Решения на основе Т-SQL с использованием оконных функций Глава 5 193 TRUNCATE TABLE dbo.Sessions; INSERT INTO dbo .Sessions(keycol, app, usr, host, starttime, endtime) VALUES (2, 'appT, ' userl ’, 'hostl', '20120212 08:30' , ’20120212 10:30’), (3, ’appV, 'user2', 'hostl', '20120212 08:30' , ’20120212 08:45’), (5, ’app1’, 'user3', ’host2', '20120212 09:00' , ’20120212 09:30’), (7, 'app1', *user4', 'host2*, '20120212 09:15' , ’20120212 10:30’), (11, 'app1', 'user5', 'host3', '20120212 09:15' , '20120212 09:30’), (13, 'app1’, 'user6', 'host3', '20120212 10:30' , ’20120212 14:30’), (17, 'app1’, ’user?', ’host4’, '20120212 10:45' , ’20120212 11:30’), (19, 'app1', ’user8', ’host4', '20120212 11:00' . ’20120212 12:30’), (23, 'app2', 'user8', 'hostl', '20120212 08:30' , '20120212 08:45'), (29, 'app2', 'user?', 'hostl', '20120212 09:00’ , '20120212 09:30'), (31, 'app2', 'user6', ’host2', '20120212 11:45’ , '20120212 12:00’), (37, 'app2', 'user5', 'host2', '20120212 12:30’ , '20120212 14:00’), (41, 'app2', 'user4', 'host3', '20120212 12:45’ , ’20120212 13:30'), (43, 'app2', 'user3', 'host3', '20120212 13:00’ , ’20120212 14:00’), (47, 'app2', 'user2', ’host4’, '20120212 14:00’ , ’20120212 16:30’), (53, app2', 'userl', 'host4', '20120212 15:30’ , '20120212 17:00’); Вот результат, который нужно получить на основе этих данных: арр тх арр1 3 арр2 4 Естественно, что для серьезного тестирования решения нужен гораздо больший объем данных. Следующий код записывает в таблицу сведения о 100 тыс. сеансов десяти разных приложений: TRUNCATE TABLE dbo.Sessions; DECLARE @numrows AS INT = 100000, -- общее число строк @numapps AS INT = 10; -- число приложений INSERT INTO dbo.Sessions WITH(TABLOCK) (keycol, app, usr, host, starttime, endtime) SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS keycol, D. *, DATEADD( second, 1 + ABS(CHECKSUM(NEWID())) % (20*60), starttime) AS endtime FROM (
194 Глава 5 Решения на основе Т-SQL с использованием оконных функций SELECT 1 арр’ + CAST(1 + ABS(CHECKSUM(NEWID())) % @numapps AS VARCHAR(IO)) AS app, ‘ used ’ AS usr, ‘hostl’ AS host, DATEADD( second, 1 + ABS(CHECKSUM(NEWID())) % (30*24*60*60), ‘20120101’) AS starttime FROM dbo.GetNums(1, @numrows) AS Nums ) AS D; Вы вправе изменить число сеансов и приложений в соответствии с соб- ственными потребностями. Прежде чем представить эффективные решения на основе оконных функций, я покажу пару-тройку других решений без использования этих функций и расскажу о недостатках подобных подходов. Сначала расскажу о традиционном основанном на наборах решении. Традиционное решение на основе наборов Каждый сеанс можно считать состоящим из двух событий — начала сеан- са, когда число активных сеансов увеличивается, и завершения сеанса, ког- да число сеансов уменьшается. Если взглянуть на временную шкалу, то мы увидим, что число активных сеансов остается константой в пределах между последовательными событиями начала или завершения сеанса. Более того, так как событие начала увеличивает число активных сеансов, максималь- ная численность сеансов должна приходиться на событие начала. В качестве примера представьте себе, что были два сеанса: сеанс приложения Appl, ко- торый начался в момент Р1 и закончился в РЗ, а также сеанс приложения с началом в Р2 и концом в Р4. Вот хронологический порядок событий и число активных сеансов после каждого события: в Р1, запуск — 1 активный сеанс; в Р1, запуск — 2 активных сеанса; в Р1, конец — 1 активный сеанс; в Р1, конец — 0 активных сеансов. Число активных сеансов между двумя последовательными точками оста- ется постоянным. Максимальное число попадает на момент начала сеанса — в данном случае на момент Р2. Традиционное основанное на наборах решение строится на основе этой логики. Решение предусматривает следующие операции: 1. Определение табличного выражения по имени TimePoints на основе за- проса таблицы Sessions, которое возвращает арр и starttime (псевдоним ts означает timestamp). 2. Использование второго табличного выражения Counts для запроса Time- Points (псевдоним Р).
Решения на основе Т-SQL с использованием оконных функций Глава 5 195 3. Во втором табличном выражении вложенный запрос используется для определения количества сеансов в таблице Sessions (псевдоним 5), у которых Р.арр равно S.app, P.ts равно или позже, чем S.starttime, и перед S.endtime. Вложенный запрос считает, сколько сеансов активны на про- тяжении каждого начала сеанса приложения. 4. Наконец во внешнем запросе выражения Counts строки группируются по арр, и для каждого приложения возвращается максимальное число сеан- сов. Вот код законченного решения: WITH TimePoints AS ( SELECT app, starttime AS ts FROM dbo.Sessions ). Counts AS ( SELECT app, ts, (SELECT COUNT(*) FROM dbo.Sessions AS S WHERE P.app = S.app AND P.ts >= S.starttime AND P.ts < S.endtime) AS concurrent FROM TimePoints AS P ) SELECT app, MAX(concurrent) AS mx FROM Counts GROUP BY app; Решение кажется прямолинейным и в нем не сразу заметна проблема с производительностью. Но при большом объеме данных решение выполня- ется очень долго. Чтобы понять, почему это происходит, посмотрите на план выполнения, показанный на рис. 5-11. Ckuerjr 1 флг, cest (r«l»ti»« to th« b»tcti) . M0> WITH TisHPoints AS ( SELECT tpp. sttrttliM ts FROM dbo.S«ssions ), Counts AS ( SELECT tpp, ts. (SELECT COUNTQ FROM dbo Sessions AS S WHERE P tpp - S tpp АЮ P ts >. 3 SELECT Cost: 0 X Strata Aggregate (Aggregate) Cost О В (Gather Stretas) Cost: О » _ 11 f.. Strata Aggregate (Aggregate) Cost 0 Ж (Inner Join) Cost- О » Index Scan (NonClustered) [Sessions] . [idx_nc_app_et_st] Cost: О Ж Cowute Scalar *99«9«« Index Seek (NonClustered) Cost О Ж (Aggregate) [Sessions] [idx_nc_app_st_et] [S] Cost: M Ж Cost; 64 Ж Рис. 5-11. План выполнения традиционного основанного на наборах решения Итератор Index Scan в верхней правой части плана (внешний элемент соединения Nested Loops) просматривает один из созданных ранее индексов покрытия запроса (idx_nc_app_et_st), чтобы получить все моменты начала приложения. Если р — число секций (приложений) и г — число строк в сек- ции (сеансов в приложении), эта часть предусматривает просмотр примерно
196 Глава 5 Решения на основе Т-SQL с использованием оконных функций рг строк. Внутренней частью соединения Nested Loops является итератор Index Seek для индекса idx_nc_app_st_et, который выполняется для каждой строки, возвращенной верхними входными данными. Его задача — опреде- лить строки, представляющие сеансы, которые были активны в данном при- ложении в момент времени, указанный во внешней строке. А теперь обратите внимание на работу, выполняемую при каждом прого- не итератора Index Seek. Для элементов Р.арр (назовем это туарр) и P.ts (на- зовем это myts) текущей строки итератор ищет все строки, в которых S.app = туарр, S.starttime < = myts и S.endtime > myts. Так как первым ключом индекса является арр, предикат поиска может эффективно выполнять фильтрацию первой части: S.app = туарр. Проблема наблюдается с двумя другими частя- ми: S.starttime <= myts и S.endtime > myts. Нет индекса, который позволил бы предикату поиска просматривать только те строки, что удовлетворяют обоим условиям. Предполагается, что этот предикат фильтрует строки, у ко- торых значение находится между двумя столбцами. Это сильно отличается от ситуации, когда надо фильтровать строки, у которых столбец находит- ся между двумя значениями. В первом случае можно задействовать индекс на фильтруемом столбце для отбора только подходящих столбцов. А вот во втором случае упорядочение по индексу можно задействовать только для одного из условий. Как показано выше, оптимизатор решил применить ите- ратор Index Seek к индексу idx_nc_app_st_et. Поиск выполняется на основе предиката поиска S.starttime <= myts, поэтому обращение происходит толь- ко к строкам, удовлетворяющим этому предикату. Однако просматриваются все оставшиеся строки и с применением предиката S.endtime > myts возвра- щаются только те, что удовлетворяют этому условию. Увидеть распределение между предикатами Seek Predicate и Predicate можно в свойствах итератора Index Seek. Вот свойство Seek Predicate: Seek Keys[1]: Prefix: [TS0L2012].[dbo].[Sessions]. app = Scalar Operater([TSQL2012].[dbo].[Sessions].[app]), End: [TSQL2012],[dbo]. [Sessions].starttime <= Scalar 0perator([TSQL2012].[dbo].[Sessions].[starttime]) А вот свойство Predicate: [TS0L2012].[dbo].[Sessions].[starttime]<[TSQL2012].[dbo]. [Sessions], [endtime] as [S].[endtime] Если вы еще не поняли, то скажу вам, что это очень плохо. Предикат поис- ка предотвращает чтение не удовлетворяющих требованиям строк, но преди- кат просмотра этого не делает. Строки должны считываться до применения предиката просмотра. Я уже упоминал, что итератор Index Scan считывает примерно рг строк. Для каждой строки итератор Index Seek просматривает в среднем половину строк секции. Это означает, что если в секции г строк, итератор просматривает г2/2 в каждой секции. В целом число обрабатывае- мых строк составляет/?/* + pti/2. Это означает, что с увеличением размера
Решения на основе Т-SQL с использованием оконных функций Глава 5 197 секции ресурсоемкое™ плана растет по квадратичному закону То есть если число строк в секции увеличивается в f раз, объем работы увеличивается примерно в f2 раз. Так что с ростом размера секции производительность бу- дет падать катастрофически. Решение с использованием курсора Основанное на курсоре решение базируется на следующем запросе, который размещает события начала и завершения сеансов в хронологическом поряд- ке: SELECT app, starttime AS ts, +1 AS type FROM dbo.Sessions UNION ALL SELECT app, endtime, -1 FROM dbo.Sessions ORDER BY app, ts, type; type app ts app1 2012-02-12 08:30:00.000 1 арр1 2012-02-12 08:30:00.000 1 арр1 2012-02-12 08:45:00.000 -1 арр1 2012-02-12 09:00:00.000 1 арр1 2012-02-12 09:15:00.000 1 арр1 2012-02-12 09:15:00.000 1 арр1 2012-02-12 09:30:00.000 -1 арр1 2012-02-12 09:30:00.000 -1 app1 2012-02-12 10:30:00.000 -1 app1 2012-02-12 10:30:00.000 -1 Как видите, запрос отмечает события начала сеанса числом +1, потому что такие события увеличивают общее число активных сеансов, а конец сеанса — числом -1, потому что они уменьшают число сеансов. Запрос сортирует со- бытия в хронологическом порядке по app, ts и type. Причина добавления типа в список ORDER BY — обеспечить, чтобы при совпадении моментов начала и завершения сеанса событие завершения учитывалось в первую очередь. (Как вы помните, в этом случае эти сеансы не считаются параллельными.) План этого запроса показан на рис. 5-12. Заметьте, что план очень эффективный. В нем выполняется упорядочен- ный просмотр двух созданных ранее индексов, а также используется ите- ратор Merge Join для конкатенации результатов, что позволяет сохранить упорядочение по индексу и избежать операции сортировки.
198 Глава 5 Решения на основе Т-SQL с использованием оконных функций Querjr 1: Querjr cost (relitive to the bitch): 100» SELECT ipp. stirttime AS ts. +1 AS tjpe FROM dbo.Sessions UNION ALL SELECT ipp. endtime. -1 FROM dbo.Sessions ORD SELECT Cost: О Я ~~~1 . , . Merge Join (Concatenation) Cost: 28 X Рис. 5-12. План выполнения решения с использованием курсора Остальная часть предусматривает вычисление нарастающего ито- га выбранного типа в каждом приложении в хронологическом порядке. Нарастающий итог определенного типа это, по сути, число активных тран- закций в каждый момент времени. Код курсора делает именно это и в группе каждого приложения хранит максимальное число в переменной. По завер- шении обработки группы полученное максимальное значение и имя при- ложения сохраняются в табличной переменной. По завершении работы код просто запрашивает табличную переменную и возвращает результат. Вот код законченного решения: DECLARE @арр AS varchar(lO), @prevapp AS varchar (10), @ts AS datetime, @type AS int. Concurrent AS int, @mx AS int; DECLARE @AppsMx TABLE ( app varchar (10) NOT NULL PRIMARY KEY, mx int NOT NULL ); DECLARE sessions.cur CURSOR FAST.FORWARD FOR SELECT app, starttime AS ts, +1 AS type FROM dbo.Sessions UNION ALL SELECT app, endtime, -1 FROM dbo.Sessions ORDER BY app, ts, type; OPEN sessions.cur;
Решения на основе Т-SQL с использованием оконных функций Глава 5 199 FETCH NEXT FROM sessions_cur INTO @app, @ts, @type; SET @prevapp = @app; SET @concurrent = 0; SET @mx = 0; WHILE @@FETCH_STATUS = 0 BEGIN IF @app <> @prevapp BEGIN INSERT INTO @AppsMx VALUES(@prevapp, @mx); SET @concurrent = 0; SET @mx = 0; SET @prevapp = @app; END SET @concurrent = (©concurrent + @type; IF (©concurrent > @mx SET @mx = (©concurrent; FETCH NEXT FROM sessions_cur INTO (©app, @ts, (©type; END IF (©prevapp IS NOT NULL INSERT INTO (©AppsMx VALUES((©prevapp, @mx); CLOSE sessions_cur; DEALLOCATE sessions_cur; SELECT * FROM (©AppsMx; Решение обладает всеми недостатками решений на основе курсора. Что ка- сается производительности, то приходится расходовать дополнительные энер- гию и время на обработку каждой строки, но масштабируется решение линейно. Если число строк в таблице примерно равно рг, решение на основе курсора про- сматривает 2рг строк. Кроме того, ввиду дополнительной работы по обработ- ке отдельных строк при каждом чтении курсора (назовем ее о), общие затраты равны 2рг + 2рго. Если объем данных увеличивается в/раз, затраты составляют 2prf + 2prfo. Поэтому это решение работает быстрее, чем традиционное реше- ние на основе наборов, начиная даже с малого размера секции. Решения на основе оконных функций Я представлю два решения с использованием оконных функций: первое доступно только в SQL Server 2012, потому что в нем используются новые
200 Глава 5 Решения на основе Т-SQL с использованием оконных функций возможности оконного агрегирования, а второе применимо ко всем версиям SQL Server, начиная с SQL Server 2005, потому что в нем применяется функ- ция ROWNUMBER. Вспомните запрос, который мы использовали в предыдущем решении с использованием курсора. Он размещает события начала и завершения сеан- сов в хронологическом порядке, отмечая их соответственно типами собы- тия «+1» и «-1». После этого число параллельных сеансов в каждый момент времени вычисляется как накопительный итог. До SQL Server 2012 курсор был одним из эффективных решений для вычисления нарастающих итогов. Но теперь у нас есть возможность упорядочения и кадрирования в оконных функциях агрегирования. Начальный запрос и общие принципы решения с оконной функцией агре- гирования похожи на те, что используются в решения на основе курсора — разница в отсутствии курсора и связанной с ним дополнительной нагрузки. Вот код законченного решения: WITH С1 AS ( SELECT арр, starttime AS ts. +1 AS type FROM dbo.Sessions UNION ALL SELECT app, endtime. -1 FROM dbo.Sessions ), C2 AS ( SELECT *. SUM(type) OVER(PARTITION BY app ORDER BY ts, type ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS ent FROM C1 ) SELECT app, MAX(cnt) AS mx FROM C2 GROUP BY app; Запрос в СТЕ-выражении С1 генерирует унифицированную последова- тельность событий начала и завершения. Запрос в СТЕ-выражении С2 вы- числяет нарастающий итог типа, секционированный по арр и упорядочен- ный по ts и type. Это число активных сеансов в каждый момент времени. Наконец внешний запрос группирует строки, возвращенные С2, по арр и возвращает максимальное количество для каждого арр (приложения). Полюбуйтесь, насколько лаконично и просто выглядит это решение. Оно также исключительно эффективно и характеризуется линейной масштаби- руемостью. На рис. 5-13 показан план выполнения этого запроса.
Решения на основе Т-SQL с использованием оконных функций Глава 5 201 Query 1: Query cost (relative to the bitch). Ю0Х WITH Cl AS ( SELECT tpp. sttrttieie AS ts. +1 AS type FPOH dbo Sessions UNION ALL SELECT ipp. endtime. -1 FROM dbo. Sessions ). CZ AS ( SELECT «. SUH (type) OVER (PARTITION BY ipp i Рис. 5-13. План выполнения решения с использованием оконной функции агрегирования Второе решение на основе оконных функции доступно в SQL Server, на- чиная с версии SQL Server 2005, и оно основано на использовании функции ROW_NUMBER. Об этом элегантном решении я узнал от Бена Фланагана (Ben Flanaghan). Как и в предыдущем решении, здесь события начала и завершения размещаются в хронологическом порядке и отмечаются соот- ветственно типом события «+1» или «-1». Отличие только в топ части, где вычисляются активные сеансы в каждый момент времени. Вот код закон- ченного решения: WITH С1 AS ( SELECT app. starttime AS ts, +1 AS type, keycol. ROW_NUMBER() OVER(PARTITION BY app ORDER BY starttirne. keycol) AS start_ordinal FROM dbo.Sessions UNION ALL SELECT app, endtime, -1, keycol, NULL FROM dbo.Sessions ), C2 AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY app ORDER BY ts. type, keycol) AS start_ or_end_ordinal FROM C1 ) SELECT app, MAX(start_ordinal - (start_or_end_ordinal - start_ordinal)) AS mx FROM C2 GROUP BY app: Запрос, определяющий СТЕ-выражение C1, создает хронологическую последовательность событий. Здесь же используется функция ROW- NUMBER для вычисления порядкового номера событий начала (соответ- ствующий атрибут называется stcut_ordinal). Атрибут start_ordinal указы- вает в каждом^ событии начала сеанса, сколько интервалов стартовали на данный момент. Для событий завершения во втором запросе используется
202 Глава 5 Решения на основе Т-SQL с использованием оконных функций NULL как знак подстановки для start ordinal — это нужно для унификации событий запуска и завершения. Запрос, определяющий СТЕ-выражение С2, запрашивает С1 и использу- ет функцию ROW NUMBER для вычисления атрибута start_or_end_ordinal на основе унифицированных событий, который определяет, сколько собы- тий — начала и конца сеансов случилось на данный момент. Магия происходит во внешнем запросе, который запрашивает С2. Пусть end ordinal равно start or end ordinal - start_ordinaL Тогда число активных интервалов составляет start ordinal - end ordinal. Иначе говоря, число ак- тивных интервалов равно start_ordinal - (stait or end ordinal - start ordi- nal). Как видите, внешнему запросу остается сгруппировать полученные из С2 строки по арр и вернуть для каждого арр максимальное число активных интервалов. План этого решения показан на рис. 5-14. Query 1. Query cost (relative to the batch) 100» WITH Cl AS ( SELECT app. itarttiiee AS ts. +1 AS type. ROW NUMBER() OVER(PARTITION BY app ORDER BY etarttune) AS »tart ordinal FROM dbo Sessions UH ION ALL SELECT app. endtnie. - Рис. 5-14. План выполнения решения с функцией ROW_NUMBER Также в плане видно, что в обоих случаях применения функции ROW- NUMBER для вычисления порядковых номеров событий — как начала, так и завершения сеанса — мы полагаемся на упорядочение в индексе. То же вер- но в отношении операции агрегирования. Таким образом, в плане вообще не нужна операция сортировки. Измерение производительности Я выполнил измерение производительности для сравнения производитель- ности разных решений (рис. 5-15). Полюбуйтесь, насколько неторопливы традиционные решения, осно- ванные на наборах. Наглядно видна квадратичная зависимость. Решение с курсором масштабируется линейно и отличается намного лучшей произво- дительностью. Решения на основе оконных функций намного опережают остальные решения в плане эффективности и тоже характеризуются линей- ной масштабируемостью.
Решения на основе Т-SQL с использованием оконных функций Глава 5 203 Рис. 5-15. Результаты измерения производительности различных решений для вычисления максимального количества параллельных интервалов Упаковка интервалов Упаковка интервалов подразумевает группировку каждого набора смежных интервалов так, чтобы никакой интервал группы не перекрывался и не со- седствовал (не граничил) с каким-либо интервалом других групп, и возвра- щение минимального начала и максимального конца каждой группы. Часто задачи упаковки в SQL Server предусматривают элемент секционирования (например, по пользователю или приложению) — тогда упаковка выполня- ется для каждой секции независимо. В сценарии, который я использую для демонстрации решений задачи упаковки интервалов, применяются пользовательские сеансы приложений или служб. Используйте следующий код для создания таблиц Users (поль- зователи) и Sessions (сеансы) и наполнения их тестовыми данными для про- верки работы решений: SET NOCOUNT ON; USE TSQL2012; IF OBJECT_ID('dbo.Sessions’) IS NOT NULL DROP TABLE dbo.Sessions; IF OBJECT_ID(’dbo.Users’) IS NOT NULL DROP TABLE dbo.Users; CREATE TABLE dbo.Users ( username VARCHAR(14) NOT NULL, CONSTRAINT PK_Users PRIMARY KEY(username) );
204 Глава 5 Решения на основе Т-SQL с использованием оконных функций INSERT INTO dbo.Users(username) VALUESC Userl’ ), (‘User2’), (‘User3’); CREATE TABLE dbo.Sessions ( id INT NOT NULL IDENTITY(1, 1), username VARCHAR(14) NOT NULL, starttime DATETIME2(3) NOT NULL, endtime DATETIME2(3) NOT NULL, CONSTRAINT PK.Sessions PRIMARY KEY(id), CONSTRAINT CHK_endtime_gteq_starttime CHECK (endtime >= starttime) INSERT INTO dbo.Sessions(username, starttime, (‘Userl’, ‘20121201 08:00:00.000’ , '20121201 (‘Userl’, ‘20121201 08:30:00.000’ , ‘20121201 (‘Userl’, ‘20121201 09:00:00.000’ , '20121201 (‘Userl’, ‘20121201 10:00:00.000’ , ‘20121201 (‘Userl’, ‘20121201 10:30:00.000’ , ‘20121201 (‘Userl’, ‘20121201 11:30:00.000’ , ‘20121201 (‘User2’, ‘20121201 08:00:00.000’ , ‘20121201 (‘User2’, ‘20121201 08:30:00.000’ , ‘20121201 (‘User2’, ‘20121201 09:00:00.000’ , ‘20121201 (‘User2’, ‘20121201 11:00:00.000’ , ‘20121201 (‘User2’, ‘20121201 11:32:00.000’ , ‘20121201 (‘User2’, ‘20121201 12:04:00.000’ , ‘20121201 (‘User3’, ‘20121201 08:00:00.000’ , ‘20121201 (‘User3’, ‘20121201 08:00:00.000’ , ‘20121201 (‘User3’, ‘20121201 08:30:00.000’ , ‘20121201 (‘User3’, ‘20121201 09:30:00.000’ , ‘20121201 endtime) VALUES 08:30:00.000’), 09:00:00.000’), 09:30:00.000’), 11:00:00.000’), 12:00:00.000’), 12:30:00.000’), 10:30:00.000’ ), 10:00:00.000’ ), 09:30:00.000’ ), 11:30:00.000’), 12:00:00.000’ ), 12:30:00.000’ ), 09:00:00.000’ ), 08:30:00.000’), 09:00:00.000’), 09:30:00.000’); Вот результат, который нужно получить на основе этих данных: username starttime endtime Userl 2012-12-01 08:00:00.000 2012-12-01 09:30:00.000 Userl 2012-12-01 10:00:00.000 2012-12-01 12:30:00.000 User2 2012-12-01 08:00:00.000 2012-12-01 10:30:00.000 User2 2012-12-01 11:00:00.000 2012-12-01 11:30:00.000 User2 2012-12-01 11:32:00.000 2012-12-01 12:00:00.000 User2 2012-12-01 12:04:00.000 2012-12-01 12:30:00.000 User3 2012-12-01 08:00:00.000 2012-12-01 09:00:00.000 User3 2012-12-01 09:30:00.000 2012-12-01 09:30:00.000 На рис. 5-16 показаны как исходные интервалы из таблицы Sessions (по- лосы), так и упакованные интервалы (стрелки).
Решения на основе Т-SQL с использованием оконных функций Глава 5 205 Рис. 5-16. Неупакованные и упакованные интервалы Следующим кодом наполните таблицу Sessions большим набором тесто- вых данных для проверки производительности готового решения: DECLARE @num_users AS INT = 2000, @intervals_per_user AS INT = 2500, @start_period AS DATETIME2(3) = ’2012010V. @end_period AS DATETIME2(3) = ’20120107’. @max_duration_in_ms AS INT = 3600000; -- 60 минут TRUNCATE TABLE dbo.Sessions: TRUNCATE TABLE dbo.Users; INSERT INTO dbo.Users(username) SELECT ‘User’ + RIGHT(‘000000000’ + CAST(U.n AS VARCHAR(10)). 10) AS username FROM dbo.GetNums(1, @num_users) AS U; WITH C AS ( SELECT ‘User’ + RIGHT(‘000000000’ + CAST(U.n AS VARCHAR(10)), 10) AS username, DATEADD(ms. ABS(CHECKSUM(NEWID())) % 86400000. DATEADD(day, ABS(CHECKSUM(NEWID())) % DATEDIFF(day. @start_period, @end_penod). @start_period)) AS starttime FROM dbo.GetNums(1, @num_users) AS U CROSS JOIN dbo.GetNums(1, @intervals_per_user) AS I )
206 Глава 5 Решения на основе Т-SQL с использованием оконных функций INSERT INTO dbo.Sessions WITH (TABLOCK) (username, starttime, endtime) SELECT username, starttime, DATEADD(ms, ABS(CHECKSUM(NEWID())) % (@max_duration_in_ms +1), starttime) AS endtime FROM C; Этот код добавляет 5 млн строк в таблицу Sessions. Я заполнил таблицу данными 2000 пользователей с 2500 сеансами у каждого на протяжении не- дели, причем длительность сеанса меньше или равна часу Но вы вправе за- дать свои параметры и заполнить таблицу другим объемом данных. Традиционное решение на основе наборов Первое решение — классический вариант, который выполняет задачу, но очень неэффективно. Для него нужны следующие два индекса: CREATE INDEX idx_user_start_end ON dbo.Sessions(username, starttime, endtime); CREATE INDEX idx_user_end_start ON dbo.Sessions(username, endtime, starttime); Вот код решения: WITH StartTimes AS ( SELECT DISTINCT username, starttime FROM dbo.Sessions AS S1 WHERE NOT EXISTS (SELECT * FROM dbo.Sessions AS S2 WHERE S2.username = S1.username AND S2.starttime < S1.starttime AND S2.endtime >= S1.starttime) ), EndTimes AS ( SELECT DISTINCT username, endtime FROM dbo.Sessions AS S1 WHERE NOT EXISTS (SELECT * FROM dbo.Sessions AS S2 WHERE S2.username = S1.username AND S2.endtime > S1.endtime AND S2.starttime <= S1.endtime) ) SELECT username, starttime, (SELECT MIN(endtime) FROM EndTimes AS E WHERE E.username = S.username AND endtime >= starttime) AS endtime FROM StartTimes AS S;
Решения на основе Т-SQL с использованием оконных функций Глава 5 207 СТЕ-выражение StartTimes выделяет начала упакованных интервалов, используя запрос, который возвращает все начала интервалов, для которых не удается найти другого интервала этого же пользователя, который начался до начала текущего интервала и закончился одновременно или после начала текущего интервала. СТЕ-выражение EndTimes выделяет концы упакован- ных интервалов, используя запрос, который возвращает все концы интерва- лов, для которых не удается найти другого интервала этого же пользователя, который завершился после конца текущего интервала и закончился одно- временно или до конца текущего интервала. После этого внешний запрос сопоставляет каждому началу упакованного интервала ближайший конец интервала и продолжает обработку, возвращая минимальное время конца, которое больше или равно текущему началу. Как я уже говорил, это решение очень неэффективно. Для обработки тесто- вых данных из 5 млн строк в таблице Sessions потребовалось несколько часов. Прежде чем продолжить надо удалить ранее созданные индексы: DROP INDEX idx_user_start_end ON dbo.Sessions; DROP INDEX idx_user_end_start ON dbo.Sessions; Решения на основе оконных функций Далее я расскажу о двух сравнительно новых основанных на оконных функ- циях способах, которые намного быстрее традиционного решения. Для но- вых решений надо создать такие индексы: CREATE UNIQUE INDEX idx_user_start_id ON dbo.Sessions(username, starttime, id): CREATE UNIQUE INDEX idx_user_end_id ON dbo.Sessions(username, endtime, id); В первом способе в основном задействована функция ROW_NUMBER. Полное решение приведено в листинге 5-1. На моем ноутбуке оно обрабаты- вает 5 млн строк за 47 секунд. Листинг 5-1. Упаковка интервалов с использованием функции ROW_NUMBER WITH С1 AS -- пусть е количеством событий начала, as- завершения сеансов ( SELECT id, username, starttime AS ts, +1 AS type, NULL AS e, ROW_NUMBER() OVER(PARTITION BY username ORDER BY starttime, id) AS s FROM dbo.Sessions UNION ALL SELECT id, username, endtime AS ts, -1 AS type, ROW_NUMBER() OVER(PARTITION BY username ORDER BY endtime, id) AS e, NULL AS s FROM dbo.Sessions
208 Глава 5 Решения на основе Т-SQL с использованием оконных функций С2 AS - - пусть se является количеством событий начала или завершения интервалов, -- то есть числом событий (начала или завершения) до текущего момента ( SELECT С1.*, ROW_NUMBER() OVER(PARTITION BY username ORDER BY ts, type DESC, id) AS se FROM C1 ), C3 AS - - Для событий начала выражение s - (se - s) - 1 показывает, - - сколько сеансов были активны непосредственно перед текущим сеансом - - Для событий завершения выражение (se - е) - е показывает, - - сколько сеансов были активны непосредственно после текущего сеанса - - Эти два выражения равны точно нулю в моменты соответственно - - начала и конца упакованных интервалов - - После фильтрации только событий начала или конца упакованных интервалов -- группируем отдельные пары соседних событий начала и завершения. ( SELECT username, ts, FLOOR((ROW_NUMBER() OVER(PARTITION BY username ORDER BY ts) - 1) / 2 + 1) AS grpnum FROM C2 WHERE COALESCED - (se - s) - 1, (se - e) - e) = 0 ) SELECT username, MIN(ts) AS starttime, max(ts) AS endtime FROM C3 GROUP BY username, grpnum; Код СТЕ-выражения С1 унифицирует события начала и конца в одну хро- нологическую последовательность событий (начала или конца). События начала отмечаются типом события «+1», потому что в этом случае число активных сеансов увеличивается, а события конца отмечаются типом «-1», потому что они уменьшают число активных сеансов. На рис. 5-17 показана хронологическая последовательность унифицированных событий, отсорти- рованных по username, ts, type DESC, id, а столбцы справа показывают, сколь- ко сеансов были активны до и после каждого события. Заметьте, что упакованный интервал всегда начинается, когда число ак- тивных сеансов до события начала равно нулю, и завершается, когда число активных сеансов после события завершения равно нулю. Поэтому по от- ношению к каждому событию начала надо знать, сколько сеансов активны до него, а по отношению к каждому событию завершения, — сколько сеансов активны после него. Эта информация вычисляется поэтапно.
Решения на основе Т-SQL с использованием оконных функций Глава 5 209 id |username |ts |type | numintervals 0 1 Userl 2012-12-0108:00 1 1 2 Userl 2012-12-0108:30 1 2 1 Userl 2012-12-0108:30 -1 i Г 3 Userl 2012-12-0109:00 1 2 2 Userl 2012-12-0109:00 -1 1 3 Userl 2012-12-0109:30 -1 0 0 4 Userl 2012-12-0110:00 1 1 5 Userl 2012-12-01 10:30 1 2 4 Userl 2012-12-0111:00 -1 1 6 Userl 2012-12-01 11:30 1 2 5 Userl 2012-12-0112:00 -1 1 6 Userl 2012-12-01 12:30 -1 o : 0 7 User2 2012-12-0108:00 1 1 HL- 8 User2 2012-12-0108:30 1 2 9 User2 2012-12-0109:00 1 9 User2 2012-12-0109:30 -1 2 Г 8 User2 2012-12-0110:00 -1 i IT 7 User2 2012-12-01 10:30 -1 0 0 10 User2 2012-12-0111:00 1 1 10 User2 2012-12-0111:30 -1 0 0 11 User2 2012-12-0111:32 1 1 11 User2 2012-12-0112:00 -1 0 0 12 User2 2012-12-0112:04 1 1 12 User2 2012-12-0112:30 -1 0 0 13 User3 2012-12-0108:00 1 1 — 14 User3 2012-12-0108:00 1 2 15 User3 2012-12-0108:30 1 3 14 User3 2012-12-0108:30 -1 2 ИГ 13 User3 2012-12-0109:00 -1 1 15 User3 2012-12-0109:00 -1 0 ! 0 16 User3 2012-12-0109:30 1 1 16 User3 2012-12-0109:30 -1 0 Рис. 5-17. Расположенные в хронологическом порядке события начала и завершения Заметьте, что код в СТЕ-выражении С1 вычисляет число событий нача- ла (атрибут по имени $), при этом значения NULL используются как знаки подстановки в этот атрибут вместо событий завершения, а также вычис- ляет число событий завершения (атрибут по имени е), при этом значения NULL используются как знаки подстановки в этот атрибут вместо событий начала. Код СТЕ-выражения С2 просто суммирует число событий начала и завершения (атрибут по имени $е), секционированных по username и со- ртированных по ts, type DESC, id. Ниже приводится результат работы С2, от- сортированный по username, ts, type DESC, id (для удобства чтения я отметил тип событий начала как «+1», а не просто «1» и заменил значения NULL пробелами): 8 Зак 601
210 Глава 5 Решения на основе Т-SQL с использованием оконных функций id username ts type e s se 1 Userl 2012-12-01 08 00 +1 1 1 2 Userl 2012-12-01 08 30 +1 2 2 1 Userl 2012-12-01 08 30 -1 1 3 3 Userl 2012-12-01 09 00 +1 3 4 2 Userl 2012-12-01 09 00 -1 2 5 3 Used 2012-12-01 09 30 -1 3 6 4 Used 2012-12-01 10 00 +1 4 7 5 Userl 2012-12-01 10 30 +1 5 8 4 Userl 2012-12-01 11 00 -1 4 9 6 Userl 2012-12-01 11 30 +1 6 10 5 Used 2012-12-01 12 00 -1 5 11 6 Userl 2012-12-01 12 30 -1 6 12 7 User2 2012-12-01 08 00 +1 1 1 8 User2 2012-12-01 08 30 +1 2 2 9 User2 2012-12-01 09 00 +1 3 3 9 User2 2012-12-01 09 30 -1 1 4 8 User2 2012-12-01 10 00 -1 2 5 7 User2 2012-12-01 10 30 -1 3 6 10 User2 2012-12-01 11 00 +1 4 7 10 User2 2012-12-01 11 30 -1 4 8 11 User2 2012-12-01 11 32 +1 5 9 11 User2 2012-12-01 12 00 -1 5 10 12 User2 2012-12-01 12 04 +1 6 11 12 User2 2012-12-01 12 30 -1 6 12 13 User3 2012-12-01 08 00 +1 1 1 14 User3 2012-12-01 08 00 +1 2 2 15 User3 2012-12-01 08 30 +1 3 3 14 User3 2012-12-01 08 30 -1 1 4 13 User3 2012-12-01 09 00 -1 2 5 15 User3 2012-12-01 09 00 -1 3 6 16 User3 2012-12-01 09 30 +1 4 7 16 User3 2012-12-01 09 30 -1 4 8 Все самое важное происходит в коде СТЕ-выражения СЗ. Для каждого события начала известно, сколько сеансов началось до этого ($), а также мы знаем, сколько сеансов начались или закончились до этого (se). Поэтому можно легко вычислить, сколько сеансов закончились на данный момент (se - $). Заметьте, что мы знаем, сколько сеансов началось и сколько закончи- лось, поэтому можно определить, сколько сеансов активны после события начала: $ - (se - s). Можно к этому отнестись как к вычислению, сколько человек в комнате, если в нее вошли х и вышли у человек. Наконец, чтобы определить, сколько сеансов были активны до данного события начала, про- сто отнимем единицу из полученной ранее величины: s - (se - s') - 1. Аналогично определяется число активных сеансов после каждого события завершения. Имея число сеансов, завершившихся (е), а также начавшихся или
Решения на основе Т-SQL с использованием оконных функций Глава 5 211 завершившихся (se) до текущего момента, можно вычислить, сколько сеансов было начато: se - е. Тогда число активных сеансов равно (se - е) - е. А теперь вспомним, что нужно отобрать только события начала, у кото- рых число активных сеансов до этого события равно нулю, а также события завершения, после которых число активных сеансов становится нулевым. Оба фильтра можно унифицировать в один: WHERE COALESCED - (se - s) - 1, (se - e) - e) = 0 После отбора получаем пары смежных событий начала и завершения, каждая из которых обозначает начало и конец упакованного интервала. Нужно назначить идентификатор группы каждой паре, чтобы можно было потом свести их в одну строку. Эту задачу можно решить, назначая номера строк (назовет это п) и вычисления (п - 1)/2 + 7, где деление выполняется нацело. Для значений п 1, 2, 3, 4,... получаем результат 1, 1, 2, 2,.... В SQL Server арифметический оператор деления (/) обозначает целочис- ленное деление, в котором операндами являются целые числа, но в Oracle этот оператор означает десятичное деление. Я добавил функцию FLOOR, чтобы код работал корректно в обеих СУБД. Таким образом, код СТЕ- выражения СЗ возвращает такой результат: username ts grpnum Userl 2012-12-01 08:00 1 Userl 2012-12-01 09:30 1 Userl 2012-12-01 10:00 2 Userl 2012-12-01 12:30 2 User2 2012-12-01 08:00 1 User2 2012-12-01 10:30 1 User2 2012-12-01 11:00 2 User2 2012-12-01 11:30 2 User2 2012-12-01 11:32 3 User2 2012-12-01 12:00 3 User2 2012-12-01 12:04 4 User2 2012-12-01 12:30 4 User3 2012-12-01 08:00 1 User3 2012-12-01 09:00 1 User3 2012-12-01 09:30 2 User3 2012-12-01 09:30 2 Внешнему запросу остается выполнить группировку строк, полученных из СЗ, по username и grpnum и вернуть минимальное значение ts в качестве начала и ts — в качестве конца упакованного интервала. План, созданный оптимизатором SQL Server для этого запроса, исключи- тельно эффективный при условии, что вы создали упомянутые выше индек- сы idx_user_start_id and idx_user_end_id. План показан на рис. 5-18.
212 Глава 5 Решения на основе Т-SQL с использованием оконных функций Query 1: Query cost (relitive to the bitch): 100% WITH Cl AS -- let e = end ordinils, let s = stirt ordinils ( SELECT id, usernime, stirttime AS ts, +1 AS type. NULL AS t. ROWJUMBERQ OVE Рис. 5-18. План решения с использованием номеров строк Самое замечательное в этом плане то, что в нем применяются два упо- рядоченных просмотра индексов, созданных для поддержки этого решения (idx_user_start_id and idx user end id), причем решение полагается на эти упорядоченные просмотры при выполнении (сделайте глубокий вдох) вы- числения: номеров строк для счетчиков событий начала (s); номеров строк для счетчиков событий начала ($); соединения слиянием для унификации результатов; количества событий начала и завершения (se) в унифицированных на- борах; номеров строк, которые нужны для получения grpnum после фильтра- ции. И все это выполняется без единого оператора сортировки! Действительно радостно видеть, что оптимизатор так точно понимает принцип представ- ления упорядочения. Наконец хеш-агрегат используется для группировки данных по grpnum (строк, оставшихся после фильтрации). Так как у боль- шинства операций в этом плане линейная масштабируемость, все решение должно масштабироваться примерно линейно. В общем зачете план предусматривает только два просмотра данных (по разу на каждый индекс) в порядке индекса. Как я говорил, на выполнение этого решения на моем ноутбуке уходит всего 47 секунд. Но есть одна вещь, которая в этом решении используется недостаточно эффективно — паралле- лизм. И эта проблема решена в следующем решении. Чтобы задействовать параллелизм «по полной», нужно инкапсулировать логику решения из листинга 5-1 в табличное выражение, которое работает с одним пользователем, азатем применить его к каждому пользователю. Здесь предполагается, что у вас есть таблица с уникальными пользователями, что вполне справедливо. После этого очень удобно инкапсулировать логику ре- шения из листинга 5-1 для одного пользователя во встроенную табличную функцию, как в этом коде:
Решения на основе Т-SQL с использованием оконных функций Глава 5 213 IF OBJECT_ID(’dbo.Userintervals', 'IF') IS NOT NULL DROP FUNCTION dbo. Userintervals; GO CREATE FUNCTION dbo.Userlntervals(@user AS VARCHAR(14)) RETURNS TABLE AS RETURN WITH C1 AS ( SELECT id, starttime AS ts, +1 AS type, NULL AS e, ROW_NUMBER() OVER(ORDER BY starttime, id) AS s FROM dbo.Sessions WHERE username = @user UNION ALL SELECT id, endtime AS ts, -1 AS type, ROW_NUMBER() OVER(ORDER BY endtime, id) AS e, NULL AS s FROM dbo.Sessions WHERE username = @user ), C2 AS ( SELECT C1.*, ROW_NUMBER() OVER(ORDER BY ts, type DESC, id) AS se FROM C1 ), C3 AS ( SELECT ts, FLOOR((ROW_NUMBER() OVER(ORDER BY ts) - 1) / 2 + 1) AS grpnum FROM C2 WHERE COALESCED - (se - s) - 1, (se - e) - e) = 0 ) SELECT MIN(ts) AS starttime, max(ts) AS endtime FROM C3 GROUP BY grpnum; GO После этого можно использовать оператор CROSS APPLY для примене- ния этой функции к каждому пользователю в таблице Users table: SELECT U.username, A.starttime, A.endtime FROM dbo.Users AS U CROSS APPLY dbo.UserIntervals(U.username) AS A; Для этого запроса SQL Server генерирует параллельный план, показан- ный на рис. 5-19.
214 Глава 5 Решения на основе Т-SQL с использованием оконных функций Query 1: Query cost (relitive to the bitch) : IDO A SELECT Uustrnunt. A.stirttiire, A.endtime FROM dbo.Users AS U CROSS APPLY dbo.Userintervils(U.usernime) AS A; (Cither Streims) Cost: 0 % ^Nested Loops < Clustered Index Scin (Clustered) (Inner loin) [Users], [PKJJsers] [U] Cost: 0 « Cost: 0 « Рис. 5-19. План решения с использованием APPLY и номеров строк Как видите, в плане применяется параллельный просмотр кластеризо- ванного индекса таблицы Users, после чего отдельные пользователи обраба- тываются во внутренней ветке соединения Nested Loops. Операции во вну- тренней ветке что-то напоминают — это похоже на происходящее в плане на рис. 5-18, но на этот раз обрабатываются данные только одного пользовате- ля. Естественно, что внутренняя ветка выполняется параллельно многими потоками. На моем мобильном компьютере выполнение этого кода заняло шесть секунд. Второе решение на основе оконных функций показано в листинге 5-2. В нем используется оконная функция агрегирования SUM с использовани- ем особенностей определения окна, появившихся в SQL Server 2012. Листинг 5-2. Решение с использованием оконного агрегата WITH С1 AS ( SELECT username, starttime AS ts, +1 AS type, 1 AS sub FROM dbo.Sessions UNION ALL SELECT username, endtime AS ts, -1 AS type, 0 AS sub FROM dbo.Sessions ). C2 AS ( SELECT C1.*, SUM(type) OVER(PARTITION BY username ORDER BY ts, type DESC
Решения на основе Т-SQL с использованием оконных функций Глава 5 215 ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) - sub AS ent FROM C1 ). C3 AS ( SELECT username, ts, FLOOR((ROW_NUMBER() OVER(PARTITION BY username ORDER BY ts) - 1) / 2 + 1) AS grpnum FROM C2 WHERE ent = 0 ) SELECT username, MIN(ts) AS starttime, max(ts) AS endtime FROM C3 GROUP BY username, grpnum; Query 1. Query cost (relative to the bitch) : Ю0Я WITH Cl AS ( SELECT usernime. stirttime AS ts. +1 AS type. 1 AS sub FROM dbo .Sessions WIOH ALL SELECT userntnie. endtime AS ts. -1 AS type Рис. 5-20. План выполнения решения с использованием оконного агрегата В этом решении применяются принципы, похожие на те, что используют- ся в предыдущем решении, но вместо номеров строк для вычисления числа активных сеансов в каждый момент времени задействована оконная функ- ция агрегирования SUM. Нарастающий итог типов (как вы помните, «+1» представляет событие начала сеанса, а «-1» — событие завершения) в хро- нологическом порядке, секционированный по username, является числом активных сеансов в каждый момент времени. А теперь вспомним, что для событий начала сеанса надо знать число активных сеансов до этих событий, а для событий завершения — число активных сеансов после этих событий. Поэтому нужно отнять единицу от числа событий начала сеанса и ничего не вычитать из числа событий завершения. Это решение генерирует атрибут по имени sub, принимающий значение «1» для событий начала и «0» — для событий завершения, после этого это значение вычитается из нарастающего итога с применением следующего выражения:
216 Глава 5 Решения на основе Т-SQL с использованием оконных функций SUM(type) OVER(PARTITION BY username ORDER BY ts, type DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) - sub AS ent Остальное похоже на логику предыдущего решения. Для этого решения создается план, показанный на рис. 5-20, и оно выполняется на моем ноут- буке за 87 секунд. Примененный в предыдущем решении прием создания встроенной та- бличной функции для одного пользователя и применения оператора APPLY для вызова этой функции для каждого пользователя в таблице Users можно использовать и в решении на основе оконного агрегата SUM. Вот код опре- деления встроенной функции: IF OBJECT_ID(‘dbo.Userintervals’, ‘IF’) IS NOT NULL DROP FUNCTION dbo. Userintervals; GO CREATE FUNCTION dbo.Userlntervals(@user AS VARCHAR(14)) RETURNS TABLE AS RETURN WITH C1 AS ( SELECT starttime AS ts, +1 AS type, 1 AS sub FROM dbo.Sessions WHERE username = @user UNION ALL SELECT endtime AS ts, -1 AS type, 0 AS sub FROM dbo.Sessions WHERE username = @user ), C2 AS ( SELECT Cl.*, SUM(type) OVER(ORDER BY ts, type DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) - sub AS ent FROM C1 ), C3 AS ( SELECT ts, FLOOR((ROW_NUMBER() OVER(ORDER BY ts) - 1) / 2 + 1) AS grpnum FROM C2 WHERE ent = 0 )
Решения на основе Т-SQL с использованием оконных функций Глава 5 217 SELECT MIN(ts) AS starttime, max(ts) AS endtime FROM C3 GROUP BY grpnum; А вот запрос, который применяет эту функцию к каждому пользователю: SELECT U.username, A.starttime, A.endtime FROM dbo.Users AS U CROSS APPLY dbo.UserIntervals(U.username) AS A; Для этого решения создается план, показанный на рис. 5-21, и оно выпол- няется на моем ноутбуке за 13 секунд. Query 1: Query cost (relitive to the bitch) 100% SELECT U.usernime, A.stirttime. A. end time FROM dbo.Users AS U CROSS APPLY dbo UserIntervils(U usernime) AS A. Nested Loops (Inner loin) Cost. 0 % Clustered Index Scin (Clustered) [Users]. [PKJJsers] [U] Cost. 0 % Pirillelism (Cither Streims) Cost: 0 % Streim Aggregite (Aggregite) ^Sequence Project (Compute Scilir) Cost; 0 % Segment Cost: 0 « J Streim Aggregite (Aggregite) Cost: 4 % Window Spool Cost: 15 % Conpute Scilir Cost: 0 % Segment Segment Sequence Project (Compute Scilir) Merge loin (Concit'enition) Cost. 20 % Index Seek (NonClustered) [Sessions] . [idx_user_stirt_id] Cost: 16 % Conpute Scilir J Index Seek (NonClustered) [Sessions] . [idx_user_end_id] Рис. 5-21. План выполнения решения с использованием APPLY и оконного агрегата Пробелы и островки Задачу нахождения пробелов и островков в SQL очень часто приходится ре- шать в реальных жизненных ситуациях. Основной принцип заключается в том, что у вас есть определенная последовательность чисел или значений дат и времени, между которыми должен соблюдаться фиксированный интервал, но некоторые элементы отсутствуют. Решение задачи поиска пробелов под- разумевает нахождение элементов, которых не хватает в последовательно- сти, а поиск островков — нахождение непрерывных диапазонов существую- щих значений. Для демонстрации методики поиска пробелов и островков я воспользуюсь таблицей по имени Т1 с численной последовательностью в столбце coll с целым интервалом, равным единице, и таблицу Т2 с после- довательностью метода даты и времени в столбце coll с интервалом в один день. Вот код создания Т1 и Т2 и наполнения их тестовыми данными:
218 Глава 5 Решения на основе Т-SQL с использованием оконных функций SET NOCOUNT ON; USE TSQL2012; - - dbo.TI (численная последовательность с уникальными значениями, - - интервал: 1) IF OBJECT_ID(‘dbo.TI’ , ‘U’) IS NOT NULL DROP TABLE dbo.TI; CREATE TABLE dbo.TI ( coll INT NOT NULL CONSTRAINT PK_T1 PRIMARY KEY ); GO INSERT INTO dbo.T1(co!1) VALUES(2).(3),(7),(8),(9),(11).(15), (16), (17), (28); - - dbo.T2 (временная последовательность с уникальными значениями, - - интервал: 1 день) IF OBJECT_ID(‘dbo.Т2’, ‘U’) IS NOT NULL DROP TABLE dbo.T2; CREATE TABLE dbo.T2 ( CO11 DATE NOT NULL CONSTRAINT PK_T2 PRIMARY KEY ); GO INSERT INTO dbo.T2(co!1) VALUES (‘20120202’), (‘20120203’), (‘20120207’ ), (‘20120208’ ), (‘20120209’ ). (‘20120211’ ), (‘20120215’ ), (‘20120216’ ), (‘20120217’), (‘20120228’ ); Пробелы Как говорилось ранее, задача поиска пробелов предусматривает нахождение диапазонов отсутствующих значений в последовательности. Для наших те- стовых данных требуемый результат для численной последовательности в Т1 таков:
Решения на основе Т-SQL с использованием оконных функций Глава 5 219 rangestart rangeend 4 10 12 18 6 10 14 27 А вот нужный результат для последовательности меток дат и времени вТ2: rangestart rangeend 2012-02-04 2012-02-06 2012-02-10 2012-02-10 2012-02-12 2012-02-14 2012-02-18 2012-02-27 В версиях SQL Server предшествующих SQL Server 2012 методики работы с пробелами были довольно дорогими и подчас сложными. Но с появлением функций LAG и LEAD эту задачу стало возможным решать просто и эффек- тивно. С помощью функции LEAD можно для каждого текущего значения в coll (назовем его cur) следующее значение в последовательности (назовем его nxt). Затем можно фильтром отобрать только пары, разница между которыми больше интервала. Затем надо добавить один интервал к cur и отнять интер- вал от nxt, чтобы получить сведения о пробеле. Вот полное решение для чис- ловой последовательности и план его выполнения (рис. 5-22): WITH С AS ( SELECT coll AS cur, LEAD(coH) OVER(ORDER BY coll) AS nxt FROM dbo.TI ) SELECT cur + 1 AS rangestart, nxt - 1 AS rangeend FROM C WHERE nxt - cur > 1; Query 1: Query cost (relative to the bitch): 100% WITH C AS ( SELECT coll AS cur, LEAD(coll) OVER (ORDER BY coll) AS nxt FROM dbo.71 ) SELECT cur + 1 *S rangestart. nxt - 1 AS rangeend FROP Coopute Scalar Cost: 1 % Stream Aggregate (Aggregate) Cost: 0 % Я n Window Spool Cost: 1 % S eg aent Cost: 0 % Sequence Project (Compute Scalar) Cost: 0 % Clustered Index Scan (Clustered) [Tl] . [PK.Tl] Cost: gs % Рис. 5-22. План решения задачи нахождения пробелов
220 Глава 5 Решения на основе Т-SQL с использованием оконных функций Полюбуйтесь, насколько эффективен этот план: в нем выполняется толь- ко один упорядоченный просмотр индекса на основе столбца coll. Для при- менения этой же методики к временной последовательности надо просто за- действовать функцию DATEDIFF для вычисления разницы между cur и nxt, а затем — функцию DATEADD для добавления или вычитания интервала: WITH С AS ( SELECT coll AS cur, LEAD(coll) OVER(ORDER BY coll) AS nxt FROM dbo.T2 ) SELECT DATEADD(day, 1, cur) AS rangestart, DATEADD(day, -1, nxt) rangeend FROM C WHERE DATEDIFF(day, cur, nxt) > 1; Островки Задача нахождения островков подразумевает выявление диапазонов суще- ствующих значений. Вот ожидаемый результат для числовой последова- тельности: start_range end_range 2 3 7 9 11 11 15 17 28 28 А вот требуемый результат для временной последовательности: start_range end_range 2012-02-02 2012-02-07 2012-02-11 2012-02-15 2012-02-28 2012-02-03 2012-02-09 2012-02-11 2012-02-17 2012-02-28 Одно из самых эффективных решений задачи поиска островков преду- сматривает использование ранжирования. Используется функция DENSE- RANK для создания последовательности целых чисел в упорядочении по coll и вычисляется разница между coll и «плотным рангом» (drnk), примерно так: SELECT coll, DENSE-RANKQ OVER(ORDER BY coll) AS drnk, coll - DENSE_RANK() OVER(ORDER BY coll) AS diff FROM dbo.TI;
Решения на основе Т-SQL с использованием оконных функций Глава 5 221 coll drnk diff 2 1 1 3 2 1 7 3 4 8 4 4 9 5 4 116 5 15 7 8 16 8 8 17 9 8 28 10 18 Заметьте, что в пределах островка разница одинакова, причем она уни- кальна для каждого островка. Это происходит потому, что coll и drnk уве- личиваются с одним интервалом. При переходе на следующий островок coll увеличивается более, чем на один интервал, a drnk всегда увеличивает- ся на один интервал. Поэтому разница в каждом последующем интервале больше, чем в предыдущем. Благодаря тому, что эта разница одинакова и уникальна в пределах каждого островка, можно использовать ее в качестве идентификатора группы. Так что остается только сгруппировать строки по этой разнице и вернуть максимальное и минимальное значение coll в каж- дой группе: WITH С AS ( SELECT coll, coll - DENSE_RANK() OVER(ORDER BY coll) AS grp FROM dbo.TI ) SELECT MIN(col1) AS start_range, MAX(col1) AS end_range FROM C GROUP BY grp: План этого решения показан на рис. 5-23. Query 1 Query cost (relative to the bitch) : ID OX SELECT MIN (coll) AS stirt ringe, MAX(coll) AS end ringe FROM (SELECT coll, coll - DENSE RANK() OVER(ORDER BY coll) AS grp FRCH dbo Tl) AS SELECT Streim Aggregite Sort Cost; 0 % (Aggregite) Cost: 78 % Cost: 0 X Coitpute Scilir Cost: 0 X Sequence Project (Compute Scilir) Cost; О X Segment Cost: 0 X Segment Cost: О X Clustered Index Scin (Clustered) [Tl] [PK_T1] Cost: 22 X Рис. 5-23. План решения задачи нахождения островков План очень эффективен, потому что при вычислении плотного ранга ис- пользуется упорядочение индекса на основе coll. Возможно, вы поинтересуетесь, почему я использую функцию DENSE_ RANK, а не ROW NUMBER. Это нужно для тех случаев, когда не гаран- тируется уникальность значений последовательности. При использовании функции ROW NUMBER эта методика работает, только если значения по-
222 Глава 5 Решения на основе Т-SQL с использованием оконных функций следовательности уникальны (таковы наши тестовые данные), и отказыва- ет, если разрешены дубликаты. При использовании DENSE_RANK решение работает как для уникальных, так и для неуникальных значений, поэтому я всегда предпочитаю использовать функцию DENSE_RANK. Эта же методика применима к временным интервалам, но решение не так очевидно. Вспомните, что в описанном решении создается идентификатор групп, а именно значение, одинаковое для всех членов одного островка и отличающееся от значений для членов в других островках. Во временных последовательностях интервалы между значениями coll и плотного ранга разные — у первого интервал день, а у второго единица. Чтобы этот способ работал, просто вычтите из значения coll количество временных интерва- лов, равное плотному рангу. Для этого надо воспользоваться функцией DATEADD. Тогда вы получите метку даты и времени, которая одинакова для всех членов одного островка и отличается от значений в других остров- ках. Вот код законченного решения: WITH С AS ( SELECT coll, DATEADD(day, -1 * DENSE_RANK() OVER(ORDER BY coll), coll) AS grp FROM dbo.T2 ) SELECT MIN(col1) AS start_range, MAX(col1) AS end_range FROM C GROUP BY grp; Как видите, вместо прямого вычитания результата функции плотного ранга из coll, мы применяем DATEADD для вычитания из coll плотного ранга, умноженного на интервал, то есть день. Есть много задач, в которых требуется применять «островковую» ме- тодику, в том числе отчеты о доступности, периодах активности и другие. Эту же методику можно применять для решения классической задачи упа- ковки интервалов дат. Допустим, есть такая таблица с информацией об ин- тервалах дат: IF OBJECT_ID(‘dbo.Intervals’, ‘U’) IS NOT NULL DROP TABLE dbo.Intervals: CREATE TABLE dbo.Intervals ( id INT NOT NULL, startdate DATE NOT NULL, enddate DATE NOT NULL ); INSERT INTO dbo.Intervals(id. startdate, enddate) VALUES (1, ‘20120212’, ‘20120220’), (2, ‘20120214’, ‘20120312’), (3, ‘20120124’, ‘20120201’):
Решения на основе Т-SQL с использованием оконных функций Глава 5 223 Эти интервалы могут представлять периоды активности, действитель- ности или любые другие типы периодов. Задача заключается в том, чтобы при заданном периоде (с началом ©from и ©to конца), упаковать интервалы в нем. Иначе говоря, вам надо объединить перекрывающиеся и непосред- ственно прилегающие интервалы. Вот ожидаемый результат для приведен- ных тестовых данных при периоде с 1 января 2012 года до 31 декабря 2012 года: rangestart rangeend 2012-01-24 2012-02-01 2012-02-12 2012-03-12 В решении описанная ранее в этой главе функция GetNums используется для генерации последовательности дат, которые укладываются в данный пе- риод. В коде определяется СТЕ по имени Dates, представляющее этот набор дат. Далее код соединяет СТЕ-выражение Dates (псевдоним D) в таблице Intervals (псевдоним /), сопоставляя каждой дате интервалы, которые ее со- держат, используя такой предикат соединения: D.dtBETWEENLstartdateAND Lenddate, Далее в коде используется описанная выше методика вычисления идентификатора групп (назовем его grp), который определяет островки. На базе этого запроса в коде определяется СТЕ-выражение по имени Groups. Наконец внешний запрос группирует строки nog/p и возвращает минималь- ную и максимальную дату каждого островка, которые и представляют собой границы упакованных интервалов. Вот код законченного решения: DECLARE @from AS DATE = *20120101’, @to AS DATE = *20121231’; WITH Dates AS ( SELECT DATEADD(day, n-1, @from) AS dt FROM dbo.GetNums(1, DATEDIFF(day, @from, @to) + 1) AS Nums ), Groups AS ( SELECT D.dt, DATEADD(day, -1 * DENSE_RANK() OVER(ORDER BY D.dt), D.dt) AS grp FROM dbo.Intervals AS I JOIN Dates AS D ON D.dt BETWEEN I.startdate AND I.enddate ) SELECT MIN(dt) AS rangestart, MAX(dt) AS rangeend FROM Groups GROUP BY grp;
224 Глава 5 Решения на основе Т-SQL с использованием оконных функций Заметьте, что это решение работает не очень производительно, если ин- тервалы охватывают длинные периоды времени. И это понятно, потому что решение распаковывает каждый период на отдельные даты. Есть версии задачи нахождения островков, которые намного сложнее базовой версии. Допустим, к примеру, что нужно игнорировать пробелы меньшие или равные определенному размеру, например в числовой после- довательности нас не интересуют пробелы 2 и меньше. Тогда ожидаемый результат будет таким: rangestart rangeend 2 3 7 11 15 17 28 28 Заметьте, что значения 7, 8, 9 и И все являются частью одного островка с началом в 7 и концом в 11. Пробел между 9 и 11 игнорируется, потому что он меньше 2. Для решения этой задачи можно использовать функции LAG и LEAD. Сначала определяем СТЕ по имени С/, в котором запрос таблицы Т1 вы- числяет следующие два атрибута: isstart и isend. Атрибут isstart является флагом, который равен единице, когда значение последовательности явля- ется первым в островке, и нулю в противном случае. Значение не является первым значением в островке, если разница между coll и предыдущим зна- чением (полученным с применением функции LAG) меньше или равна 2, в противном случае это первое значение островка. Аналогично, значение не является последним значением в островке, если разница следующим значе- нием (полученным с применением функции LEAD) и coll меньше или равна 2, в противном случае это последнее значение островка. Затем в коде определяется СТЕ по имени С2, которое отбирает только строки, в которых значения последовательности являются началом или кон- цом островка. С помощью функции LEAD выделяются пары начала и конца каждого островка. Это достигается за счет использования выражения 1 - isend в качестве смещения функции LEAD. Это означает, что если текущая строка, представляющая начало островка, одновременно представляет его конец, то смещения равно нулю, иначе оно равно единице. Наконец внеш- ний запрос просто отбирает из результатов С2 только те строки, в которых isstart равно единице 1. Вот код законченного решения: WITH С1 AS ( SELECT coll, CASE WHEN coll - LAG(col1) OVER(ORDER BY coll) <= 2 THEN 0 ELSE 1 END AS isstart, CASE WHEN LEAD(coll) OVER(ORDER BY coll) - coll <= 2 THEN 0 ELSE 1 END
Решения на основе Т-SQL с использованием оконных функций Глава 5 225 AS isend FROM dbo.TI ). C2 AS ( SELECT coll AS rangestart, LEAD(col1. 1-isend) OVER(ORDER BY coll) AS rangeend. isstart FROM C1 WHERE isstart = 1 OR isend = 1 ) SELECT rangestart, rangeend FROM C2 WHERE isstart = 1; План этого запроса показан на рис. 5-24. Query 1- Query cost (relative to the bitch) 100% WITH Cl AS ( SELECT coll. CASE WHEN coll - LAG(coll) OVER(OPDER BY coll) <= Z THEN 0 ELSE 1 END AS isstirt. CASE WHEN LEAD(coll) 0VER( Sequence Project (Compute Scilir) Cost: 0 % Segment Cost: 0 % Window Spool Segment Conpute Scilir Cost; 1 % Cost: 0 % Cost: 0 % Sequence Project Segment (Compute Scilir) Cost 0 % Cost: 1 % Streim Aggregite (Aggregite) Cost: 0 % Рис. 5-24. План решения задачи нахождения островков при игнорировании пробелов равных и меньших 2 В следующем примере задачи нахождения островков потребуются тесто- вые данные, сгенерированные следующим кодом: IF OBJECT_ID(’dbo.TI’, ’U’) IS NOT NULL DROP TABLE dbo.TI; CREATE TABLE dbo.TI id INT NOT NULL PRIMARY KEY, val VARCHAR(10) NOT NULL ): GO INSERT INTO dbo.TI(id, val) VALUES (2, ’a’), (3, ’a’). (5, ’a’), (7, ’b’),
226 Глава 5 Решения на основе Т-SQL с использованием оконных функций (11, Ь), (13, 'а'), (17, ’а’), (19, ’а), (23, с), (29, 'с'), (31, а), (37, ’а’), (41, а), (43, 'а'), (47, ’с’), (53, ’с'), (59, 'с'); В этой версии задачи нужно определить диапазоны идентификаторов, в которых значение атрибута val одинаково. Надо иметь в виду, что одному значению val могут соответствовать несколько островков. Вот ожидаемый результат: mn mx val 2 5 a 7 11 b 13 19 a 23 29 c 31 43 a 47 59 c Первый шаг этого решения — вычислить разницу между номерами строк с использованием упорядочения по id и номером строк (назовем его grp) на основе упорядочения по val и id\ SELECT id, val, ROW_NUMBER() OVER(ORDER BY id) - ROW_NUMBER() OVER(ORDER BY val, id) AS grp FROM dbo.TI; id val grp 2 a 0 3 a 0 5 a 0 13 a 2 17 a 2 19 a 2 31 a 4 37 a 4 41 a 4
Решения на основе Т-SQL с использованием оконных функций Глава 5 227 43 а 4 7 b -7 11 ь -7 23 с -4 29 с -4 47 с 0 53 с 0 59 с 0 Заметьте, что для каждого уникального значения в атрибуте val, значе- ние grp является уникальным в каждом островке. Это происходит потому, что при упорядочении по id у номеров строк есть пробелы между разными островками, а в номерах строк при упорядочении по val и id их нет. Поэтому для одного значения val при переходе от одного островка к следующему раз- ница увеличивается, а в рамках островка она остается неизменной. Чтобы завершить решение, определим СТЕ на основе предыдущего запроса, после чего во внешнем запросе сгруппируем строки по val и grp и вернем мини- мальный и максимальный идентификаторы для каждого значения val\ WITH С AS ( SELECT id, val, ROW_NUMBER() OVER(ORDER BY id) - ROW_NUMBER() OVER(ORDER BY val, id) AS grp FROM dbo.TI ) SELECT MIN(id) AS mn, MAX(id) AS mx, val FROM C GROUP BY val, grp ORDER BY mn: Медианы В главах 2 и 3 я рассказывал, как вычисляются процентили. Я говорил, что 50-й процентиль обычно называется медианой и, грубо говоря, представляет собой такое значение из набора, для которого 50% всех остальных значений набора данных меньше этого значения. Я показал решения для вычисления любых процентилей как в SQL Server 2012, так и предыдущих версиях SQL Server. Здесь я только напомню вам решение в SQL Server 2012 с использо- ванием функции PERCENTILE CONT (CONT здесь означает модель не- прерывного распределения), а затем покажу интересные решения для вы- числения медианы в более ранних версиях SQL Server. В качестве тестовых данных я воспользуюсь таблицей Stats.Scores, содер- жащей результаты экзаменов студентов. Допустим, нам нужно для каждого экзамена вычислить медиану результатов в предположении модели непре- рывного распределения. Если число результатов в определенном экзамене
228 Глава 5 Решения на основе Т-SQL с использованием оконных функций нечетное, нужно вернуть средний результат. Если же число результатов четное, нужно вернуть среднее значение для двух средних результатов. Вот ожидаемый результат для наших тестовых данных: testid median Test ABC 75 Test XYZ 77.5 Как уже говорилось ранее в книге, функция PERCENTILE CONT поя- вилась в SQL Server 2012 и служит для вычисления процентилей в предпо- ложении модели непрерывного распределения. Однако она реализована как оконная функция, а не как функция, в которой используется сгруппирован- ные упорядоченные наборы. Это означает, что можно использовать ее для получения процентиля вместе со строками данных, но для получения этой информации только раз в группе, нужно добавить определенную логику фильтрации. Например, можно вычислять номера строк с применением того же определения секционирования окон, что и в функции PERCENTILE- CONT, и произвольного упорядочения, а затем фильтром отобрать только строки с номером равным единице. Вот полное решение задачи вычисления медианы результатов экзаменов: WITH С AS ( SELECT testid, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY (SELECT NULL)) AS rownum, PERCENTILE_CONT(O.5) WITHIN GROUP(ORDER BY score) OVER(PARTITION BY testid) AS median FROM Stats.Scores ) SELECT testid, median FROM C WHERE rownum = 1; Оно немного неуклюжее, но свою работу делает. До SQL Server 2012 приходилось быть более изобретательным, тем не ме- нее для решения этой задачи все равно можно было использовать оконные функции. Одно из решений заключалось в вычислении для каждой строки ее позиции в результатах экзамена при упорядочении по оценкам (назовем это pos) и числу результатов для соответствующего экзамена (назовем это ent). Для вычисления pos применяется функция ROW_NUMBER, а для расчета ent — оконная функция агрегирования COUNT. Затем отбираются только строки, которые должны участвовать в вычислении медианы, а имен- но строки, у которых pos равно (ent + 1) / 2 или (ent + 2) / 2. Заметьте, что в этих выражениях используется целочисленное деление, а дробная часть отбрасывается. При нечетном числе элементов оба выражения возвращают
Решения на основе Т-SQL с использованием оконных функций Глава 5 229 одинаковое срединное значение. Например, если в группе 9 элементов, оба выражения возвращают 5. При четном числе элементов оба выражения воз- вращают два срединных значения. Например, если в группе 10 элементов, выражения вернут 5 и 6. После фильтрации нужных строк остается выпол- нить их группировку по идентификатору экзамена и вернуть средний ре- зультат для каждого экзамена. Вот готовое решение: WITH С AS ( SELECT testid, score, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score) AS pos. COUNT(*) OVER(PARTITION BY testid) AS ent FROM Stats.Scores ) SELECT testid, AVG(1. * score) AS median FROM C WHERE pos IN( (ent + 1) / 2, (ent + 2) / 2 ) GROUP BY testid; Другое интересное решение задачи в версиях, предшествующих SQL Server 2012, предусматривает вычисление двух номеров строк: первый при упорядочении по возрастанию по score и studentid (studentid добавлено для детерминизма), а второй — при упорядочении по убыванию. Вот код вычис- ления этих номеров и результат работы запроса: SELECT testid, score, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score, studentid) AS rna, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score DESC. studentid DESC) AS rnd FROM Stats.Scores; testid score rna rnd Test ABC 95 9 1 Test ABC 95 8 2 Test ABC 80 7 3 Test ABC 80 6 4 Test ABC 75 5 5 Test ABC 65 4 6 Test ABC 55 3 7 Test ABC 55 2 8 Test ABC 50 1 9 Test XYZ 95 10 1 Test XYZ 95 9 2 Test XYZ 95 8 3 Test XYZ 80 7 4 Test XYZ 80 6 5 Test XYZ 75 5 6
230 Глава 5 Решения на основе Т-SQL с использованием оконных функций Test XYZ 65 4 7 Test XYZ 55 3 8 Test XYZ 55 2 9 Test XYZ 50 1 10 Можно ли обобщить правило, определяющее строки, которые должны участвовать в вычислении медианы? Заметим, что при нечетном количестве строк, медиана располагается там, где номера строк совпадают. При четном числе элементов медиана находится там, где разница между двумя номерами строк равна единице. Объединить два правила можно так: медиана находится в строках, где абсолютная разни- ца между номерами строк меньше или равна единице. Вот готовое решение, основанное на этом обобщенном правиле: WITH С AS ( SELECT testid, score, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score, studentid) AS rna, ROW_NUMBER() OVER(PARTITION BY testid ORDER BY score DESC, studentid DESC) AS rnd FROM Stats.Scores ) SELECT testid, AVG(1. * score) AS median FROM C WHERE ABS(rna - rnd) <= 1 GROUP BY testid; Условные агрегаты Наша следующая задача — вычисление нарастающих итогов, которые всегда возвращают неотрицательное значение. То есть, если нарастающий итог ста- новится отрицательным, надо возвращать нуль. Далее, при переходе с сле- дующему элементу последовательности, отсчет начинается с нуля. Вот код создания тестовой таблицы Т1 и наполнения ее тестовыми данными: USE TSQL2012; IF OBJECT_ID('dbo.Т1 ’) IS NOT NULL DROP TABLE dbo.TI; GO CREATE TABLE dbo.TI ( ordcol INT NOT NULL PRIMARY KEY, datacol INT NOT NULL );
Решения на основе Т-SQL с использованием оконных функций Глава 5 231 INSERT INTO (1, 10), (4, -15), (5, 5), (6, -10), (8, -15), (10, 20), (17, 10), (18, -10), (20, -30), (31, 20); dbo.TI VALUES Вот требуемый результат, в котором вычисляется неотрицательная сум- ма значений datacol на основе упорядочения по ordcoh ordcol datacol nonnegativesum 1 10 10 4 -15 0 5 5 5 6 -10 0 8 -15 0 10 20 20 17 10 30 18 -10 20 20 -30 0 31 20 20 Я покажу элегантное решение с применением оконных функций, разра- ботанное Гордоном Линофф (Gordon Linoff). Вот код готового решения и результат его работы (промежуточные операции по вычислению partsum и adjust представлены для удобства понимания): WITH С1 AS ( SELECT ordcol, datacol, SUM(datacol) OVER (ORDER BY ordcol ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS partsum FROM dbo.TI ), 02 AS ( SELECT *, MIN(partsum) OVER (ORDER BY ordcol ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) as adjust FROM C1
232 Глава 5 Решения на основе Т-SQL с использованием оконных функций ) SELECT *, partsum - CASE WHEN adjust < 0 THEN adjust ELSE 0 END AS nonnegativesum FROM C2; ordcol datacol partsum adjust nonnegativesum 1 10 10 10 10 4 -15 -5 -5 0 5 5 0 -5 5 6 -10 -10 -10 0 8 -15 -25 -25 0 10 20 -5 -25 20 17 10 5 -25 30 18 -10 -5 -25 20 20 -30 -35 -35 0 31 20 -15 -35 20 Код СТЕ-выражения С1 создает атрибут по имени partsum, содержащий простой нарастающий итог datacol с применением упорядочения по ordcol. Атрибут partsum может принимать негативное значение, потому что значе- ния в datacol могут быть отрицательными. Затем код СТЕ-выражения С2 запрашивает С1, создавая атрибут по имени adjust, в котором вычисляется минимальное значение partsum вплоть до текущего момента. Наконец, внеш- ний запрос проверяет, требуется ли корректировка partsum для вычисления неотрицательной суммы. Если adjust (на данный момент минимум partsum) неотрицательный, корректировка не требуется. При отрицательном же зна- чении adjust этот атрибут нужно вычесть из partsum. Может потребоваться несколько раз изучить результат работы, чтобы по- нять логику, но, уверяю вас, все работает, как надо! Сортировка иерархий Представьте, что вам нужно представить информацию определенной иерар- хии в отсортированном виде. Нужно разместить родительские элементы перед потомками. Также нужен контроль над порядком размещения элемен- тов, находящихся на одном уровне. Для создания тестовых данных восполь- зуемся следующим кодом, который создает и наполняет данными таблицу по имени dbo.Employees (не нужно ее путать с существующей таблицей HR.Employees, где хранятся другие данные): USE TSQL2012; IF OBJECT_ID('dbo.Employees') IS NOT NULL DROP TABLE dbo.Employees: GO CREATE TABLE dbo.Employees
Решения на основе Т-SQL с использованием оконных функций Глава 5 233 empid INT NOT NULL PRIMARY KEY, mgrid INT NULL REFERENCES dbo.Employees. empname VARCHAR(25) NOT NULL, salary MONEY NOT NULL, CHECK (empid <> mgrid) ); INSERT INTO dbo.Employees(empid, mgrid. empname, salary) VALUES (1, NULL, 'David' $10000.00), (2, 1, 'Eitan' $7000.00), (3, 1, 'Ina' $7500.00), (4, 2, 'Seraph' , $5000.00), (5, 2, 'Jiru' $5500.00), (6, 2. 'Steve' $4500.00), (7, 3, 'Aaron' $5000.00), (8, 5, 'Lilach' , $3500.00), (9. 7, 'Rita' $3000.00), (10, 5, 'Sean' $3000.00), (11, 7, 'Gabriel', $3000.00), (12, 9, 'Emilia' , $2000.00), (13, 9, 'Michael', $2000.00), (14, 9, 'Didi' $1500.00); CREATE UNIQUE INDEX idx_unc_mgrid_empid ON dbo.Employees(mgrid. empid); Допустим, что сотрудников надо представить в иерархическом порядке — менеджер перед подчиненными, а также расположить сотрудников в поряд- ке етрпате. Для решения этой задачи можно использовать два инструмента: функцию ROW_NUMBER и рекурсивное СТЕ-выражение. Сначала опре- деляем обычное СТЕ по имени EmpsRN, где вычисляется атрибут по имени п, представляющий номер строки с секционированием по mgrid и упорядо- чением по етрпате и empid {empid добавляется по необходимости для де- терминизма): WITH EmpsRN AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY mgrid ORDER BY empname, empid) AS n FROM dbo.Employees ) SELECT * FROM EmpsRN: empid mgrid empname salary n 1 NULL David 10000.00 1 2 1 Eitan 7000.00 1
234 Глава 5 Решения на основе Т-SQL с использованием оконных функций 3 1 Ina 7500.00 2 5 2 Jiru 5500.00 1 4 2 Seraph 5000.00 2 6 2 Steve 4500.00 3 7 3 Aaron 5000.00 1 8 5 Lilach 3500.00 1 10 5 Sean 3000.00 2 11 7 Gabriel 3000.00 1 9 7 Rita 3000.00 2 14 9 Didi 1500.00 1 12 9 Emilia 2000.00 2 13 9 Michael 2000.00 3 Затем определяется рекурсивное СТЕ по имени EmpsPath, которое итера- тивно перечисляет сотрудников по одному уровню за раз, начиная с корня (исполнительного директора) и далее по иерархической структуре органи- зации. Можно построить бинарный путь для каждого сотрудника, который начинается с пустого пути в корне и на каждом уровне подчинения кон- катенируется путь руководителя с бинарной формой п (номером строки). Заметьте, что для минимизации размера пути нужно столько байт, чтобы охватить максимальное число прямых подчиненных, которые есть у одного менеджера. Например, если число прямых подчиненных не превышает 255, достаточно одного байта, два байта поддерживают до 32 767 прямых подчи- ненных и т. д. Допустим, в нашем случае нужно два байта. Можно также вы- числить уровень сотрудника в дереве (расстояние от корня), назначив кор- ню значение 0 и на каждом уровне добавляя 1. Вот код, который вычисляет как путь сортировки, так и уровень: WITH EmpsRN AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY mgrid ORDER BY empname, empid) AS n FROM dbo.Employees ), EmpsPath AS ( SELECT empid, empname. salary, 0 AS Ivl, CAST(0x AS VARBINARY(MAX)) AS sortpath FROM dbo.Employees WHERE mgrid IS NULL UNION ALL SELECT C.empid, C.empname, C.salary, P.lvl + 1, P.sortpath + CAST(n AS BINARY(2)) FROM EmpsPath AS P
Решения на основе Т-SQL с использованием оконных функций Глава 5 235 JOIN EmpsRN AS С ON С.mgrid = Р.empid ) SELECT * FROM EmpsPath; empid empname salary Ivl sortpath 1 David 10000.00 0 Ox 2 Eitan 7000.00 1 0x0001 3 Ina 7500.00 1 0x0002 7 Aaron 5000.00 2 0x00020001 11 Gabriel 3000.00 3 0x000200010001 9 Rita 3000.00 3 0x000200010002 14 Didi 1500.00 4 0x0002000100020001 12 Emilia 2000.00 4 0x0002000100020002 13 Michael 2000.00 4 0x0002000100020003 5 Jiru 5500.00 2 0x00010001 4 Seraph 5000.00 2 0x00010002 6 Steve 4500.00 2 0x00010003 8 Lilach 3500.00 3 0x000100010001 10 Sean 3000.00 3 0x000100010002 Надо еще обеспечить, чтобы сотрудники отображались в правильном по- рядке, для чего нужно выполнить упорядочение по sortpath. Можно также организовать отступы, отображающие уровень сотрудника в иерархии, ре- плицируя строку Ivl раз. Вот код законченного решения: WITH EmpsRN AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY mgrid ORDER BY empname, empid) AS n FROM dbo.Employees ). EmpsPath AS ( SELECT empid, empname, salary, 0 AS Ivl, CAST(0x AS VARBINARY(MAX)) AS sortpath FROM dbo.Employees WHERE mgrid IS NULL UNION ALL SELECT C.empid, C.empname, C.salary, P.lvl + 1, P.sortpath + CAST(n AS BINARY(2)) FROM EmpsPath AS P
236 Глава 5 Решения на основе Т-SQL с использованием оконных функций JOIN EmpsRN AS С ON С.mgrid = Р.empid ) SELECT empid, salary. REPLICATE(‘ | ‘, Ivl) + empname AS empname FROM EmpsPath ORDER BY sortpath; Посмотрите, как в результате менеджер всегда располагается перед под- чиненными, а сотрудники на одном уровне упорядочиваются по етрпате'. empid salary empname 1 10000.00 David 2 7000.00 | Eitan 5 5500.00 | | Jiru 8 3500.00 | | | Lilach 10 3000.00 | | | Sean 4 5000.00 | | Seraph 6 4500.00 | | Steve 3 7500.00 | Ina 7 5000.00 | | Aaron 11 3000.00 | | | Gabriel 9 3000.00 I I I Rita 14 1500.00 Illi Didi 12 2000.00 |||| Emilia 13 2000.00 Illi Michael Если на одиом уровне требуется другой порядок, допустим по зарплате, просто измените соответствующим образом предложение упорядочения окна в функции ROW_NUMBER: WITH EmpsRN AS ( SELECT *, ROW_NUMBER() OVER(PARTITION BY mgrid ORDER BY salary, empid) AS n FROM dbo.Employees )• EmpsPath AS ( SELECT empid. empname, salary, 0 AS Ivl, CAST(0x AS VARBINARY(MAX)) AS sortpath FROM dbo.Employees WHERE mgrid IS NULL UNION ALL SELECT C.empid. C.empname, C.salary, P.lvl + 1. P.sortpath + CAST(n AS BINARY(2))
Решения на основе Т-SQL с использованием оконных функций Глава 5 237 FROM EmpsPath AS Р JOIN EmpsRN AS C ON C.mgrid = P.empid ) SELECT empid, salary, REPLICATEC | ', Ivl) + empname AS empname FROM EmpsPath ORDER BY sortpath; Вот результат выполнения этого запроса: empid salary empname 1 10000.00 David 2 7000.00 | Eitan 6 4500.00 | | Steve 4 5000.00 | | Seraph 5 5500.00 | | Jiru 10 3000.00 | | | Sean 8 3500.00 | | | Lilach 3 7500.00 | Ina 7 5000.00 | | Aaron 9 3000.00 I I I Rita 14 1500.00 Illi Didi 12 2000.00 |||| Emilia 13 2000.00 |||| Michael 11 3000.00 | | | Gabriel Резюме Меня не перестает восхищать красота оконных функций. Их разработали для того, чтобы устранить ряд недостатков более традиционных конструк- ций SQL, и они хорошо поддаются оптимизации. В этой книге вы имели воз- можность неоднократно убедиться в том, что с помощью оконных функций можно элегантно и эффективно решить огромное количество задач. Я наде- юсь, что изложенное в этой книге станет только началом, и вы найдете соб- ственные интересные и изобретательные способы использования оконных функций. Стандарт SQL придает огромное значение оконным функциям, и поэто- му в нем появляется все больше функций и их возможностей. В Microsoft потратили значительные усилия на реализацию ранее отсутствовавшей под- держки оконных функций в SQL Server 2012, и я думаю, что это позволит намного эффективнее решать многие задачи. Я очень сильно надеюсь, что в компании продолжат работу над реализацией стандарта и в новых версиях SQL Server появится поддержка других оконных функций.
Об авторе Ицик Бен-Ган (Itzik Ben-Gan) является преподавателем и соучредителем в компании SolidQ. Обладатель звания SQL Server Microsoft MVP с 1999 года. Он провел огромное число занятий в самых разных странах, обучая студен- тов работе с запросами, тонкой настройке запросов и программированию с использованием Т-SOL. Перу Ицика принадлежат несколько книг по Т-SQL, много статей в журнале «SQL Server Pro», а также статьи и «белые книги» в библиотеке MSDN и журнале «The SolidQJournal». Ицик регуляр- но выступает на таких мероприятиях, как Tech-Ed, SQL PASS, SQL Server Connections и SolidQ, а также делает презентации для пользовательских групп SQL Server. Он считается признанным экспертом в SolidQ во всем, что касается Т-SQL. Для компании SolidQ Ицик разработал курсы для на- чинающих и опытных пользователей Т-SQL и регулярно читает их в разных странах.
Бен-Ган Ицик Microsoft SQL Server 2012 Высокопроизводительный код T-SQL Оконные функции Совместный проект издательства «Русская редакция» и издательства «БХВ-Петербург». Р У С С К АЯ РЕНИН Подписано в печать 21.11.2012 г. Формат 70x100/16. Усл. физ. л. 16. Тираж 1500 экз. Заказ № 601 Первая Академическая типография «Наука» 199034, Санкт-Петербург, 9 линия, 12/28
Microsoft' SQL Server’ 2012 Высокопроизводительный код T-SQL Оконные функции Научитесь применять мощь оконных функций в Т-SQL для повышения производительности и скорости выполнения своих запросов. Оптимизируйте свои запросы с применением оконных функций Transact-SQL, чтобы получить простые и элегантные решения большого числа задач. Под руководством эксперта по T-SQL Ицика Бен-Гана вы научитесь работать с данными, используя наборы строк, с удивительной гибкостью, прозрачностью и эффективностью. Работаете ли вы администратором баз данных или разрабатываете код на T-SQL — эта книга даст вам в руки проверенный набор решений множества повседневных задач. В этой книге: • применение высокоэффективных методик вычислений с применением наборов строк; • подробный рассказ о функциях упорядоченных наборов, таких как оконные функции ранжирования, аналитики и смещения; • реализация функций гипотетического набора и обратного распределения в стандарте SQL; • новые стратегии более совершенного создания последова- тельностей, разбиения на страницы, фильтрации и сведения; • повышение скорости работы запросов за счет использования секционирования, упорядочения и индексации для покрытия запросов; • применение новых итераторов оптимизации, таких как Window Spool; • решение стандартных задач, таких как вычисление нарастающих итогов, интервалов, медиан и пробелов. Об авторе Ицик Бен-Ган (Itzik Ben- Gan) — обладатель звания Microsoft MVP по SQL Server с 1999 года, соучредитель компании SolidQ, предо- ставляющей консалтинго- вые услуги по всем плат- формам данных Microsoft Постоянный автор журнала SQL Server Pro, регулярный докладчик на отраслевых мероприятиях Professional Association for SQL Server (PASS) и Microsoft TechEd. Примеры кода и системные требования Исходные тексты примеров и тестовые данные можно загрузить с веб * ст ра н и цы http//доMicrosoftcom/fWLink/?ljnkid-246708. Системные требования вы найдете в главе «Об этой книге». <S0N 970-5-7502*0416 Й 9 705750 204158 Издательство Русски редакций Москва, ул Свободы, д 17. а/я 14 E-mail. info@iusedit com Internet: www rusedit.com Ten <495)638-5-638 Издательство БХВ-Петербург Санкт-Петербург. Измайловский пр.. 29 E-mail: mail@bhvru Internet: www.bhv.ru Тел (8 i 2) 251-4244 Microsoft В. ПССШ ЩЩ11