. Используемые входные данные аналогичны данным в предыдущих статьях серии. В листинге 1 приведен исходный текст для создания таблицы Sessions и ее заполнения небольшим набором тестовых данных для проверки корректности решения.

Отличие данной статьи — в определении индексов idx_start и idx_end. Списки ключей такие же, как в предыдущих статьях серии, но на этот раз индексы дополнены предложением INCLUDE. Индекс idx_start охватывает столбец endtime, а индекс idx_end — столбец starttime. Новые индексы более эффективны для решений, описываемых в данной статье.

Листинг 2 содержит исходный текст для создания вспомогательной функции GetNums, которая формирует последовательность целых чисел в запрошенном диапазоне. Кроме того, листинг 2 включает исходный текст для заполнения таблицы Sessions большим набором тестовых данных в целях проверки производительности.

На этот раз наша задача — вычислить максимальное число одновременных сеансов в течение каждого фиксированного интервала внутри указанного периода, отдельно для каждого приложения. Например, предположим, что нужный период начинается в 8:00 12 февраля 2013 г. и заканчивается в 17:00 12 февраля 2013 г. Для каждого приложения и часа внутри данного периода необходимо вычислить максимальное число одновременных сеансов. Условимся, что если один сеанс заканчивается точно после начала другого, то два сеанса считаются одновременными. Также предположим, что сеанс начинается в 15.00 и заканчивается в 16.00. Такой сеанс считается активным во время фиксированного часового интервала, начинающегося в 15.00 и заканчивающегося в 16.00, но неактивным в течение фиксированного интервала с 16.00 до 17.00. На рисунке 1 показано, что необходимо вычислить для малого набора тестовых данных, сформированного программным кодом в листинге 1.

 

Диаграмма подсчитываемых интервалов
Рисунок 1. Диаграмма подсчитываемых интервалов

На рисунке 2 представлен результат, для ясности отсортированный по приложениям и времени начала; однако можно предположить, что упорядочения в задаче не требуется. Результаты можно сортировать иначе, при условии, что возвращается тот же набор результатов.

 

Подсчеты в течение фиксированных интервалов
Рисунок 2. Подсчеты в течение фиксированных интервалов

В решении, описанном в данной статье, используется вспомогательная таблица с именем TimeStamps. Таблица заполняется временами начала всех фиксированных интервалов, которые необходимо поддержать. Исходный текст в листинге 3 формирует таблицу TimeStamps и заполняет ее временами начала всех фиксированных часовых интервалов в 2013 году. Предполагается, что требуется охватить период всего 2013 года.

Для простоты в листинге 4 приведен исходный текст решения только для приложения app3. После разъяснения решения для одного приложения я покажу, как применить его ко всем приложениям. Шаги в решении разъясняются для отдельных обобщенных табличных выражений (CTE), начиная с CTE с именем C1.

Аналогично методам, описанным в предыдущих статьях, решение начинается с формирования хронологической последовательности событий. Это выполняется в теле табличного выражения (CTE) C1. В прошлом хронологическая последовательность событий строилась путем объединения только событий начала и завершения. Но этот случай особый: необходимо возвратить информацию о каждом фиксированном часовом интервале, даже если в течение интервала не произошло никаких событий (начала и завершения). Напомню, что события начала отмечаются инкрементом +1, так как эти события увеличивают подсчет активных сеансов, а события завершения отмечаются декрементом -1, так как они уменьшают подсчет. Чтобы учесть особенности нашей задачи, решение добавляет ложные записи во время начала всех фиксированных часовых интервалов, отмечая их инкрементом 0, чтобы событие не повлияло на подсчет. Таким образом решение обеспечивает наличие хотя бы одного события в каждом фиксированном интервале.

Для обработки случаев, когда события различных типов происходят в одно время, запросы в C1 назначают различное порядковое значение (ord) каждому типу событий. Событиям завершения назначается значение ord, равное 1, так как их следует рассмотреть первыми, событиям начала назначается значение 2, ложным событиям — 3.

Помните, что следует учитывать только интервалы, попадающие в определенный входной период; поэтому первые два запроса в теле C1 содержат фильтр starttime < @endtime AND endtime >= @starttime (включая @starttime и исключая @endtime), а третий запрос содержит фильтр ts >= @starttime AND ts < @endtime.

Второй шаг решения реализован обобщенным табличным выражением (CTE) с именем C2. Запрос в теле CTE запрашивает C1 и вычисляет текущий итог столбца инкрементов (значения 1, -1 и 0) с учетом порядка ts и ord. Результирующий столбец получает имя cnt:

SELECT
ts,
increment,
SUM(increment) OVER(ORDER BY ts, ord
ROWS UNBOUNDED PRECEDING) AS cnt
FROM C1

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

Следующий шаг реализуется в CTE C3. Вот запрос в теле CTE:

SELECT
DATEADD( hour, DATEDIFF(hour, @starttime, ts), @starttime )
AS starttime,
cnt
FROM C2
WHERE increment <> -1

Максимальный подсчет в течение каждого часа обязательно состоится после события начала или после ложного события (если в течение часа не произошло события начала), поэтому запрос фильтрует только события, которые не являются событиями завершения. Запрос также вычисляет для каждой метки времени соответствующее начало часа с использованием выражения DATEADD( hour, DATEDIFF(hour, @starttime, ts), @starttime ) и присваивает результирующему столбцу имя starttime.

Наконец, внешний запрос группирует строки из C3 по времени начала (начало часа), возвращая начало часа, конец часа и максимальный подсчет для группы:

SELECT
starttime,
DATEADD(hour, 1, starttime) AS endtime,
MAX(cnt) AS mx
FROM C3
GROUP BY starttime;

Решение в листинге 4 применяется к одному входному приложению. Затем эта логика инкапсулируется во встроенную табличную функцию, которая принимает в качестве входных данных приложение и период. В листинге 5 дано определение такой функции с именем IntervalCounts.

Чтобы применить функцию ко всем приложениям из таблицы Apps, применяется оператор APPLY. Ниже приведен пример использования малого набора тестовых данных с входным периодом, который начинается в 8:00 12 февраля 2013 года и завершается в 17:00 12 февраля 2013 года.

12, 2013:
SELECT A.app, IC.*
FROM dbo.Apps AS A
CROSS APPLY dbo.IntervalCounts(A.app, '20130212 08:00:00',
'20130212 17:00:00') AS IC;

Этот программный код формирует желаемый результирующий набор, показанный на рисунке 2.

После того, как таблицы заполнены большим набором тестовых данных, можно привести пример использования функции с входным периодом, который начинается 1 января 2013 года (включительно) и завершается 1 февраля 2013 года (исключительно):

SELECT A.app, IC.*
FROM dbo.Apps AS A
CROSS APPLY dbo.IntervalCounts(A.app, '20130101', '20130201')
AS IC;

На рисунке 3 показан план этого запроса (с использованием Plan Explorer программы SQL Sentry).

 

Последовательный план для решения в листинге?5
Рисунок 3. Последовательный план для решения в листинге ?5

Для выполнения данного запроса на моем компьютере потребовалось 13 секунд. План неплох, но его можно улучшить. Во-первых, как мы видим, SQL Server выбрал последовательный план. Без сомнения, время выполнения можно сократить с помощью параллелизма. Во-вторых, поскольку группирование основывается на вычислениях, оптимизатор не использует порядок индекса для вычисления статистического выражения; вместо этого применяется статистическое выражение Hash Match. Для хэширования требуется предоставление памяти для выполнения запроса (как и сортировки, когда оптимизатор выполняет сортировку перед использованием статистического выражения потока).

Во второй части данной статьи было показано, как заставить оптимизатор задействовать параллельный план. Нужно добавить в запрос искусственное перекрестное объединение:

DECLARE @n AS BIGINT = 1;
SELECT A.app, IC.*
FROM dbo.Apps AS A
CROSS APPLY dbo.IntervalCounts(A.app, '20130101', '20130201')
AS IC
CROSS JOIN (SELECT TOP (@n) * FROM dbo.Apps) AS B
OPTION (OPTIMIZE FOR (@n = 100));

На этот раз был получен параллельный план, показанный на рисунке 4.

 

Параллельный план для решения в листинге 5
Рисунок 4. Параллельный план для решения в листинге 5

При использовании параллельного плана запрос на моем компьютере был выполнен за 7 секунд. Существует также способ избежать хэширования и сортировки. О нем будет рассказано в следующем разделе.

Избавляемся от хэширования и сортировки

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

1. Вычисляемые столбцы с именами fstartime и fendtime, содержащие приведенные к началу часа значения starttime и endtime, соответственно:

ALTER TABLE dbo.Sessions ADD
fstarttime AS
DATEADD( hour,
DATEDIFF(hour, CONVERT(DATETIME2(0),
'19000101', 112), starttime),
CONVERT(DATETIME2(0), '19000101', 112) ),
fendtime AS
DATEADD( hour,
DATEDIFF(hour, CONVERT(DATETIME2(0),
'19000101', 112), endtime),
CONVERT(DATETIME2(0), '19000101', 112) );
Indexes similar to idx_start and idx_end, but with the column holding the floored time preceding the column holding the original time. Namely, fstarttime before startime, and fendtime before endtime:
CREATE UNIQUE INDEX idx_fstart
ON dbo.Sessions(app, fstarttime, starttime, keycol)
INCLUDE(endtime);
CREATE UNIQUE INDEX idx_fend
ON dbo.Sessions(app, fendtime, endtime, keycol)
INCLUDE(starttime);

2. Изменение реализации функции IntervalCounts с использованием новых столбцов fstarttime и fendtime, как показано в листинге 6.

Приведенные столбцы добавляются в списки SELECT двух первых запросов в теле C1, а результирующему столбцу присваивается имя fts (floored timestamp). Что касается третьего запроса, который возвращает ложные события, опрашивая таблицу TimeStamps, то столбец ts уже представляет начало часа, поэтому он просто добавляется как столбец fts.

Затем вы полностью пропускаете шаг, на котором вычисляются приведенные метки времени в предшествующем решении в листинге 5, так как столбец fts в новом решении в листинге 6 уже содержит приведенные метки времени. CTE C2 в новом решении выполняет вычисление увеличения с нарастающим итогом текущего подсчета, но на этот раз fts предшествует ts в предложении порядка окна. Это позволяет оптимизатору выполнить упорядоченный просмотр индексов idx_fstart и idx_fend и использовать данный порядок на последнем шаге, реализованном внешним запросом, вычисляющим максимальный подсчет для каждой часовой группы.

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

SELECT A.app, IC.*
FROM dbo.Apps AS A
CROSS APPLY dbo.IntervalCounts(A.app, '20130101', '20130201') AS IC;

Полученный план показан на рисунке 5.

 

План для решения в листинге 6
Рисунок 5. План для решения в листинге 6

У этого плана есть две интересные особенности. Во-первых, при вычислении статистического выражения не применяется хэширование или сортировка; вместо этого оптимизатор использует статистическое выражение потока на основе существующего порядка данных из индексов. Во-вторых, SQL Server выбирает параллельный план, не предполагающий участия программиста. Время выполнения этого решения на моем компьютере составило 7 секунд. Это слегка меня разочаровало, поскольку время выполнения оказалось таким же, как в предшествующем решении после применения хитрости, приведшей к параллельному плану. Однако у этого плана все же есть преимущества: не нужно никаких уловок, чтобы получить параллельный план, и запросу не требуется особого предоставления памяти для сортировки и хэширования.

Итак, в этой статье мы продолжили рассматривать запросы, связанные с интервалами и подсчетами. На этот раз требовалось вычислить максимальное число одновременных сеансов внутри каждого фиксированного часового интервала. Было продемонстрировано решение, которое формирует хронологическую последовательность событий, в том числе ложных событий, обеспечивающих наличие в результате места для каждого часа во входном периоде. Затем вычислялись подсчеты с помощью функции окна, которая применяет статистическое выражение с нарастающим итогом. Первое решение формирует последовательный план. С помощью искусственного перекрестного объединения удалось получить параллельный план. Наконец, было показано решение, в котором добавляются вычисляемые столбцы, содержащие приведенные временные метки, индексы, охватывающие эти столбцы, и пересмотренный запрос, в котором используются эти столбцы. Таким образом можно получить параллельный план, не прибегая к специальным уловкам, сортировке и хэшированию для вычисления максимального подсчета для каждого часового интервала.

Листинг 1. DDL для таблицы Sessions с небольшим набором тестовых данных

