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

Тестовые данные

В некоторых примерах статьи запрос направляется во временную таблицу тестовой базы данных WideWorld­Importers. Документацию по этой базе данных можно найти по адресу: go.microsoft.com/fwlink/? LinkID=800631. Чтобы установить тестовую базу данных на своем компьютере, загрузите файл резервной копии WideWorldImporters-Full.bak по адресу: go.microsoft.com/fwlink/? LinkID=800630. Предположим, вы поместили файл в папку C:\WWI\; восстановите базу данных, выполнив программный код листинга 1 (при необходимости замените исходный и целевой пути).

Представление столбцов периода в целевом часовом поясе

Напомню, что столбцы периода во временной таблице содержат время часового пояса в формате UTC как значения DATETIME2. Предположим, что их нужно представить как значения DATETIMEOFFSET в определенном часовом поясе. Рекомендуемый инструмент для таких преобразований — функция AT TIME ZONE, которая рассматривалась в статье «Тип данных DATETIMEOFFSET и функция AT TIME ZONE в SQL Server 2016» (опубликована в Windows IT Pro/RE № 3 2017 года). Очевидно, что это задача непростая. Для возвращения значения типа DATETIME2, сохраненного в исходном часовом поясе (в нашем случае UTC) как значения типа DATETIMEOFFSET с определенным целевым часовым поясом, необходимы два преобразования AT TIME ZONE: одно для преобразования значения без учета сдвига в значение со сдвигом с исходным часовым поясом, другое для переключения сдвига с исходного часового пояса на целевой. Такое преобразование демонстрирует программный код в листинге 2. Этот программный код формирует выходные данные, показанные на экране 1.

 

Выходные данные листинга 2
Экран 1. Выходные данные листинга 2

 

Сложность временных таблиц связана с текущими строками, хранящими максимально возможное значение типа в столбце конца периода (9999-12-31 23:59:59.9999999 при использовании максимальной точности). В качестве примера рассмотрите запрос, приведенный в листинге 3.

Этот запрос формирует выходные данные, показанные на экране 2. Здесь показаны четыре элемента, каждый с четырьмя вариантами. Текущий вариант отмечен максимальным значением в столбце ValidTo.

 

Выходные данные запроса листинга 3
Экран 2. Выходные данные запроса листинга 3

 

Если нужно представить значения в целевом часовом поясе с отрицательным сдвигом, например тихоокеанское время США (зима), то местное время корректируется назад. При использовании максимального значения оно выражается в целевом часовом поясе: 9999-12-31 15:59:59.9999999 -08:00. Это показано в программным коде лис­тинга 4.

Этот программный код формирует выходные данные, показанные на экране 3.

 

Выходные данные запроса листинга 4
Экран 3. Выходные данные запроса листинга 4

 

Если целевой часовой пояс имеет положительный сдвиг, например Russian Standard Time (московское время), то местное время максимального значения типа не поддается выражению в SQL Server, так как это год 10 000, а SQL Server может выражать только даты до года 9999. Не объявляя о неудачной попытке преобразования, SQL Server возвращает максимальное значение типа со смещением +00:00. Это демонстрирует программный код в листинге 5.

Этот программный код выдает выходные данные, показанные на экране 4.

 

Выходные данные запроса листинга 5
Экран 4. Выходные данные запроса листинга 5

 

Если вас устраивает такой результат, то вам не следует предпринимать никаких дальнейших действий. Однако, если вы требуете устойчивого поведения, независимо от отрицательного или положительного сдвига целевого часового пояса, то следует возвратить максимальное значение типа со сдвигом +00:00. Это легко сделать с помощью выражения CASE, например как в листинге 6.

Этот программный код формирует выходные данные, показанные на экране 5.

 

Выходные данные запроса листинга 6
Экран 5. Выходные данные запроса листинга 6

 

Затем опробуйте данный программный код с часовым поясом тихоокеанское время США (зима) (см. листинг 7).

Вы получите выходные данные, показанные на экране 6.

 

Выходные данные запроса листинга 7
Экран 6. Выходные данные запроса листинга 7

 

Лично для меня это предпочтительный вариант.

Вырожденные интервалы

