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

Начиная с SQL Server 2008, пространственные интервалы можно представить с помощью пространственных типов данных GEOMETRY и GEOGRAPHY, и оперировать этими типами данных посредством методов. К пространственным запросам можно также применять индексацию и оптимизацию.

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

При подготовке запросов, связанных с интервалами, требуется определить типовые отношения между интервалами. Один из самых распространенных запросов — проверить наличие пересечения двух интервалов (например, найти два сеанса, которые были активными в течение определенного периода времени, представленного входами @l и @u). Проверка на пересечение — это комбинация одиннадцати из тринадцати отношений, определенных Джеймсом Алленом (www.ics.uci.edu/~alspaugh/cls/shr/allen.html). Эти одиннадцать отношений: meets, overlaps, finished-by, contains, starts, equals, started-by, during, finishes, overlapped-by и met-by. К сожалению, классическим методам, используемым для определения пересечения интервалов и некоторых других отношений, свойственны фундаментальные проблемы оптимизации. В результате производительность запросов, связанных с интервалами, обычно очень невысока.

Но положение не безнадежно — исследователи из Мюнхенского университета (Ганс-Питер Кригель, Марко Потке и Томас Зайдль) создали остроумную модель под названием Relational Interval Tree (RI-дерево). Благодаря усовершенствованиям Лорана Мартена (Франция) появилась возможность эффективно обрабатывать интервалы в SQL Server. Однако модель и дальнейшая оптимизация связаны с математическими вычислениями, которые могут показаться слишком сложными.

Существует возможность встроить модель в компонент базы данных SQL Server и сделать ее прозрачной для пользователя, которому достаточно применить простой синтаксис, чтобы сформировать новый вид индекса, оставив запросы неизменными. Остальное — дело SQL Server; ваши запросы будут просто выполняться быстрее. Пока в SQL Server нет подобных функций, но я надеюсь, что компания Microsoft положительно отнесется к идее и реализует такую функциональность в будущих версиях.

В данной статье сначала описывается традиционное представление интервалов в SQL Server, классические запросы к интервалам и основные проблемы оптимизации таких запросов. Затем будут описаны модель RI-дерева и дальнейшая оптимизация. В конце речь пойдет о потенциале интеграции модели RI-дерева в SQL Server.

Следующие ресурсы включают мой запрос относительно расширения функциональности к Microsoft, научную публикацию с описанием модели RI-дерева и статьи, описывающие дальнейшие этапы оптимизации:

  • Microsoft Connect submission suggesting integration in SQL Server (connect.microsoft.com/SQLServer/feedback/details/780746);
  • научная публикация с описанием модели RI-дерева (Kriegel, Potke и Seidl, Мюнхенский университет): «Managing Intervals Efficiently in Object-Relational Databases» (www.dbs.ifi.lmu.de/Publikationen/Papers/VLDB2000.pdf);
  • статьи, описывающие оптимизацию модели RI-дерева (Laurent Martin): «A Static Relational Interval Tree» (www.solidq.com/sqj/Pages/2011-September-Issue/A-Static-Relational-Interval-Tree.aspx) и «Advanced interval queries with the Static Relational Interval Tree»(www.solidq.com/sqj/Pages/Relational/Advanced-interval-queries-with-the-Static-Relational-Interval-Tree.aspx).

Традиционные представления интервалов

Традиционно интервалы в SQL Server представляются двумя атрибутами, содержащими нижнее и верхнее значения каждого интервала, и атрибутом, содержащим ключ. Конечно, в таблице могут быть другие атрибуты для иных целей. В моих примерах используется таблица с именем Intervals и 10 000 000 строк, представляющих интервалы традиционным способом.

С помощью исходного текста в листинге 1 и листинге 2 создайте и заполните данными такую таблицу для своей среды. Листинг 1 строит базу данных с именем IntervalsDB, вспомогательную функцию с именем GetNums, которая формирует последовательность целых чисел в заданном диапазоне, и промежуточную таблицу с именем Stage с 10 000 000 строк.

Вы будете использовать один и тот же источник данных из промежуточной таблицы сначала для заполнения традиционного представления интервалов, а затем представления RI-дерева. Выполните программный код в листинге 2, чтобы создать таблицу Intervals с традиционным представлением интервалов и заполните ее образцовыми данными из промежуточной таблицы.

Для представления как нижних, так и верхних значений интервала используются целые числа, так как модель RI-дерева, которая рассматривается ниже, работает с целыми числами. Значения даты и времени нужно сопоставить целым числам, например, вычисляя разницу между некоторой точкой отсчета и целевым значением с учетом требуемой точности. Конечно, в традиционном представлении интервалов можно использовать любой тип, реализующий полный порядок.

Обратите внимание на два индекса, определенные в таблице Intervals. Один индекс создан по столбцу lower в качестве ключа и охватывает столбец upper, а другой создан по столбцу upper в качестве ключа и охватывает столбец lower.

