最重要的原则:时区 ≠ 偏移

UTC 偏移 是一个静态数字:+08:00-05:00+05:30

时区 是一个命名的规则集,决定在历史上的任何给定时刻——过去、现在和未来——应用哪个偏移。它编码了 DST 转换、历史偏移变化和政治决策。

code
"Asia/Shanghai"    → 始终 UTC+08:00(1991 年后无 DST)
"America/New_York" → 冬季 UTC-05:00,夏季 UTC-04:00
"Europe/London"    → 冬季 UTC+00:00,夏季 UTC+01:00
"Pacific/Apia"     → 2011 年前是 UTC-11:00,之后跳到 UTC+13:00

典型错误:存储 offset = -5 表示"美国东部时间"的代码全年有一半是错的。存储 "America/New_York" 的代码永远正确,因为时区数据库编码了 DST 规则。

IANA 时区数据库

IANA 时区数据库(也称"tz 数据库"、"Olson 数据库"或"zoneinfo")是全球时区规则的权威来源。

架构

code
/usr/share/zoneinfo/          (编译后的二进制 TZif 文件)
├── Africa/
│   ├── Cairo
│   ├── Nairobi
│   └── ...
├── America/
│   ├── New_York
│   ├── Chicago
│   ├── Los_Angeles
│   └── ...
├── Asia/
│   ├── Shanghai
│   ├── Tokyo
│   ├── Kolkata
│   └── ...
├── Europe/
│   ├── London
│   ├── Paris
│   ├── Moscow
│   └── ...
└── Pacific/
    ├── Auckland
    ├── Apia
    └── ...

命名约定:洲/城市

