DEVTOOLBOX / GUIDES 本篇

时间与日期歧义清单:Unix 时间戳、时区、ISO 8601 与周数怎么选

时间类缺陷大多不是算错,而是没分清瞬间、偏移、时区与墙上时间这四样东西。这篇按七类歧义逐条给判据:什么时候只能存 UTC,什么时候必须连时区标识一起存,ISO 8601 与 RFC 3339 各自留了什么没定,夏令时与加月运算的不可逆点在哪里,以及能把大半问题一次性压掉的存储与显示规则。

内容复核 10 分钟阅读

四种「时间」:瞬间、偏移、时区、墙上时间

同一串「2024-11-03T01:30」在纽约一年里出现两次,在东京只出现一次,在 UTC 也只出现一次。差别不在写法,而在这串字符究竟声明了哪一类信息。四种东西必须分开看:瞬间是时间轴上的一个点,与观测地点无关;偏移是某个瞬间相对 UTC 的固定差值,例如 +08:00;时区是一套带历史与未来规则的集合,用 IANA 标识表达;墙上时间是日历与钟面上读到的那串数字,例如 9 月 1 日 9 点。

判据看「谁决定这一刻」。已经发生、只需排序或求差值的事件,存瞬间,代价是丢掉当事人当时看到的钟点;指向未来某个钟点的安排,例如每周一 9 点例会、账单日、门诊预约,必须存时区标识加墙上时间,代价是运行时得取到较新的 tzdata;只存偏移,仅在跨系统交换且不关心未来规则时够用,因为它把夏令时规则冻成了常数;只存墙上时间,等于把歧义留给每个读取方,一年两次必然出现两份答案。

不可逆的方向只有一条:瞬间降级为墙上时间会丢掉偏移,偏移降级为墙上时间会丢掉时区,反推不回来。看起来一样的地方有两处:带偏移与带时区的字符串序列化后都可能只剩 +08:00,但前者回答不了「三个月后是否仍是 +08:00」;而不带标注的本地时间里,本地时区与 UTC 同一格式,肉眼分不出,这正是排查日志时最常见的误判起点。

表示典型写法保证什么丢什么
瞬间2024-11-03T06:30:00Z时间轴上唯一一个点当事人看到的钟点
偏移2024-11-03T14:30:00+08:00与 UTC 的固定差值夏令时与历史规则
时区Asia/Shanghai,加 9 月 1 日 9 点历史与未来的偏移规则单独一条无法定位瞬间
墙上时间2024-11-03 01:30阅读直观、贴近用户可能对应零个或两个瞬间

先确定这条数据要回答「什么时候」,还是「钟面上几点」

时间戳:秒与毫秒、闰秒,以及 2038 边界

Unix 时间戳是自 1970-01-01T00:00:00Z 起经过的秒数,但按 POSIX 约定它不计闰秒,因此它不是 SI 秒的计数:两个日期相减得到的是名义经过秒数,在需要严格时刻校正的场合并不等价。第一个坑是单位:10 位是秒、13 位是毫秒、16 位是微秒、19 位是纳秒,按位数判断到 2286 年才会失效。

边界有两条。2038 来自 32 位有符号整数上限 2^31−1,即 2147483647 秒,对应 2038-01-19T03:14:07Z,用 32 位存值的旧系统会在那一刻回绕成负数。另一条在 JavaScript:Date 的合法区间是 ±8.64e15 毫秒,等于前后各一亿天,约 ±27.4 万年;越界后得到 NaN 而不是异常,于是「Invalid Date」既可能来自解析失败,也可能来自溢出,日志里长得一模一样。

闰秒要写具体:1972 年以来共插入 27 次,最近一次是 2016-12-31,平均约每 18 个月一次;正闰秒让 UTC 出现 23:59:60 这一秒,负闰秒至今未发生。2022 年 CGPM 决议要求在 2035 年前停止插入,所以任何内置闰秒表都有保鲜期。判据是:存储用整数毫秒,传输用带 Z 的 RFC 3339,要亚秒就固定微秒并用 int64 或 16 位整数;不要用浮点存时间戳,float32 只有 24 位有效位,在 1.7e9 附近的最小间隔已达 128 秒。而秒与毫秒混用的后果最直接:同一个 2023 年的时刻换成另一种单位,年份会落到 1970 年或五万年后,类型检查不会拦,只有日志里的年份看得出。