В большинстве запросов, связанных с интервалами — например, запрос, определяющий пересечения интервалов, — участвуют два предиката диапазона. Например, если входной интервал определен переменной @l (для lower) и @u (для upper), то интервалами, пересекающимися с входным интервалом, являются те, которые удовлетворяют следующему предикату: lower <= @u AND upper >= @l. В этом состоит фундаментальная проблема оптимизации — поиск в индексе может основываться только на одном предикате диапазона. Другие предикаты диапазона необходимо обработать как остаточные. Поэтому оптимизатору необходимо выбрать для работы один из двух индексов и выполнить поиск на основе одного из предикатов по ключу основного индекса, чтобы отобрать подходящие строки. При просмотре оставшихся строк в листах индекса проверьте другой предикат, чтобы определить, какие строки следует возвратить.

Понять это очень важно, поэтому полезно остановиться на данном вопросе подробнее. Рассмотрим следующий список, отсортированный по X, Y:

X, Y

1, 10

1, 20

1, 40

1, 50

2, 20

2, 30

2, 50

3, 20

3, 40

3, 50

3, 50

3, 60

4, 10

4, 10

4, 50

4, 60

Предположим, отсортированный список представляет данные на конечный уровень двоичного индекса. Рассмотрим запрос со следующим фильтром: X >= 3 и Y <= 40. На основе предиката X >= 3 можно с помощью поиска в индексе перейти прямо к конечной строке (3, 20) и просмотреть только строки, удовлетворяющие предикату. Однако нельзя избежать просмотра всех оставшихся строк в элементах индекса и проверки оставшегося предиката Y <= 40 в качестве остаточного предиката.

Мы можем наблюдать проблему оптимизации на примере запроса, выполняющего поиск пересечения в таблице Intervals. Прежде всего, используйте следующий программный код, чтобы включить STATISTICS IO и STATISTICS TIME:

SET STATISTICS IO ON;
SET STATISTICS TIME ON;

Затем выполните приведенный ниже запрос, чтобы обнаружить интервалы, пересекающиеся со входным интервалом, который находится приблизительно в середине всего диапазона:

DECLARE @l AS INT = 5000000, @u AS INT = 5000020;
SELECT id
FROM dbo.Intervals
WHERE lower <= @u AND upper >= @l
OPTION (RECOMPILE);

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

 

План исполнения традиционного запроса на?пересечение
Рисунок 1. План исполнения традиционного запроса на?пересечение

Входные данные в этом запросе представляют интервал, находящийся приблизительно в середине всего диапазона, поэтому в действительности неважно, какой из двух индексов используется оптимизатором. В данном случае оптимизатор выбрал индекс idx_upper. Что касается проблемы оптимизации, обратите внимание, что в разделе Seek Predicates содержится только предикат для столбца upper; предикат для столбца lower находится в разделе Predicate — это остаточный предикат. В результате приходится просматривать около половины конечного уровня индекса. На моем компьютере статистика для этого запроса была следующая — операции логического считывания: 11256; время процессора: 482 мс. Надо признать, не очень высокая эффективность.

Как отмечалось выше, при поиске интервала в середине всего диапазона не имеет значения, какой индекс используется оптимизатором. Однако при поиске интервала, находящегося поблизости от начала диапазона, естественно, эффективнее использовать idx_lower, что связано с просмотром меньшей области в начале элементов индекса. Проведите эксперимент с входными данными @l = 80 и @u = 100; используйте параметр RECOMPILE так, чтобы оптимизатор мог анализировать значения переменных. Статистика для данного запроса — операции логического считывания: 3; время процессора: 0 мс.

Аналогично при запросе, обращенном к концу диапазона данных, оптимизатор выбирает idx_upper, просматривая малую область в конце листа индекса. Например, попробуйте выполнить запрос с входными данными @l = 9999900 и @u = 9999920. Статистика для данного запроса — операции логического считывания: 3; время процессора: 0 мс.

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

RI-дерево

Модель RI-дерева, разработанная Кригелем, Потке и Зайдлем, обеспечивает очень эффективную обработку запросов к интервалам. При построении модели вычисляется атрибут каждого интервала (сохраняется в качестве столбца в таблице), строятся два индекса и используются новые запросы для поиска пересечений и других отношений интервала.

Ядро модели — виртуальное двоичное дерево, узлы которого представляют собой целочисленные значения в диапазоне, который требуется охватить. Например, если нужно представить интервалы, которые начинаются и кончаются в диапазоне значений от 1 до 31, то виртуальное дерево представлено на рисунке 2 (пока не обращайте внимания на показанные здесь интервал и узел ветвления).

 

Виртуальное базовое дерево и узел ветвления
Рисунок 2. Виртуальное базовое дерево и узел ветвления

