DevKitLab Logo DevKitLab
时区 / 日期与时间 / 夏令时 / 调试

日期为什么会差一天?时区与日历日期

把生日存成 5 月 1 日,显示出来却是 4 月 30 日。把事件排在 7 月 15 日,日历上却是 14 日。跑一份日报,午夜前的那条记录算到了错误的一天。原因几乎总是同一个:日历日期不是一个时间点,而 bug 就发生在「无时区的日期」撞上「有时区的瞬间」的那道边界上。

你把一个用户的生日存成 5 月 1 日,显示出来却是 4 月 30 日。你把一个事件排在 7 月 15 日,日历上却显示 14 日。你跑一份日报,那条正好发生在午夜之前的记录,被算到了错误的一天。这些其实是同一个 bug,只是换了种表现形式,而通常的反应——加几个小时、减一天、再套一层转换——只修好了眼前这一个,又弄坏了另外两个。

这些现象背后其实是同一个事实:日历日期不是一个时间点。「2026-07-15」并不指某个时刻,它指的是一整天——从当地午夜到下一个当地午夜的那段时间——而这段时间在每个时区里都从不同的瞬间开始。15 号在东京的午夜,比 15 号在洛杉矶的午夜早了十六个小时。把这个差距铺到全球,一个日历日期在地球上「是今天」的时间总共约 50 小时——从最东边 UTC+14 的时区进入它,到 UTC−12 的时区最后离开它。日期是一段跨度,瞬间是一个点。「差一天」的 bug,就发生在这两者的边界上——当你把一个无时区的日期变成有时区的瞬间,或者把一个有时区的瞬间挤回成日期,而用了一个你并不想用的时区。

这篇文章从两个方向走一遍这道边界。把日期变瞬间(那个变成昨天的纯日期字符串)。把瞬间变日期(toISOString 报出明天)。再往深处的修法——认出那些压根就不该是瞬间的日期,比如生日和截止日。然后是夏令时,它让同一时区内的日期运算也会滑动;以及日报里的按天分桶,你在哪个时区里截断,就决定了一条记录落到哪一天。它是《Unix 时间戳为什么不对》《UTC、GMT、ISO 8601 与 Unix 时间戳有什么区别》之后的第三块拼图——那两篇分别讲清了瞬间和时区,这一篇讲的是它们反复撞上的那个日历日期。

唯一的核心:日期是一段跨度,不是一个时刻

把两样东西分开,大部分困惑就清楚了:

  • 瞬间是时间线上的一个点——一个 Unix 时间戳、一个以 Z 锚定的时刻。对所有人、所有地方都一样。
  • 日历日期——「2026-07-15」——是民用日历上的一个日期标签,表示从当地午夜到下一个当地午夜的那段跨度。它通常接近 24 小时,但夏令时切换可能让它变短或变长,例如变成 23 小时或 25 小时。它没有唯一的瞬间,因为它在每个时区里都从不同的时刻开始、又在不同的时刻结束。

这两者是不同种类的东西,而每一个日期偏移 bug,都是它们之间一次搞砸了的转换。麻烦在于,这个转换通常是隐形的:你一旦把一个日期放进一个 Date 对象、一个带时间的 ISO 字符串、或一个其类型或 ORM 会引入时区语义的 timestamp 列,一个时区就可能悄悄掺进来——如果你想要本地却用了 UTC、或想要 UTC 却用了本地,日期就落到了某个午夜的错误一侧。(不同数据库在这里差别很大——timestamp with time zonewithout time zone 以及各个 ORM 的行为都不同,务必以你所用的为准。)没有任何东西「损坏」,你只是在错误的时区里跨过了「日期/瞬间」这道边界。

方向一:把日期变成瞬间

最常见的一种。你有一个纯日期——"2026-07-15"——却让它经过一个基于时刻的类型。在 JavaScript 里:

new Date("2026-07-15");     // 按 2026-07-15T00:00:00Z 解析 —— UTC 午夜