单位位数安全表示的上限常见来源
秒1010 位可覆盖到 2286 年POSIX、JWT 的 iat、HTTP 头
毫秒13Date 上限 8.64e15JavaScript、Java 毫秒
微秒16安全整数可到约 2255 年PostgreSQL timestamptz
纳秒19超出 2^53−1,必须 bigintGo 的 time、Python 的 time_ns

先按位数定单位,再决定用什么整数类型装它

ISO 8601 与 RFC 3339:各自声明了什么、又留了什么没定

ISO 8601(现行版 ISO 8601:2019,扩展部分见 ISO 8601-2:2019)定义的是表示法,不是交换协议:偏移可以省略、允许周日期、允许序数日期、允许 24:00:00 表示当日结束、允许只写到年月、允许基本格式、小数分隔符用逗号或点都合法。RFC 3339 是它的子集,为互联网协议收紧:日期必须完整,偏移必须存在,小时只能取 00 到 23,年份固定四位。

没定的比定了的多。字典序排序没有定:偏移不同时,字符串排序与时间排序不一致。夏令时重叠与空洞没有定,表示法层面无法表达「这个墙上时间出现了两次」。日历单位没有定:P1M 是标称月,天数取决于落在哪个月。默认精度、缺失字段的补全、日期是否隐含当地零点,全部下放给应用层。判据就是:只在给人读的地方用 ISO 8601 的宽松形式,进数据库、进日志、进 API 一律 RFC 3339 且必带偏移,能用 Z 就用 Z。

误用最典型的一处在 JavaScript:按 ECMAScript 规定,只含日期的形式按 UTC 解析,含时分的形式按本地时区解析,同一份数据换个写法就差一个偏移量,而且不会报错。代价要说清:统一用 Z 牺牲可读性,2024-05-23T04:00:00Z 对北京用户是中午 12 点;要给人看就交给格式化函数按目标时区渲染,而不是在存储里迁就阅读习惯。还有一个次要理由:偏移不统一时字符串排序与时间排序不一致,统一成 Z 之后字典序才等于时间序。

特性ISO 8601:2019RFC 3339
偏移可省略,允许纯本地时间必填,Z 或 ±hh:mm
周日期与序数日期允许,如 2024-W21-4、2024-144不允许
小时取值允许 24:00:00 表示当日结束只允许 00 到 23
闰秒 60 秒允许允许,仅限真实闰秒
年份范围可用正负号扩展位数固定四位,0000 到 9999
时长与重复区间有 P1Y、R5 这类表示未定义

ISO 8601 是表示法全集,RFC 3339 是面向互联网的受限子集

时区:IANA 标识、夏令时切换,以及 23 或 25 小时的那一天

IANA tz database(亦称 zoneinfo、tzdata)是被广泛实现的时区规则来源,标识形如 Asia/Shanghai,内容包含历史偏移变更与未来夏令时规则,每年发布若干次,标识本身也会改名,例如 2022b 版把 Europe/Kiev 改为 Europe/Kyiv 并保留旧名为链接。所以时区标识是会过期的引用:一份几年没更新的 tzdata,会让历史换算和未来排期一起出错。把规则缓存成常量、只随镜像重建而更新,实际效果等于把时区当成固定偏移。

夏令时把某些日子变成 23 或 25 小时。纽约 3 月第二个周日 02:00 直接跳到 03:00,这一天只有 23 小时,01:30 到 02:59 的墙上时间不存在;11 月第一个周日 02:00 退回 01:00,这一天 25 小时,01:00 到 01:59 出现两次。RFC 5545 给了唯一标准化的处理:落在空洞里的本地时间按切换前的偏移解释,落在重叠里的取第一次出现。极端先例也有,萨摩亚在 2011-12-30 整日跳过,那一天在当地不存在。

判据:说「北京时间」用 Asia/Shanghai 而不是 UTC+8,因为前者带历史(中国在 1986 至 1991 年实行过夏令时),后者只是一个常数;只有明确表示固定差值时才写 +08:00,例如日志、协议字段、跨时区会议的具体时刻。UTC 偏移的实际范围是 -12:00 到 +14:00,跨度 26 小时,所以地球上同时存在三个日期属于常态,不是数据损坏。符号陷阱另算一处:Etc/GMT+8 表示的是 UTC 减 8 小时,正号与直觉相反,这类标识只在明确知道约定时才用。