Можно воспользоваться функцией LOG, чтобы вычислить высоту дерева. Чтобы охватить диапазон от 1 до значения @max, высота двоичного дерева — @h = CEILING(LOG(@max + 1, 2)). Корень дерева — POWER(2, @h-1). Причина, по которой дерево — виртуальное, заключается в том, что полное дерево нигде не запоминается; его узлы используются только при необходимости, как будет показано ниже.

Узел ветвления. Фундаментальный компонент модели RI-дерева — объект, именуемый «узлом ветвления» (fork node). Он рассчитывается для каждого интервала, который необходимо представить в базе данных, и хранится вместе с ним. На рисунке 2 показано, как вычисляется узел ветвления для некоторого тестового интервала. Вы проходите по виртуальному дереву начиная с корня с разветвлением на два; первый узел, обнаруженный внутри интервала, — узел ветвления.

В листинге 3 реализован алгоритм, основанный на модели RI-дерева в определяемой пользователем функции T-SQL с именем forkNode для дерева с высотой 31 (охватывает диапазон от 1 до 2147483647).

Например, вызовите функцию с входными данными 11 и 13, и получите 12 в качестве узла ветвления на выходе:

SELECT dbo.forkNode(11, 13);

Недостаток расчета узла ветвления, представленного в листинге 3, в том, что используется определяемая пользователем функция T-SQL и итеративная логика. Такая комбинация очень неэффективна, особенно если требуется рассчитывать узел ветвления для каждого интервала, сохраняемого в базе данных. Чтобы дать представление о степени неэффективности, отмечу, что на моем компьютере для заполнения таблицы с 10 000 000 строк потребовалось 304 секунды, из которых 297 секунд было потрачено на расчет узла ветвления.

Нагрузка на процессор в алгоритме итераций для расчета узла ветвления пропорциональна высоте дерева. В модели RI-дерева Кригеля, Потке и Зайдля используется вариант, при котором высота и диапазон дерева динамически расширяются в зависимости от добавляемых интервалов. Но при этом необходимо ввести дополнительные параметры для дерева и изменять некоторые из них при каждом добавлении интервала. В результате поддерживаются только однострочные вставки; кроме того, вставки выполняются медленно и часто приводят к возникновению узкого места.

Лоран Мартен предложил способ расчета узла ветвления с использованием скалярного выражения. В результате появилась возможность обрабатывать большое RI-дерево и задействовать очень эффективные многострочные вставки. Как показано на рисунке 3, узел ветвления для данного интервала — наименьший общий предок границ интервала, представленных значениями в столбцах lower (11) и upper (13).

 

Оптимизированный узел ветвления
Рисунок 3. Оптимизированный узел ветвления

Рассмотрите двоичное представление узлов на рисунке 3.

Для каждого данного узла примем L = ведущие разряды перед завершающими нулями; например, для узла 12 (01100), L = 011.

При данном узле X, ведущие разряды которого — L, все узлы в левом поддереве X имеют значение ведущих разрядов L-1 (например, для L = 011, L-1 = 010), а все узлы в правом поддереве X имеют значение ведущих разрядов L.

Для некоторого неконечного узла X и X-1 предок один и тот же, поэтому для расчета предка X значение X можно заменить на X-1.

Если Z — конечный узел, Z и Z-1 отличаются только последним разрядом; для Z это 1, а для Z-1 — 0.

Учитывая эти обстоятельства, узел ветвления можно рассчитать следующим образом: сопоставление префикса (lower — 1) и upper, соединенное с 1 и дополненное завершающими нулями.

Выполнить эти вычисления в SQL Server можно с помощью следующих действий (взяв в качестве примера интервал [11, 13] на рисунке 3):

Let A = (lower — 1) ^ upper
Let B = POWER(2, FLOOR(LOG(A, 2)))
Let C = upper % B
Let D = upper — C

На шаге 1 вычисляется значение (назовем его A), отмечающее разряды, различающиеся в (lower — 1) и upper, как «единицы». Чтобы добиться этого, выполняется побитовая операция XOR между (lower — 1) и upper. В нашем примере 11 ^ 13 в двоичном представлении — 01010 ^ 01101 = 00111.

На шаге 2 вычисляется значение (назовем его B), в котором крайний левый разряд, различающийся в (lower — 1) и upper, устанавливается в «единицу», а все остальные разряды устанавливаются в 0. Другими словами, этот шаг определяет крайний левый разряд в A, который установлен. Обратите внимание, что на основе приведенных выше соображений крайний левый разряд, различающийся в (lower — 1) и upper, будет равен 0 в (lower — 1) и 1 в upper. В нашем примере результат шага в двоичной форме — 00100.

На шаге 3 сохраняются завершающие разряды из upper, следующие после разряда, установленного в 1 в B. В нашем примере это 01101 % 00100 = 01.

На шаге 4 завершающим разрядам в upper после установленного разряда в B присваивается значение 0. В нашем примере: 01101 — 00001 = 01100. Готово — мы получили узел ветвления!

