原文對 ISO 8601 時間格式中 T 和 Z 的表述有一些錯誤,我已經對原文進行了一些修訂,抱歉給你們形成誤解。javascript
最近使用 sequelize
過程當中發現一個「奇怪」的問題,將某個時間插入到表中後,經過 sequelize
查詢出來的時間和經過 mysql
命令行工具查詢出來的時間不同。很是困惑,因而研究了下,下面是學習成果。html
咱們先來介紹一些可能當年在地理課上學習過的基本概念。java
提及來,時間真是一個神奇的東西。之前人們經過觀察太陽的位置來決定時間(好比:使用日晷),這就使得不一樣經緯度的地區時間是不同的。後來人們進一步規定以子午線爲中心,向東西兩側延伸,每 15 度劃分一個時區,恰好是 24 個時區。而後由於一天有 24 小時,地球自轉一圈是 360 度,360 度 / 24 小時 = 15 度/小時,因此每差一個時區,時間就差一個小時。node
最開始的標準時間(子午線中心處的時間)是英國倫敦的皇家格林威治天文臺的標準時間(由於它恰好在本初子午線通過的地方),這就是咱們常說的 GMT
(Greenwich Mean Time)。而後其餘各個時區根據標準時間肯定本身的時間,往東的時區時間晚(表示爲 GMT+hh:mm)、往西的時區時間早(表示爲 GMT-hh:mm)。好比,中國標準時間是東八區,咱們的時間就老是比 GMT
時間晚 8 小時,他們在凌晨 1 點,咱們已是早晨 9 點了。mysql
可是 GMT
實際上是根據地球自轉、公轉計算的(太陽天天通過英國倫敦皇家格林威治天文臺的時間爲中午 12 點),不是很是準確,因而後面提出了根據原子鐘計算的標準時間 UTC
(Coordinated Universal Time)。git
通常狀況下,GMT
和 UTC
能夠互換,可是實際上,GMT
是一個時區,而 UTC
是一個時間標準。github
能夠在這裏看到全部的時區:http://www.timeanddate.com/ti...sql
因此,當咱們「展現」某個時間時,明確時區就變得很是重要了。否則你只說如今是 2016-01-11 19:30:00
,而後不告訴我時區,我實際上是無法準確知道時間的(固然,我能夠認爲這個時間是我所在時區的當地時間)。若是你說如今是 2016-01-11 19:30:00 GMT+0800
,那我就知道這個時間是東八區的時間了。若是我在東八區,那時間就是 19:30,若是我在 GMT
時區,那時間就是 11:30(減掉 8 小時)。shell
咱們如今來介紹下 JavaScript 中的「時間」,包括:Date
、Date.parse
、Date.UTC
、Date.now
。數據庫
注:下面的代碼示例能夠在 node shell 裏面運行,若是你運行的時候結果和下面的不一致,那可能我們不在一個時區:)
構造時間的方法有下面幾種:
new Date(); // 當前時間 new Date(value); // 自 1970-01-01 00:00:00 UTC 通過的毫秒數 new Date(dateString); // 時間字符串 new Date(year, month[, day[, hour[, minutes[, seconds[, milliseconds]]]]]);
須要注意的是:構造出的日期用來顯示時,會被轉換爲本地時間(調用 toString
方法):
> new Date() Mon Jan 11 2016 20:15:18 GMT+0800 (CST)
打印出我寫這篇文章時的本地時間。後面的 GMT+0800
表示是「東八區」,CST
表示是「中國標準時間(China Standard Time)」。
有一個很「詭異」的地方是若是咱們直接使用 Date
,而不是 new Date
,獲得的將會是字符串,而不是 Date
類型的對象:
> typeof Date() 'string' > typeof new Date() 'object'
咱們先說最複雜的時間字符串形式。它實際上支持兩種格式:一種是 RFC-2822 的標準;另外一種是 ISO 8601 的標準。咱們主要介紹後一種。
ISO 8601的標準格式是:YYYY-MM-DDTHH:mm:ss.sssZ
,分別表示:
YYYY
:年份,0000 ~ 9999MM
:月份,01 ~ 12DD
:日,01 ~ 31T
:分隔日期和時間HH
:小時,00 ~ 24mm
:分鐘,00 ~ 59ss
:秒,00 ~ 59.sss
:毫秒Z
:時區,能夠是:Z
(UFC)、+HH:mm
、-HH:mm
這裏咱們主要來講下 T
、以及 Z
。
。這裏的表述是錯誤的,T
也能夠用空格表示,可是這兩種表示有點不同,T
其實表示 UTC
,而空格會被認爲是本地時區(前提是不經過 Z
指定時區)T
僅僅是分隔日期和時間的符號,沒有其餘含義。因此下面的例子其實結果是同樣的。
> new Date('1970-01-01 00:00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST) > new Date('1970-01-01T00:00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
這裏補充一點須要注意的,時間字符串這種形式有一個特殊的邏輯:若是你不提供「時間」(也就是 T
分隔後的內容),獲得的實際上是 UTC
時間。好比:
> new Date('1970-01-01') Thu Jan 01 1970 08:00:00 GMT+0800 (CST) > new Date('1970-01-01T00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
Z
用來表示傳入時間的時區(zone),不指定而且沒有使用 這個說法也不嚴謹,指定 T
分隔而是使用空格分隔時,就按本地時區處理。Z
時表示 UTC
時間,不指定時表示的是本地時間。
> new Date('1970-01-01T00:00:00') Thu Jan 01 1970 00:00:00 GMT+0800 (CST) > new Date('1970-01-01T00:00:00Z') Thu Jan 01 1970 08:00:00 GMT+0800 (CST)
示例 1 是東八區時間,顯示的時間和傳入的時間一致(由於我本地時區是東八區)。
示例 2 指定了 Z(也就是 UTC 零時區),顯示的時間會加上本地時區的偏移(8 小時)。
RFC-2822 的標準格式大概是這樣:Wed Mar 25 2015 09:56:24 GMT+0100
。其實就是上面顯示時間時使用的形式:
> new Date('Thu Jan 01 1970 00:00:00 GMT+0800 (CST)') Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
除了能表示基本信息,還能夠表示星期,可是一點也不容易讀,不建議使用。完整的規範能夠在這裏查看:http://tools.ietf.org/html/rf...
Date
構造器還能夠接受整數,表示想要構造的時間自 UTC
時間 1970-01-01 00:00:00
通過的毫秒數。好比下面的代碼:
> new Date(1000 * 1) Thu Jan 01 1970 08:00:01 GMT+0800 (CST)
傳人 1 秒,等價於:1970-01-01 00:00:01Z
,顯示的時間加上了本地時區的偏移(8 小時)。
最後,Date
構造器還支持傳遞多個參數,這種方法就沒辦法指定時區了,都當作本地時間處理。好比下面的代碼:
> new Date(1970, 0, 1, 0, 0, 0) Thu Jan 01 1970 00:00:00 GMT+0800 (CST)
顯示時間和傳入時間一致,均是本地時間。注意:月份是從 0 開始的。
Date.parse
接受一個時間字符串,若是字符串能正確解析就返回自 UTC
時間 1970-01-01 00:00:00
通過的毫秒數,不然返回 NaN
:
> Date.parse('1970-01-01 00:00:00') -28800000 > new Date(Date.parse('1970-01-01 00:00:00')) Thu Jan 01 1970 00:00:00 GMT+0800 (CST) > Date.parse('1970-01-01T00:00:00') 0 > new Date(Date.parse('1970-01-01T00:00:00')) Thu Jan 01 1970 08:00:00 GMT+0800 (CST)
示例 1,-28800000 換算後恰好是 8 小時表示的毫秒數,28800000 / (1000 * 60 * 60)
,咱們傳入的是本地時區時間,等於 UTC
時間的 1969-12-31 16:00:00
,和 UTC
時間 1970-01-01 00:00:00
相差恰好 -8 小時。
示例 2,將 parse 後的毫秒數傳遞給構造器,最後顯示的時間加上了本地時區的偏移(8 小時),因此結果恰好是 1970-01-01 00:00:00
。
示例 3,傳入的是 UTC
時區時間,因此結果爲 0。
示例 4,將 parse 後的毫秒數傳遞給構造器,最後顯示的時間加上了本地時區的偏移(8 小時),因此結果恰好是 1970-01-01 08:00:00
。
Date.UTC
接受的參數和 Date
構造器多參數形式同樣,而後返回時間自 UTC
時間 1970-01-01 00:00:00
通過的毫秒數:
> Date.UTC(1970,0,1,0,0,0) 0 > Date.parse('1970-01-01T00:00:00') 0 > Date.parse('1970-01-01 00:00:00Z') 0
能夠看出,Date.UTC
進行的是一種「絕對運算」,傳入的時間就是 UTC
時間,不會轉換爲當地時間。
Date.now
返回當前時間距 UTC
時間 1970-01-01 00:00:00
通過的毫秒數:
> Date.now() 1452520484343 > new Date(Date.now()) Mon Jan 11 2016 21:54:55 GMT+0800 (CST) > new Date() Mon Jan 11 2016 21:55:00 GMT+0800 (CST)
MySQL 中和時間相關的數據類型主要包括:YEAR
、TIME
、DATE
、DATETIME
、TIMESTAMP
。
DATE
、YEAR
、TIME
比較簡單,大概總結以下:
名稱 | 佔用字節 | 取值 |
---|---|---|
DATE | 3 字節 | 1000-01-01 ~ 9999-12-31 |
YEAR | 1 字節 | 1901 ~ 2155 |
TIME | 3 字節 | -838:59:59 ~ 838:59:59 |
注:TIME
的小時範圍能夠這麼大(超過 24 小時),是由於它還能夠用來表示兩個時間點之差。
咱們主要來講明下 DATETIME
和 TIMESTAMP
,能夠作下面的總結:
名稱 | 佔用字節 | 取值 | 受 time_zone 設置影響 |
---|---|---|---|
DATETIME | 8 字節 | 1000-01-01 00:00:00 ~ 9999-12-31 23:59:59 | 否 |
TIMESTAMP | 4 字節 | 1970-01-01 00:00:00 ~ 2038-01-19 03:14:07 | 是 |
第一個區別是佔用字節不一樣,致使能表示的時間範圍也不同。
第二個區別是 DATETIME
是「常量」,保存時就是保存時的值,檢索時是同樣的值,不會改變;而 TIMESTAMP
則是「變量」,保存時數據庫服務器將其從time_zone
時區轉換爲 UTC
時間後保存,檢索時將其轉換從 UTC
時間轉換爲 time_zone
時區時間後返回。
好比,咱們有下面這樣一張表:
CREATE TABLE `tests` ( `id` INTEGER NOT NULL auto_increment , `datetime` DATETIME, `timestamp` TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB;
鏈接到數據庫服務器後,能夠執行 SHOW VARIABLES LIKE '%time_zone%'
查看當前時區設置。相似下面這樣的結果:
Variable_name | Value |
---|---|
system_time_zone | CST |
time_zone | SYSTEM |
說明我目前時區是 CST
(China Standard Time),也就是東八區。
咱們嘗試插入下面的數據:
INSERT INTO `tests` (`id`, `datetime`, `timestamp`) VALUES (DEFAULT, '1970-01-01 00:00:00', '1970-01-01 00:00:00');
會發現有一個報錯:Error Code: 1292. Incorrect datetime value: '1970-01-01 00:00:00' for column 'timestamp'
。給 timestamp 這一列提供的值不對,由於咱們嘗試插入 1970-01-01 00:00:00
時,數據庫服務器會根據 time_zone
的設置將其轉換爲 UTC
時間,也就是 1969-12-31 16:00:00
,而這個值明顯超過了 TIMESTAMP
類型的範圍。
咱們換個大一點的值:
INSERT INTO `tests` (`id`, `datetime`, `timestamp`) VALUES (DEFAULT, '2000-01-01 00:00:00', '2000-01-01 00:00:00');
此次就成功插入了。
再次檢索時結果也是正確的(數據庫服務器將值從 UTC
時間轉換爲 time_zone
設置的時區時間):
SELECT * FROM sample.tests;
返回:
id | datetime | timestamp |
---|---|---|
1 | 2000-01-01 00:00:00 | 2000-01-01 00:00:00 |
若是咱們先將 time_zone
設置爲一個不一樣的值後再進行檢索就會發現不一樣的結果:
SET time_zone = '+00:00'; SELECT * FROM sample.tests;
返回:
id | datetime | timestamp |
---|---|---|
1 | 2000-01-01 00:00:00 | 1999-12-31 16:00:00 |
能夠看到 datetime 列值沒有受 time_zone
設置的影響,而 timestamp 列值卻改變了。數據庫服務器將其從 UTC
時區轉換爲 time_zone
時區的時間(首先 2000-01-01 00:00:00
在上面進行插入時根據 time_zone
被轉換爲了 1999-12-31 16:00:00
,這次檢索時 time_zone
被設置爲 +00:00
,轉換回來恰好就是 1999-12-31 16:00:00
)。
那這兩種類型怎麼選擇呢?建議優先使用 DATETIME
,表示範圍大、不容易受服務器的設置影響。
分別說明了 JavaScript 和 MySQL 中的「時間」後,咱們來聊聊 ORM 框架通常都是怎麼樣在二者間進行正確、合適的轉換來避免混亂的。下面的說明將基於 sequelize 框架來解釋,主要是一種思路,其餘的框架能夠閱讀框架提供的文檔或是源碼。
sequelize 實際上有一個 timezone
的配置,默認是 +00:00
(http://sequelize.readthedocs....)。這個 timezone
有下面的用途:
SET time_zone = opts.timezone
第一個用途很簡單,體如今源碼裏就是執行一個 SQL
語句:
connection.query("SET time_zone = '" + self.sequelize.options.timezone + "'"); /* jshint ignore: line */
第二個用途主要體如今兩個地方:1)在 JavaScript 中調用 ORM 方法進行插入、更新時,須要將 Date
類型轉爲正確的 SQL 語句;2)從 MySQL 服務器查詢數據時,須要將數據庫查詢到的值轉換爲 JavaScript 中的 Date 類型。下面咱們分別來看一看。
這個轉換的核心代碼以下:
SqlString.dateToString = function(date, timeZone, dialect) { if (moment.tz.zone(timeZone)) { date = moment(date).tz(timeZone); } else { date = moment(date).utcOffset(timeZone); } if (dialect === 'mysql' || dialect === 'mariadb') { return date.format('YYYY-MM-DD HH:mm:ss'); } else { // ZZ here means current timezone, _not_ UTC return date.format('YYYY-MM-DD HH:mm:ss.SSS Z'); } };
代碼邏輯以下:
timeZone
是否存在,若是存在(存在指的是相似 America/New_York
這樣的表示法),調用 tz
設置 date
的時區。+00:00
、-07:00
這樣的表示法),調用 utcOffset
設置 date
的相對 UTC
的時區偏移。format
成 MySQL 須要的 YYYY-MM-DD HH:mm:ss
格式。舉兩個例子。
若是 timeZone
等於 +00:00
,date 等於 new Date('2016-01-12 09:46:00')
,到 UTC
的偏移等於 (timeZone - 本地時區) + timeZone:(00:00 - 08:00) + 00:00 = -08:00
,即 2016-01-12 09:46:00-08:00
,因而 format
後的結果是 2016-01-12 01:46:00
。
若是 timeZone
等於 +08:00
,date 等於 new Date('2016-01-12 09:46:00')
,到 UTC
的偏移等於 (timeZone - 本地時區) + timeZone:(08:00 - 08:00) + 08:00 = 08:00
,即 2016-01-12 09:46:00+08:00
。因而 format
後的結果是 2016-01-12 09:46:00
。
若是 timeZone
等於 Asia/Shanghai
,結果也會是 2016-01-12 09:46:00
,和 +08:00
等價。
sequelize 的 timezone
默認是 +00:00
,因此,咱們在 JavaScript 中的時間最後應用到數據庫中都會被轉換成 UTC
的時間(比實際的時間早 8 小時)。
這個轉換過程其實是更底層的 node-mysql 庫來實現的。核心代碼以下:
switch (field.type) { case Types.TIMESTAMP: case Types.DATE: case Types.DATETIME: case Types.NEWDATE: var dateString = parser.parseLengthCodedString(); if (dateStrings) { return dateString; } var dt; if (dateString === null) { return null; } var originalString = dateString; if (field.type === Types.DATE) { dateString += ' 00:00:00'; } if (timeZone !== 'local') { dateString += ' ' + timeZone; } dt = new Date(dateString); if (isNaN(dt.getTime())) { return originalString; } return dt; // 更多代碼... }
處理過程大概是這樣:
parser
將服務器返回的二進制數據解析爲時間字符串dateStrings
而不是轉換回 Date
類型,直接返回 dateString
DATE
,時間字符串的時間部分統一爲 00:00:00
timeZone
不是 local
(本地時區),時間字符串加上時區信息Date
構造器,若是構造出的時間不合法,返回原始時間字符串,不然返回時間對象默認狀況下,sequelize 在進行鏈接時傳遞給 node-mysql 的 timeZone
是 +00:00
,因此,第 4 步的時間字符串會是相似這樣的值 2016-01-12 01:46:00+00:00
,而這個值傳遞給 Date
構造器,在顯示時轉換回本地時區時間,就變成了 2016-01-12 09:46:00
(比數據庫中的時間晚 8 小時)。
在使用 sequelize 定義模型時,實際上是沒有 TIMESTAMP
類型的,sequelize 只提供了一個 Sequelize.DATE
類型,生成建表語句時被轉換爲 DATETIME
。
若是是在舊錶上定義模型,而這張舊錶恰好有 TIMESTAMP
類型的列,對 TIMESTAMP
類型的列定義模型時仍是可使用 Sequelize.DATE
,對操做沒有任何影響。可是 TIMESTAMP
是受 time_zone
設置影響的,這會引發一些困惑。下面咱們來看一個例子。
sequelize 默認將 time_zone
設置爲 +00:00
,當咱們執行下面代碼時:
Test.create({ 'datetime': new Date('2016-01-10 20:07:00'), 'timestamp': new Date('2016-01-10 20:07:00') });
會進行上面提到的 JavaScript 時間到 MySQL 時間字符串的轉換,生成的 SQL 實際上是(時間被轉換爲了 UTC
時間,比本地時間早了 8 小時):
INSERT INTO `tests` (`id`,`datetime`,`timestamp`) VALUES (DEFAULT,'2016-01-10 12:07:00','2016-01-10 12:07:00');
當咱們執行 Test.findAll()
來查詢數據時,會進行上面提到的 MySQL 時間到 JavaScript 時間的轉換,其實就是返回這樣的結果(顯示時時間從 UTC
時間轉換回了本地時間):
> new Date('2016-01-10 12:07:00+00:00') Sun Jan 10 2016 20:07:00 GMT+0800 (CST)
和咱們插入時的時間是一致的。
若是咱們經過 MySQL 命令行來查詢數據時,發現實際上是這樣的結果:
id | datetime | timestamp |
---|---|---|
1 | 2016-01-10 12:07:00 | 2016-01-10 20:07:00 |
這很好理解,由於咱們數據庫服務器的 time_zone
默認是東八區,TIMESTAMP
是受時區影響的,查詢時被數據庫服務器從 UTC
時間轉換回了 time_zone
時區時間;DATETIME
不受影響,仍是 UTC
時間。
若是咱們先執行 SET time_zone = '+00:00'
,再進行查詢,那結果就都會是 UTC
時間了。因此,不要覺得數據出錯了哦。
總結下就是,sequelize 會將本地時間轉換爲 UTC
時間後入庫,查詢時再將 UTC
時間轉換爲本地時間。這能達到最好的兼容性,存儲老是使用 UTC
時間,展現時應用端本身轉換爲本地時區時間後顯示。固然這個的前提是數據類型選用 DATETIME
。
這裏要說的最後一個問題是基於舊錶定義 sequelize 模型,而且表中時間值插入時沒有轉換爲 UTC
時間(所有是東八區時間),並且 DATETIME
和 TIMESTAMP
混用,該怎麼辦?
在默認配置下,狀況以下:
查詢 DATETIME
類型數據時,時間老是會晚 8 小時。好比,數據庫中某條老數據的時間是 2012-01-01 01:00:00
(已是本地時間了,由於沒轉換),查詢時被 sequelize 轉換爲 new Date('2012-01-01 01:00:00+00:00')
,顯示時轉換爲本地時間 2012-01-01 09:00:00
,結果顯然不對。
查詢 TIMESTAMP
類型數據時,時間是正確的。這是由於 TIMESTAMP
受 time_zone
影響,sequelize 默認將其設置爲 +00:00
,查詢時數據庫服務器先將時間轉換到 time_zone
設置的時區時間,因爲沒有時區偏移,恰好查出來的就是數據庫中的值。好比:2012-01-01 00:00:00
(注意這個值是 UTC 時間),sequelize 將其轉換爲 new Date('2012-01-01 00:00:00+00:00')
,顯示時轉換爲本地時間 2012-01-01 08:00:00
,恰好「僥倖」正確。
新插入的數據 sequelize 會進行上一部分說的雙向轉換來保證結果的正確。
維持默認配置顯然致使查詢 DATETIME
不許確,解決方法就是將 sequelize 的 timezone
配置爲 +08:00
。這樣一來,狀況變成下面這樣:
查詢 DATETIME
類型數據時,時間 2012-01-01 01:00:00
被轉換爲 new Date('2012-01-01 01:00:00+08:00')
,顯示時轉換爲本地時間 2012-01-01 01:00:00
,結果正確。
查詢 TIMESTAMP
類型數據時,因爲 time_zone
被設置爲了 +08:00
,數據庫服務器先將庫中 UTC
時間 2011-01-01 00:00:00
轉換到 time_zone
時區時間(加上 8 小時偏移)爲 2011-01-01 08:00:00
,sequelize 將其轉換爲 new Date('2011-01-01 08:00:00+08:00')
,顯示時轉換爲本地時間 2011-01-01 08:00:00
,結果正確。
插入、更新數據時,全部 JavaScript 時間會轉換爲東八區時間入庫。
這樣帶來的問題是,全部入庫時間都是東八區時間,若是有其餘應用的時區不是東八區,那就須要本身基於東八區時間計算偏移並轉換時間後顯示了。
一不當心寫的有點長了,下面列出參考資料供你們進一步學習: