之前排查过一个线上问题,用户在美国西海岸反馈说,系统里有个定时任务每天上午 10 点执行,但是日志显示触发时间总是差一个小时。查了服务器时区,UTC。代码里写的是 cron.schedule('0 10 * * *'),按照服务器的 UTC 时间,10 点确实准时触发了。但用户以为"上午 10 点"是太平洋时间,两边的理解差了 7 个小时。
这个问题不算 bug,纯属沟通失误。但它暴露了一个核心问题:时间本身没有时区,是人给它赋予时区的。
类似的问题我碰到过不止一次。另一个项目里,同事把"2024-03-10 02:30:00"这个字符串直接存到了数据库的 DATETIME 字段,没有附带时区信息。几个月后做数据迁移,新服务器的时区设置不同,读出这个时间后往 Unix 时间戳转换,结果差了整整一小时。而且这个时间点在美西正好是夏令时切换日——凌晨 2 点不存在(时钟直接从 1:59 跳到 3:00)。那批数据的时间全部错位。
Unix 时间戳没有时区
很多人包括我自己,刚接触 Unix 时间戳的时候都会有一个误解:以为时间戳包含时区信息。
Unix 时间戳的定义很简单:从 1970-01-01 00:00:00 UTC 到目标时间的总秒数(或毫秒数)。 它是一个单调递增的整数。1714204800000 这个数字在全世界任何计算机上含义都一样——不分北京、纽约、伦敦。
这就意味着:时间戳本身没有时区概念。时区只在"显示"的时候才介入。
我在东京用 Date.now() 拿到一个时间戳,和在北京同时拿到的值是一样的。但我把它转成本地时间串,东京显示 09:00,北京显示 08:00,伦敦可能是 00:00 或 01:00(取决于夏令时)。
时间戳 → 可读时间,这步需要时区。可读时间 → 时间戳,这步也需要知道时区才能正确转换。
这就是所有时间相关 bug 的根源。程序员把时间戳和可读时间混为一谈,在应该用时间戳的地方用了字符串,或者反过来。
UTC 偏移与夏令时
UTC(协调世界时)是时间基准。世界上每个时区都表示为 UTC 的偏移量。北京时间是 UTC+8,日本时间是 UTC+9,纽约在标准时间是 UTC-5。
夏令时(DST)让事情变得更复杂。它不是简单的"夏天拨快一小时",各国家的切换规则不一样,甚至每年可能变。
美国 2024 年的夏令时从 3 月 10 日开始到 11 月 3 日结束。欧洲的切换日期不同。中国曾经也使用过夏令时(1986-1991),但现在已经取消了。如果你在处理历史数据,还得考虑这些。
做数据报表的时候踩过这个坑。系统需要把 UTC 时间转成多个时区的本地时间展示。为了图省事,给每个时区固定了一个偏移量——北京时间 +8,东京时间 +9,纽约时间 -5。
当年 3 月 15 日出报表的时候,数据对不上。查了半天发现纽约的偏移应该是 -4(夏令时),我写了死的 -5。修正后 11 月又出问题了,因为夏令时结束回到了 -5。
判断的方式也很笨:在纽约找一个朋友,让他告诉我当时他的本地时间,对比系统显示的时间,差了 1 小时。后来我把 DST 逻辑抽出来用了 Intl.DateTimeFormat,浏览器自己计算夏令时,不再硬编码偏移量。
同一个时间戳,不同的本地时间
这是理解时区转换最关键的一个概念。拿一个具体的时间戳来演示。
时间戳:1714204800000(毫秒)
对应 UTC 时间:2024-04-27 16:00:00
在不同时区显示为:
| 时区 | 本地时间 | 与 UTC 偏移 |
|---|---|---|
| 北京 | 2024-04-28 00:00:00 | +8 |
| 东京 | 2024-04-28 01:00:00 | +9 |
| 伦敦(夏令时) | 2024-04-27 17:00:00 | +1 |
| 纽约(夏令时) | 2024-04-27 12:00:00 | -4 |
| 洛杉矶(夏令时) | 2024-04-27 09:00:00 | -7 |
同一个时间戳,对应了 5 个不同的"墙面时间"。发送方可能在北京的 00:00 发了一条消息,接收方在纽约看到的是 12:00——时间戳是同一个,只是显示不同。
这就是为什么存时间戳比存字符串更安全。如果存的是 2024-04-28 00:00:00,但不附带时区信息,接收方不知道该按哪个时区理解。时间戳就没有这个问题。
JavaScript Date 的诡异之处
JavaScript 的 Date 对象在时区处理上有几个著名的坑。
月份从 0 开始。 new Date(2024, 0, 1) 是 1 月 1 日,不是 0 月。new Date(2024, 11, 31) 是 12 月 31 日。我记不清犯过多少次这个错误了。比如计算某个月的最后一天:
// 我想拿到 2024 年 12 月的最后一天
new Date(2024, 12, 0); // 这返回的是 2024-11-30(11月30日)
因为月份参数 12 被解释为"第 13 个月",等价于 2025 年第 1 个月,第 0 天又是上个月的最后一天。绕了两圈。正确写法是 new Date(2024, 11, 31) 或 new Date(2024, 12, 0) 但月份要传 12。这段逻辑我每次写都要停下来想。
ISO 8601 字符串解析在不同浏览器里行为不一致。 new Date('2024-04-28T00:00:00') 在 Chrome 里会被当作 UTC 时间,在 Safari 里可能被当作本地时间。这个问题在 2024 年还存在于某些 Safari 版本中。
我的判断方式是:用 Date.parse 返回的结果在 Chrome 和 Safari 上分别跑一次,如果结果差了一个时区偏移量,那就是解析方式不同。后来我全部改成 new Date('2024-04-28T00:00:00Z')——加了个 Z 明确表示 UTC,所有浏览器表现就一致了。
getHours() 返回的是本地时间。 你在东京跑 new Date().getHours() 和在北京跑,虽然 Date.now() 相同,但 getHours() 的结果不一样。要用 getUTCHours() 才能拿到不受时区影响的小时数。
const now = Date.now(); // 1714204800000
// 在北京
new Date(now).getHours(); // 0(北京时间凌晨)
new Date(now).getUTCHours(); // 16(UTC 时间下午)
// 在纽约
new Date(now).getHours(); // 12(纽约时间中午)
new Date(now).getUTCHours(); // 16(还是 16,UTC 不变)
同一个 Date 对象,在不同时区的机器上调用 getHours() 结果不同,但 getUTCHours() 一定相同。如果写代码时用了 getHours() 来比较时间,换台服务器结果就变了。
一个真实的 DST 生产事故
之前做过一个电商项目。每年 3 月第二个周日(美国夏令时开始日),订单系统的统计报表都会出问题。具体表现是:那天的凌晨 1:00 到 3:00(太平洋时间)之间的订单,有一部分统计不到。
排查过程是这样的。我先查了那段时间的订单数据,数据库里确实有记录,时间戳正常。再看统计代码,发现它用了一个定时任务,每天凌晨按"本地时间"拉取前一天的订单。
代码逻辑是把"当天 00:00:00"转成时间戳,然后比较订单的创建时间戳。问题出在"当天 00:00:00"这个本地时间的转换上——夏令时开始那天,凌晨 2:00 不存在,所以转换结果往前跳了一个小时,把一部分订单排除在外了。
修复方式是把定时任务的调度和比较逻辑都改成 UTC 时间,不再依赖本地时区转换。从那以后,每年 3 月再也没出过问题。
后来我把这个经验带到了新项目,所有内部存储和传输都用时间戳,只在界面展示时转成用户时区。这个原则帮我避免了很多问题。
用 Unix 时间戳转换工具 可以在线验证时间戳和可读时间的双向转换,支持毫秒级精度和多种时区对比。
写在最后
时间处理的核心规则其实就三条:存时间戳,用 UTC 做中间层,只在显示时转本地时间。但执行起来需要抵御各种"偷懒"的诱惑——为了图方便存了 DATETIME 字符串,为了省事硬编码了时区偏移量,为了快速上线忽略了夏令时。每个选择当时看起来都没问题,但等到数据跨时区流动的那一天,坑就出现了。