情形当地墙上时间该日小时数建议处理
春季跳变,纽约 3 月第二个周日02:00 到 02:59 不存在23顺延到 03:00,或按切换前偏移解释
秋季回拨,纽约 11 月第一个周日01:00 到 01:59 出现两次25取第一次出现,或显式带偏移
整日跳过,萨摩亚 2011-12-30整日不存在0按 UTC 计算,勿按本地日期累加
常规日全部存在24直接换算即可

夏令时不改变秒的长度,改变的是某些墙上时间是否存在

日历运算:为什么「加一个月」是歧义的

歧义根源是月不是固定长度。1 月 31 日加一个月,目标日期不存在,只有两种做法:截断到 2 月 28 日或 29 日,或溢出进 3 月。JavaScript 的 setMonth 选溢出,2024 年 1 月 31 日调它得到 3 月 2 日;Temporal 的 PlainDate.add 默认 overflow 为 constrain,得到 2 月 29 日。同一个「加一个月」,两套官方实现相差两天,而且都不报错。

更深一层是日也不固定:加一天在切换日是 23 或 25 小时,所以「加一天」与「加 86400 秒」只在没有时区规则时等价。RFC 5545 把这点写进了规范,DURATION 里的周与天是标称值,会随时历位置变化,时分秒才是精确值。ISO 8601 的 P1M、P1D 同理,它们是日历刻度,不是时长。

判据:缓存过期、超时、重试退避、令牌有效期用固定时长,因为要的是精确间隔;账单周期、续费、生日、月末结算、季度报表用日历单位加显式规则。月末要写「取当月最后一天」,而不是「减一天」;月末加月该截断、该溢出还是固定到某日,必须单独写下来,否则同一套代码在不同月份产生不同账期。

不可逆点很直接:加月再减月回不到原值。3 月 31 日减一个月截断到 2 月 29 日,再加一个月只能回到 3 月 29 日,钳位是有损映射。同类误用是把 2 月 29 日的生日按「每年 2 月 29 日」存储,非闰年直接丢失,正确做法是在契约里写明按 2 月 28 日还是 3 月 1 日计。

运算结果实际跨度可逆性
1 月 31 日加 1 个月,截断2 月 29 日(闰年)29 天否,再减一个月得 1 月 29 日
1 月 31 日加 1 个月,溢出3 月 2 日(闰年)31 天否,再减一个月得 2 月 2 日
3 月 7 日 09:00 加 1 天,纽约(跨春季跳变)3 月 8 日 09:0023 小时是,全程按日历刻度
同一时刻加 86400 秒3 月 8 日 10:0024 小时是,全程按固定时长

同一个「加一」,按日历刻度或按固定时长会得到不同结果

周数、季度与 Cron:彼此不一致的约定

周数差异最集中。ISO 8601 的第 1 周是含当年第一个星期四的那一周,等价于含 1 月 4 日的那一周,周一为首,一年 52 或 53 周。由此推出一个硬结论:ISO 周所属年与日历年不同。2024-12-30 是周一,属于 2025-W01;2026-01-01 是周四,所以 2025-12-29 已经是 2026-W01-1。只存「第 1 周」而不存周所属年,跨年数据必然错位。

其他约定并存。Excel 的 WEEKNUM 默认周日为首、第 1 周含 1 月 1 日,要 ISO 周得用 ISOWEEKNUM;Python 的 %U 以周日为首、第 1 周从第一个周日开始,1 月 1 日之前的日期落在第 0 周,%W 同理但以周一为首,%V 与 isocalendar 才是 ISO;Java 的 WeekFields.SUNDAY_START 与 WeekFields.ISO 又是两套。同一日期在四套约定下给出四个周数,其中还会出现 ISO 里不存在的第 0 周。

季度更散。ISO 8601 不定义季度,财年起点由法规决定:美国联邦财年自 10 月 1 日起,日本自 4 月 1 日起,英国税年自 4 月 6 日起,零售业常见的 4-4-5 日历按 52 或 53 周切分。同一个 Q1 在不同体系里指向完全不同的三个月,所以报表里一旦出现季度,必须同时固定财年起点与切分方式,否则同比不可比。

