Unix 时间戳 vs ISO 8601 —— 开发者踩过的那些坑
在开发者跨系统传输的所有数据类型里,时间是唯一一个会被主观读取的。错误的数字、错误的字符串、错误的布尔值,第一次用就会响亮地失败。错误的时间戳是看不见的——它能解析、能排序、能渲染,但差了 1000 倍、几小时、或一个日历日,于是静默地毁了报表、deadline 与有时限的 API token。本文是一份《现场手册》:如何在不丢一天(或一小时)精度的前提下把时间安全地搬上线。
秒与毫秒:静默破坏
任何时间戳的小数点前一位告诉你它的单位。Unix epoch 1 754 773 560 000 毫秒大约是同一值按秒解释的 56 年前。JavaScript 的 Date 用毫秒;多数后端 API 和数据库用秒;某些遗留系统用微秒。数值本身没有标识能区分三者;区别全在生产者的约定里,消费者必须知道。
产生最多事故的 bug 模式:
- 后端把
createdAt存为 epoch 毫秒。 - 前端读取这个值,按 epoch 秒解释,构造出一个早 56 年的 Date。
- 前端报的每一个「bug」都是用户的账号年龄变成了 56 岁。
在每个系统边界上的修法:选一个单位(毫秒最稳,正好落在 JavaScript Date 的精度阈值内)、写进文档、加一个能抓住错误解释的单元测试。本站的 时间戳工具 有 Auto 模式,会同时尝试两种解释并显示合理的那个;对面向用户的工具来说,这通常就是正确的设计选择。
时区:第二种静默破坏
Unix epoch 是无歧义的——它是从 1970-01-01 00:00:00 UTC 起算的秒(或毫秒)数,就这么简单。带时区偏移的 ISO 8601 字符串(2026-08-06T15:00:00+09:00)也是无歧义的。不带偏移的 ISO 8601 字符串(2026-08-06T15:00:00)真正是歧义的——它是个挂在某个时区本地时间上的挂钟时刻,消费方只能猜。
四种安全模式,按优先级:
- 始终带上时区。RFC 3339 要求,ISO 8601 允许,每个主流格式都支持。
2026-08-06T15:00:00+09:00每次都无歧义地解析到唯一的时刻。 - 存储时永远带偏移,渲染时再决定要不要去掉。PostgreSQL 的
timestamptz、MySQL 的带显式TZ的DATETIME、ISO 8601 字符串——都能带偏移。仅在展示给人类时即时去掉,并在界面上标明时区。 - 永远不要假设本地时区。「用户在东京」默认没记在哪儿——服务端时区是什么它就是什么,用户的由他们配。永远存储「时刻」,渲染时再推导本地视图。
- 用 IANA 时区标识符,不用城市名。
Asia/Tokyo、America/Los_Angeles、Europe/London——它们是稳定的、自动遵循夏令时规则、每个编程语言的日期时间库都认。「Tokyo」是个任何库都无从处理的字符串;「JST」技术上是个固定偏移(+09:00)不含夏令时历史,对 1951 年日本夏令时结束前的日期会丢失历史准确性。
夏令时:永远的脚枪
夏令时是部分年份把时钟拨快一小时的做法,而且政治上不稳定。美国 2007 年改过一次规则,可能还会改;欧盟 2019 年投票废除,反复推迟;俄罗斯 2011 年废除,2014 年又引入,再次废除。任何硬编码「春进秋退」的代码,在任何给定年份对至少一个国家是错的。
唯一安全的模式是把判断委托给语言的时区库——而它又委托给 IANA 时区数据库——永远不要重实现这些规则。JavaScript 里相关原语是 Intl.DateTimeFormat(渲染用)和传给标准库 date 构造函数的 timeZone 选项;Python 是 zoneinfo.ZoneInfo;Java 是 java.time.ZoneId;C# 是 TimeZoneInfo;Rust 是 chrono_tz;Go 是标准库 time.LoadLocation。它们每一个都替你处理夏令时切换。
夏令时制造三个出其不意的边界:
- 「春进」的间隙——本地时间不存在的一个小时窗口。2026 年纽约 3 月第二个周日的
02:30不存在。代码问「这个挂钟时间的 UTC 偏移是多少」时必须返回一个结果(通常是切换前的偏移),并把假设写在文档里。 - 「秋退」的重叠——本地时间存在两次的一个小时窗口。2026 年纽约 11 月第一个周日的
01:30发生两次;两次的偏移不一样。多数系统挑一种(通常是第一次发生,或构造 timestamp 时所在的偏移)。 - 跨夏令时切换做时间算术。「春进前那个周六的 23:30 加两小时是什么?」朴素的回答是「周日次日 01:30」,但美国多数时区里这是错的,因为 02:00 会跳到 03:00,算术会落在 03:30。正确的问题是「加 7,200,000 ms,再转本地时间」;永远不要对挂钟时间做「加 N 小时」。
日历有年,秒没有
epoch 秒擅长表达「瞬间」算术,对「这是几月」毫无用——这要看时区。ISO 8601 日历字符串(2026-W31-3 第 31 周的第 3 天,或 2026-08-06 一个具体的本地日期)擅长「这个日历日」,对算术毫无用。给问题选对抽象层级。
一条有用的规则:跨服务端 / 客户端 / 区域边界的一切都应是 UTC 时刻(带显式毫秒的 Unix epoch,或带显式偏移的 ISO 8601)。人类读的一切都应是那个时刻的本地渲染,并附上时区缩写。仅日历字段——生日、「财季末」——应是 ISO 8601 仅日期字符串(2026-08-06),由上下文赋予时区(「2026-08-06」不需要偏移就知道「带孩子去公园那天」是那一天)。
四条规则
- 永远存储时刻。带毫秒的 Unix epoch 是最稳的选择,ISO 8601 带显式时区偏移是次稳。
- 永远注明单位。秒与毫秒是无声的灾难性错误;良药是文档加单元测试。
- 线上传输永远带时区。不允许例外,不允许跨进程边界用挂钟时刻裸串。
- 时区数学交给库。永远不要重实现夏令时。IANA 数据库是真相源,库对其的包装是恰当的抽象层。
收尾
时间是唯一一种错了单位、错了时区、错了历法仍能正确排序的数据类型。上述四条规则简短但消灭了生产里几乎所有的时间相关 bug。工具:时间戳工具 用于在不同格式间检视与转换,JSON 格式化工具 用于肉眼扫带时间戳的负载(把键名看着像时间字段的值悬浮一下看人类日期),以及 IANA 时区数据库用于任何跨区域的服务器端算术。