IANA 名称使用每个区域中人口最多的城市,而非国家名:

  • Asia/Shanghai(不是 Asia/ChinaCST
  • America/New_York(不是 US/EasternEST
  • Europe/London(不是 Europe/UKGMT

这避免了歧义:"CST"根据上下文可以是中国标准时间、美国中部标准时间或古巴标准时间。IANA 名称没有歧义。

数据库维护

IANA 数据库由志愿者社区维护,每年发布多次。每次发布(如 2024a2024b)包含:

  • 政府颁布的新时区规则
  • 对历史数据的修正
  • DST 转换日期的变更

真实变更案例

  • 2011年:萨摩亚完全跳过了 12 月 30 日,从 UTC-11 切换到 UTC+13
  • 2014年:俄罗斯从 11 个时区减少到 9 个,2016 年又恢复为 11 个
  • 2022年:约旦永久取消 DST(保持 UTC+03:00)
  • 2023年:黎巴嫩因政治争议同时存在两个时区达 12 天

这就是为什么时区处理需要一个定期更新的数据库,而非硬编码规则。

DST 转换:间隙与折叠

间隙(Spring Forward)

时钟拨快时,一段本地时间不存在

code
美国东部,2024年3月10日:
  1:59:59 AM EST (UTC-05:00)
  → 时钟跳到 →
  3:00:00 AM EDT (UTC-04:00)

  这一天的 2:30 AM 不存在

如果创建 "2024-03-10 02:30:00 America/New_York" 会怎样?

  • 有些库抛出错误
  • 有些偏移到 3:30 AM EDT
  • 有些偏移到 1:30 AM EST
  • 如果代码未处理,则是未定义行为

折叠(Fall Back)

时钟拨慢时,一段本地时间出现两次

code
美国东部,2024年11月3日:
  1:59:59 AM EDT (UTC-04:00)
  → 时钟回拨到 →
  1:00:00 AM EST (UTC-05:00)

  这一天的 1:30 AM 存在两次:
    1:30 AM EDT = 05:30 UTC
    1:30 AM EST = 06:30 UTC

如果存储 "2024-11-03 01:30:00 America/New_York" 而不指明是哪次出现,你就有了一个歧义时间戳——它可能指相差一小时的两个 UTC 时刻中的任何一个。

Python 3.9+ 处理方式

python
from datetime import datetime
from zoneinfo import ZoneInfo

eastern = ZoneInfo("America/New_York")

# 间隙:2024年3月10日 2:30 AM 不存在
# datetime 不会报错——它选择转换后的偏移
gap_time = datetime(2024, 3, 10, 2, 30, tzinfo=eastern)
print(gap_time)         # 2024-03-10 02:30:00-04:00 (EDT,非 EST)
print(gap_time.utctimetuple())  # 实际等于 3:30 AM EDT

# 折叠:2024年11月3日 1:30 AM 存在两次
fold0 = datetime(2024, 11, 3, 1, 30, tzinfo=eastern, fold=0)  # 第一次(EDT)
fold1 = datetime(2024, 11, 3, 1, 30, tzinfo=eastern, fold=1)  # 第二次(EST)

print(fold0.utcoffset())  # -04:00 (EDT)
print(fold1.utcoffset())  # -05:00 (EST)

现代 API

Python:zoneinfo(3.9+,替代 pytz)

python
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

# 创建时区感知的 datetime
now_utc = datetime.now(timezone.utc)
now_tokyo = now_utc.astimezone(ZoneInfo("Asia/Tokyo"))
now_ny = now_utc.astimezone(ZoneInfo("America/New_York"))

print(f"UTC:    {now_utc.isoformat()}")
print(f"东京:   {now_tokyo.isoformat()}")
print(f"纽约:   {now_ny.isoformat()}")

# 时区间转换
meeting_tokyo = datetime(2024, 6, 15, 10, 0, tzinfo=ZoneInfo("Asia/Tokyo"))
meeting_ny = meeting_tokyo.astimezone(ZoneInfo("America/New_York"))
print(f"10:00 东京 = {meeting_ny.strftime('%H:%M')} 纽约")  # 21:00(前一天)

为什么用 zoneinfo 而非 pytz:pytz 有非标准 API 怪癖(必须用 localize() 而非将 tzinfo 传给构造函数)。zoneinfo 遵循 PEP 615,与标准 datetime 语义兼容。

JavaScript:Intl.DateTimeFormat(当前标准)

javascript
function formatInTimezone(date, timezone) {
  return new Intl.DateTimeFormat('en-US', {
    timeZone: timezone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false,
    timeZoneName: 'short'
  }).format(date);
}

const now = new Date();
console.log(formatInTimezone(now, 'America/New_York'));
console.log(formatInTimezone(now, 'Asia/Tokyo'));
console.log(formatInTimezone(now, 'Europe/London'));

// 获取特定时区在特定时刻的偏移
function getOffset(timezone, date = new Date()) {
  const utcStr = date.toLocaleString('en-US', { timeZone: 'UTC' });
  const tzStr = date.toLocaleString('en-US', { timeZone: timezone });
  return (new Date(tzStr) - new Date(utcStr)) / 3600000;
}

JavaScript:Temporal(Stage 3 提案)

Temporal 是即将取代 Date 对象的方案,具有一等时区支持:

javascript
// Temporal(polyfill 可用:@js-temporal/polyfill)
import { Temporal } from '@js-temporal/polyfill';

// 当前时刻
const now = Temporal.Now.instant();

// 转换到时区
const tokyo = now.toZonedDateTimeISO('Asia/Tokyo');
const ny = now.toZonedDateTimeISO('America/New_York');

console.log(tokyo.toString());
// 2024-06-15T22:30:00+09:00[Asia/Tokyo]

// 在时区中创建特定本地时间
const meeting = Temporal.ZonedDateTime.from({
  timeZone: 'America/New_York',
  year: 2024, month: 6, day: 15,
  hour: 14, minute: 0
});

// 转换到另一个时区
const meetingTokyo = meeting.withTimeZone('Asia/Tokyo');
console.log(meetingTokyo.hour);  // 3(第二天)

// DST 安全的算术
const laterMeeting = meeting.add({ hours: 24 });
// 添加 24 小时的经过时间,正确处理任何 DST 转换

Temporal 区分:

  • Temporal.Instant — UTC 时间线上的一个点(无时区)
  • Temporal.ZonedDateTime — 在特定时区中观察的时刻
  • Temporal.PlainDateTime — 没有时区的"墙上时钟"读数(本地时间)

Go:time.Location

go
package main

import (
    "fmt"
    "time"
)

func main() {
    // 加载时区
    tokyo, _ := time.LoadLocation("Asia/Tokyo")
    ny, _ := time.LoadLocation("America/New_York")

    now := time.Now().UTC()
    
    fmt.Println("UTC:  ", now.Format(time.RFC3339))
    fmt.Println("东京: ", now.In(tokyo).Format(time.RFC3339))
    fmt.Println("纽约: ", now.In(ny).Format(time.RFC3339))

    // 在特定时区中创建时间
    meeting := time.Date(2024, 6, 15, 14, 0, 0, 0, ny)
    fmt.Println("会议在东京:", meeting.In(tokyo).Format("15:04"))
}

常见生产 Bug

1. 存储本地时间而不带时区

python
# BUG:在 VARCHAR 列中存储了 "2024-03-10 02:30:00"
# 这个时间在 America/New_York 中不存在——它是什么意思?
# 没人知道。数据已腐败。

# 修复:存储 UTC 时间戳或带时区偏移的时间戳
# PostgreSQL:TIMESTAMP WITH TIME ZONE(内部以 UTC 存储)
# 应用层:存储前始终转换为 UTC

2. 使用缩写作为标识符

javascript
// BUG:"CST"是歧义的
const tz = "CST";  // 中国标准时间?美国中部标准时间?古巴标准时间?

// 修复:使用 IANA 标识符
const tz = "America/Chicago";  // 无歧义

3. 假设偏移是整数

python
# 这些是真实的 UTC 偏移:
# UTC+05:30  印度 (IST)
# UTC+05:45  尼泊尔
# UTC+08:45  西澳大利亚(Eucla,非正式)
# UTC+12:45  查塔姆群岛(新西兰)
# UTC+09:30  南澳大利亚(阿德莱德)

# BUG:将偏移存储为整数小时
offset_hours = 5  # 丢失了印度的 :30

# 修复:将偏移存储为总分钟数或使用 IANA 名称
offset_minutes = 330  # 印度:5*60 + 30

4. 硬编码 DST 规则

javascript
// BUG:假设美国 DST 规则全球适用或不会变
function isDST(date) {
  const mar = new Date(date.getFullYear(), 2, 1);
  const nov = new Date(date.getFullYear(), 10, 1);
  return date > mar && date < nov;
}

// 修复:让时区数据库处理
function isDST(date, timezone) {
  const jan = new Date(date.getFullYear(), 0, 1);
  const jul = new Date(date.getFullYear(), 6, 1);
  const janOffset = getOffset(timezone, jan);
  const julOffset = getOffset(timezone, jul);
  const currentOffset = getOffset(timezone, date);
  const standardOffset = Math.min(janOffset, julOffset);
  return currentOffset !== standardOffset;
}

5. "24小时后" vs "明天同一时间"

python
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

eastern = ZoneInfo("America/New_York")

# 2024年3月9日 10:00 AM EST
saturday = datetime(2024, 3, 9, 10, 0, tzinfo=eastern)

# "24小时后"——添加 24 小时的经过时间
elapsed = saturday + timedelta(hours=24)
print(elapsed)  # 2024-03-10 11:00:00-04:00(11 AM EDT,不是 10 AM!)

# "明天同一时间"——保持墙上时钟时间
sunday_same_time = datetime(2024, 3, 10, 10, 0, tzinfo=eastern)
print(sunday_same_time)  # 2024-03-10 10:00:00-04:00(10 AM EDT)

在 DST 转换日,"24小时后"和"明天同一时间"给出不同结果。你想要哪个取决于使用场景:

  • 重复会议:相同墙上时钟时间(每天 10 AM)
  • 药物计划:相同经过时间(每 24 小时)
  • 金融结算:由合约定义(通常是 UTC)

ISO 8601:交换格式

code
2024-06-15T14:30:00Z           UTC(Z 后缀)
2024-06-15T14:30:00+00:00      UTC(显式偏移)
2024-06-15T10:30:00-04:00      美国东部夏令时
2024-06-15T23:30:00+09:00      日本标准时间

如果偏移正确,这四个全部表示同一时刻。

交换规则

  1. 始终包含偏移 — 裸 2024-06-15T14:30:00 是歧义的(哪里的本地时间?)
  2. 存储时优先 Z 或 +00:00 — 持久化前转换为 UTC
  3. 人类显示时包含时区名 — 仅有偏移无法告知 DST 规则
  4. API 使用 RFC 3339 profile — 它是 ISO 8601 加上消除歧义的限制

数据库存储模式

PostgreSQL

sql
-- TIMESTAMP WITH TIME ZONE:以 UTC 存储,以会话时区显示
-- 这是你几乎总是想要的
CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    starts_at TIMESTAMPTZ NOT NULL,  -- 以 UTC 存储
    timezone TEXT NOT NULL            -- IANA 名称用于显示上下文
);

-- 带显式时区插入
INSERT INTO events (name, starts_at, timezone)
VALUES ('Conference', '2024-06-15 09:00:00-04:00', 'America/New_York');

-- 带时区转换查询
SELECT name, starts_at AT TIME ZONE timezone AS local_time
FROM events;

模式

存储两件事:

  1. UTC 时刻(TIMESTAMPTZ)— 事件在绝对时间线上何时发生
  2. IANA 时区名称(TEXT)— 显示/重复事件的上下文

为什么需要两者?因为如果时区规则变化(政府取消 DST),你需要时区名称重新计算墙上时钟时间。仅有 UTC 时刻告诉你"何时"但不告诉你预期的本地含义。

总结

时区工程需要尊重这些现实:

  1. 时区 ≠ 偏移 — 时区是历史规则集;偏移是单一时刻的数字
  2. 使用 IANA 名称America/New_York),不用缩写(EST)或裸偏移(-5
  3. 存储 UTC — 仅在显示时转换为本地时间
  4. DST 创造间隙和折叠 — 代码必须处理不存在的时间和存在两次的时间
  5. 数据库会变 — 时区规则是政治决策;保持 tzdata 更新
  6. "明天同一时间" ≠ "+24小时" — 明确你的功能需要哪种语义
  7. 使用现代 API — Python zoneinfo(非 pytz)、Temporal(非 Date)、Go time.Location