Cron 的不一致在字段语义。经典 Vixie cron 五个字段,当「日」与「星期」都被限制时,两者是或关系而不是与;Quartz 用六或七字段、首位是秒,并要求两个日期字段之一写问号占位。月份在 cron 里是 1 到 12,在 JavaScript 的 Date 里是 0 到 11,差一位是最常见的 off-by-one。落在夏令时空洞里的一次性任务,多数实现直接跳过不补。

约定周首日第 1 周的定义典型实现
ISO 8601周一含当年第一个星期四,等价于含 1 月 4 日isocalendar、WeekFields.ISO
Excel 默认周日含 1 月 1 日WEEKNUM 不带第二参数
POSIX 的 %U周日第一个周日开始,此前算第 0 周strftime
POSIX 的 %W周一第一个周一开始,此前算第 0 周strftime

四套约定对同一日期可以给出四个不同的周数

能消掉大部分时间 bug 的存储与显示规则

规则可压成三条:存储存瞬间,展示按用户时区渲染,调度存时区加墙上时间。瞬间统一用 UTC 或带 Z 的 RFC 3339,列名带单位;墙上时间必须与时区标识一起落库,不要只存 2024-09-01 09:00 这样的裸串,因为它对应几十个不同的瞬间,事后只能靠猜。

区间一律用半开区间。结束时间写 23:59:59 会漏掉最后一秒内的数据,写 23:59:59.999 仍然依赖精度假设,只有左闭右开在任意精度下都成立。按日统计时,先按目标时区的墙上日期确定边界,再换算成瞬间比较,否则统计窗口与用户认知的那一天会错开。

数据库的默认值最害人。PostgreSQL 的 timestamptz 存 UTC 瞬间、按会话时区渲染,timestamp 存墙上时间、不做换算;MySQL 的 TIMESTAMP 在写入与读取时按会话 time_zone 转换,服务器时区一改历史数据含义就变,且范围止于 2038-01-19T03:14:07Z,需要更远就换 DATETIME 或 bigint。别处的历史包袱同样在:Excel 把 1900 当作闰年,序列号 60 对应不存在的 2 月 29 日;Windows 的 FILETIME 自 1601 起算,Unix 自 1970 起算。

最贵的不可逆操作,是把带时区的时间降级成裸墙上时间再入库。之后反推只能用「当时服务器在哪个时区」来猜,一碰上历史夏令时规则就整体偏一小时,而数据本身没有任何线索能暴露这个偏差。次贵的是把时间戳存成浮点或字符串再排序。默认值方面,容器里 TZ 未设置通常是 UTC,本地开发是 +08:00,同一条用例两边结果不同,所以测试必须显式指定时区。

数据类别存什么不要存原因
已发生的事件UTC 瞬间,整数毫秒或带 Z 的字符串不带时区的本地时间排序与差值须与地点无关
未来的安排时区标识加墙上时间只存偏移偏移不含未来夏令时规则
统计窗口半开区间 [start, end)23:59:59 作为结束会漏掉最后一秒内的数据
展示文本按目标时区实时格式化预先算好的本地字符串规则更新后无法重算

按数据类别决定存什么,比全站统一一种格式更少出错

常见问题

为什么 <code>new Date('2024-05-23')</code> 与带时分的写法会差几个小时?
ECMAScript 规定只含日期的形式按 UTC 解析,含时分的形式按本地时区解析。写法相近但语义不同,跨时区服务里请始终补上 Z 或明确偏移。
时间戳是 10 位还是 13 位?
10 位是秒,13 位是毫秒,16 位是微秒,19 位是纳秒。位数判断到 2286 年才失效,长期存储请把单位写进列名或字段名。
跨年那一周的周数该怎么存?
存 ISO 周所属年加周数,例如 2025-W01。2024-12-30 属于 2025 年第 1 周,只存周数会错位整整一年,还会丢掉 53 周年份的信息。
MySQL 的 TIMESTAMP 与 DATETIME 怎么选?
TIMESTAMP 按会话时区转换,上限是 2038-01-19T03:14:07Z;DATETIME 不转换也无此限。跨时区服务优先 DATETIME 配 UTC 约定。
夏令时切换那天的定时任务为什么会漏跑?
春季跳变让 02:00 到 02:59 不存在,落在这个区间的任务多数实现直接跳过。把关键任务挪到 03:00 之后,或按 UTC 排期再加墙钟校验。

动手试试