SET NOCOUNT ON;
USE tempdb;
IF OBJECT_ID(N'dbo.Sessions', N'U') IS NOT NULL DROP TABLE dbo.Sessions;
IF OBJECT_ID(N'dbo.Apps', N'U') IS NOT NULL DROP TABLE dbo.Apps;
CREATE TABLE dbo.Apps
(
app VARCHAR(10) NOT NULL,
CONSTRAINT PK_Apps PRIMARY KEY(app)
);
CREATE TABLE dbo.Sessions
(
keycol INT NOT NULL,
app VARCHAR(10) NOT NULL,
starttime DATETIME2(0) NOT NULL,
endtime DATETIME2(0) NOT NULL,
CONSTRAINT PK_Sessions PRIMARY KEY(keycol),
CONSTRAINT CHK_Sessios_et_st CHECK(endtime > starttime)
);
CREATE UNIQUE INDEX idx_start ON dbo.Sessions(app, starttime, keycol) INCLUDE(endtime);
CREATE UNIQUE INDEX idx_end ON dbo.Sessions(app, endtime, keycol) INCLUDE(starttime);
— Код для заполнения таблицы Sessions небольшим набором тестовых данных
TRUNCATE TABLE dbo.Sessions;
TRUNCATE TABLE dbo.Apps;
INSERT INTO dbo.Apps(app) VALUES('app1'),('app2'),('app3');
INSERT INTO dbo.Sessions(keycol, app, starttime, endtime) VALUES
(2, 'app1', '20130212 08:30:00', '20130212 10:30:00'),
(3, 'app1', '20130212 08:30:00', '20130212 08:45:00'),
(5, 'app1', '20130212 09:00:00', '20130212 09:30:00'),
(7, 'app1', '20130212 09:15:00', '20130212 10:30:00'),
(11, 'app1', '20130212 09:15:00', '20130212 09:30:00'),
(13, 'app1', '20130212 10:30:00', '20130212 14:30:00'),
(17, 'app1', '20130212 10:45:00', '20130212 11:30:00'),
(19, 'app1', '20130212 11:00:00', '20130212 12:30:00'),
(23, 'app2', '20130212 08:30:00', '20130212 08:45:00'),
(29, 'app2', '20130212 09:00:00', '20130212 09:30:00'),
(31, 'app2', '20130212 11:45:00', '20130212 12:00:00'),
(37, 'app2', '20130212 12:30:00', '20130212 14:00:00'),
(41, 'app2', '20130212 12:45:00', '20130212 13:30:00'),
(43, 'app2', '20130212 13:00:00', '20130212 14:00:00'),
(47, 'app2', '20130212 14:00:00', '20130212 16:30:00'),
(53, 'app2', '20130212 15:30:00', '20130212 17:00:00'),
(61, 'app3', '20130212 08:00:00', '20130212 08:30:00'),
(62, 'app3', '20130212 08:00:00', '20130212 09:00:00'),
(63, 'app3', '20130212 09:00:00', '20130212 09:30:00'),
(64, 'app3', '20130212 09:30:00', '20130212 10:00:00');

 

Листинг 2. Вспомогательная функция GetNums с большим набором тестовых данных

