时区:别再去拨那个钟

大部分日期时间 bug,都是从把「一个时刻」和「墙上钟读数」混为一谈开始的。这篇先给每一类时间起个名字,再具体讲清楚:该存什么、传什么、怎么排程,以及线上差一小时或差一天时该看哪些证据。

🎙️ 发布并录制于: ·

01时刻不是本地时间

时刻是全球时间轴上的一个点。本地日期时间是某地墙上的钟显示出来的东西。「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
动手之前先给这个值定性先问清楚:它是「某件事发生的时候」,是「钟应该显示什么」,是「一个日历日期」,还是「一条重复的人类规则」?如果需求只写了「九点发送」,那代码还不能写。要追问:哪里的九点,以及夏令时切换之后,九点还该是九点吗。

02UTC、offset 和 IANA zone 是三种东西

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 相同的一堆时区,将来会各走各的。

03ISO 8601:把 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"
这是接口契约,不是拿解析器赌运气每个字段都要说清楚:它是时刻、本地日期时间、日期,还是 IANA zone。要求传时刻的地方,就拒绝没有 offset 的字符串。别把所有时间字段都文档成「ISO 日期」,这句话恰好把导致 bug 的那个区别藏起来了。

04DST 会造出一个 gap 和一个 fold

钟往前拨的时候,有一段本地时间根本不存在,这叫 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 切换都要测。别在输出那一层减一小时把它糊过去。

05存的是含义,不是一种通用形状

已经发生过的事件,用语义明确是 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 这样的列名,是成本最低的文档。

06未来排程需要意图,再加规则

航班、预约,或者「每个工作日九点」,属于某个命名时区里的一条本地规则。要存本地日期时间、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
策略要自己选,不要继承某个库的意外行为碰到不存在的时间,要么拒绝,要么往后顺延并告知用户。碰到有歧义的时间,选靠前那次或靠后那次,并把这个选择记下来。各个库的默认行为不一样,所以一条隐式策略会在代码从 JavaScript 挪到 Python 或数据库时悄悄变掉。

07纯日期的值,就得一直是纯日期

生日、发票日期、酒店退房日期,都是日历日期。把它们转成 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 时刻。第三步:让它在整条接口和数据库链路上都保持纯日期类型。第四步:格式化年月日,不做任何时区换算。加十二小时是个脆弱的伪装,不是修复。

08Unix 时间戳:秒还是毫秒

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 valueRangeError: Invalid time value 常见于非法输入被传进了 toISOString()第一步:把原始值和它的类型打出来。第二步:拒掉 null、空字符串和 NaN第三步:确认是秒还是毫秒。第四步:构造出日期后,先确认 Number.isNaN(date.getTime()) 为 false,再去格式化。别 catch 住异常然后输出今天的日期。

09跨 Python、JavaScript、接口和 SQL 排查

别从格式化开始查。在每个边界上都把值抓下来:原始文本、解析后的类型、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)
naive 和 aware,一步步修碰到 TypeError: can't compare offset-naive and offset-aware datetimes第一步:把两个值的 reprtzinfo 都打出来。第二步:确定那个 naive 值原本应该属于哪个时区,别因为方便就假设是 UTC。第三步:ZoneInfo 把那个来源时区挂上去,并处理好 gap 或 fold 策略。第四步:把两个值都转成 UTC 再比较。replace(tzinfo=UTC) 只是给这个钟换了个标签,它不做换算。

系统性的顺序是:先用一个已知时刻复现;把进程、数据库和浏览器的时区都固定住;打出原始输入和 epoch;检查接口里的 offset;检查 SQL 列类型和会话时区;只在展示时转换一次;最后为 UTC 零点和两次 DST 切换补上回归测试。

10时区速查表

在选类型或者写转换之前,先过一遍这张决策表。

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 是规则,不是装饰。日期不是零点。这三个区别一旦能在你的数据库和接口边界上活下来,大部分日期时间「谜案」就不再神秘了。

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.