Все эти операции можно объединить в следующей формуле (представленной как выражение T-SQL в SQL Server 2012) для вычисления узла ветвления:

upper — upper % POWER(2, FLOOR(LOG((lower — 1) ^ upper, 2)))

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

Получив скалярное выражение для расчета узла ветвления на основе столбцов lower и upper, можно реализовать его как вычисляемый столбец (назовем его «узел») в таблице, содержащей интервалы. На основе модели RI-дерева потребуются два индекса: один для списка ключей (node, lower) и другой для списка ключей (node, upper). Обратите внимание, что в зависимости от ваших потребностей можно добавить ведущие столбцы в список ключей для фильтров на основе равенства в запросах, а также столбцы в список index include для полноты охвата.

Исходный текст в листинге 4 создает таблицу IntervalsRIT, заполняет ее тестовыми данными из промежуточной таблицы и создает ограничение первичного ключа и индексацию на основе модели RI-дерева.

Заполненная таблица IntervalsRIT и ее индексы — это представление интервалов на основе RI-дерева, которое заменяет прежнюю таблицу Intervals и ее индексы. Вспомните, что при использовании функции forkNode на моем компьютере потребовалось 297 секунд просто для того, чтобы рассчитать узлы ветвления для 10 000 000 интервалов. Благодаря оптимизированной формуле Мартена это заняло лишь 6 секунд.

Запросы к модели RI-дерева. В таблице IntervalsRIT интервалы хранятся, наряду с узлом ветвления, в столбце с именем node. Рассмотрим, как направить запрос к таблице, чтобы определить интервалы, пересекающиеся с некоторым входным интервалом [@l, @u]. В примерах в этой статье используется входной интервал [@l = 11, @u = 13].

В модели RI-дерева имеется три несвязанных группы интервалов, которые могут пересекаться с входным интервалом. Я называю их левой (left), средней (middle) и правой (right) группами.

Левая группа. Рассмотрим путь виртуального дерева, спускающегося из корня к @l, как показано на рисунке 4. Пусть leftNodes — набор всех узлов w, которые встречаются на пути и находятся слева от входного интервала.

 

Левые узлы
Рисунок 4. Левые узлы

Для всех узлов w в leftNodes истинны следующие утверждения (см. рисунок 4).

  • Интервал, зарегистрированный в узле d в левом поддереве w, не может пересекаться с входным интервалом. Если бы это произошло, он был бы зарегистрирован в предке d.
  • Интервал, зарегистрированный в w, может принадлежать к одной из двух разновидностей: (1) если upper < @l, то очевидно интервал не пересекается с входным интервалом; (2) если upper >= @l, то интервал должен пересекаться с входным интервалом, поскольку уже известно, что lower <= @u (интервал, зарегистрированный в узле, который находится слева от входного интервала, очевидно начинается раньше, чем заканчивается входной интервал).

Поэтому интервалы, принадлежащие к левой группе, зарегистрированы в узле в leftNodes и имеют haveupper >= @l.

В листинге 5 содержится определение табличной функции с именем leftNodes, которая возвращает узлы в указанном выше наборе leftNodes.

Следующий запрос возвращает все интервалы в левой группе (зарегистрированные в узлах в leftNodes и пересекающиеся с входным интервалом):

DECLARE @l AS INT = 5000000, @u AS INT = 5000020;
SELECT I.id
FROM dbo.IntervalsRIT AS I
JOIN dbo.leftNodes(@l, @u) AS L
ON I.node = L.node
AND I.upper >= @l;

Изучите план запроса на рисунке 5.

 

План запроса с левыми узлами
Рисунок 5. План запроса с левыми узлами

План выполняет поиск в индекс idx_upper для каждого узла в leftNodes. Важно, что теперь существует один предикат равенства и один предикат диапазона; и тот и другой можно применять как предикаты поиска.

Правая группа. Правая группа симметрична левой. Рассмотрите путь виртуального дерева, спускающегося от корня к @u, как показано на рисунке 6. Пусть rightNodes — набор всех узлов w, которые встречаются на пути и находятся справа от входного интервала.

 

Правые узлы
Рисунок 6. Правые узлы

Для всех узлов w в rightNodes истинны следующие утверждения (см. рисунок 6).

  • Интервал, зарегистрированный в узле d в правом поддереве w, не может пересекаться с входным интервалом. Если бы это произошло, он был бы зарегистрирован в предке d.
  • Интервал, зарегистрированный в w, может принадлежать к одной из двух разновидностей: (1) если lower > @u, то очевидно интервал не пересекается с входным интервалом; (2) если lower <= @u, то интервал должен пересекаться с входным интервалом, поскольку уже известно, что upper >= @l (интервал, зарегистрированный в узле, который находится справа от входного интервала, очевидно заканчивается после того, как начинается входной интервал).