Когда вы изменяете данные во временной таблице в ходе явной транзакции, фактическим временем всех изменений является время начала транзакции. Это может привести к любопытной ситуации, если применить несколько обновлений к одной строке в одной транзакции. Исходный и последний варианты строки будут иметь отличные от нуля интервалы длительности, также известные как вырожденные интервалы. Чтобы продемонстрировать это, я воспользуюсь временной таблицей с именем Products. Выполните программный код листинга 8, чтобы создать таблицу Products и заполнить ее несколькими строками.

Выполните программный код из листинга 9, чтобы применить несколько обновлений в одной транзакции.

Самая старая версия строки для продукта 3 должна иметь P1 как значение validfrom и P2 как значение validto с ценой, равной 10.00. Самая новая, текущая версия должна иметь P2 как значение validfrom и maximum как значение validto с ценой, равной 13.00. Но были сформированы еще две версии со значениями validfrom и validto равными P2, с ценами 11.00 и 12.00. Если вы запрашиваете данные с использованием предложения FOR SYSTEM_TIME, то SQL Server отбрасывает вырожденные интервалы. Например, используйте программный код, приведенный в листинге 10, чтобы вернуть все версии строк для продукта 3 с использованием вложенного предложения ALL.

Этот программный код формирует выходные данные, исключая вырожденные интервалы (результаты приведены на экране 7).

 

Результаты запроса листинга 10
Экран 7. Результаты запроса листинга 10

 

План выполнения для этого запроса показан на рисунке 1.

 

Вырожденные интервалы отброшены
Рисунок 1. Вырожденные интервалы отброшены

 

Обратите внимание, что план содержит предикаты, которые удаляют вырожденные интервалы из обеих таблиц.

Чтобы сохранить вырожденные интервалы, необходимо напрямую запросить текущую таблицу и таблицу журнала и объединить результаты (см. листинг 11).

Этот программный код формирует выходные данные, которые содержат вырожденные интервалы (см. экран 8).

 

Результаты запроса в листинге 11
Экран 8. Результаты запроса в листинге 11

 

План для этого запроса показан на рисунке 2.

 

Вырожденные интервалы включены
Рисунок 2. Вырожденные интервалы включены

 

Как мы видим, в плане нет предикатов для удаления вырожденных интервалов.

При использовании вложенного предложения AS OF для возврата интервалов, которые были действительны в определенный момент времени, все вырожденные интервалы отбрасываются. Например, рассмотрим запрос, приведенный в листинге 12 (предполагается, что @datetime — входной параметр для процедуры или функции).

Этот запрос возвращает строки, в которых @datetime больше или равно validfrom и меньше, чем validto. Это означает, что вырожденные интервалы никогда не будут совпадать. Поэтому нет смысла составлять запросы непосредственно к текущей таблице и таблице журнала с логически эквивалентными предикатами. Однако при использовании остальных вложенных предложений (FROM TO, BETWEEN и CONTAINED IN) запрос не возвратит вырожденных интервалов, тогда как прямые запросы к текущей таблице и таблице журнала — возвратят. Примеры в листинге 13 показывают альтернативные варианты с прямыми запросами для каждого вложенного предложения.

Завершив проверку вариантов, выполните программный код листинга 14.

Оптимизированное вложенное предложение CONTAINED IN

Вложенное предложение CONTAINED IN возвращает все строки, период действия которых содержится во входном интервале, то есть строки с началом периода, превышающим или равным началу входного интервала, и концом интервала, меньшим или равным концу входного интервала. В SQL Server применяется специальная оптимизация для вложенного предложения CONTAINED IN: физическое сканирование текущей таблицы не выполняется, если конец входного интервала отличается от максимально возможного значения типа. Программе известно, что в этом случае строки из текущей таблицы не могут подойти. Поэтому, если нужно идентифицировать версии строк, содержащихся во входном периоде, лучше использовать вложенное предложение CONTAINED IN, а не вложенное предложение ALL с явным предикатом фильтра. Чтобы продемонстрировать это, запустите сначала следующий программный код, чтобы включить отчеты статистики ввода-вывода:

SET STATISTICS IO ON;

Выполните программный код листинга 15 с использованием вложенного приложения ALL, чтобы идентифицировать версии строк за период с 2010 по 2016 год.