在 JavaScript 的 Date 解析里,一个纯日期的 ISO 字符串会被按 UTC 午夜解析(其他语言、数据库和库并不一定相同,要按各自的技术栈确认)。那是一个确定的瞬间,而它的日历日期,现在取决于你在哪里渲染它。任何带负偏移的地方,那个瞬间都还停在前一天的晚上:

// 洛杉矶的浏览器(夏季 UTC−7):
new Date("2026-07-15").toLocaleDateString("en-US"); // "7/14/2026"

你存的是 15 号,用户看到的是 14 号。瞬间本身没错——2026-07-15T00:00:00Z 正是你要的——但「15 号的 UTC 午夜」,从美洲看过去,挂钟上还是 14 号。修法是压根别让一个纯日期经过一个瞬间类型:把它保持成字符串 "2026-07-15"(或一个真正的日期类型),并在不做时区转换的前提下格式化它。如果你确实需要一个瞬间,就用一个能正确处理时区规则的日期库(或 Temporal),在一个明确选定的 IANA 时区里去解释这个本地挂钟时间——光写 2026-07-15T00:00:00,用的是运行环境自己的时区(跑在服务器上就是服务器的),而不是任意某个用户的。这和时间戳那篇从解析一侧讲的是同一个坑;这里是同一枚硬币的存储一侧。

方向二:把瞬间变成日期

现在看相反的转换,它常让那些「照吩咐、一切都用 UTC」的人措手不及。你有一个正确的瞬间,想要它的日期,于是直接采用最省事的做法:

// 一个真实的时刻:纽约 7 月 15 日晚上 10 点(夏季 UTC−4)
const d = new Date("2026-07-15T22:00:00-04:00");
d.toISOString();            // "2026-07-16T02:00:00.000Z"
d.toISOString().slice(0,10);// "2026-07-16" —— 明天

toISOString() 永远按 UTC 渲染,所以切前十个字符,拿到的是 UTC 的日期。对一个纽约的傍晚来说,UTC 已经翻到了下一天,你的「日期」就成了明天。从一个瞬间里取出日期是一次转换,而默认时区是 UTC——不管你有没有要求。修法是在你真正想要的那个时区里格式化这个瞬间:

new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" }).format(d);
// "2026-07-15" —— 它在纽约那天的日期

en-CA 碰巧在实践中会渲染成 YYYY-MM-DD,但 Intl 的输出是本地化字符串,不是有保证的机器格式——要稳定拿到 YYYY-MM-DD,用 formatToParts() 自己把各部分拼起来。)有意地选定时区——用户的、业务的、或者真是 UTC 就用 UTC——但绝不要让 toISOString().slice(0,10) 替你决定。

更深的修法:有些日期从来就不是瞬间

上面两个方向,都是日期和瞬间之间的转换。但最有力的修法,往往是认出有些值就是日期、别无其他,压根不该碰瞬间类型。生日、截止日、公共假日、发票日期、一个「全天」事件——这些是浮动的日历日期。1990 年 5 月 1 日,在东京和在芝加哥是同一个 5 月 1 日;它没有时间,也没有时区。

把这样一个值存成时间戳,你就亲手制造了那次偏移:1990-05-01 变成 1990-05-01T00:00:00Z,而在格林尼治以西那就是 4 月 30 日的晚上,于是这个生日对你的一部分用户就早显示了一天。修法是类型问题,而不是转换问题:把日历日期存进一个日期类型(SQL 的 DATE,或者干脆就是字符串 "1990-05-01"),当成日期去比较和显示,永远不要让它经过一个有时区的瞬间来回一趟。能拦下大多数这类 bug 的,是早早问一句:这个值是一个瞬间(某个时刻发生的事),还是一个日历日期(民用日历上的某一天)?它是什么,就按什么存:

生日      → DATE          "1990-05-01"                 (一个日历日期 —— 没有时间、没有时区)
创建时间  → timestamp     "2026-07-15T22:00:00-04:00"  (一个瞬间)

这个「先判定种类、再决定存成什么」的思路,正是《时间该存时间戳还是 ISO 字符串》整篇展开的问题——那里把整数时间戳、ISO 字符串与原生列类型之间的取舍讲得更细。

