He resuelto esto teniendo una tabla de calendario muy simple: cada año tiene una fila por zona horaria admitida , con el desplazamiento estándar y la fecha / hora de inicio / finalización del horario de verano y su desplazamiento (si esa zona horaria lo admite). Luego, una función en línea, vinculada al esquema y con valores de tabla que toma el tiempo de origen (en UTC, por supuesto) y suma / resta el desplazamiento.
Obviamente, esto nunca funcionará extremadamente bien si está informando sobre una gran parte de los datos; La partición puede parecer útil, pero aún tendrá casos en los que las últimas horas en un año o las primeras horas en el próximo año realmente pertenecen a un año diferente cuando se convierten a una zona horaria específica, por lo que nunca puede obtener una partición verdadera aislamiento, excepto cuando su rango de informes no incluye el 31 de diciembre o el 1 de enero.
Hay un par de casos extraños que debes considerar:
2014-11-02 05:30 UTC y 2014-11-02 06:30 UTC ambos se convierten a 01:30 AM en la zona horaria del Este, por ejemplo (uno por primera vez 01:30 fue golpeado localmente, y luego uno por segunda vez cuando los relojes retrocedieron de las 2:00 a.m. a la 1:00 a.m., y transcurrió otra media hora). Por lo tanto, debe decidir cómo manejar esa hora de informes; de acuerdo con UTC, debería ver el doble del tráfico o el volumen de lo que esté midiendo una vez que esas dos horas se asignen a una sola hora en una zona horaria que observe el horario de verano. Esto también puede jugar juegos divertidos con secuencia de eventos, ya que algo que lógicamente tuvo que suceder después de que algo más pudiera aparecerocurrir antes de eso una vez que el tiempo se ajusta a una sola hora en lugar de dos. Un ejemplo extremo es una vista de página que ocurrió a las 05:59 UTC, luego un clic que ocurrió a las 06:00 UTC. En la hora UTC, esto sucedió con un minuto de diferencia, pero cuando se convirtió a la hora del Este, la vista ocurrió a la 1:59 a.m., y el clic ocurrió una hora antes.
2014-03-09 02:30 nunca sucede en los Estados Unidos. Esto se debe a que a las 2:00 a.m., adelantamos los relojes a las 3:00 a.m. Por lo tanto, es probable que desee generar un error si el usuario ingresa ese tiempo y le pide que lo convierta a UTC, o que diseñe su formulario para que los usuarios no puedan elegir ese tiempo.
Incluso con esos casos límite en mente, sigo pensando que tiene el enfoque correcto: almacenar los datos en UTC. Es mucho más fácil asignar datos a otras zonas horarias desde UTC que desde alguna zona horaria a otra zona horaria, especialmente cuando diferentes zonas horarias comienzan / terminan el horario de verano en diferentes fechas, e incluso la misma zona horaria puede cambiar usando diferentes reglas en diferentes años ( por ejemplo, EE. UU. cambió las reglas hace aproximadamente 6 años).
Deberá usar una tabla de calendario para todo esto, no una CASE
expresión gigantesca (no una declaración ). Acabo de escribir una serie de tres partes para MSSQLTips.com sobre esto; Creo que la tercera parte será la más útil para ti:
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 verdadero ejemplo en vivo, mientras tanto
Digamos que tiene una tabla de hechos muy simple. El único hecho que me importa en este caso es el tiempo del evento, pero agregaré un GUID sin sentido solo para hacer que la tabla sea lo suficientemente amplia como para preocuparse. Nuevamente, para ser explícitos, la tabla de hechos almacena eventos en tiempo UTC y solo en tiempo UTC. Incluso he agregado el sufijo a la columna _UTC
para que no haya confusión.
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
Ahora, carguemos nuestra tabla de hechos con 10,000,000 filas, que representan cada 3 segundos (1,200 filas por hora) desde 2013-12-30 a la medianoche UTC hasta algún momento después de las 5 AM UTC del 2014-12-12. Esto garantiza que los datos abarquen un límite de un año, así como el horario de verano hacia adelante y hacia atrás para múltiples zonas horarias. Esto parece realmente aterrador, pero tardó ~ 9 segundos en mi sistema. La tabla debería terminar siendo de unos 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;
Y solo para mostrar cómo se verá una consulta de búsqueda típica en esta tabla de filas de 10MM, si ejecuto 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);
Recibo este plan, y regresa en 25 milisegundos *, haciendo 358 lecturas, para devolver 72 totales por hora:
* Duración medida por nuestro Explorador de planes de SQL Sentry gratuito , que descarta los resultados, por lo que esto no incluye el tiempo de transferencia de red de los datos, la representación, etc. Como descargo de responsabilidad adicional, trabajo para SQL Sentry.
Obviamente, tarda un poco más si hago que mi rango sea demasiado grande: un mes de datos tarda 258 ms, dos meses lleva más de 500 ms, y así sucesivamente. El paralelismo puede entrar en acción:
Aquí es donde comienza a pensar en otras soluciones mejores para satisfacer las consultas de informes, y no tiene nada que ver con la zona horaria que mostrará su salida. No voy a entrar en eso, solo quiero demostrar que la conversión de zona horaria realmente no hará que sus consultas de informes absorban mucho más, y es posible que ya lo hagan si obtiene grandes rangos que no son compatibles con el adecuado índices Me limitaré a los pequeños intervalos de fechas para mostrar que la lógica es correcta y dejar que se preocupe por asegurarse de que sus consultas de informes basadas en el rango funcionen adecuadamente, con o sin conversiones de zona horaria.
Bien, ahora necesitamos tablas para almacenar nuestras zonas horarias (con compensaciones, en minutos, ya que no todos están incluso horas sin UTC) y las fechas de cambio de horario de verano para cada año admitido. Para simplificar, solo voy a ingresar unas pocas zonas horarias y un solo año para que coincida con los datos anteriores.
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
);
Incluyó algunas zonas horarias para la variedad, algunas con compensaciones de media hora, algunas que no observan el horario de verano. Tenga en cuenta que Australia, en el hemisferio sur, observa el horario de verano durante nuestro invierno, por lo que sus relojes retroceden en abril y avanzan en octubre. (La tabla anterior voltea los nombres, pero no estoy seguro de cómo hacer que esto sea menos confuso para las zonas horarias del hemisferio sur).
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
Ahora, una tabla de calendario para saber cuándo cambian las TZ. Solo voy a insertar filas de interés (cada zona horaria anterior, y solo los cambios de horario de verano para 2014). Para facilitar los cálculos de ida y vuelta, almaceno el momento en UTC donde cambia la zona horaria y el mismo momento en la hora local. Para las zonas horarias que no observan el horario de verano, es estándar durante todo el año, y el horario de verano "comienza" el 1 de enero.
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])
);
Definitivamente, puede completar esto con algoritmos (y la próxima serie de consejos utiliza algunas técnicas inteligentes basadas en conjuntos, si lo digo yo mismo), en lugar de bucle, complete manualmente, ¿qué tiene? Para esta respuesta, decidí rellenar manualmente un año para las cinco zonas horarias, y no voy a molestarme con ningún truco elegante.
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');
Bien, entonces tenemos nuestros datos de hechos y nuestras tablas de "dimensiones" (me estremezco cuando digo eso), entonces, ¿cuál es la lógica? Bueno, supongo que va a hacer que los usuarios seleccionen su zona horaria e ingresen el rango de fechas para la consulta. También supondré que el rango de fechas será días completos en su propia zona horaria; sin días parciales, no importa las horas parciales. Por lo tanto, pasarán una fecha de inicio, una fecha de finalización y un TimeZoneID. A partir de ahí, utilizaremos una función escalar para convertir la fecha de inicio / finalización de esa zona horaria a UTC, lo que nos permitirá filtrar los datos en función del rango UTC. Una vez que hayamos hecho eso, y hayamos realizado nuestras agregaciones en él, podemos aplicar la conversión de los tiempos agrupados nuevamente a la zona horaria de origen, antes de mostrar al usuario.
El 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
Y la función con valores de tabla:
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])
);
Y un procedimiento que lo usa ( editar : actualizado para manejar la agrupación de desplazamiento 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
(Es posible que desee realizar un cortocircuito allí, o un procedimiento almacenado por separado, en el caso de que el usuario quiera informar en UTC; obviamente, la traducción hacia y desde UTC será un trabajo muy ocupado).
Llamada de muestra:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Devuelve en 41 ms * y genera este plan:
* Nuevamente, con resultados descartados.
Durante 2 meses, regresa en 507 ms, y el plan es idéntico aparte de los recuentos de filas:
Si bien es un poco más complejo y aumenta un poco el tiempo de ejecución, estoy bastante seguro de que este tipo de enfoque funcionará mucho, mucho mejor que el enfoque de la mesa de bridge. Y este es un ejemplo poco convencional para una respuesta dba.se; Estoy seguro de que mi lógica y eficiencia podrían ser mejoradas por personas mucho más inteligentes que yo.
Puede leer detenidamente los datos para ver los casos límite de los que hablo: no hay fila de salida para la hora en que los relojes avanzan, dos filas para la hora en que retrocedieron (y esa hora sucedió dos veces). También puedes jugar con malos valores; si pasa en 20140309 02:30 hora del este, por ejemplo, no va a funcionar demasiado bien.
Es posible que no tenga todas las suposiciones correctas sobre cómo funcionarán sus informes, por lo que es posible que deba hacer algunos ajustes. Pero creo que esto cubre lo básico.