深入理解 JavaScript 与 MySQL 的时间处理及时区转换

作者:秋池时间:2026-10-23 09:54:46浏览:289

本文深入解析 JavaScript 与 MySQL 中的时间处理机制,重点讲解 ISO 8601 格式中 T 和 Z 的含义、Date 构造器与时区的关系,以及 DATETIME 与 TIMESTAMP 的区别。结合 Sequelize 框架源码,详细阐述本地时间与 UTC 时间的双向转换逻辑,并提供兼容旧数据的配置建议。

引言与修订说明

原文对 ISO 8601 时间格式中 T 和 Z 的表述存在一些误区,作者已对此进行了修订,以避免读者产生误解。

在使用 Sequelize 进行数据库操作时,作者发现通过 ORM 查询得到的时间与通过 MySQL 命令行直接查询的结果不一致。为了解决这一困惑,作者对时间处理机制进行了深入的研究,以下是具体的学习总结。

时间基本概念与时区

基本概念

首先回顾一些可能曾在地理课上学过的基础知识。

时间的概念确实很神奇。早期人类通过观察太阳位置来计时(例如使用日晷),这导致不同经纬度地区的时间存在差异。后来,人们规定以本初子午线为中心,向东西两侧各延伸 15 度划分一个时区,共 24 个时区。由于地球自转一圈为 360 度,耗时 24 小时,因此每跨越一个时区,时间相差一小时。

最初的标准时间基于英国伦敦皇家格林威治天文台的时间(因其位于本初子午线上),即常说的 GMT(Greenwich Mean Time)。其他时区根据 GMT 确定各自时间:向东的时区时间较晚(表示为 GMT+hh:mm),向西的时区时间较早(表示为 GMT-hh:mm)。例如,中国标准时间位于东八区,比 GMT 时间晚 8 小时。当 GMT 时间为凌晨 1 点时,中国已是早晨 9 点。

然而,GMT 是基于地球自转和公转计算的(太阳每天经过格林威治天文台的时间为中午 12 点),精度有限。因此,后来提出了基于原子钟的标准时间 UTC(Coordinated Universal Time)。

通常情况下,GMT 和 UTC 可以互换使用,但严格来说,GMT 是一个时区,而 UTC 是一个时间标准。

所有时区的详细信息可以参考:https://www.timeanddate.com/time/map/。

因此,在“展示”某个时间时,明确时区至关重要。如果只说“现在是 2016-01-11 19:30:00”而不指明时区,无法准确获知具体时间(除非假设这是本地时间)。若说明“现在是 2016-01-11 19:30:00 GMT+0800”,则明确这是东八区时间。若身处东八区,时间为 19:30;若身处 GMT 时区,则需减去 8 小时,时间为 11:30。

JavaScript 时间处理详解

JavaScript 中的“时间”

接下来介绍 JavaScript 中的时间处理,包括:Date、Date.parse、Date.UTC、Date.now。

注:以下代码示例可在 Node.js Shell 中运行。如果运行结果与示例不一致,可能是因为您所处的时区不同。

Date 构造器

创建时间对象有以下几种方式:


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

ISO 8601 的标准格式为:YYYY-MM-DDTHH:mm:ss.sssZ,各部分含义如下:

  • YYYY:年份,范围 0000 ~ 9999
  • MM:月份,范围 01 ~ 12
  • DD:日,范围 01 ~ 31
  • T:分隔日期和时间
  • HH:小时,范围 00 ~ 24
  • mm:分钟,范围 00 ~ 59
  • ss:秒,范围 00 ~ 59
  • .sss:毫秒
  • Z:时区,可以是 Z(代表 UTC)、+HH:mm 或 -HH:mm

这里重点讨论 T 和 Z 的含义。

T

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

Z 用于指定传入时间的时区(zone)。如果不指定 Z 且使用空格分隔,则按本地时区处理。之前的表述不严谨:指定 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

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)

虽然 RFC-2822 还能表示星期,但可读性较差,不建议使用。完整规范可查阅:https://tools.ietf.org/html/rfc2822#page-14。

时间戳

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

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 接受的参数与 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 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 时间类型与 Sequelize 转换

MySQL 中的“时间”

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 vs TIMESTAMP

我们主要说明 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'。提供的值不正确,因为尝试插入 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 间转换

分别说明了 JavaScript 和 MySQL 中的“时间”后,我们来聊聊 ORM 框架通常如何在两者间进行正确、合适的转换以避免混乱。以下说明将基于 Sequelize 框架来解释,主要是一种思路,其他框架可以参考其文档或源码。

Sequelize 实际上有一个 timezone 配置,默认是 +00:00(http://sequelize.readthedocs.org/en/latest/api/sequelize/.)。这个 timezone 有下面的用途:

  • 建立数据库连接时,执行 SET time_zone = opts.timezone
  • MySQL 的时间类型和 JavaScript 的时间类型的互相转换

第一个用途很简单,体现在源码中就是执行一个 SQL 语句:


connection.query("SET time_zone = '" + self.sequelize.options.timezone + "'"); /* jshint ignore: line */

第二个用途主要体现在两个地方:1)在 JavaScript 中调用 ORM 方法进行插入、更新时,需要将 Date 类型转为正确的 SQL 语句;2)从 MySQL 服务器查询数据时,需要将数据库查询到的值转换为 JavaScript 中的 Date 类型。下面我们分别来看一看。

JavaScript -> MySQL

这个转换的核心代码如下:


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');
 }
};

代码逻辑如下:

  1. 检查 timeZone 是否存在,如果存在(指类似 America/New_York 这样的表示法),调用 tz 设置 date 的时区。
  2. 如果不存在(类似 +00:00、-07:00 这样的表示法),调用 utcOffset 设置 date 的相对 UTC 的时区偏移。
  3. 最后使用上面设置的时区偏移将其 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 小时)。

MySQL -> JavaScript

这个转换过程实际上是更底层的 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;
 // 更多代码...
}

处理过程大概是这样:

  1. 用 parser 将服务器返回的二进制数据解析为时间字符串
  2. 如果配置了强制返回字符串 dateStrings 而不是转换回 Date 类型,直接返回 dateString
  3. 如果字段类型是 DATE,时间字符串的时间部分统一为 00:00:00
  4. 如果配置的 timeZone 不是 local(本地时区),时间字符串加上时区信息
  5. 将时间字符串传给 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 实战与旧数据兼容方案

一个例子

在使用 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 时间会转换为东八区时间入库。

这样带来的问题是,所有入库时间都是东八区时间,如果有其他应用的时区不是东八区,那就需要自己基于东八区时间计算偏移并转换时间后显示了。

参考资料

一不小心写的有点长了,下面列出参考资料供大家进一步学习:

http://www.timeanddate.com/time/gmt-utc-time.html https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Datehttps://developer.mozilla.org... https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/UTC http://www.ecma-international.org/ecma-262/5.1/#sec-15.9.1.15 http://www.w3schools.com/js/js_date_formats.asp https://momentjs.com/timezone/docs/ https://sequelize.readthedocs.io/en/latest/api/sequelize/ https://github.com/mysqljs/mysql 《MySQL 技术内幕》

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持本站。

作者最新文章