План выполнения для этого запроса показан на рисунке 3.

 

Доступ к обеим таблицам ALL с использованием обеих таблиц
Рисунок 3. Доступ к обеим таблицам ALL с использованием обеих таблиц

 

Обратите внимание на доступ как к текущей таблице, так и к таблице журнала. Значение счетчика выполнения — 1 для сканирования индексов на обеих таблицах. Также обратите внимание на выходные данные STATISTICS IO, в которых присутствуют операции чтения для обеих таблиц (см. экран 9).

 

Выходные данные STATISTICS IO
Экран 9. Выходные данные STATISTICS IO

 

Затем попытайтесь выполнить ту же задачу с использованием вложенного предложения CONTAINED IN, как в листинге 16.

План для этого запроса показан на рисунке 4.

 

Доступ только к таблице журналов с использованием CONTAINED IN
Рисунок 4. Доступ только к таблице журналов с использованием CONTAINED IN

 

Посмотрите на Startup Expression Predicate («Предикат выражения запуска»). Он проверяет, равен ли конец входного периода максимальному возможного типа; только после этого начинается просмотр текущей таблицы. В этом примере, поскольку предикат ложный, просмотр не выполняется. Обратите внимание, что в выходных данных STATISTICS IO нет сведений об операциях ввода-вывода с текущей таблицей (см. экран 10).

 

В выходных данных STATISTICS IO нет сведений об операциях ввода-вывода с текущей таблицей
Экран 10. В выходных данных STATISTICS IO нет сведений об операциях ввода-вывода с текущей таблицей

 

Может возникнуть вопрос, почему не направить прямой запрос к таблице журнала и таким образом избежать физического контакта с текущей таблицей? Такая возможность существует. Но, во-первых, цель предложения FOR SYSTEM_TIME — упростить логику и избавиться от необходимости заботиться о базовом разделении текущей таблицы и таблицы журнала. Во-вторых, что, если входные данные являются параметрами и пользователь-исполнитель иногда может запросить максимальный конец входного интервала? Вам не придется проверять начальное условие с помощью инструкции IF. Оптимизатор сделает это за вас. В листинге 17 приведен пример с переменными.

План для этого запроса показан на рисунке 5.

 

CONTAINED IN с переменными
Рисунок 5. CONTAINED IN с переменными

 

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

 

Выходные данные STATISTICS IO при использовании переменных
Экран 11. Выходные данные STATISTICS IO при использовании переменных

 

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

SET STATISTICS IO OFF;

Итак, в этой статье я дал несколько рекомендаций относительно временных таблиц. Мы рассмотрели, как добиться согласованного поведения, когда нужно представить столбцы периода в желательном целевом часовом поясе с использованием выражения CASE. Кроме того, теперь вы знаете, что такое вырожденные интервалы и как возвратить их, направив запрос напрямую в текущую таблицу и таблицу журнала. Наконец, в статье описана оптимизация, используемая для вложенного предложения CONTAINED IN в предложении FOR SYSTEM_TIME, и объяснено, почему предпочтительно использовать именно это предложение по сравнению с альтернативными вариантами.

Листинг 1. Восстановление тестовой базы данных
RESTORE DATABASE WideWorldImporters
  FROM DISK = N'C:\WWI\WideWorldImporters-Full.bak'
  WITH
    FILE = 1,
    MOVE N'WWI_Primary'         TO N'C:\WWI\WideWorldImporters.mdf', 
    MOVE N'WWI_UserData'        TO N'C:\WWI\WideWorldImporters_UserData.ndf', 
    MOVE N'WWI_Log'             TO N'C:\WWI\WideWorldImporters.ldf', 
    MOVE N'WWI_InMemory_Data_1' TO N'C:\WWI\WideWorldImporters_InMemory_Data_1',
    REPLACE, NOUNLOAD, STATS = 5;
Листинг 2. Преобразование AT TIME ZONE
DECLARE
  @dt             AS DATETIME2 = '20170118 12:00:00.0000000',
  @sourcetimezone AS sysname   = 'UTC',
  @targettimezone AS sysname   = 'Russian Standard Time';

SELECT
  @dt AT TIME ZONE @sourcetimezone AT TIME ZONE @targettimezone AS targetvalue;
