Я решил эту проблему, имея очень простую календарную таблицу - каждый год имеет одну строку для каждого поддерживаемого часового пояса со стандартным смещением и датой начала / окончания даты DST и его смещением (если этот часовой пояс поддерживает это). Затем встроенная привязанная к схеме табличная функция, которая берет время источника (конечно, в UTC) и добавляет / вычитает смещение.
Это, очевидно, никогда не будет работать очень хорошо, если вы сообщаете о большой части данных; разделение может показаться полезным, но у вас все еще будут случаи, когда последние несколько часов в году или первые несколько часов в следующем году на самом деле принадлежат другому году при преобразовании в определенный часовой пояс - так что вы никогда не сможете получить истинный раздел изоляция, за исключением случаев, когда ваш диапазон отчетности не включает 31 декабря или 1 января.
Есть несколько странных крайних случаев, которые вы должны рассмотреть:
2014-11-02 05:30 UTC и 2014-11-02 06:30 UTC, например, конвертируются в 01:30 в восточном часовом поясе (например, первый раз в 01:30 был достигнут локально, а затем один во второй раз, когда часы откатились с 2:00 до 1:00, и прошло еще полчаса). Таким образом, вам нужно решить, как обрабатывать этот час отчетности - согласно UTC, вы должны увидеть удвоение трафика или объема того, что вы измеряете, после того, как эти два часа сопоставлены с одним часом в часовом поясе, в котором наблюдается DST. Это также может играть в забавные игры с последовательностью событий, поскольку что-то, что логически должно было произойти после того, как могло появиться что-то ещепроизойдет до того, как время будет установлено на один час вместо двух. Крайним примером является просмотр страницы, который произошел в 05:59 UTC, а затем щелчок, произошедший в 06:00 UTC. В UTC это происходило с интервалом в одну минуту, но при преобразовании в восточное время просмотр происходил в 1:59, а щелчок происходил на час раньше.
2014-03-09 02:30 в США никогда не бывает Это потому, что в 2:00 утра мы переводим часы вперед на 3:00 утра. Скорее всего, вы захотите вызвать ошибку, если пользователь введет такое время и попросит вас преобразовать его в UTC или спроектировать форму так, чтобы пользователи не могли выбрать такое время.
Даже с учетом этих крайних случаев, я все еще думаю, что у вас есть правильный подход: хранить данные в UTC. Гораздо проще сопоставить данные с другими часовыми поясами из UTC, чем из некоторого часового пояса в другой часовой пояс, особенно когда разные часовые пояса начинают / заканчивают летнее время в разные даты, и даже один и тот же часовой пояс может переключаться с использованием разных правил в разные годы ( например, США изменили правила 6 лет назад или около того).
Вы захотите использовать таблицу календаря для всего этого, а не какое-то гигантское CASE
выражение (не утверждение ). Я только что написал серию из трех частей для MSSQLTips.com по этому вопросу; Думаю, 3-я часть будет наиболее полезной для вас:
http://www.mssqltips.com/sqlservertip/3173/handle-conversion-between-time-zones-in-sql-server--part-1/
http://www.mssqltips.com/sqlservertip/3174/handle-conversion-between-time-zones-in-sql-server--part-2/
http://www.mssqltips.com/sqlservertip/3175/handle-conversion-between-time-zones-in-sql-server--part-3/
Настоящий живой пример, тем временем
Допустим, у вас есть очень простая таблица фактов. Единственный факт, который меня волнует в этом случае - это время события, но я добавлю бессмысленный GUID, чтобы таблица была достаточно широкой, чтобы о ней заботиться. Опять же, чтобы быть явным, таблица фактов хранит события только по времени UTC и UTC. Я даже добавил суффикс столбца, _UTC
чтобы не было путаницы.
CREATE TABLE dbo.Fact
(
EventTime_UTC DATETIME NOT NULL,
Filler UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID()
);
GO
CREATE CLUSTERED INDEX x ON dbo.Fact(EventTime_UTC);
GO
Теперь давайте загрузим нашу таблицу фактов с 10 000 000 строк, представляющих каждые 3 секунды (1200 строк в час) с 2013-12-30 в полночь по UTC до примерно после 5:00 UTC 2014-12-12. Это гарантирует, что данные пересекают границу года, а также DST вперед и назад для нескольких часовых поясов. Это выглядит действительно страшно, но в моей системе это заняло ~ 9 секунд. Таблица должна быть около 325 МБ.
;WITH x(c) AS
(
SELECT TOP (10000000) DATEADD(SECOND,
3*(ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1),
'20131230')
FROM sys.all_columns AS s1
CROSS JOIN sys.all_columns AS s2
ORDER BY s1.[object_id]
)
INSERT dbo.Fact WITH (TABLOCKX) (EventTime_UTC)
SELECT c FROM x;
И просто чтобы показать, как будет выглядеть типичный поисковый запрос для этой таблицы строк размером 10 мм, если я выполню этот запрос:
SELECT DATEADD(HOUR, DATEDIFF(HOUR, 0, EventTime_UTC), 0),
COUNT(*)
FROM dbo.Fact
WHERE EventTime_UTC >= '20140308'
AND EventTime_UTC < '20140311'
GROUP BY DATEADD(HOUR, DATEDIFF(HOUR, 0, EventTime_UTC), 0);
Я получаю этот план, и он возвращается через 25 миллисекунд *, выполняя 358 операций чтения, и возвращает 72 ежечасных результата:
* Длительность измеряется нашим бесплатным SQL Sentry Plan Explorer , который отбрасывает результаты, так что сюда не входит время передачи данных по сети, рендеринг и т. Д. В качестве дополнительного отказа я работаю для SQL Sentry.
Очевидно, это займет немного больше времени, если я сделаю свой диапазон слишком большим - месяц данных занимает 258 мс, два месяца - более 500 мс и так далее. Параллелизм может вызвать:
Здесь вы начинаете думать о других, более эффективных решениях для удовлетворения запросов отчетности, и это не имеет никакого отношения к тому, в каком часовом поясе будут отображаться ваши выходные данные. Я не буду вдаваться в подробности, я просто хочу продемонстрировать, что преобразование часового пояса на самом деле не заставит ваши запросы отчетности отстать намного больше, и они могут уже отстой, если вы получаете большие диапазоны, которые не поддерживаются должным образом. индексов. Я собираюсь придерживаться небольших диапазонов дат, чтобы показать, что логика верна, и позволить вам беспокоиться о том, чтобы ваши отчеты о запросах на основе диапазонов работали адекватно, с преобразованиями часовых поясов или без них.
Хорошо, теперь нам нужны таблицы для хранения наших часовых поясов (со смещением, в минутах, поскольку не у всех есть даже часы по Гринвичу) и даты изменения летнего времени для каждого поддерживаемого года. Для простоты я собираюсь ввести только несколько часовых поясов и один год, чтобы соответствовать данным выше.
CREATE TABLE dbo.TimeZones
(
TimeZoneID TINYINT NOT NULL PRIMARY KEY,
Name VARCHAR(9) NOT NULL,
Offset SMALLINT NOT NULL, -- minutes
DSTName VARCHAR(9) NOT NULL,
DSTOffset SMALLINT NOT NULL -- minutes
);
Включено несколько часовых поясов для разнообразия, некоторые со смещением в полчаса, некоторые не соблюдают летнее время. Обратите внимание, что в Австралии в южном полушарии наблюдается летнее время в течение нашей зимы, поэтому их часы возвращаются в апреле и вперед в октябре. (Таблица выше переворачивает названия, но я не уверен, как сделать это менее запутанным для часовых поясов южного полушария.)
INSERT dbo.TimeZones VALUES
(1, 'UTC', 0, 'UTC', 0),
(2, 'GMT', 0, 'BST', 60),
-- London = UTC in winter, +1 in summer
(3, 'EST', -300, 'EDT', -240),
-- East coast US (-5 h in winter, -4 in summer)
(4, 'ACDT', 630, 'ACST', 570),
-- Adelaide (Australia) +10.5 h Oct - Apr, +9.5 Apr - Oct
(5, 'ACST', 570, 'ACST', 570);
-- Darwin (Australia) +9.5 h year round
Теперь, календарь таблицы, чтобы знать, когда TZ меняются. Я только собираюсь вставить интересующие строки (каждый часовой пояс выше, и только изменения летнего времени за 2014 год). Для простоты вычислений я сохраняю момент в UTC, где меняется часовой пояс, и один и тот же момент в местном времени. Для часовых поясов, которые не соблюдают летнее время, это стандартно в течение всего года, и летнее время «начинается» с 1 января.
CREATE TABLE dbo.Calendar
(
TimeZoneID TINYINT NOT NULL FOREIGN KEY
REFERENCES dbo.TimeZones(TimeZoneID),
[Year] SMALLDATETIME NOT NULL,
UTCDSTStart SMALLDATETIME NOT NULL,
UTCDSTEnd SMALLDATETIME NOT NULL,
LocalDSTStart SMALLDATETIME NOT NULL,
LocalDSTEnd SMALLDATETIME NOT NULL,
PRIMARY KEY (TimeZoneID, [Year])
);
Вы можете определенно заполнить это алгоритмами (и в следующей серии советов используются некоторые умные методы, основанные на множествах, если я так скажу), а не зацикливаться, заполнять вручную, что у вас. Для этого ответа я решил просто вручную ввести один год для пяти часовых поясов, и я не собираюсь прибегать к каким-либо хитроумным трюкам.
INSERT dbo.Calendar VALUES
(1, '20140101', '20140101 00:00','20150101 00:00','20140101 00:00','20150101 00:00'),
(2, '20140101', '20140330 01:00','20141026 00:00','20140330 02:00','20141026 01:00'),
(3, '20140101', '20140309 07:00','20141102 06:00','20140309 03:00','20141102 01:00'),
(4, '20140101', '20140405 16:30','20141004 16:30','20140406 03:00','20141005 02:00'),
(5, '20140101', '20140101 00:00','20150101 00:00','20140101 00:00','20150101 00:00');
Итак, у нас есть данные фактов и таблицы «измерений» (я съеживаюсь, когда говорю это), так в чем же логика? Что ж, я предполагаю, что вы будете предлагать пользователям выбирать свой часовой пояс и вводить диапазон дат для запроса. Я также предполагаю, что диапазон дат будет полными днями в их собственном часовом поясе; никаких неполных дней, не говоря уже о неполных часах. Таким образом, они передадут дату начала, дату окончания и TimeZoneID. Оттуда мы будем использовать скалярную функцию для преобразования даты начала / окончания из этого часового пояса в UTC, что позволит нам фильтровать данные на основе диапазона UTC. После того, как мы это сделали и выполнили наши агрегации, мы можем применить преобразование сгруппированных времен назад к часовому поясу источника, прежде чем отобразить его пользователю.
Скаляр UDF:
CREATE FUNCTION dbo.ConvertToUTC
(
@Source SMALLDATETIME,
@SourceTZ TINYINT
)
RETURNS SMALLDATETIME
WITH SCHEMABINDING
AS
BEGIN
RETURN
(
SELECT DATEADD(MINUTE, -CASE
WHEN @Source >= src.LocalDSTStart
AND @Source < src.LocalDSTEnd THEN t.DSTOffset
WHEN @Source >= DATEADD(HOUR,-1,src.LocalDSTStart)
AND @Source < src.LocalDSTStart THEN NULL
ELSE t.Offset END, @Source)
FROM dbo.Calendar AS src
INNER JOIN dbo.TimeZones AS t
ON src.TimeZoneID = t.TimeZoneID
WHERE src.TimeZoneID = @SourceTZ
AND t.TimeZoneID = @SourceTZ
AND DATEADD(MINUTE,t.Offset,@Source) >= src.[Year]
AND DATEADD(MINUTE,t.Offset,@Source) < DATEADD(YEAR, 1, src.[Year])
);
END
GO
И табличная функция:
CREATE FUNCTION dbo.ConvertFromUTC
(
@Source SMALLDATETIME,
@SourceTZ TINYINT
)
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN
(
SELECT
[Target] = DATEADD(MINUTE, CASE
WHEN @Source >= trg.UTCDSTStart
AND @Source < trg.UTCDSTEnd THEN tz.DSTOffset
ELSE tz.Offset END, @Source)
FROM dbo.Calendar AS trg
INNER JOIN dbo.TimeZones AS tz
ON trg.TimeZoneID = tz.TimeZoneID
WHERE trg.TimeZoneID = @SourceTZ
AND tz.TimeZoneID = @SourceTZ
AND @Source >= trg.[Year]
AND @Source < DATEADD(YEAR, 1, trg.[Year])
);
И процедура, которая его использует ( edit : updated для обработки 30-минутной группировки смещений):
CREATE PROCEDURE dbo.ReportOnDateRange
@Start SMALLDATETIME, -- whole dates only please!
@End SMALLDATETIME, -- whole dates only please!
@TimeZoneID TINYINT
AS
BEGIN
SET NOCOUNT ON;
SELECT @Start = dbo.ConvertToUTC(@Start, @TimeZoneID),
@End = dbo.ConvertToUTC(@End, @TimeZoneID);
;WITH x(t,c) AS
(
SELECT DATEDIFF(MINUTE, @Start, EventTime_UTC)/60,
COUNT(*)
FROM dbo.Fact
WHERE EventTime_UTC >= @Start
AND EventTime_UTC < DATEADD(DAY, 1, @End)
GROUP BY DATEDIFF(MINUTE, @Start, EventTime_UTC)/60
)
SELECT
UTC = DATEADD(MINUTE, x.t*60, @Start),
[Local] = y.[Target],
[RowCount] = x.c
FROM x OUTER APPLY
dbo.ConvertFromUTC(DATEADD(MINUTE, x.t*60, @Start), @TimeZoneID) AS y
ORDER BY UTC;
END
GO
(Возможно, вы захотите попробовать там короткое замыкание или отдельную хранимую процедуру, если пользователь хочет отправлять отчеты в UTC - очевидно, что перевод в и из UTC будет расточительной занятой работой.)
Образец звонка:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Возвращает в 41мс *, и генерирует этот план:
* Опять же, с отброшенными результатами.
В течение 2 месяцев он возвращается через 507 мс, и план идентичен, кроме количества строк:
Несмотря на то, что он немного более сложный и немного увеличивает время выполнения, я довольно уверен, что этот тип подхода сработает намного, намного лучше, чем подход с промежуточным столом. И это случайный пример ответа dba.se; Я уверен, что моя логика и эффективность могут быть улучшены людьми намного умнее меня.
Вы можете просмотреть данные, чтобы увидеть граничные случаи, о которых я говорю - нет строки вывода для часа, в котором часы идут вперед, две строки для часа, когда они откатились (и этот час произошел дважды). Вы также можете играть с плохими ценностями; например, если вы уйдете в 20140309 02:30 по восточному времени, это не сработает.
Возможно, у меня не все правильные предположения о том, как будут работать ваши отчеты, поэтому вам, возможно, придется внести некоторые коррективы. Но я думаю, что это охватывает основы.