Ho risolto questo problema avendo una tabella di calendario molto semplice: ogni anno ha una riga per fuso orario supportato , con offset standard e data / ora di inizio / ora legale dell'ora legale e relativo offset (se quel fuso orario lo supporta). Quindi una funzione inline, associata a schema, valutata a tabella che prende il tempo di origine (ovviamente in UTC) e aggiunge / sottrae l'offset.
Ovviamente ciò non funzionerà mai molto bene se stai segnalando una grande porzione di dati; il partizionamento potrebbe sembrare utile, ma avrai comunque casi in cui le ultime ore in un anno o le prime ore nel prossimo anno appartengono effettivamente a un anno diverso quando vengono convertite in un fuso orario specifico, quindi non puoi mai ottenere una vera partizione isolamento, tranne quando l'intervallo di rapporti non include il 31 dicembre o il 1 gennaio.
Ci sono un paio di casi strani che devi considerare:
2014-11-02 05:30 UTC e 2014-11-02 06:30 UTC vengono entrambi convertiti in 01:30 AM nel fuso orario orientale, ad esempio (uno per la prima volta 01:30 è stato colpito localmente, quindi uno per la seconda volta quando gli orologi sono tornati indietro dalle 2:00 all'01: 00 e è trascorsa un'altra mezz'ora). Quindi è necessario decidere come gestire quell'ora di reportistica: in base a UTC, dovresti vedere il doppio del traffico o del volume di qualsiasi cosa stai misurando una volta che queste due ore vengono mappate su una singola ora in un fuso orario che osserva l'ora legale. Questo può anche giocare a giochi divertenti con il sequenziamento di eventi, poiché qualcosa che logicamente doveva succedere dopo che poteva apparire qualcos'altroprima che avvenga una volta che il tempo è regolato su una sola ora anziché su due. Un esempio estremo è una visualizzazione di pagina avvenuta alle 05:59 UTC, quindi un clic che si è verificato alle 06:00 UTC. Nel tempo UTC questi avvenivano a distanza di un minuto, ma quando convertiti in fuso orario orientale, la vista avveniva alle 1:59 e il clic avveniva un'ora prima.
09-03-2014 02:30 non succede mai negli Stati Uniti. Questo perché alle 2:00 AM spostiamo gli orologi in avanti alle 3:00 AM. Quindi probabilmente vorrai generare un errore se l'utente inserisce un tempo simile e ti chiede di convertirlo in UTC o di progettare il tuo modulo in modo che gli utenti non possano scegliere un orario del genere.
Anche con questi casi limite in mente, penso ancora che tu abbia l'approccio giusto: archiviare i dati in UTC. Molto più facile mappare i dati su altri fusi orari da UTC che da alcuni fusi orari ad altri fusi orari, soprattutto quando fusi orari diversi iniziano / terminano l'ora legale in date diverse e anche lo stesso fuso orario può passare a regole diverse in anni diversi ( per esempio gli Stati Uniti hanno cambiato le regole circa 6 anni fa).
Ti consigliamo di utilizzare una tabella di calendario per tutto ciò, non CASE
un'espressione gigantesca (non un'istruzione ). Ho appena scritto una serie in tre parti per MSSQLTips.com su questo; Penso che la terza parte sarà la più utile per te:
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/
Un vero esempio dal vivo, nel frattempo
Diciamo che hai una tabella dei fatti molto semplice. L'unico fatto che mi interessa in questo caso è l'ora dell'evento, ma aggiungerò un GUID insignificante solo per rendere il tavolo abbastanza ampio da occuparsene. Ancora una volta, per essere espliciti, la tabella dei fatti memorizza gli eventi solo in ora UTC e ora UTC. Ho anche aggiunto il suffisso alla colonna, _UTC
quindi non c'è confusione.
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
Ora cariciamo la nostra tabella dei fatti con 10.000.000 di righe, che rappresentano ogni 3 secondi (1.200 righe all'ora) dal 30-12-2013 a mezzanotte UTC fino a qualche ora dopo le 5:00 UTC del 2014-12-12. Ciò garantisce che i dati si trovino a cavallo di un confine annuale, nonché l'ora legale avanti e indietro per più fusi orari. Sembra davvero spaventoso, ma ci sono voluti ~ 9 secondi sul mio sistema. La tabella dovrebbe finire per essere circa 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 solo per mostrare come apparirà una tipica query di ricerca su questa tabella di righe da 10 MM, se eseguo questa query:
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);
Ricevo questo piano e ritorna in 25 millisecondi *, facendo 358 letture, per restituire i totali di 72 ore:
* Durata misurata dal nostro piano di esplorazione SQL Sentry gratuito , che elimina i risultati, quindi questo non include il tempo di trasferimento in rete dei dati, il rendering, ecc. Come disclaimer aggiuntivo, lavoro per SQL Sentry.
Ci vuole un po 'più di tempo, ovviamente, se ingrandisco troppo il mio intervallo: un mese di dati impiega 258 ms, due mesi impiega oltre 500 ms e così via. Il parallelismo può dare il via a:
È qui che inizi a pensare ad altre soluzioni migliori per soddisfare le query di reporting e non ha nulla a che fare con quale fuso orario verrà visualizzato l'output. Non mi occuperò di questo, voglio solo dimostrare che la conversione del fuso orario non farà davvero risucchiare molto di più le tue query sui rapporti e potrebbero già risucchiare se stai ottenendo ampi intervalli che non sono supportati da indici. Seguirò piccoli intervalli di date per dimostrare che la logica è corretta e ti preoccuperò di assicurarti che le tue query sui rapporti basate su intervallo funzionino adeguatamente, con o senza conversioni di fuso orario.
Bene, ora abbiamo bisogno di tabelle per memorizzare i nostri fusi orari (con offset, in minuti, dato che non tutti hanno nemmeno ore di pausa rispetto all'ora UTC) e le date di modifica dell'ora legale per ogni anno supportato. Per semplicità, inserirò solo alcuni fusi orari e un solo anno per abbinare i dati sopra.
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
);
Incluso alcuni fusi orari per varietà, alcuni con offset di mezz'ora, altri che non osservano l'ora legale. Si noti che l'Australia, nell'emisfero meridionale, osserva l'ora legale durante il nostro inverno, quindi i loro orologi risalgono ad aprile e in avanti ad ottobre. (La tabella sopra mostra i nomi, ma non sono sicuro di come rendere questo meno confuso per i fusi orari dell'emisfero meridionale.)
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
Ora, una tabella del calendario per sapere quando cambiano le TZ. Inserirò solo righe di interesse (ogni fuso orario sopra e solo le modifiche all'ora legale per il 2014). Per facilitare i calcoli avanti e indietro, memorizzo sia il momento in UTC in cui cambia un fuso orario, sia lo stesso momento nell'ora locale. Per i fusi orari che non osservano l'ora legale, è standard tutto l'anno e l'ora legale inizia il 1 ° gennaio.
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])
);
Puoi sicuramente popolarlo con algoritmi (e la prossima serie di suggerimenti usa alcune tecniche basate su set intelligenti, se lo dico io stesso), piuttosto che loop, popolare manualmente, cosa hai. Per questa risposta ho deciso di popolare manualmente solo un anno per i cinque fusi orari e non mi preoccuperò di trucchi fantasiosi.
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');
Va bene, quindi abbiamo i nostri dati sui fatti e le nostre tabelle "dimensionali" (io rabbrividisco quando lo dico), quindi qual è la logica? Bene, presumo che gli utenti selezionino il loro fuso orario e inseriscano l'intervallo di date per la query. Supporrò anche che l'intervallo di date sarà di giorni interi nel loro fuso orario; nessun giorno parziale, non importa ore parziali. Quindi passeranno una data di inizio, una data di fine e un TimeZoneID. Da lì useremo una funzione scalare per convertire la data di inizio / fine da quel fuso orario in UTC, che ci permetterà di filtrare i dati in base all'intervallo UTC. Una volta fatto ciò, ed eseguito le nostre aggregazioni su di esso, possiamo quindi applicare la conversione dei tempi raggruppati al fuso orario di origine, prima di mostrarli all'utente.
L'UDF scalare:
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 la funzione con valori di tabella:
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 una procedura che lo utilizza ( modifica : aggiornato per gestire il raggruppamento degli offset di 30 minuti):
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
(Potresti provare a cortocircuitare lì, o una procedura memorizzata separata, nel caso in cui l'utente desideri segnalare in UTC - ovviamente la traduzione da e verso UTC sarà uno spreco di lavoro occupato.)
Chiamata di esempio:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Restituisce in 41 ms * e genera questo piano:
* Ancora una volta, con risultati scartati.
Per 2 mesi, restituisce 507 ms e il piano è identico a parte i conteggi delle righe:
Sebbene leggermente più complesso e aumentando un po 'il tempo di esecuzione, sono abbastanza fiducioso che questo tipo di approccio funzionerà molto, molto meglio dell'approccio con tabella a ponte. E questo è un esempio fuori dal comune per una risposta dba.se; Sono sicuro che la mia logica ed efficienza potrebbero essere migliorate da persone molto più intelligenti di me.
Puoi esaminare i dati per vedere i casi limite di cui parlo: nessuna riga di output per l'ora in cui gli orologi si spostano in avanti, due righe per l'ora in cui sono passati (e quell'ora è avvenuta due volte). Puoi anche giocare con valori cattivi; se passi in 20140309 02:30 ora orientale, ad esempio, non funzionerà troppo bene.
Potrei non avere tutte le ipotesi giuste su come funzionerà la tua segnalazione, quindi potresti dover apportare alcune modifiche. Ma penso che questo copre le basi.