Листинг 3. Пример запроса с максимально возможным значением
USE WideWorldImporters;

SELECT StockItemID, ValidFrom, ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME ALL
WHERE StockItemName LIKE 'An%'
ORDER BY StockItemID, ValidFrom;
Листинг 4. Максимальное значение в целевом часовом поясе
DECLARE
  @targettimezone AS sysname = 'Pacific Standard Time';

SELECT StockItemID,
  ValidFrom AT TIME ZONE N'UTC' AT TIME ZONE @targettimezone AS ValidFrom,
  ValidTo AT TIME ZONE N'UTC' AT TIME ZONE @targettimezone AS ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME ALL
WHERE StockItemName LIKE 'An%'
ORDER BY StockItemID, ValidFrom;
Листинг 5. Максимальное значение в часовом поясе с положительным сдвигом
DECLARE
  @targettimezone AS sysname = 'Russian Standard Time';
SELECT StockItemID,
  ValidFrom AT TIME ZONE N'UTC' AT TIME ZONE @targettimezone AS ValidFrom,
  ValidTo AT TIME ZONE N'UTC' AT TIME ZONE @targettimezone AS ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME ALL
WHERE StockItemName LIKE 'An%'
ORDER BY StockItemID, ValidFrom;
Листинг 6. Использование выражения CASE
DECLARE
  @targettimezone AS sysname = 'Russian Standard Time';
SELECT StockItemID,
  ValidFrom AT TIME ZONE N'UTC' AT TIME ZONE @targettimezone AS ValidFrom,
  ValidTo AT TIME ZONE
    CASE WHEN ValidTo = '99991231 23:59:59.9999999'
      THEN 'UTC'
      ELSE @targettimezone
    END AS ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME ALL
WHERE StockItemName LIKE 'An%'
ORDER BY StockItemID, ValidFrom;
Листинг 7. Использование выражения CASE с часовым поясом тихоокеанское время США (зима)
DECLARE
  @targettimezone AS sysname = 'Pacific Standard Time';
SELECT StockItemID,
  ValidFrom AT TIME ZONE N'UTC' AT TIME ZONE @targettimezone AS ValidFrom,
  ValidTo AT TIME ZONE
    CASE WHEN ValidTo = '99991231 23:59:59.9999999'
      THEN 'UTC'
      ELSE @targettimezone
    END AS ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME ALL
WHERE StockItemName LIKE 'An%'
ORDER BY StockItemID, ValidFrom;
Листинг 8. Создание таблицы
-- Создать и заполнить таблицу Products
USE tempdb;
IF OBJECT_ID(N'dbo.Products', N'U') IS NOT NULL
BEGIN
  IF OBJECTPROPERTY(OBJECT_ID(N'dbo.Products', N'U'), N'TableTemporalType') = 2
    ALTER TABLE dbo.Products SET ( SYSTEM_VERSIONING = OFF );
  DROP TABLE IF EXISTS dbo.ProductsHistory, dbo.Products;
END;
GO

CREATE TABLE dbo.Products
(
  productid   INT          NOT NULL
    CONSTRAINT PK_dboProducts PRIMARY KEY(productid),
  productname NVARCHAR(40) NOT NULL,
  supplierid  INT          NOT NULL,
  categoryid  INT          NOT NULL,
  unitprice   MONEY        NOT NULL,
  validfrom   DATETIME2
    GENERATED ALWAYS AS ROW START HIDDEN NOT NULL,
  validto     DATETIME2
    GENERATED ALWAYS AS ROW END   HIDDEN NOT NULL,
  PERIOD FOR SYSTEM_TIME (validfrom, validto)
)
WITH ( SYSTEM_VERSIONING = ON ( HISTORY_TABLE = dbo.ProductsHistory ) );

-- Вставить строки в точке времени P1
INSERT INTO dbo.Products(productid, productname, supplierid, categoryid, unitprice)
  VALUES(1, 'Product HHYDP', 1, 1, 18.00),
        (2, 'Product RECZE', 1, 1, 19.00),
        (3, 'Product IMEHJ', 1, 2, 10.00),
        (4, 'Product KSBRM', 2, 2, 22.00),
        (5, 'Product EPEIM', 2, 2, 21.35);
