Saya telah memecahkan ini dengan memiliki tabel kalender yang sangat sederhana - setiap tahun memiliki satu baris per zona waktu yang didukung , dengan offset standar dan datetime awal / akhir dat DST dan offsetnya (jika zona waktu mendukungnya). Kemudian fungsi inline, terikat skema, bernilai tabel yang mengambil waktu sumber (dalam UTC tentu saja) dan menambah / mengurangi offset.
Ini jelas tidak akan pernah berkinerja sangat baik jika Anda melaporkan sebagian besar data; partisi mungkin terlihat membantu, tetapi Anda masih memiliki kasus di mana beberapa jam terakhir dalam satu tahun atau beberapa jam pertama di tahun berikutnya sebenarnya milik tahun yang berbeda ketika dikonversi ke zona waktu tertentu - sehingga Anda tidak akan pernah mendapatkan partisi yang benar isolasi, kecuali ketika rentang pelaporan Anda tidak termasuk 31 Desember atau 1 Januari.
Ada beberapa kasus tepi aneh yang perlu Anda pertimbangkan:
2014-11-02 05:30 UTC dan 2014-11-02 06:30 UTC keduanya dikonversi menjadi 01:30 pagi di zona waktu Timur, misalnya (satu untuk pertama kali 01:30 ditabrak secara lokal, lalu satu untuk kedua kalinya ketika jam diputar dari 2:00 pagi sampai 1:00 pagi, dan setengah jam berlalu). Jadi, Anda perlu memutuskan bagaimana menangani jam pelaporan - menurut UTC, Anda harus melihat dua kali lipat lalu lintas atau volume apa pun yang Anda ukur setelah dua jam dipetakan menjadi satu jam di zona waktu yang mengamati DST. Ini juga dapat memainkan permainan yang menyenangkan dengan mengurutkan acara, karena sesuatu yang secara logis harus terjadi setelah sesuatu yang lain bisa munculterjadi sebelum itu setelah waktunya disesuaikan dengan satu jam, bukan dua. Contoh ekstrem adalah tampilan halaman yang terjadi pada 05:59 UTC, lalu klik yang terjadi pada 06:00 UTC. Dalam waktu UTC ini terjadi satu menit terpisah, tetapi ketika dikonversi ke waktu Timur, tampilan terjadi pada 1:59, dan klik terjadi satu jam sebelumnya.
2014-03-09 02:30 tidak pernah terjadi di AS. Ini karena pada jam 2:00 pagi kami menggulung jam ke depan hingga jam 3 pagi. Sangat mungkin Anda ingin meningkatkan kesalahan jika pengguna memasukkan waktu seperti itu dan meminta Anda untuk mengubahnya menjadi UTC, atau merancang formulir Anda sehingga pengguna tidak dapat memilih waktu seperti itu.
Bahkan dengan mempertimbangkan kasus tepi tersebut, saya masih berpikir Anda memiliki pendekatan yang tepat: menyimpan data dalam UTC. Jauh lebih mudah untuk memetakan data ke zona waktu lain dari UTC daripada dari beberapa zona waktu ke beberapa zona waktu lain, terutama ketika zona waktu yang berbeda memulai / mengakhiri DST pada tanggal yang berbeda, dan bahkan zona waktu yang sama dapat beralih menggunakan aturan yang berbeda di tahun yang berbeda ( misalnya AS mengubah aturan 6 tahun yang lalu atau lebih).
Anda akan ingin menggunakan tabel kalender untuk semua ini, bukan CASE
ekspresi raksasa (bukan pernyataan ). Saya baru saja menulis seri tiga bagian untuk MSSQLTips.com tentang ini; Saya pikir bagian ke-3 akan menjadi yang paling berguna untuk Anda:
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/
Contoh nyata yang nyata, sementara itu
Katakanlah Anda memiliki tabel fakta yang sangat sederhana. Satu-satunya fakta yang saya pedulikan dalam hal ini adalah waktu acara, tetapi saya akan menambahkan GUID yang tidak berarti hanya untuk membuat tabel cukup luas untuk diperhatikan. Sekali lagi, untuk menjadi eksplisit, tabel fakta menyimpan acara dalam waktu UTC dan waktu UTC saja. Saya bahkan sudah suffix kolom dengan _UTC
sehingga tidak ada kebingungan.
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
Sekarang, mari kita muat tabel fakta kami dengan 10.000.000 baris - mewakili setiap 3 detik (1.200 baris per jam) dari 2013-12-30 di tengah malam UTC hingga sekitar pukul 5 pagi UTC pada 2014-12-12. Ini memastikan bahwa data mengangkang batas tahun, serta DST maju dan mundur untuk beberapa zona waktu. Ini terlihat sangat menakutkan, tetapi membutuhkan waktu ~ 9 detik pada sistem saya. Tabel seharusnya sekitar 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;
Dan hanya untuk menunjukkan seperti apa bentuk kueri pencarian terhadap tabel baris 10 MM ini, jika saya menjalankan kueri ini:
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);
Saya mendapatkan paket ini, dan kembali dalam 25 milidetik *, menghasilkan 358 kali dibaca, untuk mengembalikan total 72 jam:
* Durasi yang diukur oleh SQL Sentry Plan Explorer gratis kami , yang membuang hasil, jadi ini tidak termasuk waktu transfer jaringan dari data, rendering, dll. Sebagai penafian tambahan, saya bekerja untuk SQL Sentry.
Dibutuhkan sedikit lebih lama, tentu saja, jika saya membuat jangkauan saya terlalu besar - sebulan data membutuhkan 258 ms, dua bulan membutuhkan lebih dari 500 ms, dan seterusnya. Paralelisme dapat mendorong:
Di sinilah Anda mulai memikirkan solusi lain yang lebih baik untuk memenuhi permintaan pelaporan, dan itu tidak ada hubungannya dengan zona waktu apa yang akan ditampilkan oleh output Anda. Saya tidak akan membahasnya, saya hanya ingin menunjukkan bahwa konversi zona waktu tidak benar-benar akan membuat kueri pelaporan Anda menyedot lebih banyak, dan mereka mungkin sudah menyedot jika Anda mendapatkan rentang besar yang tidak didukung oleh yang tepat indeks. Saya akan berpegang pada rentang tanggal kecil untuk menunjukkan bahwa logika itu benar, dan membiarkan Anda khawatir tentang memastikan kueri pelaporan berbasis rentang Anda berkinerja memadai, dengan atau tanpa konversi zona waktu.
Oke, sekarang kita perlu tabel untuk menyimpan zona waktu kita (dengan offset, dalam hitungan menit, karena tidak semua orang bahkan berjam-jam di UTC) dan DST mengubah tanggal untuk setiap tahun yang didukung. Untuk kesederhanaan, saya hanya akan memasukkan beberapa zona waktu dan satu tahun untuk mencocokkan data di atas.
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
);
Termasuk beberapa zona waktu untuk variasi, beberapa dengan offset setengah jam, beberapa yang tidak mematuhi DST. Perhatikan bahwa Australia, di belahan bumi selatan mengamati DST selama musim dingin kami, jadi jam mereka kembali pada bulan April dan maju pada bulan Oktober. (Tabel di atas membalik nama-nama, tapi saya tidak yakin bagaimana membuat ini kurang membingungkan untuk zona waktu belahan bumi selatan.)
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
Sekarang, tabel kalender untuk mengetahui kapan TZ berubah. Saya hanya akan memasukkan baris yang menarik (setiap zona waktu di atas, dan hanya perubahan DST untuk 2014). Untuk kemudahan perhitungan bolak-balik, saya menyimpan momen di UTC di mana zona waktu berubah, dan momen yang sama di waktu setempat. Untuk zona waktu yang tidak mematuhi DST, standar sepanjang tahun, dan DST "dimulai" pada 1 Januari.
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])
);
Anda pasti dapat mengisi ini dengan algoritma (dan seri tip yang akan datang menggunakan beberapa teknik berbasis set pintar, jika saya mengatakannya sendiri), daripada loop, mengisi secara manual, apa pun yang Anda miliki. Untuk jawaban ini saya memutuskan untuk secara manual mengisi satu tahun untuk lima zona waktu, dan saya tidak akan mengganggu trik mewah.
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');
Oke, jadi kita punya data fakta kita, dan tabel "dimensi" kita (saya merasa ngeri ketika mengatakan itu), jadi apa logikanya? Yah, saya kira Anda akan meminta pengguna memilih zona waktu mereka dan memasukkan rentang tanggal untuk kueri. Saya juga akan berasumsi bahwa rentang tanggal akan menjadi hari penuh di zona waktu mereka sendiri; tidak ada hari parsial, apalagi parsial jam. Jadi mereka akan melewati tanggal mulai, tanggal akhir, dan TimeZoneID. Dari sana kami akan menggunakan fungsi skalar untuk mengonversi tanggal mulai / berakhir dari zona waktu tersebut ke UTC, yang akan memungkinkan kami untuk memfilter data berdasarkan rentang UTC. Setelah kami selesai melakukannya, dan melakukan agregasi kami di atasnya, kami kemudian dapat menerapkan konversi waktu yang dikelompokkan kembali ke zona waktu sumber, sebelum ditampilkan kepada pengguna.
Skalar 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
Dan fungsi bernilai tabel:
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])
);
Dan prosedur yang menggunakannya ( edit : diperbarui untuk menangani pengelompokan offset 30 menit):
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
(Anda mungkin ingin melakukan hubungan arus pendek di sana, atau prosedur tersimpan terpisah, dalam hal pengguna ingin melaporkan dalam UTC - jelas menerjemahkan ke dan dari UTC akan menjadi pekerjaan yang sibuk dan boros.)
Contoh panggilan:
EXEC dbo.ReportOnDateRange
@Start = '20140308',
@End = '20140311',
@TimeZoneID = 3;
Kembali dalam 41ms *, dan menghasilkan rencana ini:
* Sekali lagi, dengan hasil yang dibuang.
Selama 2 bulan, ia kembali dalam 507 ms, dan rencananya identik selain jumlah baris:
Meskipun sedikit lebih kompleks dan sedikit meningkatkan waktu berjalan, saya cukup yakin bahwa jenis pendekatan ini akan berhasil, jauh lebih baik daripada pendekatan tabel jembatan. Dan ini adalah contoh spontan untuk jawaban dba.se; Saya yakin logika dan efisiensi saya dapat ditingkatkan oleh orang-orang yang jauh lebih pintar daripada saya.
Anda dapat membaca dengan teliti data untuk melihat kasus tepi yang saya bicarakan - tidak ada deretan output untuk jam di mana jam bergulir ke depan, dua baris untuk jam di mana mereka memutar kembali (dan jam itu terjadi dua kali). Anda juga dapat bermain dengan nilai-nilai buruk; jika Anda lulus pada 20140309 02:30 waktu Timur, misalnya, itu tidak akan berfungsi dengan baik.
Saya mungkin tidak memiliki semua asumsi yang benar tentang cara kerja pelaporan Anda, jadi Anda mungkin harus melakukan beberapa penyesuaian. Tapi saya pikir ini mencakup dasar-dasarnya.