Поэтому интервалы, принадлежащие к правой группе, зарегистрированы в узле в rightNodes и имеют lower <= @u.

В листинге 5 содержится определение табличной функции, именуемой rightNodes, которая возвращает узлы в указанном выше наборе rightNodes.

Следующий запрос возвращает все интервалы в правой группе (зарегистрированные в узлах в rightNodes, которые пересекаются с входным интервалом):

DECLARE @l AS INT = 5000000, @u AS INT = 5000020;
SELECT I.id
FROM dbo.IntervalsRIT AS I
JOIN dbo.rightNodes(@l, @u) AS R
ON I.node = R.node
AND I.lower <= @u;

На рисунке 7 показан план для этого запроса. Он симметричен плану на рисунке 5, но на этот раз используется индекс idx_lower.

 

План запроса с правыми узлами
Рисунок 7. План запроса с правыми узлами

Средняя группа. Пусть middleNodes — набор узлов w, находящихся во входном интервале, как показано на рисунке 8.

 

Средние узлы
Рисунок 8. Средние узлы

Любой интервал, зарегистрированный в w, имеет lower <= @u и upper >= @l; поэтому он пересекается с входным интервалом. Используйте следующий запрос, чтобы вернуть все интервалы в средней группе:

DECLARE @l AS INT = 5000000, @u AS INT = 5000020;
SELECT id
FROM dbo.IntervalsRIT
WHERE node BETWEEN @l AND @u;

Оптимизатор может использовать как idx_upper, так и idx_lower для эффективной обработки запроса. На рисунке 9 мы видим, что в данном случае оптимизатор, похоже, предпочел использовать idx_upper.

 

План запроса для средних узлов
Рисунок 9. План запроса для средних узлов

Листинг 6 содержит исходный текст на основе модели RI-дерева, объединяющий результаты запросов, возвращающих пересечения во всех трех группах (левой, средней и правой).

Для объединенного запроса была получена следующая статистика: 81 для IntervalsRIT + 2 для табличной переменной, возвращенной leftNodes + 2 для табличной переменной, возвращенной rightNodes; время процессора: 16 мс. Это намного лучше, чем у традиционного запроса, показанного в начале статьи; напомню статистику для традиционного запроса: операции логического считывания: 11256; время процессора: 482 мс.

Оптимизированный расчет предков. Недостаток реализации функций leftNodes и rightNodes в предшествующем разделе заключается в использовании итеративной логики, не отличающейся эффективностью в T-SQL. Представьте избыточные затраты для поиска пересечений не с единственным входным интервалом, а с целым набором входных интервалов.

Лоран Мартен предложил элегантное решение, в котором используется логика, основанная на наборах. В данной статье описана разновидность решения, на мой взгляд, более доступная для понимания. Основная логика примерно такая же, как в решении Мартена.

Если имеется входной интервал [@l, @u], то leftNodes — набор предков @l, которые находятся слева от входного интервала. Аналогично, rightNodes — набор предков @r, находящихся справа от входного интервала. Мартен сделал следующее наблюдение относительно предков любого данного узла @node. Предположим, @node = 13 (в двоичном формате 01101). Можно определить родителя узла, присвоив значение 0 самому правому разряду узла, а разряду слева от него — значение 1. Этот процесс можно повторять до тех пор, пока не будет достигнут корень, чтобы определить всех предков. Например, предки 13 — 14, 12, 8 и 16:

01101 (13)

01110 (14)

01100 (12)

01000 (8)

10000 (16)

Чтобы вычислить предков данного узла, используется вспомогательная таблица с именем BitMasks, похожая на таблицу 1 (значения в столбцах b1 и b3 представлены в двоичном формате).

Для виртуального дерева с высотой h таблицу следует заполнить строками, в которых n имеет значения между 1 и h-1. Например, программный код в Листинге 7 создает таблицу BitMasks и заполняет ее значениями для дерева с высотой h = 31 (n от 1 до 30).

Может возникнуть вопрос, почему в таблице BitMasks нет столбца b2. В данной разновидности решения используются только b1 и b3, но в первоначальном решении Мартена есть также столбец b2.

Обратите внимание на следующие особенности таблицы 1. Чтобы вычислить предков входного узла @node, направляют запрос к таблице BitMasks, применяя выражение @node & b1 | b3 на каждом уровне, что в сущности реализует описанную выше логику: на каждом уровне назначьте значение 0 разряду этого уровня и присвойте разряду слева от него значение 1. Однако необходимо отфильтровать только уровни, на которых существуют предки, а именно те, у которых самый правый разряд, установленный в 1 (просто b3), находится слева от самого правого установленного в 1 разряда в @node.

В SQL Server для представления целых чисел используется дополнительный код. Как отмечается в соответствующей статье Википедии, «простой способ ручного преобразования двоичного числа в дополнительный код — начать с наименьшего значащего разряда и скопировать все нули (двигаясь в направлении к наибольшему значащему разряду) до тех пор, пока не встретится первая 1, и инвертировать все оставшиеся разряды».