Листинг 9. Применение нескольких обновлений в одной транзакции
BEGIN TRAN; -- точка времени P2

WAITFOR DELAY '00:00:01';

-- точка времени P3
UPDATE dbo.Products
  SET unitprice += 1
WHERE productid = 3;

WAITFOR DELAY ‘00:00:01’;

-- точка времени P4
UPDATE dbo.Products
  SET unitprice += 1
WHERE productid = 3;

WAITFOR DELAY '00:00:01';

-- точка времени P5
UPDATE dbo.Products
  SET unitprice += 1
WHERE productid = 3;

COMMIT TRAN;
Листинг 10. Запрос с использованием предложения FOR SYSTEM_TIME
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products
  FOR SYSTEM_TIME ALL
WHERE productid = 3
ORDER BY validfrom;
Листинг 11. Запрос с сохранением вырожденных интервалов
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products
WHERE productid = 3

UNION ALL
SELECT productid, unitprice, validfrom, validto
FROM dbo.ProductsHistory
WHERE productid = 3

ORDER BY validfrom;
Листинг 12. Запрос с предложением AS OF
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products
  FOR SYSTEM_TIME AS OF @datetime;
Листинг 13. Альтернативные варианты с прямыми запросами для каждого вложенного предложения
Пример с FROM TO, который исключает вырожденные интервалы:
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products FOR SYSTEM_TIME FROM @start TO @end;
Альтернативный вариант с прямыми запросами, включающий вырожденные интервалы:
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products
WHERE validfrom < @end AND validto > @start
UNION ALL
SELECT productid, unitprice, validfrom, validto
FROM dbo.ProductsHistory
WHERE validfrom < @end AND validto > @start;
Пример с BETWEEN, который исключает вырожденные интервалы:
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products FOR SYSTEM_TIME BETWEEN @start AND @end;
Альтернативный вариант с прямыми запросами, включающий вырожденные интервалы:
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products
WHERE validfrom <= @end AND validto > @start
UNION ALL
SELECT productid, unitprice, validfrom, validto
FROM dbo.ProductsHistory
WHERE validfrom <= @end AND validto > @start;
Пример с CONTAINED IN, который исключает вырожденные интервалы:
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products FOR SYSTEM_TIME CONTAINED IN (@start, @end);
Альтернативный вариант с прямыми запросами, включающий вырожденные интервалы:
SELECT productid, unitprice, validfrom, validto
FROM dbo.Products
WHERE validfrom >= @start AND validto <= @end
UNION ALL
SELECT productid, unitprice, validfrom, validto
FROM dbo.ProductsHistory
WHERE validfrom >= @start AND validto <= @end;
Листинг 14. Программный код для очистки
IF OBJECT_ID(N'dbo.Products', N'U') IS NOT NULL
BEGIN
  IF OBJECTPROPERTY(OBJECT_ID(N'dbo.Products', N'U'), N'TableTemporalType') = 2
    ALTER TABLE dbo.Products SET ( SYSTEM_VERSIONING = OFF );
  DROP TABLE IF EXISTS dbo.ProductsHistory, dbo.Products;
END;
Листинг 15. Использование вложенного приложения ALL
USE WideWorldImporters;

SELECT StockItemID, ValidFrom, ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME ALL
WHERE StockItemName LIKE 'An%'
  AND ValidFrom >= '20100101 00:00:00.0000000'
  AND ValidTo <= '20161231 23:59:59.9999999';
Листинг 16. Использованием вложенного предложения CONTAINED IN
SELECT StockItemID, ValidFrom, ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME CONTAINED IN
    ('20100101 00:00:00.0000000', '20161231 23:59:59.9999999')
WHERE StockItemName LIKE 'An%';
Листинг 17. Пример с переменными
DECLARE
  @start AS DATETIME2 = '20100101 00:00:00.0000000',
  @end AS DATETIME2 = '20161231 23:59:59.9999999';

SELECT StockItemID, ValidFrom, ValidTo
FROM Warehouse.StockItems
  FOR SYSTEM_TIME CONTAINED IN (@start, @end)
WHERE StockItemName LIKE 'An%';