Resolvi isso com uma tabela de calendário muito simples - cada ano tem uma linha por fuso horário suportado , com o deslocamento padrão e o horário de início / fim do horário de verão do DST e seu deslocamento (se esse fuso horário suportar). Em seguida, uma função embutida, vinculada ao esquema e com valor de tabela, que leva o tempo de origem (no UTC, é claro) e adiciona / subtrai o deslocamento.
Obviamente, isso nunca terá um desempenho extremamente bom se você estiver reportando uma grande parte dos dados; o particionamento pode parecer útil, mas você ainda terá casos em que as últimas horas em um ano ou as primeiras horas no próximo ano pertencem a um ano diferente quando convertidas em um fuso horário específico - para que você nunca possa obter uma partição verdadeira isolamento, exceto quando o intervalo de relatórios não incluir 31 de dezembro ou 1º de janeiro.
Existem alguns casos extremos estranhos que você precisa considerar:
2014-11-02 05:30 UTC e 2014-11-02 06:30 UTC, ambos convertem para 01:30 no fuso horário do leste, por exemplo (um pela primeira vez 01:30 foi atingido localmente e depois um pela segunda vez, quando os relógios voltaram das 2:00 às 1:00 e mais meia hora se passou). Portanto, você precisa decidir como lidar com essa hora de geração de relatórios - de acordo com a UTC, você deve ver o dobro do tráfego ou volume do que estiver medindo quando essas duas horas forem mapeadas para uma única hora em um fuso horário que observe o horário de verão. Isso também pode jogar jogos divertidos com a sequência de eventos, já que algo que logicamente teve que acontecer depois que algo mais pudesse apareceracontecer antes que o tempo seja ajustado para uma única hora em vez de duas. Um exemplo extremo é uma exibição de página que aconteceu às 05:59 UTC e, em seguida, um clique que aconteceu às 06:00 UTC. No horário UTC, eles aconteciam com um minuto de diferença, mas, quando convertidos para o horário do leste, a exibição acontecia às 1:59 e o clique ocorria uma hora antes.
2014-03-09 02:30 nunca acontece nos EUA. Isso ocorre porque às 02:00 rolamos os relógios para as 03:00. É provável que você queira gerar um erro se o usuário digitar esse horário e solicitar que você o converta para UTC ou crie seu formulário para que os usuários não possam escolher esse horário.
Mesmo com esses casos extremos em mente, ainda acho que você tem a abordagem correta: armazene os dados no UTC. Muito mais fácil mapear dados para outros fusos horários do UTC do que de algum fuso horário para outro fuso horário, especialmente quando fusos horários diferentes iniciam / encerram o horário de verão em datas diferentes e até o mesmo fuso horário pode alternar usando regras diferentes em anos diferentes ( por exemplo, os EUA mudaram as regras há seis anos).
Você desejará usar uma tabela de calendário para tudo isso, não alguma CASE
expressão gigantesca (não declaração ). Acabei de escrever uma série de três partes para o MSSQLTips.com sobre isso; Eu acho que a terceira parte será a mais útil para você:
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/
Um verdadeiro exemplo ao vivo, enquanto isso
Digamos que você tenha uma tabela de fatos muito simples. O único fato que me interessa nesse caso é a hora do evento, mas adicionarei um GUID sem sentido apenas para tornar a tabela suficientemente ampla para se preocupar. Novamente, para ser explícito, a tabela de fatos armazena eventos apenas na hora UTC e na hora UTC. Eu mesmo coloquei o sufixo na coluna _UTC
para que não haja confusão.
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
Agora, vamos carregar nossa tabela de fatos com 10.000.000 de linhas - representando a cada 3 segundos (1.200 linhas por hora) de 30/12/2013 à meia-noite UTC até um pouco depois das 05:00 UTC em 12/12/2014. Isso garante que os dados ultrapassem o limite de um ano, bem como o horário de verão para a frente e para trás em vários fusos horários. Isso parece realmente assustador, mas levou ~ 9 segundos no meu sistema. A tabela deve acabar tendo cerca de 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;
E apenas para mostrar como será uma consulta de pesquisa típica nessa tabela de linhas de 10MM, se eu executar esta consulta:
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);
Eu recebo esse plano e ele retorna em 25 milissegundos *, fazendo 358 leituras, para retornar 72 totais por hora:
* Duração conforme medida pelo nosso SQL Sentry Plan Explorer gratuito , que descarta os resultados; portanto, isso não inclui o tempo de transferência de dados, renderização etc. da rede. Como um aviso adicional, trabalho para o SQL Sentry.
Obviamente, levará um pouco mais de tempo se eu aumentar meu alcance - um mês de dados leva 258ms, dois meses leva 500ms e assim por diante. O paralelismo pode entrar em ação:
É aqui que você começa a pensar em outras soluções melhores para atender às consultas de relatórios e isso não tem nada a ver com o fuso horário em que a saída será exibida. Eu não vou entrar nisso, só quero demonstrar que a conversão de fuso horário realmente não fará com que suas consultas de relatórios sejam muito mais difíceis, e elas já podem ser ruins se você estiver obtendo grandes intervalos que não são compatíveis com o adequado índices. Vou manter pequenos intervalos de datas para mostrar que a lógica está correta e deixar você se preocupar em garantir que suas consultas de relatórios com base em intervalos tenham um desempenho adequado, com ou sem conversões de fuso horário.
Ok, agora precisamos de tabelas para armazenar nossos fusos horários (com deslocamentos, em minutos, já que nem todo mundo fica horas fora do UTC) e o horário de verão altera as datas para cada ano suportado. Para simplificar, vou inserir apenas alguns fusos horários e um único ano para corresponder aos dados acima.
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
);
Incluiu alguns fusos horários para variedade, alguns com desvios de meia hora, outros que não observam o horário de verão. Observe que a Austrália, no hemisfério sul, observa o horário de verão durante o inverno, então seus relógios voltam em abril e avançam em outubro. (A tabela acima inverte os nomes, mas não tenho certeza de como tornar isso menos confuso para os fusos horários do hemisfério sul.)
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
Agora, uma tabela de calendário para saber quando as TZs mudam. Vou inserir apenas linhas de interesse (cada fuso horário acima e apenas as alterações de horário de verão para 2014). Para facilitar os cálculos, eu armazeno o momento no UTC em que um fuso horário muda e o mesmo momento no horário local. Para fusos horários que não observam o horário de verão, é padrão o ano todo e o horário de verão "começa" em 1º de janeiro.
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])
);
Você pode definitivamente preencher isso com algoritmos (e a próxima série de dicas usa algumas técnicas inteligentes baseadas em conjuntos, se é que eu digo), em vez de fazer um loop, preencher manualmente, o que você tem. Para esta resposta, decidi preencher manualmente um ano nos cinco fusos horários, e não vou incomodar nenhum truque sofisticado.
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');
Ok, então temos nossos dados de fatos e nossas tabelas de "dimensão" (eu me encolho quando digo isso), então qual é a lógica? Bem, presumo que os usuários selecionem seu fuso horário e insiram o período da consulta. Também assumirei que o período será de dias inteiros no fuso horário; sem dias parciais, não importa horas parciais. Então eles passarão em uma data de início, uma data de término e um TimeZoneID. A partir daí, usaremos uma função escalar para converter a data de início / término desse fuso horário em UTC, o que nos permitirá filtrar os dados com base no intervalo UTC. Depois de fazer isso e executar nossas agregações, podemos aplicar a conversão dos tempos agrupados de volta ao fuso horário de origem, antes de exibir para o usuário.
O UDF escalar:
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
E a função com valor de tabela:
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])
);
E um procedimento que o utiliza ( edit : updated para lidar com o agrupamento de deslocamento de 30 minutos):
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
(Você pode tentar entrar em curto-circuito lá, ou um procedimento armazenado separado, caso o usuário deseje gerar relatórios no UTC - obviamente, a conversão de e para o UTC será um trabalho muito trabalhoso).
Exemplo de chamada:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Retorna em 41ms * e gera este plano:
* Novamente, com resultados descartados.
Por 2 meses, ele retorna em 507ms, e o plano é idêntico, exceto contas de linha:
Embora um pouco mais complexo e aumentando o tempo de execução, estou bastante confiante de que esse tipo de abordagem funcionará muito, muito melhor do que a abordagem da tabela de pontes. E este é um exemplo imediato para uma resposta dba.se; Tenho certeza de que minha lógica e eficiência podem ser melhoradas por pessoas muito mais inteligentes que eu.
Você pode ler os dados para ver os casos extremos dos quais falo - nenhuma linha de saída para a hora em que os relógios avançam, duas linhas para a hora em que eles revertem (e essa hora aconteceu duas vezes). Você também pode jogar com valores ruins; se você passar 20140309 02:30 no horário do leste, por exemplo, não vai funcionar muito bem.
Talvez eu não tenha todas as suposições corretas sobre como seus relatórios funcionarão, portanto, talvez você precise fazer alguns ajustes. Mas acho que isso cobre o básico.