大部分日期时间 bug,都是从把「一个时刻」和「墙上钟读数」混为一谈开始的。这篇先给每一类时间起个名字,再具体讲清楚:该存什么、传什么、怎么排程,以及线上差一小时或差一天时该看哪些证据。
🎙️ 发布并录制于: ·
时刻是全球时间轴上的一个点。本地日期时间是某地墙上的钟显示出来的东西。「7 月 25 号九点」在你补上时区之前,还不是一个时刻。同一个时刻,在伦敦是周六下午,在东京可能就是周日早上。
# One instant, three displays
2026-07-25T15:00:00Z UTC
2026-07-25T11:00:00-04:00 New York display
2026-07-26T00:00:00+09:00 Tokyo display
# Not enough information to identify an instant
2026-07-25 09:00
UTC 是基准时间轴。offset,比如减四小时,描述的是某一个时刻上和 UTC 的一种关系。IANA zone,比如 America/New_York,是一本规则手册,里面记着历史上和将来的 offset 变更。offset 不是时区,它没法告诉你明年三月的 offset 会变成多少。
UTC reference: Z or +00:00
-04:00 fixed offset, no DST rules
America/New_York IANA zone with rule history
Etc/GMT+4 fixed offset; sign is reversed by convention
EST ambiguous abbreviation; do not store it
在产品的各个边界上用 IANA 标识符。「CST」可以是北美中部时间、中国标准时间,也可以是古巴标准时间。Windows 的时区名是另一套命名体系,往往需要显式映射。靠用户当前的 offset 去猜时区同样是错的:今天 offset 相同的一堆时区,将来会各走各的。
接口里要传一个时刻,就发带 Z 或者带数字 offset 的 ISO 8601 字符串。Z 表示 UTC。字母 T 分隔日期和时间。小数秒可选。一个不带 offset 的日期时间字符串是故意残缺的,不同运行时可能把它当本地时间、当 UTC,或者直接拒绝解析。
2026-07-25T15:04:05Z # unambiguous UTC instant
2026-07-25T11:04:05-04:00 # same style, explicit offset
2026-07-25 # calendar date, not midnight UTC
2026-07-25 11:04:05 # ambiguous; no offset or zone
# JavaScript: serialize an instant in UTC
new Date("2026-07-25T11:04:05-04:00").toISOString()
"2026-07-25T15:04:05.000Z"
钟往前拨的时候,有一段本地时间根本不存在,这叫 gap。钟往后拨的时候,有一段本地时间出现两次,这叫 fold。2026 年 3 月 8 日在纽约,钟从 1 点 59 直接跳到 3 点,2 点半是不存在的。到了 11 月 1 日,1 点 30 会出现两次,对应两个不同的 offset。
# America/New_York, 2026
2026-03-08 01:59:59-05:00
↓ next second
2026-03-08 03:00:00-04:00
2026-03-08 02:30 does not exist
2026-11-01 01:30:00-04:00 # first occurrence
2026-11-01 01:30:00-05:00 # second occurrence
Expected 09:00, got 10:00。第一步:在两条路径上都打日志,记下时刻、IANA zone 和实际算出的 offset。第二步:看是不是某条路径固定加了 24 小时,或者复用了昨天的 offset。第三步:墙上钟排程要在目标时区里加日历天,只有算时长才用加秒数。第四步:两次 DST 切换都要测。别在输出那一层减一小时把它糊过去。已经发生过的事件,用语义明确是 UTC 的数据库类型存一个时刻,展示时再转换。如果审计或法律呈现有要求,还要额外保留当时的原始 offset。我的立场很直接:「一切都按 UTC 存」对过去事件是好建议,对生日、营业时间和未来的本地排程是坏建议,因为那些值还不是时刻。
# PostgreSQL shapes
occurred_at timestamptz # instant; normalized internally
birth_date date # calendar date
opens_at time # local wall time, paired with business zone
starts_local timestamp # local intent
zone_id text # e.g. Europe/Paris
# Persist an explicit contract
{"occurredAt":"2026-07-25T15:04:05Z"}
{"startsLocal":"2027-03-28T09:00:00","timeZone":"Europe/Paris"}
PostgreSQL 的 timestamp with time zone 存的是一个时刻,不是你提交时带的那个时区名。MySQL 和 SQLite 的行为各不相同。去读你自己数据库的类型规则,并且显式设置连接或会话时区。像 created_at_utc 这样的列名,是成本最低的文档。
航班、预约,或者「每个工作日九点」,属于某个命名时区里的一条本地规则。要存本地日期时间、IANA zone,以及遇到 gap 和 fold 时的策略。为了执行更快,你可以缓存下一次的 UTC 时刻,但时区数据更新时,未来的那些发生时间必须重算。各国政府改动时钟规则的提前期,往往比你数据库的保留周期还短。
{
"localStart": "2027-10-31T01:30:00",
"timeZone": "Europe/London",
"foldPolicy": "later",
"gapPolicy": "shift-forward"
}
# Define recurrence in calendar terms
weekdays at 09:00 in Europe/London
not: every 86,400 seconds forever
生日、发票日期、酒店退房日期,都是日历日期。把它们转成 UTC 零点,等于凭空造出一个时刻。这个造出来的时刻在 UTC 以西显示,日期就往前退了一天。让 YYYY-MM-DD 保持为日期类型或者一个校验过的字符串,直到有一条真实业务规则给它指定时间和时区。
# The classic browser bug in an America/Los_Angeles environment
new Date("2026-07-25").toString()
"Fri Jul 24 2026 17:00:00 GMT-0700 ..."
Expected 2026-07-25, got 2026-07-24
# Correct date-only handling
const birthday = "2026-07-25"; # validate, store, display as a date
Date 时刻。第三步:让它在整条接口和数据库链路上都保持纯日期类型。第四步:格式化年月日,不做任何时区换算。加十二小时是个脆弱的伪装,不是修复。Unix 时间戳数的是从 Unix epoch 起经过的时间,通常不计闰秒。Python 里常见的是秒。JavaScript 的 Date 构造函数收的是毫秒。今天的秒级时间戳大约十位数,毫秒级大约十三位。按位数猜在排查时有用,但接口契约必须写明单位。
1753455845 # seconds
1753455845000 # milliseconds
# JavaScript
new Date(seconds * 1000)
new Date(milliseconds)
# Python, aware UTC datetime
datetime.fromtimestamp(seconds, tz=timezone.utc)
RangeError: Invalid time value 常见于非法输入被传进了 toISOString()。第一步:把原始值和它的类型打出来。第二步:拒掉 null、空字符串和 NaN。第三步:确认是秒还是毫秒。第四步:构造出日期后,先确认 Number.isNaN(date.getTime()) 为 false,再去格式化。别 catch 住异常然后输出今天的日期。别从格式化开始查。在每个边界上都把值抓下来:原始文本、解析后的类型、epoch 值、offset、IANA zone、数据库类型、会话时区、最终展示时区。把时刻当时刻来比较,只在最外层做转换。一张写着「下午三点」的截图是很弱的证据;一个 ISO 字符串加上 zone 和 offset 才是有用的证据。
# Python: create aware values
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
now = datetime.now(timezone.utc)
local = now.astimezone(ZoneInfo("America/New_York"))
TypeError: can't compare offset-naive and offset-aware datetimes
# JavaScript: inspect the instant, then display in a named zone
console.log(date.toISOString(), date.getTime())
new Intl.DateTimeFormat("en", {timeZone:"America/New_York",
dateStyle:"full", timeStyle:"long"}).format(date)
TypeError: can't compare offset-naive and offset-aware datetimes,第一步:把两个值的 repr 和 tzinfo 都打出来。第二步:确定那个 naive 值原本应该属于哪个时区,别因为方便就假设是 UTC。第三步:用 ZoneInfo 把那个来源时区挂上去,并处理好 gap 或 fold 策略。第四步:把两个值都转成 UTC 再比较。replace(tzinfo=UTC) 只是给这个钟换了个标签,它不做换算。系统性的顺序是:先用一个已知时刻复现;把进程、数据库和浏览器的时区都固定住;打出原始输入和 epoch;检查接口里的 offset;检查 SQL 列类型和会话时区;只在展示时转换一次;最后为 UTC 零点和两次 DST 切换补上回归测试。
在选类型或者写转换之前,先过一遍这张决策表。
Already happened? → instant; store UTC semantics
Display for a user? → instant + chosen IANA zone
Future local schedule? → local datetime + IANA zone + gap/fold policy
Birthday/invoice date? → date only; never invent midnight UTC
Recurring at 09:00? → calendar recurrence in its IANA zone
Elapsed for 24 hours? → duration, not “same time tomorrow”
API instant → 2026-07-25T15:04:05Z
API date → 2026-07-25
Zone → America/New_York, not EST
Unix input → unit stated: seconds or milliseconds
One hour wrong → DST rule, fixed offset, or double conversion
One day wrong → date-only parsed as an instant near UTC midnight
Wild historical result → seconds/milliseconds or stale zone data
Debug: raw → parsed type → epoch → offset → IANA zone
→ DB type/session zone → API string → display zone
我一直守着的一条规则是:在你说得出一个值的含义之前,不要转换它。UTC 是一条时间轴,不是给用户看的界面。IANA zone 是规则,不是装饰。日期不是零点。这三个区别一旦能在你的数据库和接口边界上活下来,大部分日期时间「谜案」就不再神秘了。