数据仓库设计,用于针对多个时区的数据进行报告

Pet*_*r M 10 data-warehouse database-design sql-server reporting timezone

我们正在尝试优化数据仓库设计,该设计将支持针对多个时区的数据进行报告。例如,我们可能有一份一个月的活动(数百万行)的报告,需要显示按一天中的小时分组的活动。当然,一天中的那个小时必须是给定时区的“本地”小时。

当我们只支持 UTC 和一个本地时间时,我们的设计运行良好。UTC 和本地时间的日期和时间维度的标准设计,ID 在事实表上。但是,如果我们必须支持 100 多个时区的报告,那么这种方法似乎无法扩展。

我们的事实表会变得很宽。此外,我们必须解决 SQL 中的语法问题,即指定在任何给定的报告运行中使用哪个日期和时间 ID 进行分组。也许是一个非常大的 CASE 语句?

我已经看到一些建议,可以通过您所覆盖的 UTC 时间范围获取所有数据,然后将其返回到表示层以转换为本地并在那里聚合,但是使用 SSRS 进行的有限测试表明这将非常慢。

我也查阅了一些关于这个主题的书籍,他们似乎都说只有 UTC 和转换显示或有 UTC 和一个本地。将不胜感激任何想法和建议。

注意:此问题类似于:处理数据集市/仓库中的时区,但我无法评论该问题,因此觉得这值得自己提出问题。

更新:在Aaron 进行了一些重大更新并发布了示例代码和图表后,我选择了他的答案。我之前对他的回答的评论不再有意义,因为他们提到了答案的原始编辑。如果有必要,我会尝试回来并再次更新

Aar*_*and 18

我通过一个非常简单的日历表解决了这个问题 - 每年每个支持的时区都有一行,带有标准偏移量和 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 回滚到凌晨 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 映射到其他时区比从某个时区映射到其他时区要容易得多,尤其是当不同时区在不同日期开始/结束 DST 时,甚至同一时区可以在不同年份使用不同规则切换(例如,美国在 6 年前左右更改了规则)。

您将需要使用日历表来完成所有这些工作,而不是一些庞大的CASE 表达式(不是statement)。我刚刚为MSSQLTips.com写了一个由三部分组成的系列文章;我认为第三部分对您最有用:


一个真实的例子,与此同时

假设您有一个非常简单的事实表。在这种情况下,我关心的唯一事实是事件时间,但我将添加一个无意义的 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
Run Code Online (Sandbox Code Playgroud)

现在,让我们用 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;
Run Code Online (Sandbox Code Playgroud)

并且只是为了显示一个典型的搜索查询在这个 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);
Run Code Online (Sandbox Code Playgroud)

我得到了这个计划,它在 25 毫秒*内返回,执行 358 次读取,以返回 72 小时的总数:

在此处输入图片说明

*持续时间由免费的SentryOne Plan Explorer测量,它丢弃结果,因此这不包括数据的网络传输时间、渲染等。

显然,如果我的范围太大,则需要更长的时间 - 一个月的数据需要 258 毫秒,两个月需要超过 500 毫秒,依此类推。并行性可能会出现:

在此处输入图片说明

这是您开始考虑其他更好的解决方案来满足报告查询的地方,这与您的输出将显示的时区无关。我不会讨论这个,我只是想证明时区转换不会让你的报告查询变得更糟糕,如果你得到适当的不支持的大范围,它们可能已经很糟糕索引。我将坚持使用较小的日期范围来表明逻辑是正确的,并让您担心确保基于范围的报告查询能够充分执行,无论是否进行时区转换。

好的,现在我们需要表来存储我们的时区(以分钟为单位的偏移量,因为不是每个人都比 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
);
Run Code Online (Sandbox Code Playgroud)

包括几个时区的多样性,一些有半小时的偏移,一些不遵守夏令时。请注意,位于南半球的澳大利亚在我们的冬季观察 DST,因此他们的时钟在 4 月倒退,在 10月提前。(上表翻转了名称,但我不知道如何让南半球时区不那么混乱。)

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
Run Code Online (Sandbox Code Playgroud)

现在,一个日历表可以知道 TZ 何时发生变化。我只会插入感兴趣的行(上面的每个时区,并且只有 2014 年的 DST 更改)。为了方便来回计算,我将时区变化的 UTC 时刻和当地时间的同一时刻存储在 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])
);
Run Code Online (Sandbox Code Playgroud)

你绝对可以用算法来填充它(并且即将到来的技巧系列使用一些基于集合的聪明技术,如果我自己这么说的话),而不是循环,手动填充,你有什么。对于这个答案,我决定手动为五个时区填充一年,我不会打扰任何花哨的技巧。

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');
Run Code Online (Sandbox Code Playgroud)

好的,所以我们有我们的事实数据和我们的“维度”表(当我这么说时我感到畏缩),那么逻辑是什么?好吧,我假设您将让用户选择他们的时区并输入查询的日期范围。我还将假设日期范围将是他们自己时区中的全天;没有部分时间,更不用说部分时间。因此,他们将传入开始日期、结束日期和 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
Run Code Online (Sandbox Code Playgroud)

和表值函数:

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])
);
Run Code Online (Sandbox Code Playgroud)

以及使用它的过程(编辑:更新以处理 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
Run Code Online (Sandbox Code Playgroud)

(如果用户想要在 UTC 中进行报告,您可能希望在那里进行短路或单独的存储过程 - 显然,与 UTC 相互转换将是一种浪费的繁忙工作。)

示例调用:

EXEC dbo.ReportOnDateRange 
  @Start      = '20140308', 
  @End        = '20140311', 
  @TimeZoneID = 3;
Run Code Online (Sandbox Code Playgroud)

在 41ms* 内返回,并生成此计划:

在此处输入图片说明

*再次,丢弃结果。

2 个月,它在 507 毫秒内返回,除行计数外,计划是相同的:

在此处输入图片说明

虽然稍微复杂一些并且增加了一点运行时间,但我相当有信心这种类型的方法会比桥接表方法好得多。这是 dba.se 答案的现成示例;我相信比我聪明得多的人可以提高我的逻辑和效率。

您可以仔细阅读数据以查看我谈到的边缘情况 - 时钟向前滚动的小时没有输出行,时钟回滚的小时有两行输出(那个小时发生了两次)。你也可以玩坏价值观;例如,如果您在东部时间 20140309 02:30 中通过,则效果不会太好。

关于您的报告将如何工作,我可能没有正确的所有假设,因此您可能需要进行一些调整。但我认为这涵盖了基础知识。