IF OBJECT_ID(N'dbo.GetNums', N'IF') IS NOT NULL DROP FUNCTION dbo.GetNums;
GO
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
— Код для заполнения таблицы Sessions большим набором тестовых данных
TRUNCATE TABLE dbo.Sessions;
TRUNCATE TABLE dbo.Apps;
DECLARE
@numrows AS INT = 2000000, — общее число строк
@numapps AS INT = 100; — число приложений
INSERT INTO dbo.Apps WITH(TABLOCK) (app)
SELECT 'app' + CAST(n AS VARCHAR(10)) AS app
FROM dbo.GetNums(1, @numapps) AS Nums;
INSERT INTO dbo.Sessions WITH(TABLOCK)
(keycol, app, 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
(
SELECT
'app' + CAST(1 + ABS(CHECKSUM(NEWID())) % @numapps AS VARCHAR(10)) AS app,
DATEADD(
second,
1 + ABS(CHECKSUM(NEWID())) % (30*24*60*60),
'20130101') AS starttime
FROM dbo.GetNums(1, @numrows) AS Nums
) AS D;

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

— DDL для таблицы TimeStamps
IF OBJECT_ID(N'dbo.TimeStamps', N'U') IS NOT NULL
DROP TABLE dbo.TimeStamps;
CREATE TABLE dbo.TimeStamps
(
ts DATETIME2(0) NOT NULL
CONSTRAINT PK_TimeStamps PRIMARY KEY
);
GO
— Заполнение таблицы TimeStamps
DECLARE
@s AS DATETIME2(0) = '20130101', — inclusive
@e AS DATETIME2(0) = '20140101'; — exclusive
INSERT INTO dbo.TimeStamps WITH (TABLOCK) (ts)
SELECT DATEADD(hour, n-1, @s) AS ts
FROM dbo.GetNums(1, DATEDIFF(hour, @s, @e)) AS Nums;
GO

 

Листинг 4. Максимальные подсчеты в течение фиксированных интервалов

DECLARE
@app AS VARCHAR(10) = 'app1',
@starttime AS DATETIME2(0) = '20130212 08:00:00', — включая
@endtime AS DATETIME2(0) = '20130212 17:00:00'; — исключая
WITH C1 AS
(
SELECT
endtime AS ts,
-1 AS increment,
1 AS ord
FROM dbo.Sessions
WHERE app = @app
AND starttime < @endtime
AND endtime >= @starttime
UNION ALL
SELECT
starttime AS ts,
1 AS increment,
2 AS ord
FROM dbo.Sessions
WHERE app = @app
AND starttime < @endtime
AND endtime >= @starttime
UNION ALL
SELECT
ts,
0 AS increment,
3 AS ord
FROM dbo.TimeStamps
WHERE ts >= @starttime AND ts < @endtime
),
C2 AS
(
SELECT
ts,
increment,
SUM(increment) OVER(ORDER BY ts, ord
ROWS UNBOUNDED PRECEDING) AS cnt
FROM C1
),
C3 AS
(
SELECT
DATEADD( hour, DATEDIFF(hour, @starttime, ts), @starttime ) AS starttime,
cnt
FROM C2
WHERE increment <> -1
)
SELECT
starttime,
DATEADD(hour, 1, starttime) AS endtime,
MAX(cnt) AS mx
FROM C3
GROUP BY starttime;

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

IF OBJECT_ID(N'dbo.IntervalCounts', N'IF') IS NOT NULL DROP FUNCTION dbo.IntervalCounts;
GO
CREATE FUNCTION dbo.IntervalCounts
(
@app AS VARCHAR(10),
@starttime AS DATETIME2(0),
@endtime AS DATETIME2(0)
) RETURNS TABLE
AS
RETURN
WITH C1 AS
(
SELECT
endtime AS ts,
-1 AS increment,
1 AS ord
FROM dbo.Sessions
WHERE app = @app
AND starttime < @endtime
AND endtime >= @starttime
UNION ALL
SELECT
starttime AS ts,
1 AS increment,
2 AS ord
FROM dbo.Sessions
WHERE app = @app
AND starttime < @endtime
AND endtime >= @starttime
UNION ALL
SELECT
ts,
0 AS increment,
3 AS ord
FROM dbo.TimeStamps
WHERE ts >= @starttime AND ts < @endtime
),
C2 AS
(
SELECT
ts,
increment,
SUM(increment) OVER(ORDER BY ts, ord
ROWS UNBOUNDED PRECEDING) AS cnt
FROM C1
),
C3 AS
(
SELECT
DATEADD( hour, DATEDIFF(hour, @starttime, ts), @starttime ) AS starttime,
cnt
FROM C2
WHERE increment <> -1
)
SELECT
starttime,
DATEADD(hour, 1, starttime) AS endtime,
MAX(cnt) AS mx
FROM C3
GROUP BY starttime;
GO

Листинг 6. Определение функции IntervalCounts после добавления вычисляемых столбцов

IF OBJECT_ID(N'dbo.IntervalCounts', N'IF') IS NOT NULL DROP FUNCTION dbo.IntervalCounts;
GO
CREATE FUNCTION dbo.IntervalCounts
(
@app AS VARCHAR(10),
@starttime AS DATETIME2(0),
@endtime AS DATETIME2(0)
) RETURNS TABLE
AS
RETURN
WITH C1 AS
(
SELECT
fendtime AS fts,
endtime AS ts,
-1 AS increment,
1 AS ord
FROM dbo.Sessions
WHERE app = @app
AND fstarttime < @endtime
AND fendtime >= @starttime
UNION ALL
SELECT
fstarttime AS fts,
starttime AS ts,
1 AS increment,
2 AS ord
FROM dbo.Sessions
WHERE app = @app
AND fstarttime < @endtime
AND fendtime >= @starttime
UNION ALL
SELECT
ts AS fts,
ts,
0 AS increment,
3 AS ord
FROM dbo.TimeStamps
WHERE ts >= @starttime AND ts < @endtime
),
C2 AS
(
SELECT
fts,
increment,
SUM(increment) OVER(ORDER BY fts, ts, ord
ROWS UNBOUNDED PRECEDING) AS cnt
FROM C1
)
SELECT
fts AS starttime,
DATEADD(hour, 1, fts) AS endtime,
MAX(cnt) AS mx
FROM C2
WHERE increment <> -1
GROUP BY fts;
GO