Это значит, что для данного значения @node самый правый установленный в 1 разряд можно вычислить с помощью выражения @node & -@node. Для фильтрации только уровней, представляющих предков положительного входного узла, следует использовать следующий предикат: b3 > @node & -@node.

В Листинге 8 содержится определение возвращающей табличное значение встроенной функции Ancestors, которая возвращает всех предков входного узла. Чтобы возвратить только левых предков данного узла, направляется запрос к функции и фильтруются только строки, в которых возвращенный узел меньше входного узла. Чтобы возвратить только правых предков, направляется запрос к функции и фильтруются только строки, в которых возвращенный узел больше входного узла.

Запрос в Листинге 9 используется вместо представленного в Листинге 6, чтобы возвратить интервалы, пересекающиеся с входным интервалом [@l, @u].

Обратите внимание на добавление фильтров, исключающих узлы из левого пути, которые меньше минимального узла в таблице, и узлы из правого пути, которые больше максимального узла в таблице. На рисунке 10 показан план запроса в Листинге 9.

 

План запроса с таблицей BitMasks и функцией Ancestors
Рисунок 10. План запроса с таблицей BitMasks и функцией Ancestors

Поскольку функция Ancestors встроенная, план просматривает таблицу BitMasks напрямую, без накладных расходов, связанных с самой функцией, как в предыдущем случае. Статистика этого запроса — операции логического считывания: 66 для IntervalsRIT + 2 для BitMasks; время процессора: 0 мс.

Возможности интеграции в SQL Server

Благодаря модели RI-дерева и оптимизации удается весьма эффективно обрабатывать интервалы в SQL Server. Но математические выкладки, необходимые для этого решения, могут оказаться слишком сложными для пользователей. Есть возможность встроить модель в ядро SQL Server, сделав ее прозрачной для пользователя. Достичь этого можно несколькими способами.

Один вариант — ввести новый тип индекса (назовем его индексом интервала), который пользователю необходимо создать с помощью довольно простого синтаксиса. SQL Server может рассчитать узел ветвления для каждого интервала и построить два двоичных индекса, таких как описанные выше idx_lower и idx_upper. Необходим также оптимизатор, чтобы обнаружить запросы интервалов на основе предикатов в фильтре запроса, а после обнаружения следует выполнить внутреннюю обработку запроса по методу, представленному в листинге 9 (или листинге на основе исходной модели). При таком подходе пользователю остается лишь создать индекс интервала, а ядро SQL Server сделает остальное. Исходные запросы остаются неизменными, и лишь выполняются быстрее.

Описанные ранее индексы idx_lower и idx_upper представляют самую простую форму необходимых индексов. Как описано в работе «Advanced interval queries with the Static Relational Interval Tree», все отношения Аллена для интервалов можно обработать на основе модели RI-дерева. Для некоторых требуется один индекс для списка ключей (node, lower, upper) и другой для (node, upper, lower). Для запросов, относящихся только к пересечениям, достаточно определить индексы для (node, lower) и (node, upper) соответственно (поэтому параметр INTERSECTS_ONLY в предложенном синтаксисе индекса). Кроме того, если запрос имеет дополнительные фильтры на основе равенства для других столбцов, необходимо добавить их к обоим индексам как начальные ключи. Наконец, если нужно возвратить дополнительные столбцы помимо указанных в списке ключей, и требуется, чтобы индексы покрывали запрос, необходимо добавить эти столбцы как включенные. Поэтому предложенный индекс интервала со всеми необязательными элементами будет выглядеть следующим образом:

CREATE INDEX myindex
ON dbo.Intervals[(fcol1, fcol2,. ..)] — начальные
equality-based filters
INTERVAL(lower, upper) — столбцы интервала
[INCLUDE(icol1, icol2,. ..)] — включенные столбцы
[WITH (INTERSECTS_ONLY = ON)]; — определяет список ключей

Во внутреннем механизме SQL Server будут созданы следующие два обычных B*tree-индекса:

CREATE INDEX myidx1 ON dbo.Intervals([fcol1, fcol2,. ..,]
node, lower[, upper]) [INCLUDE(icol1, icol2,. ..)];
CREATE INDEX myidx2 ON dbo.Intervals([fcol1, fcol2,. ..,]
node, upper[, lower]) [INCLUDE(icol1, icol2,. ..)];

Как отмечалось выше, оптимизатор обнаружит запросы интервалов на основе предикатов в фильтре запроса и воспользуется этими индексами.

Помните, что хотя во всех примерах используются целые числа, на практике часто приходится работать с интервалами дат и времени. Сопоставление значений даты и времени целым числам и обратное может привести к значительным затратам в T-SQL. При внутренней реализации использование собственного скомпилированного кода в сочетании с каким-нибудь языком низкого уровня позволит компании Microsoft сделать это эффективно.

