Ich habe dieses Problem durch eine sehr einfache Kalendertabelle gelöst - jedes Jahr gibt es eine Zeile pro unterstützter Zeitzone mit dem Standardversatz und der Start- / Endzeit / Endzeit der Sommerzeit und deren Versatz (sofern diese Zeitzone dies unterstützt). Dann eine Inline-Funktion mit Schema-Bindung und Tabellenwert, die die Quellzeit (natürlich in UTC) benötigt und den Offset addiert / subtrahiert.
Dies wird offensichtlich nie besonders gut funktionieren, wenn Sie über einen großen Teil der Daten berichten. Die Partitionierung scheint zu helfen, aber es gibt immer noch Fälle, in denen die letzten Stunden in einem Jahr oder die ersten Stunden im nächsten Jahr tatsächlich zu einem anderen Jahr gehören, wenn sie in eine bestimmte Zeitzone konvertiert werden - sodass Sie niemals eine echte Partition erhalten können Isolation, außer wenn Ihr Berichtsbereich den 31. Dezember oder den 1. Januar nicht umfasst.
Es gibt ein paar seltsame Randfälle, die Sie berücksichtigen müssen:
2014-11-02 05:30 UTC und 2014-11-02 06:30 UTC konvertieren beide zum Beispiel in der östlichen Zeitzone auf 01:30 Uhr (eine zum ersten Mal wurde 01:30 lokal getroffen und dann eine zum zweiten Mal, als die Uhren von 2:00 Uhr auf 1:00 Uhr zurückgingen und eine weitere halbe Stunde verstrichen war). Sie müssen also entscheiden, wie mit dieser Stunde der Berichterstellung umgegangen werden soll. Laut UTC sollten Sie den doppelten Datenverkehr oder das doppelte Volumen von allem, was Sie messen, sehen, sobald diese zwei Stunden einer einzelnen Stunde in einer Zeitzone zugeordnet sind, in der die Sommerzeit eingehalten wird. Dies kann auch lustige Spiele mit Sequenzierung von Ereignissen spielen, da etwas, das logischerweise passieren musste, nachdem etwas anderes erscheinen konntebevor es passiert, sobald das Timing auf eine Stunde anstatt auf zwei eingestellt ist. Ein extremes Beispiel ist eine Seitenansicht, die um 05:59 UTC erfolgte, und ein Klick, der um 06:00 UTC erfolgte. In der UTC-Zeit passierten diese im Abstand von einer Minute, aber bei der Umrechnung in die östliche Zeit erfolgte die Ansicht um 01:59 Uhr, und das Klicken erfolgte eine Stunde zuvor.
2014-03-09 02:30 kommt in den USA nie vor. Dies liegt daran, dass wir um 2:00 Uhr die Uhr auf 3:00 Uhr vorwärts rollen. Daher möchten Sie wahrscheinlich einen Fehler auslösen, wenn der Benutzer eine solche Zeit eingibt und Sie auffordert, diese in UTC zu konvertieren, oder Ihr Formular so zu gestalten, dass Benutzer eine solche Zeit nicht auswählen können.
Selbst unter Berücksichtigung dieser Randfälle denke ich immer noch, dass Sie den richtigen Ansatz haben: Speichern Sie die Daten in UTC. Es ist viel einfacher, Daten von UTC auf andere Zeitzonen abzubilden als von einer Zeitzone auf eine andere Zeitzone, insbesondere wenn verschiedene Zeitzonen die Sommerzeit an verschiedenen Daten beginnen / beenden und sogar dieselbe Zeitzone in verschiedenen Jahren nach unterschiedlichen Regeln wechseln kann ( Zum Beispiel haben die USA die Regeln vor ungefähr 6 Jahren geändert.
Sie werden für all dies eine Kalendertabelle verwenden wollen, keinen gigantischen CASE
Ausdruck (keine Aussage ). Ich habe gerade eine dreiteilige Serie für MSSQLTips.com darüber geschrieben. Ich denke, der 3. Teil wird für Sie am nützlichsten sein:
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/
In der Zwischenzeit ein echtes Live-Beispiel
Angenommen, Sie haben eine sehr einfache Faktentabelle. Die einzige Tatsache, die mir in diesem Fall wichtig ist, ist die Ereigniszeit, aber ich werde eine bedeutungslose GUID hinzufügen, um die Tabelle breit genug zu machen, um mich darum zu kümmern. Um genau zu sein, speichert die Faktentabelle Ereignisse nur in UTC-Zeit und UTC-Zeit. Ich habe die Spalte sogar mit einem Suffix versehen, _UTC
damit keine Verwirrung entsteht.
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
Laden wir nun unsere Faktentabelle mit 10.000.000 Zeilen, die alle 3 Sekunden (1.200 Zeilen pro Stunde) vom 30.12.2013 um Mitternacht UTC bis kurz nach 5 Uhr UTC am 12.12.2014 darstellen. Dies stellt sicher, dass die Daten eine Jahresgrenze überschreiten sowie die Sommerzeit für mehrere Zeitzonen vorwärts und rückwärts. Das sieht wirklich beängstigend aus, hat aber auf meinem System ~ 9 Sekunden gedauert. Die Tabelle sollte ungefähr 325 MB groß sein.
;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;
Und nur um zu zeigen, wie eine typische Suchabfrage für diese 10-MM-Zeilentabelle aussehen wird, wenn ich diese Abfrage ausführe:
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);
Ich erhalte diesen Plan und er kehrt in 25 Millisekunden * mit 358 Lesevorgängen zurück, um 72 Stundensummen zurückzugeben:
* Dauer gemessen mit unserem kostenlosen SQL Sentry Plan Explorer , der die Ergebnisse verwirft, sodass die Netzwerkübertragungszeit der Daten, das Rendern usw. nicht enthalten sind. Als zusätzlichen Haftungsausschluss arbeite ich für SQL Sentry.
Es dauert natürlich etwas länger, wenn ich meine Reichweite zu groß mache - ein Monat Daten dauert 258 ms, zwei Monate mehr als 500 ms und so weiter. Parallelität kann eintreten:
Hier fangen Sie an, über andere, bessere Lösungen nachzudenken, um Berichtsanfragen zu beantworten, und es hat nichts damit zu tun, in welcher Zeitzone Ihre Ausgabe angezeigt wird. Ich werde nicht darauf eingehen, ich möchte nur zeigen, dass die Zeitzonenkonvertierung Ihre Berichtsabfragen nicht wirklich viel mehr zum Kotzen bringt, und sie können bereits zum Kotzen werden, wenn Sie große Bereiche erhalten, die nicht von den richtigen unterstützt werden Indizes. Ich werde mich an kleine Datumsbereiche halten, um zu zeigen, dass die Logik korrekt ist, und Sie müssen sich darum kümmern, dass Ihre bereichsbezogenen Berichtsabfragen mit oder ohne Zeitzonenkonvertierungen eine angemessene Leistung erbringen.
Okay, jetzt brauchen wir Tabellen zum Speichern unserer Zeitzonen (mit Offsets in Minuten, da nicht jeder sogar Stunden von UTC entfernt ist) und DST-Änderungsdaten für jedes unterstützte Jahr. Der Einfachheit halber werde ich nur einige Zeitzonen und ein einziges Jahr eingeben, um den obigen Daten zu entsprechen.
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
);
Enthält einige Zeitzonen für Abwechslung, einige mit halbstündigen Offsets, andere ohne Sommerzeit. Beachten Sie, dass Australien, in der südlichen Hemisphäre beobachtet DST während unseres Winters, so dass ihre Uhren gehen zurück im April und vorwärts im Oktober. (In der obigen Tabelle werden die Namen umgedreht, aber ich bin mir nicht sicher, wie ich dies für Zeitzonen der südlichen Hemisphäre weniger verwirrend machen soll.)
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
Nun eine Kalendertabelle, um zu wissen, wann sich TZs ändern. Ich werde nur interessierende Zeilen einfügen (jede Zeitzone oben und nur Sommerzeitänderungen für 2014). Zur Vereinfachung der Berechnungen speichere ich sowohl den Moment in UTC, in dem sich eine Zeitzone ändert, als auch den gleichen Moment in der Ortszeit. Für Zeitzonen, in denen die Sommerzeit nicht eingehalten wird, ist sie das ganze Jahr über Standard, und die Sommerzeit "beginnt" am 1. Januar.
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])
);
Sie können dies definitiv mit Algorithmen füllen (und die kommende Tipp-Serie verwendet einige clevere satzbasierte Techniken, wenn ich es selbst sage), anstatt manuell zu schleifen, was Sie haben. Für diese Antwort habe ich beschlossen, nur ein Jahr für die fünf Zeitzonen manuell auszufüllen, und ich werde mich nicht um ausgefallene Tricks kümmern.
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');
Okay, wir haben also unsere Faktendaten und unsere "Dimension" -Tabellen (ich erschrecke, wenn ich das sage). Was ist also die Logik? Nun, ich gehe davon aus, dass Benutzer ihre Zeitzone auswählen und den Datumsbereich für die Abfrage eingeben müssen. Ich gehe auch davon aus, dass der Datumsbereich volle Tage in ihrer eigenen Zeitzone sein wird. Keine Teiltage, egal Teilstunden. Sie übergeben also ein Startdatum, ein Enddatum und eine TimeZoneID. Von dort aus werden wir eine Skalarfunktion verwenden, um das Start- / Enddatum von dieser Zeitzone in UTC umzuwandeln, wodurch wir die Daten basierend auf dem UTC-Bereich filtern können. Sobald wir dies getan und unsere Aggregationen daran durchgeführt haben, können wir die Konvertierung der gruppierten Zeiten zurück in die Quellzeitzone anwenden, bevor sie dem Benutzer angezeigt werden.
Die skalare 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
Und die tabellenwertige Funktion:
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])
);
Und eine Prozedur, die es verwendet ( bearbeiten : aktualisiert, um die 30-Minuten-Offset-Gruppierung zu handhaben):
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
(Möglicherweise möchten Sie dort einen Kurzschluss oder eine separate gespeicherte Prozedur ausprobieren, falls der Benutzer in UTC Bericht erstatten möchte. Die Übersetzung von und nach UTC ist offensichtlich eine verschwenderische Arbeit.)
Beispielanruf:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Gibt in 41 ms * zurück und generiert diesen Plan:
* Wieder mit verworfenen Ergebnissen.
Für 2 Monate kehrt es in 507 ms zurück, und der Plan ist bis auf die Anzahl der Zeilen identisch:
Obwohl dies etwas komplexer ist und die Laufzeit ein wenig erhöht, bin ich ziemlich zuversichtlich, dass diese Art von Ansatz viel, viel besser funktionieren wird als der Bridge-Table-Ansatz. Und dies ist ein Beispiel für eine dba.se-Antwort. Ich bin sicher, meine Logik und Effizienz könnten von Leuten verbessert werden, die viel schlauer sind als ich.
Sie können die Daten lesen, um die Randfälle zu sehen, über die ich spreche - keine Ausgabezeile für die Stunde, in der die Uhren vorwärts rollen, zwei Zeilen für die Stunde, in der sie zurückgerollt sind (und diese Stunde zweimal passiert ist). Sie können auch mit schlechten Werten spielen. Wenn Sie beispielsweise 20140309 02:30 Eastern Time vergehen, wird es nicht so gut funktionieren.
Ich habe möglicherweise nicht alle Annahmen richtig darüber, wie Ihre Berichterstattung funktionieren wird, daher müssen Sie möglicherweise einige Anpassungen vornehmen. Aber ich denke, das deckt die Grundlagen ab.