我已经通过一个非常简单的日历表解决了这一问题-每年每个受支持的时区都有一行,其中包含标准偏移量以及DST的开始日期时间/结束日期时间及其偏移量(如果该时区支持的话)。然后是一个内联的,受模式约束的,表值函数,它占用源时间(当然,以UTC为单位)并添加/减去偏移量。
如果您要针对大量数据进行报告,那么这显然将永远无法表现出色;分区似乎有帮助,但是当转换为特定时区时,您仍然会遇到这样的情况:一年中的最后几个小时或次年的前几个小时实际上属于不同的年份-因此您永远无法获得真正的分区隔离,除非您的报告范围不包括12月31日或1月1日。
您需要考虑几种奇怪的情况:
例如,在美国东部时区,2014-11-02 05:30 UTC和2014-11-02 06:30 UTC都转换为01:30 AM(第一次在本地时区打出01:30,然后一个时钟第二次从2:00 AM退回1:00 AM,又过了半小时)。因此,您需要确定如何处理这一小时的报告-根据UTC,一旦将这两个小时映射到遵守DST的时区中的一个小时,就应该看到所测量内容的流量或流量增加一倍。这也可以按事件顺序玩有趣的游戏,因为在某些其他事情可能出现之后,某些事情在逻辑上必须发生一旦时间调整为一个小时而不是两个小时,就可以在此之前发生。一个极端的例子是,在UTC时间05:59发生的网页浏览,然后在UTC 06:00发生的点击。在UTC时间中,这些时间间隔是一分钟,但是转换为东部时间后,观看时间是在1:59 AM发生的,而点击发生在一个小时之前。
2014-03-09 02:30在美国从未发生过。这是因为在2:00 AM,我们将时钟向前滚动到3:00 AM。如果用户输入这样的时间并要求您将其转换为UTC,或者设计您的表单以使用户无法选择这样的时间,那么很可能会引发错误。
即使考虑到这些极端情况,我仍然认为您有正确的方法:将数据存储在UTC中。将数据从UTC映射到其他时区比从某个时区映射到其他时区要容易得多,尤其是当不同时区在不同日期开始/结束夏令时时,甚至同一时区也可以使用不同年份的不同规则进行切换(例如美国在6年前左右更改了规则)。
您将要为所有这些使用日历表,而不是一些庞大的CASE
表达式(不是statement)。我刚刚为此写了一个由三部分组成的系列,用于MSSQLTips.com。我认为第三部分对您最有用:
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行-从2013-12-30在UTC午夜到2014-12-12凌晨5点之后的某个时间,每3秒(每小时1,200行)。这样可以确保数据跨越一年边界,以及多个时区的DST向前和向后。这看起来确实很吓人,但在我的系统上花费了大约9秒钟。表最终应为大约325 MB。
;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;
如果要运行此查询,仅显示此10MM行表的典型搜索查询的外观:
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。
显然,如果我将范围设置得过大,则需要花费更长的时间-一个月的数据需要258ms,两个月的时间超过500ms,依此类推。并行性可能会开始:
在这里,您开始考虑其他更好的解决方案来满足报表查询,并且与您的输出将显示在哪个时区无关。我不打算讨论这个问题,我只是想证明时区转换并不会真正使您的报告查询变得更加糟糕,如果您得到的范围较大,而正常情况下不支持的话,它们可能已经很糟糕了。索引。我将坚持使用较小的日期范围来表明该逻辑是正确的,并且让您担心确保基于范围的报表查询在执行时区转换或不执行时区转换时均能正常执行。
好的,现在我们需要使用表格来存储我们的时区(以分钟为单位的偏移量,因为并不是每个人都比UTC少几个小时)和每个受支持年份的DST更改日期。为简单起见,我将只输入几个时区和一年以匹配上面的数据。
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
);
包括几个变化的时区,一些时区偏移半小时,有些不遵守DST。需要注意的是澳大利亚在南半球的冬天我们在观察DST,所以它们的时钟走回来在四月和前十月。(上表颠倒了名字,但我不确定如何使南半球时区的混乱程度降低。)
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中,并将本地时间存储在相同时间。对于不遵守DST的时区,整年都是标准时间,DST将于1月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])
);
以及使用它的过程(编辑:已更新为处理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;
以41ms *返回,并生成以下计划:
* 同样,结果被丢弃。
2个月后,它返回507毫秒,除行数外,该计划是相同的:
尽管稍微复杂一些,并增加了运行时间,但我相当有信心这种方法将比桥接表方法有效得多。这是dba.se答案的现成示例;我敢肯定,比我聪明得多的人可以提高我的逻辑和效率。
您可以细读数据以查看我所谈论的极端情况-时钟向前滚动的小时没有输出,向后滚动的小时没有输出两行(该时间发生了两次)。您也可以玩低估值的游戏。例如,如果您在美国东部时间20140309 02:30过去,那将不能很好地工作。
对于您的报告的工作方式,我可能没有所有正确的假设,因此您可能需要进行一些调整。但是我认为这涵盖了基础知识。