详解UTC、Unix时间戳、时区问题

后端9 min
#Node#Mysql#时间
目录(14)

问题背景

概述

在实际开发中发现,Mysql timestamp 存储的时间与本地机器的时间不一致(误)。因此引发了一系列思考。

看一下示例,我在2025-09-15 11:39:xx这个时间点插入一条数据,然后查询出来,发现时间变成了2025-09-15 03:40:54

如图所示:

img-1

聪明的同学可能一下就想到了问题所在,是不是 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

??? 发生了什么,一瞬间小脑瓜里充满了疑问。接着我们尝试梳理一下,这里会产生问题的几个方面进行排查和定位。

  1. 写入数据的时候,创建时间 是由 ORM 的 hook 生成的,还是 Mysql 的行为?
  2. 和时区是否有关?这里要辨别服务器时区、Mysql 时区。
  3. 为什么格式化以后,时间又正常了?

问题定位与分析

运行环境

  1. 服务器运行在我本地。
  2. Mysql 8.0 运行在 docker 内。
  3. ORM 使用的是 drizzle + mysql2。
  4. 时间转换的库采用的 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

问题排查

首先明确各个可能影响时间的因素。

  • 服务器时区,东八区。
  • Mysql 时区,未设定。
  • ORM 时区,未设定。(且不支持设定)
  • dayjs 未设定时区。

逐步排查

  1. 写入数据库的时候,ORM 未使用 hook,因此插入的时候是 Mysql 通过 CURRENT_TIMESTAMP 写入的时间。
  2. gmt_create 的字段类型为 timestamptimestamp 会将当前会话的时区转换为 UTC 时间写入到数据库中。当展示的时候,会根据当前会话的时区转换为本地时间展示。
  3. 因此写入数据的时候是通过,Mysql 的 CURRENT_TIMESTAMP 函数获取的当前时区时间写入字段的。
  4. 当从 ORM 中取出的时候,也没有经过 hook,其返回格式为 2025-09-15T03:39:54.000Z 是一个符合 ISO 8601 / RFC 3339 标准的日期时间格式。其中 Z 代表 UTC 时区(即 0 时区)。
  5. 当 dayjs 转换时间的时候,默认会取运行环境的本地时区进行格式化,因此 2025-09-15T03:39:54.000Z 被格式化为服务器(东八区)的 2025-09-15 11:39:54

结论与思考

结论

基于之前的排查,我们可以得出结论,Mysql 查到的数据是 UTC 时间,是符合预期的。

ORM 取时间的时候没有做转换,而 dayjs 格式化的时候将它转成了服务器时间。

因此之前感觉 Mysql 存储的时间是错误的,这个结论不正确。

我们可以进一步考证。 查 SQL 的时候,我们手动指定一个时区,如图所示:

img-3

冰果,符合预期。

思考

从这个小误会,我们发现在业务里处理时间涉及到很多环节,而且一不小心就容易搞混。

比如前文提到的 UTC 、timestamp 等等,它们到底是什么东东?我们应该如何分辨和应用?

如果涉及到国际化业务我们又应该如何处理? 客户端如何正确展示时间?服务端如何正确处理时间?时间格式又应该如何可靠的存储??

UTC、GMT、Unix 时间戳、纪元时间是什么?

常见时间格式

在解决实际问题之前,我们先了解一下我们面对的“敌人”都有什么形态。

互联网常见时间格式参考表

格式名称示例说明与用途
ISO 86012025-09-15T15:27:00Z国际标准,广泛用于 API、数据库、日志等
RFC 33392025-09-15T15:27:00+08:00基于 ISO 8601,常用于 JSON、REST 接口
RFC 2822/5322Mon, 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 PMMM/DD/YYYY,12 小时制,常见于美式网站
欧洲格式15.09.2025 15:27DD.MM.YYYY,24 小时制,常见于欧式系统
Excel 序号45800Excel 中的日期序号,自 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

前者的 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(东八区)
}

UTC、GMT

UTC,全称是 Coordinated Universal Time(协调世界时),是目前全球通用的时间标准。

GMT,全称是 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 官网的一个近期活动,看一下他们的实现。

  1. apple.com

img-2

  1. apple.com/cn

img-4

通过对比我们可以看到,apple 返回了两种时间格式,一个是 UTC 时间,一个是本地时间。

结合 Mysql timestamp 的设计,不难想到,我们也可以采用存储 UTC 时间,然后交由客户端处理为本地时间的方案。

实现细节

Mysql 类型选择

类型存储大小时间范围(有效值)格式是否受时区影响小数秒支持用途说明
DATE3 字节1000-01-01 到 9999-12-31YYYY-MM-DD仅表示日期
TIME3 字节-838:59:59 到 838:59:59HH:MM:SS表示时间或持续时间
YEAR1 字节1901 到 2155YYYY表示年份
DATETIME8 字节1000-01-01 00:00:00 到 9999-12-31 23:59:59YYYY-MM-DD HH:MM:SS日期和时间组合,绝对时间
TIMESTAMP4–7 字节1970-01-01 00:00:01 到 2038-01-19 03:14:07 UTCYYYY-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

ORM

ORM 的连接一般可以指定时区,默认指定 Z 即可。

注: drizzle ORM 并不支持设置 Session 时区。

客户端处理

服务端经过统一处理后,返回的时间正常应为 UTC 时间,客户端即可根据用户的运行环境转换为对应的本地时间。

结语

写道这里,我们大概对于时间格式、时间存储等方面有了一个比较清晰的认识。

但实际上,针对当前的实现方案仍有几个问题值得思考:

  1. 当用户 bios 没电了,时间存在问题,这时候服务端和客户端可以修正时间差嘛? 应该通过什么方式进行交互?
  2. 基于上面的问题,我们在接口校验的时候,常常会引入时间差的概念,当前模式是否支持?
  3. 当我们涉及到字符串 YYYY-MM-DD HH:mm:ss 的时候又应该如何处理?

参考文章

附图