最重要的原则:时区 ≠ 偏移
UTC 偏移 是一个静态数字:+08:00、-05:00、+05:30。
时区 是一个命名的规则集,决定在历史上的任何给定时刻——过去、现在和未来——应用哪个偏移。它编码了 DST 转换、历史偏移变化和政治决策。
"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")是全球时区规则的权威来源。
架构
/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/China或CST)America/New_York(不是US/Eastern或EST)Europe/London(不是Europe/UK或GMT)
这避免了歧义:"CST"根据上下文可以是中国标准时间、美国中部标准时间或古巴标准时间。IANA 名称没有歧义。
数据库维护
IANA 数据库由志愿者社区维护,每年发布多次。每次发布(如 2024a、2024b)包含:
- 政府颁布的新时区规则
- 对历史数据的修正
- DST 转换日期的变更
真实变更案例:
- 2011年:萨摩亚完全跳过了 12 月 30 日,从 UTC-11 切换到 UTC+13
- 2014年:俄罗斯从 11 个时区减少到 9 个,2016 年又恢复为 11 个
- 2022年:约旦永久取消 DST(保持 UTC+03:00)
- 2023年:黎巴嫩因政治争议同时存在两个时区达 12 天
这就是为什么时区处理需要一个定期更新的数据库,而非硬编码规则。
DST 转换:间隙与折叠
间隙(Spring Forward)
时钟拨快时,一段本地时间不存在:
美国东部,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)
时钟拨慢时,一段本地时间出现两次:
美国东部,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+ 处理方式
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)
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(当前标准)
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 对象的方案,具有一等时区支持:
// 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
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. 存储本地时间而不带时区
# BUG:在 VARCHAR 列中存储了 "2024-03-10 02:30:00"
# 这个时间在 America/New_York 中不存在——它是什么意思?
# 没人知道。数据已腐败。
# 修复:存储 UTC 时间戳或带时区偏移的时间戳
# PostgreSQL:TIMESTAMP WITH TIME ZONE(内部以 UTC 存储)
# 应用层:存储前始终转换为 UTC
2. 使用缩写作为标识符
// BUG:"CST"是歧义的
const tz = "CST"; // 中国标准时间?美国中部标准时间?古巴标准时间?
// 修复:使用 IANA 标识符
const tz = "America/Chicago"; // 无歧义
3. 假设偏移是整数
# 这些是真实的 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 规则
// 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 "明天同一时间"
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:交换格式
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 日本标准时间
如果偏移正确,这四个全部表示同一时刻。
交换规则
- 始终包含偏移 — 裸
2024-06-15T14:30:00是歧义的(哪里的本地时间?) - 存储时优先 Z 或 +00:00 — 持久化前转换为 UTC
- 人类显示时包含时区名 — 仅有偏移无法告知 DST 规则
- API 使用 RFC 3339 profile — 它是 ISO 8601 加上消除歧义的限制
数据库存储模式
PostgreSQL
-- 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;
模式
存储两件事:
- UTC 时刻(TIMESTAMPTZ)— 事件在绝对时间线上何时发生
- IANA 时区名称(TEXT)— 显示/重复事件的上下文
为什么需要两者?因为如果时区规则变化(政府取消 DST),你需要时区名称重新计算墙上时钟时间。仅有 UTC 时刻告诉你"何时"但不告诉你预期的本地含义。
总结
时区工程需要尊重这些现实:
- 时区 ≠ 偏移 — 时区是历史规则集;偏移是单一时刻的数字
- 使用 IANA 名称(
America/New_York),不用缩写(EST)或裸偏移(-5) - 存储 UTC — 仅在显示时转换为本地时间
- DST 创造间隙和折叠 — 代码必须处理不存在的时间和存在两次的时间
- 数据库会变 — 时区规则是政治决策;保持 tzdata 更新
- "明天同一时间" ≠ "+24小时" — 明确你的功能需要哪种语义
- 使用现代 API — Python zoneinfo(非 pytz)、Temporal(非 Date)、Go time.Location