Среди других потенциальных дополнений к SQL Server на основе этой модели — встроенные функции для расчета узла ветвления и предков, поддержки типов даты и времени, и опять же, их более эффективной реализации по сравнению с пользовательскими вычислениями. Кроме того, эти функции обеспечат индивидуальный подход для опытных пользователей. Как отмечалось выше, я подал соответствующее предложение о расширении функциональности в компанию Microsoft.

Ускоряем обработку запросов, работающих с интервалами

Традиционным методам, часто используемым для обработки запросов, работающих с интервалами, особенно типовых запросов пересечений, свойственны фундаментальные проблемы, снижающие производительность. Если в фильтре запроса имеется два предиката диапазона, только один можно использовать в качестве предиката поиска в индексе на основе двоичного дерева. Благодаря модели RI-дерева и дополнительной оптимизации удается получить индексный поиск на основе единственного предиката диапазона, что существенно ускоряет обработку запросов. Однако модель и ее оптимизация может оказаться излишне сложной для пользователей. Поскольку проблема — чисто техническая, и решение уже существует, его можно полностью интегрировать в ядро базы данных, сделав прозрачным для пользователей. В заключение я бы хотел поблагодарить Ганса-Питера Кригеля, Марко Потке и Томаса Зайдля за предложенную ими модель RI-дерева, а также Лорана Мартена за внесенные в нее дополнения.

Листинг 1. Исходный текст для создания образцовой базы данных, вспомогательной функции и данных промежуточной таблицы

— создание образцовой базы данных IntervalsDB

SET NOCOUNT ON;
USE master;
IF DB_ID('IntervalsDB') IS NOT NULL DROP DATABASE IntervalsDB;
CREATE DATABASE IntervalsDB;
GO
USE IntervalsDB;
GO

— создание вспомогательной функции dbo.GetNums

— цель: табличная функция, возвращающая последовательность целых чисел между входными значениями

