J'ai résolu ce problème en ayant un tableau de calendrier très simple - chaque année a une ligne par fuseau horaire pris en charge , avec le décalage standard et le datetime de début / datetime de fin de l'heure d'été et son décalage (si ce fuseau horaire le prend en charge). Ensuite, une fonction en ligne, liée au schéma et de valeur de table qui prend le temps source (en UTC bien sûr) et ajoute / soustrait le décalage.
Cela ne fonctionnera évidemment jamais extrêmement bien si vous effectuez des rapports sur une grande partie des données; le partitionnement peut sembler utile, mais vous aurez toujours des cas où les dernières heures d'une année ou les premières heures de l'année suivante appartiennent en fait à une année différente lors de la conversion dans un fuseau horaire spécifique - de sorte que vous ne pourrez jamais obtenir la vraie partition l'isolement, sauf lorsque votre plage de rapports n'inclut pas le 31 décembre ou le 1er janvier.
Il y a quelques cas étranges que vous devez considérer:
2014-11-02 05:30 UTC et 2014-11-02 06:30 UTC se convertissent tous les deux à 01:30 AM dans le fuseau horaire de l'Est, par exemple (un pour la première fois 01:30 a été touché localement, puis un pour la deuxième fois lorsque les horloges ont reculé de 2h00 à 1h00 et une autre demi-heure s'est écoulée). Vous devez donc décider comment gérer cette heure de génération de rapports - selon UTC, vous devriez voir doubler le trafic ou le volume de tout ce que vous mesurez une fois que ces deux heures sont mappées sur une seule heure dans un fuseau horaire respectant l'heure d'été. Cela peut également jouer à des jeux amusants avec le séquencement des événements, car quelque chose qui devait logiquement se produire après que quelque chose d'autre puisse apparaîtrese produire avant lui une fois le calendrier réglé à une seule heure au lieu de deux. Un exemple extrême est une vue de page qui s'est produite à 05:59 UTC, puis un clic qui s'est produit à 06:00 UTC. En heure UTC, ces événements se sont produits à une minute d'intervalle, mais lorsqu'ils ont été convertis en heure de l'Est, la vue s'est produite à 1 h 59 du matin et le clic s'est produit une heure plus tôt.
2014-03-09 02:30 n'arrive jamais aux USA. En effet, à 2 heures du matin, nous faisons avancer les horloges à 3 heures du matin. Il est donc probable que vous souhaitiez générer une erreur si l'utilisateur entre une telle heure et vous demande de la convertir en UTC ou de concevoir votre formulaire afin que les utilisateurs ne puissent pas choisir une telle heure.
Même avec ces cas marginaux à l'esprit, je pense toujours que vous avez la bonne approche: stocker les données en UTC. Il est beaucoup plus facile de mapper des données vers d'autres fuseaux horaires à partir de l'UTC que d'un fuseau horaire vers un autre fuseau horaire, en particulier lorsque différents fuseaux horaires commencent / terminent l'heure d'été à différentes dates, et même le même fuseau horaire peut changer en utilisant des règles différentes au cours des différentes années ( par exemple, les États-Unis ont modifié les règles il y a environ 6 ans).
Vous voudrez utiliser une table de calendrier pour tout cela, pas une CASE
expression gargantuesque (pas une déclaration ). Je viens d'écrire une série en trois parties pour MSSQLTips.com à ce sujet; Je pense que la 3e partie vous sera la plus utile:
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 vrai exemple en direct, en attendant
Disons que vous avez un tableau de faits très simple. Le seul fait dont je me soucie dans ce cas est l'heure de l'événement, mais j'ajouterai un GUID vide de sens juste pour rendre la table suffisamment large pour que cela soit important. Encore une fois, pour être explicite, la table de faits stocke les événements en temps UTC et en temps UTC uniquement. J'ai même suffixé la colonne _UTC
pour qu'il n'y ait pas de confusion.
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
Maintenant, chargeons notre table de faits avec 10000000 lignes - représentant toutes les 3 secondes (1200 lignes par heure) du 30/12/2013 à minuit UTC jusqu'à quelque temps après 5 h 00 UTC le 12/12/2014. Cela garantit que les données chevauchent une limite annuelle, ainsi que l'heure d'été en avant et en arrière pour plusieurs fuseaux horaires. Cela semble vraiment effrayant, mais a pris environ 9 secondes sur mon système. Le tableau devrait finir par être d'environ 325 Mo.
;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;
Et juste pour montrer à quoi ressemblera une requête de recherche typique par rapport à cette table de lignes de 10 mm, si j'exécute cette requête:
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);
J'obtiens ce plan, et il revient en 25 millisecondes *, faisant 358 lectures, pour retourner 72 totaux horaires:
* Durée mesurée par notre explorateur de plans SQL Sentry gratuit , qui ignore les résultats, donc cela n'inclut pas le temps de transfert réseau des données, le rendu, etc. En tant que clause de non-responsabilité supplémentaire, je travaille pour SQL Sentry.
Cela prend un peu plus de temps, évidemment, si je fais ma plage trop grande - un mois de données prend 258 ms, deux mois prend plus de 500 ms, et ainsi de suite. Le parallélisme peut entrer en jeu:
C'est là que vous commencez à penser à d'autres solutions meilleures pour satisfaire les requêtes de rapports, et cela n'a rien à voir avec le fuseau horaire que votre sortie affichera. Je n'entrerai pas dans les détails, je veux juste démontrer que la conversion de fuseau horaire ne va pas vraiment faire en sorte que vos requêtes de rapports soient beaucoup plus sujettes, et elles peuvent déjà le faire si vous obtenez de grandes plages qui ne sont pas prises en charge par une bonne index. Je vais m'en tenir à de petites plages de dates pour montrer que la logique est correcte et vous permettre de vous assurer que vos requêtes de rapports basées sur des plages fonctionnent correctement, avec ou sans conversion de fuseau horaire.
D'accord, nous avons maintenant besoin de tableaux pour stocker nos fuseaux horaires (avec décalages, en minutes, car tout le monde n'a même pas d'heures de décalage UTC) et les dates de changement d'heure d'été pour chaque année prise en charge. Par souci de simplicité, je ne vais entrer que quelques fuseaux horaires et une seule année pour faire correspondre les données ci-dessus.
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
);
Inclus quelques fuseaux horaires pour la variété, certains avec des décalages d'une demi-heure, certains qui n'observent pas l'heure d'été. Notez que l'Australie, dans l'hémisphère sud, observe l'heure d'été pendant notre hiver, donc leurs horloges remontent en avril et avancent en octobre. (Le tableau ci-dessus renverse les noms, mais je ne sais pas comment rendre cela moins déroutant pour les fuseaux horaires de l'hémisphère sud.)
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
Maintenant, une table de calendrier pour savoir quand les TZ changent. Je vais seulement insérer des lignes d'intérêt (chaque fuseau horaire ci-dessus, et seuls les changements d'heure d'été pour 2014). Pour faciliter les calculs dans les deux sens, je stocke à la fois le moment en UTC où un fuseau horaire change et le même moment dans l'heure locale. Pour les fuseaux horaires qui n'observent pas l'heure d'été, il est standard toute l'année et l'heure d'été "démarre" le 1er janvier.
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])
);
Vous pouvez certainement remplir cela avec des algorithmes (et la prochaine série de conseils utilise des techniques intelligentes basées sur des ensembles, si je le dis moi-même), plutôt que de boucler, de remplir manuellement, qu'avez-vous. Pour cette réponse, j'ai décidé de remplir manuellement un an pour les cinq fuseaux horaires, et je ne vais pas déranger d'astuces fantaisistes.
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');
D'accord, nous avons donc nos données factuelles et nos tableaux de "dimensions" (je grince des dents quand je dis cela), alors quelle est la logique? Eh bien, je suppose que les utilisateurs vont sélectionner leur fuseau horaire et entrer la plage de dates pour la requête. Je suppose également que la plage de dates sera de jours entiers dans leur propre fuseau horaire; pas de jours partiels, peu importe les heures partielles. Ils passeront donc une date de début, une date de fin et un TimeZoneID. À partir de là, nous utiliserons une fonction scalaire pour convertir la date de début / fin de ce fuseau horaire en UTC, ce qui nous permettra de filtrer les données en fonction de la plage UTC. Une fois que nous avons fait cela, et effectué nos agrégations dessus, nous pouvons ensuite appliquer la conversion des temps groupés au fuseau horaire source, avant de l'afficher à l'utilisateur.
L'UDF scalaire:
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
Et la fonction table:
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])
);
Et une procédure qui l'utilise ( édition : mise à jour pour gérer le regroupement de décalages de 30 minutes):
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
(Vous voudrez peut-être essayer de court-circuiter là-bas, ou une procédure stockée distincte, dans le cas où l'utilisateur souhaite signaler en UTC - la traduction vers et depuis UTC va évidemment être un travail fastidieux.)
Exemple d'appel:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Retourne en 41ms *, et génère ce plan:
* Encore une fois, avec des résultats rejetés.
Pendant 2 mois, il revient en 507ms, et le plan est identique à part les rowcounts:
Bien que légèrement plus complexe et augmentant un peu le temps d'exécution, je suis assez confiant que ce type d'approche fonctionnera beaucoup, beaucoup mieux que l'approche de la table de bridge. Et ceci est un exemple instantané pour une réponse dba.se; Je suis sûr que ma logique et mon efficacité pourraient être améliorées par des gens beaucoup plus intelligents que moi.
Vous pouvez parcourir les données pour voir les cas marginaux dont je parle - aucune ligne de sortie pour l'heure où les horloges avancent, deux lignes pour l'heure où elles ont reculé (et cette heure s'est produite deux fois). Vous pouvez également jouer avec de mauvaises valeurs; si vous passez à 20140309 02:30 heure de l'Est, par exemple, ça ne marchera pas trop bien.
Je n'ai peut-être pas toutes les hypothèses sur le fonctionnement de vos rapports, vous devrez donc peut-être faire quelques ajustements. Mais je pense que cela couvre les bases.