而对一个未来或周期性的事件——「明年 3 号的早上 9 点」——要把 IANA 时区名(America/New_York)连同本地时间一起存,而不是一个固定偏移:那时真正生效的偏移,会随时区的夏令时规则变动而改变。

夏令时:那个不满 24 小时的一天

即便在同一个时区内,日期运算也会滑动,因为「一天」是个日历概念,不是固定的 86,400 秒。在向前拨动一小时的地区,本地的那一天只有 23 小时;向后拨动一小时,则有 25 小时。所以把「加一天」实现成加 24 小时,就会在夏令时边界上漂移:

// 纽约在 2026-03-08 向前拨
const before = new Date("2026-03-07T12:00:00-05:00"); // 周六中午
new Date(before.getTime() + 86400000);
// → 纽约周日 2026-03-08 13:00 —— 晚了一小时,因为那天少了一小时

这种误差累积起来,或者正好落在午夜附近,日期本身就会滑动。这里还会带来两个相关的风险:向前拨时,某些挂钟时间根本不存在(被跳过的那一小时,所以一个天真的「午夜」或「凌晨 2:30」可能是无效的);向后拨时,某些时间发生两次(有歧义)。这就是为什么「一天的开始」「加一个月」「下周同一时间」必须在日历上、用一个能正确处理时区规则的日期库来算,而不是往一个时间戳上加秒数。至于日常那种——两个日期之间隔多少天、30 天后是哪天——一个日期计算器会替你数好,你不必操心中间是不是正好跨了一个夏令时边界。

按天分桶:由时区决定落在哪一天

最后一种常见的偏移出现在报表和分析里。你把时间戳「按天」分组——而一条记录落进哪一天,完全取决于你在哪个时区里截断:

const evt = new Date("2026-07-15T23:30:00-05:00"); // 芝加哥晚上 11:30
evt.toISOString().slice(0,10);        // "2026-07-16"  ← 按 UTC 分桶
// 但在芝加哥它还是:2026-07-15

按 UTC 截断这个事件的时间戳,它就算到了 16 号;业务报表则会把它算在 15 号。把这个问题放到整个数据集上,每一条靠近本地午夜的记录都会落到相邻的一天,于是「昨天的总数」就悄悄不对了——偏得不多,这正是它能活到生产环境的原因。修法是选定一个报表时区(通常是业务的,有时是每个用户的),并始终在那个时区里截断每一个时间戳。想看一个瞬间在你用户实际所在的各个时区里分别对应哪一天,把它丢进时区转换器,看着日历日期变化、而瞬间纹丝不动。

日期移位了的排查清单

当一个日期早了一天或晚了一天,别急着加小时。按顺序问这几句:

  1. 这个值是日期,还是瞬间?生日、假日、截止日是日历日期——存成 DATE/字符串,彻底别让它靠近瞬间类型。「创建于」是一个瞬间。大多数移位 bug,都是一个日期被当成瞬间存或解析了。
  2. 在把日期变成瞬间吗?纯日期字符串按 UTC 午夜解析,所以在任何负偏移时区里都读成前一天。别让一个纯日期经过 new Date(...);非要经过,就显式设定时区。
  3. 在把瞬间变成日期吗toISOString().slice(0,10) 给的是 UTC 的日期,对美洲的傍晚时间戳来说就是明天。在你想要的时区里格式化——new Intl.DateTimeFormat("en-CA", { timeZone }).format(d),或用 formatToParts() 自己拼出 YYYY-MM-DD
  4. 在做日期运算吗?跨夏令时切换时,「加一天」不等于「加 86,400 秒」。用一个能正确处理时区规则的日期库,或者用工具做单纯的数天数。
  5. 在按天分桶吗?在一个有意选定的报表时区里截断每一个时间戳,否则午夜附近的记录会散落到错误的一天。

这五条背后其实是同一个核心区别:瞬间是一个点,日历日期是一段跨度,而它们只通过一个时区相遇。对每一个像日期的值,先判定它是这两者中的哪一个——并在你跨越它们时,把时区说清楚。做到这一点,日期就不再漂移了,因为你不再让一个默认时区替你挑那一天。