@low and @high
CREATE FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE
AS
RETURN
WITH
L0 AS (SELECT c FROM (SELECT 1 UNION ALL SELECT 1) AS D(c)),
L1 AS (SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 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

— создание промежуточной таблицы dbo.Stage с 10 000 000 интервалами

CREATE TABLE dbo.Stage
(
id INT NOT NULL,
lower INT NOT NULL,
upper INT NOT NULL,
CONSTRAINT PK_Stage PRIMARY KEY(id),
CONSTRAINT CHK_Stage_upper_gteq_lower CHECK(upper >= lower)
);
DECLARE
@numintervals AS INT = 10000000,
@minlower AS INT = 1,
@maxupper AS INT = 10000000,
@maxdiff AS INT = 20;
WITH C AS
(
SELECT
n AS id,
@minlower + (ABS(CHECKSUM(NEWID())) %
(@maxupper — @minlower — @maxdiff + 1)) AS lower,
ABS(CHECKSUM(NEWID())) % (@maxdiff + 1) AS diff
FROM dbo.GetNums(1, @numintervals) AS Nums
)
INSERT INTO dbo.Stage WITH(TABLOCK) (id, lower, upper)
SELECT id, lower, lower + diff AS upper
FROM C;

Листинг 2. Исходный текст для создания и заполнения таблицы Intervals

CREATE TABLE dbo.Intervals
(
id INT NOT NULL,
lower INT NOT NULL,
upper INT NOT NULL,
CONSTRAINT PK_Intervals PRIMARY KEY(id),
CONSTRAINT CHK_Intervals_upper_gteq_lower CHECK(upper >= lower)
);
CREATE INDEX idx_lower ON dbo.Intervals(lower) INCLUDE(upper);
CREATE INDEX idx_upper ON dbo.Intervals(upper) INCLUDE(lower);
INSERT INTO dbo.Intervals WITH(TABLOCK) (id, lower, upper)
SELECT id, lower, upper
FROM dbo.Stage;

Листинг 3. Определение функции forkNode

— На основе «Managing Intervals Efficiently in Object-Relational Databases»

CREATE FUNCTION dbo.forkNode(@lower AS INT, @upper AS INT) RETURNS INT
WITH SCHEMABINDING
AS
BEGIN
DECLARE @node AS INT = 1073741824; — @node = 2^(h-1),
h = height of tree, #values: 2^h — 1
DECLARE @step AS INT = @node / 2;
WHILE @step >= 1
BEGIN
IF @upper < @node
SET @node -= @step;
ELSE IF @lower > @node
SET @node += @step;
ELSE
BREAK;
SET @step /= 2;
END;
RETURN @node;
END;
GO

Листинг 4. Исходный текст для создания и заполнения таблицы IntervalsRIT

— На основе «A Static Relational Interval Tree»

CREATE TABLE dbo.IntervalsRIT
(
id INT NOT NULL,
node AS upper — upper % POWER(2, FLOOR(LOG((lower — 1) ^ upper, 2)))
PERSISTED NOT NULL,
lower INT NOT NULL,
upper INT NOT NULL,
CONSTRAINT PK_IntervalsRIT PRIMARY KEY(id),
CONSTRAINT CHK_IntervalsRIT_upper_gteq_lower CHECK(upper >= lower)
);
CREATE INDEX idx_lower ON dbo.IntervalsRIT(node, lower);
CREATE INDEX idx_upper ON dbo.IntervalsRIT(node, upper);
INSERT INTO dbo.IntervalsRIT WITH(TABLOCK) (id, lower, upper)
SELECT id, lower, upper
FROM dbo.Stage;

Листинг 5. Определение функций leftNodes и rightNodes

— На основе «Managing Intervals Efficiently in Object-Relational Databases»

— функция rightNodes

CREATE FUNCTION dbo.leftNodes(@lower AS INT, @upper AS INT)
RETURNS @T TABLE
(
node INT NOT NULL PRIMARY KEY
)
AS
BEGIN
DECLARE @node AS INT = 1073741824;
DECLARE @step AS INT = @node / 2;

— спускаемся от корневого узла к lower

WHILE @step >= 1
BEGIN

— right node

IF @lower < @node
SET @node -= @step;

— left node

ELSE IF @lower > @node
BEGIN
INSERT INTO @T(node) VALUES(@node);
SET @node += @step;
END

— lower

ELSE
BREAK;
SET @step /= 2;
END;
RETURN;
END;
GO

— функция rightNodes

CREATE FUNCTION dbo.rightNodes(@lower AS INT, @upper AS INT)
RETURNS @T TABLE
(
node INT NOT NULL PRIMARY KEY
)
AS
BEGIN
DECLARE @node AS INT = 1073741824;
DECLARE @step AS INT = @node / 2;

— спускаемся от корневого узла к upper

WHILE @step >= 1
BEGIN

— левый узел

IF @upper > @node
SET @node += @step

— правый узел

ELSE IF @upper < @node
BEGIN
INSERT INTO @T(node) VALUES(@node);
SET @node -= @step
END

— upper

ELSE
BREAK;
SET @step /= 2;
END;
RETURN;
END;
GO

Листинг 6. Запрос пересечений

— На основе «Managing Intervals Efficiently in Object-Relational Databases»

DECLARE @l AS INT = 5000000, @u AS INT = 5000020;
SELECT I.id
FROM dbo.IntervalsRIT AS I
JOIN dbo.leftNodes(@l, @u) AS L
ON I.node = L.node
AND I.upper >= @l
UNION ALL
SELECT I.id
FROM dbo.IntervalsRIT AS I
JOIN dbo.rightNodes(@l, @u) AS R
ON I.node = R.node
AND I.lower <= @u
UNION ALL
SELECT id
FROM dbo.IntervalsRIT
WHERE node BETWEEN @l AND @u
OPTION (RECOMPILE);

Листинг 7. Исходный текст для создания и заполнения таблицы BitMasks

— На основе «A Static Relational Interval Tree»

CREATE TABLE dbo.BitMasks
(
b1 INT NOT NULL,
b3 INT NOT NULL
);
INSERT INTO dbo.BitMasks(b1, b3)
SELECT -POWER(2, n), POWER(2, n)
FROM (VALUES(1),(2),(3),(4),(5),(6),(7),(8),(9),(10),
(11),(12),(13),(14),(15),(16),(17),(18),(19),(20),
(21),(22),(23),(24),(25),(26),(27),(28),(29),(30)) AS Nums(n);

Листинг 8. Определение функции Ancestors

CREATE FUNCTION dbo.Ancestors(@node AS INT) RETURNS TABLE
AS
RETURN
SELECT @node & b1 | b3 as node
FROM dbo.BitMasks
WHERE b3 > @node & -@node;
GO

 

Листинг 9. Запрос к пересечению с помощью таблицы BitMasks и функции Ancestors

DECLARE @l AS INT = 5000000, @u AS INT = 5000020;
SELECT I.id
FROM dbo.IntervalsRIT AS I
JOIN dbo.Ancestors(@l) AS L
ON L.node < @l
AND L.node >= (SELECT MIN(node) FROM dbo.IntervalsRIT)
AND I.node = L.node
AND I.upper >= @l
UNION ALL
SELECT I.id
FROM dbo.IntervalsRIT AS I
JOIN dbo.Ancestors(@u) AS R
ON R.node > @u
AND R.node <= (SELECT MAX(node) FROM dbo.IntervalsRIT)
AND I.node = R.node
AND I.lower <= @u
UNION ALL
SELECT id
FROM dbo.IntervalsRIT
WHERE node BETWEEN @l AND @u
OPTION (RECOMPILE);