详解UTC、Unix时间戳、时区问题
# 问题背景
# 概述
在实际开发中发现,Mysql timestamp 存储的时间与本地机器的时间不一致(误)。因此引发了一系列思考。
看一下示例,我在2025-09-15 11:39:xx
这个时间点插入一条数据,然后查询出来,发现时间变成了2025-09-15 03:40:54
。
如图所示:
聪明的同学可能一下就想到了问题所在,是不是 ORM、 Mysql 没有指定正确的时区?
但先别急,更神奇的地方来了,当我们取出的原数据进行格式化以后,时间又仿佛又正常了。
// 伪代码
const info = db.findOne(); // {"id": 23, "gmtCreate": "2025-09-15T03:39:54.000Z"}
// 格式化时间
dayjs(info.gmtCreate).format("YYYY-MM-DD HH:mm:ss"); // 2025-09-15 11:39:54
2
3
4
5
??? 发生了什么,一瞬间小脑瓜里充满了疑问。接着我们尝试梳理一下,这里会产生问题的几个方面进行排查和定位。
- 写入数据的时候,
创建时间
是由 ORM 的 hook 生成的,还是 Mysql 的行为? - 和时区是否有关?这里要辨别服务器时区、Mysql 时区。
- 为什么格式化以后,时间又正常了?
# 问题定位与分析
# 运行环境
- 服务器运行在我本地。
- Mysql 8.0 运行在 docker 内。
- ORM 使用的是 drizzle + mysql2。
- 时间转换的库采用的 dayjs。
示例的代码逻辑:
/**
* ORM的 schema
* mysqlTable({
* id: int({ unsigned: true }).autoincrement().primaryKey(),
* gmtCreate: timestamp('gmt_create').notNull().default(sql`CURRENT_TIMESTAMP`),
* })
*
* 表的 schema
* CREATE TABLE `tb_review_record` (
* `id` int unsigned NOT NULL AUTO_INCREMENT,
* `gmt_create` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
* }
*/
// 插入
const newInfo = db.create(); // 未传入任何时间
// 取到对应值
const info = db.findOne(); // {"id": 23, "gmtCreate": "2025-09-15T03:39:54.000Z"}
// 格式化时间
dayjs(info.gmtCreate).format("YYYY-MM-DD HH:mm:ss"); // 2025-09-15 11:39:54
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 问题排查
# 首先明确各个可能影响时间的因素。
- 服务器时区,东八区。
- Mysql 时区,未设定。
- ORM 时区,未设定。(且不支持设定)
- dayjs 未设定时区。
# 逐步排查
- 写入数据库的时候,ORM 未使用 hook,因此插入的时候是 Mysql 通过
CURRENT_TIMESTAMP
写入的时间。 gmt_create
的字段类型为timestamp
。timestamp
会将当前会话的时区转换为 UTC 时间写入到数据库中。当展示的时候,会根据当前会话的时区转换为本地时间展示。- 因此写入数据的时候是通过,Mysql 的
CURRENT_TIMESTAMP
函数获取的当前时区时间写入字段的。 - 当从 ORM 中取出的时候,也没有经过 hook,其返回格式为
2025-09-15T03:39:54.000Z
是一个符合 ISO 8601 / RFC 3339 标准的日期时间格式。其中Z
代表 UTC 时区(即 0 时区)。 - 当 dayjs 转换时间的时候,默认会取运行环境的本地时区进行格式化,因此
2025-09-15T03:39:54.000Z
被格式化为服务器(东八区)的2025-09-15 11:39:54
# 结论与思考
结论
基于之前的排查,我们可以得出结论,Mysql 查到的数据是 UTC 时间,是符合预期的。
ORM 取时间的时候没有做转换,而 dayjs 格式化的时候将它转成了服务器时间。
因此之前感觉 Mysql 存储的时间是错误的,这个结论不正确。
我们可以进一步考证。 查 SQL 的时候,我们手动指定一个时区,如图所示:
冰果,符合预期。
思考
从这个小误会,我们发现在业务里处理时间涉及到很多环节,而且一不小心就容易搞混。
比如前文提到的 UTC 、timestamp 等等,它们到底是什么东东?我们应该如何分辨和应用?
如果涉及到国际化业务我们又应该如何处理? 客户端如何正确展示时间?服务端如何正确处理时间?时间格式又应该如何可靠的存储??
# UTC、GMT、Unix 时间戳、纪元时间是什么?
# 常见时间格式
在解决实际问题之前,我们先了解一下我们面对的“敌人”都有什么形态。
互联网常见时间格式参考表
格式名称 | 示例 | 说明与用途 |
---|---|---|
ISO 8601 | 2025-09-15T15:27:00Z | 国际标准,广泛用于 API、数据库、日志等 |
RFC 3339 | 2025-09-15T15:27:00+08:00 | 基于 ISO 8601,常用于 JSON、REST 接口 |
RFC 2822/5322 | Mon, 15 Sep 2025 15:27:00 +0800 | 邮件头部时间格式,适用于电子邮件系统 |
Unix 时间戳 | 1757940420 | 自 1970 年起的秒数,适用于后端存储与计算 |
自然语言 | September 15, 2025, 3:27 PM | 人类可读,适合前端展示 |
中文格式 | 2025年9月15日 15:27 | 本地化展示,适合中文用户界面 |
美国格式 | 09/15/2025 3:27 PM | MM/DD/YYYY,12 小时制,常见于美式网站 |
欧洲格式 | 15.09.2025 15:27 | DD.MM.YYYY,24 小时制,常见于欧式系统 |
Excel 序号 | 45800 | Excel 中的日期序号,自 1900 年起的天数 |
Julian 日期 | JD 2460560.144 | 天文学与科学计算中使用 |
Swatch Internet Time | @500 | 一天划分为 1000 beats,极少使用 |
里面有些是供人类可读性使用的,有些是专业领域使用的。我们主要关注前 4 种格式。
- ISO 8601 是国际标准,也是最常用的时间格式。它是一种紧凑的、可扩展的、跨平台的时间格式,被广泛用于 API、数据库、日志等场景。
- RFC 3339 是基于 ISO 8601 的扩展,常用于 JSON、REST 接口等场景。
- RFC 2822/5322 是邮件头部时间格式,适用于电子邮件系统。
- Unix 时间戳 是自 1970 年起的秒数,适用于后端存储与计算。
其中前三种格式直观感受大同小异,都是 时间格式+偏移量
。尤其是前两种我们业务中也经常有见到,如:
new Date(); // 2025-09-15T07:37:09.433Z
dayjs().tz("Asia/Shanghai").format(); // 2025-09-15T15:45:52+08:00
2
3
前者的 Z
代表 UTC 时区 ,后者的 +08:00
代表东八区。
还有一个格式是 Unix 时间戳,相信大家也不陌生,它是从 1970 年 1 月 1 日 00:00:00 UTC 开始计时的时间差值。
常见的分为 10 位的秒戳和 13 位的毫秒戳。
{
"unix": 1757922548, // 对应时间:2025-09-15 15:49:08 CST(东八区)
"valueOf": 1757922577000 // 对应时间:2025-09-15 15:49:37 CST(东八区)
}
2
3
4
# UTC、GMT
UTC,全称是 Coordinated Universal Time(协调世界时),是目前全球通用的时间标准。
GTM,全称是 Greenwich Mean Time(格林尼治标准时间),历史上最早被广泛采用的全球时间标准。它的定义和地位曾经非常重要,但现在已经被更精确的 UTC(协调世界时) 所取代。 目前仍用于航海、部分手表、历史文献中。
在我们开发中,首选的都是 UTC 时间, GMT 仅在一些欧洲用户偏好的地方有做 UI 相关的处理,这里不做赘述。
# 纪元时间、Unix 时间戳
纪元时间,全称是Epoch Time,指的是 1970 年 1 月 1 日 00:00:00 UTC 时间,它是 Unix 时间戳的起点。是 Unix 诞生之初,开发者团队为了简化时间计算定义的时间。(这个纪元时间的来历,网上有很多都市传说,感兴趣的可以课下了解一下...)
Unix 时间戳,就是从纪元时间开始,到现在的秒数。它是一个整数,通常以秒为单位,也可以用毫秒表示。
例如我们可以常常看到,1970 年 1 月 1 日 这个时间,往往就是对 "0" 进行时间戳格式化导致的。
dayjs.unix(0).format("YYYY-MM-DD HH:mm:ss"); // 1970-01-01 08:00:00 注意: 0 本身是纪元时间,这里格式化以后取了东八区
# 时区问题
时区这个概念,在我们日常交流的语境里经常会被淡化,比如几点和朋友约好吃饭、几点上课、几点放学等等,我们往往不会去考虑时区的问题。
但是在涉外业务中,时区问题就变得非常关键。例如 apple 公司想要举办一个全球性的活动,那么这个活动的时间应该如何通知到不同地区的用户?
我们现在打开 apple 官网的一个近期活动,看一下他们的实现。
- apple.com
- apple.com/cn
通过对比我们可以看到,apple 返回了两种时间格式,一个是 UTC 时间,一个是本地时间。
结合 Mysql timestamp 的设计,不难想到,我们也可以采用存储 UTC 时间,然后交由客户端处理为本地时间的方案。
# 实现细节
# Mysql 类型选择
类型 | 存储大小 | 时间范围(有效值) | 格式 | 是否受时区影响 | 小数秒支持 | 用途说明 |
---|---|---|---|---|---|---|
DATE | 3 字节 | 1000-01-01 到 9999-12-31 | YYYY-MM-DD | ❌ | ❌ | 仅表示日期 |
TIME | 3 字节 | -838:59:59 到 838:59:59 | HH:MM:SS | ❌ | ✅ | 表示时间或持续时间 |
YEAR | 1 字节 | 1901 到 2155 | YYYY | ❌ | ❌ | 表示年份 |
DATETIME | 8 字节 | 1000-01-01 00:00:00 到 9999-12-31 23:59:59 | YYYY-MM-DD HH:MM:SS | ❌ | ✅ | 日期和时间组合,绝对时间 |
TIMESTAMP | 4–7 字节 | 1970-01-01 00:00:01 到 2038-01-19 03:14:07 UTC | YYYY-MM-DD HH:MM:SS | ✅ | ✅ | 日期和时间组合,受时区影响 |
Mysql 的时间类型中只有 TIMESTAMP
支持时区的转换,其他几个类型,如果传入什么时区,则会存储什么时区。而 TIMESTAMP
的值是从当前时区转换为 UTC,并在检索时从 UTC 转换回当前时区。如果连接的时区保持不变,则得到的时间值就会和存储的相同。但是如果存储以后,变更了时区则再次取出来,时间值会根据新时区进行变更。
因此,在连接数据库时,要保证时区的一致性。
# 服务端时区问题
服务端的时区,一般和运行环境相关,如果部署在 Docker 中,那么默认就是 UTC 时区。
如果部署在不同的区域的服务商里,可能还需要额外指定时区。
因此在业务里,我们要保持时区的一致性,避免取到偏移值。
常见的时间处理方式有两种: 1. Date 对象。 2. 时间处理库,如 dayjs。
Date 对象
Date 对象是基于 Unix 时间戳实现的,因此它处理时间的方式是基于 UTC 时间进行处理的。
若我们想获取当前的 UTC 时间只需要 new Date()
即可。
dayjs
dayjs 默认会使用运行环境的时区进行转换,因此我们在业务里使用 dayjs 需要先进行拓展。
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
/**
* 拓展 Dayjs
*/
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.tz.setDefault("UTC");
// 处理时间时采用 dayjs.utc 或者 dayjs.tz
console.log(dayjs.utc().format()); // 2025-09-15T10:18:39Z
console.log(dayjs().format()); // 2025-09-15T18:18:39+08:00
console.log(dayjs.tz(dayjs().format(), "UTC").format()); // 2025-09-15T10:18:39Z
console.log(dayjs.tz(dayjs().format(), "Asia/Shanghai").format()); // 2025-09-15T10:18:39+08:00
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ORM
ORM 的连接一般可以指定时区,默认指定 Z 即可。
注: drizzle ORM 并不支持设置 Session 时区。
# 客户端处理
服务端经过统一处理后,返回的时间正常应为 UTC 时间,客户端即可根据用户的运行环境转换为对应的本地时间。
# 结语
写道这里,我们大概对于时间格式、时间存储等方面有了一个比较清晰的认识。
但实际上,针对当前的实现方案仍有几个问题值得思考:
- 当用户 bios 没电了,时间存在问题,这时候服务端和客户端可以修正时间差嘛? 应该通过什么方式进行交互?
- 基于上面的问题,我们在接口校验的时候,常常会引入时间差的概念,当前模式是否支持?
- 当我们涉及到字符串
YYYY-MM-DD HH:mm:ss
的时候又应该如何处理?
# 参考文章
- Mysql 8.0 - date (opens new window)
- Mysql 8.0 function_current-timestamp (opens new window)
- Date - MDN (opens new window)